A queued Laravel appointment-reminder notification sometimes texts patients about cancelled appointments and fails when sent inside a transaction; what is going on, and how do you fix it?
answer
- one job per notifiable per channel
- via() runs at dispatch time
- afterCommit() or ShouldQueueAfterCommit
- shouldSend($notifiable, $channel) in the worker
- #[DeleteWhenMissingModels] since Laravel 13
basics
~20 sA ShouldQueue notification becomes one job per recipient and channel, with via() decided at dispatch. Push it after commit with afterCommit() or ShouldQueueAfterCommit, re-check the appointment in shouldSend() inside the worker, and in Laravel 13 drop jobs whose models vanished with #[DeleteWhenMissingModels].
solid answer
~40 sWith `ShouldQueue`, `notify()` calls `via()` immediately and dispatches one `SendQueuedNotifications` job per notifiable per channel, so mail, SMS and database run, retry and fail independently. **Inside a transaction** the job can reach a worker before the commit — the skeleton's queue connections set `after_commit` to `false` — and `SerializesModels` re-queries an appointment that is not there yet; call `->afterCommit()` or implement `ShouldQueueAfterCommit`. **Stale decisions**: channels were chosen at dispatch, possibly hours before a delayed reminder runs, so define `shouldSend(object $notifiable, string $channel): bool`, which runs in the worker; return `false` for a cancelled appointment and a `NotificationSkipped` event fires instead. **Deleted rows**: Laravel 13 honours `#[DeleteWhenMissingModels]` on queued notifications, deleting the job instead of failing it. Per-channel `withDelay()`, `viaQueues()` and `viaConnections()` let SMS and mail run on different schedules and queues.
code
php · 29 lines<?php
namespace App\Notifications;
use App\Models\Appointment;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Notifications\Notification;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
#[DeleteWhenMissingModels]
class AppointmentReminder extends Notification implements ShouldQueueAfterCommit
{
public function __construct(public Appointment $appointment) {}
public function via(object $notifiable): array
{
return ['mail', 'vonage', 'database'];
}
public function shouldSend(object $notifiable, string $channel): bool
{
return $this->appointment->status === 'confirmed';
}
public function viaQueues(): array
{
return ['vonage' => 'sms', 'mail' => 'mail'];
}
}go deeper
Recall that implementing ShouldQueue makes notify() queue the notification, and that a queue worker then sends it.
Explain the job-per-notifiable-per-channel split, that via() runs at dispatch, and how afterCommit() and shouldSend() change timing and decisions.
Diagnose the transaction race and stale reminders, apply ShouldQueueAfterCommit, shouldSend() and Laravel 13's DeleteWhenMissingModels, and isolate SMS with viaQueues().
Weigh delayed jobs against a scheduled sweep for reminders — cancellability, observability and provider cost — and define which channel failures need human follow-up.
## How a queued notification is dispatched When a notification class implements `Illuminate\Contracts\Queue\ShouldQueue`, `notify()` does not send anything. The notification sender instead: 1. clones the notification and calls `via($notifiable)` **now, in the request**; 2. assigns one notification id per notifiable, shared by all its channels; 3. dispatches one `Illuminate\Notifications\SendQueuedNotifications` job **per notifiable per channel** — two patients and three channels make six jobs; 4. serializes each job; because the base `Notification` class uses `SerializesModels`, an `Appointment` property is stored as an identifier and re-queried when the worker unserializes the job. In the worker, each job sends its single channel. Before sending, the sender calls `shouldSend()` if the notification defines it and fires `NotificationSending`; after a successful send it fires `NotificationSent`, and on an exception `NotificationFailed` before rethrowing so the queue can retry. Consequences worth saying out loud: the SMS job can fail and retry while the email has already gone; a retry resends only that channel; and nothing re-runs `via()` in the worker. ## Failure mode 1: dispatched inside a transaction Booking code often looks like `DB::transaction(fn () => ... $patient->notify(new AppointmentReminder($appointment)))`. The queue connections in the Laravel 13 skeleton set `after_commit` to `false`, so jobs are pushed immediately. A fast worker can unserialize the job before the transaction commits, re-query the appointment, find nothing, and fail. Fixes: - `$patient->notify((new AppointmentReminder($appointment))->afterCommit());` - `$this->afterCommit()` in the notification's constructor; - or `implements ShouldQueueAfterCommit` instead of `ShouldQueue`. All three hold the dispatch until open transactions commit, and drop it if they roll back. ## Failure mode 2: stale decisions Reminders are usually delayed — "24 hours before" — through `delay()` or a `withDelay($notifiable)` method returning per-channel delays. Everything decided at dispatch can be wrong by the time the job runs: the appointment was cancelled, the patient withdrew SMS consent. Because models are re-queried on unserialize, the notification sees current data in the worker. Use it: ```php public function shouldSend(object $notifiable, string $channel): bool { if ($this->appointment->status !== 'confirmed') { return false; } return $channel !== 'vonage' || $notifiable->sms_opt_in; } ``` Returning `false` skips that channel and fires `NotificationSkipped`, which you can log or count. ## Failure mode 3: the model is gone If the appointment row was deleted, unserializing the job throws `ModelNotFoundException` and the job fails — noisy entries in the failed-jobs table for something that is simply no longer relevant. Since Laravel 13, queued notifications honour `#[DeleteWhenMissingModels]` (or `public $deleteWhenMissingModels = true;`) on the notification class, so such jobs are deleted quietly. Before 13 the flag on a notification was not respected. ## Per-channel control | Need | Mechanism on the notification | |---|---| | Different delays per channel | `delay(['mail' => ..., 'vonage' => ...])` or `withDelay($notifiable)` | | Different queues per channel | `viaQueues()` returning `['vonage' => 'sms', 'mail' => 'mail']` | | Different connections per channel | `viaConnections()` | | Job middleware per channel | `middleware($notifiable, $channel)` | | Attempts and timeouts | `#[Tries]`, `#[Timeout]`, `#[MaxExceptions]` on the class | | Final failure hook | a `failed($exception)` method on the notification | Putting SMS on its own queue matters in practice: a slow SMS provider then cannot hold up emails and database rows. ## Observing what happened Because every channel runs as its own job, "did the reminder go out?" has a per-channel answer. Laravel gives you hooks at each step: - `NotificationSending` — fired before each channel sends; a listener returning `false` cancels that channel. - `NotificationSkipped` — fired when `shouldSend()` or a sending listener says no. - `NotificationSent` — fired after a channel succeeds, carrying the channel's response. - `NotificationFailed` — fired when a channel throws, before the exception reaches the queue. - `afterSending($notifiable, $channel, $response)` — a method on the notification itself, called after each successful channel. Recording these per appointment and channel turns a vague complaint — "I never got the text" — into a checkable fact: skipped because the appointment was cancelled, failed after five tries, or sent and accepted by the provider. ## Summary - One job per notifiable per channel; `via()` is evaluated at dispatch. - Commit first (`afterCommit()`/`ShouldQueueAfterCommit`), re-check in `shouldSend()`, and let Laravel 13's `#[DeleteWhenMissingModels]` discard jobs for deleted rows. - Tune channels independently with `withDelay()`, `viaQueues()` and `viaConnections()`.
- The SMS job for a reminder failed and was retried. Does the patient get the email twice?No. Each channel is its own `SendQueuedNotifications` job, so a retry re-runs only the SMS job; the mail and database jobs already succeeded. The flip side is that channels can end in different states, which is why `NotificationFailed` and `NotificationSent` events are worth recording per channel.
- A patient turns off SMS after a reminder was queued. Why do they still get the text, and how do you stop it?`via()` ran at dispatch and fixed the channel list, so the vonage job already exists. Define `shouldSend($notifiable, $channel)` and return `false` for `vonage` when the patient's current `sms_opt_in` is off; it runs in the worker against freshly re-queried models.
saying these in an interview costs you the question
- A queued notification with three channels is a single job that sends all three.
- via() is re-evaluated in the worker just before sending.
- Queued notifications always wait for the database commit by default.
- shouldSend() runs in the request, when notify() is called.
- #[DeleteWhenMissingModels] has worked on queued notifications since long before Laravel 13.