NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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:

AssertionPasses 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:

ExpectationPasses 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 crypto

By 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.