Skip to content

Scenario Behaviors

Reusable, class-based behavior overrides for scenarios — when bool/array/closure shorthand isn't enough.

When to Use

Most scenario overrides are simple enough for inline forms:

php
'eligibility_check' => [
    IsFarmerNotEligibleGuard::class => false,                    // bool
    StoreApplicationAction::class => ['applicationId' => 'APP'], // array
],

Use class-based scenario behaviors when you need:

  • Complex logic — multi-step conditionals, branching based on context
  • Reuse — the same override logic used in multiple scenarios
  • Full DI — inject services, models, or other dependencies
  • Testability — unit-test the override behavior independently

Base Classes

Four abstract classes, one per behavior type:

Base classExtendsReplaces
GuardScenarioBehaviorGuardBehaviorGuards — must return bool
ActionScenarioBehaviorActionBehaviorActions — void (mutates context)
CalculatorScenarioBehaviorCalculatorBehaviorCalculators — void (pre-computes context)
OutputScenarioBehaviorOutputBehaviorOutputs — returns output data

All extend InvokableBehavior, so they inherit full parameter injection support.

DI and Type Compatibility

Scenario behaviors are type-compatible with the original behavior they replace. When ScenarioPlayer registers overrides, it uses App::bind() to swap the original class with the scenario version in the container.

php
// In plan():
'routing' => [
    CustomerContextCalculator::class => CustomerContextCalculatorScenario::class,
],

// What happens:
// App::bind(CustomerContextCalculator::class, fn () => new CustomerContextCalculatorScenario());
// When the engine resolves CustomerContextCalculator, it gets the scenario version.

The scenario behavior receives the same injected parameters as the original — ContextManager, EventBehavior, State, etc.

Examples

Guard — Complex Eligibility Logic

php
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Scenarios\GuardScenarioBehavior;

class IsCustomerInfoCompleteGuardScenario extends GuardScenarioBehavior
{
    public function __invoke(ContextManager $ctx): bool
    {
        // Only pass if farmer has both phone and email
        $farmer = $ctx->get('farmer');

        return $farmer !== null
            && $farmer->phone !== null
            && $farmer->email !== null;
    }
}

Action — Mock External Service

php
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Scenarios\ActionScenarioBehavior;

class StoreApplicationActionScenario extends ActionScenarioBehavior
{
    public function __invoke(ContextManager $ctx): void
    {
        // Skip real API call, write mock data to context
        $ctx->set('applicationId', 'APP-SCENARIO-' . now()->timestamp);
        $ctx->set('applicationStatus', 'submitted');
        $ctx->set('submittedAt', now()->toISOString());
    }
}

Calculator — Pre-Set Context Data

php
use App\Models\Farmer;
use App\Models\Retailer;
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Scenarios\CalculatorScenarioBehavior;

class CustomerContextCalculatorScenario extends CalculatorScenarioBehavior
{
    public function __invoke(ContextManager $ctx): void
    {
        $ctx->set('farmer', Farmer::find(42));
        $ctx->set('retailer', Retailer::find(7));
    }
}

Output — Fixed Output Data

php
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Scenarios\OutputScenarioBehavior;

class OrderSummaryOutputScenario extends OutputScenarioBehavior
{
    public function __invoke(ContextManager $ctx): array
    {
        return [
            'orderId'    => $ctx->get('orderId', 'ORD-SCENARIO'),
            'status'     => 'approved',
            'totalAmount' => 15000,
        ];
    }
}

Naming Convention

Scenario behavior names mirror the original behavior name with Scenario suffix:

OriginalScenario version
HasConsentGuardHasConsentGuardScenario
StoreApplicationActionStoreApplicationActionScenario
CustomerContextCalculatorCustomerContextCalculatorScenario
OrderSummaryOutputOrderSummaryOutputScenario

This enables search: HasConsent finds both HasConsentGuard and HasConsentGuardScenario side by side.

File Organization

Place scenario behaviors next to the scenario that uses them in a subdirectory named after the scenario:

app/Machines/CarSales/
└── Scenarios/
    ├── AtVerificationScenario.php                    # simple — all inline
    └── AtCheckingProtocolScenario/                   # complex — has behavior classes
        ├── AtCheckingProtocolScenario.php
        └── Guards/
            └── IsCustomerInfoCompleteGuardScenario.php

For simple scenarios (all inline overrides), a single file is sufficient. Create a subdirectory only when the scenario has class-based behavior overrides.

Inline vs Class-Based Overrides

The override mechanism differs based on whether the behavior key is a class FQCN or a camelCase inline key:

Key formatExampleOverride mechanism
FQCN (class-based)IsEligibleGuard::class => falseApp::bind() — container resolution swapped
camelCase (inline)'isEligibleGuard' => falseInlineBehaviorFake::fake() — inline interception

Both support the same value forms (bool, array, closure, class). The engine resolves the correct mechanism automatically based on the key format.

InlineBehaviorFake is the testing infrastructure class that intercepts inline behavior closures. When a camelCase key is registered, ScenarioPlayer calls InlineBehaviorFake::fake($key, $replacement) which replaces the original closure with the scenario's override during execution. Cleanup is automatic — ScenarioPlayer::cleanupOverrides() resets all fakes after each execute() call.

Override Form Comparison

FormBest forExample
BoolSimple guard pass/failGuard::class => false
ArrayContext data injectionAction::class => ['key' => 'value']
ClosureOne-off inline logicGuard::class => fn (ContextManager $ctx): bool => ...
ClassReusable, complex, testableGuard::class => GuardScenario::class

Released under the MIT License.