Looking to hire Laravel developers? Try LaraJobs

laravel-interlock maintained by robertboes

Description
Keep the state of an Eloquent model consistent with a declared transition graph.
Author
Last update
2026/10/23 10:31 (dev-main)
License
Downloads
12

Comments
comments powered by Disqus

Laravel Interlock

Interlock keeps an Eloquent model's state consistent with a transition graph you declare. The name comes from railways: an interlocking stops a signaller from setting conflicting routes, whichever lever they pull. Interlock does the same for model state. A model can't reach a state its machine doesn't allow, and two conflicting transitions can't both succeed.

State only changes through named transitions such as ship, cancel or publish. Assigning the attribute and saving throws. So do update([...]), create([...]) in a state that isn't initial, and Order::where(...)->update([...]). Every transition writes with compare-and-swap, so when the state has changed since the model was loaded, the transition fails instead of overwriting it.

Transitions can also take typed input, check guards and authorization, and write related rows in the same transaction, with follow-up work held back until it commits. Triggers apply a transition on their own once a condition holds. History, if you turn it on, records who changed what and why. You can export the machines to Mermaid for your docs, or to XState for the frontend.

Enforcement covers everything that goes through Eloquent. The query builder and raw SQL are outside it; see Where enforcement stops.

Contents

Installation

Interlock requires PHP 8.4 or later and Laravel 12 or 13.

composer require robertboes/laravel-interlock

The service provider registers itself. Publishing the config file is optional:

php artisan vendor:publish --tag=interlock-config

History needs its table. Publish the migration and run it only if you turn history on for a machine:

php artisan vendor:publish --tag=interlock-migrations
php artisan migrate

The table keeps each model's key in a string column, so models with integer, UUID and ULID keys share it. Interlock compares keys as strings, so the index is used whatever the key type. The actor columns use nullableMorphs(); change them to nullableUuidMorphs() or nullableUlidMorphs() before migrating if your users have UUID or ULID keys.

Interlock ships guidelines for Laravel Boost. If your app uses Boost, php artisan boost:install (or boost:update) adds them to your AI assistant's instructions, so it changes state through transitions instead of writing the attribute.

Defining a state machine

A state machine belongs to one state attribute of a model, such as status. Its states are the cases of a backed enum. Its transitions are named changes from one or more from-states to exactly one target state.

Start with the enum. Labels, colors and other behavior can live on it as usual:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Preparing = 'preparing';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

Then the machine. It declares its initial and final states as attributes and lists its transitions:

use RobertBoes\Interlock\Attributes\FinalStates;
use RobertBoes\Interlock\Attributes\InitialStates;
use RobertBoes\Interlock\StateMachine;

#[InitialStates(OrderStatus::Pending)]
#[FinalStates(OrderStatus::Delivered, OrderStatus::Cancelled)]
class OrderStatusMachine extends StateMachine
{
    public function transitions(): array
    {
        return [
            $this->transition('prepare')->from(OrderStatus::Pending)->to(OrderStatus::Preparing)->authorize('manage-orders'),
            $this->transition('ship')->from(OrderStatus::Preparing)->to(OrderStatus::Shipped)->authorize('manage-orders'),
            $this->transition('deliver')->from(OrderStatus::Shipped)->to(OrderStatus::Delivered)->authorize('manage-orders'),
            $this->transition('cancel')->from(OrderStatus::Pending, OrderStatus::Preparing)->to(OrderStatus::Cancelled)->authorize('cancel-order'),
        ];
    }
}

Every transition says who may apply it by hand, here with a Gate ability. A transition anyone may apply says so with ->withoutAuthorization(); see Authorization.

Finally, link the machine to the model with #[UseStateMachine], use the HasStateMachines trait and cast the state attribute to the enum. The cast is required:

use Illuminate\Database\Eloquent\Model;
use RobertBoes\Interlock\Attributes\UseStateMachine;
use RobertBoes\Interlock\HasStateMachines;

#[UseStateMachine('status', OrderStatusMachine::class)]
class Order extends Model
{
    use HasStateMachines;

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
        ];
    }
}

The machine is compiled and checked the first time the model is used. A mistake in it, such as a transition without from-states or a target state, a transition that doesn't say who may apply it, a parent state as a target, or two transitions with the same name, throws an InvalidDefinitionException that says what to fix. Discovery, triggers and exports also throw one for a model that declares #[UseStateMachine] but forgot the HasStateMachines trait, because nothing would enforce its machines.

Initial and final states

Initial states are the states a model may be created in. When a machine has exactly one, Interlock fills it in on create, so Order::create() starts the order in pending. When it has several, the model has to be created with one of them; creating it without a state throws AmbiguousInitialStateException. Creating it in a state that is not initial throws DirectWriteException.

Initial states are declared on the machine rather than the enum, because one enum may back several machines.

Final states are where a model is meant to end up, so they have no outgoing transitions. Declaring them lets the soundness check tell an intended end from a dead end.

Fluent and class-based transitions

A transition without input or logic takes one line, as above. The fluent builder also takes guards, authorization, a trigger and two steps:

$this->transition('deliver')
    ->from(OrderStatus::Shipped)
    ->to(OrderStatus::Delivered)
    ->authorize('manage-orders')
    ->apply(fn(Order $order) => $order->delivered_at = now())
    ->afterCommit(fn(Order $order) => $order->customer->notify(new OrderDelivered($order))),
  • apply runs inside the transition's transaction, after the state attribute is set to the target state and before the write. Attributes it sets are saved in the same update, and rows it writes are rolled back when the transition fails for any reason, stale state included.
  • afterCommit runs once the transition has committed. Send mail, make HTTP calls and dispatch queued work here, so they never run for a transition that didn't happen. Inside an outer transaction, it waits for that transaction to commit. When it throws, the transition stays applied: its Transitioned event is still dispatched, and so are the afterCommit steps and events of the other transitions saved with it. The first exception is rethrown once they all have run.

A transition that needs input is a class with a typed constructor. Its from-states and target state are attributes, so exports and checks can read them without input values:

use RobertBoes\Interlock\Attributes\Authorize;
use RobertBoes\Interlock\Attributes\FromStates;
use RobertBoes\Interlock\Attributes\TargetState;
use RobertBoes\Interlock\Transition;

#[FromStates(OrderStatus::Preparing)]
#[TargetState(OrderStatus::Shipped)]
#[Authorize('manage-orders')]
final class Ship extends Transition
{
    public function __construct(public readonly string $trackingCode) {}

    public function apply(Order $order): void
    {
        $order->tracking_code = $this->trackingCode;
        $order->shipments()->create(['tracking_code' => $this->trackingCode]);
    }

    public function afterCommit(Order $order): void
    {
        $order->customer->notify(new OrderShipped($order));
    }
}

List the class in the machine's transitions(), next to the fluent ones:

return [
    $this->transition('prepare')->from(OrderStatus::Pending)->to(OrderStatus::Preparing)->authorize('manage-orders'),
    Ship::class,
    // ...
];

Both steps are optional. The transition's name comes from its class name in snake case, so Ship is ship and CreateLabel is create_label. Override the static name() method to choose another.

Nested states

A parent state groups child states in the same column, such as preparing grouping packing and label_created. Mark each child with #[ParentState]:

use RobertBoes\Interlock\Attributes\ParentState;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Preparing = 'preparing';
    #[ParentState(self::Preparing)]
    case Packing = 'packing';
    #[ParentState(self::Preparing)]
    case LabelCreated = 'label_created';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

A model is never in a parent state itself, only in one of its children, so a parent state cannot be an initial state, a target state or a stored value. A transition from a parent state applies from any of its children: cancel from Preparing cancels a packing order and one with a label. The state scopes expand a parent state to its children, and Interlock::isWithin($order->status, OrderStatus::Preparing) tells you whether a state is nested within another.

Guarded attributes

A guarded attribute is one that only named transitions may write, such as a tracking_code that only ship may set. Declare it on the machine, by transition name or class:

use RobertBoes\Interlock\Attributes\GuardedAttribute;

#[GuardedAttribute('tracking_code', Ship::class)]
class OrderStatusMachine extends StateMachine

Writing a guarded attribute anywhere else throws, just like a direct write to the state attribute, and so does creating a model with it set. Writes to every other attribute stay unrestricted.

Several machines on one model

Independent lifecycles get a machine each. Repeat #[UseStateMachine] once per state attribute:

#[UseStateMachine('status', OrderStatusMachine::class)]
#[UseStateMachine('payment_status', PaymentStatusMachine::class)]
class Order extends Model

Transition names must be unique per model across all its machines, so applying a transition never needs to say which machine it belongs to. One enum may back several machines with different graphs, such as an Approval enum behind both legal_status and finance_status.

Recording when a state was entered

To know how long a model has been in its state, such as "resolved for 7 days", give the machine a column for Interlock to keep up to date. Declare it with enteredAt::

#[UseStateMachine('status', TicketStatusMachine::class, enteredAt: 'status_changed_at')]
class Ticket extends Model

Without enteredAt: nothing changes. With it, Interlock sets the column to now (Laravel's fresh timestamp, so Carbon::setTestNow() applies):

  • when the model is created;
  • on every transition, in the same write as the state. A self-transition resets it too;
  • for each machine a staged save transitions, and under Interlock::fake();
  • on a bypassed write, or a bypassed mass write through the builder, that changes the state, unless the write sets the column itself. That lets imports and factories keep a historical time: Ticket::factory()->create(['status' => TicketStatus::Resolved, 'status_changed_at' => now()->subDays(8)]). A mass write can't tell which rows change state, so one that writes the state attribute sets the column on every row it writes. Inserted rows that leave the column out get it too, except through insertUsing and insertOrIgnoreUsing, whose subquery has to select it.

It follows the leaf state: moving between two children of one parent state resets it, so the time spent within the parent is not tracked. Outside a bypass, writing the column yourself throws DirectWriteException, through the model and through the builder, like a guarded attribute. replicate() clears it, so the copy records when it was created. History does not record it; each entry has its own timestamp.

The column is cast to datetime unless the model casts it already. Index it, since a trigger queries it every minute. Adding it to an existing table leaves it null, and a null column never matches InStateFor, so backfill existing rows. updated_at is the best estimate:

Schema::table('tickets', function (Blueprint $table) {
    $table->timestamp('status_changed_at')->nullable()->index();
});

DB::table('tickets')->whereNull('status_changed_at')->update(['status_changed_at' => DB::raw('updated_at')]);

Applying transitions

Apply a transition by name, or pass an instance of its class when it takes input:

$order->transition('prepare');
$order->transition(new Ship($trackingCode));

A transition writes immediately; there is no separate save(). The model's other unsaved changes go out in the same update, so filling a form and applying a transition costs one query:

$post->fill($request->validated())->transition('publish');

History and events still record only what the transition itself wrote. Eloquent's own model events (saving, updating, updated, saved) and timestamps fire as usual, so observers and search indexing keep working. A declared self-transition, from a state to itself, is applied like any other.

When a transition can't be applied, you get an exception that says why:

  • UnknownTransitionException: the model has no transition with that name.
  • NotAFromStateException: the current state isn't one of its from-states, as when a form is submitted twice. It renders as 409; its message only says the transition can't be applied in the record's current state, and the details are on its properties (model, transition, stateAttribute, currentState, fromStates) and in the context Laravel logs.
  • IllegalTransitionException: you staged a second transition on a machine that already holds one.
  • MissingTransitionInputException: you applied a class-based transition that takes input by name, instead of passing an instance.
  • CancelledTransitionException: a saving or updating listener cancelled the save, so nothing was written. A save() that writes staged transitions returns false instead, like Eloquent's.

Stale state

Every transition adds the state the model was loaded in to the WHERE clause of its update. If another request has changed the stored state in the meantime, the transition throws StaleStateException, writes nothing and rolls back whatever its apply step wrote. The model keeps its unsaved changes in memory, so you can reload and decide what to do:

$a = Order::find($id);
$b = Order::find($id);

$a->transition('cancel');
$b->transition('prepare'); // throws StaleStateException

Its message only says the record changed and should be reloaded, because Laravel shows it in the 409 response. The details are on its properties (model, transition, stateAttribute, loadedState, storedState, rowDeleted) and in the context Laravel logs when it reports the exception.

Interlock does not take locks itself. Lock the row yourself with lockForUpdate() when you would rather wait than fail.

Staged transitions

To write transitions on several machines and other changes in one update, stage them and save once:

$order->stageTransition(new Ship($trackingCode))
    ->stageTransition('capture_payment')
    ->save();

Staging only checks the graph, so it fails straight away when the current state isn't a from-state, and from then on the state attribute reads the target state. Everything else waits for save(): authorization, guards, the apply step and the compare-and-swap write, all checked against the data as it will be stored. Each machine holds one staged transition at a time; staging a second on the same machine throws. discardStagedTransitions() drops them and puts the state attributes back, and so does refresh().

Signals

Sometimes one action means a different transition depending on the state. When a customer replies to a support ticket, a ticket awaiting the customer goes back to the support queue, but a closed one is reopened. Rather than have every caller pick between customer_replied and reopen, let both answer the reply signal:

use RobertBoes\Interlock\Attributes\OnSignal;

$this->transition('customer_replied')
    ->onSignal('reply')
    ->from(TicketStatus::AwaitingCustomer)
    ->to(TicketStatus::Open)
    ->authorize('reply-to-ticket'),

#[OnSignal('reply')]
#[FromStates(TicketStatus::Closed)]
#[TargetState(TicketStatus::Open)]
#[Authorize('reply-to-ticket')]
final class Reopen extends Transition

signal() applies the transition that answers the signal from the current state and returns its name, or null when no transition answers it from that state:

$ticket->signal('reply');          // "customer_replied", "reopen" or null
$ticket->signal('reply', $user);   // on behalf of a given actor

It applies the transition exactly as transition() does: the same default actor, authorization, guards, compare-and-swap write, steps, events and exceptions. Like transition(), it checks the stored state, and it does not retry after a StaleStateException. A signal that no transition of the model answers throws UnknownTransitionException.

Every transition answers a signal of its own name unless it declares another, so $ticket->signal('close') applies close. A transition answers one signal. The graph is checked when it is compiled, and throws InvalidDefinitionException when:

  • two transitions answer one signal from the same state, parent states expanded to their children, so the state alone decides which one applies;
  • transitions on two machines of a model answer one signal: signal names are unique per model, like transition names;
  • a transition that takes input declares a signal, since a signal applies the transition by name. Such a transition answers only its own name, and signal() reaching it throws MissingTransitionInputException, as transition() does.

The transition's name is still what history, guards, triggers, transition() and the Transitioning and Transitioned events use; none of them record the signal.

Guards, authorization and actors

A guard says "not now", authorization says "not you". They throw different exceptions, so an API can tell them apart.

Guards

A guard is a condition on the model that must hold for the transition to apply, such as "stock is available". It sees the model's unsaved changes, so it checks the model as it will be stored. Fluent transitions take a name, a check and a message:

$this->transition('publish')
    ->from(PostStatus::Ready)
    ->to(PostStatus::Published)
    ->authorize('publish-post')
    ->guard('has_title', fn(Post $post) => filled($post->title), 'A post needs a title before it is published.'),

A class-based transition marks its guard methods with #[Guard]. Each is named after its method in snake case, so inStock() is in_stock:

use RobertBoes\Interlock\Attributes\Guard;

#[Guard('Not every item is in stock.')]
public function inStock(Order $order): bool
{
    return $order->items->every->inStock();
}

A failing guard throws FailedGuardException with the guard's message, which renders as 422. Guards run inside the transaction, before apply.

Authorization

Every transition declares who may apply it by hand, in one of four ways:

  • authorization, ->authorize(...) (an authorize() method or #[Authorize] on a class), for the actors it allows;
  • ->withoutAuthorization() (#[WithoutAuthorization]), for anyone;
  • ->systemOnly() (#[SystemOnly]), for system code only;
  • a trigger and none of the above, for no one but the trigger.

A state machine guards changes that are often hard to undo, so a transition that declares none of these isn't open to everyone: the graph rejects it.

Authorization is a closure or method that receives the actor and the model and returns a bool or a Gate response, or the name of a Gate ability:

$this->transition('reject')
    ->from(ExpenseStatus::Submitted)
    ->to(ExpenseStatus::Rejected)
    ->authorize(fn(?User $actor, Expense $expense) => $actor?->isManager() === true),

$this->transition('withdraw')
    ->from(ExpenseStatus::Submitted)
    ->to(ExpenseStatus::Draft)
    ->authorize('withdraw-expense'),

A class-based transition defines an authorize(?User $actor, Expense $expense) method, or names a Gate ability with #[Authorize('approve-expense')] on the class. Declare one or the other, not both.

A denied actor gets UnauthorizedTransitionException. It extends Laravel's AuthorizationException, so it renders as 403 and your existing handling of authorization failures applies. Authorization is checked before the transaction begins.

Anyone may apply a transition declared with ->withoutAuthorization(), or a class marked #[WithoutAuthorization]:

use RobertBoes\Interlock\Attributes\WithoutAuthorization;

$this->transition('submit')->from(ExpenseStatus::Draft)->to(ExpenseStatus::Submitted)->withoutAuthorization(),

#[WithoutAuthorization]
final class ReportProblem extends Transition

Anyone means anyone: every user, a guest, and a job or command that applies it with a null actor. Use it for transitions any code path may apply, not to skip deciding. For a transition only a job applies, use systemOnly() instead.

Only system code applies a transition declared ->systemOnly(), or a class marked #[SystemOnly]: code running inside Interlock::asSystem(), or the transition's trigger if it has one. Applying it anywhere else throws SystemOnlyTransitionException, for every actor; staging one throws it on a save() outside the scope. It extends UnauthorizedTransitionException, so it renders as 403. availableTransitions() offers it only inside the scope. See System code.

Only its trigger applies a transition that has a trigger and declares no authorization. Applying a trigger-only transition by hand throws TriggerOnlyTransitionException, for every actor, a guest and a null actor included; staging one throws it on save(). It extends UnauthorizedTransitionException, so it renders as 403 too. availableTransitions() never offers it. Give it authorization or withoutAuthorization() as well to let people apply it by hand; its trigger still applies it either way.

The machine is checked when its graph is compiled, so a mistake fails in any test that touches the model. A transition that declares none of authorization, withoutAuthorization(), systemOnly() and a trigger throws InvalidDefinitionException naming the transition and the four ways to fix it. So does one that declares two of authorization, withoutAuthorization() and systemOnly(). A trigger combines with systemOnly(), meaning "the trigger, or system code", and with authorization or withoutAuthorization().

Actors

The actor is the user a transition is applied on behalf of. It defaults to the authenticated user, or to no one inside Interlock::asSystem(). Jobs and commands can pass one explicitly, or pass null to act on behalf of no one:

$expense->transition('approve', $manager);
$expense->transition('approve', null);

A null actor means no user, such as a guest, and authorization still decides for it. A Gate ability whose callback takes a non-nullable user denies a null actor, as Laravel's Gate does for guests.

Triggers are different: a trigger applies its transition without an actor and without checking its authorization. Declaring the trigger is your decision that the system may apply the transition, so there is no user to ask about. Its guards still run, and history records the trigger's name with no actor.

System code

A job that deletes an account's data applies start, complete and fail, and no user should. withoutAuthorization() would let any actor apply them, a guest included, since a guest and a job both have a null actor. Declare them systemOnly() instead, and run the job as system code:

$this->transition('start')
    ->from(DeletionStatus::Requested)
    ->to(DeletionStatus::Processing)
    ->systemOnly()
    ->trigger(fn(Builder $query) => $query->where('process_after', '<=', now())),

$this->transition('complete')->from(DeletionStatus::Processing)->to(DeletionStatus::Completed)->systemOnly(),
use RobertBoes\Interlock\System\RunsAsSystem;

final class ProcessDataDeletion implements ShouldQueue
{
    use Queueable;

    public function __construct(public DeletionRequest $request) {}

    public function middleware(): array
    {
        return [new RunsAsSystem('Deletion job')];
    }

    public function handle(): void
    {
        // ...
        $this->request->transition('complete');
    }
}

RunsAsSystem runs the job's handle() inside Interlock::asSystem(). Like all job middleware, it only runs when the job is dispatched: a test that calls handle() directly skips it, and the transition throws SystemOnlyTransitionException. Dispatch the job in the test with dispatchSync(), or leave the middleware out and wrap the transitions inside handle() in Interlock::asSystem() yourself.

The middleware doesn't wrap the job's failed() method either, so wrap the transition there yourself:

use RobertBoes\Interlock\Interlock;

public function failed(Throwable $exception): void
{
    Interlock::asSystem('Deletion job', fn() => $this->request->transition('fail'));
}

Any other code runs as the system the same way, such as a scheduled command, or an admin's "Process now" button. Check the admin's permission first; an actor you pass is recorded beside the reason:

Gate::authorize('process-deletions');

Interlock::asSystem('Admin processed now', fn() => $deletion->transition('start', auth()->user()));

Inside the scope:

  • authorization is skipped for every transition, as it is for a trigger; guards still run;
  • system-only transitions may be applied, and availableTransitions() offers every transition from the current state whose guards pass;
  • a trigger-only transition is still refused, and never offered: only its trigger applies it;
  • the actor defaults to null, even when a user is signed in. Pass one to record it.

The reason is required, and a blank one throws InvalidSystemScopeException. History records it beside the actor. Scopes nest, the innermost reason winning, and a scope ends when its callback returns or throws. asSystem() returns whatever the callback returns.

A trigger counts as system code too: it applies its transition, system-only or not, without checking authorization.

Available transitions

availableTransitions() returns the transitions an actor can apply to the model right now, across all its machines: those whose from-states include the current state, whose guards pass and whose authorization allows the actor. A trigger-only transition is never among them, and a system-only one only inside Interlock::asSystem(), where authorization is skipped. Nothing is written and no events are dispatched.

$order->availableTransitions();          // for the authenticated user
$order->availableTransitions($manager);  // for a given actor
$order->availableTransitions(null);      // on behalf of no one

Each one serializes as its name, the signal it answers, the state attribute of the machine it belongs to and its target state, ready for an API resource or Inertia props:

[
    {"name": "ship", "signal": "ship", "state_attribute": "status", "target_state": "shipped"},
    {"name": "cancel", "signal": "cancel", "state_attribute": "status", "target_state": "cancelled"},
    {"name": "authorize_payment", "signal": "authorize_payment", "state_attribute": "payment_status", "target_state": "authorized"}
]

A transition that takes input is offered on its from-states and authorization alone, because its guards may read input that only exists once it is applied. Its authorize() method must not read that input.

Querying by state

Interlock ships tappable scope classes. They add no methods to your models and no global macros:

use RobertBoes\Interlock\Scopes\InFromStateOf;
use RobertBoes\Interlock\Scopes\InState;
use RobertBoes\Interlock\Scopes\NotInState;

Order::query()->tap(new InState(OrderStatus::Preparing))->get();
Order::query()->tap(new NotInState([OrderStatus::Delivered, OrderStatus::Cancelled]))->get();
Order::query()->tap(new InFromStateOf('ship'))->get();

InState and NotInState expand a parent state to its children, so "all preparing orders" includes the packing ones. They find the state attribute from the enum; pass it as stateAttribute when one enum backs several machines on the model: new InState(Approval::Approved, stateAttribute: 'legal_status').

InFromStateOf finds the models in a from-state of the transition. It does not evaluate guards or authorization; ask the model for its available transitions when those matter.

InStateFor finds the models that have been in a state for at least a duration, by the machine's entered-at column:

use Carbon\CarbonInterval;
use RobertBoes\Interlock\Scopes\InStateFor;

Ticket::query()->tap(new InStateFor(TicketStatus::Resolved, days: 7))->get();
Ticket::query()->tap(new InStateFor([TicketStatus::Open, TicketStatus::Waiting], hours: 4))->get();
Ticket::query()->tap(new InStateFor(TicketStatus::Resolved, CarbonInterval::minutes(90)))->get();

It matches the models whose column is at or before now minus the duration, worked out each time the query runs. Pass minutes:, hours:, days: or a CarbonInterval; they add up. Parent states expand to their children, as with InState. Without states it constrains only the time, and pass stateAttribute: on a model with several machines. It throws InvalidStateQueryException when the machine declares no enteredAt column. A null column never matches.

The scopes also work as eager-load constraints, with with() and load():

Customer::with(['orders' => new InState(OrderStatus::Preparing)])->get();
$customer->load(['orders' => new InFromStateOf('ship')]);

If you prefer Eloquent scope methods, add the opt-in HasStateScopes trait to the model:

use RobertBoes\Interlock\HasStateScopes;

Order::whereInState(OrderStatus::Preparing)->get();
Order::whereNotInState(OrderStatus::Cancelled)->get();
Order::whereInFromStateOf('ship')->get();
Ticket::whereInStateFor(TicketStatus::Resolved, days: 7)->get();

Enforcement

A direct write is any change to a state attribute or guarded attribute that doesn't go through a transition. Interlock rejects it with DirectWriteException and writes nothing. That covers:

  • assigning the attribute and calling save(), or passing it to update() or fill() and saving;
  • create() and insert in a state that is not an initial state, or with a guarded attribute set;
  • saveQuietly(), updateQuietly(), createQuietly() and Model::withoutEvents(), because enforcement does not depend on model events;
  • writing an entered-at column yourself, through the model or the builder;
  • mass writes through the Eloquent builder: update, updateFrom, updateOrInsert, upsert, increment, decrement, incrementEach, decrementEach, insert, insertOrIgnore, insertOrIgnoreReturning (Laravel 13 and later) and insertGetId, whenever they touch a state attribute or guarded attribute, and insertUsing and insertOrIgnoreUsing whenever their columns include one, since the values a subquery selects are not known up front.

There is no setting that turns enforcement off. The only way past it is a bypass, which needs a reason. Deletes, force deletes and restores are not governed; that is what policies are for.

Where enforcement stops

Everything that goes through Eloquent is enforced. DB::table(), toBase() and raw SQL are not. These write to the database without passing through the model or its Eloquent builder, so Interlock never sees them:

DB::table('orders')->where('id', $id)->update(['status' => 'shipped']);   // not enforced
Order::query()->toBase()->update(['status' => 'shipped']);               // not enforced
DB::statement("update orders set status = 'shipped' where id = ?", [$id]); // not enforced

Interlock is not a database constraint. A database backstop that blocks these writes too is planned for a later version; until then, keep state changes on the Eloquent path and treat these routes the way you would treat editing the table by hand.

Custom Eloquent builders

Interlocked models get Interlock's own Eloquent builder, which is what rejects mass writes. If a model already has a custom builder, add the RejectsDirectWrites trait to it:

use Illuminate\Database\Eloquent\Builder;
use RobertBoes\Interlock\RejectsDirectWrites;

class OrderBuilder extends Builder
{
    use RejectsDirectWrites;
}

An interlocked model whose custom builder lacks the trait throws MissingBuilderTraitException as soon as it builds a query, to read, save or mass write, so enforcement is never lost silently. Making an instance doesn't check it, so new Order() needs no database connection.

replicate() starts the lifecycle over

This differs from Laravel's default. Laravel's replicate() copies every attribute. On an interlocked model, the copy instead starts its lifecycle over: each state attribute is reset to its machine's initial state, or left null when the machine has several, and every guarded attribute and entered-at column is cleared. Without that, saving a copy of a shipped order would be a direct write to shipped and would throw.

$copy = $shippedOrder->replicate();

$copy->status;        // OrderStatus::Pending
$copy->tracking_code; // null

A replicating listener may still set a state attribute, guarded attribute or entered-at column on the copy; each is only reset when the listener leaves it unset. Saving a copy with a guarded attribute or entered-at column set takes a bypass, as creating any model with one does.

Bypass

Seeders, imports and data fixes sometimes need to set any state. Run them inside a bypass, with a reason:

use RobertBoes\Interlock\Interlock;

Interlock::bypass('Import orders from the old shop', function () use ($rows) {
    foreach ($rows as $row) {
        Order::create([
            'reference' => $row['reference'],
            'status' => OrderStatus::from($row['status']),
        ]);
    }
});

The reason is required, so the audit trail says why and not just that something changed. The bypass covers model saves and mass writes through the Eloquent builder, and it ends when the callback returns or throws, so it can never stay on by accident. bypass() returns whatever the callback returns.

A bypassed write to a state attribute dispatches BypassedWrite with the reason. A bypassed write to a guarded attribute dispatches BypassedGuardedAttributeWrite, which also carries the attribute's old and new value. If the machine records history, both end up there. A bypassed mass write dispatches BypassedMassWrite instead. It names the model class and the attributes written, entered-at columns included, but not the rows (the query picks those), so it isn't recorded in history.

Triggers

A trigger applies a transition on its own once a condition holds, such as "publish_at has passed". Declare it on the transition as a constraint on the model's query:

use Illuminate\Database\Eloquent\Builder;

$this->transition('publish')
    ->from(PostStatus::Ready)
    ->to(PostStatus::Published)
    ->trigger(fn(Builder $query) => $query->where('publish_at', '<=', now())),

The condition can be any callable that takes the query, so a tappable scope class your app already uses works as it is:

->trigger(new PublishAtHasPassed()),

The transition's from-states are added to the condition, so it only says what is specific to the trigger. For "in this state for N days", use InStateFor without states; the trigger's from-states supply them:

$this->transition('close')
    ->from(TicketStatus::Resolved)
    ->to(TicketStatus::Closed)
    ->trigger(new InStateFor(days: 7)),

It works out the cutoff on every run, so the compiled and cached machine never freezes a date. A duration that comes from config has to be read on every run too, so wrap it in a closure: ->trigger(fn(Builder $query) => $query->tap(new InStateFor(days: config('tickets.close_after_days')))).

A class-based transition declares a static trigger() method instead:

public static function trigger(Builder $query): void
{
    $query->where('payment_status', PaymentStatus::Unpaid)->where('created_at', '<=', now()->subDays(3));
}

The publish above declares no authorization, so it is trigger-only: only the trigger applies it, and applying it by hand throws TriggerOnlyTransitionException. To let people publish early as well, add ->authorize(...), ->withoutAuthorization() for anyone, or ->systemOnly() for system code; the trigger still applies it, as triggers don't check authorization. See Authorization.

A transition that takes input cannot be built by name, so it cannot have a trigger: a transition class whose constructor has required parameters and declares trigger() throws InvalidDefinitionException when its graph is compiled.

Scheduling means setting the data the condition reads, never storing a pending run. To reschedule a post, change publish_at. To stop it, move it back to draft. Nothing needs cancelling.

Running triggers

Triggers only run while the Laravel scheduler runs. Interlock registers interlock:triggers to run every minute, so make sure your server runs php artisan schedule:run every minute (or php artisan schedule:work locally). Without it, no trigger ever fires.

Two config keys change that:

  • interlock.triggers.schedule.cron (default * * * * *): how often the command runs.
  • interlock.triggers.schedule.enabled (default true): set it to false to schedule interlock:triggers yourself, for example on one server only or with your own overlap and output options.

The command does no querying itself. It dispatches one queued job per trigger, so a large backlog never slows the scheduler down, and a queue worker must be running to process them. The jobs go to the app's default queue connection and queue; interlock.triggers.connection and interlock.triggers.queue send them elsewhere, such as a queue with its own workers so a big backlog doesn't hold up other work. Each job is unique per trigger, so a slow run and the next one never work on the same trigger at once; this needs a cache store that supports locks, as Laravel's unique jobs do. When a job can't be dispatched, say because the queue is down, the command reports it, dispatches the other triggers, and exits with a failure; the next run tries that trigger again.

The job applies the transition to each matching model, a chunk at a time in key order, without an actor and without checking its authorization, and records the trigger's name in history. Its guards still run. Three config keys size it:

  • interlock.triggers.chunk_size (default 100): how many models it loads at a time.
  • interlock.triggers.max_per_run (default 1000): the most models one run handles. A bigger backlog drains over the next runs, each carrying on where the last stopped, even when the last died partway: the run saves its place after every chunk.
  • interlock.triggers.timeout (default 60): the seconds one job may run. Keep it below the retry_after of the queue connection (90 by default), or the queue hands a slow run to a second worker while the first is still working on it. A job stops taking on models once three quarters of its timeout has passed, and the next run carries on from there. A job that runs out of time anyway is failed, not run again. Set it to 0 for no timeout, as on the queue. A run must still finish within retry_after, timeout or not, so size max_per_run and chunk_size for it.

A busy trigger can fan out into one job per chunk, so it can use several queue workers. Pass fanOut: true to the fluent trigger(), or mark a class-based transition's trigger() method with #[FanOut]:

->trigger(fn(Builder $query) => $query->where('publish_at', '<=', now()), fanOut: true),

The next run of a fanned-out trigger does nothing until every chunk job of the last one has finished or failed. Should one never report back, the trigger runs again once each chunk job could have used up its timeout in turn, or after five minutes, whichever is later.

Each chunk job carries its chunk's keys in its payload. With UUID or ULID keys, an interlock.triggers.chunk_size of around 5,000 exceeds the 256 KB message limit of SQS, so keep chunks of fanned-out triggers in the hundreds there.

Queue workers keep running the code they booted with, so run php artisan queue:restart after a deploy that adds, renames or removes a trigger. A trigger job still queued for a transition that was removed, or lost its trigger, does nothing.

A model whose state changed since it was loaded, or whose guard fails, is skipped quietly. Any other exception is reported and the run carries on with the rest. Either way the model still matches the condition, so the next run picks it up again. There are no retries, and nothing to clean up. A lost database connection is the exception: it fails the job instead of being reported once per model, and the next run tries again. Authorization can't stop a trigger; to stop it, change or remove the trigger, or add a guard.

Monitoring triggers

A trigger can stop working without any error: the scheduler stops, the queue workers stop, or the trigger runs but its models never leave the from-states, say because a guard always fails or every model throws. So you don't need a watchdog of your own, or a backstop sweep beside the trigger, each run records in the app's cache when it finished and whether models matched at its start.

php artisan interlock:triggers --status

This prints one row per trigger: how many models match it now, when its last run finished, since when models have kept matching without a run leaving none, and whether it is overdue. It changes nothing. Interlock::triggerStatus() returns the same rows as TriggerStatus objects.

A trigger is overdue when models match it now and either no run has finished within interlock.triggers.overdue_after minutes (default 15), or models have kept matching it for longer than that: no run, and for a trigger that fans out no last chunk job, has left none matching since. Models that become due between runs don't count against it, as long as each run applies them. A backlog older than the threshold does count as overdue: a trigger with more due models than its runs work through stays matching after every run. Raise the threshold for such a trigger, or let it work through more per run with interlock.triggers.max_per_run or fanOut: true.

interlock:triggers:check fails and names each overdue trigger, and succeeds when none is. Schedule it with a heartbeat ping on success, so your uptime monitor hears from it only while every trigger keeps up, and also notices when the scheduler itself stops:

use Illuminate\Support\Facades\Schedule;

Schedule::command('interlock:triggers:check')
    ->everyFiveMinutes()
    ->pingOnSuccess('https://heartbeat.example.com/interlock-triggers');

A trigger that fans out counts as running once its dispatching run finishes; chunk jobs still pending don't make it overdue by themselves. The check reads the cache the trigger jobs write to, so the scheduler and the queue workers must share a cache store.

The "scheduled and locked" pattern

A plain trigger leaves the model editable until the moment it fires: a ready post can still be retracted. When a scheduled change must not be touched any more, model the lock as its own state whose only way out is the triggered transition:

#[InitialStates(PostStatus::Draft)]
#[FinalStates(PostStatus::Published)]
#[GuardedAttribute('publish_at', 'schedule')]
class PostStatusMachine extends StateMachine
{
    public function transitions(): array
    {
        return [
            $this->transition('submit')->from(PostStatus::Draft)->to(PostStatus::Ready)->authorize('edit-post'),
            $this->transition('schedule')->from(PostStatus::Ready)->to(PostStatus::Scheduled)->authorize('edit-post'),
            $this->transition('publish')
                ->from(PostStatus::Scheduled)
                ->to(PostStatus::Published)
                ->trigger(fn(Builder $query) => $query->where('publish_at', '<=', now())),
        ];
    }
}
$post->publish_at = $request->date('publish_at');
$post->transition('schedule');

Once scheduled, no transition but publish leaves scheduled, and only schedule may write publish_at, so neither the state nor the time can change until the trigger publishes the post. The lock shows up in the graph and in the exports, rather than hiding in a flag. To allow unlocking, add a transition out of scheduled, such as unschedule back to ready. publish declares no authorization, so it is trigger-only: no one can publish a scheduled post by hand, and availableTransitions() never offers it. Don't authorize only the null actor, $actor === null, to mean "only the trigger": a guest is a null actor too, so any code a guest can reach could apply it, and availableTransitions() would offer it to guests.

History

History is opt-in per machine. Publish and run the migration, then add #[RecordsHistory] to the machine:

use RobertBoes\Interlock\Attributes\RecordsHistory;

#[RecordsHistory]
class OrderStatusMachine extends StateMachine

Without the migration, the machine's first transition throws a MissingHistoryTableException that names the machine and says how to fix it. A history table migrated before the system_reason column was added needs it added: $table->text('system_reason')->nullable().

Every transition and every bypassed write on that machine's state attribute or guarded attributes is then recorded in the interlock_history table, inside the same transaction as the write, so history can never disagree with the stored state. Quiet saves are recorded too. Each entry holds:

  • the model's type and key, and the state attribute;
  • the transition's name, null for a bypassed write;
  • the from-state and target state, as their backing values;
  • the actor's type and key, null when there was none;
  • system_reason, the reason of the Interlock::asSystem() scope it was written in, null outside one;
  • the attributes the transition or bypassed write wrote, old and new, and not the model's other changes or its entered-at columns;
  • the bypass reason, the trigger that applied it, and a timestamp.

Read a model's history through its relationship, optionally for one state attribute. Entries in the same second share a timestamp, so order a timeline by key:

$order->history()->get();
$order->history('status')->oldest('id')->get();

Each entry is a RobertBoes\Interlock\History\HistoryEntry model, with model and actor relationships. model_id is a string, so compare it with one when you query the table yourself: an integer stops MySQL using the index, and Postgres refuses the comparison.

HistoryEntry::where('model_type', $order->getMorphClass())->where('model_id', (string) $order->getKey())->get();

To write history somewhere else, implement RobertBoes\Interlock\History\HistoryRecorder and name your class in interlock.history.recorder (default DatabaseHistoryRecorder). It is resolved from the container, and called inside the write's transaction, so throwing rolls the write back. Binding the interface in a service provider works too, and wins over the config:

use RobertBoes\Interlock\History\HistoryRecorder;

$this->app->singleton(HistoryRecorder::class, MyHistoryRecorder::class);

Events

Interlock dispatches ordinary Laravel events, not model events, so muting Eloquent events doesn't mute them:

Event When
RobertBoes\Interlock\Events\Transitioning Inside the transaction, after the apply step and before the write
RobertBoes\Interlock\Events\Transitioned After the transaction commits, once the afterCommit step has run
RobertBoes\Interlock\Events\BypassedWrite After a direct write to a state attribute that a bypass allowed, once it commits
RobertBoes\Interlock\Events\BypassedGuardedAttributeWrite After a direct write to a guarded attribute that a bypass allowed, once it commits
RobertBoes\Interlock\Events\BypassedMassWrite After a mass write through the builder that a bypass allowed, once it commits

The three bypass events implement ShouldDispatchAfterCommit, so a write that rolls back dispatches none. Transitioning and Transitioned carry the model, the transition's name, the from-state and the target state.

The events that carry a model serialize it the way Laravel's queued jobs do, by its key. A queued listener gets the model as it is stored when the listener runs, fetched fresh from the database, not a snapshot of the instance that dispatched the event. If the model was deleted in the meantime, the listener's job fails with a ModelNotFoundException.

Exporting machines

The interlock:signalbox command exports your machines, every registered one or a single one, as Mermaid or XState:

# Every machine, as Mermaid diagrams in one Markdown document
php artisan interlock:signalbox

# One machine
php artisan interlock:signalbox "App\StateMachines\OrderStatusMachine"

# XState v5 config as JSON
php artisan interlock:signalbox --format=xstate

# XState as TypeScript with literal types, written to a file for the frontend
php artisan interlock:signalbox --format=xstate-ts --output=resources/js/machines.ts

To look at your machines, export them with --viz and paste the output into Stately Sketch, then click Update. It prints the XState export, with every machine side by side as regions of one parallel machine unless you pass a single one.

php artisan interlock:signalbox --viz | pbcopy
Format Output
mermaid (default) A stateDiagram-v2, with parent states as composite states
xstate XState v5 machine config JSON
xstate-ts The same config as TypeScript, plus state and event types

In the XState export, signals are events: each state's on is keyed by the signal a transition answers, with the transition's name in meta: { transition: 'reopen' } where the two differ, and the TypeScript event type is the union of the signals. Parent states are nested states and final states are type: 'final'. Guards appear by name only, so no server logic leaks; a transition with several guards gets one guard naming them all, such as has_assessment and within_policy_limit. Triggered transitions carry meta: { trigger: true }, and system-only transitions meta: { systemOnly: true }. A machine's XState ID is its class name without the namespace, so exporting every machine as XState fails when two machines share a class name; rename one, or export them one at a time. The Mermaid export labels an edge with its signal, followed by the transition's name where they differ, such as reply (reopen). For the locked post machine above, it looks like this:

stateDiagram-v2
    Draft
    Ready
    Scheduled
    Published
    [*] --> Draft
    Draft --> Ready: submit
    Ready --> Scheduled: schedule
    Scheduled --> Published: publish
    Published --> [*]

HTTP responses

Every Interlock exception implements RobertBoes\Interlock\Exceptions\InterlockException, so you can catch or report them as a group. Uncaught, they render like this:

Exception Status Meaning
UnauthorizedTransitionException 403 The actor may not apply the transition (or the status a denying Gate response gave)
TriggerOnlyTransitionException 403 The transition is trigger-only, so no one may apply it by hand; it extends UnauthorizedTransitionException
SystemOnlyTransitionException 403 The transition is system-only and was applied outside Interlock::asSystem(); it extends UnauthorizedTransitionException
FailedGuardException 422 A guard failed; the body's message is the guard's message
StaleStateException 409 The stored state changed since the model was loaded; reload and retry
NotAFromStateException 409 The current state isn't one of the transition's from-states, as when a form is submitted twice
DirectWriteException, IllegalTransitionException, UnknownTransitionException, InvalidDefinitionException, MissingBuilderTraitException, and the rest 500 A programming error to fix, not something to show users

Override any of them the usual Laravel way, in bootstrap/app.php:

use RobertBoes\Interlock\Exceptions\FailedGuardException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(fn(FailedGuardException $e) => response()->json([
        'message' => $e->getMessage(),
        'errors' => ['transition' => [$e->getMessage()]],
        'guard' => $e->guard,
    ], 422));
})

This keeps Laravel's 422 validation shape and adds the guard's name. Clients should match on the guard name, which stays the same, not on the message text, which you may reword or translate. FailedGuardException also carries the model and the transition name.

Discovery

Triggers, exports and the soundness check need to know every interlocked model, so Interlock finds them by scanning for #[UseStateMachine]. That way a trigger never silently fails to run for a model you forgot to register.

  • interlock.discovery.paths lists the directories it scans, recursively. The default is app/Models.
  • interlock.models lists models outside those paths, such as models from packages or modules. A package's service provider can add its own from its boot method with Interlock::registerModels(Invoice::class).

In production, cache the result with php artisan interlock:cache. It runs as part of php artisan optimize, and php artisan interlock:clear (part of optimize:clear) removes it.

Testing

Factories

Models created through Eloquent factories bypass enforcement automatically, with the reason "factory", so a test can create a model in any state in one line:

$order = Order::factory()->create(['status' => OrderStatus::Shipped]);

Everything else in your tests stays enforced, so endpoint tests still catch illegal transitions. A model a factory only made, and that your code saves later, is enforced as usual.

Factories bypass in every environment except production, so local seeders work too. In production, a factory's direct write is rejected like any other; wrap it in Interlock::bypass() with a reason, for example in a seeder that sets up a demo tenant.

To opt a single test into a full bypass, call Interlock::bypassForTest(). It lasts until the test ends and is refused outside tests:

it('imports legacy orders', function () {
    Interlock::bypassForTest('legacy fixtures');

    // ...
});

Checking machines are sound

One assertion checks every registered machine for states that cannot be reached from an initial state, states without a way out that are not final, final states with outgoing transitions, and definitions the graph rejects, such as duplicate transition names. It reports every problem at once:

use RobertBoes\Interlock\Interlock;

it('has sound state machines', function () {
    Interlock::assertMachinesAreSound();
});

Pass model classes to check only those: Interlock::assertMachinesAreSound(Order::class). Under Pest, the same check is an expectation:

expect(Interlock::models())->toHaveSoundStateMachines();
expect(Order::class)->toHaveSoundStateMachines();

Asserting on transitions

Assert that code transitioned a model, applied through that instance or any other instance of the same row:

Interlock::assertTransitioned($order, 'ship');
Interlock::assertNotTransitioned($order, 'cancel');
Interlock::assertNotTransitioned($order); // no transition at all

Only transitions that committed count. A misspelled transition name throws, so it can't make assertNotTransitioned() pass by accident.

Faking transitions

Interlock::fake() skips the apply and afterCommit steps of every transition for the rest of the test, to test the code that applies them in isolation:

Interlock::fake();

$this->post("/orders/{$order->id}/ship", ['tracking_code' => 'TRACK-1']);

Interlock::assertTransitioned($order, 'ship');

Faked transitions skip their guards and authorization too. They still check their from-states, write their target state with the model's other changes and dispatch their events, so an illegal transition still throws. A trigger-only transition applied by hand still throws TriggerOnlyTransitionException, and a system-only transition applied outside Interlock::asSystem() still throws SystemOnlyTransitionException, as that is the machine's structure rather than a question of who asks.

License

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