📁 Volume II: Laravel Performance Tuning Kit

👀 Topic 24: Observers & withoutEvents

The silent performance killers you never see coming.

"You write a simple User::create()...
But behind the scenes, Laravel fires 5 events, runs 3 observers, and sends 2 emails.
Your 5ms insert becomes 500ms.
And you have no idea why."
⚠️ THE SILENT PERFORMANCE KILLER

Eloquent observers and events are powerful — they keep your code clean and decoupled. But they come at a cost. Every model operation (save, update, delete, create) triggers a cascade of events and observers. For bulk operations, this can be catastrophic. A 1,000-row update becomes 1,000 individual updates, each with its own event chain.

🔴 The Problem: Every Operation Triggers Observers

WHAT HAPPENS WHEN YOU CALL $user->update() ═══════════════════════════════════════════════════════════════════ User::find(1)->update(['name' => 'New Name']) │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 1. Model Event: updating (before DB) │ │ └── UserObserver::updating() ← Your code runs here │ │ │ │ 2. Database UPDATE (actual query) │ │ │ │ 3. Model Event: updated (after DB) │ │ └── UserObserver::updated() ← Your code runs here │ │ │ │ 4. Model Event: saved (after any save) │ │ └── UserObserver::saved() ← Your code runs here │ │ │ │ 5. If any attributes changed: │ │ └── Model Event: dirty (when attributes change) │ │ │ │ 6. If using traits (SoftDeletes, HasEvents, etc.): │ │ └── Additional internal events │ └─────────────────────────────────────────────────────────────────┘ FOR 1,000 UPDATES (BULK OPERATION): ═══════════════════════════════════════════════════════════════════ User::where('role', 'guest')->update(['role' => 'member']); Without observers: 1 query → 5ms With observers: 1,000 queries + 1,000 observer chains → 5,000ms (5 seconds!) The database query is fast. The observers kill you.
THE OBSERVER SINS

📊 The Real Cost of Observers

Operation Without Observers With Observers Overhead
Single User::create() 2ms 10-50ms 5-25x slower
Bulk update (1,000 rows) 5ms (1 query) 5,000ms (1,000 queries + observers) 1,000x slower
Bulk insert (1,000 rows) 10ms (batch insert) 10,000ms (1,000 inserts + observers) 1,000x slower
Bulk delete (10,000 rows) 15ms (one query) 15,000ms + (N queries) 1,000x slower
THE BOTTOM LINE

Observers are great for single-record operations but catastrophic for bulk operations. A bulk update that should take 5ms takes 5 seconds — all because each record triggers its own observer chain.

✅ The Solution: Model::withoutEvents()

THE GOLDEN RULE OF BULK OPERATIONS

Always wrap bulk operations in withoutEvents() to disable observers and events.

👎 BAD (Triggers all observers)

// This triggers UserObserver::updating()
// for EVERY single row!
User::where('role', 'guest')
    ->update(['role' => 'member']);

// Each row: SQL query + observer chain
// 1,000 rows = 1,000 queries + 1,000 observer chains
// Time: 5+ seconds

👍 GOOD (No observers)

// No observers triggered!
User::withoutEvents(function () {
    User::where('role', 'guest')
        ->update(['role' => 'member']);
});

// One SQL query. Zero observer overhead.
// Time: 5ms
SINGLE RECORD OPERATIONS WITH withoutEvents()
// For single record, observers are fine.
// But if you want to skip them for specific cases:

$user = User::find(1);

$user->withoutEvents(function () use ($user) {
    $user->update(['name' => 'New Name']);
    // No events fired
});

// Or use the instance method
$user->updateQuietly(['name' => 'New Name']);
// Laravel 9+ has updateQuietly() specifically for this

📦 Bulk Insert: The Ultimate Performance Killer

👎 BAD (N inserts = N observer chains)

// Insert 10,000 users
foreach ($newUsers as $userData) {
    User::create($userData);
    // Each create triggers:
    // - saving event
    // - creating event
    // - created event
    // - saved event
    // All observers run 10,000 times!
}
// Time: 30+ seconds

👍 GOOD (Batch insert, no events)

// Batch insert without events
User::withoutEvents(function () use ($newUsers) {
    User::insert($newUsers);
    // One SQL INSERT with 10,000 rows
    // Zero observers
    // Zero events
});
// Time: 0.5 seconds

// Or use insertOrIgnore for duplicates
User::withoutEvents(function () use ($newUsers) {
    User::insertOrIgnore($newUsers);
});
PERFORMANCE GAIN

Bulk insert with withoutEvents() is 100x faster than individual creates with observers. 30 seconds → 0.3 seconds.

🗑️ Bulk Delete: The Hidden Danger

THE SOFT DELETE TRAP

Even worse than updates: deleting with SoftDeletes trait triggers events on EVERY row.

👎 BAD (Deletes 1,000 rows individually)

// Each delete triggers:
// - deleting event
// - deleted event
// - SoftDeletes updates deleted_at
User::where('inactive', true)
    ->get()
    ->each->delete();
// Each row: 1 query + observer chain
// 1,000 rows = 1,000 queries
// Time: 5+ seconds

👍 GOOD (One query, no events)

User::withoutEvents(function () {
    User::where('inactive', true)
        ->delete();  // One DELETE query
    // No observer chains
});
// Time: 5ms

📤 Move Observer Logic to Queues (The Real Fix)

OBSERVER WITH QUEUE (RECOMMENDED) ═══════════════════════════════════════════════════════════════════ BEFORE (Synchronous - SLOW): ┌─────────────────────────────────────────────────────────────────┐ │ UserObserver::created($user) { │ │ Mail::to($user)->send(new WelcomeMail()); // 150ms │ │ Stats::increment('users'); // 10ms │ │ Webhook::call($user); // 200ms │ │ } │ │ │ │ User::create($data); // TOTAL: 360ms │ └─────────────────────────────────────────────────────────────────┘ AFTER (Asynchronous - FAST): ┌─────────────────────────────────────────────────────────────────┐ │ UserObserver::created($user) { │ │ dispatch(new SendWelcomeMailJob($user->id)); // 1ms │ │ dispatch(new UpdateStatsJob($user->id)); // 1ms │ │ dispatch(new CallWebhookJob($user->id)); // 1ms │ │ } │ │ │ │ User::create($data); // TOTAL: 3ms + queue processing │ │ User response: 3ms (instead of 360ms) │ └─────────────────────────────────────────────────────────────────┘
IMPLEMENTATION
// app/Observers/UserObserver.php
class UserObserver
{
    public function created(User $user): void
    {
        // DON'T do this (synchronous)
        // Mail::to($user)->send(new WelcomeMail($user));
        
        // DO this (asynchronous)
        SendWelcomeMailJob::dispatch($user->id);
        UpdateStatsJob::dispatch($user->id);
        SyncCrmJob::dispatch($user->id);
    }
}

// app/Jobs/SendWelcomeMailJob.php
class SendWelcomeMailJob implements ShouldQueue
{
    public function __construct(public int $userId) {}
    
    public function handle(): void
    {
        $user = User::find($this->userId);
        Mail::to($user)->send(new WelcomeMail($user));
    }
}

🔇 Disabling Specific Observers

METHOD 1: withoutEvents() (Disables ALL)
User::withoutEvents(function () {
    User::create([...]);
});
METHOD 2: unsetEventDispatcher() (Disables ALL for model)
$user = new User();
$user->unsetEventDispatcher();  // Disable events for this instance only
$user->save();  // No events fired
METHOD 3: Conditional observers (Laravel 8.40+)
// app/Providers/EventServiceProvider.php
protected $observers = [
    User::class => [
        UserObserver::class,
    ],
];

// UserObserver.php
public function shouldHandle(User $user): bool
{
    // Skip observers for bulk operations
    return !$user->withoutEvents ?? false;
}
⚠️ WARNING: withoutEvents() vs updateQuietly()

withoutEvents() wraps a closure and disables all events.
updateQuietly() is an instance method that skips events for that specific update (Laravel 9+).

$user->updateQuietly(['name' => 'New Name']);  // No events
$user->deleteQuietly();  // No events (Laravel 9+)

📋 Observer Best Practices

Situation Recommendation
Single record operations Observers are fine (but move heavy work to queues)
Bulk updates (multiple records) Use withoutEvents() to disable observers
Batch insert (many new records) Use insert() with withoutEvents()
Batch delete Use delete() on query builder with withoutEvents()
Email sending in observers ❌ DON'T — use queued jobs instead
External API calls in observers ❌ DON'T — use queued jobs instead
Logging in observers ✅ OK if minimal, but batch logs
📌 THE RULE: Observers are for cross-cutting concerns, not for heavy work. Move email, API calls, and complex logic to queued jobs. Always use withoutEvents() for bulk operations.

📝 Topic 24 Summary: Observers & withoutEvents

Operation Type Without withoutEvents() With withoutEvents() Gain
Bulk update (1,000 rows) 5+ seconds 5ms 1,000x faster
Batch insert (1,000 rows) 30+ seconds 300ms 100x faster
Batch delete (10,000 rows) 15+ seconds 15ms 1,000x faster
📌 THE RULE: Observers are powerful but dangerous. Use them for single-record operations only. For bulk operations, always wrap in withoutEvents(). For email/API calls, move to queued jobs. Your response times will drop from seconds to milliseconds.
NEXT TOPIC PREVIEW

Topic 25: Logging I/O Trap — Every Log::info() opens a file. With 10,000 logs per request, your I/O dies. How to batch logs and save disk I/O.