I’ve been making our Eloquent models harder to misuse. AI agents write a lot of code in this project, and “remember the convention” wasn’t enough.
I watched @mateusjatenee’s video on the action pattern a while back. The part that stuck with me was the division of work: an action handles a use case, loads the models, owns the transaction, and coordinates jobs or notifications. The business rule lives on the model, in a method like `$order->cancel()` or `$invoice->markPaid()`.
That still leaves an easy way around the rule: someone can call `$order->update(['status' => 'paid'])` elsewhere. So I added checks that make the convention enforceable:
→ PHPStan disallows `update()`, `save()`, `delete()`, `fill()`, `increment()`, and bulk query writes outside the models folder. To change state, you add a method to the model.
→ Pest architecture tests keep models from calling actions, jobs, or notifications. Actions are `final readonly`, expose `handle()`, and don’t authorize or validate HTTP input. Controllers don’t write to the database directly.
→ Models throw domain exceptions that render their own responses, so controllers don’t need a `try/catch` just to return a 422.
→ Short, folder-scoped Laravel Boost rules explain these choices to the agents working in the codebase.
The combination has worked better than either approach on its own. The rules give the agent context; PHPStan and Pest catch departures from it. When I review a state change now, I know where to find it and where its invariant is checked.
Thanks, Mateus, for the push. His video on models as data bags is next on my list.