Going Further

State Machine

The project's custom state machine for legal status transitions — conditions, soft transitions, failure introspection, and the companion ability classes that wrap it for UI gating.

Two patterns work together to keep workflow logic and authorization out of controllers and services:

  • State machine — defines the legal status transitions for a domain entity (Order: new → accept → active → complete), the conditions that must hold to transition, and the persistence + events that run when one fires.
  • Abilities — encapsulate "can this action happen?" for a single model instance, returning a bool. Abilities often delegate to the state machine, then layer extra business logic on top.
Both are custom, not packages. The source lives under src/Domain/Core/Actions/StateMachine/ and src/Domain/Core/Traits/. Do not install spatie/laravel-model-states or any external state package.

When to use which

QuestionPattern
"Is this transition between statuses legal right now?"State machine
"Persist a status change + run side effects + record history"State machine (executeTransition)
"The button is always visible — when clicked, tell the user why it can't run"State machine (checkTransition()summary() / reasons)
"Hide the button entirely when the action isn't available"Ability
"Combine a state-machine check with extra rules"Ability wrapping the state machine
"Cross-cutting RBAC (gates for any user/model pair)"Laravel Policy in app/Policies/

The rule of thumb: a state machine gives you the workflow and the reason it says no — reach for it whenever the caller needs to show the user a message. An ability is a single bool for show/hide, with no reason attached. A controller typically asks the ability for show/hide, and asks the state machine when the user actually clicks.

Part 1 — State machine

The pieces

PiecePurpose
AbstractStateMachineBase class; you extend it per domain.
TransitionDefinitionFluent builder: from(), to(), condition(), permission(), soft().
BaseConditionBase for guard classes. Helpers: allow(), deny(), allowIf().
HasStateMachine traitAdd to a model to expose stateMachine(), executeTransition(), etc.
StatusEnumInterface / TransitionEnumInterfaceStatus and transition enums implement these.
TransitionResultDataResult DTO with rich failure introspection.

All of it lives under src/Domain/Core/. The most complete reference implementation is the Order domain (src/Domain/Order/Actions/StateMachine/OrderStateMachine.php) — clone its shape.

How to add one

1. Status enumsrc/Domain/{Domain}/Enums/{Entity}Status.php, backed int (preferred, matches the DB column), implementing StatusEnumInterface:

enum OrderStatus: int implements StatusEnumInterface
{
    use BaseEnum;

    case NEW_STATUS = 1;
    case ACCEPT_STATUS = 5;
    case COMPLETE = 10;
    case CANCELED_STATUS = 11;

    public function isTerminal(): bool { return in_array($this, [self::COMPLETE, self::CANCELED_STATUS]); }
    public function isInitial(): bool  { return $this === self::NEW_STATUS; }
}

2. Transition enum{Entity}Transition.php, backed string (these are verbs: accept, cancel, expire), implementing TransitionEnumInterface.

3. Conditions (guards) — one file per rule under Actions/StateMachine/Conditions/, each extending BaseCondition. Keep one rule per class so failure introspection stays useful:

class HasProviderCondition extends BaseCondition
{
    public function check(Model $model, TransitionEnumInterface $transition, array $context = []): ConditionResultData
    {
        return $this->allowIf($model->carrier_id !== null, trans('api.order_has_no_provider'));
    }
}

4. The state-machine classActions/StateMachine/{Entity}StateMachine.php, extending AbstractStateMachine and registering transitions:

protected function registerTransitions(): void
{
    $this->addTransition(
        (new TransitionDefinition(OrderTransition::ACCEPT))
            ->from([OrderStatus::NEW_STATUS, OrderStatus::WAITING_STATUS])
            ->to(OrderStatus::ACCEPT_STATUS)
            ->condition(HasProviderCondition::class)
    );

    $this->addTransition(
        (new TransitionDefinition(OrderTransition::CANCEL_COMPLETED))
            ->from(OrderStatus::COMPLETE)
            ->to(OrderStatus::CANCELED_STATUS)
            ->condition(HasNoInvoicesCondition::class)
            ->permission('orders.cancel_completed')
    );
}

It also implements getStatusAttribute() (the column, usually 'status'), getStatusEnumClass(), and recordHistory().

5. Wire the model — add the HasStateMachine trait and point at the class:

class Order extends Model
{
    use HasStateMachine;

    protected function getStateMachineClass(): string { return OrderStateMachine::class; }
}

Using it

$order->canTransition(OrderTransition::CANCEL);       // bool
$order->checkTransition(OrderTransition::CANCEL);     // TransitionResultData (rich)
$order->availableTransitions();                       // Collection<TransitionEnumInterface>
$order->whyCannotTransition(OrderTransition::CANCEL); // string[] of reasons (empty if allowed)

// Execute — persists status + history + events, wrapped in DB::transaction.
$order->executeTransition(OrderTransition::CANCEL, ['user' => $currentUser]);
// → TransitionResultData; throws TransitionException if not allowed.

Soft transitions

Mark a transition ->soft() when the workflow rule applies (same from, condition, permission) but the operation is not a status change — issuing an invoice, generating a document, dispatching a notification while the entity stays in its current status. The state machine still enforces every guard and fires events, but it does not write to the model or call recordHistory(). Omit ->to(...).

(new TransitionDefinition(OrderTransition::FINAL_INVOICE_ISSUE))
    ->from([OrderStatus::ACCEPT_STATUS, OrderStatus::ACTIVE_NOW])
    ->condition(HasNoInvoicesCondition::class)
    ->soft();   // no ->to(); the model's status is untouched

Failure introspection

The rich result type is the payoff — branch behaviour on why a transition failed:

$result = $order->checkTransition(OrderTransition::CANCEL, ['user' => $user]);

$result->allowed;          // would it fire?
$result->summary();        // human-readable one-liner
$result->reasons;          // string[] of localized messages

$result->dueToStatus();                              // failed on wrong from-status
$result->dueToConditions(HasNoInvoicesCondition::class);     // a specific condition failed
$result->dueToOnlyConditions(HasNoInvoicesCondition::class); // ONLY that condition is failing
$result->dueToPermissions('orders.cancel_completed');        // missing a permission

dueToOnlyConditions(...) is why conditions stay one-rule-per-class — it lets a caller say "allow this even though invoices block it, because the user can void the invoice from here."

Fire transitions from an Action, never a controller

final class CancelOrder
{
    public static function handle(CancelOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = $data->order;
            $order->update(['canceled_at' => now()]);

            $check = $order->checkTransition(OrderTransition::CANCEL, ['user' => $data->user]);
            throw_if(! $check->allowed, OrderException::canNotCancelOrder());

            $order->executeTransition(OrderTransition::CANCEL, ['user' => $data->user]);

            return $order;
        });
    }
}

executeTransition() wraps the status write + history in its own transaction; the outer transaction in the Action covers everything else the Action does. See Actions.

Part 2 — Abilities

An ability class lives at Models/Abilities/{Entity}Abilities.php (plural for entities with many actions; {Entity}Ability.php singular for simple ones). It takes the model as a public readonly constructor property and exposes one bool method per question — named for the action, no can prefix:

readonly class MoneyTransferAbility
{
    public function __construct(
        public MoneyTransfer $moneyTransfer,
    ) {}

    public function update(): bool
    {
        return $this->moneyTransfer->status === MoneyTransferStatus::PENDING;
    }
}

Wire it with the ModelAbility trait and $abilityClass (see Models → Abilities), then call:

$order->ability('end');                       // bool
$order->ability('issueInvoice', $user, $id);  // extra args forwarded to the method
$order->getAbility();                         // the abilities instance, for several flags at once

The canonical hybrid: ability wraps state machine

Let the state machine answer the workflow question, then layer business logic on top:

public function cancel(): bool
{
    $result = $this->order->stateMachine()->canTransition(OrderTransition::CANCEL);

    // Even if invoices block cancellation, still show the button — a cashier with
    // the right permission can void the invoice from the cancel flow.
    if ($result->dueToOnlyConditions(HasNoInvoicesCondition::class)) {
        return true;
    }

    return $result->allowed;
}

Ability vs. Policy

ConcernWhere it lives
"Can this specific record be updated right now?" (depends on its status)Ability ({Entity}Abilities)
"Can a user with role X delete these records in general?"Policy (app/Policies/, called from a Form Request authorize())
Wrapping a state-machine transition into a UI-friendly boolAbility
Cross-cutting RBAC across many modelsPolicy

They coexist: the Form Request checks the Policy (HTTP-level RBAC), and the controller/view asks the Ability for instance-level "is this available now?" to drive the UI. See Security.

Index pages — per-row enable/disable with a reason

A common CP pattern: a table where each row's checkbox is enabled when the transition is legal and disabled with a hover tooltip explaining why when it isn't. This is the "always show, explain on hover" case — use the state machine, because you need the reason, not just a bool.

The N+1 trap.checkTransition() runs every condition class, and conditions read whatever they want off the model — relations, computed attributes, counts. In a per-row loop, anything not eager-loaded becomes one query per row, per condition (50 rows × an invoice relation = 50 extra queries). Before looping, eager-load every relation any condition for that transition touches — treat the condition classes as part of the query plan.
$orders = $this->orderContract->filterOrders(FilterOrderData::fromArray($request->all()))
    ->with(['user_invoice', 'provider_invoice', 'user_finance'])  // eager-load for the conditions
    ->paginate();

$rows = $orders->getCollection()->map(function (Order $order) use ($request) {
    $check = $order->stateMachine()->canTransition(OrderTransition::CANCEL_COMPLETED, ['user' => $request->user()]);

    return [
        'order'                  => $order,
        'can_cancel_completed'   => $check->allowed,
        'cancel_disabled_reason' => $check->allowed ? null : $check->summary(),
    ];
});

Resolve the transition once per row in PHP — never call checkTransition() from Blade. And the disabled-on-render check is UX only: re-run checkTransition() server-side for every selected row on submit. Verify with Telescope/Debugbar that query count is constant + 1 per page, not constant + N. See Eloquent and queries.

Anti-patterns

  • Free-form status writes. $order->status = X; $order->save() inside a service skips guards, history, and events. If a status has more than a couple of legal transitions, it gets a state machine.
  • Duplicating transition rules in a service. An if ($order->status === ...) re-check belongs in a Condition on the transition.
  • Persisting status inside an ability. Abilities are read-only — they answer questions; status changes go through executeTransition.
  • One mega-condition that checks five things. Split it so dueToOnlyConditions(...) stays meaningful.
  • Calling executeTransition from a controller. Wrap it in an Action; the controller calls the Contract → Service → Action.
  • Ability methods that throw instead of returning false. Throw only when there's no recoverable UI path.

Verification checklist

  • Status enum implements StatusEnumInterface (label(), isTerminal(), isInitial()); transition enum implements TransitionEnumInterface.
  • State-machine class implements getStatusAttribute(), getStatusEnumClass(), registerTransitions(), recordHistory().
  • Every TransitionDefinition has at least from(...); if it changes status it has to(...), otherwise it's ->soft().
  • Each guard is its own BaseCondition subclass — no inline closures, no multi-rule conditions.
  • grep for raw ->status = writes on the entity returns nothing outside the state machine.
  • Ability classes live under Models/Abilities/, take the model as public readonly, and return bool.
  • Transitions fire from Actions; UI/response builders call ->ability('...'), not the state machine directly.
  • Models — where ability classes, status enums, and the entity wiring live.
  • Actions — the Action layer that fires transitions inside DB::transaction.
  • Eloquent and queries — eager-loading, the fix for the index-page N+1 trap.
  • Anti-patterns — the review vocabulary these rules feed into.