Check rules and output formats¶
Every flag, rule key, exit code and output format of loom:check and loom:diff. For a walkthrough, see Gate your CI.
Both commands read finished index.json files. They run no scanners and don't need your source tree.
Commands¶
php artisan loom:check {index?} {--baseline=} {--strict} {--skip=*} {--format=text}
php artisan loom:diff {old} {new} {--format=text}
index defaults to storage/loom/index.json. For loom:diff, order matters: entries in new but not old are added, and entries in old but not new are removed.
| Flag | Command | Effect |
|---|---|---|
--baseline=PATH |
check | Previous index for growth checks. Only unresolved-dispatches reads it. |
--strict |
check | Any unresolved dispatch fails, not just new ones. Overrides --baseline. |
--skip=KEY |
check | Skip a rule. Repeatable. An unknown key exits 2. |
--format= |
both | text (default), json or markdown. An unknown value exits 2. |
Rules¶
A rule either passes or adds violations. The run fails if any rule that ran has one.
| Key | Fails when | What to do |
|---|---|---|
schema |
The index doesn't validate against the Loom schema. | Re-run loom:scan. If the file was edited by hand or came from a different Loom version, regenerate it. |
orphan-listeners |
A listener handles no events. | Type-hint the event on handle(), register the listener, or delete it. |
orphan-events |
An event is never dispatched and never handled. | Delete it, or add the dispatch or listener that was meant to exist. |
unresolved-dispatches |
See below. | Make the dispatched class visible (a literal class name), or accept it into the baseline. |
cyclic-dispatch |
A chain of dispatches loops back to its start. | Break one link in the reported chain. |
Unresolved dispatches, baseline and strict¶
unresolved-dispatches polices what a branch adds, not the debt it inherits.
| Flags | Behaviour |
|---|---|
Neither --baseline nor --strict |
No-op. It passes because growth can't be measured without a reference. |
--baseline=PATH |
Fails on any unresolved dispatch not in the baseline. |
--strict |
Fails on every unresolved dispatch, whatever the baseline says. |
A green gate can be enforcing nothing
Plain loom:check counts this rule as passed while checking nothing. With a baseline, entries match by file, line and expression, so moving code makes old entries look new.
Cycle detection¶
cyclic-dispatch builds a graph with a node per event and job and an edge wherever a handler dispatches another. If a listener of OrderPlaced dispatches InventoryAdjusted, there is an edge OrderPlaced → InventoryAdjusted. Jobs join through their own dispatches.
One cycle per run
It reports a representative cycle for each back-edge, not every possible cycle in a tangled region. Fix the reported chain, re-run, and the next one surfaces.
Exit codes¶
| Command | Code | Meaning | What to do |
|---|---|---|---|
| check | 0 |
Every rule that ran passed. | Nothing. |
| check | 1 |
A rule found a violation. | Fix it or change the policy. Fail the job. |
| check | 2 |
Nothing was checked: bad path, invalid JSON, unknown --format, unknown --skip key. |
Fix the invocation. Fail the job too. |
| diff | 0 |
No semantic changes. | Nothing. |
| diff | 1 |
Changes exist. Not an error. | Post the diff. Fail only if you want drift to block. |
| diff | 2 |
A path isn't a file, the JSON is invalid, or --format is unknown. |
Fix the invocation. |
Check output¶
The verdict is the same in every format.
text¶
Rules with violations, then a count. Passing output is All checks passed.. Real terminal output is colored.
orphan-listeners — Every listener handles at least one event
✗ Listener App\Listeners\LegacyAudit handles no events.
orphan-events — Every event is dispatched or handled
✗ Event App\Events\OrderCancelled is never dispatched and never handled.
unresolved-dispatches — No new unresolved dispatches (strict: none at all)
✗ Unresolved dispatch (dynamic_class_name) at app/Services/Checkout.php:14: event(new $eventClass())
cyclic-dispatch — No cyclic event/job dispatch chains
✗ Cyclic dispatch: App\Events\InventoryAdjusted → App\Events\OrderPlaced → App\Events\InventoryAdjusted
4 violation(s) across 4 rule(s).
json¶
Every rule appears, including skipped ones, so a consumer can tell what was enforced. This run used --baseline:
{
"passed": false,
"violation_count": 4,
"rules": [
{
"key": "schema",
"description": "Index validates against the JSON schema",
"skipped": false,
"violations": []
},
{
"key": "orphan-listeners",
"description": "Every listener handles at least one event",
"skipped": false,
"violations": [
{
"message": "Listener App\\Listeners\\LegacyAudit handles no events.",
"context": {
"fqcn": "App\\Listeners\\LegacyAudit",
"file": "app/Listeners/LegacyAudit.php",
"line": 7
}
}
]
},
{
"key": "orphan-events",
"description": "Every event is dispatched or handled",
"skipped": false,
"violations": [
{
"message": "Event App\\Events\\OrderCancelled is never dispatched and never handled.",
"context": {
"fqcn": "App\\Events\\OrderCancelled"
}
}
]
},
{
"key": "unresolved-dispatches",
"description": "No new unresolved dispatches (strict: none at all)",
"skipped": false,
"violations": [
{
"message": "Unresolved dispatch (dynamic_class_name) at app/Services/Checkout.php:14: event(new $eventClass())",
"context": {
"reason": "dynamic_class_name",
"file": "app/Services/Checkout.php",
"line": 14,
"expression": "event(new $eventClass())"
}
}
]
},
{
"key": "cyclic-dispatch",
"description": "No cyclic event/job dispatch chains",
"skipped": false,
"violations": [
{
"message": "Cyclic dispatch: App\\Events\\InventoryAdjusted → App\\Events\\OrderPlaced → App\\Events\\InventoryAdjusted",
"context": {
"cycle": [
"App\\Events\\InventoryAdjusted",
"App\\Events\\OrderPlaced",
"App\\Events\\InventoryAdjusted"
]
}
}
]
}
]
}
With --skip=orphan-events, that rule stays in the list but reports as skipped:
{
"key": "orphan-events",
"description": "Every event is dispatched or handled",
"skipped": true,
"violations": []
}
markdown¶
A ## loom:check heading and one ### section per failing rule, ready for a PR comment. A passing run prints All checks passed..
## loom:check
### orphan-listeners — Every listener handles at least one event
- Listener App\Listeners\LegacyAudit handles no events.
### orphan-events — Every event is dispatched or handled
- Event App\Events\OrderCancelled is never dispatched and never handled.
### unresolved-dispatches — No new unresolved dispatches (strict: none at all)
- Unresolved dispatch (dynamic_class_name) at app/Services/Checkout.php:14: event(new $eventClass())
### cyclic-dispatch — No cyclic event/job dispatch chains
- Cyclic dispatch: App\Events\InventoryAdjusted → App\Events\OrderPlaced → App\Events\InventoryAdjusted
Diff output¶
loom:diff reports entries added, removed or changed, sorted, so the same pair of indexes always produces identical output. No changes prints No semantic changes. (text, markdown) or {} (json).
text¶
+ added, - removed, ~ changed, with the changed cross-link members listed under it. Added and removed entries print as the full JSON entry.
events
+ {"id":"App\\Events\\OrderCancelled","fqcn":"App\\Events\\OrderCancelled","kind":"class","file":"app/Events/OrderCancelled.php","line":7,"dispatched_from":[],"handled_by":[]}
~ App\Events\OrderPlaced
handled_by:
+ {"listener":"App\\Listeners\\ReserveStock","method":"handle"}
dispatched_from:
+ {"file":"app/Listeners/RecountOrders.php","line":14,"method":"App\\Listeners\\RecountOrders::handle"}
unresolved_dispatches
+ {"file":"app/Services/Checkout.php","line":14,"expression":"event(new $eventClass())","reason":"dynamic_class_name"}
json¶
Empty sections are omitted. Each section has added, removed and changed. Added and removed entries carry the full entry, so a consumer never reopens the source files. Here an event was removed and one of its dispatch sites went away:
{
"events": {
"added": [],
"removed": [
{
"id": "App\\Events\\InventoryAdjusted",
"fqcn": "App\\Events\\InventoryAdjusted",
"kind": "class",
"file": "app/Events/InventoryAdjusted.php",
"line": 7,
"dispatched_from": [],
"handled_by": []
}
],
"changed": [
{
"identity": "App\\Events\\OrderPlaced",
"field_changes": [],
"sublist_changes": [
{
"field": "dispatched_from",
"added": [],
"removed": [
{
"file": "app/Services/Checkout.php",
"line": 13,
"method": "App\\Services\\Checkout::finalize"
}
]
}
]
}
]
}
}
markdown¶
A ## heading per section. Field changes use → and members are prefixed + or -.
## events
- **removed** `{"id":"App\\Events\\InventoryAdjusted","fqcn":"App\\Events\\InventoryAdjusted","kind":"class","file":"app/Events/InventoryAdjusted.php","line":7,"dispatched_from":[],"handled_by":[]}`
- **changed** `App\Events\OrderPlaced`
- `dispatched_from`:
- `-` `{"file":"app/Services/Checkout.php","line":13,"method":"App\\Services\\Checkout::finalize"}`
What the diff compares¶
scanned_at, loom_version, laravel_version and the whole stats block are ignored, so two scans that differ only in those produce an empty diff. Entries are matched by identity, which is how a diff tells "changed" from "removed and re-added".
| Section | Matched by |
|---|---|
| events, listeners, jobs, mailables, notifications | fqcn |
| observers | fqcn and observes |
| model_events | id |
| scheduled_tasks | file, line, kind, target |
| unresolved_dispatches | file, line, expression |
| closure_listeners | file, line, event, registration |
Cross-link arrays (handled_by, handles, dispatches, dispatched_from and similar) compare by membership: a member is added or removed, never edited in place.
Two exceptions
Reordering notifications.channels counts as a change, since delivery order can matter. Closure listeners are add or remove only, because a closure has no class name to match on.
Field shapes for each section are in the schema reference.