Model Events & Observers
Hook into the lifecycle — creating, updated, deleting — without cluttering your controllers.
Every Eloquent model fires events as it moves through its life: creating, created, updating, updated, saving, saved, deleting, deleted, restored, and more. Listening to these is how you keep side effects (slugs, audit logs, cache busting) out of your business code.
Where to put the logic
- Closures on the model's
booted()— fine for one small thing. - An Observer class — the clean home once you have several hooks. One class, one responsibility.
// Small and local
protected static function booted()
{
static::creating(function (Post $post) {
$post->slug ??= \Illuminate\Support\Str::slug($post->title);
});
}The gotchas that bite people
The *ing events fire before the write and can cancel it by returning false. The *ed events fire after. And crucially: bulk operations like Post::where(...)->update([...]) or truncate() do not fire model events at all, because they never hydrate a model.
Example
<?php
namespace App\Observers;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderObserver
{
public function creating(Order $order): void
{
// Runs before INSERT; can mutate the model or return false to cancel.
$order->reference ??= 'ORD-' . strtoupper(\Illuminate\Support\Str::random(10));
}
public function updated(Order $order): void
{
// Only react to a real status change, not every save.
if ($order->wasChanged('status') && $order->status === 'paid') {
// Defer side effects until the surrounding transaction commits.
DB::afterCommit(function () use ($order) {
\App\Jobs\SendReceipt::dispatch($order);
});
}
}
public function deleting(Order $order): void
{
if ($order->status === 'shipped') {
throw new \DomainException('Cannot delete a shipped order.');
}
}
}
// --- Registering via attribute (Laravel 11+/12) ---
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
{
// Remember: Order::where(...)->update([...]) will NOT fire these hooks.
}When to use it
- A PostObserver's creating() hook automatically generates a URL slug from the title before any new Post row is inserted, without cluttering the controller.
- An OrderObserver's updated() method fires a webhook notification when an order's status changes to 'shipped', keeping the controller free of notification logic.
- A UserObserver's deleted() hook removes the user's S3 avatar file when the account is deleted, ensuring storage is not orphaned.
More examples
Generate and register an observer
Artisan generates an observer class pre-bound to the Post model with empty hooks for creating, created, updating, updated, deleting, and deleted.
php artisan make:observer PostObserver --model=Post
# Creates app/Observers/PostObserver.phpAuto-generate a slug in creating()
The creating() hook fires before INSERT, letting the observer set the slug without any controller involvement; deleting() cascades cleanup.
class PostObserver
{
public function creating(Post $post): void
{
if (empty($post->slug)) {
$post->slug = Str::slug($post->title);
}
}
public function deleting(Post $post): void
{
// Clean up related media files
$post->media()->delete();
}
}Register the observer in a service provider
Observers are wired up in AppServiceProvider::boot(), or with the #[ObservedBy] attribute on the model in Laravel 10+.
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Post::observe(PostObserver::class);
Order::observe(OrderObserver::class);
}
// Or register via the model itself (Laravel 10+)
#[ObservedBy(PostObserver::class)]
class Post extends Model {}
Discussion