One call notifies every recipient of one event:
Notifications::send(
string $type, // a key or a type class-string
?Model $subject,
RecipientSpec|Model|Closure|iterable $recipients,
array $data = [], // plain JSON, ≤ notifications.inbox.max_data_bytes
?Model $actor = null,
Context|Model|string|null $context = null, // overrides the type's context()
?string $idempotencyKey = null,
): SendResult;The fluent builder
type() runs the same pipeline with named steps:
Notifications::type(RequestAwaitingApproval::class)
->about($request)
->to(Recipients::role($request->company, 'approver'))
->by($request->author)
->with(['reference' => $request->reference])
->in($request->company)
->once("request-submitted:{$request->id}") // a replay sends nothing to anyone it reached
->escalate(after: 'P2D', to: Recipients::role($request->company, 'director'))
->send(); // SendResult
$pending = Notifications::type('request.awaiting_approval')->about($request)->to($approvers);
$pending->data(); // the SendNotificationData it would sendAbout one subject
Notifications::for($request)->send('request.awaiting_approval', $approvers, ['reference' => 'PR-9']);
Notifications::for($request)->type('request.awaiting_approval')->to($approvers)->send();
Notifications::for($request)->subject(); // the modelThe pipeline
Per call: find the type → check the subject class, the data (plain JSON under the byte cap) and the key types → resolve the context → resolve the recipients (nulls and duplicates dropped, the actor removed unless notifyActor()) → above notifications.fan_out.queue_above (50) recipients, queue the work in chunks of fan_out.chunk (100). Then, per recipient, in one transaction:
- Idempotency — a receipt per key and recipient; already there → Duplicate.
- Access check → NoAccess, and a RecipientSkipped event.
- Effective channels from the user’s preferences.
- The inbox step (see Inbox) when the inbox is on.
- The mail step (see Email) when mail is on.
Events are dispatched after the commit and so are jobs: a send inside a host transaction that rolls back leaves nothing.
Contexts
A context says where an item belongs for filtering — a company, a team, a billing area. It overrides the type’s context() hook:
use RoundlyConsulting\Notifications\Context;
Notifications::send($type, $subject, $users, context: $company); // a model
Notifications::send($type, $subject, $users, context: Context::of($company, 'Acme')); // a model + label
Notifications::send($type, $subject, $users, context: Context::key('billing', 'Billing'));
Notifications::send($type, $subject, $users, context: 'billing'); // key = labelA model context’s key is {alias}:{id} — the morph-map alias when the class is mapped, else the snake-cased basename (company:3) — never a class name, because the key goes over the wire. Two unmapped classes with one basename share a key space, so map them with Relation::morphMap. A string context is at most 191 characters and may not contain a backslash. The label is stored as a snapshot. A context is never a tenancy boundary.
Idempotency
->once($key) (or send(..., idempotencyKey: $key)) records a receipt per recipient; a replay of the same key gives those recipients Duplicate and writes nothing. Reminders and escalations fire with their own keys, so a re-fire after a crash sends nothing twice. Receipts are pruned after notifications.idempotency.ttl (P30D).
Escalation on a send
“If nobody acknowledged it by X, notify Y.” Escalations need a subject — acknowledgement is per subject:
Notifications::type('request.awaiting_approval')->about($request)->to($approvers)
->once('request:'.$request->id)
->escalate(after: 'P2D', to: Recipients::role($request->company, 'director'))
->escalate(after: 'P5D', to: Recipients::role($request->company, 'board'), type: 'request.overdue')
->send();Each send arms its own chain. Repeating such a send about one subject without an idempotency key starts one chain per send, so give it once() or a window when it can repeat. An unreadable or non-positive delay throws InvalidReminderRuleException at the escalate() call. See Reminders and escalation for acknowledgement.
Data
data must be plain JSON — scalars, nulls and arrays — under notifications.inbox.max_data_bytes (16 KB). A model, an object or a non-finite float throws InvalidNotificationDataException naming the path. data is stored in clear: put references in it, not secrets.
SendResult
$result = Notifications::send('request.awaiting_approval', $request, $approvers);
$result->type; // the key
$result->outcomes; // list<RecipientOutcome>: recipientKey, recipientType (basename),
// inbox (InboxOutcome), mail (MailOutcome), inboxItemId, deliveryId
$result->queued; // recipients handed to fan-out jobs (their outcomes are not known yet)
$result->inbox(InboxOutcome::Created); // filter by outcome
$result->mail(MailOutcome::Held);
$result->recipients(); // keys reached (not skipped)
$result->skipped(); // no access or a replayed idempotency key| Enum | Cases |
|---|---|
InboxOutcome | Created, Collapsed, Muted, NotInChannels, NoAccess, Duplicate, Queued |
MailOutcome | Pending, Held, Digest, Folded, Deduplicated, Muted, NotInChannels, NoAccess, Duplicate, Queued |
Transactions and deadlocks
Each recipient’s transaction retries a deadlock three times — but only when the package owns the outermost transaction. Inside a host transaction Laravel cannot retry a nested transaction: it throws, and the host’s transaction is lost. Send outside long host transactions where you can, or retry the host transaction yourself. First inserts of an open row run under a savepoint, so a lost race never aborts a PostgreSQL transaction; the step re-reads and collapses instead.
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.