Introduction
Observers sit between your application and the database, firing automatically on every create, update, and delete. That convenience is also a risk — silent side effects are easy to break. Tests are the safety net that lets you refactor observer logic without fear.
Key Concepts
- Side effect: Any observable change outside the model itself — a notification sent, a log line written, a cache key cleared.
- Faking: Replacing a real Laravel service (mail, notifications, events, queue) with a spy that records calls instead of executing them.
withoutEvents(): A helper that silences observers inside a closure or test, useful when setting up fixtures that shouldn't trigger observer logic.
Real World Context
When an observer fires a welcome email on User creation, every factory call in your test suite would hit the mail service. Tests become slow, flaky, and dependent on SMTP config. Faking the mailer lets your assertions focus on what the observer tried to do, not whether the side effect succeeded.
Deep Dive
Asserting an Observer's Side Effects
Here we verify that PostObserver::created dispatches a notification without actually sending one. Laravel's Notification::fake() swaps the notification service with a spy:
phpuse App\Models\Post; use App\Models\User; use App\Notifications\NewPostNotification; use Illuminate\Support\Facades\Notification; it('notifies followers when a post is created', function () { Notification::fake(); $author = User::factory()->create(); $follower = User::factory()->create(); $author->followers()->attach($follower); Post::factory()->for($author)->create(['title' => 'Hello world']); Notification::assertSentTo($follower, NewPostNotification::class); });
After running this test, no real emails go out, but the assertion still catches regressions if the observer silently stops firing.
Verifying Cancellation
An observer that returns false from deleting vetoes the delete. Assert the behavior, not the implementation:
phpit('refuses to delete a post that has comments', function () { $post = Post::factory()->has(Comment::factory())->create(); $result = $post->delete(); expect($result)->toBeFalse(); expect(Post::find($post->id))->not->toBeNull(); });
Notice we test the outcome (the post still exists) rather than inspecting the observer method directly — this stays green even if you later move the logic to a policy or a guard clause.
Silencing Observers in Setup
When seeding test fixtures, you often want to skip observer side effects:
phpPost::withoutEvents(function () { Post::factory()->count(100)->create(); });
This is the right call for setup code that just needs rows in the database, not behavior.
Common Pitfalls
- Forgetting
Notification::fake()— Real notifications leak into your test runs, making them slow and environment-dependent. - Asserting on private methods — Tests should observe behavior (the notification was sent, the cache was cleared), not the exact method name the observer calls. Otherwise refactoring breaks tests.
- Seeding with events enabled — Running a factory loop with live observers can fire thousands of side effects and even deadlock queued jobs.
Best Practices
- Fake early, assert late — Call
Notification::fake(),Event::fake(), orQueue::fake()at the top of the test before any factory runs, then assert at the end. - One observer behavior per test — Keep tests small and focused. A single
it()block should cover one event and one expected side effect. - Use
expectsEventssparingly — Prefer asserting on the downstream effect (a notification, a DB row) over asserting that a specific event fired. Downstream assertions survive refactors.
Summary
- Observers are a prime source of hidden regressions; tests keep them honest.
- Use
Notification::fake(),Event::fake(), andQueue::fake()to stub external services. - Test the observable outcome, not the observer's internals.
- Reach for
withoutEvents()when seeding fixtures that shouldn't fire observer logic.