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.
- Disable event for the current request
- Disable Eloquent model listeners during test setup
- Prevent queued listener based on model state
- Delay event listeners until after batch update
- 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);
}
}