Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Six Mistakes With Laravel Eloquent Events and Observers (and the Fixes)

About Post

Observers feel like magic the first time you use one. A contract is created, and somewhere, without a single extra line in the controller, a welcome email goes out, an audit log is written and a cache is cleared.

Six months later, that magic is why a data import sent hundreds of emails, why a bulk update skipped the audit log entirely, and why nobody on the team can say exactly what happens when you call save().

Eloquent model events (creating, updated, deleted and friends) and the observers that group them are genuinely useful. They're also one of the easiest ways to hide behaviour in a Laravel app. Here are the mistakes I see most, why they happen, and what to do instead.

Mistake 1: hiding business side effects in observers

The mistake. Important actions live in an observer: send the tenant a welcome email in created, notify finance in updated.

Why it hurts. Every Contract::create() anywhere now sends an email: in seeders, in tests, in a one-off import script, in an admin "duplicate contract" feature someone adds next year. The controller reads like it only saves a record, and the most important thing it does is invisible.

The fix. Keep observers for things that should always happen with the data, whoever touches it: setting a UUID, normalising a field, clearing a cache, writing an audit row. Put business actions where the business action happens, in an action class or service that's called explicitly:

class SignContract
{
    public function handle(Contract $contract, User $signedBy): void
    {
        $contract->markSigned($signedBy);

        $contract->tenant->notify(new ContractSigned($contract));
        ContractSignedEvent::dispatch($contract);
    }
}

Now "signing a contract sends a notification" is written down in the place someone will look for it.

Mistake 2: expecting events on mass updates

The mistake.

Contract::where('ends_at', '<', now())->update(['status' => 'expired']);

Why it hurts. This runs a single SQL UPDATE. No models are loaded, so no model events fire: no updating, no updated, no saved. Your audit observer never hears about it. The same goes for query-builder delete(), insert(), upsert() and increment() on a query.

The fix. If the events matter, load the models and save them one by one, in chunks:

Contract::where('ends_at', '<', now())
    ->where('status', '!=', 'expired')
    ->lazyById()
    ->each(fn (Contract $contract) => $contract->update(['status' => 'expired']));

Slower, but correct. And if speed matters more than the events, keep the mass update and do the side effect explicitly afterwards, with a comment saying why. The bug is never the choice itself; it's not knowing you made one.

Mistake 3: the observer that saves the model it's observing

The mistake.

public function saved(Contract $contract): void
{
    $contract->search_text = Str::lower("{$contract->reference} {$contract->tenant_name}");
    $contract->save(); // fires saved again...
}

Why it hurts. save() fires saved, which calls save(), which fires saved. Depending on whether anything is dirty the second time round, you get an extra query on every save or an infinite loop. Either way, it's a trap.

The fix. Set derived values in saving (or creating/updating), before the write happens, so no second save is needed. If you genuinely must save inside an event, use saveQuietly(), which skips events.

public function saving(Contract $contract): void
{
    $contract->search_text = Str::lower("{$contract->reference} {$contract->tenant_name}");
}

Mistake 4: side effects that escape a rolled-back transaction

The mistake. A contract and its payment schedule are created in a database transaction. The observer on Contract dispatches a job or sends an email in created. Then creating the schedule fails and the transaction rolls back.

Why it hurts. The contract no longer exists. The email about it has already been sent, or a queued job is about to look for a record that isn't there.

The fix. Tell the observer to wait for the commit:

use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;

class ContractObserver implements ShouldHandleEventsAfterCommit
{
    // handlers now run only after the surrounding transaction commits
}

Queued jobs and listeners have the same option with afterCommit. In anything involving money or contracts, I'd treat "after commit" as the default.

Mistake 5: reacting to changes that didn't happen

The mistake. A saved handler that sends "your contract was updated" whenever it runs.

Why it hurts. saved fires even when nothing changed, for example when a form is submitted with the same values. updated only fires when there were dirty attributes, but it doesn't care which ones, so a timestamp touch counts too.

The fix. Check exactly what changed:

public function updated(Contract $contract): void
{
    if ($contract->wasChanged('status')) {
        AuditLog::record($contract, 'status', $contract->getOriginal('status'), $contract->status);
    }
}

Inside updated, getOriginal() still returns the old value, which is exactly what an audit log needs.

Mistake 6: tests that silently switch observers off

The mistake. A test calls Event::fake() to assert that an event was dispatched. Suddenly an unrelated assertion fails because the contract has no UUID.

Why it hurts. Event::fake() fakes all events, and Eloquent model events go through the same dispatcher. Your observers stop running. Factories that relied on a creating hook now produce incomplete models.

The fix. Fake only what you're asserting on:

Event::fake([ContractSignedEvent::class]);

Or create the models you need before calling Event::fake(). And do write at least one test that exercises the observer for real, so you know the hidden behaviour actually happens.

A simple test for any observer: if a new developer called save() on this model in a seeder, would they be surprised by what happens? If yes, that logic belongs somewhere explicit.

Quick reference

  • Observers for data rules; explicit actions for business side effects.
  • Mass updates and deletes fire no model events. Decide consciously.
  • Derive values in saving; never save() inside saved.
  • ShouldHandleEventsAfterCommit for anything that leaves the database.
  • wasChanged() before reacting to an update.
  • Event::fake([...]) with a list, not without one.

Register observers where people will see them, too: the #[ObservedBy] attribute on the model class makes the hidden behaviour a little less hidden. The Eloquent events docs have the full list of events.

Which of these has caught you out? I'd bet on the mass update, but I want to hear about the stranger ones.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close