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

Recipients and access

Recipients resolve at send time with the subject — no email address is ever hard-coded:

use RoundlyConsulting\Notifications\Recipients;

Recipients::users($anna, $boris, $collection, null);       // explicit models; nulls dropped
Recipients::role($request->company, 'approver');           // through the group resolver
Recipients::members($team);                                 // every member
Recipients::owner();                                        // HasNotificationOwner::notificationOwner()
Recipients::owner('accountManager');                        // …else this relation
Recipients::using(OnCallResolver::class, ['level' => 2]);   // your RecipientResolver, scalar args
Recipients::merge(Recipients::owner(), Recipients::role($company, 'manager'));

// A plain model, a collection or an array works wherever recipients are expected
Notifications::send('security.alert', null, $user);
Notifications::send('security.alert', null, User::query()->where('is_admin', true)->get());
SpecResolutionStorable
Recipients::users($a, $b, $collection, null)The models; nulls dropped, a non-model throws InvalidRecipientException.✓
Recipients::role($group, 'approver')The group resolver (notifications.recipients.group_resolver), members with the role. The group may be a closure of the subject.✓ (not with a closure group)
Recipients::members($group)The same, every member.✓ (not with a closure group)
Recipients::owner(?string $relation)HasNotificationOwner::notificationOwner(), else the named relation, else NoOwnerException.✓
Recipients::using(MyResolver::class, ['level' => 2])MyResolver::resolve(?Model $subject, array $args), resolved from the container; arguments must be scalars or lists of them.✓
Recipients::merge(...$specs)The union.when every part is
fn (Contract $c) => Recipients::role($c->company, 'manager')A closure evaluated with the subject.✗ — only inside code-defined reminder rules

Storable specs survive in a reminder row, an escalation or a queued fan-out; storing a closure throws UnstorableRecipientsException.

Your own resolver

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Notifications\Contracts\RecipientResolver;

final class OnCallResolver implements RecipientResolver
{
    public function __construct(private Rota $rota) {}   // resolved from the container

    public function resolve(?Model $subject, array $args): iterable
    {
        return $this->rota->onCall(level: $args['level'] ?? 1);
    }
}

The group resolver and the teams recipe

The default ContractGroupResolver needs the group to implement ProvidesNotificationRecipients; anything else throws UnresolvableRecipientsException naming the group’s basename. Bind your own ResolvesGroupMembers through notifications.recipients.group_resolver — with teams-for-laravel (suggested, not required):

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Notifications\Contracts\ResolvesGroupMembers;
use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Member;

final class TeamsGroupResolver implements ResolvesGroupMembers
{
    public function members(Model $group, ?string $role = null): iterable
    {
        return Teams::for($group)->members()->all()
            ->reject->isExpired()                     // all() includes expired memberships
            ->filter(fn (Member $member): bool => $role === null || $member->role === $role)
            ->map->member;
    }
}

// config/notifications.php
'recipients' => ['group_resolver' => App\Notifications\TeamsGroupResolver::class],

Access checks

  • A type without a subject() and a send without a subject: always allowed.
  • Otherwise canAccess($recipient, $subject); the default asks the Gate ability notifications.access.ability (view) on the subject, for the recipient.
  • Fail-closed and loud: when the default is used and the subject has neither a policy nor the ability, the send throws MissingAccessCheckException naming the type key and the subject basename. Override canAccess() (or ->access() on a fluent type) to opt out.
  • It runs at every hop, always with a fresh subject (soft-deleted counts as gone unless allowsTrashedSubject()): the send, each fan-out job, mail delivery (queued, trailing, held), every digest entry, every reminder fire, every escalation, the interop listener and the release of a held interop mail. A recipient who lost access gets nothing.
// The default check — the Gate ability notifications.access.ability ('view') on the subject:
Gate::forUser($recipient)->inspect('view', $subject)->allowed();

// Your own, on a class type…
public function canAccess(Model $recipient, ?Model $subject): bool
{
    return $subject?->company->users()->whereKey($recipient->getKey())->exists() ?? true;
}

// …or a fluent one
Notifications::define('announcement.posted')->subject(Announcement::class)->access(fn ($recipient, $subject): bool => true);

Revoking access

Inbox reads are not re-checked by default — the inbox shows what was delivered. When access is removed, revoke it:

// Someone left the company: remove their items about this request and cancel their pending mail
Notifications::for($request)->revoke($formerMember);

// For everyone — also cancels the subject's pending reminders and escalations
Notifications::for($request)->revoke();

Or turn on notifications.inbox.recheck_on_read, which hides failing items per page (counts stay raw).

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.