{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiczk3lcfnhoveq76xwijimm2pgn3wd5ohqpft3rz5pvdmo4gjioau",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpozqtvrn5i2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiehjgbhwa73wmw3tlft27fr4z7mqtlb7vspe7gwzrt7pqm6f6pige"
},
"mimeType": "image/webp",
"size": 91850
},
"path": "/gabrielanhaia/eloquent-events-vs-domain-events-why-the-framework-hook-isnt-enough-3mbg",
"publishedAt": "2026-07-02T21:47:04.000Z",
"site": "https://dev.to",
"tags": [
"php",
"laravel",
"architecture",
"ddd",
"Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework",
"Complete Guide to Go Programming",
"Hexagonal Architecture in Go",
"Hermes IDE",
"GitHub",
"xgabriel.com",
"@var",
"@return"
],
"textContent": " * **Book:** Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework\n * **Also by me:** _Thinking in Go_ (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go\n * **My project:** Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools\n * **Me:** xgabriel.com | GitHub\n\n\n\nYou 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.\n\nYou 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.\n\nThat'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.\n\n## What Eloquent events actually fire on\n\nEloquent 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.\n\nHere is the shape most teams start with:\n\n\n\n <?php\n\n namespace App\\Models;\n\n use Illuminate\\Database\\Eloquent\\Model;\n\n class Order extends Model\n {\n protected static function booted(): void\n {\n static::updated(function (Order $order): void {\n // \"the order changed, email the customer\"\n OrderMailer::confirmation($order);\n });\n }\n }\n\n\nThe 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.\n\n## The intent is gone by the time the hook runs\n\nReconstructing intent from column diffs looks fine until the edge cases pile up.\n\n\n\n static::updated(function (Order $order): void {\n if ($order->wasChanged('status')\n && $order->status === 'paid') {\n PaymentConfirmed::dispatch($order);\n }\n });\n\n\nNow 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.\n\nThe 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.\n\n## Raise the event where the decision is made\n\nA 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.\n\nPut the decision inside the model (as a rich aggregate) or a dedicated domain service. The event is recorded the moment the rule passes:\n\n\n\n <?php\n\n namespace App\\Domain\\Order;\n\n final class Order\n {\n /** @var list<object> */\n private array $recordedEvents = [];\n\n public function __construct(\n public readonly OrderId $id,\n private OrderStatus $status,\n ) {}\n\n public function confirmPayment(PaymentId $payment): void\n {\n if ($this->status !== OrderStatus::Pending) {\n throw new OrderNotPayable($this->id);\n }\n\n $this->status = OrderStatus::Paid;\n\n $this->recordThat(\n new PaymentWasConfirmed($this->id, $payment)\n );\n }\n\n\nThe 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:\n\n\n\n private function recordThat(object $event): void\n {\n $this->recordedEvents[] = $event;\n }\n\n /** @return list<object> */\n public function releaseEvents(): array\n {\n $events = $this->recordedEvents;\n $this->recordedEvents = [];\n\n return $events;\n }\n }\n\n\nThe event is a plain, immutable record of a fact. In PHP 8.4 the whole thing is a few lines:\n\n\n\n <?php\n\n namespace App\\Domain\\Order;\n\n final readonly class PaymentWasConfirmed\n {\n public function __construct(\n public OrderId $orderId,\n public PaymentId $paymentId,\n public \\DateTimeImmutable $occurredAt\n = new \\DateTimeImmutable(),\n ) {}\n }\n\n\nNote 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.\n\n## Dispatch after the events are released, not from a save hook\n\nThe application layer coordinates the two halves: persist the aggregate, then hand its recorded events to the dispatcher.\n\n\n\n <?php\n\n namespace App\\Application\\Order;\n\n use Illuminate\\Contracts\\Events\\Dispatcher;\n use Illuminate\\Support\\Facades\\DB;\n\n final readonly class ConfirmPaymentHandler\n {\n public function __construct(\n private OrderRepository $orders,\n private Dispatcher $events,\n ) {}\n\n public function __invoke(ConfirmPayment $command): void\n {\n DB::transaction(function () use ($command) {\n $order = $this->orders->get($command->orderId);\n\n $order->confirmPayment($command->paymentId);\n\n $this->orders->save($order);\n\n foreach ($order->releaseEvents() as $event) {\n $this->events->dispatch($event);\n }\n });\n }\n }\n\n\nThe 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.\n\n## The after-commit ordering that bites everyone\n\nHere'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.\n\nEloquent'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.\n\nTwo fixes, and you want both.\n\nFor queued listeners and jobs, Laravel exposes the after-commit switch. Set it globally per connection:\n\n\n\n // config/database.php\n 'mysql' => [\n // ...\n 'after_commit' => true,\n ],\n\n\nOr per job, which is explicit and survives a config change:\n\n\n\n <?php\n\n namespace App\\Jobs;\n\n use Illuminate\\Contracts\\Queue\\ShouldQueue;\n use Illuminate\\Foundation\\Queue\\Queueable;\n\n class SendPaymentReceipt implements ShouldQueue\n {\n use Queueable;\n\n public bool $afterCommit = true;\n\n public function __construct(public string $orderId) {}\n\n public function handle(): void\n {\n // runs only after the DB transaction commits\n }\n }\n\n\nFor 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:\n\n\n\n public function __invoke(ConfirmPayment $command): void\n {\n $order = DB::transaction(function () use ($command) {\n $order = $this->orders->get($command->orderId);\n $order->confirmPayment($command->paymentId);\n $this->orders->save($order);\n\n return $order;\n });\n\n foreach ($order->releaseEvents() as $event) {\n $this->events->dispatch($event);\n }\n }\n\n\nNow 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.\n\nIf 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.\n\n## When Eloquent events are still the right tool\n\nNone of this means model events are useless. They are the right tool for concerns that genuinely live at the persistence layer:\n\n * Setting a `uuid` on `creating`.\n * Touching a denormalized `search_index` column on `saved`.\n * Cascading a soft-delete of child rows on `deleting`.\n * Invalidating a cache key tied to a specific table.\n\n\n\nThose 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.\n\nKeep 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.\n\n## If this was useful\n\nThe 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.\n\nWhich 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?\n\n_Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon._",
"title": "Eloquent Events vs Domain Events: Why the Framework Hook Isn't Enough"
}