Watching David Heinemeier Hansson’s “On Writing Software Well” series awhile back, I took note of episode 2 showing Ruby on Rails active record callbacks being conditionally suppressed. “Heavy callback scenarios” (see timestamp 19:20) require preventing queue worker jobs and extra database queries that sometimes are not required.

I mainly work with PHP framework Laravel which as of version 7.x doesn’t offer transactional event listener callbacks although there have been community discussions about Eloquent events aware of database transactions. Here I’ll cover some app-level solutions that somewhat mirror DHH’s code.

  1. Disable event for the current request
  2. Disable Eloquent model listeners during test setup
  3. Prevent queued listener based on model state
  4. Delay event listeners until after batch update
  5. Dispatch events after database transaction is committed

1. Disable event for the current request

Event::forget('eloquent.created: ' . User::class);

Post complete. That event won’t be dispatched for the duration of the current web request or console command.

OK, if you wish to temporarily stop events and restore them later then the below solutions may be of more interest.

2. Disable Eloquent model listeners during test setup

Laravel’s TestCase parent class offers $this->withoutEvents() to disable class-based events like App\Events\PodcastPublished. Eloquent model event listeners are still registered and invoked. Calling Model::unsetEventDispatcher() will turn off all Eloquent event listeners and the dispatcher must be manually re-registered.

Laravel 5.7.26 added Model::withoutEvents(); to turn off all Eloquent events but only during one Closure call.

For an example test setup, the below code won’t fire events 'eloquent.creating: App\User', 'eloquent.created: App\User', 'eloquent.saving: App\User', etc. The model event listeners will be re-wired once the Closure has completed.

$user = User::withoutEvents(function () {
    // no Eloquent listeners called
    return factory(User::class)->create();
});

// this will call Eloquent listeners
$userWithBootListenersCalled = factory(User::class)->create();

3. Prevent queued listener based on model state

Laravel 5.5.0 added the ability for queued listeners to define a shouldQueue method for conditional queueing. Up until Laravel 5.4, you may have setup the class:

app/Listeners/NotifyArticleUpdated.php

namespace App\Listeners;

use App\Events\ArticleUpdated;

class NotifyArticleUpdated implements ShouldQueue
{
    public function handle(ArticleUpdated $event)
    {
        if ($event->article->isDraft()) {
            return;
        }

        // notify channels about published $event->article
    }
}

With the shouldQueue method, you can conditionally avoid noop queue and database overhead. When the Article model is still being drafted, NotifyArticleUpdated isn’t sent to the queue so the Article model isn’t re-fetched from the database by a queue worker.

namespace App\Listeners;

use App\Events\ArticleUpdated;

class NotifyArticleUpdated implements ShouldQueue
{
    public function handle(ArticleUpdated $event)
    {
        // notify channels about published $event->article
    }

    public function shouldQueue(ArticleUpdated $event)
    {
        return ! $event->article->isDraft();
    }
}

4. Delay event listeners until after batch update

You have a calendar with many appointments that are pulled from an external JSON endpoint or downloadable CSV/iCal file. The Appointment model registers a sometimes-conditional CalendarUpdated event that dispatches multiple listeners, such email notifications or calendar pushes to another service.

app/Appointment.php

namespace App;

use App\Events\CalendarUpdated;

class Appointment extends Model
{
    public static function boot()
    {
        parent::boot();

        static::created(function (self $appointment) {
            event(new CalendarUpdated($appointment->calendar));
        });

        static::deleted(function (self $appointment) {
            event(new CalendarUpdated($appointment->calendar));
        });

        static::updated(function (self $appointment) {
            if ($appointment->wasChanged('starts_at', 'ends_at')) {
                event(new CalendarUpdated($appointment->calendar));
            }
        });
    }
}

Downloading 100 appointments for one calendar in a single request would redundantly fire at least 100 listeners. But only one listener call is desired. Event::forget(CalendarUpdated::class) removes listener configurations so they can’t be restored. Instead you wish to temporarily forget listeners while doing the batch import and then conditionally dispatch them later.

The below dispatcher will ignore events of the given event class name(s). Swapping it into Laravel container app('events'), Event::fake() can also be used by appointment endpoint feature test assertions.

app/Events/Dispatchers/NullEventDispatcher.php

namespace App\Events\Dispatchers;

use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Traits\ForwardsCalls;

class NullEventDispatcher implements DispatcherContract
{
    use ForwardsCalls;

    protected $dispatcher;
    protected $events;

    public function __construct(DispatcherContract $dispatcher, $events)
    {
        $this->dispatcher = $dispatcher;
        $this->events = Arr::wrap($events);
    }

    public function dispatch($event, $payload = [], $halt = false)
    {
        if (!$this->suppresses($event)) {
            return $this->dispatcher->dispatch($event, $payload, $halt);
        }
    }

    protected function suppresses($event)
    {
        return in_array($this->eventName($event), $this->events);
    }

    protected function eventName($event)
    {
        if (is_string($event)) {
            return $event;
        }

        if (is_object($event)) {
            return get_class($event);
        }
    }

    public function listen($events, $listener)
    {
        $this->dispatcher->listen($events, $listener);
    }

    // To meet DispatcherContract, also proxy methods 'hasListeners',
    // 'subscribe', 'until', 'push', 'flush', 'forget', 'forgetPushed'.

    public function __call($method, $parameters)
    {
        return $this->forwardCallTo($this->dispatcher, $method, $parameters);
    }
}

Define a re-usable trait that can be used by any controller, console command, etc.

app/Events/Dispatchers/Concerns/SuppressesEvents.php

namespace App\Events\Dispatchers\Concerns;

use App\Events\Dispatchers\NullEventDispatcher;
use Illuminate\Support\Facades\Event;

trait SuppressesEvents
{
    public function suppressingEvents($events, callable $callback)
    {
        try {
            $dispatcher = Event::getFacadeRoot();

            Event::swap(
               new NullEventDispatcher($dispatcher, $events)
            );

            return $callback();
        } finally {
            Event::swap($dispatcher);
        }
    }
}

app/Http/Controllers/AppointmentUrlsController.php

namespace App\Http\Controllers;

use App\Events\CalendarUpdated;
use App\Events\Dispatchers\Concerns\SuppressesEvents;
use Zttp;

class AppointmentUrlsController extends Controller
{
    use SuppressesEvents;

    public function store()
    {
        request()->validate(['url' => 'required|string|url']);

        // Doesn't fire 100+ listeners for one calendar.
        $appointments = $this->suppressingEvents(
            CalendarUpdated::class, function () use ($calendar) {
                return $calendar->import(
                    Zttp::get(request('url'))->json()
                );
            })
        );

        // Only fires listeners once per calendar.
        if ($appointments->contains->wasChanged('starts_at', 'ends_at')) {
            event(new CalendarUpdated($calendar));
        }

        return back();
    }
}

5. Dispatch events after database transaction is committed

DHH’s screencast is noted for having a Ruby on Rails’ after_commit active record callback. Such a feature isn’t built into Laravel’s Eloquent Model class although the framework does offer a general Illuminate\Database\Events\TransactionCommitted to hook into. The following example shows how to delay event listeners until after Eloquent model changes have been committed.

Suppose you wish to fire off some listeners on event ProductSale when a price is decreased.

app/Providers/EventServiceProvider.php

class EventServiceProvider extends ServiceProvider
{
     protected $listen = [
        \App\Events\ProductSale::class => [
            \App\Listeners\NotifyCustomersAboutSale::class,
            \App\Listeners\NotifyMarketingPriceChange::class,
            \App\Listeners\UpdateInventory::class,
        ],
    ];

    // ...
}

app/Product.php

namespace App;

use App\Events\ProductSale;

class Product extends Model
{
    public static function boot()
    {
        static::updated(function ($product) {
            if ($product->wasChanged('price') &&
                $product->price < $this->getOriginal('price')
            ) {
                event(new ProductSale($product, $this->getOriginal('price')));
            }
        });
    }
}

app/Http/Controllers/ProductsController.php

namespace App\Http\Controllers;

use App\Product;

class ProductsController extends Controller
{
    public function update(Product $product)
    {
        DB::transaction(function () use ($product) {
            // Update $product and related models.
            // May throw QueryException or ValidationException.
        });

        return back();
    }
}

If an exception is thrown mid-transaction, the database changes will not be persisted but the above event listeners will run. You end up notifying customers about a sale that isn't reflected in the database until that transaction is attempted again.

The most obvious solution is to pull event handling out of the model, refactoring to the controller.

namespace App\Http\Controllers;

use App\Product;

class ProductsController extends Controller
{
    public function update(Product $product)
    {
        DB::transaction(function () use ($product) {
            // Update $product and related models.
        });

        if ($product->wasChanged('price') &&
            $product->price < $this->getOriginal('price')
        ) {
            event(new ProductSale($product, $this->getOriginal('price')));
        }

        return back();
    }
}

Domain logic in the controller? And what if the price is changed in multiple endpoints? To make the solution re-usable, you can instead intercept Laravel's event Dispatcher and control ProductSale to be dispatched after the transaction, only if the commit was successful.

namespace App\Http\Controllers;

use App\Events\Dispatcher\AfterCommitDispatcher;
use App\Events\ProductSale;
use App\Product;

class ProductsController extends Controller
{
    public function update(Product $product)
    {
        AfterCommitDispatcher::delaying(ProductSale::class)
            ->transaction(function () use ($product) {
                // Update $product and related models.
            });

        return back();
    }
}

The AfterCommitDispatcher decorates the existing app('events') object, but intercepts any named event classes passed to it. Those intercepted events are only dispatched when DB::transaction() returns without any exceptions.

app/Events/Dispatchers/AfterCommitDispatcher.php

namespace App\Events\Dispatchers;

use Closure;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Traits\ForwardsCalls;

class AfterCommitDispatcher implements DispatcherContract
{
    use ForwardsCalls;

    protected $classes;
    protected $dispatcher;
    protected $events;

    public function __construct($classes)
    {
        $this->classes = Arr::wrap($classes);
        $this->events = [];
    }

    public static function delaying($classes)
    {
        return new static($classes);
    }

    public function transaction(Closure $callback)
    {
        $this->setEventDispatcher();

        try {
            return tap(DB::transaction($callback()), function () {
                $this->commit();
            });
        } finally {
            $this->unsetEventDispatcher();
        }
    }

    protected function setEventDispatcher()
    {
        $this->dispatcher = app(DispatcherContract::class);
        Event::swap($this);
    }

    protected function commit()
    {
        foreach ($this->events as $args) {
            $this->dispatcher->dispatch(...$args);
        }
    }

    protected function unsetEventDispatcher()
    {
        Event::swap($this->dispatcher);
        $this->dispatcher = null;
        $this->events = [];
    }

    public function dispatch($event, $payload = [], $halt = false)
    {
        if ($this->awaitsCommitFor($event)) {
            $this->events[] = func_get_args();

            return;
        }

        return $this->dispatcher->dispatch($event, $payload, $halt);
    }

    protected function awaitsCommitFor($event)
    {
        return is_object($event) &&
            in_array(get_class($event), $this->classes);
    }

    public function listen($events, $listener)
    {
        $this->dispatcher->listen($events, $listener);
    }

    // To meet DispatcherContract, also proxy methods 'hasListeners',
    // 'subscribe', 'until', 'push', 'flush', 'forget', 'forgetPushed'.

    public function __call($method, $parameters)
    {
        return $this->forwardCallTo($this->dispatcher, $method, $parameters);
    }
}