Scanner internals¶
Contributor reference for how each scanner discovers, parses and cross-links its primitive. For what consumers can and cannot expect, see What Loom detects and Why was my code missed. For the pipeline as a whole see architecture.
Sections are per scanner. Known limitations and triage checklists live in the consumer pages above.
EventScanner¶
Discovers event classes and emits the events[] section of the index.
What it detects¶
EventScanner uses two discovery paths and merges them by FQCN:
-
Filesystem walk of
app/Events/. Every top-levelclassdeclaration with a non-nullnamespacedNamebecomes an event entry. Abstract classes are included. Interfaces and traits are skipped. Anonymous classes are skipped (they have no FQCN). -
Dispatch-site seeding across
app/. EventScanner walks every PHP file underapp/and records statically resolvable targets from: event(new SomeEvent(...))andevent(SomeEvent::class)(form: helper). The first argument is extracted even with trailing args, soevent(new SomeEvent($payload), $extraArgs)seedsSomeEvent. Plain-old-PHP-object events (noDispatchabletrait) are discovered this way.broadcast(new SomeEvent(...))andbroadcast(SomeEvent::class)(form: helper). The bare broadcast form seeds discovery the same wayevent(...)does, so broadcast-only events reachevents[]. (The conditionalbroadcast_if/broadcast_unlessforms are emitted as dispatch sites by DispatchScanner but are not used for discovery seeding here.)Event::dispatch(new SomeEvent(...))andEvent::dispatch(SomeEvent::class)(form: facade)SomeEvent::dispatch(...),SomeEvent::dispatchIf($cond, ...), andSomeEvent::dispatchUnless($cond, ...)(form: dispatchable). The conditional forms resolve to the same target asdispatch(...); the leading condition argument is ignored for resolution.
When a seeded target's FQCN isn't already known from the filesystem walk, EventScanner locates the class file through the PSR-4 map in the app's composer.json (App\ to app/ when it declares none), accepting only files inside a scan directory and not excluded. The class must exist on disk and contain the declared FQCN; otherwise the candidate is dropped.
The dispatchable form is subject to an extra filter: candidates are only accepted if their resolved file sits under an Events/ directory inside a scan directory. Without this filter, every job class using the Dispatchable trait would land in events[]. The helper and facade forms have no such filter — they are unambiguous event dispatches per Laravel's API.
Output¶
One entry per discovered event FQCN, conforming to $defs/event:
{
"id": "App\\Events\\OrderPlaced",
"fqcn": "App\\Events\\OrderPlaced",
"kind": "class",
"file": "app/Events/OrderPlaced.php",
"line": 12,
"dispatched_from": [],
"handled_by": []
}
dispatched_from and handled_by are emitted as empty arrays. They are populated by the cross-link pass in IndexBuilder using DispatchScanner and ListenerScanner output. EventScanner never writes to those fields directly.
Entries are sorted by fqcn ascending. id always equals fqcn (the schema reserves id for future kinds; kind is always "class").
Expected behavior¶
- Event class outside
app/Events/. Picked up when dispatched via the helper or facade form. Filesystem walk'sfile/linewins over the dispatch-site discovery (the class declaration is more authoritative than the call site). - Multiple classes in one file. Each top-level
Stmt\Class_produces its own entry. This is a PSR-4 violation but legal PHP, and the scanner handles it. - Abstract base event classes. Included. They have a file and line and may be referenced by subclasses.
- Trait usage on event classes. No special handling. The class itself is recorded; the trait is irrelevant.
- Dispatch site uses
\Fully\Qualified\Name. Resolved correctly via NameResolver — fully qualified names need nousestatement. - Dispatch site inside a closure. The dispatch-site visitor reads on
leaveNode, so closures are walked. Their resolved targets do contribute to event discovery (they're real references to event classes), but DispatchScanner skips emittingdispatched_fromfor sites inside closures. See dispatches.md. - Same event discovered via multiple paths. Deduped by FQCN. Filesystem walk wins for
file/line. - Parse errors in a file.
AstWalkerswallows them. The scanner sees no hits from that file.
ListenerScanner¶
Discovers event listeners and emits the listeners[] section of the index.
Closure-based listeners (arrow functions and
function () { … }values in$listen,Event::listen(), or subscriber return-arrays) are emitted into the separateclosure_listeners[]section instead — they have no FQCN to key against thelisteners[]shape. See closure-listeners.md.
What it detects¶
ListenerScanner uses four discovery paths and merges them by listener FQCN:
-
Auto-discovery via
app/Listeners/. Mirrors Laravel'sDiscoverEvents: every instantiable (not abstract, trait or interface) class inapp/Listeners/is resolved throughClassHierarchyResolver::effectiveMethods(), and each public method namedhandle*or__invokethat has a first parameter is a handler. Declared, inherited and trait-provided methods all count, with traitasrenames andinsteadofapplied; the effective method name is recorded. The events are the class names in the first parameter's type (a nullable or union type gives each class; builtin and intersection members give none;selfresolves to the declaring class). A matching method with no class type still lists the listener, withhandles: []. Classes that transitively implementIlluminate\Contracts\Queue\ShouldQueue— directly or via a parent class indexed underapp/— are markedqueued: true. -
$listenarray onEventServiceProvider. Walks the entireapp/tree (not justapp/Providers/) and looks at classes namedEventServiceProviderOR extendingIlluminate\Foundation\Support\Providers\EventServiceProvider. The$listenproperty (any visibility, must bearray) is parsed: eachEventClass::class => [Listener::class, …]pair becomes a registration. BareListener::classvalues map tomethod: "handle". Tuple values[Listener::class, 'method']preserve the method name. Resolvable callable values —Closure::fromCallable([Listener::class, 'method']),Closure::fromCallable([Listener::class])(method defaults to"handle"), andListener::method(...)first-class callable syntax — resolve to the same FQCN+method and route through the same merge as the literal tuple. -
listen()calls on the event dispatcher. Walks the entireapp/tree forlisten(EventClass::class, Listener::class)calls made against the event dispatcher — both the\Illuminate\Support\Facades\Event::listen(...)facade form and the container-resolved forms (see Container-form registrations below). The first argument may be a single event class-string OR an array of class-string events:Event::listen([Event1::class, Event2::class], $listener)binds the one listener to every event in the array and expands to one registration per event (each carrying the same listener FQCN+method). BareListener::classsecond arguments map tomethod: "handle"; tuple form[Listener::class, 'method']preserves the method name. Resolvable callable second arguments —Closure::fromCallable([Listener::class, 'method']),Closure::fromCallable([Listener::class]), andListener::method(...)first-class callable syntax — resolve identically and route through the same merge. The class-shape filter only applies in path 2 — these calls are scanner-agnostic about the surrounding class, so providers in DDD-style layouts (e.g.app/Domain/Invoicing/Providers/InvoicingServiceProvider.php) are discovered. All forms emitregistration: event_listen_call. -
Subscribers. Classes registered via the
$subscribe = [SubscriberClass::class, …]array on anEventServiceProvider, or viaEvent::subscribe(SubscriberClass::class)calls. The subscriber's ownsubscribe($events): arraymethod is then parsed in two complementary ways — see Subscribers below. Subscribers receiveregistration: "subscriber"— the highest-precedence source.
Subscribers¶
A Laravel event subscriber can wire its handlers in two forms inside its subscribe() method, and Loom parses both. A single subscriber can mix them — the contributions from each form are merged.
Return-array form. return [Event::class => 'method', Event::class => [self::class, 'method']]. Each pair contributes a {event, method} entry to the subscriber's own handles[]. Both bare-string method values and tuple values [self::class, 'method'] / [static::class, 'method'] / [OwnFqcn::class, 'method'] resolve to the subscriber itself.
Imperative form. A body that calls $events->listen(...) against the dispatcher parameter. The dispatcher is identified by parameter position — its name and type-hint are irrelevant. The visitor walks into control-flow constructs (if, foreach, try/catch, …) but does not descend into nested closures or other method bodies.
Routing rules for an imperative $events->listen(EventClass::class, $callable) call:
| Callable shape | Routes to |
|---|---|
[self::class, 'method'], [static::class, 'method'], or [OwnFqcn::class, 'method'] |
Subscriber's own handles[] (own FQCN) |
Bare string 'method' (Laravel binds bare-string callables to the subscriber instance) |
Subscriber's own handles[] |
[OtherClass::class, 'method'] |
Registers OtherClass as a regular listeners[] entry with registration: "subscriber" |
fn ($e) => … or function ($e) { … } |
Emitted into closure_listeners[] with registration: "subscriber" |
The third rule has a noteworthy consequence: when a subscriber imperatively wires a foreign listener (one not declared on the subscriber's own class), that subscriber becomes the registration-source for the foreign listener — its registration is upgraded to subscriber, the highest-precedence source. This matches Laravel's runtime semantics (the subscriber is responsible for the registration) but means a listener's registration can flip from a lower-precedence value to subscriber purely because some subscriber elsewhere chose to wire it.
The precedence rule (subscriber > listen_array > event_listen_call > auto_discovered) is unchanged; only what counts as a subscriber-sourced registration is broader.
Container-form registrations¶
Beyond the Event:: facade, a listen() call against an event dispatcher resolved from the container is recognized. These route through the same path-3 merge and emit registration: event_listen_call — there is no separate registration source and no schema change.
The dispatcher is identified by FQCN: Illuminate\Contracts\Events\Dispatcher, Illuminate\Events\Dispatcher, or the bare Dispatcher basename. The recognized receiver shapes are:
| Receiver | Example |
|---|---|
App container array-access (literal 'events' key) |
$this->app['events']->listen($event, $listener) |
app() helper with a Dispatcher FQCN |
app(\Illuminate\Contracts\Events\Dispatcher::class)->listen(...) |
resolve() helper with a Dispatcher FQCN |
resolve(Dispatcher::class)->listen(...) |
$this->app->make(...) / ->makeWith(...) with a Dispatcher FQCN |
$this->app->make(Dispatcher::class)->listen(...) |
| A local variable assigned from any of the above earlier in the same scope | $dispatcher = app(Dispatcher::class); … $dispatcher->listen(...) |
Every listener form supported on the facade is supported here too: Listener::class, the tuple [Foo::class, 'method'], Closure::fromCallable([...]), the first-class Foo::method(...) form, and inline closures (which route to closure_listeners[] — see closure-listeners.md).
Variable tracking is a single flat pass within the file (assignment-precedes-use): the variable must be assigned from a recognized dispatcher expression before the listen() call. Reassigning the variable to a non-dispatcher value invalidates tracking from that point.
Output¶
One entry per listener FQCN, conforming to $defs/listener:
{
"fqcn": "App\\Listeners\\SendOrderConfirmation",
"file": "app/Listeners/SendOrderConfirmation.php",
"line": 14,
"handles": [
{ "event": "App\\Events\\OrderPlaced", "method": "handle" }
],
"registration": "listen_array",
"queued": true,
"dispatches": []
}
dispatches is always emitted as an empty array. It is populated by the cross-link pass from DispatchScanner's per-call-site data.
registration is set per the precedence rule: subscriber > listen_array > event_listen_call > auto_discovered. When a listener is discovered through multiple paths, the entry's registration reports the highest-precedence source observed.
handles[] is a list of {event, method} pairs. Both fields are always present; method defaults to "handle" when the registration didn't name one (auto-discovery, bare Listener::class in $listen, bare Listener::class in Event::listen()). Entries are deduped by the (event, method) tuple and sorted by event ascending then method ascending. A single listener can have multiple handles[] entries for the same event under different methods.
Entries are sorted by fqcn ascending.
Multi-handler listeners¶
A single listener class can declare multiple handler methods and be registered against different events under different methods. Loom represents each registration as its own handles[] entry:
// EventServiceProvider
protected $listen = [
OrderPlaced::class => [
[SendNotifications::class, 'handleOrderPlaced'],
],
OrderRefunded::class => [
[SendNotifications::class, 'handleOrderRefunded'],
],
];
emits a single listeners[] entry:
{
"fqcn": "App\\Listeners\\SendNotifications",
"handles": [
{ "event": "App\\Events\\OrderPlaced", "method": "handleOrderPlaced" },
{ "event": "App\\Events\\OrderRefunded", "method": "handleOrderRefunded" }
],
"registration": "listen_array"
}
The same event handled by different methods on one listener ([Listener::class, 'foo'] and [Listener::class, 'bar'] against the same event) produces two handles[] entries — the dedupe key is the full (event, method) tuple.
Expected behavior¶
- Listener registered via multiple paths. Single entry.
handlesis the union of(event, method)tuples;registrationis the highest-precedence source. - Listener with typed
handle(OrderPlaced $event). Auto-discovered.handles: [{ "event": "App\\Events\\OrderPlaced", "method": "handle" }](or whatever the resolved type-hint is). - Listener with
handle()and no parameter, an abstract class, oruse T { handle as protected; }. Not discovered, because Laravel skips them. - Listener with
handle($event)(no type-hint). Auto-discovered withhandles: []. The listener is still registered; it just doesn't auto-discover a target event. Other paths may still add entries. - Listener listed in
$listenbut located outsideapp/Listeners/. Picked up via the PSR-4 guess (leadingApp\→app/). If the file can't be located on disk, the listener is dropped (the schema requiresfileandline). $listentuple form[Listener::class, 'method']. Both the listener FQCN and the method name are recorded. The resultinghandles[]entry is{event: …, method: "method"}.Event::listen(Event::class, [Listener::class, 'method']). Method name preserved. BareEvent::listen(Event::class, Listener::class)maps tomethod: "handle".Event::listen([Event1::class, Event2::class], Listener::class). The array of class-string events expands to one registration per event — the listener'shandles[]gains an entry for each event in the array.- Resolvable callable forms.
Closure::fromCallable([Listener::class, 'method'])andListener::method(...)first-class callable syntax both resolve to a regularlisteners[]entry forListener::method. The single-elementClosure::fromCallable([Listener::class])mirrors bare::classand maps tomethod: "handle". Valid in both the$listenarray (listen_array) andEvent::listen()(event_listen_call); the resolved FQCN+method flows through the same merge as the literal[Listener::class, 'method']tuple. Event::listen()inside a non-provider class. Discovered. TheEvent::listenvisitor doesn't filter by surrounding class shape — it accepts any static call.Event::listen(\Illuminate\Support\Facades\Event::class, ...)fully qualified. Resolved correctly via NameResolver.ShouldQueueimplemented transitively.queued: truewhenever any class in the listener'sextendschain (or the listener itself) carriesimplements ShouldQueue. Resolution uses the cross-file class hierarchy resolver.
Closure Listeners¶
Discovers closure-based event listener registrations and emits the closure_listeners[] section of the index.
Closures and arrow functions cannot be represented in listeners[] because they have no FQCN. They get their own top-level section, with one entry per closure registration site.
closure_listeners[] is for genuinely anonymous, unresolvable closures. A callable that resolves to a concrete class and method — including Closure::fromCallable([Foo::class, 'method']) and the Foo::method(...) first-class callable form — is a regular listener and lands in listeners[], not here. See listeners.md.
What it detects¶
Three discovery paths:
-
Closure value inside
$listenarray.protected $listen = [OrderPlaced::class => [fn ($e) => …]](or a long-formfunction ($e) { … }) on a class namedEventServiceProviderOR extendingIlluminate\Foundation\Support\Providers\EventServiceProvider. Emitted withregistration: "listen_array". -
Closure as the second argument of a dispatcher
listen()call.Event::listen(OrderPlaced::class, fn ($e) => …)anywhere underapp/. Emitted withregistration: "event_listen_call". The class-shape filter that applies to$listenwalks does NOT apply here — any qualifyinglisten()call does. This covers both theEvent::facade form and the container-resolved dispatcher forms ($this->app['events']->listen(...),app(Dispatcher::class)->listen(...),resolve(Dispatcher::class)->listen(...),$this->app->make(Dispatcher::class)->listen(...), and a local variable assigned from one of those) — see the ListenerScanner container-form registrations for the exact receiver shapes and their limitations.
Inferred event. Event::listen(function (OrderPlaced $e) { … }) (closure or arrow function as the first argument) takes the event from the first parameter's type, resolved through use imports. Nullable hints unwrap; union hints emit one entry per class. Untyped, object, mixed or other builtin hints have no event and are skipped.
- Closure inside a subscriber's
subscribe()body — either as a return-array value (return [OrderPlaced::class => fn ($e) => …]) or as the second argument to an imperative$events->listen(OrderPlaced::class, fn ($e) => …)call against the dispatcher parameter. Applies to any class registered as a subscriber (via$subscribearray orEvent::subscribe(...)). Both sub-cases emit withregistration: "subscriber".
Both Closure (long-form function ($e) { … }) and ArrowFunction (fn ($e) => …) are detected. The event key may be a ::class reference or a raw string ('user.created').
Output¶
One entry per closure registration site, conforming to $defs/closureListener:
{
"event": "App\\Events\\OrderPlaced",
"file": "app/Providers/EventServiceProvider.php",
"line": 38,
"end_line": 41,
"registration": "event_listen_call",
"queued": false,
"dispatches": [
{
"target": "App\\Events\\OrderConfirmationSent",
"kind": "event",
"confidence": "high",
"file": "app/Providers/EventServiceProvider.php",
"line": 40
}
]
}
eventis always astring. FQCN for::classregistrations, raw string for'user.created'-style registrations.file/linepoint to the closure node itself, not the surrounding registration call.lineis the closure's opening line.end_lineis the closure body's closing line. Together[line, end_line]is the closure's source span — the span used to attribute dispatch sites (seedispatches).registrationenum:listen_array,event_listen_call,subscriber.queuedis alwaysfalse. Closure-queue detection is out of scope.dispatchescarries the events and jobs dispatched inside the closure body. Empty when the body dispatches nothing. See below.
dispatches¶
Each entry in dispatches[] is a dispatch object conforming to $defs/dispatch — the same shape used by listeners[*].dispatches:
{ "target": "App\\Events\\OrderConfirmationSent", "kind": "event", "confidence": "high", "file": "...", "line": 40 }
target— FQCN of the dispatched event or job.kind—eventorjob.confidence—high/medium/low.file/line— the dispatch site.
Attribution is positional, by source span. A closure listener has no class or method identity to key on — unlike listeners[*].dispatches, which matches a dispatch to its enclosing listener by class plus method. So closures match by line instead: a dispatch site is attributed to a closure listener when it sits in the same file and its line falls within [line, end_line] inclusive.
Only resolved (statically-known) dispatches are captured in dispatches[]. A dispatch with a dynamic target inside a closure body goes to unresolved_dispatches[] like any other.
The closure listener is a registration closure and owns every site in its span. A closure that is merely passed along (DB::transaction(fn () => ...), each, tap, afterCommit, a variable-assigned closure) is pass-through: ClosureOwnershipPhase clears its inClosure tag so the sites are attributed to the enclosing class method instead. A route closure is the other registration closure; see routes.
Entries are sorted by (event, file, line) ascending for determinism.
Expected behavior¶
- FQCN event key.
Event::listen(OrderPlaced::class, fn ($e) => …)→event: "App\\Events\\OrderPlaced". - String event key.
Event::listen('user.created', fn ($e) => …)→event: "user.created". - Arrow function vs long-form closure. Both forms are detected and produce identical output. The
event,file, andlineare recorded against the closure node. - Mixed
$listenarrays. When$listen = [OrderPlaced::class => [SendNotifications::class, fn ($e) => …]], the class entry flows intolisteners[]and the closure entry flows intoclosure_listeners[]. Each path emits independently; nothing is dropped. - Subscriber return-arrays with mixed values.
return [OrderPlaced::class => 'handlerMethod', OrderRefunded::class => fn ($e) => …]contributes the string method to the subscriber'slisteners[*].handles[]and the closure toclosure_listeners[]. - Multiple closure registrations against the same event. Each registration site is its own entry. No dedupe by
event. - Dispatches inside the closure body. A resolved
event(new Foo)/dispatch(new Bar)inside the closure populates that entry'sdispatches[]. An empty (or dispatch-free) closure keepsdispatches: []. - No reverse edge. A dispatch made inside a closure listener appears in that closure's
dispatches[], but the target event's or job's reversedispatched_from[]does not list the closure as a source. This mirrors theevents[*].handled_bydesign: closures have no nameable identity to record on the other side of the edge. Consumers asking "where isFoodispatched from?" won't see closure listeners indispatched_from. - Nested closures. A dispatch sitting inside a closure nested within the listener closure is attributed to every enclosing closure-listener span that contains it. Overlapping spans only arise through nesting.
ObserverScanner¶
Discovers Eloquent observers and emits both observers[] and model_events[] sections of the index.
This is the only scanner that emits to two top-level schema sections. Both come from the same discovery walk; the dual emission is bundled here because the data is the same.
What it detects¶
ObserverScanner uses three discovery paths and emits both observer entries and synthetic model-event entries:
-
#[ObservedBy(Observer::class)]attribute on models. Walksapp/, finds top-level classes carrying an attribute whose resolved name isIlluminate\Database\Eloquent\Attributes\ObservedBy. The class carrying the attribute is the model; the attribute's::classarguments are the observer(s). The array form#[ObservedBy([A::class, B::class])]produces multiple registrations. -
Model::observe(Observer::class)static calls. Walksapp/, matchesExpr\StaticCallwith method nameobserve. The receiver determines the model: User::observe(...)— model is the resolved FQCN on the leftstatic::observe(...)/self::observe(...)inside a class body — model is the enclosing class FQCNparent::observe(...)— skipped$this->observe(...)— skipped (not a static call)
The argument can be Observer::class, or an array of ::class references for multiple observers.
Event::listen('eloquent.{hook}: {Model}', $handler)listener strings. Walksapp/, matchesEvent::listen()calls with a literal first-arg string of the formeloquent.{hook}: {ModelFQCN}(with or without the space after the colon). The hook must be one of the canonical Eloquent hooks. The handler can be:'Class@method'string[Observer::class, 'method']arrayObserver::classwith no method (defaults to the hook name)- Closures and dynamic args are skipped
Path 3 contributes to model_events[] only, never to observers[]. The handler may not be a true observer class — promoting it to observers[] with a synthetic single-hook hooks list would misrepresent it.
For each observer discovered through paths 1 or 2, the scanner enumerates hook methods. It locates the observer's file (from the in-memory class-to-file map built during the walk, with PSR-4 fallback) and resolves the observer's effective methods (declared, trait-provided and inherited; a vendor parent is opaque), keeping those named after an observable Eloquent event (ModelHook::observableValues()). Laravel registers an observer method when method_exists is true, so visibility is not a filter. booting and booted are fired as model events but are not observable, so they are never hooks.
Output¶
observers[] entries ($defs/observer)¶
{
"fqcn": "App\\Observers\\UserObserver",
"file": "app/Observers/UserObserver.php",
"line": 9,
"observes": "App\\Models\\User",
"registration": "attribute",
"hooks": ["created", "deleted", "updated"],
"dispatches": []
}
One entry per (observerFqcn, modelFqcn) pair. An observer registered against multiple models produces multiple entries.
registration precedence when the same pair is found via both paths: attribute > observe_call. The attribute is the modern Laravel 11+ pattern declared directly on the model — when both exist, it wins.
hooks is sorted ascending. dispatches is always emitted as an empty array (populated by the cross-link pass from DispatchScanner).
model_events[] entries ($defs/modelEvent)¶
{
"id": "eloquent.created: App\\Models\\User",
"kind": "model_event",
"model": "App\\Models\\User",
"event": "created",
"handled_by": [
{ "handler": "App\\Observers\\UserObserver", "method": "created", "file": "app/Observers/UserObserver.php", "line": 8 }
]
}
One entry per (model, hook) pair. handled_by aggregates:
- Observer-hook references — every observer that observes
modeland hashookin itshookslist contributes{handler: ObserverFqcn, method: hook}at the observer class location - Path 3 handlers — every
Event::listen('eloquent.{hook}: {model}', ...)registration contributes{handler, method}at theEvent::listencall location
handled_by is deduped on handler::method (observer wins over Event::listen) and sorted by it. If a (model, hook) pair has no observer hook method and no path-C handler, no model_events entry is emitted — the section catalogs actual handlers, not every possible model event.
Entries are sorted by id ascending.
Canonical hook enum¶
The hook names recognized in both hooks[] (observer methods) and the model_events.event field:
retrieved, creating, created, updating, updated, saving, saved,
deleting, deleted, restoring, restored, replicating, trashed,
forceDeleting, forceDeleted, booting, booted
booting and booted are valid model_events.event values (from eloquent.booted: {Model} strings) but never observer hooks[]: Laravel's observable-event list stops at forceDeleted.
Expected behavior¶
- Observer registered via both attribute and
Model::observe(). Single entry per(observer, model)pair withregistration: attribute. static::observe(UserObserver::class)insideUser::booted(). Model resolves toUservia the enclosing class context.- Observer registered against multiple models. Multiple entries (one per model). The schema's single-string
observesconstraint requires this. - Observer with no hook methods that match the canonical enum. Still emitted with
hooks: []. The registration exists; the hook list is empty. - Observer class with mixed-visibility hook methods. All hooks are collected regardless of visibility (public, protected, private).
- Hook inherited from a parent observer or provided by a trait. Counted, alongside the observer's own.
#[ObservedBy([A::class, B::class])]. Produces two observer entries.- Model_events dedupe. Observer hooks AND
Event::listen('eloquent.created: User', UserObserver::class . '@created')referring to the same observer hook contribute the same"UserObserver::created"string — deduplicated to a single entry inhandled_by. - Path 3 with non-observer handler.
Event::listen('eloquent.deleted: App\Models\Product', 'App\Handlers\InvoiceHandler@deleted')produces amodel_eventsentry withInvoiceHandler::deletedinhandled_bybut does NOT create an observer entry forInvoiceHandler.
JobsScanner¶
Discovers queueable and synchronous job classes and emits the jobs[] section of the index.
What it detects¶
JobsScanner finds job classes via two discovery paths and merges them by FQCN:
-
Filesystem walk of
app/Jobs/. Every*.phpfile underapp/Jobs/(recursively) is parsed and any concrete class found becomes a job candidate. Abstract classes, interfaces, traits, and anonymous classes are skipped. -
Dispatch-site seeding. Any class targeted by
Bus::dispatch(...),dispatch(...), or a Dispatchable form —X::dispatch(),X::dispatchIf($cond, ...),X::dispatchUnless($cond, ...)— is located via the PSR-4 guess (leadingApp\→app/) and parsed. The conditional forms resolve to the same target asX::dispatch(); the leading condition argument does not affect resolution. This lets jobs in DDD-style layouts likeapp/Domain/Billing/Jobs/SettleInvoice.phpget picked up even though they live outsideapp/Jobs/. A target wrapped in a fluent chain is resolved through the chain to its FQCN:dispatch((new ProcessOrder())->delay(60))andProcessOrder::dispatch()->onQueue('high')both seedProcessOrder(onlynew X/X::classreceivers resolve, not variable receivers). The dispatch-time modifiers on that chain are captured into the site'soverridesobject — see Cross-link behavior.
Entries are deduped by FQCN. A job discovered through both paths produces a single entry.
queued: true iff the class transitively implements Illuminate\Contracts\Queue\ShouldQueue — either declared on its own implements clause, or inherited via a parent class indexed under app/. Resolved via the cross-file class hierarchy resolver. Inheritance through vendor or framework classes that live outside app/ is opaque to the resolver and won't surface as queued: true.
Output¶
One entry per job FQCN, conforming to $defs/job:
{
"fqcn": "App\\Jobs\\ProcessOrder",
"file": "app/Jobs/ProcessOrder.php",
"line": 14,
"queued": true,
"queue_config": {
"connection": "redis",
"queue": "high",
"delay": null,
"tries": 3,
"timeout": 60,
"backoff": null
},
"dispatched_from": [],
"dispatches": []
}
queue_config is null when queued: false. When queued: true it is an object with all six keys present — connection, queue, delay, tries, timeout, backoff. Each value is either the scalar literal declared as a class property, or null when the property is not declared on the class. null means "not declared" — at runtime Laravel applies its own defaults from config/queue.php and from the framework's job traits.
dispatched_from and dispatches are always emitted as empty arrays from the scanner. Both are populated by the cross-link pass from DispatchScanner's per-call-site data.
Entries are sorted by fqcn ascending.
Cross-link behavior¶
jobs[*].dispatched_from— for each dispatch site with finalizedkind === 'job'whosetargetmatches a job FQCN, a$defs/dispatchSiteentry is appended. Sites carrying the pre-disambiguationkind: ambiguousdo NOT contribute; they're finalized in cross-link Phase 2 before this join runs.
When the dispatch site carries dispatch-time modifiers, the entry also carries an optional overrides object. For jobs both the inner argument-instance chain (dispatch((new ProcessOrder)->onQueue('high')->delay(60))) and the outer PendingDispatch chain (ProcessOrder::dispatch($o)->onQueue('high')->onConnection('redis')->delay(60), dispatch(new ProcessOrder)->afterCommit()) are read. Source mapping: ->onQueue('high') → queue, ->onConnection('redis') → connection, ->delay(60) → delay (integer seconds), ->afterCommit() → after_commit, ->locale(...) → locale, ->mailer(...) → mailer. The key is omitted when no static modifier is present:
{
"file": "app/Services/Checkout.php",
"line": 91,
"method": "App\\Services\\Checkout::finalize",
"overrides": { "connection": "redis", "queue": "high", "delay": 60 }
}
overrides records what the call site changed; queue_config still reflects the job's class-default property declarations. The two are independent.
jobs[*].dispatches— for each dispatch site whose enclosing class FQCN matches a job and whose enclosing method ishandle, a$defs/dispatchentry is appended. Dispatches emitted from helper methods called byhandle()are NOT attributed (see the troubleshooting guide).
DispatchScanner¶
Identifies dispatch sites (event and job dispatches) throughout the codebase, emits unresolved_dispatches[] directly, and feeds the cross-link pass that populates listeners[*].dispatches, observers[*].dispatches, and events[*].dispatched_from.
This is the most cross-cutting scanner. It runs last (after EventScanner, ListenerScanner, and ObserverScanner) so the cross-link pass has access to every primitive's data.
What it detects¶
DispatchScanner walks every PHP file under app/ and routes/ and records dispatch sites in any class method body, and in closures inside them. In routes/ only closure bodies yield sites (the route owns them). It also records each literal 'hook' => Foo::class entry of a class's $dispatchesEvents property as an event site (form: dispatches_events, method: $dispatchesEvents[hook]) through DispatchesEventsVisitor; EventScanner seeds the same targets into events[]. Recognized forms:
event(new SomeEvent(...))andevent(SomeEvent::class)—kind: event,form: helperbroadcast(new SomeEvent(...))andbroadcast(SomeEvent::class)—kind: event,form: helper(the broadcast-path twin ofevent(); event at arg 0)broadcast_if($cond, new SomeEvent(...))andbroadcast_unless($cond, SomeEvent::class)—kind: event,form: helper. The event is arg 1; the leading condition does not affect resolution. A dynamic event arg (broadcast($var)) emits anunresolved_dispatches[]entry like any other helper form.Event::dispatch(new SomeEvent(...))andEvent::dispatch(SomeEvent::class)—kind: event,form: facadedispatch(new SomeJob(...))anddispatch(SomeJob::class)—kind: job,form: job_helperBus::dispatch(new SomeJob(...))—kind: job,form: bus_facadeBus::chain([...])andBus::batch([...])— onekind: jobsite per literal item (new X,X::class); a non-literal list or item goes tounresolved_dispatches.withChain/->chain()on a dispatched job is not detected.SomeClass::dispatch(...)Dispatchable trait —kind: ambiguousat the visitor level, finalized by the cross-link pass againstevents[]SomeClass::dispatchIf($cond, ...)andSomeClass::dispatchUnless($cond, ...)conditional Dispatchable forms — resolved exactly likeSomeClass::dispatch(...)(the static class is the target; the leading condition argument does not affect resolution). Samekind: ambiguous→ cross-link finalization. These exist only as static Dispatchable-trait forms; there is noEvent::dispatchIf/Event::dispatchUnlessfacade form (Laravel'sEventfacade has no such methods), so no facade conditional form is recognised.
A dispatched target wrapped in a fluent chain resolves through the chain to its target FQCN. ClassRef::fromInstanceOrConstant() unwraps a leading MethodCall chain before resolving, so dispatch((new ProcessOrder())->delay(60)) and ProcessOrder::dispatch()->onQueue('high') resolve to ProcessOrder rather than falling through to unresolved_dispatches[]. Only new X and X::class receivers resolve through the chain — a variable receiver ($job->onQueue('high') where $job is a variable) does not. The chain modifier values are captured into the site's overrides object (see below).
The visitor maintains a class+method stack on enterNode/leaveNode of Stmt\Class_ and Stmt\ClassMethod, plus a closure-depth counter. A site with a closure depth above zero carries inClosure: true; cross-link decides whether a registration closure owns it. Each recorded site carries:
target— resolved FQCN of the dispatched event or jobkind—event,job, orambiguous(finalized in cross-link)form—helper,facade,dispatchable,job_helper,bus_facadeclass— enclosing class FQCNmethod— enclosing method namefile,line— set by the scanner from the file path being walked
When the dispatched target can't be resolved statically, the visitor emits to a separate unresolved list instead. Resolution reasons:
dynamic_class_name— first arg is a variable (event($var))string_concatenation— first arg is a string concat or interpolation (event("App\\Events\\" . $x),event("App\\Events\\{$x}"))container_resolution— first arg is a container call (event(app(EventKey::class)),event(resolve(...)),event($container->make(...)))conditional_dispatch— first arg is a ternary whose branches can't be resolved. If both ternary branches resolve to concretenew X()calls, two resolved sites are emitted instead.
Output¶
Direct: unresolved_dispatches[] ($defs/unresolvedDispatch)¶
{
"file": "app/Services/Notifier.php",
"line": 42,
"expression": "event($eventClass)",
"reason": "dynamic_class_name"
}
Sorted by (file, line) for determinism.
Internal: _dispatch_sites[]¶
DispatchScanner returns an internal _dispatch_sites section (underscore-prefixed). IndexBuilder consumes it during cross-link and strips it before schema validation — the JSON output never contains this key.
Each entry carries the visitor's output plus the relative file path:
[
'target' => 'App\\Events\\OrderConfirmationSent',
'provisionalKind' => 'event', // 'event'|'job'|'ambiguous' from the visitor
'form' => 'helper',
'classFqcn' => 'App\\Listeners\\SendOrderConfirmation',
'method' => 'handle',
'file' => 'app/Listeners/SendOrderConfirmation.php',
'line' => 31,
'confidence' => 'high',
]
The cross-link pass consumes this to populate the three cross-linked fields below.
Cross-link populates these from _dispatch_sites¶
Handler dispatches[] (listeners, jobs, observers, routes, closure listeners) only carry event and job kinds. Mail and notification sends inside a handler feed mailables[*].sent_from / notifications[*].notified_from but are not listed in dispatches[].
listeners[*].dispatches — entries whose classFqcn matches a listener AND whose enclosing method is in that listener's handles[*].method set. Built as $defs/dispatch:
{
"target": "App\\Events\\OrderConfirmationSent",
"kind": "event",
"confidence": "high",
"file": "app/Listeners/SendOrderConfirmation.php",
"line": 31
}
observers[*].dispatches — same shape, matched on classFqcn === observer.fqcn AND method is in the canonical Eloquent hook enum.
events[*].dispatched_from — sites with finalized kind: event and target matching an event entry. Shape per $defs/event.dispatched_from:
The same $defs/dispatchSite shape backs jobs[*].dispatched_from, mailables[*].sent_from, and notifications[*].notified_from.
Dispatch-time modifiers (overrides)¶
Each dispatch site optionally carries an overrides object ($defs/dispatchOverrides) recording statically-resolvable fluent modifiers applied at the call site. The key is emitted only when at least one modifier is found; a site with none has no overrides key. Recognised modifiers and their keys:
| Source | Key | Type |
|---|---|---|
->locale('es') |
locale |
string |
->mailer('ses') |
mailer |
string |
->onConnection('redis') |
connection |
string |
->onQueue('high') |
queue |
string |
->delay(60) |
delay |
integer (seconds) |
->afterCommit() |
after_commit |
boolean (only ever true) |
Keys are emitted in schema order (locale, mailer, connection, queue, delay, after_commit); only keys for modifiers actually present appear.
Two chain positions are read:
- Inner argument-instance chain — modifiers on the dispatched instance itself:
dispatch((new ProcessOrder)->onQueue('high')->delay(60)),$user->notify((new InvoicePaid)->onQueue('emails')),Mail::to($u)->send((new OrderShipped)->locale('fr')). - Outer PendingDispatch chain (jobs and events) — modifiers after the dispatch returns a
PendingDispatch:ProcessOrder::dispatch($o)->onQueue('high')->onConnection('redis')->delay(60),dispatch(new ProcessOrder($o))->afterCommit(). The Mail facade receiver chain is also read:Mail::to($u)->locale('fr')->mailer('ses')->send($m).
{
"file": "app/Services/Checkout.php",
"line": 91,
"method": "App\\Services\\Checkout::finalize",
"overrides": { "connection": "redis", "queue": "high", "delay": 60 }
}
Channel filter (channels, notifications only)¶
$defs/dispatchSite also carries an optional mode (DispatchMode: sync, after_response, push), a sibling of overrides, set from the call form: dispatchSync / dispatch_sync / Bus::dispatchSync / Bus::dispatchNow / Mail::sendNow / Notification::sendNow / notifyNow map to sync; dispatchAfterResponse and a literal-true ->afterResponse() on the dispatch chain map to after_response; Queue::push/pushOn/later/laterOn/bulk and Mail::queue/onQueue/queueOn/later/laterOn map to push. Plain forms emit no mode. The visitor sets it (ChainModifierExtractor::mode() reads the chain); the cross-link pass copies it onto the reverse arrays. X::dispatchSync() / X::dispatchAfterResponse() exist only on the Bus Dispatchable trait, so those sites are kind: job straight away rather than ambiguous. Queue::pushRaw (string payload) is not recorded. ShouldQueue and the sync queue driver are never evaluated.
$defs/dispatchSite carries one further optional field — channels — emitted only on notifications[*].notified_from entries. It records the literal channel filter passed as the third argument to Notification::send($users, $notification, $channels) / Notification::sendNow(...), which restricts that dispatch to a specific channel set and overrides the notification's own via(). Values use the same representation as notifications[*].channels: literal string channel names stored lowercased, Class::class channel constants stored as FQCN, in source order.
{
"file": "app/Services/Billing.php",
"line": 88,
"method": "App\\Services\\Billing::charge",
"channels": ["mail", "App\\Channels\\SlackChannel"]
}
The key is omitted when the argument is absent, empty, or non-literal. It is captured only on the Notification::send / Notification::sendNow facade forms — the ->notify(...) method form has no channel-filter argument, and the other three reverse-reference arrays (events[*].dispatched_from, jobs[*].dispatched_from, mailables[*].sent_from) never carry it. Full semantics live in NotificationScanner.
Kind classification¶
The cross-link pass disambiguates kind: ambiguous (Dispatchable form) before populating the other fields:
- If
targetis inevents[](built by EventScanner),kind = event - Otherwise,
kind = job
EventScanner's dispatch-site seeding ensures most Dispatchable-form events are already in events[]. Classes that aren't (jobs in app/Jobs/, internal classes that happen to use the Dispatchable trait) fall through to job.
Confidence¶
confidence is always "high" for statically resolved targets. The "medium" and "low" enum values are reserved for future runtime overlay work.
Expected behavior¶
- Dispatch inside any method on a listener that handles an event via that method. Contributes to that listener's
dispatches[]. For example, a listener withhandles: [{event: OrderPlaced, method: handlePlaced}]has the dispatches insidehandlePlaced()attributed to it. - Dispatch inside an Eloquent hook method on an observer. Contributes to that observer's
dispatches[]. - Dispatch in a helper method NOT registered as a handler. Doesn't appear in
listeners[*].dispatchesorobservers[*].dispatches(because the method isn't in the handler set). May still contribute toevents[*].dispatched_fromif it dispatches an event. - Multiple dispatches in one method. Each recorded separately.
- Two ternary branches both resolving to concrete classes. Two resolved sites emitted, one per branch. Not
conditional_dispatch. - Dispatchable form
X::dispatch(...)whereXis inevents[]. Finalized askind: event. - Dispatchable form
X::dispatch(...)whereXis inapp/Jobs/. Finalized askind: job(since EventScanner's seeding rejects it under theapp/Events/trim). - Top-level dispatch outside any class. Skipped. No enclosing class context.
- Dispatch inside a closure (assigned to a variable, passed to a function, returned from a method). Skipped. Too easy to over-report — the closure may never execute, may execute in a different context, etc.
- Dispatch inside an arrow function. Skipped (closure depth counter).
MailableScanner¶
Discovers Laravel mailable classes and emits the mailables[] section of
the index.
See ADR 0003 for the load-bearing design decisions (one section vs two, channel extraction, cross-link wiring).
What it detects¶
MailableScanner finds mailable classes via two discovery paths and merges them by FQCN:
-
Filesystem walk of
app/Mail/. Every*.phpfile underapp/Mail/(recursively) is parsed and any concrete class found becomes a mailable candidate. Abstract classes, interfaces, traits, and anonymous classes are skipped. -
Dispatch-site seeding. Any class targeted by a recognised mail-dispatch call shape is located via the PSR-4 guess (leading
App\→app/) and parsed. This lets mailables in DDD-style layouts likeapp/Domain/Billing/Mail/InvoiceMailable.phpget picked up even though they live outsideapp/Mail/.
Recognised 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::later($delay, X::class)Mail::to(...)->send(new X(...))and the same chained off->cc(...),->bcc(...),->locale(...),->mailer(...)(the visitor walks to the innermostMail::to/cc/bcc/...receiver and accepts the chain regardless of the intermediate links).Mail::to(...)->queue(new X(...)),Mail::to(...)->later($delay, new X(...)).
The mailable argument may itself be wrapped in a fluent chain and
still resolves to its target FQCN:
Mail::send((new OrderShipped)->onConnection('redis')) and
Mail::to($user)->send((new OrderShipped)->locale('fr')) both seed
OrderShipped. Only new X / X::class argument receivers resolve
through the chain — a variable receiver does not. The chain modifier
values are captured into the dispatch site's overrides object —
see Cross-link behavior. The Mail facade receiver chain
(Mail::to($u)->locale('fr')->mailer('ses')->send($m)) is read
as well.
Entries are deduped by FQCN. A mailable discovered through both paths
produces a single entry; the filesystem walk wins for file/line.
queued: true iff the class transitively implements
Illuminate\Contracts\Queue\ShouldQueue — either declared on its own
implements clause, or inherited via a parent class indexed under
app/. Resolved via the cross-file
class hierarchy resolver. Inheritance
through vendor / framework classes that live outside app/ is opaque
to the resolver and won't surface as queued: true.
Output¶
One entry per mailable FQCN, conforming to $defs/mailable:
{
"fqcn": "App\\Mail\\OrderShipped",
"file": "app/Mail/OrderShipped.php",
"line": 18,
"queued": true,
"queue_config": {
"connection": "redis",
"queue": "mail",
"delay": null,
"tries": 3,
"timeout": null,
"backoff": null
},
"sent_from": []
}
queue_config is null when queued: false. When queued: true it
is an object with all six keys present — same shape as $defs/job's
queue_config. Each value is either the scalar literal declared as a
class property, or null when the property is not declared. null
means "not declared"; Laravel applies its own defaults at runtime.
sent_from is always emitted as an empty array from the scanner.
It is populated by the cross-link pass from DispatchScanner's
per-call-site data.
Entries are sorted by fqcn ascending.
stats.mailables is added to the top-level stats block as the count
of entries.
Cross-link behavior¶
mailables[*].sent_from— for each dispatch site with finalizedkind === 'mailable'whosetargetmatches a mailable FQCN, a$defs/dispatchSiteentry is appended. Identical shape toevents[*].dispatched_fromandjobs[*].dispatched_from. Sorted by(file, line).
When the dispatch site carries dispatch-time modifiers, the entry
also carries an optional overrides object. For mailables both the
inner argument-instance chain
(Mail::to($u)->send((new OrderShipped)->locale('fr'))) and the Mail
facade receiver chain
(Mail::to($u)->locale('fr')->mailer('ses')->send($m)) are read.
Source mapping: ->locale('fr') → locale, ->mailer('ses') →
mailer, ->onConnection('sqs') → connection, ->onQueue('mail')
→ queue, ->delay(60) → delay (integer seconds), ->afterCommit()
→ after_commit. The key is omitted when no static modifier is
present:
{
"file": "app/Services/Checkout.php",
"line": 94,
"method": "App\\Services\\Checkout::finalize",
"overrides": { "locale": "fr", "mailer": "ses" }
}
overrides records what the call site changed; queue_config still
reflects the mailable's class-default property declarations.
The new provisionalKind: 'mailable' is emitted by
DispatchSiteVisitor for every recognised mail-dispatch shape and
joined in cross-link phase 5 against mailables[*].fqcn.
Mailables do not participate in the disambiguation phase (phase 2):
no recognised call shape is ambiguous with another primitive type.
Mail::send(new X) cannot be mistaken for a job dispatch.
Expected behavior¶
- Standard mailable:
class OrderShipped extends Mailable implements ShouldQueue { use Queueable; }—queued: true,queue_configpopulated from class-level scalar properties. - Mailable outside
app/Mail/dispatched viaMail::send(new X): picked up via dispatch-site seeding, file/line from the class declaration. - Mailable in
app/Mail/never dispatched anywhere: picked up via filesystem walk,sent_from: []. - Same mailable dispatched many times: one entry; every dispatch
site contributes to
sent_from[]. Mail::to($user)->cc($admins)->bcc($audit)->send(new X): chain walked; one dispatch site emitted withtarget: X.- Dispatch site inside a closure or arrow function: skipped, same rule as DispatchScanner (closures of every kind are out).
- Parse error in a file:
AstWalkerswallows it; no visitor hits for that file.
NotificationScanner¶
Discovers Laravel notification classes and emits the notifications[]
section of the index.
See ADR 0003 for the load-bearing design decisions (one section vs two, channel extraction, cross-link wiring).
What it detects¶
NotificationScanner finds notification classes via two discovery paths and merges them by FQCN:
-
Filesystem walk of
app/Notifications/. Every*.phpfile (recursively) is parsed and any concrete class becomes a candidate. Abstract classes, interfaces, traits, and anonymous classes are skipped. -
Dispatch-site seeding. Any class targeted by a recognised notification-dispatch shape is located via the PSR-4 guess (leading
App\→app/) and parsed. This lets notifications in non-standard layouts likeapp/Domain/Accounts/Notifications/InvitedNotification.phpbe picked up.
Recognised call shapes:
$any->notify(new Y(...))/$any->notify(Y::class)— the receiver is any expression (variable, property access, method call result). The visitor matches on the method name and the argument shape; it does not attempt to type-resolve the receiver. TheNotifiabletrait is Laravel's canonical receiver andnotifyis unique enough that false positives are negligible.$any->notifyNow(new Y(...))— synchronous variant.Notification::send($recipients, new Y(...)),Notification::sendNow($recipients, new Y(...))— the notification class lives at argument index 1. These two forms also accept an optional channel filter at argument index 2 (Notification::send($users, new Y(...), ['mail'])), captured into the dispatch site'schannelsarray — see Cross-link behavior.Notification::route('mail', '...')->notify(new Y(...))and longer route chains (Notification::route(...)->route(...)->notify(...)).
The notification argument may itself be wrapped in a fluent chain
and still resolves to its target FQCN:
$user->notify((new InvoicePaid)->locale('es')) and
Notification::send($users, (new InvoicePaid)->onQueue('emails'))
both seed InvoicePaid. Only new Y / Y::class argument
receivers resolve through the chain — a variable receiver does not.
The chain modifier values are captured into the dispatch site's
overrides object — see Cross-link behavior. Notifications read the
inner argument-instance chain only; modifiers on the Notification
facade before send (Notification::locale('es')->send($users, $n))
are not detected (see the troubleshooting guide).
Entries are deduped by FQCN. A notification discovered through both
paths produces a single entry; the filesystem walk wins for
file/line.
queued: true iff the class transitively implements
Illuminate\Contracts\Queue\ShouldQueue. Resolved via the cross-file
class hierarchy resolver. Inheritance
through vendor / framework classes is opaque.
channels[] is extracted from the notification's via($notifiable)
method body — see below.
Output¶
One entry per notification FQCN, conforming to $defs/notification:
{
"fqcn": "App\\Notifications\\InvoicePaid",
"file": "app/Notifications/InvoicePaid.php",
"line": 22,
"queued": true,
"queue_config": {
"connection": null,
"queue": "notifications",
"delay": null,
"tries": null,
"timeout": null,
"backoff": null
},
"channels": ["mail", "database", "slack"],
"channels_dynamic": false,
"notified_from": []
}
Field semantics:
queued,queue_config— identical to$defs/mailableand$defs/job.queue_configisnullwhenqueued: false.channels[]— extracted from avia()method whose body is a singlereturn [...];of literal strings and/orClass::classconstants. Strings ('mail','database','slack','broadcast','vonage') are stored verbatim, lowercased. Class-constant channels are stored as FQCN (Illuminate\Notifications\Channels\SlackChannel). Emitted in source order from thevia()literal — not sorted. This matches Laravel's runtime channel-dispatch order and preserves the intent recorded in the file. Empty[]when thevia()body is not statically resolvable, and also empty[]when novia()method is declared.channels_dynamic—trueonly whenvia()exists and its body is not the recognised single-return-literal-array shape (conditional logic, property/method access, variable indirection, keyed entries, non-literal items).falsewhen the body is statically resolved, andfalsewhen the class declares novia()method at all. The distinction matters: absence ofvia()means "no channels declared" (an intentional zero);via()with an unresolvable body means "channels exist at runtime but static analysis can't see them". Consumers wanting "Slack notifications" filter on"slack" in channels; consumers triaging notifications whose channels Loom can't see filter onchannels_dynamic: true.notified_from— always emitted as an empty array from the scanner. Populated by the cross-link pass.
Entries are sorted by fqcn ascending.
stats.notifications is added to the top-level stats block as the
count of entries.
Cross-link behavior¶
notifications[*].notified_from— for each dispatch site with finalizedkind === 'notification'whosetargetmatches a notification FQCN, a$defs/dispatchSiteentry is appended. Identical shape toevents[*].dispatched_from. Sorted by(file, line).
When the dispatch site carries dispatch-time modifiers on the inner
argument-instance chain
($user->notify((new InvoicePaid)->onQueue('emails')->delay(60))),
the entry also carries an optional overrides object. Source
mapping: ->onQueue('emails') → queue, ->onConnection('redis') →
connection, ->delay(60) → delay (integer seconds),
->afterCommit() → after_commit, ->locale('es') → locale,
->mailer(...) → mailer. The key is omitted when no static
modifier is present:
{
"file": "app/Services/Billing.php",
"line": 51,
"method": "App\\Services\\Billing::charge",
"overrides": { "queue": "emails" }
}
Notifications capture the inner argument-instance chain only. The
Notification facade-receiver form
(Notification::locale('es')->send($users, $n)) is not detected.
overrides records what the call site changed; queue_config still
reflects the notification's class-default property declarations.
When the dispatch site is a Notification::send / Notification::sendNow
facade call carrying a literal channel filter at argument index 2
(Notification::send($users, new InvoicePaid(), ['mail', SlackChannel::class])),
the entry also carries an optional channels array. The filter
restricts that dispatch to the given channel set, overriding the
notification's own via() declaration for that call. Values use the
same representation as notifications[*].channels from via():
literal string channel names are stored lowercased ('MAIL' →
"mail") and Class::class channel constants are stored as their
FQCN (SlackChannel::class → "App\\Channels\\SlackChannel"), in
source order:
{
"file": "app/Services/Billing.php",
"line": 88,
"method": "App\\Services\\Billing::charge",
"channels": ["mail", "App\\Channels\\SlackChannel"]
}
The key is omitted entirely when the argument is absent, empty
([]), or non-literal (a $variable, a method call, or any item
that isn't a literal string or ::class constant) — a site without a
resolvable filter stays byte-identical to before this field existed
(no empty array; the key simply isn't present). The filter is
captured only on the Notification::send / Notification::sendNow
facade forms; the $any->notify(...) / notifyNow(...) method forms
have no channel-filter argument, and the
Notification::route(...)->notify(...) chain carries recipient
routing (where the notification is sent), not a channel filter — no
channels is emitted on those sites.
The new provisionalKind: 'notification' is emitted by
DispatchSiteVisitor for every recognised notification-dispatch
shape and joined in cross-link phase 5 against
notifications[*].fqcn.
Notifications do not participate in the disambiguation phase
(phase 2): the recognised call shapes ($x->notify(...),
Notification::send(...)) are unambiguous.
Expected behavior¶
- Standard notification with static
via():public function via($notifiable) { return ['mail', 'database']; }—channels: ["mail", "database"],channels_dynamic: false. Source order is preserved. via()returning class constants:return [SlackChannel::class, 'database'];—channels: ["Illuminate\\Notifications\\Channels\\SlackChannel", "database"], in source order.via()with conditional logic:return $notifiable->prefers ? ['mail'] : ['database'];—channels: [],channels_dynamic: true.via()referencing a property:return $this->channels;—channels: [],channels_dynamic: true.- No
via()declared:channels: [],channels_dynamic: false. The class declared no channels; this is an intentional zero, not an unknown. (Would throw at runtime, but the scanner doesn't crash on it.) - Notification dispatched via
$user->notify(new X)where$userisNotification::route(...)chained: same target extraction; onenotified_fromentry per call site. - Same notification dispatched many times: one entry; every
dispatch site contributes to
notified_from[]. - Dispatch site inside a closure or arrow function: skipped, same rule as DispatchScanner.
ScheduleScanner¶
Discovers entries declared in Laravel's task scheduler and emits the
scheduled_tasks[] section of the index.
See ADR 0002 for the load-bearing design decisions (discovery strategy, cron normalisation, cross-link shape).
What it detects¶
ScheduleScanner walks four discovery surfaces and merges results by
(file, line):
-
Console\Kernel::schedule(Schedule $schedule)— any class declaration inapp/Console/Kernel.phpcarrying aschedule()method whose first parameter is typedIlluminate\Console\Scheduling\Schedule. The method body is walked. -
bootstrap/app.php->withSchedule(...)— theApplication::configure(...)chain is located, the->withSchedule(function (Schedule $schedule) { ... })link is found, and the closure body is walked. -
Schedulefacade calls — every PHP file underapp/is parsed, and every top-level expression of the formSchedule::call(...),Schedule::command(...),Schedule::job(...), orSchedule::exec(...)is captured (whereScheduleresolves toIlluminate\Support\Facades\Schedule). Useful for schedules registered in service providers or in package boot logic. -
routes/console.php— walked with the same facade-form visitor as surface 3; Laravel 11+ apps putSchedule::...calls here by default. Entries carry theroutes/console.phprelative path and dedupe on the usual key.
Each captured root call is the start of a fluent chain. The visitor walks
down the chain collecting (methodName, args) for every link, then
emits one entry per chain.
Schedule groups¶
A ->group(Closure) link applies its outer chain's attributes to every
task declared inside the closure:
Schedule::daily()->onOneServer()->group(function () {
Schedule::command('report:a');
Schedule::command('report:b')->weekly();
});
Each inner task emits as an ordinary scheduled_tasks[] entry — there is no
special marker — carrying the group's merged attributes (cron, frequency,
on_one_server, without_overlapping, timezone, constraints, …). The
group's links are spliced before the inner task's own links, so the
task's own modifiers win on conflict (last-wins, matching Laravel's
runtime). Above, report:a inherits the group's daily cron and
on_one_server; report:b inherits on_one_server but its own
->weekly() overrides the group's daily.
Both the variable form ($schedule->...->group(...)) and the facade form
(Schedule::...->group(...)) are recognised. Nested groups concatenate —
each enclosing group's attributes apply to the inner tasks, outermost
first.
Output¶
One entry per chain, conforming to $defs/scheduleEntry:
{
"kind": "command",
"name": "send-daily-mail",
"target": "mail:send {--queue=default}",
"arguments": [],
"queue": null,
"connection": null,
"cron": "0 13 * * *",
"frequency": null,
"timezone": "America/Chicago",
"without_overlapping": true,
"without_overlapping_expires_at": null,
"on_one_server": false,
"run_in_background": false,
"even_in_maintenance_mode": false,
"constraints": ["weekdays"],
"file": "app/Console/Kernel.php",
"line": 28
}
Field semantics:
kind—command|job|closure|exec. Determined by the root method of the chain (command,job,call,exec).name— string ornull. The schedule task's label from->name('...');nullwhen->name()is absent or its argument is not a statically-resolvable string literal (same resolution rule astimezone). This is the unique key Laravel uses foronOneServerdeduplication.target— depends onkind:command: the signature string ("mail:send","mail:send {--queue=default}") or, when the argument is a class constant (SendMail::class), the FQCN.job: the FQCN, resolved via NameResolver fromnew X(...)orX::class.closure:nullfor inline closures (fn () => ...,function () { ... }); identification falls back tofile:line."FQCN::method"for callable tuples ([Cls::class, 'method']) and Laravel callable strings ('App\\Cls@method').
exec: the shell command string verbatim.arguments[]— the literal parameters array passed to->command(Class::class, [...]).commandkind only;[]for every other kind. Plain items render as the stringified value; keyed items render as"key=value"; booleans as"true"/"false"; items that aren't statically resolvable are skipped. Example:->command(SendEmails::class, ['--force', 'user' => 1])→["--force", "user=1"].queue— string ornull.jobkind only: the queue override from->job($job, $queue, $connection)(2nd argument).nullwhen absent or for non-jobkinds.connection— string ornull.jobkind only: the connection override from->job($job, $queue, $connection)(3rd argument).nullwhen absent or for non-jobkinds.cron— five-field normalised cron expression ("*/5 * * * *") ornullif no recognised frequency helper appears in the chain. The recognised set is enumerated in ADR 0002 §3. Alsonullfor sub-minute helpers, whose interval is carried infrequencyinstead (see below).frequency— structured sub-minute interval{ "unit": "seconds", "every": <N> }, ornull. Set only by the seven sub-minute helpers, which a five-field cron cannot express:everySecond→every: 1,everyTwoSeconds→2,everyFiveSeconds→5,everyTenSeconds→10,everyFifteenSeconds→15,everyTwentySeconds→20,everyThirtySeconds→30.
These entries carry cron: null. For every other entry frequency is
null. At most one of cron / frequency is non-null on any entry.
unit is backed by the FrequencyUnit enum ("seconds" only today). See
ADR 0004.
- timezone — string from ->timezone('America/Chicago') or null.
- without_overlapping — true if ->withoutOverlapping() appears.
- without_overlapping_expires_at — integer or null. The expiry
minutes from ->withoutOverlapping($minutes). null when
withoutOverlapping() is called with no literal argument — the
framework default of 1440 is deliberately not fabricated (honesty over
completeness). Independent of the without_overlapping boolean, which
is unchanged.
- on_one_server — true if ->onOneServer() appears.
- run_in_background — true if ->runInBackground() appears.
- even_in_maintenance_mode — true if ->evenInMaintenanceMode()
appears.
- constraints[] — opaque string labels for non-cron restrictions.
Sorted ascending. Recognised shapes:
- Day-of-week: "weekdays", "weekends", "sundays", "mondays",
"tuesdays", "wednesdays", "thursdays", "fridays",
"saturdays".
- Time-window: "between(8:00,17:00)", "unlessBetween(8:00,17:00)".
Falls back to "between(closure)" / "unlessBetween(closure)" when
the arguments aren't scalar strings.
- Conditional: "when(closure)", "skip(closure)". The closure body
is not analysed.
- Environment: "environments(production,staging)" for scalar args
(including a single array literal); "environments(closure)"
otherwise.
- Day-of-week list: "days(0,3)" for ->days(0, 3) and
->days([0, 3]) (variadic ints or a single array literal); falls
back to "days(?)" when no scalar day can be resolved. Surfaced as a
constraint rather than folded into the cron, and does not clear a
cron set earlier in the chain.
- file, line — the position of the root method call in the chain
($schedule->command(...), not the trailing ->onOneServer()).
Entries are sorted by (file, line) ascending.
stats.scheduled_tasks is added to the top-level stats block as the count of
entries.
Cross-link behavior¶
ScheduleScanner participates in cross-link only as a source. No fields on existing primitives are widened.
scheduled_tasks[*].targetwithkind: "job"carries the job FQCN. Consumers join client-side againstjobs[*].fqcn. There is nojobs[*].scheduledflag — see ADR 0002 §5 for rationale.- Dispatch sites inside a scheduled closure (
Schedule::call(fn () => event(new X))) are not captured by DispatchScanner. Closures of every kind are a documented skip indispatches.md.
Expected behavior¶
- Standard kernel form:
protected function schedule(Schedule $schedule) { $schedule->command('foo:bar')->daily(); }— one entry,cron: "0 0 * * *". - L11 closure form:
->withSchedule(function (Schedule $schedule) { $schedule->job(new ProcessOrder)->hourly(); })— one entry,cron: "0 * * * *",kind: "job", target FQCN-resolved. - Facade form in provider boot:
Schedule::command('queue:work') ->everyMinute();— picked up regardless of which class hosts the call. - Multiple modifiers: every link in the chain is inspected; flags set, name and timezone captured, constraints collected.
name(string):->name('send-daily-mail')is captured intoname. A non-literal argument (->name($label)) leavesname: null; the rest of the chain is still captured.evenInMaintenanceMode():->evenInMaintenanceMode()setseven_in_maintenance_mode: true; absent, the flag isfalse.cron(string)passthrough:->cron('*/5 8-17 * * 1-5')is stored verbatim incron.- Multi-hour helpers honour their minutes argument:
->everyTwoHours(15)→cron: "15 */2 * * *"; the same applies toeveryThreeHours,everyFourHours, andeverySixHours. No-arg calls are unchanged (->everyTwoHours()→"0 */2 * * *"). everyOddHour:->everyOddHour()→cron: "0 1-23/2 * * *", honouring an optional minutes argument (->everyOddHour(30)→"30 1-23/2 * * *").quarterlyOn:->quarterlyOn(15, '13:00')→cron: "0 13 15 1-12/3 *"(day-of-quarter and time both honoured; both default — day1, time0:00).daysOfMonth:->daysOfMonth(1, 15)→cron: "0 0 1,15 * *". Accepts variadic ints (->daysOfMonth(1, 15)) or a single array literal (->daysOfMonth([2, 16])); Laravel runs these at 00:00. Unlike the day-of-week->days(...)constraint, this is a frequency helper that produces a cron and chains safely after another frequency (->daily()->daysOfMonth(10)→"0 0 10 * *").cron: nullwhen no day argument resolves statically (->daysOfMonth($var)).- Sub-minute helpers:
->everyTenSeconds()→cron: null,frequency: { "unit": "seconds", "every": 10 }. The seven sub-minute helpers (everySecond…everyThirtySeconds) can't be a five-field cron, so the interval lives infrequency. - Last-wins on conflicting frequencies: chain with multiple frequency
helpers (
->daily()->hourly()) reflects the last one. Matches Laravel's runtime behaviour. This holds across the cron / sub-minute boundary too:->everyTenSeconds()->daily()yieldscron: "0 0 * * *",frequency: null;->daily()->everyTenSeconds()yieldscron: null,frequency: { "unit": "seconds", "every": 10 }. At most one is non-null. - Schedule groups: a task inside
->group(Closure)inherits the outer chain's frequency and modifiers.Schedule::daily()->onOneServer() ->group(fn () => Schedule::command('a'))→ one entry forawithcron: "0 0 * * *",on_one_server: true. An inner frequency overrides the group's:->daily()->group(fn () => Schedule::command('b') ->weekly())→bwithcron: "0 0 * * 0". Nested groups concatenate. - Tuple-callable in
->call:->call([Reporter::class, 'send'])emitskind: "closure",target: "App\\Reporter::send". Class@methodcallable in->call:->call('App\\Reporter@send')normalised to"App\\Reporter::send".
RouteScanner¶
Discovers HTTP routes registered in routes/*.php and emits the
routes[] section of the index.
Route discovery covers individual registrations and the context of any
enclosing Route::group(...) — prefix, name prefix, default controller,
and middleware (see Route groups and
Middleware). Route::resource() / Route::apiResource()
registrations are expanded into their constituent CRUD routes (see
Resource controllers). Each route also carries a
dispatches[] cross-link — the events and jobs dispatched inside its
controller method, joined during the cross-link pass (see
Dispatches). Middleware-group / alias resolution is deferred
to follow-up PRs (see Known limitations).
What it detects¶
RouteScanner statically parses every *.php file under the app's
routes/ directory (no Laravel boot) and captures each route
registration it can resolve. The recognised root forms are:
-
Verb methods —
Route::get('/uri', action)and the same forpost,put,patch,delete, andoptions. One entry per call,methodset to the uppercased verb. -
Route::any('/uri', action)— one entry withmethodset toANY. -
Route::match([...], '/uri', action)— the first argument is a list of verb strings. One entry is emitted per listed verb, each sharing the sameuri,name, and controller fields.
The route name is captured from a chained ->name('...') link on the
registration.
Action forms¶
The action argument (the last positional argument, or the second for
match) is resolved into controller_fqcn / controller_method:
- Tuple —
[UserController::class, 'show']→controller_fqcn: "App\\Http\\Controllers\\UserController",controller_method: "show". - Invokable — bare
InvokeController::classor single-element[InvokeController::class]→controller_method: "__invoke". - Legacy string —
'UserController@show'is split on@into FQCN and method. - Closure —
function () { ... }orfn () => ...→ both controller fieldsnull.
Output¶
One entry per route (one per verb for match), conforming to
$defs/route:
{
"method": "GET",
"uri": "/admin/users/{id}",
"name": "admin.users.show",
"controller_fqcn": "App\\Http\\Controllers\\UserController",
"controller_method": "show",
"middleware": ["web", "auth"],
"file": "routes/web.php",
"line": 14,
"dispatches": [
{
"target": "App\\Events\\UserViewed",
"kind": "event",
"confidence": "high",
"file": "app/Http/Controllers/UserController.php",
"line": 31
}
]
}
(The uri, name, and middleware above reflect an enclosing
Route::prefix('admin')->name('admin.')->middleware(['web', 'auth']) group
applied to a leaf Route::get('/users/{id}', ...)->name('users.show').)
Field semantics:
method— HTTP verb, uppercased. One ofGET,POST,PUT,PATCH,DELETE,OPTIONS, orANY(the synthetic verb forRoute::any). ARoute::match(['get', 'post'], ...)registration yields two entries,GETandPOST.uri— the route URI, with the prefixes of any enclosingRoute::group(...)prepended (e.g./admin/users/{id}). Always begins with a leading slash; the root is/. See Route groups.name— the named-route name from->name('...'), with any enclosing group name prefix concatenated as-is (see Route groups);nullwhen the registration carries no->name()link and no group name prefix applies (or the argument is not a statically-resolvable string literal).controller_fqcn— the fully-qualified controller class for the action, ornullfor closures and unresolvable actions.controller_method— the action method;__invokefor invokable controllers;nullwhencontroller_fqcnisnull.middleware— the resolved middleware chain for the route: the middleware of every enclosing group (outermost first) followed by the route's own->middleware(...), in source order, with exact-duplicate names deduped.[]when neither the route nor any enclosing group declares middleware. See Middleware.file— path to the route file, relative to the app root (e.g.routes/web.php).line— 1-indexed line of the route registration.dispatches— the events and jobs dispatched inside the route's controller method, each a$defs/dispatchreference ({target, kind, confidence, file, line}— the same shape aslisteners[*].dispatches/jobs[*].dispatches). Populated by the cross-link pass;[]for closure routes, unresolved-controller routes, and any route whose controller method dispatches nothing. See Dispatches.
stats.routes is added to the top-level stats block as the count of
entries.
Expected behavior¶
- Verb form:
Route::get('/users', [UserController::class, 'index'])— one entry,method: "GET", controller fields resolved. anyform:Route::any('/webhook', [HookController::class, 'handle'])— one entry,method: "ANY".matchform:Route::match(['get', 'post'], '/search', [SearchController::class, 'run'])— two entries (GETandPOST), identical except formethod.- Invokable controller:
Route::get('/dashboard', DashboardController::class)orRoute::get('/dashboard', [DashboardController::class])— one entry,controller_method: "__invoke". - Legacy string action:
Route::get('/users', 'UserController@index')— split on@into FQCN and method. - Named route:
Route::get('/users/{id}', [UserController::class, 'show'])->name('users.show')—name: "users.show". - Closure action:
Route::get('/ping', fn () => 'pong')— one entry, both controller fieldsnull.
Route groups¶
Routes declared inside a Route::group(...) inherit the group's context.
Both syntaxes are supported: the array-config form
Route::group(['prefix' => 'admin', 'as' => 'admin.', 'controller' => C::class], fn)
and the fluent form
Route::prefix('admin')->name('admin.')->controller(C::class)->group(fn).
Three pieces of context are merged into the leaf route:
-
Prefix — the group's
prefixsegments are prepended to the route URI. The result always carries a leading slash, and the root is/.Route::prefix('admin')->group(fn () => Route::get('/panel', ...))yieldsuri/admin/panel;Route::get('/', ...)insideprefix('admin')yields/admin. Nested groups concatenate, outer to inner:v2>users>/{id}yields/v2/users/{id}. Ungrouped routes are unchanged (/users,/). -
Name prefix — the group's
as/name(...)prefix is concatenated as-is (no separator inserted) with the leaf route's->name(...).name('admin.')+ leaf->name('users')yieldsadmin.users. Nested prefixes chain:admin.>users.>indexyieldsadmin.users.index. A group name prefix with no leaf->name()yields just the prefix (e.g.admin.). -
Default controller — the group's
controller(C::class)resolves a bare method-name string action to that controller.Route::controller(UserController::class)->group(fn () => Route::get('/u', 'store'))yieldscontroller_fqcnUserController,controller_methodstore. It does not override an action that already names a class — an array tuple, a bareCtrl::class, or a'Class@method'string keeps its own controller.
Route files loaded under a group¶
RouteFileLoadVisitor follows the same RouteGroupTracker as RouteChainVisitor, so a loading call carries the cumulative group context at the call site (its own ->group($path) included). RouteFileDiscovery composes these contexts along the load graph (RouteGroupContext::within(); a file already on the path is not re-entered) and RouteScanner seeds one RouteChainVisitor per distinct context of a file. withRouting is modelled as Laravel's buildRoutingCallback() does: web is Route::middleware('web')->group(), api is Route::middleware('api')->prefix($apiPrefix)->group() with apiPrefix defaulting to api. A non-literal value is left out and reported through unresolvedAttributes().
Middleware¶
Each route's middleware field is the resolved chain Loom can see
statically: the middleware of every enclosing group, outermost group to
innermost, followed by the route's own ->middleware(...), in source
order. Exact-duplicate names are deduped (first occurrence wins);
otherwise order is preserved.
Three rules decide how repeated middleware() calls combine, mirroring
Laravel:
- Registrar chain (
Route::middleware('a')->middleware('b')->group()): the last call replaces the earlier ones, so onlybapplies (RouteRegistrar::attribute()overwrites themiddlewarekey). A repeatedprefix,name,asorcontrolleron the same chain also overwrites. - Nested groups: an inner group's middleware is appended after the
enclosing group's (
RouteGroup::merge()). - Route instance (
Route::get(...)->middleware('a')->middleware('b')): calls accumulate in source order (Route::middleware()merges).
Recognised forms, on both the route and a group:
- Single string —
->middleware('auth'). - Array —
->middleware(['auth', 'verified']). - Chained on a route —
->middleware('a')->middleware('b')accumulatesa,b; chained on a group registrar it keeps onlyb. - Variadic —
->middleware('a', 'b'). ::classreference — resolved to the FQCN (e.g.->middleware(EnsureTokenIsValid::class)→"App\\Http\\Middleware\\EnsureTokenIsValid").- Group middleware — via the fluent form
Route::middleware([...])->group(...)or the array-config formRoute::group(['middleware' => [...]], fn).
Middleware arguments with parameters are kept verbatim, including the
parameter list: ->middleware('throttle:60,1') records "throttle:60,1",
and ->middleware('can:update,post') records "can:update,post".
Worked example:
Route::middleware(['web', 'auth'])->prefix('account')->group(function () {
Route::get('/settings', [AccountController::class, 'settings'])
->middleware('verified');
});
The /account/settings route resolves to
middleware: ["web", "auth", "verified"] — the two group entries
(outermost first) followed by the route's own verified.
Resource controllers¶
Route::resource('photos', PhotoController::class) is expanded into
its constituent CRUD routes — one routes[] entry per generated action.
A full resource expands to seven entries:
| Action | method | uri | name | controller_method |
|---|---|---|---|---|
| index | GET |
/photos |
photos.index |
index |
| create | GET |
/photos/create |
photos.create |
create |
| store | POST |
/photos |
photos.store |
store |
| show | GET |
/photos/{photo} |
photos.show |
show |
| edit | GET |
/photos/{photo}/edit |
photos.edit |
edit |
| update | PUT |
/photos/{photo} |
photos.update |
update |
| destroy | DELETE |
/photos/{photo} |
photos.destroy |
destroy |
controller_fqcn is the resource controller for every entry
(App\Http\Controllers\PhotoController above).
Route::apiResource('photos', PhotoController::class) expands to the same
set minus the two HTML-form actions, create and edit — five
entries: index, store, show, update, destroy.
-
Member parameter — the
{...}segment is Laravel'sStr::singularof the resource name:photos→{photo},categories→{category}. The same singular is used forshow,edit,update, anddestroy. -
updateverb — emitted asPUT. Laravel registersupdatefor bothPUTandPATCH; this scanner emits a singlePUTentry for it (thePATCHalias is not separately synthesized). -
Group / middleware inheritance — a
resource()/apiResource()call inside aRoute::group(...)(or fluentprefix/name/middlewarechain) propagates the enclosing context to all generated sub-routes: the group prefix is prepended to everyuri, the group name prefix is concatenated onto everyname, and the group middleware is merged into every entry'smiddlewarechain — exactly as for leaf routes (see Route groups and Middleware). -
->only([...])/->except([...])— these filter the generated action set.Route::resource('photos', ...)->only(['index', 'show'])emits just theindexandshowentries;->except(['create', 'edit'])emits the other five. Both are honoured; if both are present Laravel's precedence applies.
The override forms ->names(...), ->parameters(...), ->scoped(...),
and ->shallow() are not applied — the expansion always uses the
default names and parameters above (see
Known limitations).
Dispatches¶
Each route's dispatches field lists the events and jobs dispatched inside
its controller method. It is the cross-section link issue #8 is fundamentally
about: an event can now be traced back through the controller that dispatches
it to the route that reaches that controller.
The field is filled by the RouteDispatchAttributionPhase cross-link phase,
not by the scanner itself — the raw scanner output seeds dispatches: [],
and the phase fills it in. The match is by (controller_fqcn,
controller_method): every dispatch site DispatchScanner records carries its
enclosing class and method, and a site is attributed to a route when that
enclosing class+method equals the route's resolved controller and action. A
controller method dispatching an event and a job lists both; the same
controller method behind several routes (e.g. a match registration, or a
resource action) attaches the dispatches to every matching route entry.
What is captured: event and job dispatches — the same kinds the dispatch
attribution emits elsewhere (each finalized to kind: "event" or
kind: "job"). Mailables and notifications are not listed here; they are
tracked through their own reverse-links (mailables[*].sent_from,
notifications[*].notified_from).
dispatches is [] for:
- Closure routes — no controller identity to key on.
- Unresolved-controller routes —
controller_fqcn/controller_methodarenull, so no site can match. - Controller methods that dispatch nothing.
Because the field is populated during the cross-link pass, it is empty unless
the index was built through IndexBuilder with DispatchScanner registered —
which the loom:scan CLI always does.