Skip to content

EventMachineState Machines That Compose

Delegation. Timers. Event sourcing. Parallel execution. All declarative, all Laravel.

EventMachineEventMachine

Declare Your States

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.

Build your first machine →

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; MachineDefinition::define(
    config: [
        'initial' => 'draft',
        'context' => ['items' => [], 'total' => 0],
        'states' => [
            'draft' => [
                'on' => ['SUBMIT' => 'review'],
            ],
            'review' => [
                'on' => [
                    'APPROVE' => 'approved',
                    'REJECT'  => 'draft',
                ],
            ],
            'approved' => ['type' => 'final'],
        ],
    ],
);

Behaviors: Guards, Actions, Calculators

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.

Explore behaviors →

php
'CHECKOUT' => [
    'target'      => 'processing',
    'calculators' => PriceCalculator::class,
    'guards'      => [[MinimumOrderGuard::class, 'min' => 100]],
    'actions'     => SendReceiptAction::class,
],
php

use Tarfinlabs\EventMachine\Behavior\CalculatorBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class PriceCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        $context->set('total', $context->get('quantity') * $context->get('price'));
    }
}
php

use Tarfinlabs\EventMachine\Behavior\GuardBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class MinimumOrderGuard extends GuardBehavior
{
    public function __invoke(ContextManager $context, int $min = 0): bool
    {
        return $context->get('total') >= $min;
    }
}
php

use Tarfinlabs\EventMachine\Behavior\ActionBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class SendReceiptAction extends ActionBehavior
{
    public function __invoke(ContextManager $context): void
    {
        Mail::to($context->get('email'))->send(new Receipt($context->get('total')));
    }
}

Every Transition, Persisted

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.

Learn about persistence →

php

use Tarfinlabs\EventMachine\Actor\Machine;use Tarfinlabs\EventMachine\Models\MachineEvent;use Tarfinlabs\EventMachine\Definition\MachineDefinition;$order = Machine::create(    definition: MachineDefinition::define(        config: [            'id' => 'order',            'initial' => 'draft',            'context' => [],            'states' => [                'draft'    => ['on' => ['SUBMIT' => 'review']],                'review'   => ['on' => ['APPROVE' => 'approved']],                'approved' => ['type' => 'final'],            ],        ],    ),);// 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();

Parallel States with True Parallel Dispatch

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.

Learn parallel states →

php
'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'],
            ],
        ],
    ],
],
php
// 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'
php
// 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'

Machine Delegation

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.

Machine delegation →

php
'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',
    ],
],
php
// 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']);

Test Everything, Fluently

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.

Testing guide →

php
// 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();
php
// 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);

Time-Based Events

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.

Time-Based Events →

php
'awaiting_payment' => [
    'on' => [
        'PAY'           => 'processing',
        'ORDER_EXPIRED' => ['target' => 'cancelled', 'after' => Timer::days(7)],
        'REMINDER'      => ['actions' => 'sendReminderAction', 'every' => Timer::days(1)],
    ],
],

Scheduled Events

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.

Scheduled Events →

php
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();

Type-Safe Context

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.

Working with context →

php

use Tarfinlabs\EventMachine\ContextManager; enum OrderStatus { case Draft; } 
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);
    }
}
php
// Type-safe access everywhere
$order->state->context->total;        // int
$order->state->context->itemCount();  // method calls work
$order->state->context->status;       // enum

Laravel Native

Built 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.

Laravel integration →

php

use Illuminate\Database\Eloquent\Model; use Tarfinlabs\EventMachine\Traits\HasMachines; use Tarfinlabs\EventMachine\Casts\MachineCast; 
// Attach to Eloquent models
class Order extends Model
{
    use HasMachines;

    protected $casts = [
        'machine' => MachineCast::class.':'.OrderMachine::class,
    ];
}
php

use Tarfinlabs\EventMachine\Behavior\ActionBehavior; 
// 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);
    }
}

Zero-Boilerplate Endpoints

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.

HTTP Endpoints →

php
MachineDefinition::define(
    config: [...],
    behavior: [...],
    endpoints: [
        'SUBMIT',                       // POST /submit
        'APPROVE' => [
            'method'     => 'PATCH',
            'middleware'  => ['auth:admin'],
            'output'     => 'approvalOutput',
        ],
        'CANCEL'  => [
            'action' => CancelEndpointAction::class,
        ],
    ],
);
php
// 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}/cancel

Archive Millions, Restore Any

Enterprise-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.

Archival & restoration →

bash
# 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
php
// 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();           // 847

Scenarios for QA

Navigate 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.

Scenarios →

php
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,
            ],
        ];
    }
}

Find Any Machine, Instantly

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.

Querying machines →

php
// 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)
}
php
// 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

Released under the MIT License.