Recipient Resolver Registry
Contract for registering recipient-rule resolvers, collecting recipients across rule sets, and deduplicating accounts before dispatch.
Overview
App\Services\Notifications\RecipientResolverRegistry is the recipient-selection seam for notifications. It maps a recipient-rule type to an App\Contracts\Notifications\RecipientResolver, then evaluates rule lists into a deduplicated account collection before notification jobs are queued.
Core binds the registry as a singleton in AppServiceProvider and currently registers five built-in resolver types there:
subject_ownerrolespecific_accounttrigger_actorcontact
Plugins currently register additional resolver types through PluginRegistrar::recipientResolver().
Public surface
| Method | Purpose |
|---|---|
register() |
Store one resolver instance under its type() |
get() |
Return the resolver for a type or throw if it is missing |
has() |
Check whether a resolver type is registered |
types() |
Return all registered resolver types, sorted alphabetically |
resolveAll() |
Evaluate a rule list into a deduplicated Collection<int, Account> |
Accepted contract and identity
Every resolver implements App\Contracts\Notifications\RecipientResolver:
public function type(): string;
public function resolve(array $rule, NotificationContextData $context): Collection;
type() is the stable registry identity and the value callers place in recipient rules:
['type' => 'specific_account', 'account_id' => 123]
NotificationContextData carries the current notification type, title model, and optional actor account. Resolvers inspect that context to decide which accounts should receive the notification.
Current core resolver types
The shipped registry currently exposes these resolver types:
| Type | Rule shape | Current behavior |
|---|---|---|
subject_owner |
['type' => 'subject_owner'] |
Resolves the title's owned_by account, including shortage waitlist monitor indirection |
role |
['type' => 'role', 'role' => 'Ops Lead'] |
Resolves accounts linked to users assigned to the named Spatie role |
specific_account |
['type' => 'specific_account', 'account_id' => 123] |
Resolves one account by an integer or numeric account_id |
trigger_actor |
['type' => 'trigger_actor'] |
Resolves the actor carried in NotificationContextData |
contact |
['type' => 'contact'] |
Resolves the title's account_id contact |
The role resolver also accepts name as a direct-resolution alias for role. That is resolver behavior at the registry boundary, not a normal settings-storage schema: normal notification-settings sanitisation only recognises role and drops a role rule without that property. A rule using only name can therefore work when passed directly to resolveAll() but will not survive the standard notification-settings save path. The same sanitiser requires specific_account rules to carry a non-empty account_id.
Type ordering, rule evaluation, and recipient collection
Type ordering
types() returns the registered type keys sorted alphabetically. This is a catalogue/discovery order only; it does not change how a given rule list is evaluated.
Rule evaluation
resolveAll($rules, $context) processes the supplied rules in the order they are given:
- read
typefrom each rule - skip the rule if
typeis missing, not a string, or not registered - resolve the rule through the matching resolver
- merge the returned accounts into the running collection
Each resolver may return zero, one, or many accounts.
Resolver exceptions propagate immediately. resolveAll() does not catch an exception and does not return recipients collected from earlier rules; the caller receives the exception instead of a partial collection.
Recipient collection behavior
After all rules run, the registry:
- filters the merged collection down to actual
Accountinstances - deduplicates by account id
- reindexes the final collection with
values()
This means a rule can safely overlap another rule without producing duplicate recipients.
Invariants and failure modes
Duplicate type registrations
Resolver types are stored in an associative array keyed by type(). Registering the same type again replaces the earlier resolver instance. The registry does not reject duplicates.
Direct lookup failures
get($type) throws InvalidArgumentException with Unknown recipient resolver type [{$type}]. when the type is missing.
Batch resolution failures and skips
resolveAll() does not throw for unknown rule types. It silently skips rules whose type is missing, malformed, or not registered.
If a resolver returns values that are not Account instances, the registry drops them during the final collection filter.
If a registered resolver throws, batch resolution aborts. Unknown types are skipped before invocation, but failures from a resolver that is actually called are not converted into a skip or an empty result.
Runtime consumption
App\Actions\Notifications\DispatchNotification is the primary consumer. It builds NotificationContextData, resolves the effective recipient rules from the notification type or override payload, then calls:
$recipients = $this->recipientResolvers->resolveAll($rules, $context);
Each resolved account is then paired with logical-channel resolution and queued SendNotification work. If resolveAll() returns an empty collection, nothing is dispatched.
Worked example
This is the current pattern exercised in tests:
$recipients = app(RecipientResolverRegistry::class)->resolveAll([
['type' => 'subject_owner'],
['type' => 'specific_account', 'account_id' => $account->id],
], $context);
If both rules resolve to the same account, the final collection still contains that account only once because the registry deduplicates by account id before returning.