A common source of bugs in Laravel applications is dispatching events inside a database transaction. Listeners often kick off their own queries or even their own transactions — and if the outer transaction hasn't committed yet, you can end up with deadlocks, stale reads, or listeners that act on data that gets rolled back.
The fix is straightforward: dispatch events after the transaction commits. But how do you write a test that actually enforces this? Here's a reusable pattern.
The problem
Consider an OrderAction that saves an order inside a transaction and then fires an OrderWasPlaced event:
final class PlaceOrderAction
{
public function __invoke(Cart $cart): Order
{
DB::transaction(function () use ($cart) {
$order = Order::create([...]);
$order->lines()->createMany($cart->lines());
// ❌ Event fired inside the transaction — listeners run
// while the order row is still locked.
Event::dispatch(new OrderWasPlaced($order));
});
}
}
Any listener that tries to read the same rows will block (or deadlock) because the transaction still holds row locks.
The correct version moves the dispatch outside:
final class PlaceOrderAction
{
public function __invoke(Cart $cart): Order
{
$order = null;
DB::transaction(function () use ($cart, &$order) {
$order = Order::create([...]);
$order->lines()->createMany($cart->lines());
});
// ✅ Transaction has committed — listeners can safely read/write.
Event::dispatch(new OrderWasPlaced($order));
return $order;
}
}
The test pattern
The key idea: register a real event listener before the action runs, and capture DB::transactionLevel() at the moment the event fires. If the event fires inside the transaction the level will be elevated; if it fires after the commit it will match the baseline.
use App\Actions\PlaceOrderAction;
use App\Events\OrderWasPlaced;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class PlaceOrderActionTest extends TestCase
{
#[Test]
public function it_dispatches_order_was_placed_after_the_transaction_commits(): void
{
// Fake jobs/queues so side-effect listeners don't cascade.
Bus::fake();
// Capture the DB nesting depth before the action runs.
// LazilyRefreshDatabase / DatabaseTransactions wraps every test
// in its own transaction, so the baseline is typically 1, not 0.
$baselineLevel = DB::transactionLevel();
$levelAtDispatch = null;
// Register a real listener — do NOT call Event::fake(), otherwise
// the dispatcher is replaced with a mock and no listeners run.
Event::listen(OrderWasPlaced::class, function () use (&$levelAtDispatch) {
$levelAtDispatch = DB::transactionLevel();
});
$cart = Cart::factory()->withLines(3)->create();
app(PlaceOrderAction::class)($cart);
$this->assertEquals(
$baselineLevel,
$levelAtDispatch,
'OrderWasPlaced must be dispatched after the transaction commits, not inside it.',
);
}
}
Why compare against $baselineLevel instead of 0?
Most Laravel test suites use LazilyRefreshDatabase or DatabaseTransactions, which wrap every test in an outer transaction for easy rollback. That means DB::transactionLevel() starts at 1 when the test body begins — not 0. Comparing against the snapshot taken before the action runs is always correct, regardless of your test database strategy.
Handling listener cascades
Sometimes the event you want to observe triggers further actions that write to the database, causing failures when those writes reference data that doesn't exist in the current test context. Two strategies:
1. Fake only the cascading action
If a listener dispatches a secondary action (e.g. StartFulfillmentAction), fake just that class so the chain stops there while leaving the event dispatcher intact:
Action::fake(StartFulfillmentAction::class);
The event still fires and your transaction-level listener still runs.
2. Fake only the jobs
If listeners queue jobs (Horizon, etc.), Bus::fake() is usually enough to prevent the cascade without touching the event system at all.
Checking multiple events
To assert that all the events fired by an action respect the post-commit invariant, collect them all in a single map:
$levels = [];
foreach ([OrderWasPlaced::class, InventoryReserved::class, InvoiceQueued::class] as $eventClass) {
Event::listen($eventClass, function () use ($eventClass, &$levels) {
$levels[$eventClass] = DB::transactionLevel();
});
}
app(PlaceOrderAction::class)($cart);
foreach (array_keys($levels) as $eventClass) {
$this->assertEquals(
$baselineLevel,
$levels[$eventClass],
"{$eventClass} must be dispatched outside the transaction.",
);
}
// Also assert every expected event actually fired.
$this->assertSame(
[OrderWasPlaced::class, InventoryReserved::class, InvoiceQueued::class],
array_keys($levels),
);
Quick reference
| Scenario | Approach |
|---|---|
| Single event | Event::listen() + DB::transactionLevel() snapshot |
| Multiple events | Loop over event classes, collect levels into a map |
| Listener cascade breaks the test | Action::fake(CascadingAction::class) or Bus::fake() |
Using Event::fake() |
❌ Replaces the dispatcher — listeners never run, pattern breaks |
| Transaction depth varies by test setup | Always snapshot $baselineLevel before the action, never hardcode 0 |
The pattern is lightweight — no mocking frameworks, no custom test doubles, just a listener closure and a single assertion. Once you've added it to one action test, it's easy to copy across the codebase anywhere you need to enforce the same invariant.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.