skip to content

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?

level: seniorimportance: should knowfreq 35%

answer

  1. one job per notifiable per channel
  2. via() runs at dispatch time
  3. afterCommit() or ShouldQueueAfterCommit
  4. shouldSend($notifiable, $channel) in the worker
  5. #[DeleteWhenMissingModels] since Laravel 13

basics

~20 s

A 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 s

With `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
<?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

for a junior

Recall that implementing ShouldQueue makes notify() queue the notification, and that a queue worker then sends it.

for a middle

Explain the job-per-notifiable-per-channel split, that via() runs at dispatch, and how afterCommit() and shouldSend() change timing and decisions.

for a senior

Diagnose the transaction race and stale reminders, apply ShouldQueueAfterCommit, shouldSend() and Laravel 13's DeleteWhenMissingModels, and isolate SMS with viaQueues().

for a principal

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.