Model observers are the wrong home for derived fields
This is me
Latest articles
- GDPR cookie consent in Laravel with Wirecookies (opens on another site)
- Localized routes in Laravel with Laralang (opens on another site)
- Collecting user feedback in Laravel with Wirebug (opens on another site)
- The same operation ends up in three places, and none of them agree
- Laravel makes you choose between readable templates and stable translation keys
- Model observers are the wrong home for derived fields
- A coding agent is a generalist, and your codebase is not general
- Translating a Laravel app is two problems, and only one of them is about words
- The tags table is simple until the fourth requirement arrives
Not every column is typed in by a user. An excerpt cut from a body, a reading_minutes estimate, a search_text field assembled from four others, a cached count. These are derived: their value is a function of other data, and something has to run that function.
The obvious home is a model observer, and for one field it genuinely is the right answer. The problem is that derived fields arrive one at a time, each one adds a few lines to saving(), and nobody ever notices the moment it stopped being fine.
By the fifth field the observer is a wall of string-building with no structure. And then something worse happens: you add a new derived field and need to fill it for the rows that already exist, so you write a backfill command, and the formula gets copied, because the version in the observer is not callable from anywhere else. Now the same derivation lives in two places and they will drift.
The observer is not too big. It is in the wrong shape: it is a hook, and a hook cannot be called on demand.
Return the value, do not assign it
A keeper is a class that receives the model and exposes one method per field it computes. The first decision is what those methods do, and it is the one everything else follows from:
public function getExcerpt(): string
{
return Str::limit(strip_tags($this->article->body), 160);
}
It returns. It does not write to $this->article->excerpt, and it does not save.
The version that assigns is shorter at the call site and I am fairly sure it is wrong. A method that returns is a pure function of the model's state: you can call it in a test with no database, compare its output against an expected value, and use it to check whether a stored column is stale without touching the row. A method that assigns can only be observed by inspecting the model afterwards, and it cannot be used for a comparison at all, because asking the question changes the answer.
Keepers hold the formula. Assignment is somebody else's job.
process() does not persist
Given that, process('excerpt') runs the method and assigns the result to the attribute. The deliberate part is that it stops there.
Saving inside process() would have been convenient in exactly one call site and wrong in the other two. Inside a saving observer it would cause a recursive write. In a backfill you want to process() several fields and save once, not once per field. So process() sets attributes in memory, returns the model, and you save when you mean to:
$article->process(['excerpt', 'reading_minutes'])->save();
Which is what makes the same keeper usable from the observer and from a backfill command with no duplicated logic. That was the whole point of the exercise.
Binding by attribute, not by naming convention
The framework-flavoured option was convention: Article gets ArticleKeeper automatically, no wiring. It looks clean in the README and it fails on contact with reality, because a model with ten derived fields wants to split them across several keepers, and a keeper for a domain concept may not map onto exactly one model name.
So binding is explicit and repeatable:
#[KeptBy(ArticleKeeper::class)]
#[KeptBy(SearchKeeper::class)]
class Article extends Model
An attribute rather than a property because it sits at the top of the class where you see it while reading the model, and repeating it is how you say "several". For anyone who would rather not annotate their models, Article::keep(...) in a provider does the same thing. The binding is data either way, not magic.
What it fixes
The observer goes back to one line, the derivation lives in one testable class per concern, and the backfill command calls the same code the observer calls. When the formula changes you edit it once and replay it over the table.
Full API, the argument-passing form and the other verbs are on the Larakeep page. Source on GitHub, package on Packagist.