laravel-loom maintained by lucasp1337
Laravel Loom
Architecture as data.
Static analyzer for Laravel's event-driven primitives. Scans your source and writes a JSON file listing the events, listeners, observers, and dispatch sites it finds — with file paths and line numbers. Unresolved dispatch calls inside method bodies (event($variable), container lookups) are surfaced with a reason code rather than dropped silently; top-level scripts and closure bodies are skipped entirely. Per-scanner edge cases live in docs/scanners/.
php artisan loom:scan
# storage/loom/index.json
No app boot, no runtime tracing, no vendor/ required. Loom reads PHP source with nikic/php-parser and emits a deterministic JSON file.
Why
Event-driven Laravel apps drift fast. Listeners get added in providers, observers attached in booted(), dispatches scattered across services. php artisan event:list shows you what Laravel happened to register at boot — not what's actually in your source. Observers don't appear at all. Dispatch sites are invisible. Subscribers vary by registration form.
Loom answers the questions that command can't:
- Where is
OrderPlaceddispatched from? →events[].dispatched_from - Which listeners handle it, and via which methods? →
events[].handled_by(class-based) +closure_listeners[]filtered byevent(closures, which don't have an FQCN to cross-link) - What does
SendOrderConfirmation::handle()dispatch? →listeners[].dispatches - Which observers run on
App\Models\User? →observers[]+model_events[] - Any dynamic dispatches Loom couldn't pin down? →
unresolved_dispatches[]with a reason and a file:line
Per-scanner edge cases and known limitations live in docs/scanners/.
Installation
composer require lucasp1337/laravel-loom --dev
PHP 8.3+ and Laravel 11, 12, or 13.
Usage
php artisan loom:scan # writes storage/loom/index.json
php artisan loom:show # prints the index
php artisan loom:show OrderPlaced # filters by FQCN substring
The output lives at storage/loom/index.json. Add it to your .gitignore if you don't want to commit it.
What gets discovered
- Events —
app/Events/**walk, plus any class statically dispatched viaevent(new Foo)/Event::dispatch(new Foo)(regardless of where the class lives). The ambiguous Dispatchable formX::dispatch()only counts as an event when the target resolves underapp/Events/. - Listeners — Laravel's auto-discovery,
$listenarrays onEventServiceProvider,Event::listen()calls anywhere underapp/, and subscribers via$subscribe/Event::subscribe(). - Closure listeners — closures and arrow functions wherever a listener can be (
$listen,Event::listen(), subscriber bodies). Emitted as a separateclosure_listeners[]section. - Observers —
Model::observe()calls, the#[ObservedBy]attribute, plus model events synthesized from observer hooks andEvent::listen('eloquent.*', …). - Jobs — classes under
app/Jobs/(recursive), plus any class dispatched viadispatch(),Bus::dispatch(), or the Dispatchable formX::dispatch()(located via PSR-4, so jobs in DDD layouts get picked up). Recordsqueuedandqueue_config(connection, queue, delay, tries, timeout, backoff) when declared as class properties. - Dispatches — every method body scanned for
event(),Event::dispatch(),dispatch(),Bus::dispatch(), andX::dispatch(). Cross-linked back to listeners and observers by handler method.
Sample output
{
"loom_version": "0.2.0",
"scanned_at": "2026-05-16T19:25:54Z",
"laravel_version": "13.7",
"stats": {
"events": 1,
"listeners": 1,
"observers": 1,
"unresolved_dispatches": 1,
"closure_listeners": 1
},
"events": [
{
"id": "App\\Events\\OrderPlaced",
"fqcn": "App\\Events\\OrderPlaced",
"kind": "class",
"file": "app/Events/OrderPlaced.php",
"line": 11,
"dispatched_from": [
{ "file": "app/Services/Checkout.php", "line": 87, "method": "App\\Services\\Checkout::finalize" }
],
"handled_by": [
{ "listener": "App\\Listeners\\SendOrderConfirmation", "method": "handle" }
]
}
],
"model_events": [
{
"id": "eloquent.creating: App\\Models\\User",
"kind": "model_event",
"model": "App\\Models\\User",
"event": "creating",
"handled_by": ["App\\Observers\\UserObserver::creating"]
}
],
"listeners": [
{
"fqcn": "App\\Listeners\\SendOrderConfirmation",
"file": "app/Listeners/SendOrderConfirmation.php",
"line": 14,
"handles": [
{ "event": "App\\Events\\OrderPlaced", "method": "handle" }
],
"registration": "auto_discovered",
"queued": true,
"dispatches": [
{
"target": "App\\Events\\OrderConfirmationSent",
"kind": "event",
"confidence": "high",
"file": "app/Listeners/SendOrderConfirmation.php",
"line": 31
}
]
}
],
"observers": [
{
"fqcn": "App\\Observers\\UserObserver",
"file": "app/Observers/UserObserver.php",
"line": 9,
"observes": "App\\Models\\User",
"registration": "attribute",
"hooks": ["created", "deleted", "updated"],
"dispatches": []
}
],
"unresolved_dispatches": [
{
"file": "app/Services/Notifier.php",
"line": 42,
"expression": "event($eventClass)",
"reason": "dynamic_class_name"
}
],
"closure_listeners": [
{
"event": "App\\Events\\OrderPlaced",
"file": "app/Providers/EventServiceProvider.php",
"line": 38,
"registration": "event_listen_call",
"queued": false,
"dispatches": []
}
]
}
The JSON shape is defined by schema/loom-index.schema.json and validated on every scan. The schema follows semver — but pre-1.0, breaking changes are tolerated and called out in the CHANGELOG. From 1.0 onwards, breaking changes will require a major bump.
Performance
On a fresh laravel new app, the scan finishes in well under a second. A medium-sized real-world app (~200 PHP files in app/) scans in around 200ms.
What's planned
Tracked at the v1.0 milestone. Highlights:
- More sections: mailables and notifications, schedule entries, routes.
- A browser UI for clicking through the index — events, listeners, dispatch chains.
- An MCP server so AI coding assistants can query the index instead of grepping.
loom:diffandloom:checkfor CI.- A few correctness fixes — container-form registrations, indirect
ShouldQueue, closure dispatch attribution.
Out of scope: runtime tracing, IDE plugins, complexity/quality metrics, and data-model / access-control primitives (models, migrations, validators, policies). Loom's domain is control flow — what dispatches what, what handles what, what runs when — which includes routes and schedules even though they're not strictly Event::dispatch()-shaped.
For per-scanner edge cases and known limitations today, see docs/scanners/.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
Local development
Installing the package only needs PHP 8.3+, but running the test suite needs ext-mbstring, ext-xml, ext-dom, and ext-xmlwriter. A Dockerfile plus a Justfile are provided so contributors without those extensions on their host PHP can run the full toolchain:
just build # build the Docker dev image (once)
just install # composer install
just check # PHPStan + Pint --test + Pest
just coverage # Pest with per-file coverage
See docs/contributing.md for the full list of recipes (including just scan <path> to run Loom against any Laravel app on disk).
Documentation
- Architecture — pipeline, scanner contract, cross-link pass
- Schema — JSON schema reference
- Scanners — per-scanner behavior, edge cases, known limitations
- Contributing — toolchain, Docker workflow, how to add a scanner
License
The MIT License (MIT). See LICENSE.md.