0% прочитано

Laravel Observer in Practice: Keeping search_text in Sync with ContentBlock.data

In ICanUp, I added a derived search_text field to ContentBlock for search. The harder problem was not extracting text, but guaranteeing that search_text always matches the current data regardless of which service, command, importer, or future code saves the model. This article shows why a saving Observer fits the invariant, why extraction remains in a separate class, and why existing records still need a backfill.

18 вересня 2026 р. 10 хв читанняLaravel

When search across Content Blocks was added to ICanUp, I needed a content_blocks.search_text field.

The block data contains more than text visible to the user. It may also contain URLs, layout variants, alignment, media identifiers, and other technical configuration.

Search needs a different projection: only the text that is meaningful to search.

The more interesting problem was not how to extract that text. I needed to guarantee that after every normal ContentBlock save, search_text could no longer drift away from data.

This is where an Observer became useful as an architectural boundary rather than a pattern demonstration. It removes the responsibility to remember the derived field from every place that may save a ContentBlock.
Laravel ContentBlock saving lifecycle where data is transformed by ContentBlockSearchTextExtractor and an Observer writes the derived search_text field before SQL persistence
ContentBlock changes, Eloquent dispatches saving, the Observer calls the extractor, and search_text becomes part of the same INSERT or UPDATE.

First I Needed to Separate Block Data from Searchable Text

A ContentBlock may contain data similar to this:

PHP - Example ContentBlock data
[    'content' => [        'title' => 'Laravel Observer',        'text' => 'Observer reacts to model lifecycle events.',        'action_label' => 'Read more',        'action_url' => '/articles/observer',    ],    'configuration' => [        'variant' => 'feature',        'alignment' => 'left',    ],]

Search does not need action_url, variant, alignment, or other technical configuration.

It needs the text that a user can actually see:

Searchable block projection
Laravel ObserverObserver reacts to model lifecycle events.Read more

I Moved the search_text Rule into a Dedicated Class

I did not want search projection rules to know about controllers, block synchronization, or the Observer.

A dedicated class owns the transformation from data to searchable text:

PHP - Dedicated search text extractor
final class ContentBlockSearchTextExtractor{    public function extract(array $data): string    {        // Extract only searchable user-visible text.    }}

Its contract is intentionally small:

One responsibility for the extractor
ContentBlock data        |        vContentBlockSearchTextExtractor        |        vsearchable user-visible text

The First Obvious Option Was Calling the Extractor from a Service

At first glance, updating the field in the service that already saves Content Blocks looks reasonable:

PHP - Updating search_text in a service
$contentBlock->search_text = $extractor->extract(    $data,); $contentBlock->save();

The code works, but it creates a hidden condition:

search_text remains correct only if every ContentBlock is always saved through this specific service.

That guarantee is too weak for me.

Later, the same model may be changed by a command, importer, admin action, test helper, or another service. Every path would need to remember one additional rule.

I Wanted to Move the Guarantee from Callers to the Model Lifecycle

Instead of this contract:

The contract I do not want
When you save ContentBlock,remember to update search_text too.

I want this invariant:

The model invariant
Whenever ContentBlock is saved through Eloquent,search_text must match data.

That is a natural fit for Eloquent lifecycle events.

The Observer Reacts to saving Regardless of the Caller

PHP - Observer keeps the derived field synchronized
final readonly class ContentBlockSearchTextObserver{    public function __construct(        private ContentBlockSearchTextExtractor $extractor,    ) {}     public function saving(        ContentBlock $contentBlock,    ): void {        $contentBlock->search_text =            $this->extractor->extract(                is_array($contentBlock->data)                    ? $contentBlock->data                    : [],            );    }

Now I no longer care which application path initiated the save.

If Eloquent reaches the saving event, the Observer receives the model before SQL persistence and builds the current search_text.

The Same Event Covers Creation and Updates

The saving event runs before persistence for both a new model and an existing model being changed.

PHP - Creating a new ContentBlock
$contentBlock = new ContentBlock();$contentBlock->data = $data;$contentBlock->save();
PHP - Updating an existing ContentBlock
$contentBlock->data = $newData;$contentBlock->save();
PHP - Updating a specific Eloquent model
$contentBlock->update([    'data' => $newData,]);

All of these paths run the relevant Eloquent lifecycle, so application code does not need to call the extractor manually.

The Model Does Not Need to Know Who Is Observing It

ContentBlock itself does not contain a call such as:

PHP - The model does not need this dependency
$this->searchTextObserver->update();

The Observer does not control the model directly either.

Eloquent acts as the intermediary:

The saving event flow
ContentBlock changes        |        vEloquent lifecycle        |        vsaving event        |        vContentBlockSearchTextObserver        |        vContentBlockSearchTextExtractor        |        vsearch_text

The Observer Is Registered in One Place

Laravel needs one registration point connecting the model and its Observer.

PHP - Register the Observer in the ServiceProvider
public function boot(): void{    ContentBlock::observe(        ContentBlockSearchTextObserver::class,    );}

After registration, application code working with ContentBlock does not need to attach or know about the Observer individually.

I Deliberately Kept Extraction Logic Out of the Observer

I could have made the Observer much larger and implemented the entire text extraction algorithm inside it.

That would give one class two separate responsibilities:

Responsibility

Owner

React to saving

ContentBlockSearchTextObserver

Define which text is searchable

ContentBlockSearchTextExtractor

The Observer remains a thin coordinator.

It knows when to rebuild the field. The extractor knows how to build it.

saving Is a Better Fit Here Than saved

The timing matters for a derived field.

saving runs before the model is persisted. The Observer can therefore change search_text and have it included in the same INSERT or UPDATE.

One database write
data changes    |    vsaving    |    vcalculate search_text    |    vSQL INSERT / UPDATE

With saved, the model has already been written.

Changing search_text after that point would require another database write or special handling to avoid repeatedly triggering lifecycle events.

Why saved is less suitable here
save data    |    vSQL UPDATE    |    vsaved    |    vchange search_text    |    vanother UPDATE
For a derived attribute that should be persisted together with its source data, a pre-persistence lifecycle event is a much more natural fit.

search_text Is Derived Data, Not an Independent Business Value

The user does not edit search_text.

The admin interface should not submit it as an independent field.

Its value is fully determined by another state:

A derived field
data  |  vsearchableProjection(data)  |  vsearch_text

That is why automatically rebuilding it during the model lifecycle matches the nature of the data itself.

The Observer Protects Future Saves but Does Not Repair Historical Data

When I added the search_text column, Content Blocks already existed in the database.

Registering an Observer does not automatically make historical rows pass through save().

Existing records therefore need a separate backfill.

BASH - Populate search_text for existing Content Blocks
php artisan app:content-blocks:backfill-search-text

The Backfill Uses the Same Extractor

I do not create a second implementation of search projection rules for the command.

Both normal application execution and historical processing use the same ContentBlockSearchTextExtractor.

One rule for current and historical data
New and changed data: ContentBlock save        |        vObserver        |        vContentBlockSearchTextExtractor  Historical data: Backfill command        |        v

The Observer keeps the invariant true going forward. The backfill brings existing records into that invariant.

An Observer Does Not Intercept Every Possible Database Change

There is an important boundary here.

An Eloquent Observer reacts to model events. Code that bypasses the Eloquent model lifecycle can bypass the Observer as well.

Mutation path

Observer

$contentBlock->save()

Yes

$contentBlock->update([...])

Yes, for a concrete model instance

ContentBlock::query()->where(...)->update([...])

I do not rely on model events

DB::table(...)->update(...)

No

Saving with events intentionally disabled

No

I therefore define the invariant precisely: when ContentBlock changes through the normal Eloquent lifecycle, the Observer synchronizes search_text.

Code that intentionally bypasses model events has to understand and handle that responsibility explicitly.

That Is Another Reason to Be Careful with Mass Updates

A query like this can be efficient:

PHP - A mass update needs separate consideration
ContentBlock::query()    ->where(...)    ->update([        'data' => $newData,    ]);

But if search_text correctness depends on model events, that path either needs to be avoided for data changes or must update the derived field explicitly.

The Observer reduces how many places need to remember the invariant, but it does not remove the need to understand Eloquent boundaries.

Why I Did Not Put the Dependency Directly into the Model

I could make ContentBlock directly call the extractor before every save, but that would make the model itself depend on the service responsible for search projection.

The Observer keeps ContentBlock unaware of the concrete observer while allowing ContentBlockSearchTextExtractor to be resolved normally through the Laravel container.

Where I Like Observers and Where I Would Avoid Them

This case is a good Observer candidate because the action is small, deterministic, and directly preserves consistency of the model.

I do not want every save() to become a hidden trigger for half of the application.

Action after model change

My approach

Rebuild derived search_text

Good Observer candidate

Normalize a local derived attribute

May be a good candidate

Send an email

Prefer an explicit application event or job

Call an external API

Prefer an explicit process with failure handling

Rebuild the entire search system

I would not hide this behind a normal save()

Publish to third-party services

Separate explicit workflow

The Observer Removed the Need to Remember search_text

This is the biggest benefit for me.

Without the Observer, every future ContentBlock writer would need to remember another rule:

A rule that is easy to forget
Changed data?Do not forget to rebuild search_text.

After moving the rule into the lifecycle, controllers, synchronization services, commands, importers, and future admin actions do not need to know about it individually.

They change ContentBlock through the normal Eloquent lifecycle. Derived-field consistency is maintained separately.

Tests for This Observer Are Very Concrete

I do not need to test that an Observer class exists. I need to test the invariant it guarantees.

  • A new ContentBlock receives the correct search_text when saved.
  • Changing data rebuilds search_text.
  • Technical configuration does not enter the search projection.
  • User-visible text is included in search_text.
  • Empty or malformed data is handled predictably.
  • The backfill uses the same extractor rules.
  • Running the backfill again does not corrupt already correct values.

What I Do Not Do with This Observer

  • Do not duplicate extraction logic in the Observer and backfill command.
  • Do not make every service manually update search_text.
  • Do not use saved when the derived field can be persisted in the same write through saving.
  • Do not expect the Observer to backfill historical rows automatically.
  • Do not assume model events cover mass Query Builder updates.
  • Do not hide expensive external integrations behind an ordinary save().
  • Do not make the Observer own search projection rules.

My Short Checklist for Observers Maintaining Derived Data

  • Define the invariant between the source field and derived field first.
  • Keep the transformation rule in a separate deterministic class.
  • Choose the lifecycle event that matches the required moment.
  • For a field that should be written with the model, consider saving.
  • Keep the Observer thin.
  • Register it centrally.
  • Handle historical data separately with a backfill.
  • Use the same extractor for runtime saves and historical backfill.
  • Remember that bulk SQL updates can bypass model events.
  • Test the invariant rather than merely testing the Observer call.

I use an Observer not because Laravel has observe(), but because I want responsibility to move from every possible caller to the lifecycle of the entity itself.

Conclusion

For ContentBlock in ICanUp, the Observer solved a very specific problem.

search_text is derived from data. I do not want controllers, commands, importers, and services to independently remember to rebuild it.

ContentBlockSearchTextObserver reacts to saving, while the actual projection rules remain inside ContentBlockSearchTextExtractor. This lets the derived field be persisted with the model without turning the Observer into a large search-rules class.

Existing Content Blocks are handled through a separate backfill using the same extractor. Code that intentionally bypasses model events must account for the derived field explicitly.

The biggest benefit is not fewer lines of code. It is removing a rule every future developer would otherwise need to remember: after changing ContentBlock, do not forget to rebuild search_text.