Skip to content

Behavior Integration ​

The plugin provides two ways to interact with workflows:

  1. Workflow Object (recommended) - clean, entity-centric API via WorkflowRegistry
  2. ORM Behavior - table-level methods mixed into your Table class

Workflow Object API ​

The Workflow class provides a Symfony-style API for working with entities:

php
// In your controller, inject or fetch the registry
use Workflow\Service\WorkflowRegistryLocator;

$registry = WorkflowRegistryLocator::get()
    ?? throw new \RuntimeException('Workflow registry is not configured');

// Get a workflow for an entity (auto-detects workflow from table)
$workflow = $registry->get($order);

// Or specify the workflow name explicitly
$workflow = $registry->get($order, 'order');

// Check if transition is allowed
if ($workflow->can('pay')) {
    $result = $workflow->apply('pay', ['user_id' => $userId]);
    // Inspect the result; this API changes only the in-memory entity.
}

// Query state
$workflow->getStateName();           // 'pending'
$workflow->getState();               // State object
$workflow->isInState('pending');     // true
$workflow->isInFinalState();         // false
$workflow->hasFlag('done');          // false

// Get transitions
$workflow->getEnabledTransitions();  // ['pay', 'cancel'] - passes guards
$workflow->getAvailableTransitions(); // ['pay', 'cancel'] - ignores guards

// Access definition and entity
$workflow->getDefinition();          // Definition object
$workflow->getEntity();              // The entity
$workflow->getName();                // 'order'

Transaction Safety

The workflow object's apply() only changes the entity in memory. A table with WorkflowBehavior rejects saving a direct state change by default. For that table, use the behavior's applyTransition() followed by a save, or its transition() method for coordinated persistence. See Persisted Transitions.

ORM Behavior API ​

The Workflow.Workflow behavior adds methods to your Table class.

Configuration ​

php
$this->addBehavior('Workflow.Workflow', [
    'workflow' => 'order',
    'validateOnSave' => true,
    'autoSave' => false,
    'autoLog' => false,
    'logAllOutcomes' => true,
    'model' => 'Orders',
]);

Configuration Options ​

OptionTypeDefaultDescription
workflowstringnullWorkflow name (auto-detected from definition if not set)
validateOnSavebooltruePrevent direct state changes on save
autoSaveboolfalseAuto-save entity after transition
autoLogboolfalseLog transitions to workflow_transitions table
logAllOutcomesbooltrueLog blocked/locked/error transitions for audit
modelstringnullPolymorphic model value stored in the model column (defaults to the table's registry alias)
stateTimestampFieldstring|nullstate_changed_atColumn stamped with the current time on each state change (auto-applied only if the column exists; null disables)
useLockingbool|nullnullPessimistic locking via the lock table (null = auto-detect from the lock table)
useOptimisticLockboolfalseLock-free concurrency via compare-and-set on the state field (takes precedence over useLocking)

Time in State ​

If the table has a state_changed_at column, the behavior stamps it with the current time on every state change — no configuration needed. This makes aging/SLA queries trivial:

php
// Orders sitting in their current state for more than 3 days
$stale = $this->Orders->find()
    ->where(['state_changed_at <' => new \Cake\I18n\DateTime('-3 days')])
    ->all();

Add the column with a migration:

php
$this->table('orders')->addColumn('state_changed_at', 'datetime', [
    'null' => true,
    'default' => null,
])->addIndex(['state_changed_at'])->update();

Use a different column name via 'stateTimestampField' => 'entered_state_at', or set it to null to disable. The stamp is only written when the state actually changes.

Concurrency: Pessimistic vs Optimistic ​

Two ways to keep concurrent transitions from racing on the same record:

  • Pessimistic (useLocking) — acquires a row in the workflow_locks table for the duration of the transition. A competing caller is told the record is locked.
  • Optimistic (useOptimisticLock) — no lock table. The state change is persisted with a compare-and-set: UPDATE ... SET state = :to WHERE id = :id AND state = :from. If another writer already advanced the row, the UPDATE matches nothing and the caller gets a locked (conflict) result instead of double-applying.
php
$this->addBehavior('Workflow.Workflow', [
    'workflow' => 'order',
    'useOptimisticLock' => true,
]);
php
$result = $this->Orders->getBehavior('Workflow')->transition($order, 'pay');
if ($result->isLocked()) {
    // Lost the race - reload and retry, or report a conflict.
}

Optimistic locking needs no extra table or version column (it compares on the state field) and suits high-contention or multi-server setups; it takes precedence over useLocking when both are set. Wrap retries around the conflict result as needed.

The claim is made before the transition's commands run, so a lost race executes no side effects, and the claim is rolled back if the transition then fails — keep useTransaction enabled (the default) so that rollback applies.

Constraints:

  • Requires a single-column primary key (throws otherwise).
  • Detects conflicts only when the transition changes the state value — a self-loop (same from and to) writes nothing to compare against.

Audit Logging ​

When autoLog is enabled, all transition attempts are logged to the workflow_transitions table. The logAllOutcomes option (default: true) controls whether failed attempts are logged:

  • success: Transition completed successfully
  • blocked: Guards prevented the transition
  • locked: Another process held the lock
  • error: Command threw an exception

This provides a complete audit trail for compliance and debugging:

php
// Get full transition history
$transitions = $this->Orders->getBehavior('Workflow')
    ->getTransitionHistory($order);

// Filter by status
$failedTransitions = $this->fetchTable('Workflow.WorkflowTransitions')
    ->find('failed')
    ->where(['foreign_key' => $order->id])
    ->toArray();

// Check blocked reasons
foreach ($transitions as $t) {
    if ($t->isBlocked()) {
        $reasons = $t->getBlockedBy();
        // ['checkBalance' => 'Insufficient funds']
    }
}

Each transition record includes:

  • status: success, blocked, locked, or error
  • from_state, to_state: State before and after
  • user_id, reason: Context from the transition call
  • context: JSON with runtime metadata, blocked reasons, or error details

Core Methods ​

Access behavior methods via getBehavior():

php
$behavior = $this->Orders->getBehavior('Workflow');

Check if a transition is possible ​

php
$behavior->canTransition($order, 'pay');

Apply a transition ​

php
$result = $behavior->applyTransition($order, 'pay', [
    'user_id' => '42',
    'reason' => 'Payment captured',
]);

Run a persisted/orchestrated transition ​

php
$result = $behavior->transition($order, 'pay', [
    'user_id' => '42',
    'reason' => 'Payment captured',
]);

For the full persisted API, per-call options, and default orchestration config, see Persisted Transitions.

For automatic timeout scheduling/cancellation during persisted transitions, see Timeout Orchestration.

Get available transitions ​

php
$transitions = $behavior->getAvailableTransitions($order);

Read the current state ​

php
$state = $behavior->getCurrentState($order);

Querying Entities by State ​

The behavior provides custom finders for querying entities based on workflow state properties.

Find by flag ​

Query entities in states that have (or don't have) a specific flag:

php
// Find all orders in states with the 'done' flag
$doneOrders = $this->Orders->find('withFlag', flag: 'done')->toArray();

// Find all orders NOT in states with the 'done' flag
$activeOrders = $this->Orders->find('withoutFlag', flag: 'done')->toArray();

Find by final state ​

Query entities based on whether they're in a final state:

php
// Find all completed orders (in any final state)
$completedOrders = $this->Orders->find('inFinalState')->toArray();

// Find all active orders (not in a final state)
$activeOrders = $this->Orders->find('notInFinalState')->toArray();

Find by specific state ​

Query entities in a specific state:

php
// Find all pending orders
$pendingOrders = $this->Orders->find('inState', state: 'pending')->toArray();

Helper methods ​

Get state names programmatically for custom queries:

php
$behavior = $this->Orders->getBehavior('Workflow');

// Get all state names with a specific flag
$doneStates = $behavior->getStateNamesWithFlag('done');
// Returns: ['completed', 'delivered']

// Get all state names without a specific flag
$notDoneStates = $behavior->getStateNamesWithoutFlag('done');
// Returns: ['pending', 'processing', 'shipped']

// Get all final state names
$finalStates = $behavior->getFinalStateNames();
// Returns: ['completed', 'cancelled']

Save Protection ​

When validateOnSave is enabled, direct state mutation is rejected.

This prevents code from bypassing the workflow engine by changing the state field manually and calling save().

For new entities:

  • empty state is allowed
  • the initial state is allowed
  • any other state is rejected

For existing entities:

  • state changes must go through applyTransition()

Convenience Traits ​

Two optional traits give you statically-typed, discoverable access without reaching through getBehavior('Workflow').

Add WorkflowTableTrait to a table that has the behavior:

php
use Cake\ORM\Table;
use Workflow\Model\Table\WorkflowTableTrait;

class OrdersTable extends Table
{
    use WorkflowTableTrait;

    public function initialize(array $config): void
    {
        parent::initialize($config);
        $this->addBehavior('Workflow.Workflow', ['workflow' => 'order']);
    }
}
php
$this->Orders->transition($order, 'pay', ['user_id' => $userId]); // save + log (+ lock when enabled)
$this->Orders->canTransition($order, 'pay');
$this->Orders->availableTransitions($order);
$this->Orders->currentState($order);

Add WorkflowTrait to the entity for read-only inspection helpers (mutating transitions stay on the table):

php
use Cake\ORM\Entity;
use Workflow\Model\Entity\WorkflowTrait;

class Order extends Entity
{
    use WorkflowTrait;
}
php
$order->currentState();              // 'pending'
$order->canTransition('pay');        // true
$order->availableTransitions();      // ['pay']
$order->isInState('pending');        // true
$order->isFinalState();              // false
$order->hasStateFlag('done');        // false

Next Steps ​

Released under the MIT License.