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(':api_checkout> from @customer')]  // API event
public function handleApiCheckout(Machine $machine, Event $event): void
{
    // Handle API event
}

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']
        );
    }
}

Testing Bindings

Test your bindings in isolation:

php
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);
    }
}

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.