This is a sample Laravel 13 application built on Durable Workflow 2.0 (alpha) with example workflows that you can run inside a GitHub Codespace.
Looking for the Laravel 12 / Durable Workflow 1.x version? It's preserved on the
Laravel-12branch. Older blog posts and tutorials that reference v1 patterns (e.g.Workflow\Workflow,yield activity(...),Workflow\Activity) target that branch.
Create a codespace from the main branch of this repo.
Once the codespace has been created, wait for the codespace to build. This should take between 5 to 10 minutes.
Once it is done. You will see the editor and the terminal at the bottom.
Run the init command to setup the app, install extra dependencies and run the migrations.
php artisan app:initStart the queue worker. This will enable the processing of workflows and activities.
php artisan queue:work redis --queue=default,activityCreate a new terminal window.
Start the example workflow inside the new terminal window.
php artisan app:workflowYou can view the waterline dashboard at https://[your-codespace-name]-18080.preview.app.github.dev/waterline/dashboard.
Check the two observability surfaces separately:
| Surface | Use it for | Where to look |
|---|---|---|
| Waterline and the workflow database | Durable workflow truth: run status, typed history, signals, updates, timers, retries, failures, and operator actions. | /waterline/dashboard, selected run detail, and php artisan workflow:v2:history-export |
| Worker logs and SDK metrics | Runtime behavior: poll latency, task duration, exporter wiring, custom application metrics, and worker-side errors before they become durable failures. | Laravel logs for this PHP sample app; SDK metrics endpoints for external workers |
For this Laravel-only sample, Waterline proves that the durable run exists and shows what the engine committed. If you add a Python or other external worker, enable that worker's SDK metrics as a separate endpoint; those metrics will not appear inside Waterline unless you scrape them with your metrics stack.
Minimal Python worker Prometheus wiring looks like this:
pip install 'durable-workflow[prometheus]'from prometheus_client import start_http_server
from durable_workflow import Client, PrometheusMetrics, Worker
metrics = PrometheusMetrics()
start_http_server(9102)
async with Client("http://localhost:8080", token="secret", metrics=metrics) as client:
worker = Worker(
client,
task_queue="default",
workflows=[GreeterWorkflow],
activities=[greet],
metrics=metrics,
)
await worker.run()Replace GreeterWorkflow and greet with the workflow and activity handlers registered by that worker.
Scrape :9102/metrics for durable_workflow_worker_* and durable_workflow_client_* series. Use Waterline for the matching workflow history and status.
Run the workflow and activity tests.
php artisan testThat's it! You can now create and test workflows.
Use this index when you want a specific Durable Workflow pattern instead of another happy-path snippet.
| Goal | Workflow | Command | MCP key |
|---|---|---|---|
| Learn the smallest v2 workflow/activity shape | App\Workflows\Simple\SimpleWorkflow |
php artisan app:workflow |
simple |
| Measure durable elapsed time without replay drift | App\Workflows\Elapsed\ElapsedTimeWorkflow |
php artisan app:elapsed |
elapsed |
| Coordinate work across Laravel app boundaries | App\Workflows\Microservice\MicroserviceWorkflow |
php artisan app:microservice |
microservice |
| Run browser automation and collect generated artifacts | App\Workflows\Playwright\CheckConsoleErrorsWorkflow |
php artisan app:playwright |
playwright |
| Start from an external webhook and wait for a signal | App\Workflows\Webhooks\WebhookWorkflow |
php artisan app:webhook |
webhook |
| Wrap an AI activity loop in durable retry/validation | App\Workflows\Prism\PrismWorkflow |
php artisan app:prism |
prism |
| Build a signal-driven AI agent with compensation | App\Workflows\Ai\AiWorkflow |
php artisan app:ai |
ai |
In addition to the basic example workflow, you can try these other workflows included in this sample app:
-
php artisan app:elapsed– Demonstrates how to correctly track start and end times to measure execution duration. -
php artisan app:microservice– A fully working example of a workflow that spans multiple Laravel applications using a shared database and queue. -
php artisan app:playwright– Runs a Playwright automation, captures a WebM video, encodes it to MP4 using FFmpeg, and then cleans up the WebM file. -
php artisan app:webhook– Showcases how to use the built-in webhook system for triggering workflows externally. -
php artisan app:prism- Uses Prism to build a durable AI agent loop. It asks an LLM to generate user profiles and hobbies, validates the result, and retries until the data meets business rules. -
php artisan app:ai- NEW! Uses Laravel AI SDK to build a durable travel agent. The agent asks questions and books hotels, flights, and rental cars. If any errors occur, the workflow ensures all bookings are canceled.
Try them out to see workflows in action across different use cases!
This app shipped on Durable Workflow 1.x (Laravel 12) until April 2026 — the Laravel-12 branch is the snapshot of that state. The v2 API is straight-line and Fiber-driven; here are the patterns you need to update when porting your own workflows.
-use Workflow\Workflow;
-use function Workflow\activity;
+use Workflow\V2\Workflow;
+use function Workflow\V2\activity;
class OrderWorkflow extends Workflow
{
- public function execute(string $orderId)
+ public function handle(string $orderId): array
{
- $order = yield activity(LoadOrderActivity::class, $orderId);
- yield activity(ChargeActivity::class, $order);
+ $order = activity(LoadOrderActivity::class, $orderId);
+ $charge = activity(ChargeActivity::class, $order);
- return $order;
+ return ['order' => $order, 'charge' => $charge];
}
}Key differences:
- Base class:
extends Workflow\V2\Workflow(notWorkflow\Workflow). - Helper imports:
use function Workflow\V2\{activity, sideEffect, await, timer, …}— every helper has aWorkflow\V2\namespaced equivalent. - No
yield:activity(...)is straight-line and returns the result directly. The Fiber-based runtime suspends transparently. - Entry method: define
handle(...); rename v1 workflowexecute(...)methods during the port. await: A singleawait($condition, $timeout, $key)replaces bothawait()andawaitWithTimeout(). Returnstrueif the condition was satisfied,falseif the timeout fired.
-use Workflow\Activity;
+use Workflow\V2\Activity;
class ChargeActivity extends Activity
{
- public function execute($order)
+ public function handle(array $order): array
{
// …
}
}- Base class:
extends Workflow\V2\Activity. - Method name: define
handle(...); rename v1 activityexecute(...)methods during the port. - Type hints: Strongly recommended — argument and return types are part of the durable activity contract.
Signals shifted from push to pull in v2. v1 declared a method handler and the engine called it on signal arrival; v2 declares the signal contract at the class level and the workflow code blocks on await($signalName) to receive it:
-use Workflow\SignalMethod;
+use Workflow\V2\Attributes\Signal;
+use function Workflow\V2\await;
+#[Signal('approve', [['name' => 'reason', 'type' => 'string']])]
class ApprovalWorkflow extends Workflow
{
- #[SignalMethod]
- public function approve(string $reason): void {
- $this->approved = true;
- $this->reason = $reason;
- }
- public function execute() {
- yield await(fn () => $this->approved);
- return $this->reason;
+ public function handle(): string {
+ return await('approve'); // blocks for the signal, returns its arg
}
}For workflows that need to drain many signals over time (chat-style loops), use await($signalName, $timeout) to bound each wait and check the return for null (timeout fired).
Updates and queries still use method-level attributes — #[Workflow\UpdateMethod] and #[Workflow\QueryMethod] carry over verbatim.
Invocation from outside the workflow uses explicit names:
-$workflow->send($payload); // v1 magic method
-$result = $workflow->receive();
+$workflow->signal('send', $payload); // v2 explicit signal name
+$result = $workflow->update('receive');-use Workflow\WorkflowStub;
+use Workflow\V2\WorkflowStub;
$stub = WorkflowStub::make(OrderWorkflow::class);
$stub->start($orderId);
-while ($stub->running()) {
- // tight loop
+while ($stub->refresh()->running()) {
+ usleep(100_000);
}make(), load($workflowId), start(...), running(), completed(), failed(), output(), refresh(), signal($name, ...$args), update($name, ...$args) all carry over.
-use Workflow\Webhooks;
-Webhooks::routes();
+use App\Workflows\Webhooks\WebhookWorkflow;
+use Workflow\V2\Webhooks;
+Webhooks::routes([
+ 'webhook-workflow' => WebhookWorkflow::class,
+]);The v2 router takes an explicit alias → workflow class map (no auto-discovery). The #[Webhook] attribute on the workflow class continues to mark which signal/update methods are exposed via webhook URLs.
Repeated human-input workflows use pull-style signals for user input and a durable message stream for replies. Signals tell the workflow that new input arrived; the stream gives each reply an ordered, consumable cursor that survives retries and continue-as-new.
app/Workflows/Ai/AiWorkflow.php is the reference pattern. It sends assistant replies through Workflow\V2\Support\MessageService, stores the reply body in the sample app's ai_workflow_messages table, and exposes a receive update that consumes one reply from the ai.assistant stream. The workflow_messages row owns stream ordering and the app table owns payload bytes.
The addCompensation(callable) / compensate() API is unchanged on the v2 Workflow base class. Compensation closures should call activities straight-line (no yield from):
-$this->addCompensation(fn () => yield activity(CancelHotelActivity::class, $hotel));
+$this->addCompensation(fn () => activity(CancelHotelActivity::class, $hotel));This sample app includes an MCP (Model Context Protocol) server that allows AI clients (ChatGPT, Claude, Cursor, etc.) to start and monitor Durable Workflow v2 workflows. Treat it as the agent-operable companion to Waterline: humans can inspect /waterline/dashboard, while AI clients receive structured workflow IDs, run IDs, statuses, recent typed history, and failure summaries.
The MCP server is available at: /mcp/workflows
| Tool | Description |
|---|---|
list_workflows |
Discover configured workflow keys, credential requirements, status values, and recent v2 runs |
start_workflow |
Start a configured v2 workflow asynchronously and get a workflow instance ID plus run ID |
get_workflow_result |
Check workflow status, output, visibility metadata, and latest failure summary |
get_workflow_history |
Inspect a bounded slice of typed v2 history events and latest durable failures |
Available workflows are defined in config/workflow_mcp.php. By default, every workflow in the sample index is exposed:
simple→App\Workflows\Simple\SimpleWorkflowelapsed→App\Workflows\Elapsed\ElapsedTimeWorkflowmicroservice→App\Workflows\Microservice\MicroserviceWorkflowplaywright→App\Workflows\Playwright\CheckConsoleErrorsWorkflow(requires local Playwright/Node/FFmpeg setup)webhook→App\Workflows\Webhooks\WebhookWorkflow(waits for thereadysignal)prism→App\Workflows\Prism\PrismWorkflow(requiresOPENAI_API_KEY)ai→App\Workflows\Ai\AiWorkflow(requiresOPENAI_API_KEY, then acceptssendsignals andreceiveupdates)
To add more workflows, update the config file:
'workflows' => [
'simple' => [
'class' => App\Workflows\Simple\SimpleWorkflow::class,
'description' => 'Small deterministic workflow.',
'pattern' => 'deterministic activity chain',
'command' => 'php artisan app:workflow',
'requires' => [],
'arguments' => [],
],
'my_workflow' => [
'class' => App\Workflows\MyWorkflow::class,
'description' => 'What an agent should know before starting it.',
'requires' => ['EXTERNAL_API_KEY'],
'arguments' => [
['name' => 'customer_id', 'type' => 'string'],
],
],
],Class-string mappings are still accepted for small local experiments, but the array form gives agents safer discovery metadata.
An AI client would typically:
- Call
list_workflowsto see available workflows - Call
start_workflowwith{"workflow": "simple", "business_key": "demo-001"} - Receive
workflow_idandrun_idin the response - Poll
get_workflow_resultwith theworkflow_iduntil status iscompleted - Read the
outputfield for the workflow result - If status is
failedorwaitinglonger than expected, callget_workflow_historywith therun_id



