Notifications::fake() swaps the manager for a NotificationsFake behind the facade and in the container, and returns it:
use RoundlyConsulting\Notifications\Facades\Notifications;
it('asks the approvers', function (): void {
$notifications = Notifications::fake(); // record-only: nothing is written, nothing is mailed
$request = PurchaseRequest::factory()->create();
$this->actingAs($request->author)->post("/requests/{$request->id}/submit")->assertRedirect();
$notifications->assertSent('request.awaiting_approval', to: $approver, about: $request);
$notifications->assertNotSent('security.alert');
});- It is record-only: every mutating call — sends (also through type()->…->send(), for()->send() and sendNotification()), inbox mutations, preference writes, reminder schedule / sync / remind / cancel / acknowledge, revoke, sweeps, prunes, the inbox channel and calls through the model traits — is recorded and not executed. A faked send returns an empty SendResult.
- Reads (inbox()->paginate(), types(), preferences()->matrix()) pass through to the real store, and type registration passes through.
- Recipient resolution still runs, so assertSent(to: $user) works with Recipients::role(). Access checks, preferences, quiet hours and de-dup do not: an assertion proves the intent to notify, not a delivery.
- NotificationsFake extends the manager, so an injected NotificationsManager is faked and still type-checks. Calling an action directly is not recorded.
- The interop listener is never recorded or blocked by the fake.
- ->passthrough() records and also runs every operation.
Assertions
Every assertion is on the fake and forwarded by the facade:
| Assertion | Passes when |
|---|---|
assertSent($type, to: ?Model|Closure, about: ?Model, times: ?int) | A send of the type (to the recipient, about the subject; exactly times) was recorded. A closure to receives each resolved recipient. |
assertNotSent($type) · assertNothingSent() | No send of the type / no send at all. |
assertMarkedRead(?Model $recipient) | markRead() or markAllRead(). |
assertArchived(?Model $recipient) | archive(). |
assertPreferenceSet($user, $type, Channel $channel, bool $enabled) | A mute, unmute or set of that channel to that value. |
assertMuted($user, $type) | The type’s mail was muted. |
assertPreferencesUpdated($user, ?Closure $where) | preferences($user)->update(); $where(PreferencesUpdate): bool narrows it. |
assertRemindersSynced($subject) | Including the sync HasNotificationReminders runs on save. |
assertReminderScheduled($subject, $type) | schedule() or remind(). |
assertRemindersCancelled($subject) | cancel(). |
assertAcknowledged($subject, ?$type) | acknowledge(). |
assertRevoked($subject, ?Model $recipient) | revoke(). |
assertSwept() | sweep() or notifications:sweep. |
$fake = Notifications::fake();
$fake->assertSent('comment.posted', to: fn (User $user): bool => $user->is_admin, times: 2);
$fake->assertMarkedRead($user);
$fake->assertPreferencesUpdated($user, fn (PreferencesUpdate $update): bool => isset($update->channels['comment.posted']));
$fake->assertRemindersSynced($contract); // also the sync HasNotificationReminders runs on save
$fake->assertAcknowledged($contract, 'contract.expiring');
$fake->assertRevoked($request, $formerMember);
$fake->assertSwept();
// The facade forwards every assertion too
Notifications::assertNothingSent();
// Record and still run
Notifications::fake()->passthrough();Recorded operations
$fake->recorded(?NotificationOperation $operation = null) returns the RecordedOperation records in call order — operation, context (the call’s arguments), the resolved recipients of a send, type and subject:
use RoundlyConsulting\Notifications\Enums\NotificationOperation;
foreach ($fake->recorded(NotificationOperation::Send) as $send) {
$send->operation; // NotificationOperation::Send
$send->type; // 'request.awaiting_approval'
$send->subject; // the subject model
$send->recipients; // list<Model> — the resolved recipients
$send->context; // the call's arguments
}Pest expectations
Registered automatically through the package’s Pest plugin; they read the database:
| Expectation | Passes when |
|---|---|
toHaveUnreadInboxItems(int $count) | Exactly this many unread, not archived items. |
toHaveInboxItem(string $type, ?Model $subject = null) | An item of the type (about the subject). |
toHaveMuted(string $type) | The type’s mail is off for the user. |
expect($user)->toHaveUnreadInboxItems(1);
expect($user)->toHaveInboxItem('request.awaiting_approval', $request);
expect($user)->toHaveMuted('comment.posted');
// Where Pest plugin discovery is off
RoundlyConsulting\Notifications\Testing\NotificationExpectations::register();The real pipeline with Notification::fake()
To assert a delivery, run the real pipeline and fake Laravel’s notifications. Digests arrive as DigestMailNotification. Freeze time for quiet hours, digests and reminders, and run Notifications::sweep() to release what is due:
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use RoundlyConsulting\Notifications\Mail\TypedMailNotification;
Notification::fake();
Notifications::send('security.alert', null, $user);
Notification::assertSentTo($user, TypedMailNotification::class, fn ($n) => $n->type === 'security.alert');
expect($user)->toHaveUnreadInboxItems(1);
// Quiet hours, digests and reminders: freeze time, then release what is due
Carbon::setTestNow('2026-11-10 07:01');
Notifications::sweep();Notification::fake() dispatches no NotificationSending, so interop does not run under it.
Factories
Every model ships a factory resolved through the model seam:
InboxItem::factory()->recipient($user)->read()->create();
NotificationDelivery::factory()->recipient($user)->status(DeliveryStatus::Held)->create();
NotificationReminder::factory()->subject($contract)->create();
DigestRun::factory()->recipient($user)->create();
NotificationReceipt::factory()->create();Testing an app
- Publish both migration sets (notifications and options) — RefreshDatabase works on SQLite in tests.
- Define a policy or the view ability for every subject class your types use, or the first send throws MissingAccessCheckException.
- Turn the queue off (or use the sync connection) when a test needs mail to go out inline.
- Use on / off values in phpunit.xml:
<php>
<env name="NOTIFICATIONS_ROUTES_ENABLED" value="on"/>
<env name="NOTIFICATIONS_QUEUE_ENABLED" value="off"/>
</php>Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.