skip to content

For Eloquent model events, when would you choose booted() closures, an observer class, or a $dispatchesEvents map, and why?

level: middleimportance: should knowfreq 40%

answer

  1. same events, different homes
  2. static::created() inside booted()
  3. observer: many handlers, injected services
  4. $dispatchesEvents: event class gets the model
  5. queueable(); ShouldHandleEventsAfterCommit

basics

~20 s

Use booted() closures for one or two small hooks that belong to the model, an observer when there are several handlers or they need injected services, and $dispatchesEvents when the model moment should become an event class that other listeners handle.

solid answer

~40 s

All three attach listeners to the same model events; the difference is where the code lives. `booted()` closures such as `static::saving(...)` suit a single rule that is part of the model, and `queueable()` can push one to a queue worker. An observer groups many handlers in one class, is resolved from the container so it can inject services, and can implement `ShouldHandleEventsAfterCommit` so its after-events wait for the transaction to commit. `$dispatchesEvents` maps an event name to your own class, constructed with the model instance, so any number of application listeners can react to, say, `ListingPublished` without the model knowing them. None of the three sees query-level mass updates.

code

php · 23 lines
php
<?php

namespace App\Models;

use App\Events\ListingPublished;
use Illuminate\Database\Eloquent\Model;
use function Illuminate\Events\queueable;

class Listing extends Model
{
    protected $dispatchesEvents = [
        'created' => ListingPublished::class, // new ListingPublished($listing)
    ];

    protected static function booted(): void
    {
        static::deleting(fn (Listing $listing) => $listing->has_accepted_offer ? false : null);

        static::updated(queueable(function (Listing $listing) {
            // runs later on a queue worker
        }));
    }
}

go deeper

for a junior

Know the three homes for model-event code: closures in booted(), an observer class, and $dispatchesEvents event classes.

for a middle

Explain the trade-off: closures are local, observers group handlers and inject services, and $dispatchesEvents publishes a domain event to any listener.

for a senior

Show how you keep remote side effects out of the transaction with ShouldHandleEventsAfterCommit or queueable closures, and why the veto events cannot be deferred.

for a principal

Decide when a model hook should become an explicit domain event so other modules depend on a published contract instead of a model's internals.

## Three ways to hook the same lifecycle Every Eloquent model event (`creating`, `updated`, `deleted` and the rest) can be handled in three ways. They all end up as listeners on the same dispatcher, so the choice is about **where the code lives** and **who else can react**, not about which events are available. | Mechanism | Where it lives | Best for | |---|---|---| | `booted()` closures | inside the model | one or two small hooks that are part of the model's own rules | | Observer class | `app/Observers`, attached with `#[ObservedBy]` or `observe()` | several handlers for one model, or handlers that need injected services | | `$dispatchesEvents` | a property on the model mapping event names to event classes | turning a model moment into a named application event that many listeners handle | ## booted() closures The model's static `booted()` method runs once per class, the first time that model class is used in the PHP process. Inside it you call the static registrars: - `static::creating(...)`, `static::saving(...)`, `static::updated(...)`, `static::deleted(...)` and so on; - `static::restoring(...)`, `static::restored(...)`, `static::forceDeleting(...)`, `static::forceDeleted(...)` from the `SoftDeletes` trait; - `static::softDeleted(...)` for the `trashed` event, because `trashed()` is already the instance method that tells you whether a model is soft-deleted. Closures suit a single rule that reads as part of the model: generating a slug in `saving`, refusing to delete a listing with an accepted offer in `deleting`. They become awkward when there are many, or when the closure has to pull services out of the container by hand. A closure can be pushed to the queue with the `queueable()` helper from `Illuminate\Events`: `static::created(queueable(function (Listing $listing) { ... }))`. The closure is serialised and runs later on a queue worker instead of inline. ## Observer classes An observer collects the handlers into one class, with one public method per event name. Its advantages: - it is **resolved from the container**, so the constructor can inject a search-index client, a logger or a repository; - it is easy to unit-test in isolation, by constructing it and calling `updated($listing)`; - it keeps the model file focused on data rules. An observer can implement **`ShouldHandleEventsAfterCommit`**. Inside an open database transaction its after-events (`created`, `updated`, `deleted` and so on) are deferred until the commit, and dropped if the transaction rolls back. The cancellable before-events (`creating`, `updating`, `saving`, `deleting`, `restoring`, `forceDeleting`) still run immediately, because they must be able to veto the write. ## $dispatchesEvents `$dispatchesEvents` maps event names to your own event classes: ```php protected $dispatchesEvents = [ 'created' => ListingPublished::class, 'deleted' => ListingWithdrawn::class, ]; ``` When the model fires `created`, Laravel constructs `new ListingPublished($listing)`, passing the **model instance** to the constructor, and dispatches it. Any number of application listeners, including queued ones, can then react to `ListingPublished` without the model knowing about them. The mapped class is dispatched before the closure and observer listeners for that same event. Use it when the moment is a **domain event** other parts of the app care about, not just a bookkeeping side effect of this model. ## Choosing in practice 1. One tiny rule tied to the model's data: a `booted()` closure. 2. Several reactions to one model, or reactions that need services: an observer. 3. A business moment other modules subscribe to: `$dispatchesEvents` plus listeners. 4. Anything slow or remote (search indexing, webhooks): run it after commit, queued, whichever mechanism you pick. ## A worked scenario A property portal's `Listing` model might use all three at once, each for a different reason: - a `booted()` closure on `saving` that derives the URL slug from the street address, because it is a rule about the model's own data; - a `ListingObserver`, registered with `#[ObservedBy]`, that updates the search index and writes audit rows, because those two handlers share injected services and belong together; - `$dispatchesEvents` mapping `created` to `ListingPublished`, because the saved-search alerts, the agent dashboard and the sitemap are separate features that each subscribe with their own listener. The split makes each concern easy to find. A reviewer asking "what happens when a listing is created?" still has to look in three places, which is the price of spreading hooks around, so keep the list short and document it next to the model. ## What none of them change - All three fire only for **model-instance** operations. A query-level `Listing::where(...)->update(...)` bypasses every one of them. - All three are muted by `saveQuietly()` and `Model::withoutEvents()`. - A `false` returned from a before-event handler cancels the write whichever mechanism registered it.

  • An observer implements ShouldHandleEventsAfterCommit and the save happens inside a transaction. When do its creating and created methods run?
    `creating` runs immediately, before the INSERT, because the before-events that can veto a write are exempt from deferral. `created` is registered as an after-commit callback: it runs when the outermost transaction commits and is discarded if the transaction rolls back. Outside any transaction both run inline.
  • Why is the trashed event registered with static::softDeleted() rather than static::trashed() in a booted() closure?
    The `SoftDeletes` trait already defines an instance method `trashed()` that returns whether the model is soft-deleted, so the closure registrar for the `trashed` event is named `softDeleted()`. Observers are unaffected: an observer method named `trashed` still handles the event.
  • What does a listener for an event class mapped in $dispatchesEvents receive?
    Laravel constructs the mapped class with the model instance as its only constructor argument and dispatches that object. The listener receives the event object and reads the model from whatever property the event class stored it in, for example `$event->listing`.

It is like announcing a sold house: a sticky note on the listing file (booted closure) reminds whoever holds the file, a named assistant (observer) handles every task for that file with their own tools, and a public announcement (dispatchesEvents) lets any office that cares respond.

saying these in an interview costs you the question

  • $dispatchesEvents is required before observers can receive events
  • booted() closures run once per model instance rather than once per class
  • Observers are queued by default and never run inline
  • ShouldHandleEventsAfterCommit also delays creating and updating handlers
  • Only observers, not closures, can cancel a save by returning false