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
- RequiresContext Attribute - Declare context dependencies on guards, actions, and validation rules
- Runtime Validation - All bindings validate context before execution (production included)
- Custom Validation - Optional
ValidatesContextinterface for value validation - PHPUnit Test Helper - Auto-generate minimal context for tests
- Type Safety - Optional type hints for context variables
- 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:
#[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:
? customer is eligible for express checkout
order moves to #express_processingProblems:
- Hidden dependencies - Flow file doesn't reveal all required context
- Test setup burden - Developers must read implementation to discover dependencies
- Runtime surprises - Missing context discovered at runtime with cryptic errors
- No IDE support - No autocomplete or type hints for context structure
- 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:
// 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:
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
- Explicit dependencies - Binding declares what context it needs
- Fail fast - Runtime validation catches missing context immediately
- Easy test setup - Helper generates minimal valid context
- Type safety - Optional types for better IDE support
- Self-documentation - Binding is self-explanatory
Proposed Solution
RequiresContext Attribute
Add #[RequiresContext] attribute to declare context dependencies:
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)
#[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:
#[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):
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_addressThis fails fast - missing context is caught immediately, not after partial execution.
Custom Validation (Optional)
For value validation beyond presence checking, bindings can implement ValidatesContext:
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
#[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 passedExample - Action with Validation
#[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
namespace EventFlow\Testing;
use PHPUnit\Framework\TestCase;
abstract class EventFlowTestCase extends TestCase
{
use EventFlowTestHelpers;
}EventFlowTestHelpers Trait
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):
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):
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
#[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:
#[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
#[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:
#[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
#[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:
? cart is not empty
order moves to #processingBinding:
#[Guard('cart is not empty')]
#[RequiresContext('cart')]
class CartNotEmptyGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
return !empty($context['cart']?->items);
}
}Test:
#[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:
process payment with fraud checkBinding:
#[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:
#[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, cartDeveloper 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):
// 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:
#[RequiresContext(
'customer', // Required
'cart', // Required
'?coupon_code' // Optional - guard handles missing case
)]Runtime validation only checks required keys. Optional keys can be absent.
Benefits
| Benefit | How |
|---|---|
| Self-documenting | #[RequiresContext] shows dependencies at a glance |
| Easier testing | contextFor() eliminates manual context building |
| Type safety | Optional type hints for IDE support |
| Fail fast | Runtime validation throws exception on missing context (production) |
| Custom validation | ValidatesContext interface for value validation |
| Backward compatible | Attribute is optional, old code works unchanged |
| Clean flow files | No changes to .flow files |
Adoption Path
Phase 1: Add to New Bindings
// 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:
# 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')]# Validate in CI pipelines
$ eventflow binding:validate --require-context
Error: 3 bindings missing #[RequiresContext]:
- CartNotEmptyGuard
- PaymentAuthorizedGuard
- SendEmailActionThese are optional future enhancements - not required for the initial implementation.
Implementation Requirements
Core Components
| Component | File | Purpose |
|---|---|---|
| RequiresContext attribute | src/Attributes/RequiresContext.php | Declare context dependencies |
| ValidatesContext interface | src/Contracts/ValidatesContext.php | Custom value validation |
| MissingContextException | src/Exceptions/MissingContextException.php | Thrown when required context missing |
| InvalidContextException | src/Exceptions/InvalidContextException.php | Thrown by custom validation |
| ContextValidator | src/Runtime/ContextValidator.php | Runtime validation logic |
| EventFlowTestHelpers | src/Testing/EventFlowTestHelpers.php | PHPUnit test helpers (trait) |
| EventFlowTestCase | src/Testing/EventFlowTestCase.php | PHPUnit base class |
Documentation Updates
| File | Changes |
|---|---|
docs/reference/php-binding.md | Add RequiresContext and ValidatesContext documentation |
docs/guide/testing.md | Add context helpers section with examples |
docs/guide/workflow/session-4b-tdd-implementation.md | Update TDD examples to use helpers |
Comparison with Alternatives
Alternative 1: Typed Context Object
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
#[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
/**
* @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:
#[RequiresContext]attribute - Declare dependencies clearly- Runtime validation - Fail fast on missing context (all environments)
ValidatesContextinterface - Optional custom validation- PHPUnit helpers - Easy test setup with
contextFor() - 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
- Gather feedback on proposal
- Implement core attribute and validation
- Create PHPUnit helpers
- Update documentation with examples
- Add to future EventFlow release