Skip to content

Guard & Action Context Dependencies

Making Isolated Testing Easier Through Explicit Context Requirements

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


Executive Summary

EventFlow guards and actions operate on context arrays, but their dependencies are implicit. When writing unit tests, developers must manually discover what context variables are needed by reading the implementation. This proposal introduces the #[RequiresContext] attribute to make context dependencies explicit, along with runtime validation and PHPUnit helpers to improve testability and developer experience.

Core Philosophy

Context requirements are documentation that executes.

The binding declares what it needs. Tests become trivial to set up.

Key Features

  1. RequiresContext Attribute - Declare context dependencies on guards, actions, and validation rules
  2. Runtime Validation - All bindings validate context before execution (production included)
  3. Custom Validation - Optional ValidatesContext interface for value validation
  4. PHPUnit Test Helper - Auto-generate minimal context for tests
  5. Type Safety - Optional type hints for context variables
  6. 100% Backward Compatible - Attribute is optional, existing code unchanged

Motivation & Problem Statement

Current Situation

Guards and actions receive array $context but don't declare dependencies:

php
#[Guard('customer is eligible for express checkout')]
class ExpressCheckoutEligibilityGuard extends GuardBehavior
{
    public function __invoke(array $context): bool
    {
        $customer = $context['customer'] ?? null;
        $tier = $context['customer_tier'] ?? null;  // Hidden dependency!
        $total = $context['total'] ?? 0;  // Another hidden dependency!
        $address = $context['shipping_address'] ?? null;
        $inventory = $context['inventory_status'] ?? null;

        if ($customer === null || $tier === null || $address === null) {
            return false;
        }

        return $customer->isPremium()
            && $tier === 'gold'
            && $total > 500
            && in_array($address->region, ['US', 'CA'])
            && $inventory === 'in_stock';
    }
}

From the flow file, you only see:

flow
? customer is eligible for express checkout
  order moves to #express_processing

Problems:

  1. Hidden dependencies - Flow file doesn't reveal all required context
  2. Test setup burden - Developers must read implementation to discover dependencies
  3. Runtime surprises - Missing context discovered at runtime with cryptic errors
  4. No IDE support - No autocomplete or type hints for context structure
  5. No validation - Typos in context keys fail silently

Example: Complex Guard with Hidden Dependencies

The flow file suggests only customer is needed, but the PHP binding needs much more:

php
// What the flow file shows
? customer is eligible for express checkout

// What the binding actually needs (hidden!)
#[Guard('customer is eligible for express checkout')]
class ExpressCheckoutEligibilityGuard {
    public function __invoke(array $context): bool {
        // Needs: customer, customer_tier, total, shipping_address, inventory_status
        // But you can't tell from the flow file!
    }
}

Testing this is painful:

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

        // Developer must manually figure out all required context!
        // Trial and error until it works...
        $context = [
            'customer' => $this->createMockCustomer(),  // How did we know this?
            'customer_tier' => 'gold',  // This too?
            'total' => 600,  // And this?
            'shipping_address' => $this->createMockAddress('US'),  // All guesswork!
            'inventory_status' => 'in_stock',  // Reading implementation...
        ];

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

Goals

  1. Explicit dependencies - Binding declares what context it needs
  2. Fail fast - Runtime validation catches missing context immediately
  3. Easy test setup - Helper generates minimal valid context
  4. Type safety - Optional types for better IDE support
  5. Self-documentation - Binding is self-explanatory

Proposed Solution

RequiresContext Attribute

Add #[RequiresContext] attribute to declare context dependencies:

php
use EventFlow\Attributes\Guard;
use EventFlow\Attributes\RequiresContext;

#[Guard('customer is eligible for express checkout')]
#[RequiresContext('customer', 'customer_tier', 'total', 'shipping_address', 'inventory_status')]
class ExpressCheckoutEligibilityGuard extends GuardBehavior
{
    public function __invoke(array $context): bool
    {
        // All required context is documented in attribute
        // Runtime validation ensures all keys exist

        $customer = $context['customer'];
        $tier = $context['customer_tier'];
        $total = $context['total'];
        $address = $context['shipping_address'];
        $inventory = $context['inventory_status'];

        return $customer->isPremium()
            && $tier === 'gold'
            && $total > 500
            && in_array($address->region, ['US', 'CA'])
            && $inventory === 'in_stock';
    }
}

With Type Information (Optional)

php
#[Guard('customer is eligible for express checkout')]
#[RequiresContext(
    'customer' => Customer::class,
    'customer_tier' => 'string',
    'total' => 'number',
    'shipping_address' => Address::class,
    'inventory_status' => 'string'
)]
class ExpressCheckoutEligibilityGuard extends GuardBehavior
{
    public function __invoke(array $context): bool
    {
        // Implementation...
    }
}

Optional Context

Use ? prefix for optional context:

php
#[RequiresContext(
    'customer',        // Required
    'cart',            // Required
    '?coupon_code',    // Optional
    '?gift_message'    // Optional
)]

Runtime Validation

ALL guard/action invocations validate context before execution (in all environments, including production):

php
namespace EventFlow\Runtime;

use EventFlow\Attributes\RequiresContext;
use EventFlow\Contracts\ValidatesContext;
use EventFlow\Exceptions\MissingContextException;
use ReflectionClass;

class BindingExecutor
{
    public function execute(object $binding, array $context): mixed
    {
        // Step 1: Validate RequiresContext (presence check)
        $this->validateRequiredContext($binding, $context);

        // Step 2: Run custom validation if binding implements ValidatesContext
        if ($binding instanceof ValidatesContext) {
            $binding->validateContext($context);
        }

        // Step 3: Execute binding
        return ($binding)($context);
    }

    private function validateRequiredContext(object $binding, array $context): void
    {
        $reflection = new ReflectionClass($binding);
        $attributes = $reflection->getAttributes(RequiresContext::class);

        if (empty($attributes)) {
            return; // No requirements - skip validation
        }

        $requirement = $attributes[0]->newInstance();
        $missing = $requirement->getMissingKeys($context);

        if (!empty($missing)) {
            throw new MissingContextException(
                "Binding " . get_class($binding) . " requires context keys: " .
                implode(', ', $missing) . ". Provided: " .
                implode(', ', array_keys($context))
            );
        }
    }
}

Exception in production:

MissingContextException: Binding App\Order\Guards\ExpressCheckoutEligibilityGuard
requires context keys: customer_tier, inventory_status.
Provided: customer, total, shipping_address

This fails fast - missing context is caught immediately, not after partial execution.


Custom Validation (Optional)

For value validation beyond presence checking, bindings can implement ValidatesContext:

php
namespace EventFlow\Contracts;

interface ValidatesContext
{
    /**
     * Validate context values (not just presence)
     *
     * @param array $context The context to validate
     * @throws InvalidContextException if validation fails
     */
    public function validateContext(array $context): void;
}

Example - Guard with Custom Validation

php
#[Guard('total is greater than 100')]
#[RequiresContext('total', 'currency')]
class TotalGreaterThan100Guard extends GuardBehavior implements ValidatesContext
{
    // Custom validation - runs AFTER RequiresContext check
    public function validateContext(array $context): void
    {
        if ($context['total'] < 0) {
            throw new InvalidContextException('Total cannot be negative');
        }

        if (!in_array($context['currency'], ['USD', 'EUR', 'TRY'])) {
            throw new InvalidContextException(
                "Invalid currency: {$context['currency']}"
            );
        }
    }

    public function __invoke(array $context): bool
    {
        // By the time we get here:
        // ✓ RequiresContext validated (total and currency exist)
        // ✓ validateContext() passed (values are valid)

        return $context['total'] > 100;
    }
}

Execution Order

1. RequiresContext check
   ↓ throws MissingContextException if keys missing
2. ValidatesContext::validateContext()
   ↓ throws InvalidContextException if values invalid
3. __invoke()
   ↓ safe to use context - all validation passed

Example - Action with Validation

php
#[Action('apply discount')]
#[RequiresContext('total', 'discount_percentage')]
class ApplyDiscountAction extends ActionBehavior implements ValidatesContext
{
    public function validateContext(array $context): void
    {
        $percentage = $context['discount_percentage'];

        if ($percentage < 0 || $percentage > 100) {
            throw new InvalidContextException(
                "Discount percentage must be between 0 and 100, got: {$percentage}"
            );
        }
    }

    public function __invoke(array $context): array
    {
        $discount = $context['total'] * ($context['discount_percentage'] / 100);

        return [
            'discount_amount' => $discount,
            'total' => $context['total'] - $discount,
        ];
    }
}

PHPUnit Helper

EventFlowTestCase Base Class

php
namespace EventFlow\Testing;

use PHPUnit\Framework\TestCase;

abstract class EventFlowTestCase extends TestCase
{
    use EventFlowTestHelpers;
}

EventFlowTestHelpers Trait

php
namespace EventFlow\Testing;

use EventFlow\Attributes\RequiresContext;
use ReflectionClass;

trait EventFlowTestHelpers
{
    /**
     * Build minimal context for a binding pattern
     *
     * @param string $pattern The EventFlow pattern (e.g., "cart is not empty")
     * @param array $overrides Additional context to merge
     * @return array
     */
    protected function contextFor(string $pattern, array $overrides = []): array
    {
        $binding = $this->findBinding($pattern);
        $requirements = $this->getRequiresContext($binding);
        $defaults = $this->generateDefaults($requirements);

        return array_merge($defaults, $overrides);
    }

    /**
     * Find binding class by pattern
     */
    private function findBinding(string $pattern): string
    {
        // Implementation: Search for binding with matching #[Guard] or #[Action] pattern
        // Returns binding class name
    }

    /**
     * Get RequiresContext attribute from binding
     */
    private function getRequiresContext(string $bindingClass): array
    {
        $reflection = new ReflectionClass($bindingClass);
        $attributes = $reflection->getAttributes(RequiresContext::class);

        if (empty($attributes)) {
            return [];
        }

        return $attributes[0]->newInstance()->getRequirements();
    }

    /**
     * Generate default values for required context
     */
    private function generateDefaults(array $requirements): array
    {
        $defaults = [];

        foreach ($requirements as $key => $type) {
            $defaults[$key] = $this->createMockValue($key, $type);
        }

        return $defaults;
    }

    private function createMockValue(string $key, mixed $type): mixed
    {
        if (is_string($type) && class_exists($type)) {
            return \Mockery::mock($type);
        }

        return match($type) {
            'string' => "mock_{$key}",
            'number', 'int', 'integer' => 42,
            'float', 'double' => 42.0,
            'boolean', 'bool' => true,
            'array' => [],
            'object' => new \stdClass(),
            default => null,
        };
    }
}

Usage in Tests

Before (Manual Setup):

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

        // Manual context creation - error prone!
        $context = [
            'customer' => $this->createMockCustomer(),
            'customer_tier' => 'gold',
            'total' => 600,
            'shipping_address' => $this->createMockAddress('US'),
            'inventory_status' => 'in_stock',
        ];

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

After (With Helper):

php
use EventFlow\Testing\EventFlowTestCase;

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

        // Helper creates all required context with mocks automatically!
        $context = $this->contextFor('customer is eligible for express checkout', [
            'customer_tier' => 'gold',
            'total' => 600,
        ]);

        // Only override what matters for THIS test
        $context['customer']->shouldReceive('isPremium')->andReturn(true);
        $context['shipping_address']->region = 'US';
        $context['inventory_status'] = 'in_stock';

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

Integration with Existing Infrastructure

Guards

php
#[Guard('cart is not empty')]
#[RequiresContext('cart')]
class CartNotEmptyGuard extends GuardBehavior
{
    public function __invoke(array $context): bool
    {
        // RequiresContext already validated that 'cart' exists
        return !empty($context['cart']?->items);
    }
}

Test:

php
#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends EventFlowTestCase
{
    public function test_empty_cart(): void
    {
        $guard = new CartNotEmptyGuard();

        // Helper creates: ['cart' => new \stdClass()]
        $context = $this->contextFor('cart is not empty', [
            'cart' => (object)['items' => []]
        ]);

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

Actions

php
#[Action('send confirmation email')]
#[RequiresContext(
    'customer' => Customer::class,
    'order_id' => 'string',
    'total' => 'number'
)]
class SendConfirmationEmailAction extends ActionBehavior
{
    public function __construct(
        private EmailService $emailService
    ) {}

    public function __invoke(array $context): array
    {
        $this->emailService->sendOrderConfirmation(
            customerId: $context['customer']->id,
            orderId: $context['order_id'],
            total: $context['total']
        );

        return [];
    }
}

Test:

php
#[Tests('send confirmation email')]
class SendConfirmationEmailActionTest extends EventFlowTestCase
{
    public function test_sends_confirmation_email(): void
    {
        $emailService = Mockery::mock(EmailService::class);
        $emailService->shouldReceive('sendOrderConfirmation')
            ->once()
            ->with(
                customerId: Mockery::any(),
                orderId: Mockery::any(),
                total: Mockery::any()
            );

        $action = new SendConfirmationEmailAction($emailService);

        // Helper auto-generates required context!
        $context = $this->contextFor('send confirmation email');

        $action->__invoke($context);
    }
}

Validation Rules

php
#[ValidationRule('unique in {table}')]
#[RequiresContext('database' => Database::class)]
class UniqueInDatabase implements Rule
{
    public function validate(mixed $value, Context $ctx, string $table): bool
    {
        // $ctx->get('database') is guaranteed to exist
        return !$ctx->get('database')->table($table)->where('value', $value)->exists();
    }
}

Complete Examples

Simple Guard

Flow File:

flow
? cart is not empty
  order moves to #processing

Binding:

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

Test:

php
#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends EventFlowTestCase
{
    public function test_returns_false_when_cart_is_empty(): void
    {
        $guard = new CartNotEmptyGuard();

        $context = $this->contextFor('cart is not empty', [
            'cart' => (object)['items' => []]
        ]);

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

    public function test_returns_true_when_cart_has_items(): void
    {
        $guard = new CartNotEmptyGuard();

        $context = $this->contextFor('cart is not empty', [
            'cart' => (object)['items' => [['id' => 1]]]
        ]);

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

Complex Action with Custom Validation

Flow File:

flow
process payment with fraud check

Binding:

php
#[Action('process payment with fraud check')]
#[RequiresContext(
    'customer' => Customer::class,
    'payment_method' => PaymentMethod::class,
    'total' => 'number',
    'currency' => 'string',
    'exchange_rate' => 'number',
    '?risk_score'  // Optional - might be pre-calculated
)]
class ProcessPaymentAction extends ActionBehavior implements ValidatesContext
{
    public function __construct(
        private PaymentGateway $gateway,
        private FraudDetector $fraudDetector
    ) {}

    public function validateContext(array $context): void
    {
        if ($context['total'] <= 0) {
            throw new InvalidContextException('Total must be greater than 0');
        }

        if ($context['exchange_rate'] <= 0) {
            throw new InvalidContextException('Exchange rate must be greater than 0');
        }
    }

    public function __invoke(array $context): array
    {
        $riskScore = $context['risk_score'] ?? $this->fraudDetector->score($context['customer']);

        if ($riskScore > 0.8) {
            return ['payment_status' => 'fraud_rejected'];
        }

        $amountInUSD = $context['total'] * $context['exchange_rate'];

        $result = $this->gateway->charge(
            $context['payment_method'],
            $amountInUSD
        );

        return [
            'payment_status' => $result->success ? 'paid' : 'failed',
            'transaction_id' => $result->transactionId,
        ];
    }
}

Test:

php
#[Tests('process payment with fraud check')]
class ProcessPaymentActionTest extends EventFlowTestCase
{
    public function test_successful_payment(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $fraudDetector = Mockery::mock(FraudDetector::class);

        $action = new ProcessPaymentAction($gateway, $fraudDetector);

        // Helper creates all required context with mocks!
        $context = $this->contextFor('process payment with fraud check', [
            'total' => 100,
            'currency' => 'EUR',
            'exchange_rate' => 1.1,
            'risk_score' => 0.2,  // Override optional to avoid fraud detector call
        ]);

        $gateway->shouldReceive('charge')
            ->once()
            ->with(Mockery::any(), 110)  // 100 * 1.1
            ->andReturn((object)['success' => true, 'transactionId' => 'txn-123']);

        $result = $action($context);

        $this->assertEquals('paid', $result['payment_status']);
        $this->assertEquals('txn-123', $result['transaction_id']);
    }

    public function test_fraud_rejection(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $fraudDetector = Mockery::mock(FraudDetector::class);

        $action = new ProcessPaymentAction($gateway, $fraudDetector);

        $context = $this->contextFor('process payment with fraud check', [
            'total' => 100,
            'currency' => 'EUR',
            'exchange_rate' => 1.1,
        ]);

        $fraudDetector->shouldReceive('score')
            ->once()
            ->andReturn(0.9);  // High risk

        $gateway->shouldNotReceive('charge');  // Payment never attempted

        $result = $action($context);

        $this->assertEquals('fraud_rejected', $result['payment_status']);
    }
}

Edge Cases & Considerations

What if RequiresContext is Wrong?

Scenario: Attribute lists customer but binding also uses customer_email

Solution: Runtime validation catches this immediately:

MissingContextException: Binding CartNotEmptyGuard requires context keys: customer_email.
Provided: customer, cart

Developer sees the error, updates #[RequiresContext] to include customer_email.

What if RequiresContext is Missing?

Scenario: No #[RequiresContext] attribute on binding

Solution: No validation - works as before (backward compatible):

php
// No attribute - old behavior
$context = [
    'customer' => $this->createMockCustomer(),
    'cart' => $this->createMockCart(),
];

This maintains 100% backward compatibility. Existing code continues to work unchanged.

What About Optional Context?

Scenario: Guard can work with or without certain context

Solution: Use ? prefix:

php
#[RequiresContext(
    'customer',        // Required
    'cart',            // Required
    '?coupon_code'     // Optional - guard handles missing case
)]

Runtime validation only checks required keys. Optional keys can be absent.


Benefits

BenefitHow
Self-documenting#[RequiresContext] shows dependencies at a glance
Easier testingcontextFor() eliminates manual context building
Type safetyOptional type hints for IDE support
Fail fastRuntime validation throws exception on missing context (production)
Custom validationValidatesContext interface for value validation
Backward compatibleAttribute is optional, old code works unchanged
Clean flow filesNo changes to .flow files

Adoption Path

Phase 1: Add to New Bindings

php
// New guards use attribute
#[Guard('total is greater than 100')]
#[RequiresContext('total')]
class TotalGreaterThan100Guard { }

// Tests use helper
class TotalGreaterThan100GuardTest extends EventFlowTestCase {
    public function test_example(): void {
        $context = $this->contextFor('total is greater than 100', ['total' => 150]);
        // ...
    }
}

Phase 2: Gradual Migration

  • Existing bindings continue to work (no attribute required)
  • Add #[RequiresContext] when touching old code
  • No breaking changes - 100% backward compatible

Phase 3 (Future): Enhanced Tooling

Possible future enhancements:

bash
# Suggest RequiresContext based on code analysis
$ eventflow binding:suggest-context "cart is not empty"

Analyzing: App\Order\Guards\CartNotEmptyGuard

Found context access:
  $context['cart']

Suggested attribute:
  #[RequiresContext('cart')]
bash
# Validate in CI pipelines
$ eventflow binding:validate --require-context

Error: 3 bindings missing #[RequiresContext]:
  - CartNotEmptyGuard
  - PaymentAuthorizedGuard
  - SendEmailAction

These are optional future enhancements - not required for the initial implementation.


Implementation Requirements

Core Components

ComponentFilePurpose
RequiresContext attributesrc/Attributes/RequiresContext.phpDeclare context dependencies
ValidatesContext interfacesrc/Contracts/ValidatesContext.phpCustom value validation
MissingContextExceptionsrc/Exceptions/MissingContextException.phpThrown when required context missing
InvalidContextExceptionsrc/Exceptions/InvalidContextException.phpThrown by custom validation
ContextValidatorsrc/Runtime/ContextValidator.phpRuntime validation logic
EventFlowTestHelperssrc/Testing/EventFlowTestHelpers.phpPHPUnit test helpers (trait)
EventFlowTestCasesrc/Testing/EventFlowTestCase.phpPHPUnit base class

Documentation Updates

FileChanges
docs/reference/php-binding.mdAdd RequiresContext and ValidatesContext documentation
docs/guide/testing.mdAdd context helpers section with examples
docs/guide/workflow/session-4b-tdd-implementation.mdUpdate TDD examples to use helpers

Comparison with Alternatives

Alternative 1: Typed Context Object

php
class ExpressCheckoutContext
{
    public Customer $customer;
    public string $customerTier;
    public float $total;
    public Address $shippingAddress;
    public string $inventoryStatus;
}

#[Guard('customer is eligible for express checkout')]
class ExpressCheckoutEligibilityGuard
{
    public function __invoke(ExpressCheckoutContext $context): bool
    {
        // Fully typed, IDE autocomplete
    }
}

Pros: Full type safety, excellent IDE autocomplete Cons: Massive boilerplate, breaks EventFlow's array $context convention, no backward compatibility

Alternative 2: Constructor Injection

php
#[Guard('cart is not empty')]
class CartNotEmptyGuard
{
    public function __construct(
        private Cart $cart  // Injected from context
    ) {}
}

Pros: Type hints, dependency injection pattern Cons: Violates EventFlow architecture (context is runtime data, not services), breaks existing bindings

Alternative 3: DocBlock Annotations

php
/**
 * @requires-context customer, cart, total
 */
#[Guard('cart is not empty')]
class CartNotEmptyGuard { }

Pros: Simple, non-invasive Cons: No runtime access, no validation, no type information, PHP attributes are the modern standard

Verdict: #[RequiresContext] attribute strikes the best balance of type safety, validation, and backward compatibility.


Summary

This proposal introduces explicit context dependencies for EventFlow guards and actions through:

  1. #[RequiresContext] attribute - Declare dependencies clearly
  2. Runtime validation - Fail fast on missing context (all environments)
  3. ValidatesContext interface - Optional custom validation
  4. PHPUnit helpers - Easy test setup with contextFor()
  5. 100% backward compatible - Attribute is optional

Key Improvements

  • Self-documenting bindings - Dependencies visible at a glance
  • Easier testing - Automatic context generation for tests
  • Fail fast - Missing context caught immediately, not after partial execution
  • Type safety - Optional type hints for IDE support
  • Clean flow files - No changes to .flow files

Next Steps

  1. Gather feedback on proposal
  2. Implement core attribute and validation
  3. Create PHPUnit helpers
  4. Update documentation with examples
  5. Add to future EventFlow release

Released under the MIT License.