Machine Responses
EventFlow machines communicate through events - but API callers need structured responses. The reply keyword bridges this gap, allowing machines to return data to API callers while maintaining the event-driven architecture internally.
Events vs Responses
Internal Communication (between machines):
emit :payment_request to @payment → async, fire-and-forget
on :payment_success from @payment → handles response as event
External Communication (API caller):
on :checkout from @customer (api) → receives API request
reply 201 with: ... → returns structured responseMachines talk to each other through events. API callers get responses.
Basic Reply Syntax
Inline Response
Every API event handler can return a structured response:
on :checkout from @customer (api)
$order_id becomes uuid()
order moves to #awaiting_payment
reply 201 with:
| id | $order_id |
| status | current_state |
| total | $total |HTTP Status Codes
The status code is required:
| Status | Meaning | When to Use |
|---|---|---|
200 | OK | Successful read/update |
201 | Created | New resource created |
202 | Accepted | Async operation started |
400 | Bad Request | Validation error |
401 | Unauthorized | Authentication required |
404 | Not Found | Resource doesn't exist |
409 | Conflict | State conflict |
500 | Internal Error | Unexpected failure |
Human-Readable Status Codes
For better readability, include the status meaning:
// Both are valid
reply 400 with:
| error | "CART_EMPTY" |
reply 400 bad request with:
| error | "CART_EMPTY" |When using the descriptive form:
reply 201 created with:
| id | $order_id |
reply 202 accepted with:
| job_id | $job_id |
reply 404 not found with:
| error | "ORDER_NOT_FOUND" |Note: Lane diagram generators automatically display the human-readable form (e.g.,
400 Bad Request) regardless of how it's written in the flow file.
Response Features
Field Mapping
Map context variables to response fields:
reply 200 with:
| order_id | $order_id |
| total | $total |
| state | current_state|Rename fields for API conventions:
reply 200 with:
| orderId | $order_id | // camelCase for JavaScript clients
| orderTotal | $total |
| orderStatus | current_state |Computed Fields
Use expressions to compute values:
reply 200 with:
| id | $order_id |
| display_total | "$" + format($total) |
| item_count | count($items) |
| tax_amount | $total * 0.1 |
| is_large | $total > 1000 |Conditional Fields
Fields that appear only when a condition is met:
reply 200 with:
| id | $order_id | |
| total | $total | |
| discount | $discount | when $discount > 0 |
| tracking_no | $tracking_no | when order is in #shipped |Nested Objects (Dot Notation)
Use dot notation for nested structures:
reply 200 with:
| id | $order_id |
| customer.name | $customer_name |
| customer.email | $customer_email |
| shipping.address | $ship_address |
| shipping.method | $ship_method |Output:
{
"id": "order-123",
"customer": {
"name": "John Doe",
"email": "john@example.com"
},
"shipping": {
"address": "123 Main St",
"method": "express"
}
}Array Notation
Use bracket notation for arrays:
reply 200 with:
| id | $order_id |
| items[0].name | $first_item_name |
| items[0].price | $first_item_price |
| items[1].name | $second_item_name |
| items[1].price | $second_item_price |For dynamic arrays, use the context variable directly:
reply 200 with:
| id | $order_id |
| items | $items |JSON:API Style
EventFlow supports JSON:API specification style responses:
reply 200 with:
| data.type | "orders" |
| data.id | $order_id |
| data.attributes.total | $total |
| data.attributes.state | current_state |
| data.relationships.customer.data.type | "customers" |
| data.relationships.customer.data.id | $customer_id |Named Responses
For responses used in multiple places, define them at machine level:
Defining a Named Response
machine: @order
response: order_created
| field | from |
| id | $order_id |
| status | current_state |
| total | $total |
| created_at | $created_at |
response: order_error
| field | from |
| error | $error_code |
| message | $error_message |Using Named Responses
on :checkout from @customer (api)
? cart is valid
$order_id becomes uuid()
order moves to #awaiting_payment
reply 201 with order_created
otherwise
$error_code becomes "INVALID_CART"
$error_message becomes "Cart validation failed"
reply 400 with order_error
on :get_order from @customer (api)
? order exists
reply 200 with order_created // Reuse same response
otherwise
$error_code becomes "NOT_FOUND"
$error_message becomes "Order not found"
reply 404 with order_errorBinding Transformers
For complex response transformations, use binding transformers with the ^ prefix:
on :get_order_report from @admin (api)
reply 200 with ^order_reportThe ^ prefix indicates this is a binding transformer. Implement in PHP:
#[ResponseTransformer('order_report')]
class OrderReportTransformer {
public function transform(Context $context): array {
return [
'summary' => $this->buildSummary($context),
'charts' => $this->generateChartData($context),
'export_formats' => ['pdf', 'csv', 'xlsx'],
];
}
}This allows complex business logic while keeping flow files clean.
Async Responses
When to Use Async
Use async responses when:
- Processing takes longer than acceptable API timeout
- Client needs to be notified when complete
- Work happens in background
Async Reply Syntax
Use reply 202 async for :event_name:
on :generate_report from @admin (api)
$report_id becomes uuid()
report moves to #queued
reply 202 async for :generate_report:
| report_id | $report_id |
| status | "queued" |This automatically creates a WebSocket channel named {event_name}.{id} (e.g., generate_report.rpt-123).
Queued Event Processing
For operations that go through a queue:
on :generate_report from @admin (api)
$report_id becomes uuid()
report moves to #queued
reply 202 async for :generate_report:
| report_id | $report_id |
| status | "queued" |
emit :process_report to @queue
with:
| report_id | $report_id |
on :process_report from @queue
report moves to #generating
// ... do the work
on :report_compiled
report moves to #ready
send callback for :generate_report:
| report_id | $report_id |
| status | "completed" |The flow:
- API request → immediate
202 Acceptedresponse - Work queued via
emit :process_report to @queue - Queue processes asynchronously
- On completion,
send callback for :generate_reportnotifies the caller
Async Options
| Option | Description | Default |
|---|---|---|
callback: | Callback type: websocket, webhook, polling | websocket |
channel: | WebSocket channel name | "{event_name}." + id |
url: | Webhook URL | Required when callback: webhook |
Callback Types
WebSocket (Default):
// Minimal - uses defaults
reply 202 async for :generate_report:
| report_id | $report_id |
// Explicit channel override
reply 202 async for :generate_report:
channel: "reports." + $report_id
| report_id | $report_id |Webhook:
reply 202 async for :process_order:
callback: webhook
url: $callback_url
| job_id | $job_id |
| status | "queued" |Polling:
reply 202 async for :export_data:
callback: polling
| operation_id | $operation_id |
| status | "queued" |
| poll_url | "/status/" + $operation_id |How Callback Tracking Works
The runtime tracks callbacks using conversation ID + event name:
- Registration:
reply 202 async for :eventstores(conversation_id, event_name) → callback_config - Delivery:
send callback for :eventfinds the matching config - Cleanup: After successful delivery, config is removed
This ensures correct delivery even with concurrent requests from different users.
Default Responses
Single Machine
API events without explicit reply return an acknowledgment:
{
"success": true,
"aggregate_id": "order-abc123",
"state": "processing"
}Machine Systems
In a machine system, the default reply includes conversation ID and all machine states:
{
"success": true,
"conversation_id": "conv-abc123",
"machines": {
"@order": "awaiting_payment",
"@payment": "pending"
}
}Best Practice: Always use explicit
replystatements for clarity. Default replies are a fallback, not a feature.
Error Responses
Inline Guard Errors
For simple validation errors, guards can include inline error responses:
on :checkout from @customer (api)
? cart is not empty | 400 cart cannot be empty
? user is logged in | 401 please log in first
? payment method is valid | 400 invalid payment method
order moves to #awaiting_payment
reply 201 with:
| id | $order_id |When a guard with an inline error fails:
- HTTP Status: Set in the response header
- Response Body:
{"message": "<error_message>"}
Error messages can include context variables:
? $total is under $limit | 400 order total $total exceeds limit of $limitSee Guards - Inline Error Responses for complete syntax.
Structured Error Responses
For richer error responses with multiple fields, use full reply blocks:
on :checkout from @customer (api)
? cart is empty
reply 400 bad request with:
| error | "CART_EMPTY" |
| message | "Cannot checkout empty cart" |
? cart is valid
// ... success path
otherwise
reply 400 bad request with:
| error | "VALIDATION_FAILED" |
| message | "Cart validation failed" |
| details | $validation_errors |Guard-Based Error Handling
on :checkout from @customer (api)
? cart is empty
reply 400 with:
| error | "CART_EMPTY" |
? not customer is logged in
reply 401 with:
| error | "UNAUTHORIZED" |
? cart total exceeds limit
reply 400 with:
| error | "LIMIT_EXCEEDED" |
| limit | $max_order_total |
// All validations passed
$order_id becomes uuid()
order moves to #awaiting_payment
reply 201 with:
| id | $order_id |Named Error Responses
For consistency across handlers:
machine: @order
response: validation_error
| field | from |
| error | $error_code |
| message | $error_message |
on :checkout from @customer (api)
? cart is empty
$error_code becomes "CART_EMPTY"
$error_message becomes "Cannot checkout empty cart"
reply 400 with validation_error
// ... success path
on :add_item from @customer (api)
? product is unavailable
$error_code becomes "UNAVAILABLE"
$error_message becomes "Product is not available"
reply 400 with validation_error // Reuse same responseJSON:API Style Errors
reply 400 bad request with:
| errors[0].code | $error_code |
| errors[0].title | $error_title |
| errors[0].detail | $error_detail |
| errors[0].source.pointer | $error_field |Output:
{
"errors": [
{
"code": "CART_EMPTY",
"title": "Validation Error",
"detail": "Cannot checkout with an empty cart",
"source": {
"pointer": "/data/attributes/cart"
}
}
]
}Testing Responses
Response assertions verify your API returns correct data:
scenario: checkout flow
on :checkout from @customer (api)
$order_id becomes uuid()
order moves to #awaiting_payment
reply 201 with:
| id | $order_id |
| status | current_state |
expect:
= order is in #awaiting_payment
= reply status is 201
= reply.id is not empty
= reply.status equals "awaiting_payment"Common Assertions
| Assertion | Description |
|---|---|
= reply status is N | HTTP status code |
= reply.field equals value | Field has exact value |
= reply.field is not empty | Field exists and not null/empty |
= reply.nested.field equals value | Nested field check |
= reply contains field | Field exists in response |
= reply does not contain field | Field absent from response |
= callback for :event was sent | Async callback delivered |
See Assertions for complete assertion syntax.
Complete Example
machine: @order
response: order_error
| field | from |
| error | $error_code |
| message | $error_message |
scenario: checkout flow
given:
@customer is logged in
cart has items
$total: number is 0
on :add_item from @customer (api)
$items adds $product
$total increases by $product.price
reply 200 with:
| item_count | count($items) |
| total | $total |
on :checkout from @customer (api)
? cart is empty
reply 400 bad request with:
| error | "CART_EMPTY" |
| message | "Cannot checkout empty cart" |
? cart is valid
$order_id becomes uuid()
order moves to #awaiting_payment
emit :payment_request to @payment
with:
| order_id | $order_id |
| amount | $total |
reply 201 created with:
| id | $order_id |
| status | current_state |
| total | $total |
| item_count | count($items) |
expect:
= order is in #awaiting_payment
= reply status is 201
= reply.id equals $order_id
otherwise
reply 400 bad request with:
| error | "INVALID_CART" |
| message | "Cart validation failed" |
on :get_order from @customer (api)
? order exists
reply 200 with:
| id | $order_id | |
| status | current_state | |
| total | $total | |
| items | $items | |
| customer.name | $customer_name | |
| customer.email | $customer_email | |
| tracking | $tracking_no | when order is in #shipped |
otherwise
$error_code becomes "NOT_FOUND"
$error_message becomes "Order not found"
reply 404 with order_error
on :payment_success from @payment
order moves to #paid
emit :reserve_stock to @inventory
on :ship from @warehouse
$tracking_no becomes $tracking
order moves to #shipped
emit :shipment_notification to @customer
expect:
= order is in #shipped
= @customer received :shipment_notificationDesign Workflow
Response design happens during Session 4 (Implementation) - not during discovery sessions.
Why This Split?
- Sessions 1-3 (Discovery, Happy Path, Edge Cases): Focus on business flow, readable by non-developers. Lane diagrams show events, not HTTP responses.
- Session 4 (Implementation): Developers add technical details like response structure, HTTP status codes, and field mapping.
This separation keeps discovery sessions accessible to all stakeholders while letting developers handle API contracts.
See Event Flowing Sessions for the complete methodology.