tjovaisas/delayed-event-bundle
Schedule Symfony events to dispatch later instead of immediately. This bundle queues delayed events and processes them when due, useful for reminders, time-based workflows, and background follow-up actions.
First, a reality check on scope: despite the name, this is a Symfony + Doctrine bundle, not a Laravel package. If you are coming from Laravel, translate it in your head to "dispatchAfterCommit / ShouldDispatchAfterCommit, but driven by Doctrine's postFlush instead of Eloquent model events." There is no Laravel integration, no service provider, and no queue wiring for you — you get a deferred event dispatcher and you wire the rest.
Minimal setup:
composer require tjovaisas/delayed-event-bundle (pin the version — the last tagged release is Feb 2022).config/bundles.php → Tjovaisas\DelayedEventBundle\...::class => ['all' => true] on Symfony 4/5+; app/AppKernel.php registerBundles() on legacy Symfony 3.vendor/tjovaisas/delayed-event-bundle is small enough to read end-to-end in ten minutes: find the Doctrine event subscriber (the thing tagged doctrine.event_subscriber listening to postFlush) and the service definition for whatever holds the event queue. That subscriber is the package.First use case: you persist an Order, and you want to send a confirmation email, push a document to an external search index, or fire a webhook. Dispatching inside prePersist/postPersist gives you no database ID and fires even if the surrounding transaction later rolls back. This bundle lets you enqueue a plain event object while Doctrine is working, then release it in one batch after the flush completes, when IDs are assigned and the unit of work is coherent.
Queue-then-release. Treat it as a two-phase flow: during entity lifecycle callbacks you enqueue a domain event describing what happened; the bundle's postFlush listener drains the queue and hands each event to Symfony's EventDispatcher. Your listeners then run against flushed data.
Keep events small and serializable. Do not put managed entities in the queued event. By dispatch time entities may be detached, and if you later move to Messenger your event has to survive serialization. Put int $orderId, string $status, scalars. Let the listener re-fetch through the repository.
Make listeners thin — hand off to Messenger. The best pattern with this bundle is: postFlush → dispatch domain event → listener does $bus->dispatch(new SendOrderConfirmation($orderId)). You get "events fire at the right moment" from this bundle and "work happens off-request" from Messenger, and each concern stays debuggable independently.
Batch imports are the sweet spot. In an import command that flushes every 100 entities, per-entity listeners would fire 100 times mid-batch. Queueing makes it one drain at the flush boundary, which is exactly what you want for index rebuilds and notification digests.
Use wrapInTransaction for the outer boundary. The common shape is:
$em->wrapInTransaction(function () use ($order, $em) {
$order->markPaid();
$em->persist($logEntry); // events queued here
$em->flush(); // queue drains on postFlush
});
Test at the kernel level. Boot the kernel in a functional test, dispatch a request/command, then assert on an event-dispatcher spy (Symfony\Component\EventDispatcher\EventDispatcherInterface decorator or a TraceableEventDispatcher) that the event fired after the row exists. A second test should assert it does not fire when the transaction aborts.
postFlush is not afterCommit. This is the big one. postFlush fires when the unit of work completes, which can still be inside an enclosing transaction. If that outer transaction rolls back, your queued events have already been dispatched for data that never persisted. If correctness matters, wrap the flush in an explicit transaction and defer dispatch until after it commits — or make every listener idempotent and defensive (re-fetch by ID, no-op if missing).flush() calls in one request mean multiple dispatch rounds. Ordering between your listeners and other postFlush listeners depends on tag priority — set it explicitly rather than assuming.flush(), silently drop everything. Never rely on this mechanism for cleanup or accounting.postFlush and another drain. Either forbid flushing inside listeners or guard the collector with a "draining" flag.composer.json. The good news: the whole package is roughly one subscriber plus a service definition, so vendoring it into src/ (or writing a 30-line postFlush listener plus a request-scoped event collector) is a viable escape hatch and arguably a better long-term bet.postFlush listener before draining; when an event "doesn't fire," it is almost always (a) no flush happened, (b) an exception before postFlush, or (c) the event went into a different collector instance than the one being drained — check the service scope (shared vs. non-shared) in the bundle's service config.How can I help you explore Laravel packages today?