Observers and Domain Events — Decoupling Your Application

Observers and Domain Events — Decoupling Your Application

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 12 of 35 | Level: Intermediate | Prerequisites: Model TraitsAction Classes


What You’ll Learn

  • The difference between Observers and Events/Listeners
  • When to use each
  • How to write clean, focused Observers
  • How to fire and listen to domain events
  • How to queue event listeners for background processing

Observers vs. Events — The Right Tool for the Right Job

Both Observers and Events let you react to things that happen in your application. The difference is where and when they’re most useful:

ObserversEvents / Listeners
Triggered byEloquent model lifecycle (created, updated, deleted)Explicitly fired from code
CouplingLow — model doesn’t know about side effectsLow — the event is the only contract
Use forAuto-setting fields, cache invalidation, simple notificationsComplex workflows, queued processing, decoupled reactions
ExampleSet slug on creating, clear cache on updatedOrderPlaced → send email, update inventory, notify Slack

Rule of thumb: Observers react to model changes. Events communicate that something business-significant happened.


Observers

Registering Observers

Register observers in AppServiceProvider::boot():

// AppServiceProvider.php
public function boot(): void
{
    Order::observe(OrderObserver::class);
    User::observe(UserObserver::class);
    Media::observe(MediaObserver::class);
    WarrantyClaim::observe(WarrantyClaimObserver::class);
}

Or on the model directly:

// config/app.php or the Model::observe() inline is fine

A Clean Observer

// app/Observers/OrderObserver.php
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;

class OrderObserver
{
    public function created(Order $order): void
    {
        // Clear dashboard cache when new order arrives
        Cache::forget('admin.dashboard.orders');
        Cache::forget('admin.dashboard.orders.monthly.' . now()->year);
    }

    public function updated(Order $order): void
    {
        if ($order->wasChanged('status')) {
            // Clear relevant caches
            Cache::forget('admin.dashboard.orders');
        }
    }

    public function deleted(Order $order): void
    {
        // Clean up associated media files
        $order->media()->each(function ($media): void {
            Storage::delete($media->path);
        });
    }
}

Notice what this Observer does NOT do:

  • No business logic
  • No sending emails (that belongs in an Event/Listener)
  • No complex computations

Observers should be fast and focused. Cache invalidation, setting default values, cleanup — these are observer concerns.

The EquipmentObserver Pattern — Generating References

From huntvedealer, an observer that auto-generates unique reference numbers:

// app/Observers/EquipmentObserver.php
class EquipmentObserver
{
    public function creating(Equipment $equipment): void
    {
        if (empty($equipment->reference_number)) {
            $equipment->reference_number = $this->generateReference();
        }
    }

    private function generateReference(): string
    {
        do {
            $ref = 'EQ-' . strtoupper(Str::random(8));
        } while (Equipment::where('reference_number', $ref)->exists());

        return $ref;
    }
}

The model doesn’t know this is happening. Any code that creates equipment automatically gets a reference number.

The MediaObserver Pattern — Cleanup on Delete

class MediaObserver
{
    public function deleted(Media $media): void
    {
        if (Storage::exists($media->path)) {
            Storage::delete($media->path);
        }

        // Delete thumbnails
        $media->thumbnails()->each(function (MediaThumbnail $thumbnail): void {
            Storage::delete($thumbnail->path);
            $thumbnail->delete();
        });
    }
}

Domain Events

Domain events communicate that something business-significant happened. They decouple the code that causes an event from the code that reacts to it.

Defining an Event

// app/Events/OrderPlaced.php
<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPlaced
{
    use Dispatchable;
    use SerializesModels;

    public function __construct(
        public readonly Order $order
    ) {}
}

Firing an Event

Fire events after a transaction commits, from an Action:

final class CreateOrderAction
{
    public function execute(User $user, OrderCustomerData $data): Order
    {
        $order = DB::transaction(function () use ($user, $data): Order {
            // ... create order, attach items
            return $order;
        });

        // AFTER the transaction — data is committed
        event(new OrderPlaced($order));

        return $order;
    }
}

Defining a Listener

// app/Listeners/SendOrderConfirmation.php
<?php

declare(strict_types=1);

namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Mail\OrderConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation implements ShouldQueue
{
    public string $queue = 'notifications';

    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->email)
            ->send(new OrderConfirmationMail($event->order));
    }

    /**
     * Handle a failed listener job.
     */
    public function failed(OrderPlaced $event, \Throwable $exception): void
    {
        logger()->error('Failed to send order confirmation', [
            'order_id' => $event->order->id,
            'error'    => $exception->getMessage(),
        ]);
    }
}

Implementing ShouldQueue means the listener runs in the background via the queue — the HTTP response doesn’t wait for the email to send.

Registering Listeners

In EventServiceProvider (Laravel 10 and below):

// app/Providers/EventServiceProvider.php
protected $listen = [
    OrderPlaced::class => [
        SendOrderConfirmation::class,
        UpdateInventoryForOrder::class,
        NotifyAdminOfNewOrder::class,
    ],

    ServiceRequestRejected::class => [
        SendRejectionNotifications::class,
    ],

    EnrolmentPaid::class => [
        GenerateInvoice::class,
        SendEnrolmentConfirmation::class,
        UpdatePartnerCommission::class,
    ],
];

In Laravel 11+ (bootstrap/app.php):

->withEvents(discover: [
    __DIR__.'/../app/Listeners',
])

Or use #[ListensTo] attribute on the listener class (Laravel 11+):

#[ListensTo(OrderPlaced::class)]
class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void { ... }
}

Mail Event Logging

One recurring pattern across the projects: log every mail send attempt for auditability.

// app/Listeners/LogMailSending.php
class LogMailSending
{
    public function handle(MessageSending $event): void
    {
        Log::channel('mail')->info('Sending mail', [
            'to'      => $event->message->getTo(),
            'subject' => $event->message->getSubject(),
        ]);
    }
}

// app/Listeners/LogMailSent.php
class LogMailSent
{
    public function handle(MessageSent $event): void
    {
        Log::channel('mail')->info('Mail sent successfully', [
            'to'      => $event->message->getTo(),
            'subject' => $event->message->getSubject(),
        ]);
    }
}

Wire these to Laravel’s built-in mail events in EventServiceProvider:

\Illuminate\Mail\Events\MessageSending::class => [LogMailSending::class],
\Illuminate\Mail\Events\MessageSent::class    => [LogMailSent::class],

Now every email is recorded in logs/mail.log — useful for debugging and for compliance.


Key Takeaways

  • Observers react to model lifecycle events (created, updated, deleted). Use them for auto-setting fields, cache invalidation, and cleanup.
  • Domain Events communicate that something business-significant happened. Use them for complex, potentially asynchronous reactions.
  • Always fire domain events after a transaction commits — not inside it.
  • Implement ShouldQueue on listeners for anything that sends emails, calls external APIs, or takes more than a few milliseconds.
  • Log mail sends with Laravel’s built-in MessageSending / MessageSent events — it’s a free audit trail.
  • Register observers in AppServiceProvider::boot() for a single, scannable list.


Frequently Asked Questions

When should I use an Observer instead of an Event? Use an Observer for model lifecycle hooks — audit trails, cascade deletes, sending a welcome email when a user registers. Use an Event + Listener for business domain events (OrderPlacedPaymentFailed) that multiple parts of the system need to react to independently. Rule of thumb: if the action is triggered by model state change, Observer; if the action is something that happened in the business, Event.

Can Observers dispatch domain Events? Yes, and this is often the cleanest combination. The Observer’s created() hook fires event(new UserCreated($user)), then independent Listeners handle sending the welcome email, notifying Slack, and creating the billing record. The Observer connects the model lifecycle to the event bus; Listeners implement the consequences.

Are Observers queued automatically? No. Observers run synchronously during the request. If an Observer method is slow (hitting an external API), extract the slow work into a queued Listener triggered by an event the Observer fires, rather than blocking the request.


Tips and Gotchas

⚠️ Warning: Observers run synchronously inside the current request. Heavy work in updated() (like sending emails, calling APIs, resizing images) will slow down every save. Move anything slow to a queued listener on an Eloquent event instead.

💡 Tip: Observers and events both respond to model lifecycle changes — the difference is audience. Observers are for infrastructure side effects (cache busting, audit logs, cleanup). Events are for business domain reactions (sending confirmation, triggering workflows). Use both; don’t conflate them.

🔥 Expert Note: ShouldDispatchAfterResponse on a listener fires it after the HTTP response is sent to the browser — the user isn’t waiting for it. Use this for non-critical notifications and logging that shouldn’t block the response.

Further Reading


← Model Traits | Next: Model Change Logger →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.