Skip to content

Testing Factories for EventFlow ​

Laravel-Style Factories for EventFlow Component Testing

Version: Draft 0.1 Date: December 2024 Status: To Be Evaluated


Executive Summary ​

EventFlow mechanisms (guards, actions, resolvers) require context to execute, but setting up that context for tests is manual and error-prone. This proposal introduces a Laravel-style factory system for EventFlow testing infrastructure, enabling:

  1. Event Factories - Generate valid event payloads with states and overrides
  2. Action Factories - Provide context for action execution with state methods
  3. Resolver Factories - Supply resolution context for resolvers
  4. Domain Factories - Create Customer, Cart, Order and other domain objects
  5. Path Definitions - Describe complete state machine paths with factory bindings
  6. Path Simulation - Execute paths to reach any intermediate state

Core Philosophy ​

Factories are testing infrastructure, NOT DSL features.

The machine runs normally - factories just provide the data it needs.

Key Insight: Guards Don't Need Factories ​

Guards evaluate existing context - they don't produce it. Instead of guard factories, path definitions declare expected outcomes (PASS/FAIL), and the simulation either mocks the result or validates against actual context.


Motivation & Problem Statement ​

Current Situation ​

Testing EventFlow mechanisms requires manual context setup:

php
class ExpressCheckoutEligibilityGuardTest extends TestCase
{
    public function test_eligible_customer(): void
    {
        $guard = new ExpressCheckoutEligibilityGuard();

        // Manual context creation - error prone!
        $context = [
            'customer' => $this->createMockCustomer(),  // How did we know this?
            'customer_tier' => 'gold',                  // Trial and error...
            'total' => 600,                             // Reading implementation...
            'shipping_address' => $this->createMockAddress('US'),
            'inventory_status' => 'in_stock',
        ];

        $this->assertTrue(($guard)($context));
    }
}

Problems:

  1. Manual context discovery - Read implementation to find required context
  2. Duplicate setup code - Same context built across many tests
  3. No state variations - Hard to test different scenarios systematically
  4. Integration testing gaps - No way to "start from state B"
  5. Brittle tests - Context structure changes break many tests

Goals ​

  1. Laravel-style factories - Factory::new()->state()->create(['overrides'])
  2. Path definitions - Describe complete paths through state machines
  3. Factory-path binding - Connect factories to path steps explicitly
  4. Simulation engine - Execute paths to reach any state
  5. Side effect handling - Skip external calls during simulation

Proposed Solution ​

Two-Level Factory System ​

┌─────────────────────────────────────────────────────────────┐
│                    LEVEL 1: UNIT TESTING                    │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │   Event     │  │   Action    │  │      Domain         │ │
│  │  Factories  │  │  Factories  │  │     Factories       │ │
│  └─────────────┘  └─────────────┘  └─────────────────────┘ │
│                                                             │
│  Test individual mechanisms in isolation                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                 LEVEL 2: PATH SIMULATION                    │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │    Path     │  │ Simulation  │  │     Machine         │ │
│  │ Definitions │──│   Engine    │──│      State          │ │
│  └─────────────┘  └─────────────┘  └─────────────────────┘ │
│                                                             │
│  "Give me a machine in state B"                            │
└─────────────────────────────────────────────────────────────┘

Level 1: Component Factories ​

Event Factories ​

Generate valid event payloads with states and overrides:

php
namespace Tests\EventFlow\Factories\Events;

use EventFlow\Testing\EventFactory;

#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory
{
    /**
     * Default definition - valid payload
     */
    public function definition(): array
    {
        return [
            'customer_id' => $this->faker->uuid(),
            'email' => $this->faker->safeEmail(),
            'items' => [
                ['product_id' => 'prod-1', 'quantity' => 1, 'price' => 100],
            ],
            'shipping_address' => [
                'street' => $this->faker->streetAddress(),
                'city' => $this->faker->city(),
                'country' => 'US',
            ],
        ];
    }

    /**
     * State: Premium customer checkout
     */
    public function premium(): static
    {
        return $this->state([
            'customer_tier' => 'premium',
            'discount_eligible' => true,
        ]);
    }

    /**
     * State: Express shipping
     */
    public function express(): static
    {
        return $this->state([
            'shipping_method' => 'express',
            'delivery_days' => 1,
        ]);
    }

    /**
     * State: International shipping
     */
    public function international(): static
    {
        return $this->state([
            'shipping_address' => [
                'street' => $this->faker->streetAddress(),
                'city' => 'Berlin',
                'country' => 'DE',
            ],
            'customs_required' => true,
        ]);
    }
}

Usage:

php
// Default payload
$payload = CheckoutEventFactory::new()->create();

// With state
$payload = CheckoutEventFactory::new()->premium()->create();

// With override
$payload = CheckoutEventFactory::new()->create([
    'email' => 'test@example.com',
]);

// Chained states + override
$payload = CheckoutEventFactory::new()
    ->premium()
    ->express()
    ->create(['items' => $customItems]);

Action Factories ​

Provide context for action execution:

php
namespace Tests\EventFlow\Factories\Actions;

use EventFlow\Testing\ActionFactory;

#[ActionFactory('calculate discount')]
class DiscountActionFactory extends ActionFactory
{
    /**
     * Default context for action
     */
    public function definition(): array
    {
        return [
            'subtotal' => 100.00,
            'customer_tier' => 'standard',
            'coupon_code' => null,
        ];
    }

    /**
     * State: Premium customer discount
     */
    public function premium(): static
    {
        return $this->state([
            'customer_tier' => 'premium',
            'base_discount' => 0.10,  // 10% base discount
        ]);
    }

    /**
     * State: With coupon
     */
    public function withCoupon(string $code = 'SAVE20'): static
    {
        return $this->state([
            'coupon_code' => $code,
            'coupon_discount' => 0.20,
        ]);
    }

    /**
     * State: High-value order
     */
    public function highValue(): static
    {
        return $this->state([
            'subtotal' => 1500.00,
            'volume_discount_eligible' => true,
        ]);
    }
}

Usage in unit tests:

php
class CalculateDiscountActionTest extends EventFlowTestCase
{
    public function test_premium_customer_gets_base_discount(): void
    {
        $action = new CalculateDiscountAction();

        // Factory provides context
        $context = DiscountActionFactory::new()
            ->premium()
            ->create(['subtotal' => 200.00]);

        $result = $action($context);

        $this->assertEquals(20.00, $result['discount_amount']);  // 10% of 200
    }

    public function test_coupon_stacks_with_premium(): void
    {
        $action = new CalculateDiscountAction();

        $context = DiscountActionFactory::new()
            ->premium()
            ->withCoupon('SAVE20')
            ->create(['subtotal' => 100.00]);

        $result = $action($context);

        $this->assertEquals(30.00, $result['discount_amount']);  // 10% + 20%
    }
}

Domain Factories ​

Create domain objects for context:

php
namespace Tests\EventFlow\Factories\Domain;

use EventFlow\Testing\DomainFactory;

class CustomerFactory extends DomainFactory
{
    public function definition(): array
    {
        return [
            'id' => $this->faker->uuid(),
            'email' => $this->faker->safeEmail(),
            'name' => $this->faker->name(),
            'tier' => 'standard',
            'verified' => true,
        ];
    }

    public function premium(): static
    {
        return $this->state([
            'tier' => 'premium',
            'loyalty_points' => 5000,
        ]);
    }

    public function gold(): static
    {
        return $this->state([
            'tier' => 'gold',
            'loyalty_points' => 15000,
            'personal_shopper' => true,
        ]);
    }

    public function unverified(): static
    {
        return $this->state([
            'verified' => false,
            'verification_pending' => true,
        ]);
    }
}

class CartFactory extends DomainFactory
{
    public function definition(): array
    {
        return [
            'items' => [
                ['product_id' => 'prod-1', 'quantity' => 1, 'price' => 50.00],
            ],
            'subtotal' => 50.00,
        ];
    }

    public function empty(): static
    {
        return $this->state([
            'items' => [],
            'subtotal' => 0,
        ]);
    }

    public function withItems(int $count): static
    {
        $items = [];
        $subtotal = 0;

        for ($i = 1; $i <= $count; $i++) {
            $price = $this->faker->numberBetween(10, 100);
            $items[] = [
                'product_id' => "prod-{$i}",
                'quantity' => 1,
                'price' => $price,
            ];
            $subtotal += $price;
        }

        return $this->state([
            'items' => $items,
            'subtotal' => $subtotal,
        ]);
    }

    public function highValue(): static
    {
        return $this->state([
            'items' => [
                ['product_id' => 'prod-luxury', 'quantity' => 1, 'price' => 1500.00],
            ],
            'subtotal' => 1500.00,
        ]);
    }
}

Level 2: Path Definitions & Simulation ​

What Are Guards? (No Factories Needed) ​

Guards evaluate existing context to make decisions. They don't produce context.

php
#[Guard('cart is not empty')]
class CartNotEmptyGuard extends GuardBehavior
{
    public function __invoke(array $context): bool
    {
        // EVALUATES context - doesn't create it
        return !empty($context['cart']?->items);
    }
}

Key insight: Instead of guard factories, path definitions declare expected outcomes:

php
Step::define()
    ->event(':checkout')
    ->guard('cart is not empty', GuardExpectation::PASS)  // Expect it to pass
    ->guard('customer is banned', GuardExpectation::FAIL) // Expect it to fail

The simulation engine either:

  • Mocks the guard result (trusts the expectation)
  • Validates the guard against actual context (asserts expectation matches reality)

Path Definition Structure - Attribute-Based Discovery ​

Key Design Decision: Factory discovery via PHP attributes (not naming convention).

Attribute Matching:

php
// Action binding - has pattern attribute
#[Action('calculate discount')]
class CalculateDiscountAction extends ActionBehavior { }

// Action factory - SAME pattern attribute (this is how they match!)
#[ActionFactory('calculate discount')]
class CalculateDiscountActionFactory extends ActionFactory { }

// Event factory - pattern attribute
#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory { }

Path definition uses the pattern to find factory:

php
->action('calculate discount', 'premium')
// System: find #[ActionFactory('calculate discount')] → call ::premium()->create()

Symmetric API Pattern: (pattern, optionalFactoryState)

Important: Path definitions only list items that HAVE factories. DSL expressions (like $total becomes calculated) are NOT listed in path definitions - they're defined in the .flow file and auto-executed by simulation.

Path definitions describe complete journeys through state machines:

php
namespace Tests\EventFlow\Paths\Order;

use EventFlow\Testing\PathDefinition;
use EventFlow\Testing\Step;
use EventFlow\Testing\GuardExpectation;

class PremiumExpressHappyPath extends PathDefinition
{
    public string $name = 'premium_express_happy';
    public string $machine = '@order';
    public string $description = 'Premium customer express checkout to shipped';

    public function steps(): array
    {
        return [
            // Step 1: Checkout with premium customer
            Step::define()
                ->event(':checkout', 'premium')                    // #[EventFactory(':checkout')]::premium()
                ->guard('cart is not empty', GuardExpectation::PASS)
                ->guard('customer is premium', GuardExpectation::PASS)
                ->action('calculate discount', 'premium')          // #[ActionFactory('calculate discount')]::premium()
                // DSL expressions like '$total becomes calculated' NOT listed
                // - they're in .flow file, auto-executed by simulation
                ->transitionsTo('#premium_processing'),

            // Step 2: Payment successful
            Step::define()
                ->event(':payment_received', 'successful')         // #[EventFactory(':payment_received')]::successful()
                ->guard('payment is valid', GuardExpectation::PASS)
                ->guard('fraud check passed', GuardExpectation::PASS)
                ->action('capture payment')                        // #[ActionFactory('capture payment')]::default
                ->action('send confirmation')                      // #[ActionFactory('send confirmation')]::default
                ->transitionsTo('#paid'),

            // Step 3: Express shipping
            Step::define()
                ->event(':ship', 'express')                        // #[EventFactory(':ship')]::express()
                ->action('generate tracking', 'express')           // #[ActionFactory('generate tracking')]::express()
                ->transitionsTo('#shipped_express'),
        ];
    }
}

API Benefits:

AspectBeforeAfter
Factory importsRequiredNot needed
Factory discoveryNaming conventionAttribute matching
API pattern.using(Class, 'state')('pattern', 'state')
DSL expressionsListed in pathNOT listed (auto from .flow)

When no factory state is specified:

php
->event(':checkout')              // Uses #[EventFactory(':checkout')]::definition() (default)
->action('calculate discount')    // Uses #[ActionFactory('calculate discount')]::definition() (default)

What gets listed in path definitions:

ItemListed?Why
Events✅ YESNeed factory for payload
Guards✅ YESNeed expectation (PASS/FAIL)
Bound Actions✅ YESNeed factory for context
DSL expressions❌ NOAuto from .flow file

Alternative Path: Failure Scenario ​

php
class PaymentFailedPath extends PathDefinition
{
    public string $name = 'payment_failed';
    public string $machine = '@order';
    public string $description = 'Order where payment fails';

    public function steps(): array
    {
        return [
            Step::define()
                ->event(':checkout')                               // #[EventFactory(':checkout')]::default
                ->guard('cart is not empty', GuardExpectation::PASS)
                ->action('calculate totals')                       // #[ActionFactory('calculate totals')]::default
                ->transitionsTo('#awaiting_payment'),

            Step::define()
                ->event(':payment_received', 'declined')           // #[EventFactory(':payment_received')]::declined()
                ->guard('payment is valid', GuardExpectation::FAIL)  // Payment fails!
                ->transitionsTo('#payment_failed'),

            Step::define()
                ->event(':retry_payment', 'successful')            // #[EventFactory(':retry_payment')]::successful()
                ->guard('retry allowed', GuardExpectation::PASS)
                ->guard('payment is valid', GuardExpectation::PASS)
                ->action('capture payment')                        // #[ActionFactory('capture payment')]::default
                ->transitionsTo('#paid'),
        ];
    }
}

Step Builder API ​

php
class Step
{
    /**
     * Start building a step definition
     */
    public static function define(): self;

    /**
     * Set event with optional factory state
     * Factory discovered via #[EventFactory(':event_name')] attribute
     */
    public function event(string $event, ?string $factoryState = null): self;

    /**
     * Declare expected guard outcome
     */
    public function guard(string $pattern, GuardExpectation $expectation): self;

    /**
     * Add action with optional factory state
     * Factory discovered via #[ActionFactory('pattern')] attribute
     */
    public function action(string $pattern, ?string $factoryState = null): self;

    /**
     * Target state after this step
     */
    public function transitionsTo(string $state): self;
}

enum GuardExpectation
{
    case PASS;
    case FAIL;
}

Simulation Engine ​

How Simulation Works ​

The machine runs its normal logic - simulation just provides the data:

php
class PathSimulator
{
    private StateGraph $graph;
    private bool $mockGuards;
    private bool $skipSideEffects;

    public function simulate(PathDefinition $path, array $initialContext = []): SimulationResult
    {
        $context = $initialContext;
        $state = $this->graph->getInitialState();
        $trace = [];

        foreach ($path->steps() as $step) {
            // 1. Generate event payload from factory
            $payload = $this->createPayload($step);

            // 2. Evaluate guards
            foreach ($step->guards as $guardPattern => $expectation) {
                $result = $this->evaluateGuard($guardPattern, $context, $payload);

                if ($this->mockGuards) {
                    // Trust the path definition
                    $result = ($expectation === GuardExpectation::PASS);
                } else {
                    // Validate expectation matches reality
                    $expected = ($expectation === GuardExpectation::PASS);
                    if ($result !== $expected) {
                        throw new GuardExpectationMismatch($guardPattern, $expected, $result);
                    }
                }

                if (!$result) {
                    // Guard failed - stop here (for failure paths)
                    break;
                }
            }

            // 3. Execute actions (accumulate context)
            foreach ($step->actions as $action) {
                $actionContext = $this->getActionContext($action, $context);
                $result = $this->executeAction($action, $actionContext);
                $context = array_merge($context, $result);
            }

            // 4. Transition state
            $state = $step->targetState;

            $trace[] = new TraceEntry($step, $state, $context);
        }

        return new SimulationResult(
            finalState: $state,
            finalContext: $context,
            trace: $trace,
        );
    }

    private function createPayload(Step $step): array
    {
        // Discover factory via attribute matching
        // e.g., ':checkout' → find class with #[EventFactory(':checkout')]
        $factoryClass = $this->factoryResolver->findEventFactory($step->event);
        $factory = new $factoryClass();

        if ($step->eventFactoryState) {
            $factory = $factory->{$step->eventFactoryState}();
        }

        return $factory->create();
    }

    private function getActionContext(string $actionPattern, ?string $factoryState, array $currentContext): array
    {
        // Discover factory via attribute matching
        // e.g., 'calculate discount' → find class with #[ActionFactory('calculate discount')]
        $factoryClass = $this->factoryResolver->findActionFactory($actionPattern);

        if (!$factoryClass) {
            return $currentContext; // No factory for this action
        }

        $factory = new $factoryClass();

        if ($factoryState) {
            $factory = $factory->{$factoryState}();
        }

        return array_merge($currentContext, $factory->create());
    }
}

Side Effect Handling ​

Actions with side effects use #[SideEffect] attribute:

php
#[Action('send confirmation email')]
#[RequiresContext('customer', 'order_id')]
class SendConfirmationAction extends ActionBehavior
{
    public function __construct(
        private EmailService $emailService
    ) {}

    public function __invoke(array $context): array
    {
        $this->sendNotification($context);

        return [
            'confirmation_sent' => true,
            'sent_at' => now(),
        ];
    }

    #[SideEffect]  // Skipped during simulation
    protected function sendNotification(array $context): void
    {
        $this->emailService->sendOrderConfirmation(
            $context['customer']->email,
            $context['order_id']
        );
    }
}

During simulation, methods marked #[SideEffect] are skipped. The action still returns its context modifications.

Testing Side Effects (Laravel Fake Pattern) ​

For actual integration tests, use Laravel's Fake pattern:

php
class SendConfirmationActionTest extends EventFlowTestCase
{
    public function test_sends_email_to_customer(): void
    {
        // Arrange - use Laravel fake
        Mail::fake();

        $action = app(SendConfirmationAction::class);
        $context = $this->contextFor('send confirmation email', [
            'customer' => CustomerFactory::new()->create(),
            'order_id' => 'order-123',
        ]);

        // Act
        $result = $action($context);

        // Assert - verify in test, not in action
        Mail::assertSent(OrderConfirmationMail::class, function ($mail) {
            return $mail->hasTo('customer@example.com');
        });

        $this->assertTrue($result['confirmation_sent']);
    }
}

CLI Workflow ​

Path Discovery ​

bash
$ eventflow paths:discover @order

╔══════════════════════════════════════════════════════════════════╗
║  Discovered Paths for @order                                     ║
╠═══════╦══════════════════════════╦══════════════╦════════════════╣
║  ID   ║  Path                    ║  Target      ║  Steps         ║
╠═══════╬══════════════════════════╬══════════════╬════════════════╣
║  P1   ║  #created → #shipped     ║  #shipped    ║  4 transitions ║
║  P2   ║  #created → #shipped_exp ║  #shipped    ║  3 transitions ║
║  P3   ║  #created → #cancelled   ║  #cancelled  ║  2 transitions ║
║  P4   ║  #created → #refunded    ║  #refunded   ║  5 transitions ║
║  P5   ║  #paid → #shipped        ║  #shipped    ║  2 transitions ║
╚═══════╩══════════════════════════╩══════════════╩════════════════╝

Found 5 unique paths. Use 'eventflow path:generate P1' to create.

Path Generation ​

bash
$ eventflow path:generate P1 --name=StandardHappyPath

Generated files:
  ✓ tests/EventFlow/Paths/Order/StandardHappyPath.php
  ✓ tests/EventFlow/Factories/Events/CheckoutEventFactory.php (if not exists)
  ✓ tests/EventFlow/Factories/Events/PaymentEventFactory.php (if not exists)
  ✓ tests/EventFlow/Factories/Events/ShippingEventFactory.php (if not exists)
  ✓ tests/EventFlow/Factories/Actions/DiscountActionFactory.php (stub)
  ✓ tests/EventFlow/Factories/Actions/PaymentActionFactory.php (stub)

Next steps:
  1. Fill in factory definitions
  2. Run: eventflow path:simulate StandardHappyPath

Path Simulation ​

bash
$ eventflow path:simulate StandardHappyPath

Simulating: StandardHappyPath (@order)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Step 1: :checkout
  Event: CheckoutEventFactory (default)
  Guards:
    ✓ cart is not empty → PASS
    ✓ customer is valid → PASS
  Actions:
    ✓ calculate totals
    ✓ reserve inventory
  State: #created → #awaiting_payment
  Context: +$subtotal, +$reserved_items

Step 2: :payment_received
  Event: PaymentEventFactory (successful)
  Guards:
    ✓ payment is valid → PASS
  Actions:
    ✓ capture payment
    ✓ send confirmation [SIDE_EFFECT SKIPPED]
  State: #awaiting_payment → #paid
  Context: +$payment_id, +$paid_at

Step 3: :ship
  Event: ShippingEventFactory (standard)
  Actions:
    ✓ generate tracking
    ✓ notify customer [SIDE_EFFECT SKIPPED]
  State: #paid → #shipped
  Context: +$tracking_number, +$shipped_at

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Simulation complete
  Final state: #shipped
  Context keys: 12
  Side effects skipped: 2

Usage in Tests ​

Unit Testing with Factories ​

php
class CalculateDiscountActionTest extends EventFlowTestCase
{
    public function test_premium_discount_applied(): void
    {
        $action = new CalculateDiscountAction();

        $context = DiscountActionFactory::new()
            ->premium()
            ->create(['subtotal' => 200.00]);

        $result = $action($context);

        $this->assertEquals(20.00, $result['discount_amount']);
    }
}

Integration Testing with Path Simulation ​

php
class OrderFlowTest extends EventFlowTestCase
{
    public function test_premium_express_happy_path(): void
    {
        $path = new PremiumExpressHappyPath();
        $result = $this->simulate($path);

        $this->assertEquals('#shipped_express', $result->finalState);
        $this->assertArrayHasKey('tracking_number', $result->finalContext);
    }

    public function test_can_reach_paid_state(): void
    {
        $path = new PremiumExpressHappyPath();

        // Simulate up to specific state
        $result = $this->simulate($path, untilState: '#paid');

        $this->assertEquals('#paid', $result->finalState);
        $this->assertArrayHasKey('payment_id', $result->finalContext);
    }

    public function test_from_paid_state(): void
    {
        // Get machine in #paid state
        $machine = $this->machineAt('@order', '#paid', using: PremiumExpressHappyPath::class);

        // Now test what happens from #paid
        $result = $machine->receive(':ship', ShippingEventFactory::new()->express()->create());

        $this->assertEquals('#shipped_express', $machine->currentState());
    }
}

Testing Guard Failures ​

php
class PaymentFailureTest extends EventFlowTestCase
{
    public function test_payment_declined_transitions_to_failed(): void
    {
        $path = new PaymentFailedPath();

        // Simulate with validation (not mocking)
        $result = $this->simulate($path, validateGuards: true);

        // Path stops at payment failure
        $this->assertEquals('#payment_failed', $result->finalState);
    }
}

Directory Structure ​

tests/
└── EventFlow/
    ├── Factories/
    │   ├── Events/
    │   │   ├── CheckoutEventFactory.php
    │   │   ├── PaymentEventFactory.php
    │   │   └── ShippingEventFactory.php
    │   ├── Actions/
    │   │   ├── DiscountActionFactory.php
    │   │   ├── PaymentActionFactory.php
    │   │   └── TrackingActionFactory.php
    │   └── Domain/
    │       ├── CustomerFactory.php
    │       ├── CartFactory.php
    │       └── AddressFactory.php
    ├── Paths/
    │   └── Order/
    │       ├── StandardHappyPath.php
    │       ├── PremiumExpressHappyPath.php
    │       ├── PaymentFailedPath.php
    │       └── RefundPath.php
    └── Unit/
        ├── Guards/
        │   └── CartNotEmptyGuardTest.php
        └── Actions/
            └── CalculateDiscountActionTest.php

Integration with Existing Proposals ​

Context Dependencies Proposal ​

Factories complement #[RequiresContext]:

php
#[Action('calculate discount')]
#[RequiresContext('subtotal', 'customer_tier', '?coupon_code')]
class CalculateDiscountAction extends ActionBehavior
{
    // ...
}

// Factory provides required context automatically
#[ActionFactory('calculate discount')]
class DiscountActionFactory extends ActionFactory
{
    public function definition(): array
    {
        return [
            'subtotal' => 100.00,          // Required
            'customer_tier' => 'standard', // Required
            // coupon_code is optional - not included by default
        ];
    }
}

The contextFor() helper can use factories internally:

php
// This still works
$context = $this->contextFor('calculate discount', ['subtotal' => 500]);

// Behind the scenes: uses DiscountActionFactory if available

Data Validation Proposal ​

Event factories generate valid data by default:

php
#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory
{
    public function definition(): array
    {
        return [
            // Respects validation: "email must be valid email"
            'email' => $this->faker->safeEmail(),

            // Respects validation: "total must be greater than 0"
            'total' => $this->faker->numberBetween(1, 1000),

            // Respects validation: "items must have at least 1 item"
            'items' => [['id' => 1, 'quantity' => 1]],
        ];
    }

    /**
     * For negative testing - generate invalid data
     */
    public function invalidEmail(): static
    {
        return $this->state(['email' => 'not-an-email']);
    }
}

Implementation Components ​

Core Classes ​

ClassPurpose
EventFactoryBase class for event payload factories
ActionFactoryBase class for action context factories
DomainFactoryBase class for domain object factories
PathDefinitionAbstract class for path definitions
StepBuilder for individual path steps
PathSimulatorExecutes paths and tracks state
SimulationResultContains final state, context, and trace

Attributes ​

AttributePurpose
#[EventFactory(':event')]Mark class as event factory
#[ActionFactory('pattern')]Mark class as action factory
#[SideEffect]Mark method to skip during simulation

PHPUnit Traits ​

TraitPurpose
InteractsWithFactoriesFactory access in tests
InteractsWithPathsPath simulation in tests

Benefits Summary ​

BenefitDescription
Laravel FamiliarSame factory pattern developers know
Explicit BindingsPath definitions show which factories provide data
State VariationsFactory states enable systematic scenario testing
Path Simulation"Give me a machine in state B"
Side Effect SafetyExternal calls skipped during simulation
Integration with ProposalsComplements RequiresContext and Data Validation
CLI ToolingDiscover paths, generate factories, run simulations
No DSL ChangesPure PHP testing infrastructure

Summary ​

This proposal introduces a comprehensive testing factory system for EventFlow:

  1. Event Factories - Generate valid payloads with states
  2. Action Factories - Provide action context with states
  3. Domain Factories - Create Customer, Cart, etc.
  4. Path Definitions - Describe state machine journeys with factory bindings
  5. Guard Expectations - Declare PASS/FAIL instead of guard factories
  6. Simulation Engine - Execute paths to reach any state
  7. Side Effect Handling - #[SideEffect] attribute for simulation safety

The key insight is that guards don't need factories - they evaluate existing context. Path definitions declare expected outcomes, and the simulation either mocks or validates against reality.

This approach maintains EventFlow's philosophy: the machine runs normally, factories just provide the data it needs.

Released under the MIT License.