SIGNALS Documentation
API Reference

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_owner
  • role
  • specific_account
  • trigger_actor
  • contact

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:

  1. read type from each rule
  2. skip the rule if type is missing, not a string, or not registered
  3. resolve the rule through the matching resolver
  4. 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 Account instances
  • 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.