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
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
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
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
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
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
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.