The problem that cost us hours
While implementing a feature flag system to test event-driven flows, we hit a frustrating bug that took hours to debug. Our multi-tenancy system, which worked flawlessly in production, suddenly broke in tests—but only after introducing Event::fake().
The symptom was clear but confusing: database constraint errors stating tenant_id cannot be null. The tenant field was NOT NULL in the database, and our HasTenancy trait was supposed to set it automatically. Why was it suddenly failing?
Understanding the root cause
Laravel's Event::fake() does exactly what it promises: it replaces the real event dispatcher with a fake one that records events instead of actually dispatching them. This is perfect for testing that specific events are fired. However, there's a hidden side effect that's not immediately obvious.
Eloquent models have their own event dispatcher separate from the application's main event system. When you call Event::fake(), you're replacing the application dispatcher, but Eloquent models continue using their old dispatcher—which is now disconnected from the fake one you're trying to test against.
This means your Event::assertDispatched() calls work (testing application events), but your model events (like saving, created, etc.) stop working entirely.
A real-world example: multi-tenancy
Here's a simplified trait that broke silently in tests:
trait HasTenancy
{
public static function bootHasTenancy()
{
static::saving(function ($model) {
$model->tenant = Tenant::current();
});
}
}This trait uses Eloquent's saving event to automatically set the tenant field. In production: works perfectly. In tests with Event::fake(): the callback never fires.
The failing test
The model creation failed with a constraint violation. The tenant field was required in the database, but our trait that should have set it automatically wasn't running. After hours of debugging, we found this is a known Laravel issue.
public function test_dispatches_events(): void
{
Event::fake(); // ← This breaks HasTenancy!
// This throws a database error! ✗
// SQLSTATE[23000]: Integrity constraint violation:
// tenant_id cannot be null
$model = Model::create(['name' => 'Test']);
}
The solution: reconnect model events
The fix is surprisingly simple once you know it exists. After calling Event::fake(), you need to manually reconnect the model event dispatcher:
// Store the initial dispatcher before faking
$initialDispatcher = Event::getFacadeRoot();
// Fake events for testing
Event::fake();
// Reconnect model events to the fake dispatcher
Model::setEventDispatcher($initialDispatcher);Here's the corrected test:
public function test_dispatches_events(): void
{
// Setup Event::fake() with dispatcher fix
$initialDispatcher = Event::getFacadeRoot();
Event::fake();
Model::setEventDispatcher($initialDispatcher);
// NOW this works! ✓
$model = Model::create(['name' => 'Test']);
// Both the model logic AND event assertions work
$this->assertNotNull($model->tenant);
Event::assertDispatched(SomeEvent::class);
}
Why this matters
This isn't just a testing quirk—it masks serious bugs. Model events are critical for multi-tenancy systems that automatically scope data, UUID generation in model boot methods, audit logging that tracks changes, cache invalidation tied to model events, and search index updates (Algolia, Meilisearch, etc.).
Without reconnecting the dispatcher, you're testing a different code path than production. Tests pass while critical logic is bypassed.
When you fake events in Laravel, always reconnect the model event dispatcher. Your future self will thank you.Marc BernetSoftware Developer
Best practices
Always reconnect when faking events. If you're testing events and have any model event listeners, always use this pattern:
$initialDispatcher = Event::getFacadeRoot();
Event::fake();
Model::setEventDispatcher($initialDispatcher);Test model behavior, not just events. Don't only assert events. Verify the side effects actually happened:
Event::assertDispatched(ModelCreated::class);
// Also verify behavior
$model = Model::find($id);
$this->assertNotNull($model->tenant);
$this->assertEquals('active', $model->status);Consider integration tests. For complex flows with multiple listeners, skip Event::fake() and test the full integration:
public function test_full_integration(): void
{
Mail::fake(); // Still fake external services
$this->doSomething();
// Assert final state
$this->assertDatabaseHas('models', ['tenant' => 1]);
Mail::assertSent(NotificationMail::class);
}