---
title: "Sending — Notifications for Laravel | Roundly"
description: "send(), the fluent type()->…->send(), for($subject), contexts, once() idempotency, escalate(), fan-out, transactions and the SendResult."
url: https://roundly-consulting.com/open-source/docs/notifications-for-laravel/sending
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Notifications for Laravel](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)

# Sending

One call notifies every recipient of one event:

```php
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:

```php
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 send
```

## About one subject

```php
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 model
```

## The 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:

```php
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 = label
```

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

```php
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

```php
$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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)
