You're trying to figure out why creating a new Order in tinker fires three emails, writes two audit rows, sends a Slack notification, and queues a job to sync inventory. None of that is in the controller. None of that is in the route. Grep for OrderShipped and you find one observer file with eight methods on it. Welcome to Laravel observers.

Observers are one of the cleanest features in the framework when you use them for the right thing, and one of the worst sources of "where is this even happening?" when you don't. This article is about where that line is — and why it's so easy to drift over it.

What An Observer Actually Is

An observer is a class whose method names match Eloquent's lifecycle events. You make one with the artisan generator, point it at a model, and Laravel calls the methods at the appropriate moment.

Bash
php artisan make:observer OrderObserver --model=Order

The full set of methods you can implement: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, forceDeleted, replicating. The *ing versions fire before persistence; the *ed versions fire after. saving/saved fire on both inserts and updates.

Wiring is a single line. In Laravel 11+ the idiomatic approach is the attribute on the model:

PHP
use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(OrderObserver::class)]
class Order extends Model
{
    // ...
}

In older codebases you'll see the equivalent registration in AppServiceProvider::boot():

PHP
public function boot(): void
{
    Order::observe(OrderObserver::class);
}

Either form works. The attribute keeps the binding next to the model, which is genuinely nicer when you're trying to find what reacts to a save.

Where Observers Earn Their Keep

The right place for an observer is the kind of side effect that's truly owned by the model itself — something that should happen every time the row changes, regardless of who changed it.

Things that fit cleanly:

  • Stamping a slug from a name on creating.
  • Maintaining a search_vector column on saving.
  • Logging soft deletes to an audit_log table on deleted.
  • Cascading cleanup on forceDeleted for relationships your DB doesn't know about (S3 objects, search indexes, cached aggregates).
PHP
final class OrderObserver
{
    public function creating(Order $order): void
    {
        $order->reference ??= 'ORD-'.strtoupper(Str::random(10));
    }

    public function saved(Order $order): void
    {
        if ($order->wasChanged('status')) {
            AuditLog::record('order.status_changed', $order, [
                'from' => $order->getOriginal('status'),
                'to'   => $order->status,
            ]);
        }
    }
}

Both methods are about the row's own integrity. They don't reach into the outside world, they don't depend on user context, and they'd be the same whether the change came from a controller, a job, a tinker session, or a seeder. That's the test.

A two-column comparison split down the middle. The green column on the left is labelled Row integrity (yes, observer) and shows OrderObserver code stamping a reference on creating and writing an audit row on saved, with a callout listing slug stamping, search vector maintenance, soft-delete audit logs, and S3 cleanup as the kinds of work that belong here. The red column on the right is labelled Product workflow (no, Action) and shows an ApproveOrder Action wrapping DB::transaction with DB::afterCommit dispatching OrderApproved, alongside a callout listing card charges, welcome emails, partner webhooks, and anything that depends on user intent.
Same Eloquent event, two homes — bookkeeping that belongs to the row vs workflow that belongs to a named Action.

Where Observers Become A Trap

The trap is using observers for product behavior instead of model integrity. Charging a card on created. Sending the welcome email on created. Triggering a webhook on updated. Anything where "what happens next" depends on the business intent of the action, not on the row's persistence.

Three things go wrong fast:

  1. Tests slow down and get weird. Every factory call now fires real or mocked side effects. You either accept the tax (slow suite) or pepper your tests with Order::withoutEvents(fn () => Order::factory()->create()) and lose coverage of the events you actually care about.
  2. Disabling becomes folklore. Someone writes a one-off importer. Did they remember to skip events? Did they import 200,000 rows and accidentally send 200,000 welcome emails? Real story, more than once.
  3. The action loses its name. "Approve order" stops being a thing in your codebase. It's just $order->update(['status' => 'approved']) and a chain of magic. Six months later, when you need a second way to approve an order (an admin Slack command, an API endpoint), you can't tell what's safe to run and what gets duplicated.

If the side effect belongs to a specific user-driven action, put it in an Action class or a job dispatched from there. The observer should be for the row, not for the workflow.

Diagram of an Eloquent save in Laravel: a single Order::save() call cascades through observer methods saving → creating → created → saved, with each method silently fanning out into mailable dispatch, audit log writes, search-index updates, queued sync jobs, and a Slack webhook. The picture shows how one update line becomes a tree of side effects the caller cannot see.
One save, many hidden hooks — observers make it easy to lose the trail.

The Transaction Footgun

The single most common observer bug: an observer fires inside a transaction, queues a job, and the transaction rolls back. The job picks up before the rollback completes — or after, with the row gone — and crashes trying to load a model that no longer exists.

Laravel ships the fix. DB::afterCommit() defers the closure until the surrounding transaction actually commits:

PHP
public function saved(Order $order): void
{
    if ($order->wasChanged('status') && $order->status === 'paid') {
        DB::afterCommit(function () use ($order) {
            ProcessPaidOrder::dispatch($order);
        });
    }
}

If your queue connection has after_commit => true set in config/queue.php, dispatched jobs get this behaviour for free. Worth setting once at the queue level instead of remembering to wrap every dispatch.

Disabling Events When You Need To

Some operations have no business firing observers. Bulk imports. Backfills. Test factories that just need a row in the table.

PHP
Order::withoutEvents(function () {
    Order::factory()->count(50_000)->create();
});

withoutEvents is scoped — only that closure runs without observers, and the previous handlers are restored after. There's also saveQuietly(), deleteQuietly(), forceDeleteQuietly() on the model itself for one-off cases.

What's not safe is Order::query()->update([...]) and Order::query()->delete(). Those bypass observers entirely because they never load the model. If your audit row depends on the observer, mass updates are silently invisible. That has bitten more than one team into discovering they had two days of missing audit data.

A three-panel panorama. The first panel labelled Tinker shows what fires when Order::factory()->create() runs — reference stamping, search vector, audit row — and what does not fire — emails, Slack, webhooks. The middle panel is a production save() timeline: BEGIN, the observer methods firing in order, DB::afterCommit registering deferred work, COMMIT, the queued job running and finding the row, and a red branch showing the alternative where without afterCommit the job races the rollback. The right panel is the trail map a new contributor uses to read the codebase: app/Observers, ObservedBy attributes and observe() calls, and the sneakier model boot() closures, plus a Horizon panel reminding the reader to tag jobs by model id and prefer domain events for cross-model facts.
Tinker, production, and the dashboard see the same observer — the trail you leave is the contract you keep.

Observers vs Events vs Jobs

People conflate these three because they all fire after something happens. They're not the same.

  • Observer methods are tightly coupled to one model. Same process, same request. Synchronous unless you queue from inside.
  • Events + listeners are decoupled — multiple listeners, multiple models can dispatch the same event, listeners can be queued. Use them when the business fact matters more than which row caused it.
  • Jobs are units of work. Anything slow, anything that can fail and retry, anything you want to see on the Horizon dashboard.

The shape that holds up: the Action that approves an order writes the row, dispatches OrderApproved (a domain event), and that event has whatever listeners the product needs. The observer on Order does only what's true of every order save — stamping fields, writing audit, maintaining derived columns. Different jobs.

Reading An Unfamiliar Codebase With Observers

When you join a Laravel codebase and want to know what fires on a save, three places to grep:

  1. app/Observers/ — explicit observer classes.
  2. Model::observe( and #[ObservedBy( — registration sites.
  3. boot() methods on individual models — closure-style hooks like static::created(fn ($model) => ...) that aren't in an observer file.

That third one is the sneakiest. A two-line closure inside a model's boot() method is invisible to anyone scanning for observers. If your team relies on observers, it's worth a convention: real hooks go in observer classes, not model boot() closures, so there's exactly one place to look.

A Useful Default

The rule that's served me well: an observer method gets to touch the row it received and write to the database. It does not call mailers, dispatch domain events, or hit external services. If it needs to do any of those, it dispatches a job inside DB::afterCommit and the job does the work — visible on Horizon, retryable, testable, named.

That keeps observers boring, which is what you want from something that runs on every save you'll ever do.

A One-Sentence Mental Model

Observers are great for the row's own bookkeeping and dangerous for the product's workflow — keep them small, defer their side effects to DB::afterCommit, and remember that anyone reading $order->save() has no idea what's about to happen unless you make the trail easy to follow.