Domain Events vs Eloquent Events in Laravel

7 min read

Eloquent's created, updated, and saved events tell us that a row changed. They do not tell the application why it changed. A subscription renewal can extend an entitlement, create an invoice, record a payment, and notify a customer. None of that is accurately described by “the subscription was saved”.

This distinction matters once an observer begins to send email, call billing, and update analytics. It receives a mutable model, has no business name, and runs for an admin correction as well as a real renewal. A domain event makes a past business fact explicit. It gives independent consumers a stable payload and separates the transaction deciding the fact from work reacting to it.

Eloquent events are persistence hooks, not an integration contract

Observers are useful for truly local persistence work: maintaining a derived value or invalidating an inexpensive internal cache. They become brittle when they encode policy which is not true for every update:

php
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Subscription;
use App\Notifications\SubscriptionRenewedNotification;

final class SubscriptionObserver
{
    public function updated(Subscription $subscription): void
    {
        if ($subscription->wasChanged('renews_at')) {
            $subscription->account->notify(
                new SubscriptionRenewedNotification($subscription),
            );
        }
    }
}

An administrator correcting renews_at now notifies a customer. A retry can notify twice. A second consumer turns a model lifecycle into an accidental integration bus. The action that performs a renewal is where the meaning is known, so it should dispatch the event.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Events;

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

final class SubscriptionRenewed implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public readonly int $subscriptionId,
        public readonly int $accountId,
        public readonly int $invoiceId,
        public readonly string $renewedAt,
        public readonly string $renewsAt,
    ) {
    }
}

The past-tense name says a decision already happened. Scalar identifiers and durable values are safer than passing a complete model graph to a queued listener which may run much later. ShouldDispatchAfterCommit is essential: it prevents consumers from observing work that the database rolls back.

Make the action and transaction the source of truth

One action exposes one business operation. It locks the subscription so two payment callbacks cannot renew the same period, persists the invoice, and dispatches the fact inside the transaction. Add a unique index on invoices.payment_reference; the PHP guard alone cannot protect concurrent workers.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Actions;

use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Invoice;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;

final class RenewSubscription
{
    public function execute(int $subscriptionId, string $paymentReference): void
    {
        DB::transaction(function () use ($subscriptionId, $paymentReference): void {
            $subscription = Subscription::query()
                ->lockForUpdate()
                ->findOrFail($subscriptionId);

            if ($subscription->last_payment_reference === $paymentReference) {
                return;
            }

            $renewedAt = now()->toImmutable();
            $nextRenewal = $subscription->renews_at->greaterThan($renewedAt)
                ? $subscription->renews_at->toImmutable()->addMonthNoOverflow()
                : $renewedAt->addMonthNoOverflow();

            $invoice = Invoice::query()->create([
                'account_id' => $subscription->account_id,
                'subscription_id' => $subscription->id,
                'payment_reference' => $paymentReference,
                'amount_cents' => $subscription->price_cents,
                'issued_at' => $renewedAt,
            ]);

            $subscription->update([
                'last_payment_reference' => $paymentReference,
                'renews_at' => $nextRenewal,
            ]);

            SubscriptionRenewed::dispatch(
                subscriptionId: $subscription->id,
                accountId: $subscription->account_id,
                invoiceId: $invoice->id,
                renewedAt: $renewedAt->toIso8601String(),
                renewsAt: $nextRenewal->toIso8601String(),
            );
        });
    }
}

The event belongs here rather than Subscription::updated: changing a date is a renewal only when this operation says so. Keep a transaction short. Do not make external HTTP calls while holding its lock; commit the decision first, then let a listener make the integration call.

Queued listeners require idempotency

Laravel queues provide at-least-once delivery. A worker can time out, retry, or die after an API accepts its request. Dispatching once is therefore not a duplicate-prevention strategy. Give the receiver a deterministic idempotency key, reload only necessary data, and throw on failure so the queue can retry.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Listeners;

use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\Factory as HttpFactory;

final class SyncRenewalToBilling implements ShouldQueue
{
    public int $tries = 3;

    public int $timeout = 30;

    /** @var list<int> */
    public array $backoff = [5, 30, 120];

    public function __construct(private readonly HttpFactory $http)
    {
    }

    public function handle(SubscriptionRenewed $event): void
    {
        $invoice = Invoice::query()->findOrFail($event->invoiceId);

        $this->http->acceptJson()
            ->connectTimeout(3)
            ->timeout(10)
            ->withHeader('Idempotency-Key', "subscription-renewal:{$invoice->id}")
            ->post(config('services.billing.url').'/renewals', [
                'account_id' => $event->accountId,
                'invoice_id' => $invoice->id,
                'renewed_at' => $event->renewedAt,
                'renews_at' => $event->renewsAt,
            ])
            ->throw();
    }
}

Laravel discovers listeners in app/Listeners by default; for a listener under app/Domain/... add that path with withEvents(discover: [...]) in bootstrap/app.php, or register it with Event::listen().

Set the queue connection's retry_after above the job timeout or two workers can execute the same listener concurrently. If the remote API has no idempotency key, persist delivery state with a lock or a dedicated delivery table. That does not create impossible “exactly once” delivery; it makes duplicate handling deliberate and observable.

Test the fact and the effect independently

The action test asserts a business fact and state. Event::fake() avoids turning a domain test into an HTTP or queue integration test.

php
<?php

declare(strict_types=1);

use App\Domain\Subscriptions\Actions\RenewSubscription;
use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Subscription;
use Illuminate\Support\Facades\Event;

it('records an invoice and emits a renewal fact', function (): void {
    Event::fake([SubscriptionRenewed::class]);
    $subscription = Subscription::factory()->create([
        'renews_at' => now()->subDay(),
        'last_payment_reference' => null,
    ]);

    app(RenewSubscription::class)->execute($subscription->id, 'pay_123');

    expect($subscription->fresh()->last_payment_reference)->toBe('pay_123');

    Event::assertDispatched(SubscriptionRenewed::class, fn (SubscriptionRenewed $event): bool =>
        $event->subscriptionId === $subscription->id
        && $event->accountId === $subscription->account_id,
    );
});

Test the listener separately with Http::fake() and Http::preventStrayRequests(). Assert its URL, payload and idempotency header; that is the boundary it owns. A test should also cover receiving the same event twice, either by asserting the CRM key or by checking the local delivery row.

An outbox closes a different failure window

After-commit dispatch prevents publication of rolled-back work, but a process can still die after commit and before it hands an event to the queue. A transactional outbox is appropriate when losing a paid invoice, partner fulfilment message, or audit export has material cost. Write an outbox row in the same transaction, then have a worker claim, publish, and mark it delivered.

php
<?php

declare(strict_types=1);

namespace App\Domain\Integrations;

use Illuminate\Database\Eloquent\Model;

final class OutboxMessage extends Model
{
    protected $fillable = ['type', 'payload', 'available_at', 'delivered_at'];

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'available_at' => 'immutable_datetime',
            'delivered_at' => 'immutable_datetime',
        ];
    }
}

Use a versioned type such as subscriptions.renewed.v1, claim small batches with ->lock("for update skip locked") (MySQL 8+ and PostgreSQL), and monitor message age and failed attempts. The consumer must remain idempotent: a crash after publishing but before marking delivery still creates a retry.

When not to use this design

Do not turn every setter into an event. A display-name edit does not need DisplayNameChanged simply because a model changed. Nor should an event replace a direct call when an action needs a synchronous result, such as payment authorization. That is a command/query relationship.

Likewise, an outbox is excessive for a cache invalidation that is cheap to rebuild, and an observer is not a substitute for a two-line action. Use model events for local persistence concerns, direct services for synchronous orchestration, and domain events when independent consumers, delayed work, auditability, or a durable business vocabulary justify the extra boundary.

Failure modes to design for before production

The usual mistake is dispatching queued work before commit. The listener races the transaction and occasionally cannot find the invoice it must sync. ShouldDispatchAfterCommit removes that race for Laravel events; manually dispatched jobs need afterCommit() or an after-commit queue connection. A sleep in the listener merely makes an intermittent correctness fault slower.

Do not pass a model and assume a queued event is a snapshot. Serialization restores it later, so a listener can see a subsequent update. Include immutable values required by the decision, keep sensitive data out of the payload, and query only for intentionally current state. Finally, a listener must not compensate a failed business transaction. The action atomically creates invoice and renewal state; the listener performs a separately retryable integration. If billing must approve access synchronously, put it in the command workflow with a clear failure response. Events broadcast completed facts; commands coordinate work that must succeed now.

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.