Skip to content

Index PHP API

The typed, in-memory counterpart to the JSON schema. Where the schema describes the bytes on disk, this page describes the PHP objects you get when you load those bytes back into a program. Use it from library code or custom tooling that wants to walk a Loom index without reaching into raw arrays.

Everything here lives in Lucasp\Loom\Index\ (loader + Index) and Lucasp\Loom\Index\Model\ (the value objects).

Loading an index

IndexLoader hydrates an Index from a written index.json — the inverse of Index::toArray(). Three entry points, depending on what you already hold:

use Lucasp\Loom\Index\IndexLoader;

$loader = new IndexLoader();

$index = $loader->fromFile('storage/loom/index.json'); // read + decode a file
$index = $loader->fromJson($jsonString);               // decode a JSON string
$index = $loader->fromArray($decodedArray);            // wrap an already-decoded array

fromFile() and fromJson() ultimately delegate to fromArray(), so all three produce the same Index.

Errors

Every failure throws Lucasp\Loom\Index\IndexLoadException (a RuntimeException):

Cause Entry point Message shape
File unreadable / missing fromFile Unable to read Loom index file: …
Invalid JSON fromJson Loom index is not valid JSON: …
JSON not an object fromJson Loom index must decode to a JSON object.
Missing envelope field fromArray Loom index is missing the required…envelope field.

The required envelope fields are loom_version, scanned_at, and laravel_version. The loader does not run schema validation — it trusts that a file Loom wrote conforms. Absent sections default to an empty list, so an index that predates a section (e.g. one written before routes[] existed) hydrates cleanly with routes() returning [] rather than throwing.

The envelope scalars are plain public properties on the result:

$index->loomVersion;     // "0.4.0"
$index->scannedAt;       // "2026-05-16T19:25:54Z"
$index->laravelVersion;  // "13.7"

Typed access

Each section has a getter on Index that returns a list<X> of read-model value objects. The lists are hydrated lazily on first call and memoized, so repeated calls are cheap and you can call as many getters as you need.

Getter Returns
events() list<Model\Event>
modelEvents() list<Model\ModelEvent>
listeners() list<Model\Listener>
closureListeners() list<Model\ClosureListener>
observers() list<Model\Observer>
jobs() list<Model\Job>
mailables() list<Model\Mailable>
notifications() list<Model\Notification>
scheduledTasks() list<Model\ScheduledTask>
routes() list<Model\Route>
unresolvedDispatches() list<Model\UnresolvedDispatch>

Lookups

Index also exposes by-FQCN lookups and two graph helpers:

$index->findEvent('App\\Events\\OrderShipped');             // ?Model\Event
$index->findListener('App\\Listeners\\NotifyWarehouse');    // ?Model\Listener
$index->findObserver('App\\Observers\\UserObserver');       // ?Model\Observer
$index->findJob('App\\Jobs\\ProcessShipment');              // ?Model\Job
$index->findMailable('App\\Mail\\ShipmentDispatched');      // ?Model\Mailable
$index->findNotification('App\\Notifications\\OrderShipped'); // ?Model\Notification

$index->dispatchersOf('App\\Events\\OrderShipped'); // list<Model\DispatchSite>
$index->handlersOf('App\\Events\\OrderShipped');    // list<Model\Handler>

Each find* returns null when the FQCN is unknown. dispatchersOf() and handlersOf() return an empty list for an unknown or unconnected event — they are convenience wrappers over findEvent($fqcn)?->dispatchedFrom and ?->handledBy, so they never throw.

Worked example — walking the graph

Load an index, then trace an event back to the code that dispatches it and forward to the listeners that handle it:

use Lucasp\Loom\Index\IndexLoader;

$index = (new IndexLoader())->fromFile('storage/loom/index.json');

foreach ($index->events() as $event) {
    echo "{$event->fqcn} ({$event->file}:{$event->line})\n";

    foreach ($index->dispatchersOf($event->fqcn) as $site) {
        echo "  dispatched from {$site->method} at {$site->file}:{$site->line}\n";
    }

    foreach ($index->handlersOf($event->fqcn) as $handler) {
        echo "  handled by {$handler->listener}::{$handler->method}\n";
    }
}

Because the read model is fully typed, your IDE and PHPStan see every field — no array-key guessing, no @var hints at call sites.

Value objects

Every value object in Lucasp\Loom\Index\Model\ is final readonly with public properties and a fromArray() factory. Fields mirror the JSON schema 1:1, but the property names are camelCase — dispatched_from becomes ->dispatchedFrom, queue_config becomes ->queueConfig, and so on. Enum-valued fields hydrate into typed enums (listed below the tables).

Section models

Model Fields
Event string $id, string $fqcn, string $kind, string $file, int $line, list<DispatchSite> $dispatchedFrom, list<Handler> $handledBy
ModelEvent string $id, string $model, string $event, list<ModelEventHandler> $handledBy
Listener string $fqcn, string $file, int $line, list<Handle> $handles, ListenerRegistration $registration, bool $queued, list<Dispatch> $dispatches
ClosureListener string $event, string $file, int $line, int $endLine, ListenerRegistration $registration, bool $queued, list<Dispatch> $dispatches
Observer string $fqcn, string $file, int $line, string $observes, ObserverRegistration $registration, list<string> $hooks, list<Dispatch> $dispatches
Job string $fqcn, string $file, int $line, bool $queued, ?QueueConfig $queueConfig, list<DispatchSite> $dispatchedFrom, list<Dispatch> $dispatches
Mailable string $fqcn, string $file, int $line, bool $queued, ?QueueConfig $queueConfig, list<DispatchSite> $sentFrom
Notification string $fqcn, string $file, int $line, bool $queued, ?QueueConfig $queueConfig, list<DispatchSite> $notifiedFrom, list<string> $channels, bool $channelsDynamic
ScheduledTask ScheduleKind $kind, ?string $name, ?string $target, list<string> $arguments, ?string $queue, ?string $connection, ?string $cron, ?Frequency $frequency, ?string $timezone, bool $withoutOverlapping, ?int $withoutOverlappingExpiresAt, bool $onOneServer, bool $runInBackground, bool $evenInMaintenanceMode, list<string> $constraints, string $file, int $line
Route string $method, string $uri, ?string $name, ?string $controllerFqcn, ?string $controllerMethod, list<string> $middleware, string $file, int $line, list<Dispatch> $dispatches, ?int $endLine
UnresolvedDispatch string $file, int $line, string $expression, string $reason

Shared models

Model Fields
Dispatch string $target, DispatchKinds $kind, Confidence $confidence, string $file, int $line
DispatchSite string $file, int $line, string $method, ?DispatchOverrides $overrides, ?list<string> $channels
DispatchOverrides ?string $locale, ?string $mailer, ?string $connection, ?string $queue, ?int $delay, ?bool $afterCommit
QueueConfig string\|int\|null $connection, string\|int\|null $queue, string\|int\|null $delay, string\|int\|null $tries, string\|int\|null $timeout, string\|int\|null $backoff
Frequency FrequencyUnit $unit, int $every — a sub-minute schedule frequency (scheduled_tasks[*].frequency); present only when cron is null
Handle string $event, string $method — a listener's event→method binding (listeners[*].handles)
ModelEventHandler string $handler, string $method, string $file, int $line — an observer hook or Event::listen target on model_events[*].handled_by
Handler string $listener, string $method — an event's listener→method binding (events[*].handled_by)

DispatchSite::$overrides is null when the call site applied no fluent modifiers; DispatchSite::$channels is null except on notification notifiedFrom entries that carry a static channel filter. Job, Mailable, and Notification carry $queueConfig === null when $queued is false. These mirror the optional/nullable rules in the schema exactly.

Enums

The schema's string-valued fields hydrate into typed enums (all in Lucasp\Loom\Index\):

Field Enum Cases
listeners[*].registration, closure_listeners[*].registration ListenerRegistration LISTEN_ARRAY, AUTO_DISCOVERED, EVENT_LISTEN_CALL, SUBSCRIBER
observers[*].registration ObserverRegistration OBSERVE_CALL, ATTRIBUTE
scheduled_tasks[*].kind ScheduleKind COMMAND, JOB, CLOSURE, EXEC
scheduled_tasks[*].frequency.unit FrequencyUnit SECONDS
dispatches[*].kind DispatchKinds EVENT, JOB, MAILABLE, NOTIFICATION, AMBIGUOUS
dispatches[*].confidence Confidence HIGH, MEDIUM, LOW

Read an enum's backing string with ->value (e.g. $route->dispatches[0]->kind->value). On a cross-linked Dispatch, kind is always EVENT or JOB — AMBIGUOUS is an internal pre-disambiguation marker that never survives into a written index. confidence is currently always HIGH; MEDIUM/LOW are reserved for future runtime-overlay work.

What is public

Public classes carry @api and are listed here:

  • Lucasp\Loom\Index\: Index, IndexLoader, IndexLoadException, and the enums Confidence, DispatchKinds, DispatchMode, FrequencyUnit, ListenerRegistration, ObserverRegistration, ScheduleKind.
  • Lucasp\Loom\Index\Model\: the value objects above (ClosureListener, Dispatch, DispatchOverrides, DispatchSite, Event, Frequency, Handle, Handler, Job, Listener, Mailable, ModelEvent, ModelEventHandler, Notification, Observer, QueueConfig, Route, ScheduledTask, UnresolvedDispatch).

The surface mirrors the schema, so it changes when the schema does.

Every other class carries @internal and may change in any release. That includes the scanners and visitors, Contracts\Scanner, IndexBuilder, the Dto\* classes, Query\IndexQuery, the check rules and the diff engine. Third-party scanners are not supported: to detect something new, contribute a scanner to Loom itself. The CLI commands, MCP tools and config/loom.php are documented in their own references.

A test fails if a class is neither @internal nor @api and listed on this page.