robertboes/laravel-interlock
Laravel Interlock is a lightweight package for preventing concurrent execution of critical code in Laravel apps. It helps avoid race conditions, duplicate jobs, and overlapping tasks with simple, configurable locks and a clean framework-friendly API.
Interlock adds guarded state machines to Eloquent models: a state attribute becomes read-only from the outside, and only declared transitions may change it. That single rule is the whole package — everything else (history, triggers, exports) hangs off it.
Setup:
composer require robertboes/laravel-interlock and publish the config (php artisan vendor:publish — take the interlock tag). If you want the audit trail, publish and run the history migration too; it is opt-in.First use case: take one model with a status column (Order, Invoice, Subscription). Declare its machine with the states you actually have, mark the terminal ones as final, then replace every $order->status = 'paid'; $order->save(); with a transition call. The moment you do, any remaining direct write will throw — which is the point, and also the thing that will break your seeders, factories and older controllers.
Validate your mental model before writing much code: run the interlock:signalbox export and look at the Mermaid diagram. If the picture is wrong, the machine is wrong.
Transitions as classes, not closures. Each transition is a class with apply and after-commit steps. Put the domain mutation in apply (update related rows, adjust totals) and everything with a side effect — mail, webhooks, broadcasts — in the after-commit step so it cannot fire on a rolled-back transaction. Business rules go in guards; actor permissions go in authorize($actor).
Drive UI from available transitions. Ask the package which transitions a given actor can apply to a model right now, and render buttons from that list. Each entry tells you which state attribute it belongs to, which matters once a model has more than one machine. Do not hand-maintain a list of "allowed statuses" in Blade — it drifts from the guards.
Staged transitions for clean writes. Stage a transition and let the next save() emit it in a single UPDATE. This is the pattern to prefer over mutating state and saving twice, and it composes with afterCommit nicely.
Handle staleness explicitly. Transitions use compare-and-swap and throw a stale-state exception when the row moved underneath you. Catch it in the controller and return 409 / re-render, or retry in a job. Do not catch-and-ignore; that is how you get double-shipped orders.
System scope for infrastructure transitions. Jobs, console commands and webhooks that are not acting as a user should run inside the system scope so system-only transitions apply. Keep that scope as tight as the bypass (below) — it is an escape hatch with a nicer name.
Multiple machines per model. Split status and payment_status into separate machines rather than one giant graph. Constraint to remember: transition names must be unique across all machines on the model, so prefix them (markPaid, cancelOrder) instead of reusing cancel.
Query with scopes. Use the state scopes for dashboards and reports (pending, overdue) rather than raw where('status', ...). Opt-in scope methods keep model noise down until you actually need them.
Time in state. Entry timestamps are recorded, so "in pending for more than 24h" is a query, not a cron with a custom column. This is usually the cheapest win after the first transition — SLA dashboards and escalation jobs become one-liners.
Triggers for autonomous behaviour. A trigger applies its transition once a condition (any callable) holds, without checking authorization — so the condition is the security boundary; keep it narrow and server-derived. Configure queue connection/queue per trigger, cap runs, and let busy triggers fan out into chunk jobs. Add the status command to your monitoring and treat "overdue trigger" as a page, not a log line.
Bypass, deliberately. Factories, seeders and opted-in tests write state directly inside a reasoned bypass. Because bypassed writes are recorded in history, prefer a bypass over dropping enforcement in test config — the intent stays visible in the audit trail.
Test at the transition boundary. Fake Interlock for unit-ish tests, and assert on transitions (applied / denied by guard / denied by authorization) in feature tests. Set up fixtures via factory bypass rather than walking the model through ten legal transitions.
Export into design review. php artisan interlock:signalbox gives Mermaid, XState JSON/TypeScript, and a --viz Stately Sketch export. Paste the Mermaid into the PR or ADR; ship the XState TS to the frontend team so their optimistic UI mirrors the real graph. Generate in CI, commit the artefact, diff it.
Soundness check in CI. Declare final states, then let the soundness check run in CI. It catches unreachable states, dead ends and missing terminal states before they become "why is this order stuck in processing" tickets.
update([...]), the query builder's mass update, insert, factories and replicate all interact with enforcement (replication deliberately resets state). Grep your codebase for ->update([ and ->status = on interlocked models before you ship the upgrade — this is the whole migration.authorize or must run in system scope / as a trigger.->refresh() and re-evaluate available transitions.sync in tests fire inline and can loop. Use the cap and chunk fan-out to bound this, and always drive trigger tests with a fake or a Bus::fake().shipped a processing state?" is a question your reporting code will ask.How can I help you explore Laravel packages today?