- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wire a listener to Eloquent's saved event on the Order model. When an order is saved, send the confirmation email. It works in the demo. Then a support ticket lands: a customer got two confirmation emails for one purchase, and another got a refund receipt for an order that was never refunded.
You dig in. The double email came from a background job that touched updated_at on the order to bump a cache. The bogus receipt came from an admin editing the shipping address, which saved the model, which fired saved, which ran a listener that assumed "saved means the order changed state." None of that was the customer's intent. All of it was persistence.
That's the whole problem in one sentence. saved tells you a row hit the database. It does not tell you what happened in your business.
What Eloquent events actually fire on
Eloquent dispatches creating, created, updating, updated, saving, saved, deleting, deleted, and a few more. Every one of them is tied to a persistence operation on a single model. They fire because you called save(), update(), create(), or delete(), not because a business rule was satisfied.
Here is the shape most teams start with:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected static function booted(): void
{
static::updated(function (Order $order): void {
// "the order changed, email the customer"
OrderMailer::confirmation($order);
});
}
}
The listener assumes updated means "something the customer cares about changed." It doesn't. updated fires for any dirty column: a cached counter, a nightly touch(), an admin fixing a typo in the notes field. The event carries no intent. You are left inspecting $order->getChanges() and wasChanged('status') to reverse-engineer what the user meant, after the fact.
The intent is gone by the time the hook runs
Reconstructing intent from column diffs looks fine until the edge cases pile up.
static::updated(function (Order $order): void {
if ($order->wasChanged('status')
&& $order->status === 'paid') {
PaymentConfirmed::dispatch($order);
}
});
Now think about what this misses. If two things move an order to paid (a Stripe webhook and a manual admin action), both hit the same diff check, and you can't tell them apart. If the status went pending -> paid -> refunded inside one request because of a retry, wasChanged only sees the final delta against what was loaded. If someone sets status = 'paid' and then, three lines later, corrects it, the hook still fires once the transaction commits.
The model event knows the after state of the row. The business operation knows why. Those are different facts, and the second one is the one you actually want to act on.
Raise the event where the decision is made
A domain event names a thing that happened in the language of the business: OrderWasPlaced, PaymentWasConfirmed, OrderWasRefunded. It is raised by the code that made the decision, not by the ORM that stored the result.
Put the decision inside the model (as a rich aggregate) or a dedicated domain service. The event is recorded the moment the rule passes:
<?php
namespace App\Domain\Order;
final class Order
{
/** @var list<object> */
private array $recordedEvents = [];
public function __construct(
public readonly OrderId $id,
private OrderStatus $status,
) {}
public function confirmPayment(PaymentId $payment): void
{
if ($this->status !== OrderStatus::Pending) {
throw new OrderNotPayable($this->id);
}
$this->status = OrderStatus::Paid;
$this->recordThat(
new PaymentWasConfirmed($this->id, $payment)
);
}
The recording is plumbing. It stays private so callers can only add an event by going through a business method that already checked the invariant. The application layer drains the buffer once, after the save:
private function recordThat(object $event): void
{
$this->recordedEvents[] = $event;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
}
The event is a plain, immutable record of a fact. In PHP 8.4 the whole thing is a few lines:
<?php
namespace App\Domain\Order;
final readonly class PaymentWasConfirmed
{
public function __construct(
public OrderId $orderId,
public PaymentId $paymentId,
public \DateTimeImmutable $occurredAt
= new \DateTimeImmutable(),
) {}
}
Note what this event carries: the identity of what changed and the fact that it happened. No Eloquent model, no getChanges() diff, no guessing. If a payment is confirmed twice, confirmPayment() throws on the second call because the aggregate guards its own invariant. The framework hook could never do that; it runs after the row is already written.
Dispatch after the events are released, not from a save hook
The application layer coordinates the two halves: persist the aggregate, then hand its recorded events to the dispatcher.
<?php
namespace App\Application\Order;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\DB;
final readonly class ConfirmPaymentHandler
{
public function __construct(
private OrderRepository $orders,
private Dispatcher $events,
) {}
public function __invoke(ConfirmPayment $command): void
{
DB::transaction(function () use ($command) {
$order = $this->orders->get($command->orderId);
$order->confirmPayment($command->paymentId);
$this->orders->save($order);
foreach ($order->releaseEvents() as $event) {
$this->events->dispatch($event);
}
});
}
}
The domain never imports Laravel. It records events into an array. The handler is the only place that knows a framework dispatcher exists, and it's the only place that knows about the database transaction. Swap Eloquent for Doctrine, or the queue for something else, and the Order class does not change a line.
The after-commit ordering that bites everyone
Here's the subtle failure that survives even a clean domain model. In the handler above, events dispatch inside DB::transaction(). If a listener sends an email, and then a later statement in the same transaction throws, the transaction rolls back. The order is not paid. The email already went out.
Eloquent's own model events have the same trap by default. A saved listener that dispatches a queued job can push that job to Redis before the outer transaction commits. The worker picks it up in milliseconds, queries for the order, and finds the pre-transaction state, because the writing connection hasn't committed yet. You get a job that operates on data that doesn't exist yet, or never will if the transaction rolls back.
Two fixes, and you want both.
For queued listeners and jobs, Laravel exposes the after-commit switch. Set it globally per connection:
// config/database.php
'mysql' => [
// ...
'after_commit' => true,
],
Or per job, which is explicit and survives a config change:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendPaymentReceipt implements ShouldQueue
{
use Queueable;
public bool $afterCommit = true;
public function __construct(public string $orderId) {}
public function handle(): void
{
// runs only after the DB transaction commits
}
}
For synchronous domain-event dispatch, don't fire inside the transaction at all. Collect the events, commit, then dispatch. Restructure the handler so the release happens after the closure returns:
public function __invoke(ConfirmPayment $command): void
{
$order = DB::transaction(function () use ($command) {
$order = $this->orders->get($command->orderId);
$order->confirmPayment($command->paymentId);
$this->orders->save($order);
return $order;
});
foreach ($order->releaseEvents() as $event) {
$this->events->dispatch($event);
}
}
Now the sequence is fixed: the write commits, then and only then do side effects run. A rolled-back transaction releases no events, so no email, no receipt, no downstream job. The intent and the side effect stay in agreement.
If your side effect must be atomic with the write (say, you cannot tolerate a committed order with a lost outbound message), that's the point where you reach for the transactional outbox pattern: write the event to an outbox table inside the same transaction, and a separate relay publishes it after commit. That's a longer topic, but the trigger for it is exactly this ordering problem.
When Eloquent events are still the right tool
None of this means model events are useless. They are the right tool for concerns that genuinely live at the persistence layer:
- Setting a
uuidoncreating. - Touching a denormalized
search_indexcolumn onsaved. - Cascading a soft-delete of child rows on
deleting. - Invalidating a cache key tied to a specific table.
Those are persistence facts, and the persistence hook is where they belong. The mistake is using a persistence hook to model a business fact. "A row was written" and "a customer's payment was confirmed" are not the same event, and the day they diverge is the day the framework hook ships a bug.
Keep the rule simple. If a listener's logic would read differently depending on why the row changed, it wants a domain event. If it behaves the same no matter who saved the model or why, a model event is fine.
If this was useful
The whole point of raising events from the aggregate instead of the ORM hook is that your business rules stop depending on the accident of when a row hits the database. The decision, the invariant, and the fact all live in one place you own, and the framework's dispatcher becomes an adapter at the edge that you can swap or reorder without touching the domain. That boundary, and the after-commit ordering it makes explicit, is exactly what Decoupled PHP is about: keeping the concern that outlives the framework out of the framework's hooks.
Which of your Eloquent saved listeners is quietly modeling business intent right now, and what would break if someone bumped updated_at for an unrelated reason?
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)