Skip to content

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 response

Machines talk to each other through events. API callers get responses.

Basic Reply Syntax

Inline Response

Every API event handler can return a structured response:

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

StatusMeaningWhen to Use
200OKSuccessful read/update
201CreatedNew resource created
202AcceptedAsync operation started
400Bad RequestValidation error
401UnauthorizedAuthentication required
404Not FoundResource doesn't exist
409ConflictState conflict
500Internal ErrorUnexpected failure

Human-Readable Status Codes

For better readability, include the status meaning:

flow
// Both are valid
reply 400 with:
  | error | "CART_EMPTY" |

reply 400 bad request with:
  | error | "CART_EMPTY" |

When using the descriptive form:

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

flow
reply 200 with:
  | order_id | $order_id    |
  | total    | $total       |
  | state    | current_state|

Rename fields for API conventions:

flow
reply 200 with:
  | orderId     | $order_id     |  // camelCase for JavaScript clients
  | orderTotal  | $total        |
  | orderStatus | current_state |

Computed Fields

Use expressions to compute values:

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

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

flow
reply 200 with:
  | id               | $order_id       |
  | customer.name    | $customer_name  |
  | customer.email   | $customer_email |
  | shipping.address | $ship_address   |
  | shipping.method  | $ship_method    |

Output:

json
{
  "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:

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

flow
reply 200 with:
  | id    | $order_id |
  | items | $items    |

JSON:API Style

EventFlow supports JSON:API specification style responses:

flow
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

flow
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

flow
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_error

Binding Transformers

For complex response transformations, use binding transformers with the ^ prefix:

flow
on :get_order_report from @admin (api)
  reply 200 with ^order_report

The ^ prefix indicates this is a binding transformer. Implement in PHP:

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:

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

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

  1. API request → immediate 202 Accepted response
  2. Work queued via emit :process_report to @queue
  3. Queue processes asynchronously
  4. On completion, send callback for :generate_report notifies the caller

Async Options

OptionDescriptionDefault
callback:Callback type: websocket, webhook, pollingwebsocket
channel:WebSocket channel name"{event_name}." + id
url:Webhook URLRequired when callback: webhook

Callback Types

WebSocket (Default):

flow
// 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:

flow
reply 202 async for :process_order:
  callback: webhook
  url: $callback_url
  | job_id | $job_id  |
  | status | "queued" |

Polling:

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

  1. Registration: reply 202 async for :event stores (conversation_id, event_name) → callback_config
  2. Delivery: send callback for :event finds the matching config
  3. 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:

json
{
  "success": true,
  "aggregate_id": "order-abc123",
  "state": "processing"
}

Machine Systems

In a machine system, the default reply includes conversation ID and all machine states:

json
{
  "success": true,
  "conversation_id": "conv-abc123",
  "machines": {
    "@order": "awaiting_payment",
    "@payment": "pending"
  }
}

Best Practice: Always use explicit reply statements 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:

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

flow
? $total is under $limit | 400 order total $total exceeds limit of $limit

See Guards - Inline Error Responses for complete syntax.

Structured Error Responses

For richer error responses with multiple fields, use full reply blocks:

flow
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

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

flow
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 response

JSON:API Style Errors

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

json
{
  "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:

flow
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

AssertionDescription
= reply status is NHTTP status code
= reply.field equals valueField has exact value
= reply.field is not emptyField exists and not null/empty
= reply.nested.field equals valueNested field check
= reply contains fieldField exists in response
= reply does not contain fieldField absent from response
= callback for :event was sentAsync callback delivered

See Assertions for complete assertion syntax.

Complete Example

flow
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_notification

Design 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.

Released under the MIT License.