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.