Skip to content

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 failing

Implementing 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 failing

The diagram updates to show the branching logic:

@customer@order@payment:checkout? @customer is logged in:login_required? cart is not empty:checkout_rejectedgenerate $order_id:payment_request(processes):payment_success

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_failed
bash
$ eventflow test order.flow order.test.flow

  :payment_failed variations
 payment failure notifies customer (11ms)

6 passing, 3 failing

Implementing 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

@customer@order@paymentStates#idle:checkoutvalidate login → validate cart:login_required:checkout_rejected:payment_request#awaiting_payment(processes):payment_successsend confirmation email:order_confirmed#confirmed:payment_failed$retry_count increases by 1:payment_failed_notification#payment_failed:retry_checkout? retry_count < 3 ?? is_adminretry#cancelled

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 data

Creating 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:

TypeCountPurpose
Guards5Evaluate conditions (login, cart, retry limit, admin, state)
Actions4Modify context or trigger side effects
Events6Structure data passed between actors
Total15Complete binding coverage

Guard Logic Summary

PatternMeaning
? AIf A is true
? A ? BIf A AND B are true
? A ?? BIf A OR B is true
? A ? B ?? CIf (A AND B) OR C is true
: else / otherwiseIf 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          →  MaxRetriesExceededEvent

The 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

Released under the MIT License.