A notification system often starts with a simple listener: an event happens, a few conditions are checked, and send is called.
The problem appears later. One listener checks permissions, another user preferences, another template activity, and another channels. After several features, the same rules live in different places.
I prefer a dedicated notification policy: one component decides whether a notification should exist, who receives it, and which channels are allowed. The listener only forwards the business event.
A Listener That Knows Too Much
public function handle(PostPublished $event): void{ $user = $event->post->author; if ($user === null) { return; } if (! $user->can('posts_create')) { return; } if (! $this->template->isActive()) { return; } This looks acceptable while there is only one listener. Once the same checks appear in scheduled publication, account events, and system reminders, each listener starts carrying its own version of the policy.
Keep the Listener Thin
The listener should know what happened and pass a typed context forward. It should not independently decide the recipient, preferences, template, channels, and global delivery rules.
public function handle(PostPublished $event): void{ $decision = $this->policy->decide( NotificationContext::forPost( post: $event->post, trigger: NotificationTrigger::ScheduledPostPublished, ), ); $this->delivery->deliver($decision);}The Policy Should Return a Structured Decision
final readonly class NotificationDecision{ public function __construct( public bool $shouldSend, public ?User $recipient, public ?NotificationTemplate $template, public string $locale, public array $channels, public ?string $eventKey, public ?string $skipReason, ) { }}A simple true or false is not enough. The delivery service needs the recipient, template, locale, channels, event key, and a reason when delivery is skipped.
What the Policy Decides
I would keep this order inside the policy:
- resolve the recipient;
- check business permission rules;
- resolve an active notification template;
- check the global automatic-delivery gate;
- read template channels;
- read channels from user preferences;
- build the effective channel set;
- build the event key for idempotency;
- return send or skip with a reason code.
Channels Are an Intersection of Rules
$channels = array_values( array_intersect( $template->enabledChannels(), $preferences->allowedChannels(), ),); if ($channels === []) { return NotificationDecision::skip( reason: 'no_allowed_channels', );}A template may support database, broadcast, and mail while user preferences disable mail. The effective channel set should come from one resolver rather than separate if statements in the listener.
Use One Global Delivery Gate
If automatic notifications can be globally disabled, that check should not be copied into every listener. One gate gives all automatic notification flows the same operational behavior.
Policy Decides, Delivery Service Executes
The policy answers "what is allowed?" The delivery service answers "how should it be delivered safely?"
The delivery service can own the idempotency ledger, database notification creation, broadcast, mail, retries, and technical outcomes. It should not rediscover the recipient or preferences.
$decision = $policy->decide($context); if (! $decision->shouldSend) { return DeliveryResult::skipped( $decision->skipReason, );} return $trackedDelivery->send( recipient: $decision->recipient, template: $decision->template, channels: $decision->channels, eventKey: $decision->eventKey,);Reason Codes Make Skips Observable
If a listener simply returns, QA cannot easily explain why. Reason codes such as missing_recipient, permission_denied, template_inactive, delivery_disabled, or no_allowed_channels make the decision observable.
Do Not Build One Giant Policy
Centralization does not mean one thousand-line class. Shared rules remain in reusable services: template resolver, channel resolver, preference resolver, and delivery gate.
A trigger-specific policy contains only the business-specific rules: who the recipient is, what makes them eligible, and how the event key is built.
A Practical End-to-End Flow
- a domain event or scheduler discovers the business event;
- the listener builds a typed context;
- the trigger policy resolves recipient and eligibility;
- the template resolver finds the active template;
- the delivery gate checks automatic delivery;
- the channel resolver combines template settings and user preferences;
- the policy returns a NotificationDecision;
- tracked delivery reserves the event key and executes the channels;
- the result is recorded as delivered, skipped, already delivered, or failed.
Policy Tests Become Simpler
The policy can be tested without actual mail or broadcast delivery:
- missing recipient - skip;
- permission revoked - skip;
- template inactive - skip;
- global gate off - skip;
- mail disabled by preferences - mail is absent from channels;
- database + broadcast allowed - both are returned.
public function test_mail_is_removed_when_user_opted_out(): void{ $decision = $this->policy->decide( $this->context( templateChannels: [ 'database', 'broadcast', 'mail', ], userChannels: [ 'database', 'broadcast', ], ), ); The listener should know what happened. The policy should decide whether a notification is needed. The delivery service should safely execute that decision.
Summary
Scattered if statements in listeners work only while notification rules are simple. Once templates, permissions, global gates, user preferences, multiple channels, and idempotency appear, the decision belongs in a policy layer.
This provides one place for the business decision, consistent behavior across triggers, observable reason codes, and simpler tests.



