Skip to content

Parallel Patterns

Parallel states let multiple independent processes run simultaneously within a single machine. Each region has its own state hierarchy and processes events independently. When all regions reach a final state, @done fires.

The Independence Rule

Regions must be independent. No region should assume it runs before or after another. No region should depend on a specific state of a sibling. If two processes need to coordinate step-by-step, they are not parallel -- they are sequential.

Separate Context Keys Per Region

Each region should own its own context keys. If two regions write to the same key, the last writer wins -- a silent data loss that is extremely hard to debug.

php
// Do: separate keys per region

'context' => [
    'paymentStatus'  => null,
    'paymentId'      => null,
    'shippingStatus' => null,
    'trackingId'     => null,
    'documentStatus' => null,
    'documentUrl'    => null,
],
php
// Don't: shared keys

'context' => [
    'status'  => null,   // Which region writes this?
    'data'    => null,   // Payment data or shipping data?
],

@done Fires When ALL Regions Reach Final

A parallel state's @done transition fires only when every region has reached a final state. This is the synchronisation point -- no explicit coordination needed.

php
'fulfillment' => [
    'type'  => 'parallel',
    '@done' => 'completed',
    'states' => [
        'payment' => [
            'initial' => 'pending',
            'states'  => [
                'pending'  => ['on' => ['PAYMENT_RECEIVED' => 'settled']],
                'settled'  => ['type' => 'final'],
            ],
        ],
        'shipping' => [
            'initial' => 'preparing',
            'states'  => [
                'preparing' => ['on' => ['SHIPMENT_DISPATCHED' => 'shipped']],
                'shipped'   => ['type' => 'final'],
            ],
        ],
    ],
],
'completed' => ['type' => 'final'],

completed is reached only when both payment is settled and shipping is shipped, regardless of which finishes first.

Anti-Pattern: Regions Depending on Execution Order

php
// Anti-pattern: region B assumes region A has already run

'fulfillment' => [
    'type'   => 'parallel',
    'states' => [
        'payment' => [
            'initial' => 'charging',
            'states'  => [
                'charging' => [
                    'entry' => 'chargePaymentAction',  // writes payment_id to context
                    'on'    => ['PAYMENT_CHARGED' => 'charged'],
                ],
                'charged' => ['type' => 'final'],
            ],
        ],
        'notification' => [
            'initial' => 'sending',
            'states'  => [
                'sending' => [
                    // BAD: reads payment_id that may not exist yet
                    'entry' => 'sendPaymentConfirmationAction',
                    'on'    => ['NOTIFICATION_SENT' => 'sent'],
                ],
                'sent' => ['type' => 'final'],
            ],
        ],
    ],
],

There is no guarantee that payment's entry action runs before notification's. With parallel dispatch enabled, they may execute on different queue workers simultaneously.

Fix: If one process depends on another's result, they are sequential, not parallel. Move the notification to after @done, or use a non-parallel approach.

php
// Fix: notification happens after payment completes

'fulfillment' => [
    'type'  => 'parallel',
    '@done' => [
        'target'  => 'completed',
        'actions' => 'sendPaymentConfirmationAction',  // runs after ALL regions done
    ],
    'states' => [
        'payment'  => [...],
        'shipping' => [...],
    ],
],

Anti-Pattern: Shared Context Key Mutation

php
// Anti-pattern: both regions write to 'status'

'payment' => [
    'initial' => 'pending',
    'states'  => [
        'pending' => [
            'entry' => 'setStatusPaymentPendingAction',  // writes context.status = 'payment_pending'
            'on'    => ['PAYMENT_RECEIVED' => 'done'],
        ],
        'done' => ['type' => 'final'],
    ],
],
'shipping' => [
    'initial' => 'preparing',
    'states'  => [
        'preparing' => [
            'entry' => 'setStatusShippingAction',  // writes context.status = 'shipping'  -- OVERWRITES!
            'on'    => ['SHIPPED' => 'done'],
        ],
        'done' => ['type' => 'final'],
    ],
],

Last-writer-wins. You cannot predict which value status holds.

Fix: Use namespaced keys: paymentStatus and shippingStatus.

Anti-Pattern: Using Parallel for Sequential Phases

php
// Anti-pattern: phases that must run in order

'processing' => [
    'type'   => 'parallel',   // wrong!
    'states' => [
        'validation' => [...],   // must complete before payment
        'payment'    => [...],   // must complete before shipping
        'shipping'   => [...],   // depends on payment result
    ],
],

If phases depend on each other, they are not parallel. Use sequential states or machine delegation.

Fix: Sequential states, optionally with child machines.

php
'states' => [
    'validating' => [
        'on' => ['VALIDATION_PASSED' => 'processing_payment'],
    ],
    'processing_payment' => [
        'on' => ['PAYMENT_RECEIVED' => 'shipping'],
    ],
    'shipping' => [
        'on' => ['SHIPMENT_DISPATCHED' => 'completed'],
    ],
    'completed' => ['type' => 'final'],
],

Cross-Region Coordination

Sometimes one region needs to wait for a sibling. The standard approach is an @always transition with a guard that checks the sibling's state via $state->matches():

php
'dealer' => [
    'initial' => 'pricing',
    'states'  => [
        'pricing' => [
            'on' => ['PRICING_COMPLETED' => 'awaiting_approval'],
        ],
        'awaiting_approval' => [
            'on' => [
                '@always' => [
                    ['target' => 'finalizing', 'guards' => 'isCustomerApprovedGuard'],
                ],
            ],
        ],
        'finalizing' => ['type' => 'final'],
    ],
],
php

use Tarfinlabs\EventMachine\Actor\State; use Tarfinlabs\EventMachine\Behavior\GuardBehavior; use Tarfinlabs\EventMachine\ContextManager; use Tarfinlabs\EventMachine\Behavior\EventBehavior; 
class IsCustomerApprovedGuard extends GuardBehavior
{
    public function __invoke(ContextManager $context, EventBehavior $event, State $state): bool
    {
        return $state->matches('fulfillment.customer.approved');
    }
}

The guard re-evaluates whenever any region transitions, so the waiting region automatically unblocks when the sibling reaches the expected state.

Example: Order Fulfillment

php
'id'      => 'order_workflow',
'initial' => 'fulfillment',
'context' => [
    'orderId'        => null,
    'paymentId'      => null,
    'trackingId'     => null,
    'documents_ready' => false,
],
'states' => [
    'fulfillment' => [
        'type'  => 'parallel',
        '@done' => 'completed',
        'states' => [
            'payment' => [
                'initial' => 'awaiting_payment',
                'states'  => [
                    'awaiting_payment' => [
                        'on' => ['PAYMENT_RECEIVED' => 'settled'],
                    ],
                    'settled' => ['type' => 'final'],
                ],
            ],
            'shipping' => [
                'initial' => 'preparing',
                'states'  => [
                    'preparing' => [
                        'on' => ['SHIPMENT_DISPATCHED' => 'in_transit'],
                    ],
                    'in_transit' => [
                        'on' => ['DELIVERY_CONFIRMED' => 'delivered'],
                    ],
                    'delivered' => ['type' => 'final'],
                ],
            ],
            'documents' => [
                'initial' => 'generating',
                'states'  => [
                    'generating' => [
                        'entry' => 'generateInvoiceAction',
                        'on'    => ['DOCUMENT_READY' => 'collected'],
                    ],
                    'collected' => ['type' => 'final'],
                ],
            ],
        ],
    ],
    'completed' => ['type' => 'final'],
],

Payment, shipping, and document generation proceed independently. The order is completed only when all three regions reach their final states.

Guidelines

  1. Regions must be independent. No shared mutable state, no execution order assumptions.

  2. Separate context keys per region. paymentStatus and shippingStatus, never a shared status.

  3. Let @done synchronise. Do not manually check if siblings are done -- that is what @done is for.

  4. Sequential work is not parallel. If phase B depends on phase A's result, use sequential states.

  5. Use @always guards for cross-region coordination. Check sibling state via $state->matches() when one region must wait for another.

Released under the MIT License.