lucasp1337/laravel-loom

Static architectural inspector for Laravel applications — emits a JSON index of events, listeners, and observers.

Maintainers

Package info

github.com/lucasp1337/laravel-loom

pkg:composer/lucasp1337/laravel-loom

Fund package maintenance!

lucasp1337

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 14

v0.2.0 2026-05-17 16:37 UTC

README

Laravel Loom — Architecture as data

Tests PHPStan Coverage License

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 OrderPlaced dispatched from?events[].dispatched_from
  • Which listeners handle it, and via which methods?events[].handled_by (class-based) + closure_listeners[] filtered by event (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

  • Eventsapp/Events/** walk, plus any class statically dispatched via event(new Foo) / Event::dispatch(new Foo) (regardless of where the class lives). The ambiguous Dispatchable form X::dispatch() only counts as an event when the target resolves under app/Events/.
  • Listeners — Laravel's auto-discovery, $listen arrays on EventServiceProvider, Event::listen() calls anywhere under app/, 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 separate closure_listeners[] section.
  • ObserversModel::observe() calls, the #[ObservedBy] attribute, plus model events synthesized from observer hooks and Event::listen('eloquent.*', …).
  • Jobs — classes under app/Jobs/ (recursive), plus any class dispatched via dispatch(), Bus::dispatch(), or the Dispatchable form X::dispatch() (located via PSR-4, so jobs in DDD layouts get picked up). Records queued and queue_config (connection, queue, delay, tries, timeout, backoff) when declared as class properties.
  • Dispatches — every method body scanned for event(), Event::dispatch(), dispatch(), Bus::dispatch(), and X::dispatch(). Cross-linked back to listeners and observers by handler method.

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,
    "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:

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.