Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 091 / 100

Eloquent observers: a PHP & Laravel guide for developers

React to model lifecycle events.

Expona Admin6 min read

Introduction

By day 91 in the 100-day PHP & Laravel Development series, you've likely built models with business logic scattered across controllers and service classes. Eloquent observers offer a cleaner way to react to model lifecycle events without coupling your code to request handlers. This guide covers Eloquent observers: a PHP & Laravel guide for developers, showing how to leverage created, updated, and deleted hooks for audit logs, cache busting, and maintaining separation of concerns.

What are Eloquent observers?

An Eloquent observer is a class that listens to lifecycle events on your models. When you save, update, or delete a model, Laravel fires events that the observer catches and responds to. This pattern keeps your model class focused on data representation and validation, while business logic triggered by state changes lives in a dedicated observer.

The main hooks are:

  • creating – before a new record is inserted
  • created – after a new record is inserted
  • updating – before an existing record is updated
  • updated – after an existing record is updated
  • deleting – before a record is deleted
  • deleted – after a record is deleted
  • restoring and restored – for soft deletes

You generate an observer using php artisan make:observer, then register it in your AppServiceProvider. The observer handles logic that should run whenever the model transitions through its lifecycle.

Created, updated, deleted hooks in action

Let's say you're building an e-commerce platform where changes to product inventory need to trigger notifications, update search indices, and sync with a fulfillment service. Without observers, you'd sprinkle this logic across create, update, and delete endpoints. With observers, you centralize it.

First, generate the observer:

php
php artisan make:observer ProductObserver --model=Product

This creates app/Observers/ProductObserver.php:

php
id}");
        $this->searchIndex->index($product);
        $this->fulfillment->registerProduct($product);
    }

    public function updated(Product $product): void
    {
        Log::info("Product updated: {$product->id}");
        
        // Only reindex if price or name changed
        if ($product->isDirty(['price', 'name', 'description'])) {
            $this->searchIndex->reindex($product);
        }
        
        // Sync stock changes with fulfillment
        if ($product->isDirty('stock')) {
            $this->fulfillment->updateStock($product->id, $product->stock);
        }
    }

    public function deleted(Product $product): void
    {
        Log::info("Product deleted: {$product->id}");
        $this->searchIndex->remove($product->id);
        $this->fulfillment->deregisterProduct($product->id);
    }
}

Register it in app/Providers/AppServiceProvider.php:

php
namespace App\Providers;

use App\Models\Product;
use App\Observers\ProductObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Product::observe(ProductObserver::class);
    }
}

Now any code that creates, updates, or deletes a product—whether from a controller, a command, or an API—automatically triggers the observer. No duplication, no forgotten side effects.

Audit logs and tracking changes

A common use case is building an audit trail. You want to record who changed what, when, and why. Observers make this straightforward.

Create a simple AuditLog model:

php
Schema::create('audit_logs', function (Blueprint $table) {
    $table->id();
    $table->string('model_type');
    $table->unsignedBigInteger('model_id');
    $table->string('action'); // created, updated, deleted
    $table->json('old_values')->nullable();
    $table->json('new_values')->nullable();
    $table->unsignedBigInteger('user_id')->nullable();
    $table->string('ip_address')->nullable();
    $table->timestamps();
});

Then in your observer, use getChanges() to capture what changed:

php
public function updated(Product $product): void
{
    AuditLog::create([
        'model_type' => Product::class,
        'model_id' => $product->id,
        'action' => 'updated',
        'old_values' => $product->getOriginal(),
        'new_values' => $product->getChanges(),
        'user_id' => auth()->id(),
        'ip_address' => request()->ip(),
    ]);
}

For sensitive fields, filter them out before logging. For policy tracking, observers give you a single source of truth about model state changes.

Cache busting and invalidation

Your API caches product lists in Redis. When a product is updated, that cache stales. Observers let you invalidate it automatically:

php
use Illuminate\Support\Facades\Cache;

public function updated(Product $product): void
{
    Cache::forget('products.all');
    Cache::forget("products.category.{$product->category_id}");
    Cache::forget("product.{$product->id}");
}

public function deleted(Product $product): void
{
    Cache::forget('products.all');
    Cache::forget("products.category.{$product->category_id}");
}

This is far cleaner than invalidating cache in multiple endpoints. Every change, everywhere, triggers the right cache invalidation. You avoid the race condition where one endpoint forgets to bust the cache.

Avoiding hidden business logic in observers

Observers are powerful, but they can become a dumping ground for logic that's hard to reason about. A junior developer adding a feature won't immediately see that deleting a product also sends an email to 500 customers.

Keep these principles in mind:

  • Observers should handle side effects, not core business logic. If deleting a product should fail under certain conditions (e.g., active orders), put that validation in the model or a service, not the observer.
  • Keep observer methods focused and short. If an observer method does more than three things, extract it to a service or job.
  • Document what the observer does. Add a docblock or inline comment explaining why this side effect matters.
  • Make side effects visible in tests. When you test model creation, assert that the observer fired. This makes the implicit explicit.

A bad pattern:

php
public function deleted(Order $order): void
{
    // Deleting an order should fail if it shipped
    if ($order->status === 'shipped') {
        throw new Exception('Cannot delete shipped order');
    }
    
    // ... more validation
}

A good pattern:

php
public function deleted(Order $order): void
{
    // Notify customers, clean up logs, etc.
    Notification::send($order->customer, new OrderCancelled($order));
    $this->auditLog->record($order, 'deleted');
}

Validation belongs in the model or a dedicated policy class, not the observer. Observers handle reactions, not rules.

Muting observers during bulk imports

You're importing 50,000 products from a vendor API. If each product creation triggers search indexing, fulfillment sync, and audit logging, the import takes hours. Use withoutEvents() or observer muting to skip them temporarily:

php
use Illuminate\Database\Events\StatementPrepared;

public function handle()
{
    // Option 1: Mute events for a block
    Product::withoutEvents(function () {
        foreach ($this->vendorProducts() as $item) {
            Product::create($item);
        }
    });
    
    // Rebuild once after import completes
    $this->searchIndex->rebuildAll();
    $this->fulfillment->syncAll();
}

Or disable specific observers:

php
public function handle()
{
    Product::unsetEventDispatcher();
    
    foreach ($this->vendorProducts() as $item) {
        Product::create($item);
    }
    
    Product::setEventDispatcher($this->dispatcher);
    
    // Rebuild after bulk operation
    $this->searchIndex->rebuildAll();
}

This is crucial for performance. Without muting, a bulk import can go from minutes to hours. Always rebuild external state (cache, search index, etc.) once the import completes.

Common mistakes

Creating infinite loops: If an observer updates the model it's observing, and that update fires the observer again, you get a loop. Use wasRecentlyCreated or isDirty() to avoid re-triggering on your own changes.

Hiding runtime errors: If observer code throws an exception, the model save fails silently in some contexts. Test your observers thoroughly and log failures.

Coupling observers to request context: Don't assume auth()->user() or request() exist. Observers run in queued jobs, commands, and other non-HTTP contexts. Pass dependencies explicitly.

Forgetting to register: You create an observer but don't call Model::observe() in a service provider. The observer never fires. Double-check the registration.

Key takeaways

  • Observers centralize lifecycle reactions—created, updated, deleted hooks—away from your controllers and endpoints.
  • Use them for audit logs, cache busting, external API syncs, and notifications. Keep them focused on side effects, not core business logic.
  • Register observers in a service provider's boot() method so they're wired up consistently.
  • For bulk operations, mute observers with withoutEvents() to avoid performance hits, then rebuild external state afterward.
  • Test that observers fire correctly. Make side effects explicit in your test suite so future developers understand the full lifecycle.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles