Tech Verse Logo
Enable dark mode
Laravel Events: When to Use Them and When They Obscure Flow

Laravel Events: When to Use Them and When They Obscure Flow

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

The Hidden Cost of Event Spaghetti

Three weeks ago I spent an entire afternoon tracking down why an administrative user's permission set failed to update after a subscription renewal. The trace was a maze: a controller dispatched a subscription event, which triggered a synchronous listener, which saved an Eloquent model, which fired a model observer, which dispatched another event whose listener silently returned early because of an unhandled null check. The code wasn't decoupled. It was just obscured.

Laravel events and listeners are among the framework's most popular features, but developers frequently use synchronous events as hidden GOTO statements. Firing a synchronous event doesn't make your architecture clean if step B is strictly required immediately after step A. It just breaks your code editor's "Go to Definition" feature and makes the call stack impossible to follow during a late-night incident.

In Laravel 12 running on PHP 8.3, understanding when to use synchronous events, queued listeners, or broadcast events comes down to understanding execution boundaries and failure domains.

Synchronous Events: Stop Hiding Direct Calls

When you dispatch an event in Laravel using OrderPaid::dispatch($order) and its listeners do not implement ShouldQueue, everything runs sequentially within the same PHP worker process. If four listeners are attached to that event, your HTTP request pause duration is the sum of all four listeners' execution times.

I measured this on a production endpoint recently. A single order checkout endpoint took 45ms to process payment and save the record. After team members attached four synchronous listeners—one to recalculate loyalty points, one to generate an invoice PDF, one to call a CRM API, and one to refresh search indexes—the endpoint response time spiked from 45ms to 320ms. When the CRM API timed out, the customer saw a 500 error page even though their credit card had already been charged.

If two actions belong to the same domain context and must succeed or fail as a single atomic unit, don't use events. Use an explicit service class inside a database transaction:

namespace App\Services;

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

class ProcessOrderService
{
    public function execute(Order $order): void
    {
        DB::transaction(function () use ($order) {
            $order->markAsPaid();
            $order->inventory()->decrementQuantity();
            $order->customer->recalculateLoyaltyTier();
        });
    }
}

Reserve synchronous events for open extension points—situations where core code inside a reusable package or a modular monolith needs to notify external modules without knowing or caring if those modules exist.

Queued Listeners: Where Events Belong

When the secondary action is a side effect that can happen a few seconds later without breaking the user experience, queued listeners are the correct tool. Sending emails, posting to webhooks, updating search indexes, and generating PDFs all belong in background queues.

In Laravel 12, marking a listener for queueing requires adding the ShouldQueue interface. However, the most common production bug with queued listeners is a database race condition. The event dispatches inside a transaction, the queue worker picks up the job immediately, but the database transaction hasn't committed yet. The queue worker attempts to query the database and throws a ModelNotFoundException.

To fix this, implement the ShouldHandleEventsAfterCommit interface or set the $afterCommit property on your listener:

namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendOrderConfirmationNotification implements ShouldQueue
{
    public bool $afterCommit = true;
    public string $queue = 'notifications';
    public int $tries = 3;

    public function handle(OrderPlaced $event): void
    {
        $order = $event->order;
        // Send email or SMS safely knowing database transaction has committed
    }
}

Another gotcha on PHP 8.3 queue workers involves long-running worker processes. If your queued listener holds references to heavy Eloquent models or static array buffers, memory consumption will steadily climb. Run your workers using php artisan queue:work --max-requests=1000 or ensure you explicitly clear large variables at the end of the handle() method to force garbage collection.

Broadcasting Events to Next.js 16 and React 19

Broadcasting extends Laravel's event system out to client applications using WebSockets. Whether you run Laravel Reverb or an external service like Pusher, broadcasting sends event payloads directly to real-time subscribers.

In modern stacks featuring Laravel 12 backend services and Next.js 16 (using React 19) frontends, real-time updates keep client state synchronized without expensive polling loops. To broadcast an event, implement the ShouldBroadcast interface on your event class.

Avoid implementing ShouldBroadcastNow on high-throughput events. ShouldBroadcastNow forces the WebSocket broadcast payload to transmit inline during the HTTP request lifecycle. If your WebSocket server experiences latency or connection hiccups, your API response times will degrade instantly. Always default to standard queued broadcasting.

Here is how a React 19 component inside Next.js 16 listens to a Laravel 12 broadcast channel using Laravel Echo:

'use client';

import { useEffect, useState } from 'react';
import { echoInstance } from '@/lib/echo';

interface OrderUpdatedEvent {
  orderId: number;
  status: string;
}

export function OrderStatusBadge({ orderId }: { orderId: number }) {
  const [status, setStatus] = useState('Processing');

  useEffect(() => {
    const channel = echoInstance
      .private(`orders.${orderId}`)
      .listen('.OrderUpdated', (event: OrderUpdatedEvent) => {
        setStatus(event.status);
      });

    return () => {
      channel.stopListening('.OrderUpdated');
    };
  }, [orderId]);

  return <span className="badge">{status}</span>;
}

Notice the dot prefix in .OrderUpdated. When Laravel broadcasts an event, it namespaces the event class unless you customize the broadcast name via the broadcastAs() method. Forgetting that leading dot or custom name is the single most common reason React developers spend hours wondering why Echo isn't catching events.

A Simple Decision Matrix

Stop using events for everything. Follow these pragmatic rules when structuring your application logic:

  • Use direct class calls or DB transactions when execution must be synchronous, atomic, and part of the main application flow.

  • Use synchronous events only when building pluggable packages or framework extensions where core code must not depend on listeners.

  • Use queued events and listeners for all asynchronous side effects like notifications, webhooks, search indexing, and metrics collection. Always set $afterCommit = true when reading recently written database records.

  • Use broadcast events to transmit state updates over WebSockets to client applications like React 19 / Next.js 16 frontends, and always run broadcasting through a queue worker.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Database Index Tuning with Postgres EXPLAIN

    Laravel Database Index Tuning with Postgres EXPLAIN

    Preventing Laravel Cache Stampedes with Atomic Locks

    Preventing Laravel Cache Stampedes with Atomic Locks

    Fast Laravel Database Testing Without In-Memory SQLite

    Fast Laravel Database Testing Without In-Memory SQLite

    Testing Laravel APIs with Pest: A Practical Guide

    Testing Laravel APIs with Pest: A Practical Guide

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12

    Model Caching Patterns in Laravel 12 for Production

    Model Caching Patterns in Laravel 12 for Production

    Ensuring Secure URLs in Laravel Applications

    Ensuring Secure URLs in Laravel Applications

    Simple Feature Flags in Laravel with Laravel Toggle

    Simple Feature Flags in Laravel with Laravel Toggle