EventMachineState Machines That Compose
Delegation. Timers. Event sourcing. Parallel execution. All declarative, all Laravel.
Delegation. Timers. Event sourcing. Parallel execution. All declarative, all Laravel.
Define complex workflows in plain PHP arrays. States, transitions, guards, actions - all in one declarative configuration.
No more scattered if/else chains. No more inconsistent state checks. Your business logic lives in one place.
··· 1 hidden line
MachineDefinition::define(
config: [
'initial' => 'draft',
'context' => ['items' => [], 'total' => 0],
'states' => [
'draft' => [
'on' => ['SUBMIT' => 'review'],
],
'review' => [
'on' => [
'APPROVE' => 'approved',
'REJECT' => 'draft',
],
],
'approved' => ['type' => 'final'],
],
],
);Calculators compute. Guards validate. Actions execute. Each transition runs through a pipeline: calculate derived values, check conditions, then execute side effects.
Every behavior is a single-responsibility class. Compose them freely to build complex workflows from simple, testable pieces.
'CHECKOUT' => [
'target' => 'processing',
'calculators' => PriceCalculator::class,
'guards' => [[MinimumOrderGuard::class, 'min' => 100]],
'actions' => SendReceiptAction::class,
],··· 2 hidden lines
class PriceCalculator extends CalculatorBehavior
{
public function __invoke(ContextManager $context): void
{
$context->set('total', $context->get('quantity') * $context->get('price'));
}
}··· 2 hidden lines
class MinimumOrderGuard extends GuardBehavior
{
public function __invoke(ContextManager $context, int $min = 0): bool
{
return $context->get('total') >= $min;
}
}··· 2 hidden lines
class SendReceiptAction extends ActionBehavior
{
public function __invoke(ContextManager $context): void
{
Mail::to($context->get('email'))->send(new Receipt($context->get('total')));
}
}Event sourcing built in. Every state change becomes an immutable event in your database. Complete audit trail without extra code.
Know exactly what happened, when, and why. Compliance-ready from day one. Debug production issues by replaying history. Query by type, date range, or payload.
··· 18 hidden lines
// Send an event
$order->send(['type' => 'SUBMIT']);
// Every transition is recorded in machine_events table
// | id | type | payload | created_at |
// |----|---------|-----------------|---------------------|
// | 1 | @init | {} | 2024-01-15 10:30:00 |
// | 2 | SUBMIT | {"userId": 5} | 2024-01-15 10:30:01 |
// | 3 | APPROVE | {"by": "admin"} | 2024-01-15 11:45:00 |
// Restore full state from any point in history
$rootEventId = $order->state->history->first()->root_event_id;
MachineEvent::where('root_event_id', $rootEventId)
->oldest('sequence_number')
->get();Run concurrent workflows — truly in parallel. Multiple independent processes execute simultaneously via Laravel queue workers. Two API calls that take 5s and 2s? Done in 5s, not 7s.
Enable parallel dispatch and your entry actions run as separate queue jobs. Context merges safely under database locks. When all regions complete, @done fires automatically. Zero code changes to your actions or guards.
'processing' => [
'type' => 'parallel',
'@done' => 'fulfilled', // When ALL regions complete
'states' => [
'payment' => [
'initial' => 'pending',
'states' => [
'pending' => ['on' => ['PAID' => 'done']],
'done' => ['type' => 'final'],
],
],
'shipping' => [
'initial' => 'preparing',
'states' => [
'preparing' => ['on' => ['SHIPPED' => 'done']],
'done' => ['type' => 'final'],
],
],
],
],// With parallel dispatch enabled: entry actions run as queue jobs
// Worker A: PaymentGateway::charge() — 5s
// Worker B: ShippingAPI::createLabel() — 2s
// Total: 5s (max), not 7s (sum)
$machine->send(['type' => 'START_PROCESSING']);
// → dispatches 2 ParallelRegionJobs
// → returns immediately
// Each job completes independently, merges context under lock
// Last job detects all regions final → @done → 'fulfilled'// Or use actor-driven parallelism without dispatch:
$machine->send(['type' => 'PAID']); // payment → done
$machine->send(['type' => 'SHIPPED']); // shipping → done
// All final → auto-transitions to 'fulfilled'Break complex workflows into composable machines. A parent state delegates work to a child machine. When the child completes, @done fires. When it fails, @fail fires. Sync or async — your choice.
Type-safe contracts define the boundary: MachineInput validates what goes in, MachineOutput types what comes out, MachineFailure types what went wrong. Fake child machines in tests — no child actually runs.
'processing_payment' => [
'machine' => PaymentMachine::class,
'input' => PaymentInput::class, // Typed & validated
'queue' => 'payments',
'@done' => [
'target' => 'shipping',
'actions' => CapturePaymentAction::class, // Type-hints MachineOutput
],
'@fail' => [
'target' => 'payment_failed',
'actions' => HandleFailureAction::class, // Type-hints MachineFailure
],
'@timeout' => [
'after' => 300,
'target' => 'payment_timed_out',
],
],// Test without running the real child machine
PaymentMachine::fake(output: new PaymentOutput(paymentId: 'pay_123'));
OrderWorkflowMachine::test()
->send('START')
->assertState('shipping')
->assertContext('paymentId', 'pay_123');
PaymentMachine::assertInvoked();
PaymentMachine::assertInvokedWith(['orderId' => 'ORD-1']);From unit tests to full workflows — one expressive API. Test individual behaviors in isolation with runWithState(), or chain entire machine lifecycles with Machine::test() and 21+ assertion methods.
Fake behaviors, assert guards, verify paths, check context — all with contextual failure messages. No database needed.
// Machine-level: fluent lifecycle testing
OrderMachine::test(['amount' => 100])
->withoutPersistence()
->faking([SendEmailAction::class])
->send('SUBMIT')
->assertState('awaiting_payment')
->send('PAY')
->assertState('preparing')
->assertBehaviorRan(SendEmailAction::class)
->send('DELIVER')
->assertState('delivered')
->assertFinished();// Unit-level: isolated behavior testing
$state = State::forTesting(['total' => 50]);
expect(MinimumOrderGuard::runWithState($state))->toBeFalse();
// Guard and path assertions
OrderMachine::test(['amount' => 0])
->assertGuarded('SUBMIT')
->assertGuardedBy('SUBMIT', MinimumAmountGuard::class);Declarative timers on transitions. Define after (one-shot) and every (recurring) timers directly in your machine config. Auto-discovered, auto-scheduled — no Kernel.php setup needed.
'awaiting_payment' => [
'on' => [
'PAY' => 'processing',
'ORDER_EXPIRED' => ['target' => 'cancelled', 'after' => Timer::days(7)],
'REMINDER' => ['actions' => 'sendReminderAction', 'every' => Timer::days(1)],
],
],Cron-based batch operations for machines. Define schedules in your machine definition, register timing in routes/console.php. Resolvers query your models, EventMachine dispatches to all matching instances.
MachineDefinition::define(
config: [...],
schedules: [
'CHECK_EXPIRY' => ExpiredApplicationsResolver::class,
'DAILY_REPORT' => null, // auto-detect
],
)
// routes/console.php
MachineScheduler::register(AppMachine::class, 'CHECK_EXPIRY')
->dailyAt('00:10')->onOneServer();Validated data at every step. Context classes powered by Spatie Laravel Data give you typed properties, validation rules, and transformations.
No more $context['total'] typos. No more missing validation. IDE autocompletion everywhere.
··· 2 hidden lines
class OrderContext extends ContextManager
{
public function __construct(
public array $items = [],
#[Min(0)]
public int $total = 0,
#[Email]
public ?string $customerEmail = null,
public OrderStatus $status = OrderStatus::Draft,
) {
parent::__construct();
}
public function itemCount(): int
{
return count($this->items);
}
}// Type-safe access everywhere
$order->state->context->total; // int
$order->state->context->itemCount(); // method calls work
$order->state->context->status; // enumBuilt for Laravel, not bolted on. Eloquent integration, dependency injection, service providers, Artisan commands - everything you expect.
Attach machines to models. Inject services into behaviors. Validate with Artisan. Test with Pest.
··· 3 hidden lines
// Attach to Eloquent models
class Order extends Model
{
use HasMachines;
protected $casts = [
'machine' => MachineCast::class.':'.OrderMachine::class,
];
}··· 1 hidden line
// Dependency injection in behaviors
class ProcessPaymentAction extends ActionBehavior
{
public function __construct(
private PaymentGateway $gateway,
private OrderRepository $orders,
) {}
public function __invoke(OrderContext $context): void
{
$this->gateway->charge($context->total);
$this->orders->markPaid($context->orderId);
}
}Define endpoints in your machine, skip the controllers. Each event becomes an HTTP endpoint automatically. One MachineRouter::register() call replaces dozens of routes and controllers.
Pre-send validation? Post-send cleanup? Exception handling? EndpointActions give you lifecycle hooks without touching machine internals.
MachineDefinition::define(
config: [...],
behavior: [...],
endpoints: [
'SUBMIT', // POST /submit
'APPROVE' => [
'method' => 'PATCH',
'middleware' => ['auth:admin'],
'output' => 'approvalOutput',
],
'CANCEL' => [
'action' => CancelEndpointAction::class,
],
],
);// One call generates all routes
MachineRouter::register(OrderMachine::class, [
'prefix' => 'orders',
'model' => Order::class,
'attribute' => 'order_mre',
'create' => true,
'modelFor' => ['SUBMIT', 'APPROVE', 'CANCEL'],
]);
// POST /orders/create
// POST /orders/{order}/submit
// PATCH /orders/{order}/approve
// POST /orders/{order}/cancelEnterprise-grade event management. Completed machines pile up? Archive them. Events compressed to a fraction of their size, but fully restorable when needed.
Six months later, compliance asks about order #12847? One line brings the entire machine back with full context and history.
# Archive inactive machines (30+ days by default)
php artisan machine:archive-events
# Events compressed: 847 events → 1 archived record
# Storage: 2.3 MB → 127 KB// Months later: restore the entire machine
$archive = MachineEventArchive::where(
'root_event_id', $rootEventId
)->first();
// Restore automatically decompresses events
$order = OrderMachine::create(state: $archive->root_event_id);
// Full machine restored with complete history
$order->state->matches('completed'); // true
$order->state->context->total; // 15000
$order->state->history->count(); // 847Navigate complex state flows in staging — instantly. Define behavior overrides and delegation outcomes once in a MachineScenario class, activate from existing endpoints. Arrive at any state with a fully functional machine.
No manual multi-step setup. No developer assistance. QA selects a scenario, sends one event, machine arrives at the target state with real transitions and real event history.
class AtCheckingProtocolScenario extends MachineScenario
{
protected string $machine = CarSalesMachine::class;
protected string $source = 'awaiting_customer_start';
protected string $event = CustomerStartedEvent::class;
protected string $target = 'checking_protocol';
protected string $description = 'At checking protocol — children completed';
protected function plan(): array
{
return [
'eligibility_check' => [
IsFarmerNotEligibleGuard::class => false,
],
'verification.findeks.running' => '@done.report_saved',
'verification.turmob.verifying' => '@done',
'verification' => [
'isFindeksRegionCompletedGuard' => true,
],
];
}
}Query machines by state with a fluent API. No more raw SQL against machine_current_states. Find all machines awaiting payment, filter by date, paginate results — with automatic parallel state deduplication.
Lazy-loaded restores mean you only pay the cost of full machine reconstruction when you actually need it.
// Find all machines in a specific state
$results = OrderMachine::query()
->inState('awaiting_payment')
->latest()
->paginate(20);
// Lightweight results — lazy restore on demand
foreach ($results as $result) {
$result->machineId; // root_event_id (instant)
$result->stateId; // current state (instant)
$result->machine(); // full Machine (lazy restore)
}// Advanced filtering
OrderMachine::query()
->active() // not in final state
->enteredBefore(now()->subDays(7)) // stale machines
->count(); // efficient COUNT(DISTINCT)
// Parallel-safe: automatic deduplication
ProcessingMachine::query()
->inState('processing') // parallel state
->get(); // each machine appears once