Introduction

Eloquent fires events at key lifecycle moments — before/after save, before/after delete, on retrieval — so you can hook in without touching every call site. Observers are the tidy way to collect those hooks in one class per model instead of sprinkling closures in booted().

Key Concepts

  • Model event: A signal fired by Eloquent during a lifecycle operation (creating, created, updating, updated, deleting, deleted, saving, saved, etc.).
  • Observer: A class that declares a method per event it wants to handle.
  • #[ObservedBy]: A PHP attribute (Laravel 11+) that wires a model to its observer declaratively.
  • saveQuietly(): A save that doesn't fire events — used inside observers to avoid infinite loops.

Real World Context

Auto-generating slugs, cascading cache invalidation, stamping audit trails, sending welcome emails — all classic model-event use cases. Putting them in an observer instead of the controller means every code path that creates a Post gets the slug, not just the one you remembered.

Deep Dive

Model Events

Eloquent fires these events:

EventWhen Fired
retrievedAfter model is retrieved from database
creatingBefore model is created
createdAfter model is created
updatingBefore model is updated
updatedAfter model is updated
savingBefore model is created OR updated
savedAfter model is created OR updated
deletingBefore model is deleted
deletedAfter model is deleted
trashedAfter model is soft deleted
forceDeletingBefore force deleting
forceDeletedAfter force deleting
restoringBefore restoring soft deleted
restoredAfter restoring soft deleted
replicatingWhen model is replicated

Using Closures in Models

php
class User extends Model
{
    protected static function booted(): void
    {
        static::creating(function (User $user) {
            $user->uuid = Str::uuid();
        });

        static::created(function (User $user) {
            $user->profile()->create();
        });

        static::deleting(function (User $user) {
            $user->posts()->delete();
        });
    }
}

Creating Observers

bash
php artisan make:observer UserObserver --model=User

This creates app/Observers/UserObserver.php:

php
<?php

namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function creating(User $user): void
    {
        $user->uuid = Str::uuid();
    }

    public function created(User $user): void
    {
        $user->profile()->create(['bio' => '']);
        $user->notify(new WelcomeNotification());
    }

    public function updated(User $user): void
    {
        if ($user->isDirty('email')) {
            $user->email_verified_at = null;
            $user->saveQuietly();  // Don't trigger events
            $user->sendEmailVerificationNotification();
        }
    }

    public function deleted(User $user): void
    {
        Log::info('User deleted', ['user_id' => $user->id]);
    }

    public function forceDeleted(User $user): void
    {
        Storage::delete($user->avatar_path);
    }
}

Registering Observers

In AppServiceProvider:

php
use App\Models\User;
use App\Observers\UserObserver;

public function boot(): void
{
    User::observe(UserObserver::class);
}

Or using the attribute:

php
use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(UserObserver::class)]
class User extends Model
{
    // ...
}

Preventing Infinite Loops

Use saveQuietly() to avoid triggering events:

php
public function updated(User $user): void
{
    // This would cause infinite loop:
    // $user->update(['last_activity' => now()]);

    // Use saveQuietly instead:
    $user->last_activity = now();
    $user->saveQuietly();
}

Conditional Logic

php
public function saving(User $user): bool|void
{
    // Return false to cancel the operation
    if ($user->isBanned()) {
        return false;
    }
}

public function deleting(User $user): bool|void
{
    // Prevent deletion if user has active subscriptions
    if ($user->hasActiveSubscription()) {
        return false;
    }
}

Complete Observer Example

php
<?php

namespace App\Observers;

use App\Models\Post;
use App\Jobs\GeneratePostSlug;
use App\Jobs\NotifySubscribers;
use Illuminate\Support\Str;

class PostObserver
{
    public function creating(Post $post): void
    {
        $post->user_id ??= auth()->id();
        $post->slug ??= Str::slug($post->title);
    }

    public function created(Post $post): void
    {
        cache()->forget('posts:count');

        if ($post->published_at) {
            NotifySubscribers::dispatch($post);
        }
    }

    public function updating(Post $post): void
    {
        if ($post->isDirty('title')) {
            $post->slug = Str::slug($post->title);
        }
    }

    public function updated(Post $post): void
    {
        cache()->forget("post:{$post->id}");
        cache()->forget('posts:latest');
    }

    public function deleted(Post $post): void
    {
        cache()->forget("post:{$post->id}");
        cache()->forget('posts:count');
    }
}

Common Pitfalls

  1. Infinite loops from update() inside updated() — calling update() from an updated observer fires updating and updated again, forever. Use saveQuietly() or $model->setAttribute(...) + $model->save() with quieting to break the cycle.
  2. Observers not firing on mass updates — Model::query()->update([...]) skips Eloquent events entirely because it's a raw SQL update. If you need the events, iterate and save each model individually.

Best Practices

  1. Use #[ObservedBy] on the model — declarative, grep-able, and impossible to forget in AppServiceProvider.
  2. Return false from saving/creating/deleting to cancel — cleaner than throwing an exception when you want to veto an operation.

Summary

  • Eloquent fires lifecycle events (creating, created, updating, updated, deleting, deleted, etc.).
  • Observers collect handlers for a model in one class.
  • #[ObservedBy(SomeObserver::class)] attaches the observer declaratively.
  • Use saveQuietly() inside observers to avoid event recursion.
✓ Completed