Session 4: Implementation
A focused developer session. Alex works alone to implement all the edge cases - updating the flow, writing PHP bindings, and turning all failing tests green.
Setting the Scene
This session is different - it's just Alex working at their desk. The edge cases are documented in failing tests from Session 3, and now it's time to make them pass.
I've got 6 failing tests from the edge case discovery session. Time to update the flow and write the PHP bindings.
bash
$ eventflow test order.flow order.test.flow
@order / checkout
happy path
✓ checkout → awaiting_payment (12ms)
✓ payment_success → confirmed (15ms)
:checkout variations
✗ empty cart rejected (8ms)
✗ guest user redirected to login (7ms)
:payment_success variations
✓ confirmation email is sent (12ms)
:payment_failed variations
✗ payment failure notifies customer (6ms)
:retry_checkout variations
✗ retry after payment failure (5ms)
✗ retry limit exceeded (4ms)
✗ admin can override retry limit (4ms)
3 passing, 6 failingImplementing Guards
First, I need to add guards for login and cart validation. The `?` prefix marks a guard - it must be true for the indented actions to run.
flow
on> :checkout from @customer
? @customer is logged in
? cart is not empty
$order_id: string becomes uuid()
$created_at: string becomes now()
emit :payment_request to @payment
with $order_id, $total
order moves to #awaiting_payment
: else
emit :checkout_rejected to @customer
with reason: "Please add items to your cart before checking out"
: else
emit :login_required to @customer
with message: "Please log in to complete your purchase" The `: else` handles what happens when the guard fails. I could also use `otherwise` - they're equivalent.
bash
$ eventflow test order.flow order.test.flow
@order / checkout
:checkout variations
✓ empty cart rejected (10ms)
✓ guest user redirected to login (9ms)
...
5 passing, 4 failingThe diagram updates to show the branching logic:
Implementing Payment Failure Handler
Now I need to handle :payment_failed. This is a new event handler that increments the retry counter.
flow
on :payment_failed from @payment
$retry_count increases by 1
emit :payment_failed_notification to @customer
with $order_id
with reason: "Payment could not be processed. Please try again."
order moves to #payment_failedbash
$ eventflow test order.flow order.test.flow
:payment_failed variations
✓ payment failure notifies customer (11ms)
6 passing, 3 failingImplementing Retry Logic with OR Guards
The retry logic needs to check if retries are under the limit, OR if the user is an admin. This is where `??` comes in - the OR guard.
flow
on> :retry_checkout from @customer
? order is in #payment_failed
? $retry_count is less than 3
?? @customer is admin
emit :payment_request to @payment
with $order_id, $total
order moves to #awaiting_payment
otherwise
emit :max_retries_exceeded to @customer
with message: "Maximum payment attempts reached. Please contact support."
order moves to #cancelled Multiple `?` guards are ANDed together. But `??` provides an alternative - if ANY of the OR conditions pass, the block runs. Here: (retry_count < 3) OR (is admin). Either one lets you retry.
bash
$ eventflow test order.flow order.test.flow
@order / checkout
happy path
✓ checkout → awaiting_payment (14ms)
✓ payment_success → confirmed (16ms)
:checkout variations
✓ empty cart rejected (10ms)
✓ guest user redirected to login (9ms)
:payment_success variations
✓ confirmation email is sent (12ms)
:payment_failed variations
✓ payment failure notifies customer (11ms)
:retry_checkout variations
✓ retry after payment failure (13ms)
✓ retry limit exceeded (10ms)
✓ admin can override retry limit (11ms)
9 passing All 9 tests pass! The flow handles every edge case. Now I need to write the PHP bindings.
The Complete Diagram
Understanding Bindings
Bindings connect the natural language in the flow file to actual PHP code. Each phrase needs a class that implements its behavior.
The flow describes WHAT should happen. Bindings provide HOW it happens.
flow
// In the flow file:
on> :checkout from @customer
? @customer is logged in // → Guard class returns true/false
? cart is not empty // → Guard class returns true/false
$order_id becomes uuid() // → Action class generates the ID
emit :payment_request // → Event class structures the dataCreating Guards
Guards are classes that evaluate conditions and return boolean values.
Login Guard
The login guard checks if the customer actor is authenticated. I'll access the customer from the context.
php
<?php
declare(strict_types=1);
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Behavior\GuardBehavior;
#[Guard('@customer is logged in')]
class CustomerLoggedInGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
$customer = $context['customer'] ?? null;
if ($customer === null) {
return false;
}
return $customer->isAuthenticated();
}
}Cart Guard
php
<?php
declare(strict_types=1);
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Behavior\GuardBehavior;
#[Guard('cart is not empty')]
class CartNotEmptyGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
$cart = $context['cart'] ?? null;
if ($cart === null) {
return false;
}
return count($cart->items) > 0;
}
}Retry Limit Guard
php
<?php
declare(strict_types=1);
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Behavior\GuardBehavior;
#[Guard('$retry_count is less than 3')]
class RetryLimitGuard extends GuardBehavior
{
private const MAX_RETRIES = 3;
public function __invoke(array $context): bool
{
$retryCount = $context['retry_count'] ?? 0;
return $retryCount < self::MAX_RETRIES;
}
}Admin Override Guard
php
<?php
declare(strict_types=1);
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Behavior\GuardBehavior;
#[Guard('@customer is admin')]
class CustomerIsAdminGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
$customer = $context['customer'] ?? null;
if ($customer === null) {
return false;
}
return $customer->hasRole('admin');
}
}Payment Failed State Guard
php
<?php
declare(strict_types=1);
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Behavior\GuardBehavior;
#[Guard('order is in #payment_failed')]
class OrderInPaymentFailedGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
$currentState = $context['_state'] ?? null;
return $currentState === 'payment_failed';
}
}Creating Actions
Actions modify the context or trigger side effects.
Generate Order ID
Actions return an array of context updates. The order ID generator creates a new UUID.
php
<?php
declare(strict_types=1);
namespace App\Order\Actions;
use EventFlow\Attributes\Action;
use EventFlow\Behavior\ActionBehavior;
use Ramsey\Uuid\Uuid;
#[Action('$order_id becomes uuid()')]
class GenerateOrderIdAction extends ActionBehavior
{
public function __invoke(array $context): array
{
return [
'order_id' => Uuid::uuid4()->toString(),
];
}
}Generate Timestamp
php
<?php
declare(strict_types=1);
namespace App\Order\Actions;
use EventFlow\Attributes\Action;
use EventFlow\Behavior\ActionBehavior;
use Carbon\Carbon;
#[Action('$created_at becomes now()')]
class GenerateCreatedAtAction extends ActionBehavior
{
public function __invoke(array $context): array
{
return [
'created_at' => Carbon::now()->toIso8601String(),
];
}
}Increment Retry Count
php
<?php
declare(strict_types=1);
namespace App\Order\Actions;
use EventFlow\Attributes\Action;
use EventFlow\Behavior\ActionBehavior;
#[Action('$retry_count increases by 1')]
class IncrementRetryCountAction extends ActionBehavior
{
public function __invoke(array $context): array
{
$currentCount = $context['retry_count'] ?? 0;
return [
'retry_count' => $currentCount + 1,
];
}
}Send Confirmation Email
Side-effect actions don't modify context but trigger external systems. They return an empty array.
php
<?php
declare(strict_types=1);
namespace App\Order\Actions;
use EventFlow\Attributes\Action;
use EventFlow\Behavior\ActionBehavior;
use App\Services\EmailService;
#[Action('send confirmation email')]
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 []; // No context changes
}
}Creating Events
Events define the structure of data passed between actors.
Payment Request Event
Events are value objects that structure the data. The flow's `with` clause maps to constructor parameters.
php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':payment_request')]
class PaymentRequestEvent extends EventBehavior
{
public function __construct(
public readonly string $orderId,
public readonly float $total,
) {}
}Order Confirmed Event
php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':order_confirmed')]
class OrderConfirmedEvent extends EventBehavior
{
public function __construct(
public readonly string $orderId,
) {}
}Rejection Events
php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':checkout_rejected')]
class CheckoutRejectedEvent extends EventBehavior
{
public function __construct(
public readonly string $reason,
) {}
}php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':login_required')]
class LoginRequiredEvent extends EventBehavior
{
public function __construct(
public readonly string $message,
) {}
}Payment Failure Events
php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':payment_failed_notification')]
class PaymentFailedNotificationEvent extends EventBehavior
{
public function __construct(
public readonly string $orderId,
public readonly string $reason,
) {}
}php
<?php
declare(strict_types=1);
namespace App\Order\Events;
use EventFlow\Attributes\Event;
use EventFlow\Behavior\EventBehavior;
#[Event(':max_retries_exceeded')]
class MaxRetriesExceededEvent extends EventBehavior
{
public function __construct(
public readonly string $message,
) {}
}Registering the Machine
All bindings are registered with the machine definition. EventFlow auto-discovers them via attributes, but explicit registration is clearer.
php
<?php
declare(strict_types=1);
namespace App\Order;
use EventFlow\Definition\MachineDefinition;
use App\Order\Guards\CustomerLoggedInGuard;
use App\Order\Guards\CartNotEmptyGuard;
use App\Order\Guards\RetryLimitGuard;
use App\Order\Guards\CustomerIsAdminGuard;
use App\Order\Guards\OrderInPaymentFailedGuard;
use App\Order\Actions\GenerateOrderIdAction;
use App\Order\Actions\GenerateCreatedAtAction;
use App\Order\Actions\IncrementRetryCountAction;
use App\Order\Actions\SendConfirmationEmailAction;
use App\Order\Events\PaymentRequestEvent;
use App\Order\Events\OrderConfirmedEvent;
use App\Order\Events\CheckoutRejectedEvent;
use App\Order\Events\LoginRequiredEvent;
use App\Order\Events\PaymentFailedNotificationEvent;
use App\Order\Events\MaxRetriesExceededEvent;
class OrderMachine
{
public static function definition(): MachineDefinition
{
return MachineDefinition::define(
config: 'order.flow',
behavior: [
'guards' => [
CustomerLoggedInGuard::class,
CartNotEmptyGuard::class,
RetryLimitGuard::class,
CustomerIsAdminGuard::class,
OrderInPaymentFailedGuard::class,
],
'actions' => [
GenerateOrderIdAction::class,
GenerateCreatedAtAction::class,
IncrementRetryCountAction::class,
SendConfirmationEmailAction::class,
],
'events' => [
PaymentRequestEvent::class,
OrderConfirmedEvent::class,
CheckoutRejectedEvent::class,
LoginRequiredEvent::class,
PaymentFailedNotificationEvent::class,
MaxRetriesExceededEvent::class,
],
]
);
}
}Validating Bindings
EventFlow validates that every phrase in the flow has a corresponding binding.
bash
$ eventflow validate order.flow --bindings
Validating bindings for @order...
Guards:
✓ '@customer is logged in' → CustomerLoggedInGuard
✓ 'cart is not empty' → CartNotEmptyGuard
✓ '$retry_count is less than 3' → RetryLimitGuard
✓ '@customer is admin' → CustomerIsAdminGuard
✓ 'order is in #payment_failed' → OrderInPaymentFailedGuard
Actions:
✓ '$order_id becomes uuid()' → GenerateOrderIdAction
✓ '$created_at becomes now()' → GenerateCreatedAtAction
✓ '$retry_count increases by 1' → IncrementRetryCountAction
✓ 'send confirmation email' → SendConfirmationEmailAction
Events:
✓ ':payment_request' → PaymentRequestEvent
✓ ':order_confirmed' → OrderConfirmedEvent
✓ ':checkout_rejected' → CheckoutRejectedEvent
✓ ':login_required' → LoginRequiredEvent
✓ ':payment_failed_notification' → PaymentFailedNotificationEvent
✓ ':max_retries_exceeded' → MaxRetriesExceededEvent
All 15 bindings validated ✓Final Test Run
Now I'll run the full test suite with real bindings to ensure everything works.
bash
$ eventflow test order.flow order.test.flow --with-bindings
Loading bindings... 15 found
@order / checkout
happy path
✓ checkout → awaiting_payment (18ms)
✓ payment_success → confirmed (21ms)
:checkout variations
✓ empty cart rejected (12ms)
✓ guest user redirected to login (10ms)
:payment_success variations
✓ confirmation email is sent (14ms)
:payment_failed variations
✓ payment failure notifies customer (11ms)
:retry_checkout variations
✓ retry after payment failure (16ms)
✓ retry limit exceeded (13ms)
✓ admin can override retry limit (12ms)
9 passing All 9 tests pass with real bindings. Ready for review with the team.
Session Outcome
What We Built
Flow Updates:
- Login validation guard (
? @customer is logged in) - Cart validation guard (
? cart is not empty) - Payment failure handler with retry count
- Retry logic with OR guard (
?? @customer is admin) - Max retries exceeded → cancelled state
PHP Bindings:
| Type | Count | Purpose |
|---|---|---|
| Guards | 5 | Evaluate conditions (login, cart, retry limit, admin, state) |
| Actions | 4 | Modify context or trigger side effects |
| Events | 6 | Structure data passed between actors |
| Total | 15 | Complete binding coverage |
Guard Logic Summary
| Pattern | Meaning |
|---|---|
? A | If A is true |
? A ? B | If A AND B are true |
? A ?? B | If A OR B is true |
? A ? B ?? C | If (A AND B) OR C is true |
: else / otherwise | If none of the above |
Binding Pattern Summary
Flow Phrase → PHP Class
─────────────────────────────────────────────────────
? @customer is logged in → CustomerLoggedInGuard
? cart is not empty → CartNotEmptyGuard
? $retry_count is less than 3 → RetryLimitGuard
?? @customer is admin → CustomerIsAdminGuard
? order is in #payment_failed → OrderInPaymentFailedGuard
$order_id becomes uuid() → GenerateOrderIdAction
$created_at becomes now() → GenerateCreatedAtAction
$retry_count increases by 1 → IncrementRetryCountAction
send confirmation email → SendConfirmationEmailAction
:payment_request → PaymentRequestEvent
:order_confirmed → OrderConfirmedEvent
:checkout_rejected → CheckoutRejectedEvent
:login_required → LoginRequiredEvent
:payment_failed_notification → PaymentFailedNotificationEvent
:max_retries_exceeded → MaxRetriesExceededEventThe Final Flow
flow
// order.flow v3
machine: @order
scenario: checkout
given:
@customer is logged in
cart has items
$total: number is calculated
$retry_count: number is 0
on> :checkout from @customer
? @customer is logged in
? cart is not empty
$order_id: string becomes uuid()
$created_at: string becomes now()
emit :payment_request to @payment
with $order_id, $total
order moves to #awaiting_payment
: else
emit :checkout_rejected to @customer
with reason: "Please add items to your cart before checking out"
: else
emit :login_required to @customer
with message: "Please log in to complete your purchase"
expect:
= order is in #awaiting_payment
on :payment_success from @payment
send confirmation email
emit :order_confirmed to @customer
with $order_id
order moves to #confirmed
on :payment_failed from @payment
$retry_count increases by 1
emit :payment_failed_notification to @customer
with $order_id
with reason: "Payment could not be processed. Please try again."
order moves to #payment_failed
on> :retry_checkout from @customer
? order is in #payment_failed
? $retry_count is less than 3
?? @customer is admin
emit :payment_request to @payment
with $order_id, $total
order moves to #awaiting_payment
otherwise
emit :max_retries_exceeded to @customer
with message: "Maximum payment attempts reached. Please contact support."
order moves to #cancelled
expect:
= order is in #confirmed
= @customer received :order_confirmed