Session 4: Implementation
A focused developer session. Alex works alone to add states to the flow, implement guards, write PHP bindings, and turn 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.
But first, Alex needs to add states based on the wait points identified in Sessions 1-2.
Step 1: Adding States to the Flow
Before implementing guards and bindings, Alex identifies the states based on wait points in the lane diagram.
Wait Point Analysis
Looking at the lane diagram from Session 2:
| After This Event | Waiting For | State |
|---|---|---|
| Before any event | :checkout from @customer | #idle (implicit) |
:payment_request sent | :payment_success or :payment_failed | #awaiting_payment |
:payment_success received | (terminal - success) | #confirmed |
:payment_failed received | :retry_checkout or max retries | #payment_failed |
| Max retries exceeded | (terminal - failure) | #cancelled |
State Diagram
Step 2: Designing API Responses
Before implementing guards and bindings, Alex also adds API responses to the flow. Response design is a developer concern - it happens in Session 4, not during discovery.
on :checkout from @customer (api)
? @customer is logged in
? cart is not empty
$order_id: string becomes uuid()
order moves to #awaiting_payment
emit :payment_request to @payment
reply 201 created with:
| id | $order_id |
| status | current_state |
| total | $total |
otherwise
reply 400 bad request with:
| error | "CART_EMPTY" |
| message | "Cannot checkout empty cart" |
otherwise
reply 401 unauthorized with:
| error | "LOGIN_REQUIRED" |
| message | "Please log in to checkout" |Note: Lane diagrams focus on event communication between actors, not HTTP responses. Response design is a technical detail that doesn't change the business flow.
See Machine Responses for complete response syntax including async responses and binding transformers.
Step 3: Running the Tests
$ 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
on :checkout from @customer (api)
? @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"$ 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
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$ eventflow test order.flow order.test.flow
:payment_failed variations
✓ payment failure notifies customer (11ms)
6 passing, 3 failingImplementing Retry Logic with OR Guards
on :retry_checkout from @customer (api)
? 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$ 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 passingThe Complete Diagram with Derived States
The "States" column in this diagram shows states that were derived from wait points in the lane diagram, not arbitrarily chosen. Each state corresponds to a specific moment where the system pauses for external input or reaches a terminal condition.
Understanding Bindings
The flow describes WHAT should happen. Bindings provide HOW it happens.
// In the flow file:
on :checkout from @customer (api)
? @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
<?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
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
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
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
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
<?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
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
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
<?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
<?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
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
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
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
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
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
<?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 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
$ 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 passingSession 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
// 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 (api)
? @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 (api)
? 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_confirmedImplementation Approaches
At this point, Alex has derived states and has a complete flow with 6 failing test scenarios. The next step is implementing the PHP bindings. There are two approaches:
Direct Implementation
Implement all bindings at once - guards, actions, and events in a single focused session.
- Fast development cycle
- All code written upfront
- Good for experienced teams
TDD Implementation
Use the BDD/TDD Double Loop - write unit tests for each binding before implementation.
- Rigorous Red-Green-Refactor cycle
- Unit test coverage for bindings
- Iterative progress tracking
The Double Loop
EventFlow naturally supports a BDD/TDD Double Loop workflow:
- Outer Loop (BDD): The flow file is the executable specification - it defines WHAT the system should do
- Inner Loop (TDD): PHP bindings implement HOW - each binding can follow Red-Green-Refactor
Both approaches end with all 9 tests passing. Choose based on your team's preferences and project requirements.