lucasp1337 / laravel-loom
Static architectural inspector for Laravel applications — emits a JSON index of events, listeners, and observers.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- justinrainbow/json-schema: ^6.0
- nikic/php-parser: ^5.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- phpstan/extension-installer: ^1.4
This package is auto-updated.
Last update: 2026-07-18 07:42:55 UTC
README
Laravel Loom
Architecture as data.
Loom statically analyzes a Laravel app's event-driven primitives and writes a deterministic JSON file: every event, listener, observer, job, schedule, mailable, notification, and dispatch site, each with its file path and line number. It reads source with nikic/php-parser — no app boot, no runtime tracing, no vendor/ required — so it sees what's actually in your code, not just what Laravel happened to register at boot.
composer require lucasp1337/laravel-loom --dev
php artisan loom:scan # writes storage/loom/index.json
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
Add storage/loom/index.json to .gitignore if you don't want to commit it.
What it finds
Click any item to see what gets picked up.
Events — app/Events/**, plus any class dispatched via event() / Event::dispatch()
namespace App\Events; class OrderPlaced {} // any class under app/Events/ // ...or any class dispatched statically, wherever it lives: event(new OrderPlaced($order)); Event::dispatch(new OrderPlaced($order)); OrderPlaced::dispatch($order); // counts as an event when it resolves under app/Events/
Listeners — auto-discovery, $listen arrays, Event::listen(), and subscribers
// Auto-discovered from the typed handle() argument class SendOrderConfirmation { public function handle(OrderPlaced $event): void {} } // $listen array on EventServiceProvider protected $listen = [ OrderPlaced::class => [SendOrderConfirmation::class], ]; // Event::listen() anywhere under app/ Event::listen(OrderPlaced::class, SendOrderConfirmation::class); // Subscriber class OrderSubscriber { public function subscribe(Dispatcher $events): array { return [OrderPlaced::class => 'onOrderPlaced']; } }
Closure listeners — closures registered as listeners, in their own section
Event::listen(OrderPlaced::class, function (OrderPlaced $event) { // captured in closure_listeners[], not listeners[] });
Observers — Model::observe(), #[ObservedBy], and eloquent.* model events
#[ObservedBy(UserObserver::class)] class User extends Model {} // ...or registered imperatively User::observe(UserObserver::class); // ...or via an eloquent.* model event Event::listen('eloquent.created: '.User::class, $callback);
Jobs — app/Jobs/**, plus any class dispatched via dispatch() / X::dispatch(), with queue config
class ProcessOrder implements ShouldQueue // any class under app/Jobs/ { public $connection = 'redis'; // queue config read from properties public $queue = 'high'; public $tries = 3; } // ...or any class dispatched as a job (located via PSR-4, so DDD layouts work): dispatch(new ProcessOrder($order)); ProcessOrder::dispatch($order); Bus::dispatch(new ProcessOrder($order)); // chain-wrapped targets resolve through the chain, and dispatch-time // modifiers are captured as an `overrides` object on the dispatch site: ProcessOrder::dispatch($order)->onQueue('high')->onConnection('redis'); dispatch((new ProcessOrder($order))->delay(60))->afterCommit();
Statically-resolvable dispatch-time modifiers — ->onQueue(), ->onConnection(), ->delay() (integer seconds), ->locale(), ->mailer(), ->afterCommit() — are recorded as an optional overrides object on the dispatch site. queue_config still reflects class-default declarations; overrides records what the call site changed.
Schedule — Kernel::schedule(), bootstrap/app.php, and Schedule::* chains, normalized to cron
// In Kernel::schedule(), bootstrap/app.php withSchedule(), or a Schedule:: chain under app/ $schedule->command('mail:send')->dailyAt('13:00')->weekdays(); $schedule->job(new ProcessOrder)->everyFiveMinutes(); Schedule::call(fn () => cleanup())->hourly();
Mailables — app/Mail/**, plus Mail::send() and Mail::to()->send() chains, with queue config
class OrderShipped extends Mailable implements ShouldQueue {} // any class under app/Mail/ // ...or any class sent via Mail:: Mail::to($user)->send(new OrderShipped($order)); Mail::queue(new OrderShipped($order));
Notifications — app/Notifications/**, plus notify() / Notification::send(), with channels
class InvoicePaid extends Notification // any class under app/Notifications/ { public function via($notifiable): array { return ['mail', 'database', 'slack']; // channels read from a static via() literal } } // ...or any class sent via notify()/Notification:: $user->notify(new InvoicePaid($invoice)); Notification::send($users, new InvoicePaid($invoice)); // the optional 3rd argument to Notification::send()/sendNow() restricts the // dispatch to a channel set; a literal filter is captured as `channels` on // the dispatch site: Notification::send($users, new InvoicePaid($invoice), ['mail', SlackChannel::class]);
A literal channel-filter argument on Notification::send() / Notification::sendNow() — an array of string channel names and/or Class::class channel constants — is recorded as an optional channels array on the notified_from dispatch site, using the same value shape as via(). It's captured only on the facade form (the ->notify(...) method form has no channel-filter argument) and omitted when the argument is absent, empty, or non-literal.
Dispatches — every handler body, cross-linked back to the listener, observer, or job it runs in
class SendOrderConfirmation { public function handle(OrderPlaced $event): void { // attributed to this listener as listeners[].dispatches event(new OrderConfirmationSent($event->order)); } }
Dynamic calls Loom can't resolve statically (event($var), container lookups) land in unresolved_dispatches[] with a reason and a file:line rather than being dropped silently. Per-scanner behavior and limitations live in docs/scanners/.
Sample output
Click to expand a representative scan against a small Laravel 13 app
{
"loom_version": "0.2.0",
"scanned_at": "2026-05-16T19:25:54Z",
"laravel_version": "13.7",
"stats": {
"events": 1,
"listeners": 1,
"observers": 1,
"jobs": 1,
"scheduled": 1,
"routes": 2,
"mailables": 1,
"notifications": 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": []
}
],
"jobs": [
{
"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": [
{
"file": "app/Services/Checkout.php",
"line": 91,
"method": "App\\Services\\Checkout::finalize",
"overrides": { "connection": "redis", "queue": "high", "delay": 60 }
}
],
"dispatches": []
}
],
"scheduled": [
{
"kind": "command",
"name": null,
"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
}
],
"routes": [
{
"method": "GET",
"uri": "orders/{order}",
"name": "orders.show",
"controller_fqcn": "App\\Http\\Controllers\\OrderController",
"controller_method": "show",
"middleware": ["web", "auth"],
"file": "routes/web.php",
"line": 19,
"dispatches": [
{
"target": "App\\Events\\OrderShipped",
"kind": "event",
"confidence": "high",
"file": "app/Http/Controllers/OrderController.php",
"line": 42
}
]
},
{
"method": "POST",
"uri": "webhooks/stripe",
"name": null,
"controller_fqcn": null,
"controller_method": null,
"middleware": [],
"file": "routes/api.php",
"line": 7,
"dispatches": []
}
],
"mailables": [
{
"fqcn": "App\\Mail\\OrderShipped",
"file": "app/Mail/OrderShipped.php",
"line": 18,
"queued": true,
"queue_config": {
"connection": null,
"queue": "mail",
"delay": null,
"tries": 3,
"timeout": null,
"backoff": null
},
"sent_from": [
{
"file": "app/Services/Checkout.php",
"line": 94,
"method": "App\\Services\\Checkout::finalize",
"overrides": { "locale": "fr", "mailer": "ses" }
}
]
}
],
"notifications": [
{
"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": [
{
"file": "app/Services/Billing.php",
"line": 51,
"method": "App\\Services\\Billing::charge",
"overrides": { "queue": "emails" }
},
{
"file": "app/Services/Billing.php",
"line": 88,
"method": "App\\Services\\Billing::charge",
"channels": ["mail", "App\\Channels\\SlackChannel"]
}
]
}
],
"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,
"end_line": 40,
"registration": "event_listen_call",
"queued": false,
"dispatches": [
{
"target": "App\\Events\\OrderConfirmationSent",
"kind": "event",
"confidence": "high",
"file": "app/Providers/EventServiceProvider.php",
"line": 39
}
]
}
]
}
The JSON shape is defined by schema/loom-index.schema.json and validated on every scan.
Consuming the index (PHP API)
loom:show is for humans; for programs, load a written index into typed objects with IndexLoader. The getters and lookups on Index return read-model value objects from Lucasp\Loom\Index\Model\ — no raw-array digging.
use Lucasp\Loom\Index\IndexLoader; $index = (new IndexLoader())->fromFile('storage/loom/index.json'); foreach ($index->events() as $event) { echo $event->fqcn, "\n"; } foreach ($index->handlersOf('App\\Events\\OrderShipped') as $handler) { echo " handled by {$handler->listener}::{$handler->method}\n"; }
This is the supported surface for tools that consume an index. See docs/index-api.md for the full loader, getters, lookups, and value-object reference.
GitHub Action
A composite action gates pull requests on Loom: it scans the branch, checks the index against policy, diffs it against the base ref, and posts a sticky PR comment.
- uses: actions/checkout@v4 - uses: lucasp1337/laravel-loom@v1 with: strict: "true"
The action runs inside your Laravel app (the repo that has laravel-loom installed). Inputs, outputs, and the baseline trade-off are in docs/github-action.md.
MCP server
loom:mcp starts a local, read-only MCP server over the index, so an AI agent can query your event graph instead of grepping source. It runs embedded — laravel/mcp auto-discovers it, nothing to wire up.
php artisan loom:mcp # serves storage/loom/index.json over stdio
It exposes eleven tools — lookups (list-entities, get-entity), edges (handlers-for, dispatch-sites-for, dispatches-from), chains (events-following, route-to-events), and analysis (impact-of-change, find-orphans). Point any stdio MCP client at php artisan loom:mcp with your project as the working directory. The full tool reference and client config are in docs/mcp.md.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
Local development
Running the package needs only PHP 8.3+, but the test suite needs ext-mbstring, ext-xml, ext-dom, and ext-xmlwriter. A Dockerfile and Justfile are provided so you can run the full toolchain without those extensions on your host:
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.
A benchmark suite (composer bench) measures scan cost across generated tiny/medium/large apps and gates CI on deterministic counts — see benchmarks/README.md.
Documentation
- Architecture — pipeline, scanner contract, cross-link pass
- Schema — JSON schema reference
- Index PHP API — load an index into typed objects (
IndexLoader, getters, value objects) - GitHub Action — the composite action that gates PRs on Loom
- MCP server — the embedded
loom:mcpserver and its eleven tools for AI agents - 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.