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.

First I Needed to Separate Block Data from Searchable Text
A ContentBlock may contain data similar to this:
[ '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:
Laravel ObserverObserver reacts to model lifecycle events.Read moreI 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:
final class ContentBlockSearchTextExtractor{ public function extract(array $data): string { // Extract only searchable user-visible text. }}Its contract is intentionally small:
ContentBlock data | vContentBlockSearchTextExtractor | vsearchable user-visible textThe 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:
$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:
When you save ContentBlock,remember to update search_text too.I want this 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
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.
$contentBlock = new ContentBlock();$contentBlock->data = $data;$contentBlock->save();$contentBlock->data = $newData;$contentBlock->save();$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:
$this->searchTextObserver->update();The Observer does not control the model directly either.
Eloquent acts as the intermediary:
ContentBlock changes | vEloquent lifecycle | vsaving event | vContentBlockSearchTextObserver | vContentBlockSearchTextExtractor | vsearch_textThe Observer Is Registered in One Place
Laravel needs one registration point connecting the model and its Observer.
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 |
|
Define which text is searchable |
|
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.
data changes | vsaving | vcalculate search_text | vSQL INSERT / UPDATEWith 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.
save data | vSQL UPDATE | vsaved | vchange search_text | vanother UPDATEsearch_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:
data | vsearchableProjection(data) | vsearch_textThat 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.
php artisan app:content-blocks:backfill-search-textThe 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.
New and changed data: ContentBlock save | vObserver | vContentBlockSearchTextExtractor Historical data: Backfill command | vThe 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 |
|---|---|
| Yes |
| Yes, for a concrete model instance |
| I do not rely on model events |
| 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:
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:
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_textwhen saved. - Changing
datarebuildssearch_text. - Technical configuration does not enter the search projection.
- User-visible text is included in
search_text. - Empty or malformed
datais 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
savedwhen the derived field can be persisted in the same write throughsaving. - 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.



