A powerful state workflow engine for PHP that handles complex state transitions with built-in observability and race condition prevention.
Most state machines force you into rigid patterns. StateFlow is different:
- 🎯 Delta-Based Transitions - Specify only what changes, not the entire state
- ⚙️ Granular Execution Control - Manage workflow execution at the per-action level
- 🔒 Race-Safe by Design - Built-in mutex locking prevents concurrent modification
- 👀 Fully Observable - Events fired at every step for monitoring and debugging
- 🎨 Flexible Validation - Two-tier gates (transition-level + action-level)
- 📦 Serializable Context - Pause, store, and resume workflows hours or days later
- ⏳ Async Action Yielding - Suspend a single action for external async work (webhooks, third-party APIs, external events) and resume with the response; remaining actions in the same transition continue automatically
- 🔧 User-Controlled - You define state structure, merge strategy, and lock behavior
- E-commerce order processing with payment/inventory/shipping workflows
- Content publishing pipelines with approval stages and notifications
- Long-running batch jobs that need checkpointing
- Multi-step user onboarding flows
- Complex workflow systems
- Any scenario where state transitions need audit trails and concurrency control
useCoverGenius\StateFlow\StateFlow;
useCoverGenius\StateFlow\Configuration;
// Define your stateclass Order implements State {
publicfunction__construct(
privatestring$status,
private ?string$paymentId = null,
) {}
publicfunctionwith(array$changes): State {
returnnewself(
status: $changes['status'] ?? $this->status,
paymentId: $changes['paymentId'] ?? $this->paymentId,
);
}
publicfunctiontoArray(): array {
return ['status' => $this->status, 'paymentId' => $this->paymentId];
}
}
// Configure the workflow$stateFlow = newStateFlow(
configProvider: fn(State$state, Delta$delta) => newConfiguration( // your configuration can be dynamic based on the current state & incoming delta
transitionGates: [newCanProcessGate()], // Must pass to proceed
actions: [
newChargePaymentAction(), // Execute in ordernewReserveInventoryAction(), // Skip if guard failsnewSendConfirmationAction(),
],
),
eventDispatcher: newLogger(), // See everything that happens
lockProvider: newRedisLock($redis), // Prevent race conditions, leave null for no locking.
);
// Execute transition with automatic locking$order = newOrder('pending');
$worker = $stateFlow->transition($order, newArrayDelta(['status' => 'processing']));
$context = $worker->execute();
if ($context->isCompleted()) {
echo"Order processed!";
} elseif ($context->isPaused()) {
// Action paused (e.g., waiting for external API)// Lock is HELD across pausesaveToDatabase($context->serialize());
// Resume hours later...$resumedWorker = $stateFlow->fromContext($context);
$resumedWorker->execute();
}Specify only what changes:
// Just this$worker = $stateFlow->transition($state, newArrayDelta(['status' => 'published']));
$context = $worker->execute();
// Not this$worker = $stateFlow->transition($state, newArrayDelta(['status' => 'published', 'author' => 'same', 'created' => 'same', ...]));
$context = $worker->execute();The StateWorker gives you full control over the workflow execution:
$worker = $stateFlow->transition($state, newArrayDelta(['status' => 'published']));
// 1. Run gates first$gateResult = $worker->runGates();
// 2. Then run actions if gates passif (!$gateResult->shouldStopTransition()) {
$context = $worker->runActions();
}
// Or let actions pause themselves for async operationsclass ProcessVideoAction implements Action {
publicfunctionexecute(ActionContext$context): ActionResult {
$job = dispatch(newVideoProcessingJob());
// Pause execution, lock is heldreturn ActionResult::pause(metadata: ['jobId' => $job->id]);
}
}
// Resume later when ready$resumedWorker = $stateFlow->fromContext($pausedContext);
$resumedWorker->execute();Suspend a single action mid-transition to wait for an external response — remaining actions continue in the same transition once resumed:
class RunFraudCheckAction implements Action, Yieldable {
publicfunctionexecute(ActionContext$context): ActionResult {
if ($context->hasYieldResponse()) {
// Second call: webhook delivered the async result$response = $context->yieldResponse();
return$response['outcome'] === 'approved'
? ActionResult::continue()
: ActionResult::stop(['reason' => 'fraud_check_rejected']);
}
// First call: dispatch external work and suspend$this->client->startCheck($context->currentState);
return ActionResult::yield(['dispatchedAt' => time()]);
}
}
// Webhook handler resumes the transition with the async response$worker = $stateFlow->fromContext($persistedContext);
$worker->resumeWithResponse(['outcome' => 'approved', 'checkId' => $id]);
// Remaining actions in the transition run immediately afterBuilt-in mutex locking, configured on the StateFlow:
$lockProvider = newRedisLockProvider($redis, $config);
$stateFlow = newStateFlow(
configProvider: $configProvider,
lockProvider: $lockProvider,
);
// This transition will be automatically locked$worker = $stateFlow->transition($state, newArrayDelta(['status' => 'published']));
$context = $worker->execute();If another process tries to transition the same entity, it will wait, fail, or skip based on your lock provider's behavior.
Every step emits events:
class MyEventDispatcher implements EventDispatcher {
publicfunctiondispatch(Event$event): void {
match (true) {
$eventinstanceof TransitionStarting => $this->log('Starting...'),
$eventinstanceof GateEvaluated => $this->log('Gate: ' . $event->result),
$eventinstanceof ActionExecuted => $this->log('Action done'),
$eventinstanceof TransitionCompleted => $this->log('Complete!'),
};
}
}Transition Gates - Must pass for transition to begin:
class CanPublishGate implements Gate {
publicfunctionevaluate(GateContext$context): GateResult {
return$context->currentState->hasContent()
? GateResult::ALLOW
: GateResult::DENY;
}
}Action Gates - Skip individual actions if guard fails:
class NotifyAction implements Action, Guardable {
publicfunctiongate(): Gate {
returnnewHasSubscribersGate();
}
publicfunctionexecute(ActionContext$context): ActionResult {
// Only runs if HasSubscribersGate passes
}
}composer require covergenius/stateflowRequirements: PHP 8.2+
📚 Comprehensive documentation available in the docs/ directory:
| Document | Description |
|---|---|
| Architecture Overview | Design goals and principles |
| Flow Diagrams | Visual flowcharts (Mermaid) |
| Core Concepts | State, Gates, Actions, Configuration |
| Observability | Event system and monitoring |
| Locking System | Race condition handling |
| Interface Reference | Complete API documentation |
| Usage Examples | Real-world patterns |
// 1. Define state with your domain modelclass OrderState implements State {
publicfunction__construct(
privatestring$id,
privatestring$status,
privatefloat$total,
private ?string$paymentId = null,
) {}
publicfunctionwith(array$changes): State {
returnnewself(
id: $this->id,
status: $changes['status'] ?? $this->status,
total: $changes['total'] ?? $this->total,
paymentId: $changes['paymentId'] ?? $this->paymentId,
);
}
publicfunctiontoArray(): array { /* ... */ }
}
// 2. Configure workflow based on transition type$configProvider = function(State$state, Delta$delta): Configuration {
returnmatch ($delta->get('status')) {
'processing' => newConfiguration(
transitionGates: [newHasInventoryGate($inventory)],
actions: [
newChargePaymentAction($paymentGateway),
newReserveInventoryAction($inventory),
newSendEmailAction($mailer),
],
),
'shipped' => newConfiguration(
transitionGates: [newHasPaymentGate()],
actions: [newCreateShipmentAction($shipping)],
),
default => newConfiguration(),
};
};
// 3. Create state flow with observability and locking$stateFlow = newStateFlow(
configProvider: $configProvider,
eventDispatcher: newMetricsDispatcher(),
lockProvider: newRedisLockProvider($redis),
lockKeyProvider: newclassimplements LockKeyProvider {
publicfunctiongetLockKey(State$state, Delta$delta): string {
return"order:" . $state->toArray()['id'];
}
},
);
// 4. Execute with race protectiontry {
$order = newOrderState('ORD-123', 'pending', 99.99);
$worker = $stateFlow->transition($order, newArrayDelta(['status' => 'processing']));
$context = $worker->execute();
if ($context->isCompleted()) {
returnresponse()->json(['status' => 'success']);
}
} catch (LockAcquisitionException$e) {
// Another request is processing this orderreturnresponse()->json(['error' => 'Order is being processed'], 409);
}| Feature | StateFlow | Traditional State Machines |
|---|---|---|
| Granular Control | ✅ Per-action execution & pause/resume | ❌ Must complete in one execution |
| Async Yielding | ✅ Single action suspends for external work, resumes with response | ❌ Must split into multiple transitions |
| Race-Safe | ✅ Built-in mutex locking | ❌ Manual coordination required |
| Observable | ✅ Events at every step | ❌ Limited visibility |
| Flexible State | ✅ User-defined merge strategy | ❌ Rigid state structure |
| Lazy Config | ✅ Load gates/actions on-demand | ❌ All configured upfront |
| Lock Persistence | ✅ Lock held across pauses/yields | ❌ N/A |
| Execution Trace | ✅ Complete audit trail | ❌ Limited history |
🚧 Alpha Stage
The architecture is designed and documented. The project is under active development.
Contributions welcome! See Contributing Guide for development setup and guidelines.
The MIT License (MIT). See LICENSE for details.
Built with ❤️ for developers who need powerful, observable, race-safe workflows.