Skip to content

Attributes ​

Attributes are the recommended way to define workflows. See the Overview for conceptual background on states, transitions, guards, and commands.

Base State Class ​

Every workflow starts with an abstract base class marked with #[StateMachine]:

php
namespace App\Workflow\Order;

use Workflow\Attribute\StateMachine;
use Workflow\State\AbstractState;

#[StateMachine(name: 'order', table: 'Orders', field: 'state')]
abstract class BaseOrderState extends AbstractState
{
}

State Classes ​

Each state is a concrete class extending your base. Use attributes to mark state types and define transitions:

php
namespace App\Workflow\Order;

use Workflow\Attribute\Command;
use Workflow\Attribute\Guard;
use Workflow\Attribute\InitialState;
use Workflow\Attribute\Transition;

#[InitialState]
#[Transition(to: PaidState::class, name: 'pay', happy: true)]
#[Transition(to: CancelledState::class, name: 'cancel')]
class PendingState extends BaseOrderState
{
    #[Guard('pay')]
    public function ensurePayable(): bool|string
    {
        return (float)$this->getEntity()?->get('total') > 0
            ? true
            : 'Order total must be positive';
    }

    #[Command('pay')]
    public function markPaymentCaptured(): void
    {
        $this->getEntity()?->set('payment_captured', true);
    }
}
php
namespace App\Workflow\Order;

use Workflow\Attribute\FinalState;
use Workflow\Attribute\OnEnter;

#[FinalState]
class PaidState extends BaseOrderState
{
    #[OnEnter]
    public function sendReceipt(): void
    {
        // Runs after the entity enters the paid state
    }
}

The cancellation target used above also needs a state class:

php
namespace App\Workflow\Order;

use Workflow\Attribute\FailedState;

#[FailedState]
class CancelledState extends BaseOrderState
{
}

Guards ​

Guard methods validate transitions. Return true to allow, or a message string to block:

php
#[Guard('pay')]
public function ensurePayable(): bool|string
{
    return (float)$this->getEntity()?->get('total') > 0
        ? true
        : 'Order total must be positive';
}

#[Guard('pay')]
public function ensureNotAlreadyPaid(): bool|string
{
    return !$this->getEntity()?->get('payment_captured')
        ? true
        : 'Order was already paid';
}

Multiple guards for the same transition run in sequence. Any blocking message stops the transition.

Guards gate manual transitions. For automatic transitions use #[Condition] instead - see Automatic Transitions.

Commands ​

Command methods run when a transition succeeds:

php
#[Command('pay')]
public function markPaymentCaptured(): void
{
    $this->getEntity()?->set('payment_captured', true);
    $this->getEntity()?->set('paid_at', new \Cake\I18n\DateTime());
}

Lifecycle Callbacks ​

#[OnEnter] and #[OnExit] run when entering or leaving a state:

php
#[OnEnter]
public function notifyCustomer(): void
{
    // Runs when entity enters this state
}

#[OnExit]
public function cleanupResources(): void
{
    // Runs when entity leaves this state
}

Attribute Reference ​

State-level ​

AttributeDescription
#[StateMachine(name, table, field)]Marks the base class with workflow metadata
#[InitialState]Starting state for new entities
#[FinalState]Terminal state (no outgoing transitions)
#[FailedState]Error state (implies final)
#[Label('Display Name')]Human-readable label
#[Color('#00AA00')]Hex color for visualization
#[Flag('done')]Custom metadata tag
#[RequireReason]Require reason when entering

Method-level ​

AttributeDescription
#[Guard('transition')]Gate a manual transition (return bool|string)
#[Condition('transition')]Gate an automatic transition (return bool)
#[Command('transition')]Action on successful transition
#[OnEnter]Callback when entering this state
#[OnExit]Callback when leaving this state
#[Timeout('1 hour', 'expire')]Auto-transition after duration

Timeout durations support ISO-8601 intervals like PT30M and relative strings like 2 hours.

Transition-level ​

php
#[Transition(
    to: TargetState::class,  // Required: target state class
    name: 'approve',         // Required: transition identifier
    happy: true,             // Optional: mark as happy path
)]

Why State Methods? ​

Guards and commands live in the state class that owns them. This provides:

  • Strong locality - logic is close to the state
  • IDE support - refactoring and navigation work naturally
  • Clear ownership - each state manages its own transition logic

Released under the MIT License.