Skip to content

Scenarios

Behavior overrides activated through existing machine endpoints — enabling QA and product teams to navigate complex state flows in staging without manual multi-step setup.

What Are Scenarios?

Complex machines like CarSalesMachine have deep state hierarchies with parallel child delegations, API integrations, and multi-step guard chains. To test a feature at state checking_protocol, a human must:

  1. Trigger START_APPLICATION with valid models
  2. Grant consent, pass eligibility check
  3. Wait for FindeksMachine to complete (6+ steps, external API calls)
  4. Wait for TurmobMachine to complete
  5. Both parallel regions must finish, guard check passes

This requires 10+ manual steps, 2 child machines, and multiple API calls. Product teams and QA cannot do this in staging without developer assistance.

Scenarios solve this: define behavior overrides and delegation outcomes once, activate from existing frontend endpoints via a scenario field, arrive at the desired state with a fully functional machine.

The resulting machine is indistinguishable from one that arrived at that state organically — real transitions, real event history, real context mutations.

Quick Start

1. Scaffold a scenario

bash
php artisan machine:scenario AtShipping OrderMachine \
    pending SubmitOrderEvent shipping

This analyzes the machine definition, finds the path from source to target, and generates:

app/Machines/Order/Scenarios/AtShippingScenario.php

2. Review and adjust the generated plan

The scaffolder generates a plan() with override entries for each intermediate state. Here is an example of what it produces:

php
protected function plan(): array
{
    return [
        'eligibility_check' => [
            IsBlacklistedGuard::class => false,      // guard override — fixed bool
        ],
        'payment_processing' => '@done.completed',   // delegation — simulate child @done
        'identity_verification' => '@done',           // delegation — simulate job @done
        'review' => [
            '@continue' => ReviewApprovedEvent::class, // interactive — auto-send event
            ProcessReviewAction::class => ['reviewApproved' => true],
            HasValidDocumentsGuard::class => true,
        ],
    ];
}

Review the generated file, adjust guard/action overrides and delegation outcomes. See MachineScenario Class below for the full class structure.

3. Enable scenarios in staging

env
MACHINE_SCENARIOS_ENABLED=true

4. Activate via endpoint

http
POST /api/orders/{orderId}/submit
{
    "type": "SubmitOrderEvent",
    "scenario": "at-shipping-scenario"
}

The machine processes the event with overrides active, arrives at shipping, and returns the final state.

5. Validate

bash
php artisan machine:scenario-validate

6. Add continuation for interactive targets

If your target is an interactive state (QA will send more events after arriving), define continuation() so the scenario stays active and applies overrides to subsequent requests:

php
protected function continuation(): array
{
    return [
        'confirming_pin' => '@done',
        'polling'        => [
            'outcome'                => '@done',
            IsPinRequiredGuard::class => false,
        ],
        'saving_report' => '@done',
    ];
}

See Continuation — Multi-Request Flows for the full guide.

When continuation() is required

Add continuation() whenever your scenario's target state has event handlers that lead to delegation states. If the user can click a button, submit a form, or trigger a retry from your target state — and that event leads to a job or child machine — that delegation will hit the real service without continuation.

Red flags: scenario target is a "failed" or "error" state with a retry button, or an "awaiting" state with a resend action. Both almost always need continuation().

Symptom of missing continuation: the HTTP response after a user action shows the state transitioning (e.g., failed to retrying) with isProcessing: false — meaning a real async dispatch is in flight. Query machine_events for child.*.start / child.*.done timestamps: same-second confirms scenario interception, a gap proves a real delegation fired.

How It Works

ScenarioPlayer sends the trigger event with behavior overrides active — guards return fixed booleans instead of evaluating real conditions, actions write mock context values instead of calling external services, and delegation outcomes are intercepted so child machines and jobs are never dispatched. After the trigger event is processed, ScenarioPlayer checks if the machine landed on a state with an @continue directive; if so, it automatically sends the specified event and repeats until no more @continue entries match. At the end, ScenarioPlayer validates that the machine reached the declared $target state — throwing ScenarioFailedException if it did not. Because the overrides operate within the real machine engine (not a simulation), the machine produces real MachineEvent records, real context mutations, and real state transitions — the resulting event history is indistinguishable from a machine that arrived at that state through organic user interaction.

MachineScenario Class

Every scenario extends MachineScenario with 5 identity properties, an optional plan() method, and an optional continuation() method:

php
class AtShippingScenario extends MachineScenario
{
    /** Which machine this scenario targets. */
    protected string $machine = OrderMachine::class;

    /** State the machine must be in BEFORE the event. */
    protected string $source = 'pending';

    /** Event that triggers this scenario. */
    protected string $event = SubmitOrderEvent::class;

    /** Where the machine should end up after execution. */
    protected string $target = 'shipping';

    /** Human-readable description shown in endpoint responses. */
    protected string $description = 'At shipping — payment completed';

    protected function plan(): array
    {
        return [
            'eligibility_check' => [
                IsBlacklistedGuard::class => false,
            ],
            'processing_payment' => '@done',
        ];
    }
}

Identity Properties

PropertyTypeExamplePurpose
$machineclass-stringOrderMachine::classWhich machine this scenario targets
$sourcestring'pending'Full state route — where the machine is BEFORE the event
$eventstringSubmitOrderEvent::classWhich event triggers this scenario
$targetstring'shipping'Full state route — where the machine should end up
$descriptionstring'At shipping'Human-readable, shown in endpoint responses

Multiple scenarios can share the same (source, event) with different targets. The slug (derived from the class name) disambiguates.

Continuation Methods

When a scenario targets an interactive state (QA will send more events after reaching $target), define continuation() to keep the scenario active across subsequent requests:

MethodReturnPurpose
continuation()array<string, mixed>Overrides applied on subsequent requests after target is reached. Same format as plan(). Returns [] by default (single-shot).
hasContinuation()boolWhether this scenario has a continuation phase (continuation() !== [])
resolvedContinuation()array<string, mixed>Public accessor for the resolved continuation plan

The $isContinuation flag (public bool, default false) is set by MachineController when restoring a continuation from the database. It distinguishes initial activation (Phase 1, uses plan()) from continuation (Phase 2, uses continuation()).

Full State Route Requirement

$source, $target, and plan() keys must use the full state route (dot-notation path):

php
// Bad — ambiguous, could exist in multiple parallel regions
protected string $source = 'awaiting_confirmation';

// Good — unambiguous full path
protected string $source = 'checkout.payment.awaiting_confirmation';

For root-level states (e.g., pending, shipping), the leaf key IS the full route.

Accessing Properties

Properties are set as protected class fields. Public getter methods (machine(), source(), event(), target(), description()) return these values. Override a getter when you need computed values:

php
// Default — property assignment (recommended)
protected string $machine = OrderMachine::class;

// Alternative — override the getter (when computation is needed)
public function machine(): string
{
    return config('machines.order.class');
}

The getters return the property value directly. Override the getter method when you need runtime computation.

@start — Transient Initial States

Child machines often have transient initial states (idle → @always → ...). There's no external event to send — the machine auto-starts. Use MachineScenario::START as the event:

php
use Tarfinlabs\EventMachine\Scenarios\MachineScenario;

class AtReportSavedFromStartScenario extends MachineScenario
{
    protected string $machine = FindeksMachine::class;
    protected string $source  = 'idle';                   // Initial state
    protected string $event   = MachineScenario::START;   // Special: no event sent
    protected string $target  = 'report_saved';

    protected function plan(): array
    {
        return [
            'checking_existing_report' => [
                HasExistingReportGuard::class => false,
            ],
            'querying_phones' => '@done',
            // ...
        ];
    }
}

When $event is @start, ScenarioPlayer creates a fresh machine and processes the @always chain with overrides active. No $machine parameter needed:

php
$scenario = new AtReportSavedFromStartScenario();
$player   = new ScenarioPlayer($scenario);
$state    = $player->execute(); // Creates machine internally

Naming Conventions

TypePatternExample
Machine scenarioAt{Target}ScenarioAtReviewScenario
Behavior scenario{OriginalName}ScenarioHasAgreedToTermsGuardScenario

Machine scenario names are descriptive — typically the target state. When multiple scenarios target the same state via different paths, disambiguate:

  • AtPaymentVerificationScenario — unique target, sufficient
  • AtPaymentVerificationViaConsentScenario — same target, different source/event

Behavior scenario names mirror the original behavior name with Scenario suffix. This enables search: HasAgreedToTerms finds both HasAgreedToTermsGuard and HasAgreedToTermsGuardScenario.

Next Steps

Released under the MIT License.