Skip to content

Handling Events ​

Events are the triggers that cause state machines to transition. This guide covers sending events and creating custom event classes.

Sending Events ​

Array Syntax ​

The simplest way to send an event:

php
$machine = OrderMachine::create();

$state = $machine->send([
    'type' => 'SUBMIT',
]);

With Payload ​

Include data with your event:

php
$state = $machine->send([
    'type' => 'ADD_ITEM',
    'payload' => [
        'productId' => 123,
        'quantity' => 2,
        'price' => 1999,
    ],
]);

With Actor ​

Track who triggered the event:

php
$state = $machine->send([
    'type' => 'APPROVE',
    'actor' => $currentUser->id,
]);

Custom Event Classes ​

For complex events, create dedicated classes:

php
use Tarfinlabs\EventMachine\Behavior\EventBehavior;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\Min;

class AddItemEvent extends EventBehavior
{
    public function __construct(
        #[Required]
        public int $productId,

        #[Required, Min(1)]
        public int $quantity = 1,

        public ?int $price = null,
    ) {
        parent::__construct();
    }

    // getType() auto-generates 'ADD_ITEM' from class name
}

Using Custom Events ​

php
// In transition definition
'on' => [
    AddItemEvent::class => [
        'actions' => 'addItemToCartAction',
    ],
],

// Sending the event
$state = $machine->send(AddItemEvent::from([
    'productId' => 123,
    'quantity' => 2,
]));

Event Validation ​

Custom events are validated automatically:

php
class PaymentEvent extends EventBehavior
{
    public function __construct(
        #[Required]
        public string $paymentMethod,

        #[Required, Min(1)]
        public int $amount,

        #[Email]
        public ?string $receiptEmail = null,
    ) {
        parent::__construct();
    }

    public static function getType(): string
    {
        return 'PAY';
    }
}

Invalid events throw MachineEventValidationException:

php
// This will throw validation exception
$machine->send(PaymentEvent::from([
    'paymentMethod' => 'card',
    'amount' => 0,  // Fails Min(1) validation
]));

Accessing Event Data in Actions ​

From Array Events ​

php

use Tarfinlabs\EventMachine\Behavior\ActionBehavior; use Tarfinlabs\EventMachine\Behavior\EventBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class AddItemAction extends ActionBehavior
{
    public function __invoke(
        ContextManager $context,
        EventBehavior $event
    ): void {
        $productId = $event->payload['productId'];
        $quantity = $event->payload['quantity'];

        // Add to cart...
    }
}

From Custom Event Classes ​

php
class AddItemAction extends ActionBehavior
{
    public function __invoke(
        ContextManager $context,
        AddItemEvent $event  // Type-hinted!
    ): void {
        $productId = $event->productId;  // Direct property access
        $quantity = $event->quantity;

        // Add to cart...
    }
}

Event Registration ​

Register event classes in the behavior array:

php
MachineDefinition::define(
    config: [...],
    behavior: [
        'events' => [
            'ADD_ITEM' => AddItemEvent::class,
            'REMOVE_ITEM' => RemoveItemEvent::class,
            'CHECKOUT' => CheckoutEvent::class,
        ],
    ],
);

Or reference classes directly in transitions:

php
'on' => [
    AddItemEvent::class => ['actions' => 'addItemAction'],
    RemoveItemEvent::class => ['actions' => 'removeItemAction'],
],

Raising Events from Actions ​

Actions can raise events that are processed after the current transition:

php

use Tarfinlabs\EventMachine\Behavior\ActionBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class ProcessOrderAction extends ActionBehavior
{
    public function __invoke(ContextManager $context): void
    {
        // Process order...

        // Raise a follow-up event
        $this->raise([
            'type' => 'SEND_CONFIRMATION',
            'payload' => ['orderId' => $context->get('orderId')],
        ]);
    }
}

The raise() method is inherited from InvokableBehavior and queues events to be processed in order after the current transition completes.

Event Properties ​

PropertyTypeDescription
typestringEvent identifier
payloadarrayEvent data
actormixedWho triggered the event
versionintEvent version (default: 1)
sourceSourceTypeEXTERNAL or INTERNAL
isTransactionalboolWrap in DB transaction

Transactional Events ​

By default, events are wrapped in database transactions:

php

use Tarfinlabs\EventMachine\Behavior\EventBehavior; 
class PaymentEvent extends EventBehavior
{
    public bool $isTransactional = true;  // Default

    public static function getType(): string { return 'PAYMENT'; } 
    // ...
}

Disable for events that shouldn't roll back:

php

use Tarfinlabs\EventMachine\Behavior\EventBehavior; 
class LogEvent extends EventBehavior
{
    public bool $isTransactional = false;

    public static function getType(): string { return 'LOG'; } 
    // ...
}

Or per-event:

php
$machine->send([
    'type' => 'LOG',
    'isTransactional' => false,
]);

Actor Tracking ​

Events can track who triggered them:

php
$machine->send([
    'type' => 'APPROVE',
    'actor' => auth()->id(),
]);

Custom logic in event classes:

php
class ApprovalEvent extends EventBehavior
{
    public function actor(ContextManager $context): mixed
    {
        // Custom actor resolution
        return $this->payload['approvedBy'] ?? auth()->id();
    }
}

Auto-Propagation in Raised Events

When actions call raise(), the actor is automatically inherited from the triggering event. You don't need to pass actor: manually in raise chains. See Raised Events — Actor Propagation.

Event Source Types ​

Events have a source property indicating where they originated:

php
use Tarfinlabs\EventMachine\Enums\SourceType;

// SourceType::EXTERNAL - Events sent by your code
$machine->send(['type' => 'PAY']);  // source = EXTERNAL

// SourceType::INTERNAL - Events generated by the machine
// (lifecycle events, raised events from actions)

Query events by source:

php
// Get only user-triggered events
$userEvents = $machine->state->history
    ->filter(fn($event) => $event->source === SourceType::EXTERNAL);

// Get internal lifecycle events
$lifecycleEvents = $machine->state->history
    ->filter(fn($event) => $event->source === SourceType::INTERNAL);

Internal Events ​

EventMachine fires internal events throughout the machine lifecycle. These are recorded in the event history and useful for debugging, auditing, and observability.

Complete Internal Events Reference ​

Event PatternWhen Fired
{machine}.startMachine initializes
{machine}.finishMachine reaches final state
{machine}.state.{state}.enterEntering a state
{machine}.state.{state}.entry.startEntry actions starting
{machine}.state.{state}.entry.finishEntry actions completed
{machine}.state.{state}.exit.startExit actions starting
{machine}.state.{state}.exit.finishExit actions completed
{machine}.state.{state}.exitExited a state
{machine}.transition.{state}.{event}.startTransition beginning
{machine}.transition.{state}.{event}.finishTransition completed
{machine}.transition.{state}.{event}.failTransition failed
{machine}.action.{action}.startAction starting
{machine}.action.{action}.finishAction completed
{machine}.guard.{guard}.passGuard passed
{machine}.guard.{guard}.failGuard failed
{machine}.calculator.{calculator}.passCalculator succeeded
{machine}.calculator.{calculator}.failCalculator threw exception
{machine}.event.{event}.raisedEvent raised from action

Example Event History ​

php
$machine = OrderMachine::create();
$machine->send(['type' => 'SUBMIT']);

// Event history shows complete lifecycle:
$machine->state->history->pluck('type')->toArray();
// [
//     'order.start',
//     'order.state.pending.enter',
//     'order.state.pending.entry.start',
//     'order.state.pending.entry.finish',
//     'SUBMIT',
//     'order.transition.pending.SUBMIT.start',
//     'order.guard.hasItems.pass',
//     'order.action.processOrder.start',
//     'order.action.processOrder.finish',
//     'order.transition.pending.SUBMIT.finish',
//     'order.state.pending.exit.start',
//     'order.state.pending.exit.finish',
//     'order.state.pending.exit',
//     'order.state.submitted.enter',
//     ...
// ]

Filtering Internal Events ​

php
// Get only transition events
$transitions = $machine->state->history
    ->filter(fn($e) => str_contains($e->type, '.transition.'));

// Get failed guards
$failedGuards = $machine->state->history
    ->filter(fn($e) => str_ends_with($e->type, '.fail'))
    ->filter(fn($e) => str_contains($e->type, '.guard.'));

TIP

Internal events have source = SourceType::INTERNAL. They're recorded for observability but don't trigger transitions.

Storage Impact

With persistence enabled, internal events are stored in the machine_events table. A single transition can generate 10+ internal events (enter, exit, guard, action lifecycle events). For long-running machines or high-frequency state changes, consider:

  • Archiving old events periodically with php artisan machine:archive-events
  • Disabling persistence for ephemeral machines ('should_persist' => false)
  • Querying external events only when displaying history to users

Reserved Events ​

The @always event is reserved for automatic transitions:

php
'on' => [
    '@always' => [
        'target' => 'nextState',
        'guards' => 'shouldTransitionGuard',
    ],
],

Complete Example ​

php
// Event class
class CheckoutEvent extends EventBehavior
{
    public function __construct(
        #[Required]
        public string $shippingAddress,

        #[Required]
        public string $paymentMethod,

        public ?string $couponCode = null,
    ) {
        parent::__construct();
    }

    public static function getType(): string
    {
        return 'CHECKOUT';
    }
}

// Machine definition
MachineDefinition::define(
    config: [
        'id' => 'cart',
        'initial' => 'browsing',
        'states' => [
            'browsing' => [
                'on' => [
                    'ADD_ITEM' => ['actions' => 'addItemAction'],
                    CheckoutEvent::class => [
                        'target' => 'processing',
                        'guards' => 'hasItemsGuard',
                        'actions' => ['validateAddressAction', 'startCheckoutAction'],
                    ],
                ],
            ],
            'processing' => [
                'entry' => 'processOrderAction',
                'on' => [
                    '@always' => [
                        ['target' => 'completed', 'guards' => 'isProcessedGuard'],
                        ['target' => 'failed'],
                    ],
                ],
            ],
            'completed' => ['type' => 'final'],
            'failed' => ['type' => 'final'],
        ],
    ],
    behavior: [
        'events' => [
            'CHECKOUT' => CheckoutEvent::class,
        ],
        'actions' => [...],
        'guards' => [...],
    ],
);

// Usage
$cart = CartMachine::create();

// Add items
$cart->send(['type' => 'ADD_ITEM', 'payload' => ['productId' => 1]]);
$cart->send(['type' => 'ADD_ITEM', 'payload' => ['productId' => 2]]);

// Checkout with typed event
$state = $cart->send(CheckoutEvent::from([
    'shippingAddress' => '123 Main St',
    'paymentMethod' => 'card',
    'couponCode' => 'SAVE10',
]));

echo $state->value; // 'completed' or 'failed'

Testing Event Handling ​

php
use Tarfinlabs\EventMachine\Behavior\EventBehavior;

// Test event validation
OrderMachine::test()
    ->assertValidationFailed(['type' => 'SUBMIT', 'payload' => ['amount' => -1]], 'amount');

// Test event acceptance
OrderMachine::test()
    ->send(['type' => 'SUBMIT', 'payload' => ['amount' => 100]])
    ->assertState('submitted');

Full Testing Guide

See Isolated Testing for more examples.

Released under the MIT License.