PHP Binding
EventFlow integrates with PHP through attribute-based bindings. Natural language patterns in .flow files map to PHP classes.
Overview
// order.flow
? cart is not empty
send confirmation email
order moves to #paid
expect:
= $total equals 1200// 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:
#[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:
#[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:
#[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:
#[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:
#[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:
#[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.
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:
emit :register to @user with:
| field | value | validation |
| tc_kimlik | $tc_kimlik | required, string, valid turkish_id |EventFlow resolution:
required→ Built-in ✓string→ Built-in ✓valid turkish_id→ Not built-in → Search bindings → FoundValidTurkishId✓
Parameterized Rules
Use placeholders in the rule pattern to capture parameters:
#[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:
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:
on :get_order_report from @admin (api)
reply 200 with ^order_reportuse 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
// 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:
| EventFlow | PHP |
|---|---|
string | string |
number | int|float |
boolean | bool |
array | array |
object | object|array |
#[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:
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:
#[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:
| Placeholder | Matches | PHP Type |
|---|---|---|
{string} | Quoted string | string |
{number} | Number | int|float |
{word} | Single word | string |
{state} | State reference | string |
{event} | Event reference | string |
{actor} | Actor reference | string |
#[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:
#[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.
#[TestedBy(...)] - Link Binding to Tests
Declare which tests verify a binding:
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:
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)]
#[TestedBy(CheckoutIntegrationTest::class)]
class CartNotEmptyGuard { }#[Tests(...)] - Link Test to Binding
Declare which binding pattern a test covers:
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:
#[Tests('cart is not empty')]
#[Tests('cart is valid')]
class CartGuardsTest extends TestCase { }Placeholder References
During rapid TDD, use placeholders:
// 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:
$ 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
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
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// 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
# 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 --watchGenerating Diagrams
# 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