Skip to content

Workflow & State Machine

The built-in state machine lets you define lifecycle workflows for any model. States are shown as colored badges, filterable folders, and a drag-and-drop Kanban board — all automatically.

Enabling the State Machine

Add a StateProperty to your model and override getStates():

<?php

namespace App\Model;

use System\Model\DefaultModel;
use System\ModelMeta;
use System\StringProperty;
use System\TextProperty;
use System\StateProperty;

class Ticket extends DefaultModel
{
    public static function meta(): ?ModelMeta
    {
        return (new ModelMeta('Tickets', 'ticket'))
            ->withSingular('Ticket')
            ->enable();
    }

    protected function getAdditionalProperties(): array
    {
        return [
            (new StringProperty('subject'))->autofocus(),
            new TextProperty('content'),
            new StateProperty(),   // enables the state machine
        ];
    }

    protected function getStates(): ?array
    {
        return [
            'new'          => $this->createState('blue', 'circle', true),        // (1)
            'in_progress'  => $this->createState('orange', 'hourglass'),
            'resolved'     => $this->createState('green', 'check'),
            'closed'       => $this->createState('grey', 'archive'),
        ];
    }

    public function getLabel(): string
    {
        return $this->subject ?? '';
    }
}
  1. The third parameter true marks this as the initial state — automatically assigned when a record is first saved.

State Options

Each state is defined with $this->createState(color, icon, initial):

Parameter Type Description
color string CSS color name or hex value (e.g. 'green', '#5b8c6f')
icon string Phosphor icon name
initial bool If true, auto-assigned on first save (only one state should be initial)

You can also define states as arrays for more options:

protected function getStates(): ?array
{
    return [
        'draft' => [
            'color' => 'steelblue',
            'icon' => 'file-text',
            'initial' => true,
            'description' => 'Work in progress',
            'position' => 10,                    // sort order in UI
        ],
        'review' => [
            'color' => 'orange',
            'icon' => 'eye',
            'comment_required' => true,          // user must add a comment
            'position' => 20,
        ],
        'approved' => [
            'color' => 'green',
            'icon' => 'check-circle',
            'position' => 30,
        ],
    ];
}

Transitions

By default, any state can transition to any other state. Override getTransitions() to restrict allowed paths:

1
2
3
4
5
6
7
8
9
protected function getTransitions(): ?array
{
    return [
        'new'         => ['in_progress'],                    // new → in_progress only
        'in_progress' => ['resolved', 'new'],                // can resolve or send back
        'resolved'    => ['closed', 'in_progress'],          // can close or reopen
        'closed'      => ['in_progress'],                    // can reopen
    ];
}

Tip

If you return null from getTransitions(), all transitions are allowed. This is the default.

Transition Diagram

The example above creates this flow:

stateDiagram-v2
    [*] --> new
    new --> in_progress
    in_progress --> resolved
    in_progress --> new
    resolved --> closed
    resolved --> in_progress
    closed --> in_progress

What You Get Automatically

Once states are defined, the framework provides:

State Badges

Colored badges appear in list views next to each record, showing its current state.

The framework creates folders in the sidebar for filtering records by state — one folder per state, plus "All".

State Change Dialog

A "Change State" button appears in the action bar. Clicking it opens a modal dialog showing available target states as clickable cards. If a state has comment_required, the user must enter a comment before confirming.

Kanban Board

A Kanban view is automatically available with states as columns. Users can drag cards between columns to trigger state transitions — with full rule enforcement.

Audit Trail

Every state change is logged with:

  • Previous state
  • New state
  • Who made the change
  • When it happened
  • Comment (if provided)

Responding to State Changes

Override afterChangeState() to run custom logic when a state changes:

use System\Model\State;

protected function afterChangeState(?State $from, State $to): void
{
    // Send email notification
    if ($to->name === 'approved') {
        $this->sendApprovalNotification();
    }

    // Log to external system
    if ($from?->name === 'draft' && $to->name === 'review') {
        ExternalApi::notifyReviewStarted($this->getId());
    }
}

Complete Example: Document Approval Workflow

<?php

namespace App\Model;

use System\Model\DefaultModel;
use System\Model\State;
use System\ModelMeta;
use System\StringProperty;
use System\TextProperty;
use System\PdfProperty;
use System\StateProperty;
use System\Mail;

class Document extends DefaultModel
{
    public static function meta(): ?ModelMeta
    {
        return (new ModelMeta('Documents', 'file-text'))
            ->withSingular('Document')
            ->enable();
    }

    protected function getAdditionalProperties(): array
    {
        return [
            (new StringProperty('title'))->autofocus(),
            new TextProperty('content'),
            (new PdfProperty('attachments'))->asArray(0, 5),
            new StateProperty(),
        ];
    }

    protected function getStates(): ?array
    {
        return [
            'draft'     => $this->createState('steelblue', 'file-plus', true),
            'submitted' => $this->createState('blue', 'paper-plane-right'),
            'review'    => [
                'color' => 'orange',
                'icon' => 'magnifying-glass',
                'comment_required' => true,      // reviewer must comment
            ],
            'approved'  => $this->createState('green', 'check-circle'),
            'rejected'  => $this->createState('red', 'x-circle'),
            'archived'  => $this->createState('grey', 'archive'),
        ];
    }

    protected function getTransitions(): ?array
    {
        return [
            'draft'     => ['submitted'],
            'submitted' => ['review'],
            'review'    => ['approved', 'rejected', 'draft'],     // reviewer decides
            'approved'  => ['archived'],
            'rejected'  => ['draft'],                              // author revises
            'archived'  => ['draft'],                              // can reopen
        ];
    }

    protected function afterChangeState(?State $from, State $to): void
    {
        if ($to->name === 'approved') {
            Mail::send(
                $this->getCreatorEmail(),
                '',
                "Document Approved: {$this->title}",
                "Your document '{$this->title}' has been approved."
            );
        }

        if ($to->name === 'rejected') {
            Mail::send(
                $this->getCreatorEmail(),
                '',
                "Document Rejected: {$this->title}",
                "Your document '{$this->title}' has been rejected. Please revise."
            );
        }
    }

    public function getLabel(): string
    {
        return $this->title ?? '';
    }
}