The denormalized search column is easy. Keeping it true is the hard part

This is me

This is me

Keyword search over a table starts as a LIKE across whichever columns seem relevant. Title, city, reference, description. It works, and then it does not: the query is a chain of orWhere clauses, none of them can use an index, and every new searchable attribute makes it slower and longer.

The standard fix is to denormalize. One search_text column per row, containing everything searchable, concatenated. Search matches against that single column, which can carry a fulltext index. Fewer clauses, one index, faster.

That part is easy. It is the second half that people underestimate.

Denormalized data has no error state

A normalized query is always right, because it reads the source of truth at query time. A denormalized column is right only if something kept it in sync, and when that something misses, nothing breaks.

The row is still there. The query still returns. It just quietly does not match a search it should have matched, and the user concludes your search is bad. There is no exception, no failed job, no log line. You find out when someone complains that a property they can see with their own eyes does not come up when they type its reference.

That asymmetry is the whole engineering problem. Not "how do I build the string", but "how do I guarantee every row's string reflects the current formula". Two things break it:

  1. A row changes and nothing recomputes. Solved by hooking the write path.
  2. The formula changes and existing rows keep the old one. Not solved by hooking the write path, because those rows are not being written.

The second one is the one that bites, because it happens every time you add a searchable field, which is exactly when you are least likely to think about the rows already in the table.

Why the observer alone cannot fix it

The natural home for the write path is a saving observer:

public function saving(Property $property): void
{
    $property->search_text = trim(implode(' ', [
        $property->title,
        $property->city,
        $property->reference,
        $property->description,
    ]));
}

This is correct for case 1 and useless for case 2. An observer is a hook: it runs when Eloquent decides to run it, and there is no way to say "run this for every row in the table right now". So when the formula changes you write a backfill command, and the formula gets copied into it.

Now the same rule exists twice. They agree today. They will not agree in six months, and the way you will discover the disagreement is a user telling you search is broken. Again, with no error anywhere.

The fix is making both paths call one function

Everything else follows from refusing to have the formula in two places. Put it in a class that returns the value:

class PropertyKeeper
{
    public function __construct(private Property $property) {}

    public function getSearchText(): string
    {
        return trim(implode(' ', array_filter([
            $this->property->title,
            $this->property->city,
            $this->property->reference,
            $this->property->description,
        ])));
    }
}

The observer collapses to one line:

public function saving(Property $property): void
{
    $property->process('search_text');
}

And the backfill runs the identical code over the whole table:

Property::query()->chunkById(500, function ($properties) {
    foreach ($properties as $property) {
        $property->process(['search_text'])->save();
    }
});

Note what this makes possible beyond deduplication. Because the method returns the value rather than assigning it, you can compare the stored column against a freshly computed one and find stale rows without writing to them. Denormalized data with no error state gets an audit, which is the closest you get to making the silent failure loud.

The routine becomes: change the formula, run the backfill, done. chunkById rather than chunk because you are writing to the table you are paginating, and offset pagination over a table you are mutating skips rows.

The rule

Denormalize when the query cost justifies it, which for search across many columns it usually does. But treat the write path and the backfill as one thing from the first day, not as a thing and a thing you will write later, because "later" arrives with the first formula change and by then the copy already exists.

The keeper mechanics, binding and argument forms are on the Larakeep page. Source on GitHub, package on Packagist.