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 ?? '';
}
}
|
- 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:
| 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 ?? '';
}
}
|