ADR 0003 — MailableScanner + NotificationScanner: separate sections, shared dispatch-site machinery¶
Status: Accepted (2026-05-19)
References:
docs/contributing/scanners-internals.md,
docs/contributing/scanners-internals.md. This ADR
captures only the load-bearing decisions for issue #6 (v1.0 milestone).
Context¶
Mailables (Mail::send(new OrderShipped(...))) and notifications
($user->notify(new InvoicePaid)) are dispatch-shaped primitives the index
does not yet record. Both share Loom's existing job pattern: a class
declared somewhere under app/, optionally implementing ShouldQueue,
referenced by call sites scattered across the app.
The hard design questions:
- One unified
messages[]section with akinddiscriminator, or two separate sections. - Discovery surfaces — which call shapes count as a dispatch and which filesystem locations seed class discovery.
- How to extract notification
channels[]given thatvia()is a method body, not a declarative annotation. - How dispatch-site detection wires into the existing
DispatchSiteVisitorand the cross-link pass. - What happens to
Mail::raw(...)and other closure-only forms with no FQCN.
Decision¶
1. Two sections — mailables[] and notifications[]¶
Both are emitted as separate top-level arrays. They share machinery
(filesystem walk + dispatch-site seeding, ShouldQueue resolution via
the class hierarchy resolver, cross-link by FQCN) but they do not share
shape.
Per-entry shape diverges:
| Field | Mailable | Notification |
|---|---|---|
fqcn |
yes | yes |
file, line |
yes | yes |
queued |
yes | yes |
queue_config |
yes (mirrors jobs) | yes (mirrors jobs) |
sent_from[] |
yes | — |
notified_from[] |
— | yes |
channels[] |
— | yes |
A unified section would force every consumer to filter on kind before
reading the channel-bearing entries, and would either bloat mailables
with a null channels or split the per-kind required-fields list via
oneOf. Neither buys anything: downstream consumer questions ("which
classes send mail to users", "which notifications hit Slack") naturally
divide along the same line the schema does.
The shared machinery is reused at the code level (a small
MessageClassVisitor base, or duplicated visitor methods — left to
ast-specialist to pick). The schema and the docs stay separate.
Rejected: unified messages[] with kind: "mailable" | "notification".
Per-entry polymorphism via oneOf is exactly the kind of schema surface
ADR 0001 warned about — bigger surface, no consumer benefit.
2. Discovery — filesystem walk + dispatch-site seeding (mirror jobs)¶
Each scanner uses the two-path discovery already proven by
JobsScanner:
MailableScanner:
- Filesystem walk of
app/Mail/**/*.php. Every concrete class (skip abstract, interfaces, traits, anonymous) is a mailable candidate. - Dispatch-site seeding from these call shapes (resolved via NameResolver):
Mail::send(new X(...)),Mail::send(X::class)Mail::queue(new X(...)),Mail::queue(X::class)Mail::later($delay, new X(...))Mail::to(...)->send(new X(...)),->queue(new X(...)),->later($delay, new X(...)). Also chained off->cc(...),->bcc(...),->locale(...),->mailer(...)— the visitor walks to the innermostMail::to/cc/bcc/locale/mailerreceiver and accepts the chain regardless of which methods sit in between.Mailable::raw(...)is out (see §5 below).
NotificationScanner:
- Filesystem walk of
app/Notifications/**/*.php. - Dispatch-site seeding from:
$any->notify(new Y(...))and$any->notify(Y::class)— receiver is opaque (any expression). The visitor accepts because Laravel'sNotifiabletrait is the canonical receiver and matching the method name is sufficient for static analysis (see §6).$any->notifyNow(new Y(...))— synchronous variant.Notification::send($recipients, new Y(...)),Notification::sendNow($recipients, new Y(...))— argument index 1 carries the notification.Notification::route('mail', '...')->notify(new Y(...))— same chain-walk pattern asMail::to(...)->send(...).
Both scanners locate dispatch-site-seeded targets via
Psr4ClassLocator::locate() (the same helper JobsScanner uses) so
mailables in app/Domain/Billing/Mail/InvoiceMailable.php and
notifications in app/Domain/Accounts/Notifications/InvitedNotification.php
are picked up.
Entries are deduped by FQCN. Filesystem walk wins for file/line.
3. Queue detection — reuse the class hierarchy resolver¶
queued: true iff the class transitively implements
Illuminate\Contracts\Queue\ShouldQueue via
ClassHierarchyResolver::implementsInterface(). Identical mechanism
to JobsScanner. Vendor-parent opacity carries the same caveat
(documented per-scanner).
queue_config is the same six-field block as jobs (connection,
queue, delay, tries, timeout, backoff) — both mailables
(via Queueable trait usage) and notifications (via Queueable)
honour those properties at runtime. We reuse $defs/queueConfig
verbatim.
4. Notification channels — static via() only, with documented fallback¶
The via($notifiable) method returns the channel array at runtime. We
statically analyse only the simplest shape: a method body whose single
top-level statement is return [...]; where the array is a literal
list of strings and / or Class::class constants.
- String channels (
'mail','database','slack','broadcast','vonage') are stored verbatim, lowercased. - Class-constant channels (
SlackChannel::class,TelegramChannel::class) are stored as the FQCN.
Channels are emitted in source order from the via() literal — not
sorted. This matches Laravel's runtime dispatch order over the channel
array and preserves user intent (['database', 'mail'] versus
['mail', 'database'] is meaningful to a reader auditing the file).
channels_dynamic distinguishes two cases:
via()exists but its body is not the recognised single-return-literal-array shape (conditional branches, property access, variable interpolation, non-literal items, keyed entries):channels: [],channels_dynamic: true. The channels exist at runtime; static analysis cannot pin them down.via()is not declared on the class at all:channels: [],channels_dynamic: false. The class declares no channels — an intentional zero, not unknown. (At runtime this would throw, but the scanner does not crash on it.)
Consumers who want "send-to-Slack notifications" filter on "slack" in
channels; consumers who want to triage dynamic notifications filter on
channels_dynamic: true. We do not emit null — the schema would
have to allow it everywhere, and an empty array iterates naturally.
Rejected: parsing conditional branches and emitting the union of
possible channels. Too lossy in practice (most conditional via()
bodies key off $notifiable->prefersMail()-style logic that's
runtime-only); the dynamic flag is honest about the gap.
5. Mail::raw($body, $closure) — skipped¶
No FQCN, no class to record. Mirrors the closure-listener precedent:
when there is no FQCN, the primitive does not appear in the index.
Documented as a known limitation. There is no unresolved_mailables[]
mirror — the unresolved framing only fits the dispatch-target shape
where static resolution has a well-defined fail mode (the four reason
codes in $defs/unresolvedDispatch). A closure body is not the same
kind of gap.
If Mail::send($variable) / Mail::send($container->make(...)) appears
in source code, the dispatch site is captured by DispatchScanner as a
standard unresolved_dispatches[] entry (see §6). The mailable itself
just doesn't get a mailables[] row.
6. Cross-link — extend DispatchSiteVisitor, populate from _dispatch_sites[]¶
The dispatch-site machinery already in DispatchSiteVisitor is widened
to recognise the new shapes, emitting them into _dispatch_sites[]
with new provisionalKind values:
provisionalKind: "mailable"forMail::send,Mail::queue,Mail::later, and theMail::to(...)->send(...)chain.provisionalKind: "notification"for$x->notify,$x->notifyNow,Notification::send,Notification::sendNow, and theNotification::route(...)->notify(...)chain.
These join the existing event, job, and ambiguous provisional
kinds. Cross-link phase 5 in IndexBuilder::crossLink() is widened:
mailables[*].sent_fromis populated from sites where finalizedkind === 'mailable'andtargetmatches a mailable entry.notifications[*].notified_fromis populated from sites where finalizedkind === 'notification'andtargetmatches a notification entry.
Both use the existing $defs/dispatchSite shape ({file, line, method}),
identical to events[*].dispatched_from and jobs[*].dispatched_from.
This preserves Loom's single-source-of-truth invariant
(AGENTS.md: "one source of truth per output field").
The scanners emit sent_from: [] / notified_from: []; cross-link
fills them.
Unresolved sites (Mail::send($variable), $user->notify($x)) flow
into unresolved_dispatches[] via the same four reason codes already
in the schema. No new reasons.
Rejected: each scanner independently walking app/ looking for its
own dispatch sites. Duplicates work, drifts from the existing
DispatchScanner-as-single-source pattern. The cost of widening
DispatchSiteVisitor is one new shape per dispatch form — much
smaller than two new walks.
7. Dispatch-site chaining — class-level config only¶
Chained ->onQueue('foo'), ->onConnection('bar'), ->delay($when),
->cc(...), ->bcc(...), ->locale(...) are not parsed into the
mailable / notification entry. queue_config reflects class-level
property declarations only. Same constraint as jobs (documented in
jobs.md); we mirror it rather than diverge.
The chain methods are walked only insofar as needed to locate the
target argument (the new X somewhere along the chain).
8. Notifiable receiver inspection — out of scope¶
A dispatch site $user->notify(new InvoicePaid) does not carry the
receiver's class. We do not attempt to type-resolve $user (would
require a function-level type inference pass that no other scanner
needs). The target FQCN — the notification class — is sufficient
to populate notifications[*].notified_from.
Receiver-class information ("which models receive InvoicePaid") is a
plausible future enhancement, but it requires either an explicit
type-tracking pass or merge with a future routes/controllers
scanner that knows the user is App\Models\User. Out of scope here.
9. Schema additions¶
Three new schema sites:
- Two new top-level required properties:
mailables,notifications. - Two new
$defsentries:$defs/mailable,$defs/notification. statsgains two new required integer properties:stats.mailables,stats.notifications.
$defs/mailable:
{
"type": "object",
"required": ["fqcn", "file", "line", "queued", "queue_config", "sent_from"],
"additionalProperties": false,
"properties": {
"fqcn": { "type": "string" },
"file": { "type": "string" },
"line": { "type": "integer", "minimum": 1 },
"queued": { "type": "boolean" },
"queue_config": {
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/queueConfig" }]
},
"sent_from": {
"type": "array",
"items": { "$ref": "#/$defs/dispatchSite" }
}
}
}
$defs/notification: adds channels[] (array of strings) and
channels_dynamic (boolean) over the same base; replaces sent_from
with notified_from.
Index.php and IndexBuilder.php both need updating — the schedule
scanner taught us that adding a new top-level section requires
touching both the value object and the orchestrator's stats / strip
logic. schema-guardian reviews the schema delta; test-engineer
adds fixture coverage.
Consequences¶
Good:
- Two sections keep per-entry shapes tight. Consumers can target "mail" or "notifications" workloads without an intermediate filter.
- All the heavy machinery (dispatch-site detection, queue resolution,
PSR-4 location) is reused, not reinvented. One change to
DispatchSiteVisitorcovers both new primitives. - The cross-link pass scales by adding a single conditional branch
per new
provisionalKind. The existing phase-5 join shape is identical ($defs/dispatchSite); no new join shape. channels_dynamicis honest about the static-analysis ceiling without forcing nullable arrays through the schema.
Costs:
- Two scanners means two scanner files, two visitor classes (or one
shared visitor base —
ast-specialist's call), two doc pages. ~2x lines vs. a unified scanner. The clarity win is worth it. DispatchSiteVisitorgrows two newprovisionalKindvalues. The cross-link disambiguation table grows. Reviewer load on that visitor goes up; mitigated by the existing per-form helper methods, which the new shapes plug into.- Channel extraction's
channels_dynamic: trueshortcut may surprise users on first contact ("why ischannelsempty?"). Doc triage step covers it. - Receiver-class blindness (
$user->notify(...)with unknown$usertype) means we can't answer "which models receive which notifications" without a follow-up. Documented as a known limitation; not a v1.0 blocker.
Alternatives considered¶
- Unified
messages[]section. Rejected: forces per-entry polymorphism (oneOfperkind), bloats consumers with a filter they wouldn't otherwise need. The "send messages to users" consumer interest argument is real but solved cheaply with a client-side concat. - Resolve
via()channels across conditional branches. Rejected: most realvia()bodies branch on per-user preference, which is not statically determinable. Emit the dynamic flag and move on. - Independent dispatch-site walks per scanner. Rejected:
duplicates
DispatchSiteVisitor's class+method stack and closure handling. Centralising in DispatchScanner keeps one source of truth for dispatch-site detection. - Add
mailables[*].sent_fromandnotifications[*].notified_fromin the scanners directly. Rejected: violates the cross-link single-source rule. Same reasonjobs[*].dispatched_fromis cross-link-only. - Include
Mail::raw(...)as atarget: nullmailable entry keyed by file:line. Rejected: no FQCN, no class — mirrors the closure-listener decision. Schema does not currently permittarget: nullon mailables, and widening it for one rare case is not worth it. - Receiver-class resolution via lightweight type tracking. Rejected for v1 scope. Plausible follow-up alongside the routes/controllers scanner.
Open questions¶
MailMessagereturned fromvia('mail')handlers. A notification'stoMail()method returns aMailMessage. Should the notification entry surface amail_subject/mail_viewfield? Probably no — that's content, not control flow, and Loom's scope is control flow. Flag for review.#[OnConnection]/#[OnQueue]PHP attributes on mailables. Laravel supports these on jobs in some recent versions. If used on mailables/notifications, should we lift them intoqueue_config? Probably yes, parity with jobs; but that's a parity gap for jobs first, separate issue.- Markdown-mailable view paths (
->markdown('emails.invoice')). Worth lifting into the entry? Same control-flow-vs-content question as (1). Lean: no.