Skip to content

PHP Binding

EventFlow integrates with PHP through attribute-based bindings. Natural language patterns in .flow files map to PHP classes.

Overview

flow
// order.flow
? cart is not empty
  send confirmation email
  order moves to #paid

expect:
  = $total equals 1200
php
// PHP bindings
#[Guard('cart is not empty')]
class CartNotEmpty { ... }

#[Action('send confirmation email')]
class SendConfirmationEmail { ... }

#[Assert('$total equals {number}')]
class TotalEquals { ... }

Attribute Reference

#[Given(...)] - Test Setup

Maps given: block statements to PHP:

php
#[Given('@customer is logged in')]
public function customerLoggedIn(Context $ctx): void
{
    $customer = Customer::factory()->create();
    $ctx->set('customer', $customer);
    Auth::login($customer);
}

#[Given('@customer is logged in as {string}')]
public function customerLoggedInAs(Context $ctx, string $email): void
{
    $customer = Customer::factory()->create(['email' => $email]);
    $ctx->set('customer', $customer);
    Auth::login($customer);
}

#[Given('cart contains')]
public function cartContains(Context $ctx, array $table): void
{
    $cart = Cart::factory()->create();
    foreach ($table as $row) {
        $product = Product::findByName($row['product']);
        $cart->add($product, $row['quantity'] ?? 1, $row['price']);
    }
    $ctx->set('cart', $cart);
}

#[Given('order is in {state}')]
public function orderInState(Context $ctx, string $state): void
{
    $order = Order::factory()->create([
        'customer_id' => $ctx->get('customer')->id,
        'state' => $state,
    ]);
    $ctx->set('order', $order);
}

#[Guard(...)] - Conditions

Maps ? prefixed lines to PHP:

php
#[Guard('cart is not empty')]
class CartIsNotEmpty implements Guard
{
    public function check(Context $ctx): bool
    {
        return $ctx->get('cart')->isNotEmpty();
    }
}

#[Guard('payment is authorized')]
class PaymentIsAuthorized implements Guard
{
    public function check(Context $ctx): bool
    {
        return $ctx->get('payment.authorized', false);
    }
}

#[Guard('$total is greater than {number}')]
class TotalGreaterThan implements Guard
{
    public function check(Context $ctx, float $amount): bool
    {
        return $ctx->get('total') > $amount;
    }
}

#[Action(...)] - Side Effects

Maps action lines (no prefix) to PHP:

php
#[Action('send confirmation email')]
class SendConfirmationEmail implements Action
{
    public function __construct(
        private MailService $mail,
        private TemplateEngine $templates
    ) {}

    public function execute(Context $ctx): void
    {
        $this->mail->send(
            to: $ctx->get('customer.email'),
            body: $this->templates->render('confirmation', [
                'order' => $ctx->get('order')
            ])
        );
    }
}

#[Action('process the payment')]
class ProcessPayment implements Action
{
    public function __construct(private PaymentGateway $gateway) {}

    public function execute(Context $ctx): void
    {
        $result = $this->gateway->charge(
            amount: $ctx->get('total'),
            card: $ctx->get('payment_method')
        );
        $ctx->set('payment_result', $result);
    }
}

#[Assert(...)] - Verifications

Maps = prefixed lines to PHP:

php
#[Assert('$total equals {number}')]
public function assertTotal(Context $ctx, float $expected): void
{
    expect($ctx->get('total'))->toBe($expected);
}

#[Assert('order is in {state}')]
public function assertOrderState(Context $ctx, string $state): void
{
    expect($ctx->get('order')->state)->toBe($state);
}

#[Assert('confirmation was sent')]
public function assertConfirmationSent(): void
{
    SendConfirmationEmail::assertInvoked();
}

#[Assert('@customer received {event}')]
public function assertCustomerReceived(Context $ctx, string $event): void
{
    $customer = $ctx->get('customer');
    expect($customer->receivedEvents)->toContain($event);
}

#[Step(...)] - General Patterns

For patterns that don't fit other categories:

php
#[Step('@customer adds {string} to cart')]
public function addToCart(Context $ctx, string $product): void
{
    $item = Product::findByName($product);
    $ctx->get('cart')->add($item);
}

#[Step('@customer clicks {string}')]
public function customerClicks(Context $ctx, string $button): void
{
    // Handle UI action
}

#[On(...)] - Event Handlers

For explicit event handling:

php
#[On(':checkout from @customer')]
public function handleCheckout(Machine $machine, Event $event): void
{
    $machine->emit(':payment_request', [
        'order_id' => $event->data['order_id'],
        'amount' => $event->data['total'],
    ])->to('@payment');
}

#[On(':checkout from @customer (api)')]  // API event - external endpoint
public function handleApiCheckout(Machine $machine, Event $event): void
{
    // Handle API event
}

#[ValidationRule(...)] - Custom Validation Rules

Creates custom validation rules that can be used in with: tables. When EventFlow encounters an unknown rule, it searches for a matching PHP binding.

php
use EventFlow\Attributes\ValidationRule;
use EventFlow\Validation\Rule;
use EventFlow\Validation\Context;

#[ValidationRule('valid turkish_id')]
class ValidTurkishId implements Rule
{
    public function validate(mixed $value, Context $ctx): bool
    {
        if (!is_string($value) || strlen($value) !== 11) {
            return false;
        }

        return $this->validateChecksum($value);
    }

    public function message(): string
    {
        return 'The :field must be a valid Turkish ID number';
    }

    private function validateChecksum(string $id): bool
    {
        // Implementation...
        return true;
    }
}

Usage in EventFlow:

flow
emit :register to @user with:
  | field     | value      | validation                          |
  | tc_kimlik | $tc_kimlik | required, string, valid turkish_id  |

EventFlow resolution:

  1. required → Built-in ✓
  2. string → Built-in ✓
  3. valid turkish_id → Not built-in → Search bindings → Found ValidTurkishId

Parameterized Rules

Use placeholders in the rule pattern to capture parameters:

php
#[ValidationRule('unique in {table}')]
class UniqueInDatabase implements Rule
{
    public function __construct(private Database $db) {}

    public function validate(mixed $value, Context $ctx, string $table): bool
    {
        return !$this->db->table($table)->where('value', $value)->exists();
    }

    public function message(): string
    {
        return 'The :field must be unique';
    }
}

Usage:

flow
emit :register to @user with:
  | field | value  | validation                                |
  | email | $email | bail, required, string, unique in "users" |

The bail rule is useful before expensive rules like unique in "users" to prevent unnecessary database queries when earlier validations fail.

See Data Validation for complete validation documentation.

#[ResponseTransformer(...)] - Binding Transformers

Maps ^transformer references in reply statements to PHP classes for complex response transformations:

flow
on :get_order_report from @admin (api)
  reply 200 with ^order_report
php
use EventFlow\Attributes\ResponseTransformer;
use EventFlow\Context;

#[ResponseTransformer('order_report')]
class OrderReportTransformer
{
    public function __construct(
        private ReportBuilder $reportBuilder,
        private ChartGenerator $chartGenerator
    ) {}

    public function transform(Context $context): array
    {
        $order = $context->get('order');

        return [
            'summary' => $this->reportBuilder->buildSummary($order),
            'charts' => $this->chartGenerator->generate($order),
            'metadata' => [
                'generated_at' => now()->toIso8601String(),
                'export_formats' => ['pdf', 'csv', 'xlsx'],
            ],
        ];
    }
}

Use binding transformers when:

  • Response requires complex business logic
  • Data aggregation from multiple sources
  • Dynamic calculations or formatting
  • Response structure cannot be expressed in EventFlow tables

See Machine Responses - Binding Transformers for usage details.

Configuration

php
// config/eventflow.php
return [
    'bindings' => [
        'App\\EventFlow\\Given',
        'App\\EventFlow\\Steps',
        'App\\EventFlow\\Actions',
        'App\\EventFlow\\Guards',
        'App\\EventFlow\\Assertions',
    ],
    'machines_path' => resource_path('flows'),
    'cache' => [
        'enabled' => env('EVENTFLOW_CACHE', true),
        'path' => storage_path('framework/eventflow'),
    ],
];

Type Handling

EventFlow types map to PHP types:

EventFlowPHP
stringstring
numberint|float
booleanbool
arrayarray
objectobject|array
php
#[Step('$total: number becomes {number}')]
public function setTotal(Context $ctx, float $value): void
{
    $ctx->set('total', $value);
}

#[Step('$items: array adds {string}')]
public function addItem(Context $ctx, string $item): void
{
    $items = $ctx->get('items', []);
    $items[] = $item;
    $ctx->set('items', $items);
}

Context Access

The Context object provides access to machine state:

php
public function example(Context $ctx): void
{
    // Read
    $cart = $ctx->get('cart');

    // Read with default
    $qty = $ctx->get('quantity', 1);

    // Read with type
    $total = $ctx->get('total', 0, 'number');

    // Write
    $ctx->set('lastAdded', $product);

    // Check existence
    if ($ctx->has('discount')) {
        // apply
    }

    // Remove
    $ctx->forget('temporary');

    // Get all
    $all = $ctx->all();
}

Dependency Injection

All binding classes support constructor injection via Laravel's container:

php
#[Action('send confirmation email')]
class SendConfirmationEmail implements Action
{
    public function __construct(
        private MailService $mail,
        private TemplateEngine $templates,
        private LoggerInterface $logger
    ) {}

    public function execute(Context $ctx): void
    {
        $this->logger->info('Sending confirmation email');

        $this->mail->send(
            to: $ctx->get('customer.email'),
            body: $this->templates->render('confirmation', [
                'order' => $ctx->get('order')
            ])
        );
    }
}

Pattern Placeholders

Use placeholders to capture values:

PlaceholderMatchesPHP Type
{string}Quoted stringstring
{number}Numberint|float
{word}Single wordstring
{state}State referencestring
{event}Event referencestring
{actor}Actor referencestring
php
#[Guard('$total is greater than {number}')]
public function totalGreaterThan(Context $ctx, float $amount): bool
{
    return $ctx->get('total') > $amount;
}

#[Action('notify {actor} about {event}')]
public function notifyActor(Context $ctx, string $actor, string $event): void
{
    // $actor = "@customer", $event = ":order_confirmed"
}

Table Data

Handle table data from given: or with: blocks:

php
#[Given('cart contains')]
public function cartContains(Context $ctx, array $table): void
{
    // $table = [
    //     ['product' => 'Laptop', 'price' => '1200', 'quantity' => '1'],
    //     ['product' => 'Mouse', 'price' => '25', 'quantity' => '2'],
    // ]

    foreach ($table as $row) {
        $product = Product::findByName($row['product']);
        $ctx->get('cart')->add(
            $product,
            (int) ($row['quantity'] ?? 1),
            (float) $row['price']
        );
    }
}

Test Linking Attributes

EventFlow supports bidirectional test linking between bindings and their tests.

Declare which tests verify a binding:

php
use EventFlow\Attributes\Guard;
use EventFlow\Attributes\TestedBy;
use Tests\Order\Guards\CartNotEmptyGuardTest;

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

Multiple tests can be linked:

php
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)]
#[TestedBy(CheckoutIntegrationTest::class)]
class CartNotEmptyGuard { }

Declare which binding pattern a test covers:

php
use EventFlow\Testing\Attributes\Tests;

#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends TestCase
{
    public function test_returns_false_when_cart_is_empty(): void
    {
        $guard = new CartNotEmptyGuard();
        $this->assertFalse(($guard)(['cart' => null]));
    }
}

Multiple patterns can be tested:

php
#[Tests('cart is not empty')]
#[Tests('cart is valid')]
class CartGuardsTest extends TestCase { }

Placeholder References

During rapid TDD, use placeholders:

php
// Binding
#[Guard('cart is not empty')]
#[TestedBy('@cart-guard')]  // Placeholder
class CartNotEmptyGuard { }

// Test
#[Tests('@cart-guard')]  // Same placeholder
class CartNotEmptyGuardTest { }

Resolve with: eventflow links:pair

For more details, see Test Linking Guide.


Testing Bindings

Test your bindings in isolation using eventflow test:binding:

bash
$ eventflow test:binding "cart is not empty"

Testing binding: 'cart is not empty'
  Binding: App\Order\Guards\CartNotEmptyGuard

 returns false when cart is null (2ms)
 returns false when cart is empty (1ms)
 returns true when cart has items (1ms)

3 passing (4ms)

Example Test Class

php
<?php

namespace Tests\Order\Guards;

use PHPUnit\Framework\TestCase;
use App\Order\Guards\CartNotEmptyGuard;
use EventFlow\Testing\Attributes\Tests;

#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends TestCase
{
    private CartNotEmptyGuard $guard;

    protected function setUp(): void
    {
        $this->guard = new CartNotEmptyGuard();
    }

    public function test_returns_false_when_cart_is_null(): void
    {
        $context = ['cart' => null];
        $this->assertFalse(($this->guard)($context));
    }

    public function test_returns_false_when_cart_is_empty(): void
    {
        $cart = new \stdClass();
        $cart->items = [];
        $context = ['cart' => $cart];
        $this->assertFalse(($this->guard)($context));
    }

    public function test_returns_true_when_cart_has_items(): void
    {
        $cart = new \stdClass();
        $cart->items = [['id' => 1]];
        $context = ['cart' => $cart];
        $this->assertTrue(($this->guard)($context));
    }
}

Testing Actions with Side Effects

php
use Mockery;
use EventFlow\Testing\Attributes\Tests;

#[Tests('send confirmation email')]
class SendConfirmationEmailTest extends TestCase
{
    public function test_sends_email_to_customer(): void
    {
        $mail = Mockery::mock(MailService::class);
        $mail->shouldReceive('send')->once();

        $action = new SendConfirmationEmail($mail, new TemplateEngine());

        $ctx = new Context([
            'customer' => ['email' => '[email protected]'],
            'order' => ['id' => 'ORD-123'],
        ]);

        $action->execute($ctx);
    }

    protected function tearDown(): void
    {
        Mockery::close();
    }
}

Auto-Discovery

Bindings are auto-discovered from configured namespaces:

app/
  EventFlow/
    Given/
      CustomerSetup.php
      CartSetup.php
    Guards/
      CartGuards.php
      PaymentGuards.php
    Actions/
      EmailActions.php
      PaymentActions.php
    Assertions/
      OrderAssertions.php
php
// app/EventFlow/Guards/CartGuards.php
namespace App\EventFlow\Guards;

use EventFlow\Attributes\Guard;
use EventFlow\Context;

class CartGuards
{
    #[Guard('cart is not empty')]
    public function cartNotEmpty(Context $ctx): bool
    {
        return $ctx->get('cart')->isNotEmpty();
    }

    #[Guard('cart is valid')]
    public function cartValid(Context $ctx): bool
    {
        return $ctx->get('cart')->validate();
    }
}

Running Tests

bash
# Run EventFlow scenarios
php artisan eventflow:test order.flow

# Run specific scenario
php artisan eventflow:test order.flow --scenario="complete purchase"

# Run all flows
php artisan eventflow:test

# Watch mode
php artisan eventflow:test --watch

Generating Diagrams

bash
# Generate diagram
php artisan eventflow:diagram order.flow

# Specify type
php artisan eventflow:diagram order.flow --type=lane
php artisan eventflow:diagram order.flow --type=state
php artisan eventflow:diagram order.flow --type=combined

# Output format
php artisan eventflow:diagram order.flow --format=svg
php artisan eventflow:diagram order.flow --format=png
php artisan eventflow:diagram order.flow --format=html

Released under the MIT License.