Skip to content

arqel-dev/workflow — API Reference ​

Namespace Arqel\Workflow\. State machines for Eloquent models: the states a record can occupy, their UI metadata (label / color / icon), the legal transitions between them, central authorization, audit events and an append-only history table.

The canonical integration is with spatie/laravel-model-states, but the package is entirely duck-typed — PHP enums, plain string slugs or custom tokens work too. spatie/laravel-model-states sits in suggest:, never in require.

Arqel\Workflow\WorkflowDefinition (final) ​

Fluent builder describing one workflow. Factory WorkflowDefinition::make(string $field): self — throws when $field is empty.

MethodTypeDescription
states(array $states)selfarray<string, {label?, color?, icon?}>, typically keyed by class-string<State> (or an enum value / arbitrary slug). Replaces rather than merges
transitions(array $transitions)selflist<class-string> of transition classes
getField()stringThe Eloquent column persisting the state
getStates()array
getTransitions()array
getStateMetadata(string $stateClass)?array{label, color, icon} for one state
toArray()array{field, states, transitions} — ready for Inertia props

Missing labels are derived from the last PascalCase segment of the FQCN (OrderState\PendingPayment → Pending Payment); a missing color/icon falls back to 'secondary' / 'circle'.

Arqel\Workflow\Concerns\HasWorkflow (trait) ​

Consumed by user-land Eloquent models, which must declare arqelWorkflow(): WorkflowDefinition.

MethodTypeDescription
getCurrentStateMetadata()?array{label, color, icon} for the record's current state
getAvailableTransitions()arrayTransition class-strings whose from() includes the current state, plus every transition with no from() (the "any-to-X" pattern)
transitionTo(string $newState, array $context = [])voidPerforms the transition and dispatches StateTransitioned
stateTransitions()MorphManyHistory rows, ordered created_at desc, id desc

State-key resolution is polymorphic: an object resolves to ::class, a BackedEnum to its value, a non-empty string to itself, anything else to null.

transitionTo() delegates to the spatie API when the current state object exposes its own transitionTo() (leaving spatie's guards intact); otherwise it takes the fallback path — plain attribute assignment plus save(). On the fallback path the enforcement runs before any mutation or persistence: when the model declares transitions, $newState must be reachable (some declared transition whose from() includes the current state — or which has no from() — and whose destination matches $newState), otherwise Exceptions\IllegalTransitionException is thrown; among the matching transitions at least one must be authorized by TransitionAuthorizer, otherwise Exceptions\UnauthorizedTransitionException is thrown. Models declaring transitions([]) stay free-form — no eligibility or authorization is imposed.

The event is suppressed when arqel-workflow.audit.enabled === false or audit.log_via !== 'event' — useful for migrations and seeders.

Arqel\Workflow\Concerns\RecordsStateTransition (trait) ​

Optional helper letting a spatie Transition class dispatch the canonical StateTransitioned event from its own handle(). It assumes no spatie API of its own.

Arqel\Workflow\Events\StateTransitioned (final) ​

Dispatched after a successful transition. Dispatchable + SerializesModels; deliberately not ShouldBroadcast — broadcasting is opt-in via a dedicated listener.

php
public function __construct(
    public readonly Model $record,
    public readonly string $from,
    public readonly string $to,
    public readonly ?int $userId = null,
    public readonly array $context = [],
) {}

Arqel\Workflow\Authorization\TransitionAuthorizer (final readonly) ​

MethodTypeDescription
authorize(string $transitionClass, ?Authenticatable $user, mixed $record)static boolAccepts any string — a class that doesn't exist returns false
authorizeStates(string $from, string $to, ?Authenticatable $user, mixed $record)static boolDecision keyed by the state pair rather than the transition class
slugifyState(string $stateClassOrKey)static stringkebab-case of the final FQCN segment, dropping a State suffix ('PendingPayment' → 'pending-payment', 'PaidState' → 'paid', ''/'*' → '*')

Three layers, in order: (1) authorizeFor(?Authenticatable, mixed): bool declared on the transition (static or instance — preferred); (2) a registered Gate named transition-{fromSlug}-to-{toSlug}; (3) deny by default. The opt-out flag is arqel-workflow.authorization.deny_when_undefined => false. Exceptions thrown inside authorizeFor degrade to false (fail closed).

Arqel\Workflow\Support\TransitionTargetResolver (final) ​

TransitionTargetResolver::resolve(string $transition): string returns the destination token for a transition class: the declared static to() when present, otherwise the XxxToYyy convention (PendingToPaid → Paid), otherwise the class short name. The same resolver feeds the write path, the authorizer and the field's serialized payload, so the token a UI button announces round-trips through the server-side checks.

Arqel\Workflow\Models\StateTransition (final) ​

Append-only history row on arqel_state_transitions. $timestamps = false; metadata cast to array, created_at to datetime.

MethodTypeDescription
model()MorphToThe record that transitioned
user()?BelongsToDefensive — reads arqel-workflow.user_model (default App\Models\User) and returns null when the class is unavailable

The migration 2026_05_01_000000_create_arqel_state_transitions_table creates morphs('model'), nullable from_state, to_state, indexed transitioned_by_user_id, JSON metadata and created_at with useCurrent() (no updated_at). model_type is written via getMorphClass(), so under Relation::enforceMorphMap() the row stores the alias.

Listeners\PersistStateTransitionToHistory is auto-registered by the service provider, skips silently when arqel-workflow.history.enabled === false, and catches Throwable so an audit failure never blocks the domain transition.

Arqel\Workflow\Fields\StateTransitionField (final, extends Arqel\Fields\Field) ​

React-bound field rendering the current state plus its transition buttons. Factory StateTransitionField::make(string $name): static.

MethodTypeDescription
showDescription(bool $show = true)static
showHistory(bool $show = true)static
transitionsAttribute(string $name = 'state')static
record(?Model $record)staticBinds the record whose state is rendered
getRecord() / getTransitionsAttribute() / isShowingDescription() / isShowingHistory()getters
getTypeSpecificProps()arraySerialized payload merged into the field schema
resolveCurrentState()?array{name, label, color, icon}
resolveAvailableTransitions()arraylist<{from, to, label, authorized}> — authorized delegates to TransitionAuthorizer
resolveHistory()arrayReal rows filtered by (model_type, model_id), model_type keyed by getMorphClass(); limit from arqel-workflow.history.limit (default 50), best-effort with Throwable caught

Exactly one entry is emitted per applicable transition, mirroring HasWorkflow::getAvailableTransitions(): a transition whose from() lists several states produces a single button carrying the record's current state as from. Transitions whose from() excludes the current state are filtered out; transitions with no from() always appear.

Filters ​

Arqel\Workflow\Filters\StateFilter (final readonly) ​

Standalone filter deriving its options from the model's WorkflowDefinition. Constructor (string $field, string $modelClass) validates that the field is non-empty, the class exists, and the class uses HasWorkflow — throwing InvalidArgumentException otherwise. Factory StateFilter::make(string $field, string $modelClass): self.

MethodTypeDescription
toArray()array{field, type: 'state', label, options}
optionsArray()arrayarray<string, {value, label, color, icon}>
apply(Builder $query, mixed $value)voidString → where; array → whereIn (non-string / empty values dropped); null or empty → no-op

Arqel\Workflow\Filters\StateFilterFactory (final) ​

StateFilterFactory::forResource(string $modelClass, ?string $field = null): StateFilter — resolves the field from arqelWorkflow()->getField() when omitted.

There is no hard dependency on arqel-dev/table; user-land plugs StateFilter into Table::filters([...]).

Exceptions ​

ClassFactory
Exceptions\IllegalTransitionException (final, extends RuntimeException)IllegalTransitionException::for(string $from, string $to): self
Exceptions\UnauthorizedTransitionException (final, extends RuntimeException)UnauthorizedTransitionException::for(string $to): self

Realtime integration ​

arqel-dev/workflow stays standalone — it never imports anything from arqel-dev/realtime. When arqel-dev/realtime is installed, its service provider registers Arqel\Realtime\Workflow\BroadcastStateTransitionListener for Events\StateTransitioned, turning each transition into an Arqel\Realtime\Events\ResourceUpdated broadcast on the record's arqel.{slug} and arqel.{slug}.{id} channels. Opt out globally with ARQEL_REALTIME_WORKFLOW_BROADCAST=false or config()->set('arqel-realtime.workflow.broadcast_state_transitions', false).

HTTP ​

The package registers no routes — there is no transition controller yet. Apps provide their own route that validates the input and calls HasWorkflow::transitionTo(). A canonical POST /admin/{resource}/{record}/transition/{transition} endpoint is future work.

Example ​

php
use Arqel\Workflow\Concerns\HasWorkflow;
use Arqel\Workflow\WorkflowDefinition;

final class Order extends Model
{
    use HasWorkflow;

    public function arqelWorkflow(): WorkflowDefinition
    {
        return WorkflowDefinition::make('order_state')
            ->states([
                OrderState\Pending::class   => ['label' => 'Pending',   'color' => 'warning',     'icon' => 'clock'],
                OrderState\Paid::class      => ['label' => 'Paid',      'color' => 'info',        'icon' => 'credit-card'],
                OrderState\Shipped::class   => ['label' => 'Shipped',   'color' => 'primary',     'icon' => 'truck'],
                OrderState\Cancelled::class => ['label' => 'Cancelled', 'color' => 'destructive', 'icon' => 'x-circle'],
            ])
            ->transitions([
                Transitions\PendingToPaid::class,
                Transitions\PaidToShipped::class,
                Transitions\AnyToCancelled::class,
            ]);
    }
}
php
final class PaidToShipped
{
    /** @return list<class-string> */
    public static function from(): array
    {
        return [OrderState\Paid::class];
    }

    public static function authorizeFor(?Authenticatable $user, mixed $record): bool
    {
        return $user !== null && $user->can('ship-orders', $record);
    }
}
php
use Arqel\Workflow\Filters\StateFilter;

Table::make()->filters([
    StateFilter::make('order_state', Order::class),
]);

MIT License — built with Inertia + React + Laravel.