Laravel Observers vs Domain Events: Draw the Boundary

9 min read

An order was saved. Is that a business fact? Not necessarily. It may be an address correction, an admin note, or an automatic timestamp update. That is why “react to Order::updated” is a different decision from “react when an order is placed”. Laravel makes both easy to wire; the hard part is choosing a contract that stays true when there are imports, retries, transactions, and more than one caller.

Use an observer for a local consequence of persistence. Use a domain event for an intentional fact that another business concern may consume. An action coordinates the transition; a listener reacts to its fact. This boundary keeps an API endpoint, a console command, a CSV import, and an admin screen from accidentally treating every save() as a sale.

The vocabulary exposes the boundary

An observer answers: “what should happen because this model was created, saved, or deleted?” Its vocabulary comes from Eloquent’s lifecycle. It is appropriate for assigning a UUID, invalidating a cache key, or maintaining a small local projection. Such work should be quick, deterministic, and safe even if a seeder or import triggers it.

A domain event answers: “what business fact has become true?” OrderPlaced, QuoteAccepted, and SubscriptionCancelled are names a product owner recognises. They belong at the use-case boundary, after the relevant invariants have passed. They are not inferred from an ORM lifecycle event that happens to look similar.

This distinction is more than naming. A draft order, a payment retry, and an imported historical order might each create an orders row. Only one path may genuinely mean “the customer placed an order today.” Put that decision in code which owns the transition.

Keep observers small and local

Catalog cache invalidation is a reasonable observer. It neither calls an external system nor decides what a business event means. Because a product can be changed within a transaction, the observer asks Laravel to run only after commit.

php
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Product;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
use Illuminate\Support\Facades\Cache;

final class ProductObserver implements ShouldHandleEventsAfterCommit
{
    public function saved(Product $product): void
    {
        Cache::forget("catalog.product.{$product->getKey()}");
        Cache::forget("catalog.product.slug.{$product->slug}");
    }

    public function deleted(Product $product): void
    {
        Cache::forget("catalog.product.{$product->getKey()}");
        Cache::forget("catalog.product.slug.{$product->slug}");
    }
}

Register it explicitly unless the project has chosen event discovery:

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Models\Product;
use App\Observers\ProductObserver;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Product::observe(ProductObserver::class);
    }
}

Do not turn this class into a hidden checkout workflow. Mail, payment, analytics and warehouse calls are hard to discover from the use case, run for factories and imports, and may happen for a record that later rolls back. ShouldHandleEventsAfterCommit fixes timing; it does not fix an unclear owner.

Let the action name the fact

The action below is the one place allowed to say an order was placed. It persists the aggregate, calculates a snapshot amount in integer cents, and emits an immutable event. The event contains stable scalar data rather than a mutable model with an attached relationship graph.

php
<?php

declare(strict_types=1);

namespace App\Orders\Actions;

use App\Models\Order;
use App\Orders\Events\OrderPlaced;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    /** @param array<int, array{product_id: int, quantity: int, unit_price_cents: int}> $lines */
    public function handle(int $customerId, array $lines): Order
    {
        return DB::transaction(function () use ($customerId, $lines): Order {
            $totalCents = collect($lines)->sum(
                fn (array $line): int => $line['quantity'] * $line['unit_price_cents'],
            );

            $order = Order::query()->create([
                'customer_id' => $customerId,
                'status' => 'placed',
                'total_cents' => $totalCents,
            ]);

            $order->lines()->createMany($lines);

            OrderPlaced::dispatch($order->getKey(), $customerId, $totalCents);

            return $order;
        });
    }
}

ShouldDispatchAfterCommit prevents a listener or worker from observing an order before the transaction is visible. If the transaction fails, Laravel discards the event instead of allowing a receipt or analytics call for an order that never existed.

php
<?php

declare(strict_types=1);

namespace App\Orders\Events;

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

final readonly class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public int $orderId,
        public int $customerId,
        public int $totalCents,
    ) {}
}

Treat this payload as a contract. Add fields deliberately, version a breaking semantic change for external consumers, and do not serialise every relation “just in case”. A listener can load the current read model by ID after commit.

Queues deliver at least once

Retries are normal after timeouts, worker crashes, and a temporary SMTP error. A queued listener must make its side effect idempotent. Here order_receipts.order_id has a database unique index. firstOrCreate() is backed by that constraint; an exists() check followed by an insert is not safe when two workers race.

php
<?php

declare(strict_types=1);

namespace App\Orders\Listeners;

use App\Mail\OrderReceiptMail;
use App\Models\Order;
use App\Models\OrderReceipt;
use App\Orders\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Support\Facades\Mail;

final class SendOrderReceipt implements ShouldQueueAfterCommit
{
    public int $tries = 3;

    /** @var array<int, int> */
    public array $backoff = [10, 60, 300];

    public function handle(OrderPlaced $event): void
    {
        $receipt = OrderReceipt::query()->firstOrCreate(['order_id' => $event->orderId]);

        if (! $receipt->wasRecentlyCreated) {
            return;
        }

        $order = Order::query()->with('customer')->findOrFail($event->orderId);

        Mail::to($order->customer->email)->send(new OrderReceiptMail($order));
    }

    public function failed(OrderPlaced $event, \Throwable $exception): void
    {
        report($exception, ['order_id' => $event->orderId]);
    }
}

Database writes and SMTP cannot form one atomic transaction. A process can die after mail leaves the process and before a status update commits. For important integrations, write an outbox record inside the order transaction and publish it with a retryable worker plus the provider’s idempotency key. A duplicate receipt may be tolerable; a duplicate card charge is not.

Test the business seam

Test the action with a scoped event fake after creating factories that rely on model events. Assert the meaningful payload, not merely that “an event happened”. Event fakes deliberately prevent listeners from executing, so this stays an action test.

php
<?php

use App\Models\Customer;
use App\Orders\Actions\PlaceOrder;
use App\Orders\Events\OrderPlaced;
use Illuminate\Support\Facades\Event;

test('placing an order dispatches its business fact', function () {
    $customer = Customer::factory()->create();
    Event::fake([OrderPlaced::class]);

    $order = app(PlaceOrder::class)->handle($customer->getKey(), [
        ['product_id' => 10, 'quantity' => 2, 'unit_price_cents' => 1_500],
    ]);

    expect($order->total_cents)->toBe(3_000);

    Event::assertDispatched(OrderPlaced::class, function (OrderPlaced $event) use ($order): bool {
        return $event->orderId === $order->getKey() && $event->totalCents === 3_000;
    });
});

Call the listener twice in its own test. The assertion is not an implementation detail: duplicate delivery must produce one receipt record and one mail.

php
<?php

use App\Mail\OrderReceiptMail;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderReceipt;
use App\Orders\Events\OrderPlaced;
use App\Orders\Listeners\SendOrderReceipt;
use Illuminate\Support\Facades\Mail;

test('the receipt listener is idempotent across a retry', function () {
    Mail::fake();
    $customer = Customer::factory()->create(['email' => '[email protected]']);
    $order = Order::factory()->for($customer)->create(['total_cents' => 3_000]);
    $event = new OrderPlaced($order->getKey(), $customer->getKey(), 3_000);

    app(SendOrderReceipt::class)->handle($event);
    app(SendOrderReceipt::class)->handle($event);

    expect(OrderReceipt::query()->where('order_id', $order->getKey())->count())->toBe(1);
    Mail::assertSent(OrderReceiptMail::class, 1);
});

Test the observer separately by updating a product and asserting its cache keys are forgotten. Do not fake all events before a factory if it depends on a creating hook: Laravel suppresses those model events too.

When not to use either

Do not emit OrderCreated from an observer and call it OrderPlaced; drafts, imports, and retries prove the names are not synonyms. Do not introduce a domain event where a single immediate collaborator and a direct method call describe the work more honestly. Do not use a listener as a substitute for a transaction when several writes form one invariant.

Use an observer for a lifecycle-shaped, local concern. Use a domain event for a business fact with independent consumers. Use an action and transaction for an invariant. Use a transactional outbox when the message must survive the gap between a committed database transaction and an external publisher. Architecture is not the number of mechanisms involved; it is making each consequence visible and truthful.

Review questions before adding a hook

Before adding an observer, ask whether every future write to this model should cause the consequence. If the answer depends on the route, actor, status transition, or a value which was true before the update, the observer is probably too broad. Put that condition in the action where the intent is already explicit. An observer that starts with if ($product->wasChanged(...)) is not inherently wrong, but several such branches are a signal that persistence lifecycle has become a poor proxy for domain intent.

Before adding an event, name its consumers. A receipt, stock reservation and analytics conversion are independent effects; each may evolve, fail, or move to a queue separately. If there is only one consumer and it must succeed before returning success, keep it as a direct collaborator of the action. A domain event is valuable for decoupled fan-out, not as a mandatory ceremony for each setter.

Finally, decide which failures are allowed to block the customer. The transaction must fail for an invalid line or unavailable stock because those are order invariants. A temporary analytics outage normally should not prevent checkout; let its listener retry and alert through failed(). A receipt listener may retry with backoff. This explicit split is better than an observer whose exceptions unpredictably turn a routine model update into a failed request.

At larger scale, observe the queue, failed jobs and outbox age as operational signals. An event architecture is only honest when the team can tell which facts were consumed, which are retrying, and which require intervention. That is the practical difference between a useful boundary and invisible background magic.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.