PHPackages                             lucasp1337/laravel-loom - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. lucasp1337/laravel-loom

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

lucasp1337/laravel-loom
=======================

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

v0.2.0(3w ago)00[14 issues](https://github.com/lucasp1337/laravel-loom/issues)[1 PRs](https://github.com/lucasp1337/laravel-loom/pulls)MITPHPPHP ^8.3CI passing

Since May 16Pushed 1w agoCompare

[ Source](https://github.com/lucasp1337/laravel-loom)[ Packagist](https://packagist.org/packages/lucasp1337/laravel-loom)[ Docs](https://github.com/lucasp1337/laravel-loom)[ GitHub Sponsors](https://github.com/lucasp1337)[ RSS](/packages/lucasp1337-laravel-loom/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (10)Versions (10)Used By (0)

   ![Laravel Loom — Architecture as data](art/logo-wide.svg)

 [![Tests](https://github.com/lucasp1337/laravel-loom/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/lucasp1337/laravel-loom/actions/workflows/run-tests.yml) [![PHPStan](https://github.com/lucasp1337/laravel-loom/actions/workflows/phpstan.yml/badge.svg?branch=main)](https://github.com/lucasp1337/laravel-loom/actions/workflows/phpstan.yml) [![Coverage](https://camo.githubusercontent.com/75fc0e237264a14581adb28255b740b9a23ad93638864def9eee5d3ca2ba4502/68747470733a2f2f636f6465636f762e696f2f67682f6c7563617370313333372f6c61726176656c2d6c6f6f6d2f67726170682f62616467652e737667)](https://codecov.io/gh/lucasp1337/laravel-loom) [![License](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](LICENSE.md)

Laravel Loom
============

[](#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
-----

[](#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
-------------

[](#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/](docs/scanners/).

Sample output
-------------

[](#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,
    "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",
      "target": "mail:send {--queue=default}",
      "cron": "0 13 * * *",
      "timezone": "America/Chicago",
      "without_overlapping": true,
      "on_one_server": false,
      "run_in_background": false,
      "constraints": ["weekdays"],
      "file": "app/Console/Kernel.php",
      "line": 28
    }
  ],
  "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.

Requirements
------------

[](#requirements)

- PHP **8.3+**
- Laravel **11, 12, or 13**

Local development
-----------------

[](#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](docs/contributing.md) for the full list of recipes.

Documentation
-------------

[](#documentation)

- [Architecture](docs/architecture.md) — pipeline, scanner contract, cross-link pass
- [Schema](docs/schema.md) — JSON schema reference
- [Scanners](docs/scanners/) — per-scanner behavior, edge cases, known limitations
- [Contributing](docs/contributing.md) — toolchain, Docker workflow, how to add a scanner

License
-------

[](#license)

The MIT License (MIT). See [LICENSE.md](LICENSE.md).

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance97

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

23d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/cf036b178f4899b62ff0538284804f4328c0ab45aa887622044a88f2e59a9253?d=identicon)[lucasp1337](/maintainers/lucasp1337)

---

Top Contributors

[![lucasp1337](https://avatars.githubusercontent.com/u/96388684?v=4)](https://github.com/lucasp1337 "lucasp1337 (43 commits)")

---

Tags

laravelstatic analysiseventslistenersobserversloom

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/lucasp1337-laravel-loom/health.svg)

```
[![Health](https://phpackages.com/badges/lucasp1337-laravel-loom/health.svg)](https://phpackages.com/packages/lucasp1337-laravel-loom)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[zidbih/laravel-deadlock

Make temporary Laravel workarounds expire and fail CI when ignored.

954.0k](/packages/zidbih-laravel-deadlock)[laravel/surveyor

Static analysis tool for Laravel applications.

8390.3k12](/packages/laravel-surveyor)[calebdw/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

15104.9k4](/packages/calebdw-larastan)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
