In a previous post about migrating to DTOs, I talked about how I stopped depending on Laravel’s “magic arrays” in favor of stricter typing. That was Step 1 of my journey toward more predictable code. This wasn’t yesterday, and it’s not exactly recent, but I still think it’s worth documenting this experience here.
Once you start using DTOs and an
Actions/style architecture, the Observer path becomes weird (and honestly, kinda unworkable) pretty quickly.
So, without further ado (yes, I know, I write a lot and I tend to ramble), today I need to talk about Step 2, and this one hurt more than I expected: I stopped using Observers as a crutch.
For years I was (I mean, I still am, just differently) a Laravel power user. If the docs had a feature, I tried it. Observers felt like a superpower: skinny controllers, “clean” models, and every side effect neatly hidden inside a UserObserver.
But as my projects stopped being simple CRUD apps and became actual business domains, I started noticing a very ugly pattern: Observers are not clean code. Observers are invisible logic. And invisible logic is the kind of thing that makes you waste an entire afternoon staring at the wrong file, convinced the bug is “in Laravel”, when it’s actually inside an Observer someone created 9 months ago and forgot about.
The “clean code” illusion
The appeal of an Observer is obvious. You open the controller and it looks beautiful:
public function store(UserData $userData)
{
// Look how "clean" this is!
$user = User::create($userData->toArray());
return response()->json($user);
}
Pretty. But there’s a catch: that “clean” is mostly makeup.
Because, without the developer who will read this file 6 months from now realizing it, that single line User::create() may trigger a whole chain reaction:
- Sends a welcome email
- Creates a Stripe Customer
- Logs activity
- Notifies a Slack channel
- Updates a search index
- Clears some cache keys
- Brews coffee
And so on.
And that’s where the fun begins. You look at the line, it just “creates a user”. But the system is basically throwing a secret party behind your back.
This is what I call Spooky Action at a Distance: you change the database here, and code executes somewhere else you didn’t even remember existed.

The execution order trap
Another very common issue is execution order betraying you.
Imagine you have logic in the created event that depends on some relationship. But if you use User::create(), created fires immediately. Often before you had a chance to associate stuff like Roles, Teams, Profile, etc.
And then you start seeing code smells like this:
public function created(User $user)
{
// Trying to guess if the relation is loaded yet...
if ($user->relationLoaded('team')) {
// ...
}
}
This is fragile. And worse, it’s fragile in a silent way.
Today it works. Tomorrow someone changes a flow, runs an import, uses createQuietly, or just changes the order of an attach, and suddenly your Observer becomes roulette.
If a business rule depends on the accidental execution order of Eloquent events, that’s not “architecture”. That’s faith.
The real issue: side effects with no owner
Deep down, what made me quit wasn’t “observers are evil”. It was realizing I was putting business rules in a place where:
- It’s not obvious they exist
- It’s not obvious when they run
- It’s not obvious how to disable them
- It’s not obvious how to test the flow without triggering half the system
Even when nothing breaks, the mental cost is still there. And when something breaks, the developer debugging it pays the bill.
There’s a moment in every experienced dev’s life where the last thing they want is more headache. They want control and predictability.

The solution: explicit beats implicit
In the same way I replaced Form Requests with DTOs to make input explicit, I replaced Observers with explicit Services and Actions.
Yes, it means writing a few more lines.
But those extra lines tell a story. And that story is what "your future self" needs when something goes wrong.
And I think this is the most important point: an Action wants to be an explicit flow. An Observer wants to be an invisible side effect. Put the two together and you get a system where nobody knows what the “source of truth” for behavior actually is.
Here’s a refactored version of the flow. Notice there’s no magic. You read the code and you know what happens:
final class CreateUserAction
{
public function __construct(
private BillingService $billing,
) {}
public function execute(UserData $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create($data->toArray());
DB::afterCommit(function () use ($user) {
$this->billing->createCustomer($user);
});
$user->notify((new WelcomeNotification())->afterCommit());
return $user;
});
}
}
The bonus is that your code becomes a control panel.
- Want an import to NOT send emails? Don’t call it.
- Want an admin flow that creates users without Stripe? Don’t call it.
- Want to test only “create user” without triggering the rest of the universe? You can.
When Observers ARE acceptable
Does this mean Observers are useless? Obviously not. I still use Observers when it’s a purely technical concern, always true, regardless of context, and not really a business rule.
Examples I consider acceptable:
- Generating a UUID for a model
- Clearing cache keys
- Updating search indexes (Elasticsearch/Meilisearch), depending on the case
But business logic (sending emails, charging a card, assigning teams, provisioning resources)? I avoid it.
Because business rules need an owner, and they need to live somewhere you can actually see.

Maturity is predictability
I already hinted at this earlier: when we’re junior, we love tools that do things for us. We love magic.
As we get more senior (more grumpy?), magic starts charging interest. You start preferring code that says exactly what will happen, even if it looks less elegant. Dropping Observers felt uncomfortable at first. I felt like I was writing “boilerplate”. But that “boilerplate” turned into something much more useful: living documentation. It’s code I can read, understand, and debug without needing a mental map of the entire event system.
If you’re tired of side effects breaking flows, take a careful look at your Observers.
Especially the created one.
Especially the one that “just does a tiny thing”.
And if it makes sense, move it into a Service or an Action and make the flow explicit.