Model Observers
Group a model's Eloquent hooks in one observer class, and know which writes dispatch no events at all.
php artisan make:observer OrderObserver --model=OrderAn observer is a class whose method names are Eloquent model events, so every hook for one model lives in one file. Create it with php artisan make:observer OrderObserver --model=Order, which writes app/Observers/OrderObserver.php with created, updated, deleted, restored and forceDeleted stubs.
Eloquent dispatches retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, trashed, forceDeleting, forceDeleted, restoring, restored and replicating. Names ending in -ing fire before the write, names ending in -ed after it.
What fires, and when
| Operation | Events, in order |
|---|---|
Order::create([...]) | saving, creating, created, saved |
$order->save() with changed attributes | saving, updating, updated, saved |
$order->save() with nothing changed | saving, saved |
$order->delete() | deleting, deleted |
Order::where(...)->update([...]) | none |
Registering, and opting out
Put #[ObservedBy([OrderObserver::class])] on the model, or call Order::observe(OrderObserver::class) in AppServiceProvider::boot. The attribute, added in Laravel 10, does exactly what the observe call does. A mass update or delete never loads the models, so saved, updated, deleting and deleted are not dispatched for them. saveQuietly(), deleteQuietly(), restoreQuietly() and Order::withoutEvents(...) skip the observer on purpose.
Example
<?php
// app/Observers/OrderObserver.php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Order;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
use Illuminate\Support\Str;
class OrderObserver implements ShouldHandleEventsAfterCommit
{
public function creating(Order $order): void
{
// Before the insert, so an attribute set here is written with it.
if ($order->reference === null) {
$order->reference = Str::upper(Str::random(10));
}
}
public function created(Order $order): void
{
// After the insert: the row exists and $order->id is populated.
}
public function updating(Order $order): void
{
if ($order->isDirty('status')) {
// isDirty() compares against the values loaded from the database.
$order->status_changed_at = now();
}
}
public function deleted(Order $order): void
{
// With SoftDeletes this runs on the soft delete; trashed() does too.
}
}When to use it
- Stamp a slug or a public reference on a record before it is inserted, without repeating the line in every controller.
- Keep a search index in step with a table by reindexing in created, updated and deleted.
- Write an audit row for every change to orders, in one file rather than scattered through the application.
- Delete a model's uploaded files when the record is force deleted.
More examples
Two ways to register the same observer
The attribute keeps the registration next to the model, which is easier to find; the observe() call is the only option when the model comes from a package you cannot edit. Do not do both for the same observer, or every hook runs twice.
<?php
// app/Models/Order.php
declare(strict_types=1);
namespace App\Models;
use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy([OrderObserver::class])]
class Order extends Model
{
//
}
<?php
// app/Providers/AppServiceProvider.php - the longhand, same effect
public function boot(): void
{
Order::observe(OrderObserver::class);
}Writes that skip the observer
This is the difference that produces bug reports: the fast mass update is also the one that fires nothing. Reach for the quiet methods in seeders and backfills, where you want the data without the side effects.
use App\Models\Order;
// Observer runs: one loaded model, one save.
$order->update(['status' => 'shipped']);
// Observer does NOT run: a query-builder update loads no models.
Order::where('status', 'pending')->update(['status' => 'shipped']);
// Same end state, observer runs per row, at the cost of one query each.
Order::where('status', 'pending')->each(function (Order $order): void {
$order->update(['status' => 'shipped']);
});
// Deliberate silence for one write:
$order->status = 'shipped';
$order->saveQuietly();
// Deliberate silence for a block:
Order::withoutEvents(function () use ($order): void {
$order->delete();
});Cancelling a write from a -ing hook
Eloquent reads the first non-null value a listener returns for a -ing event. false cancels the write; any other non-null value does not cancel it but does stop the remaining listeners for that event, so hooks that are not meant to interfere should be typed : void and return nothing.
<?php
// app/Observers/OrderObserver.php (excerpt)
public function deleting(Order $order): bool
{
// Returning false cancels the operation: delete() returns false
// and no row is removed.
return ! $order->isPaid();
}
Discussion