Rafhael Marsigli Logo

Why I Divorced Laravel Observers

4 min read
Why I Divorced Laravel Observers

Why I Divorced Laravel Observers

In my previous post about migrating to DTOs, I talked about how I stopped relying on Laravel’s “magic arrays” in favor of strict typing. That was Step 1 of my journey toward more predictable code.

Today, I need to talk about Step 2: I abandoned Observers.

For years, I was a true Laravel power user. If the documentation mentioned a feature, I used it. Observers felt like a superpower. You could keep your controllers slim and your logic “clean” by hiding everything inside a UserObserver.

But as my projects stopped being simple CRUDs and started dealing with complex business domains, I realized that Observers are not clean code. They are invisible logic. And invisible logic is the enemy of stability.

The illusion of “clean” code

The appeal of Observers is obvious. You look at the controller and it seems beautiful:

public function store(UserData $userData)
{
    // Look how clean! No clutter!
    $user = User::create($userData->toArray());

    return response()->json($user);
}

It looks great — but it hides serious problems.

Without the developer reading this file six months from now knowing it, that single User::create() line triggers a chain reaction. It sends a welcome email, creates a Stripe customer ID, logs an activity, and maybe even notifies a Slack channel.

This is what I call “Spooky Action at a Distance.” You change the database in one place, and code executes somewhere you didn’t even know existed.

The execution order trap

Another massive problem with Observers is race conditions.

Imagine you have logic in the created event that depends on a relationship. If you use User::create(), the created event fires immediately — often before you’ve had a chance to attach related models (like Roles or Teams).

You end up writing patched code to work around this:

public function created(User $user)
{
    // Trying to guess if the relation is loaded yet...
    if ($user->relationLoaded('team')) {
        // ...
    }
}

This is fragile.

Your business rule:

You should never depend on the accidental execution order of Eloquent events

The solution: explicit beats implicit

Just like I replaced Form Requests with DTOs to make data explicit, I replaced Observers with explicit Services (or Actions). Yes, this means writing a few more lines of code. But that code tells a story.

Here’s the refactored creation flow. Notice there’s no magic. You read the code and you know exactly what happens:

class CreateUserService
{
    public function handle(UserData $data): User
    {
        return DB::transaction(function () use ($data) {
            // 1. Create the user
            $user = User::create($data->toArray());

            // 2. Handle side effects explicitly
            // I see this! I know this happens!
            $this->billingService->createCustomer($user);
            
            // 3. Send notifications
            // If I want to disable this in an import,
            // I just DON'T call this line.
            // No need for Model::withoutEvents().
            $user->notify(new WelcomeNotification());

            return $user;
        });
    }
}

When ARE Observers acceptable?

Does this mean Observers are useless? Not at all.

I still use them for purely technical concerns that should always happen, regardless of context, and that don’t impact business rules.

Examples:

  • Generating a UUID for a model

  • Clearing cache keys

  • Updating search indexes (Elasticsearch / Meilisearch)

But for business logic (sending emails, charging credit cards, assigning teams)?
Never.

Conclusion: maturity is predictability

When we’re junior developers, we love tools that do things for us.

We love magic.

As we become seniors (and architects), we start valuing tools that allow us to say exactly what we want to happen.

Abandoning Observers was painful at first. It felt like I was writing “boilerplate.” But that “boilerplate” is actually living documentation. It’s code my future self can read, understand, and debug without needing a map of the application’s entire event system.

If you’re tired of side effects breaking your application, stop depending on the created event. Make your code explicit.

Share with those you love