How to keep a Laravel observer thin
An observer starts as the obvious place. A field has to be filled before the row is written, the model already fires an event at exactly that moment, and four lines in saving() are clearly better than four lines in every controller that saves an article.
Then it fills a second field, and a third. Then somebody needs an email when an article is published, and the observer is already listening, so it goes there too. This is what it looks like a year in:
class ArticleObserver { public function saving(Article $article): void { $article->excerpt = Str::limit(strip_tags($article->body), 160); $article->reading_minutes = max(1, (int) ceil(str_word_count(strip_tags($article->body)) / 200)); $article->search_text = $article->title.' '.strip_tags($article->body).' '.$article->author->name; } public function saved(Article $article): void { if ($article->wasChanged('status') && $article->status === 'published') { Mail::to($article->author)->send(new ArticlePublished($article)); app(SearchIndex::class)->push($article); } $article->author->increment('articles_count'); $article->slug = Str::slug($article->title).'-'.$article->id; $article->save(); } }
Every line got there for a reason. The file is still a problem, because three different kinds of work are sharing one entry point and only one of them belongs here.
The three jobs hiding in one file
Read the methods and sort them by what they leave behind:
- Computing a value from the row. The excerpt, the reading estimate, the search text. Pure functions of the article, with nothing outside it involved.
- Doing something to the outside world. The email, the search index push, the counter on the author.
- Orchestrating. The slug that needs the id, so the model is saved twice.
They have different right answers, and mixing them is what makes the file grow: once an observer contains a side effect, it becomes the natural place for the next one.
Derived fields belong to the row, so put them in a keeper
The three computations in saving() are not events at all. They are the answer to "what should this column hold", and they are true whether or not anybody is saving right now.
Larakeep makes that a class: one method per field, returning the value and assigning nothing.
namespace App\Keepers; use App\Models\Article; use Illuminate\Support\Str; class ArticleKeeper { public function __construct(private Article $article) {} public function getExcerpt(): string { return Str::limit(strip_tags($this->article->body), 160); } public function getReadingMinutes(): int { return max(1, (int) ceil(str_word_count(strip_tags($this->article->body)) / 200)); } public function getSearchText(): string { return $this->article->title.' '.strip_tags($this->article->body).' '.$this->article->author->name; } }
#[KeptBy(ArticleKeeper::class)] class Article extends Model { use HasKeepers; }
And the observer's saving() becomes the routing it should have been:
public function saving(Article $article): void { $article->process(['excerpt', 'reading_minutes', 'search_text']); }
The gain is not the line count. It is that the formula now lives somewhere you can call from a command, a test or a backfill, which is what the next section is about.
saving or saved, and the second write
saving() runs before the row is written, so an attribute set there goes out with the same query. saved() runs after, so anything set there needs a second save(), which is the slug line in the example: two writes per article, forever, for a string that could have been built before the first one.
Worse, that second save() fires the whole event chain again. It works here by luck, because nothing in saved() changes on the second pass. The day somebody adds a condition that does, it is an infinite loop, and saveQuietly() is not the fix, it is the way you stop seeing it.
The slug genuinely needs the id, so it genuinely cannot go in saving(). That is the exception, and the honest way to write it is one narrow update rather than a full save:
public function created(Article $article): void { $article->newQuery() ->whereKey($article->getKey()) ->update(['slug' => Str::slug($article->title).'-'.$article->id]); }
The events that never fire
This is the part that turns a tidy observer into a data problem, and it is not obvious until it bites.
A mass update fires nothing. Article::query()->update(...) goes straight to the query builder, so no saving, no saved, no observer:
Article::query()->where('type', 'draft')->update(['body' => $body]); // no events Article::insert($rows); // no events Article::upsert($rows, ['id']); // no events
And increment() is halfway. On an existing model it fires updating and updated, but not saving or saved. So an observer that computes fields in saving() does not run on this:
$article->increment('views');
The consequence is not an error, which is what makes it expensive: the derived columns simply hold yesterday's value, and nobody finds out until a search stops returning something it should.
So whatever computes those fields has to be replayable, and it has to be the same code, not a copy in a command. That is exactly what a keeper is:
Article::query()->chunkById(500, function ($articles) { foreach ($articles as $article) { $article->process(['excerpt', 'reading_minutes', 'search_text'])->save(); } });
Same class the observer uses. If you had left the formulas inside saving(), this command would be a second copy of them, and the two would drift.
Side effects do not belong in an event
The email is the line to move next, and the reason is not tidiness.
A side effect fired from an observer is invisible at the call site. The controller says $article->save() and an email leaves the building. So the importer sends nine hundred emails, the test suite sends them too until somebody remembers Mail::fake(), and the seeder that backfills last year's articles announces them all as new.
Sending is an operation, so it belongs in a class of its own, called from the place where the decision is actually made:
public function publish(Article $article) { $article->action('publish')->run(); // sets status, saves $article->action('notify_author')->dispatch(); $article->action('push_to_index')->dispatch(); return back(); }
Now the two side effects are named at the call site, they are queued rather than blocking the request, and the importer that does not want them simply does not call them. If you want the details of that half, they are in refactoring a fat controller into actions and moving an action onto a queue.
If a reaction really has to hang off the event, dispatch it rather than do it. An observer that queues a job is a routing decision, and one you can read in a stack trace; an observer that opens an SMTP connection is a surprise.
What is left
class ArticleObserver { public function saving(Article $article): void { $article->process(['excerpt', 'reading_minutes', 'search_text']); } public function created(Article $article): void { $article->newQuery() ->whereKey($article->getKey()) ->update(['slug' => Str::slug($article->title).'-'.$article->id]); } }
Two methods, no side effects, one write per save, and every formula in a class that a backfill can call. The counter on the author, which was the other quiet source of double writes, is a keeper on Author or a job on its own, depending on whether you want it exact or fast.
The test for whether a line belongs in an observer is short: if removing the observer would lose data, it belongs in a keeper; if it would stop something from happening, it belongs in an action. What is left over is the routing, and routing is all an observer was ever meant to be.
written by Edu Lazaro · August 2026