Durable AI Agents in Laravel: an LLM Agent on Temporal, Crashes Included
A durable AI agent is an LLM agent whose progress outlives the process running it. Kill the worker in the middle of a task and another one carries on from the last finished step: it does not ask the model again about anything already recorded, it does not repeat tools that already finished, and it can wait days for a person without anything of yours staying alive. That is what Temporal sells as durable execution, applied to the loop of "the model picks a tool, the tool runs, the result goes back to the model".
I wanted to know what that takes in Laravel, and what it really buys, so I built one support agent twice: as the usual loop that saves the conversation after every tool call, and as a Temporal workflow. Then I killed processes with SIGKILL, with no chance to clean up, and counted refunds.
Three results are worth knowing before you start. Temporal kept the model's decision through a crash, so the plain loop paid 49.00 and then 59.00 while Temporal paid 49.00 twice: it still repeated the refund, and only an idempotency key stopped that. One ordinary Laravel call inside a workflow made the live PHP worker return a result that did not happen, with no error anywhere, while the SDK's replayer, run on the same kind of history, rejected it. And because every model call carries the whole conversation, the history stores the conversation once per step.
Everything is in the companion repository: Laravel 13, the Temporal PHP SDK 2.20 and RoadRunner, against a real Temporal server in Docker. Every block of source below comes from a real file there, and its first line says which one. Claims about behaviour are backed by its 19 tests. The model is a scripted stand-in, explained below, so no test costs money or depends on a provider. Where I rely on documentation alone, the text says so.
What "durable" means for an agent
An agent is a loop. The model reads the conversation and either answers or asks for a tool. Your code runs the tool, appends the result, and asks the model again. A support agent handling "my parcel never arrived" might look up the order, refund it and email the customer, which is four model calls and three tool calls.

In agent or deterministic workflow the question was who decides the next step. Here the model decides, and the question is what runs around it: a deterministic runtime that records every decision and every tool result, so the agent survives the process. Making that durable means four things, and I will test each one:
- Finished steps are not repeated. After a crash, the model is not asked again about a decision that was already recorded, and a tool that finished does not run again.
- Waiting costs nothing. An agent can wait for a person to approve a refund, for hours, with no PHP process of yours holding it.
- Side effects happen once. A refund that ran when the worker died is not paid twice.
- The answer is the truth. What the workflow reports is what it did.
Temporal gives the first two, with one exception that I will show. The third needs work from you. The fourth depends on code discipline that Laravel habits break, and in PHP nothing may warn you.
The vocabulary
| term | what it means |
|---|---|
| workflow | Your agent loop, written as a PHP generator. It decides what happens next and must be deterministic. |
| activity | One call to the outside world: the model, a refund, an email. Its result is recorded. |
| event history | The server's log of everything a workflow did: each activity scheduled, started and completed, with inputs and results. |
| replay | Running the workflow code again from the start against its history, to rebuild its state on a new worker. Recorded results are handed back instead of running activities again. |
| worker | A process that polls a task queue and runs workflows and activities. In PHP, a RoadRunner binary with PHP processes under it. |
| signal | A message sent into a running workflow, such as "approved". |
| start-to-close timeout | How long one activity attempt may take before the server gives up on it and retries. |
| idempotency key | A value sent with a request so that the receiver performs it once, however often it arrives. |
The agent, and the usual way to make it restartable
The agent has three tools: lookup_order, issue_refund and send_email. A fake payment provider writes each refund to a table, so a test can count them. The usual way to survive a crash is to save the conversation after every tool call and, on a rerun, continue from the last save:
// app/Agent/PlainAgent.php
public function handle(string $ticketId, string $question): string
{
$messages = $this->load($ticketId) ?? [['role' => 'user', 'content' => $question]];
for ($step = 0; $step < self::MAX_STEPS; $step++) {
$decision = $this->model->decide($ticketId, $messages);
if (null === $decision['tool']) {
return (string) $decision['text'];
}
$messages[] = ['role' => 'assistant', 'content' => json_encode($decision, JSON_THROW_ON_ERROR)];
$result = $this->tools->run($decision['tool'], $decision['arguments']);
$messages[] = ['role' => 'tool', 'tool' => $decision['tool'], 'content' => $result];
$this->save($ticketId, $messages);
}
return 'Gave up after '.self::MAX_STEPS.' steps';
}
The model in the tests is ScriptedModel, a stand-in that makes the same choices every time, with one property of a real model kept on purpose: asked the same question again, it answers differently. Here, it proposes a goodwill credit of 10.00 more each time it is asked about the refund again, as a model at a non-zero temperature might change its mind. It also logs every call, so a test can see when the agent asked twice.
Without a crash, this agent asks the model four times and refunds 49.00. With a crash right after the payment provider pays, and before the save:

The test a_crash_after_the_refund_refunds_again_and_the_second_refund_is_a_different_amount records 49.00 and 59.00, five model calls, and two calls for the same step. Saving more often moves the window, it does not close it: there is always a moment between "the provider paid" and "I wrote down that it paid". And the rerun does not repeat a decision, it makes a new one.
Temporal in a Laravel app
Two things are different from the PHP you run behind a web server. The Temporal client needs the grpc extension, and the worker needs RoadRunner, a Go binary that keeps PHP processes alive and passes them tasks. It does not run under PHP-FPM. The repository's Dockerfile installs php8.5-grpc as a package from Ondřej Surý's repository. My first attempt built it with pecl, and after three hours I stopped the compile.
The worker file boots Laravel once per PHP process and asks the container for activity objects, so activities can use facades, Eloquent and anything bound in a service provider:
// worker.php
require __DIR__.'/vendor/autoload.php';
// RoadRunner starts this file once per PHP worker process and keeps it running, so Laravel boots once here,
// not once per task.
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$factory = WorkerFactory::create();
$worker = $factory->newWorker('support-agent');
$worker->registerWorkflowTypes(SupportAgentWorkflow::class, SlaCheckWorkflow::class, AuditWorkflow::class);
$worker->registerActivity(AgentActivities::class, fn (ReflectionClass $class): object => $app->make($class->getName()));
$factory->run();
RoadRunner reads .rr.yaml, which names this file and the Temporal address. A long-lived worker has the same caveats as Laravel Octane: static state and singletons survive from one task to the next. I did not test that here.
The same agent as a workflow
The loop moves into a workflow. Every call that leaves the process goes through an activity stub, and yield hands it to Temporal:
// app/Temporal/SupportAgentWorkflow.php
#[WorkflowMethod(name: 'SupportAgent')]
public function run(string $ticketId, string $question, array $options): Generator
{
$agent = Workflow::newActivityStub(AgentActivities::class, ActivityOptions::new()
->withStartToCloseTimeout(5)
->withRetryOptions(RetryOptions::new()
->withInitialInterval(1)
->withMaximumAttempts(5)
->withNonRetryableExceptions([OrderNotFound::class])));
$messages = [['role' => 'user', 'content' => $question]];
for ($step = 0; $step < self::MAX_STEPS; $step++) {
$decision = yield $agent->think($ticketId, $messages);
if (null === $decision['tool']) {
return $decision['text'];
}
$messages[] = ['role' => 'assistant', 'content' => json_encode($decision, JSON_THROW_ON_ERROR)];
$amount = (int) ($decision['arguments']['amount_cents'] ?? 0);
if ('issue_refund' === $decision['tool'] && $options['approvalLimitCents'] < $amount) {
// A person approves an amount, not "whatever the agent asks for next".
$this->awaitingApprovalOf = $amount;
yield Workflow::awaitWithTimeout($options['approvalTimeoutSeconds'], fn (): bool => isset($this->approvals[$amount]));
$approved = $this->approvals[$amount] ?? false;
// One approval covers one refund, so the next one has to be approved again.
unset($this->approvals[$amount]);
$this->awaitingApprovalOf = null;
if (false === $approved) {
$messages[] = ['role' => 'tool', 'tool' => 'issue_refund', 'content' => 'A person did not approve this refund'];
continue;
}
}
try {
$result = yield $agent->useTool($decision['tool'], $decision['arguments'], $options['refundKey']);
} catch (ActivityFailure $failure) {
// Out of retries, or not worth retrying. The model hears about it instead of the workflow failing.
$result = 'The tool failed: '.($failure->getPrevious()?->getOriginalMessage() ?? $failure->getMessage());
}
$messages[] = ['role' => 'tool', 'tool' => $decision['tool'], 'content' => $result];
}
return 'Gave up after '.self::MAX_STEPS.' steps';
}
#[SignalMethod]
public function approve(int $amountCents, bool $approved): void
{
$this->approvals[$amountCents] = $approved;
}
/**
* What a person is being asked to approve, for the screen that asks them.
*/
#[QueryMethod]
public function awaitingApprovalOf(): ?int
{
return $this->awaitingApprovalOf;
}
Three details are there because reviewing my first version found them. A person approves an amount, not "the next refund": the signal carries the amount they saw, and the query awaitingApprovalOf() tells the screen what to show them. My first signal was a bare approve(true), and a test showed that an approval meant for 10.00 released a refund of 49.00. An approval is also consumed when it is used, so a second refund in the same conversation needs a second one. And a tool failure is caught and handed to the model as a tool result, which the next section tests, and which turns out to need care.
Read it as an ordinary loop, because the first time it runs, it is one. The difference shows after a crash. A new worker gets the workflow, runs this method again from the top, and every yield that already has a result in the history returns that result at once, without calling anything. The code fast-forwards to where it stopped. That is replay, and it is why the code must be deterministic: the second run has to make the same calls in the same order as the first.
The approval check is deliberately in the workflow, not in the prompt. A refund above the limit waits for a person, and the model cannot talk its way past an if. I wrote about that kind of boundary in agent guardrails and permissions.
The activities are a plain Laravel class. The worker builds it through the container:
// app/Temporal/AgentActivities.php
#[ActivityInterface(prefix: 'agent.')]
final readonly class AgentActivities
{
public function __construct(private LanguageModel $model, private SupportTools $tools) {}
/**
* @param list<array{role: string, content: string, tool?: string}> $messages
* @return array{tool: ?string, arguments: array<string, string|int>, text: ?string}
*/
#[ActivityMethod]
public function think(string $ticketId, array $messages): array
{
return $this->model->decide($ticketId, $messages);
}
/**
* @param array<string, string|int> $arguments
*/
#[ActivityMethod]
public function useTool(string $tool, array $arguments, string $refundKey): string
{
$key = 'issue_refund' === $tool ? $this->idempotencyKey(RefundKey::from($refundKey), $arguments) : null;
return $this->tools->run($tool, $arguments, $key);
}
What you get, tested
| claim | test | result |
|---|---|---|
| Without a crash, the workflow behaves like the loop | without_a_crash_it_refunds_once_and_asks_the_model_four_times | One refund, four model calls |
| A decision made before a crash is not made again | a_worker_killed_after_the_refund_refunds_the_same_amount_again_without_asking_the_model | Four model calls in total, the same amount both times |
| Waiting holds no process, and nothing is repeated after it | a_worker_killed_while_waiting_for_approval_repeats_nothing_when_another_one_takes_over | Worker killed, approval sent with no worker running, then a new worker: one refund, one email, four model calls |
| An approval binds to the amount the person saw | an_approval_for_another_amount_does_not_release_the_refund | The query reports 4900, an approval of 1000 releases nothing, and the request times out as declined |
| A person who never answers is an answer too | without_an_approval_in_time_the_agent_is_told_no_and_refunds_nothing | No refund, and the customer is told |
| A tool that cannot succeed does not end the agent | a_tool_that_cannot_succeed_runs_once_and_the_model_is_told_instead_of_the_workflow_failing | One attempt, and the model hands the ticket to a person |
| A model answer that was not recorded is asked for again | a_worker_killed_after_the_model_answered_asks_it_again_and_acts_on_the_second_answer_only | The model is asked twice for the refund, and one refund of the second amount is paid |
The approval test is the one I would show a team first. The worker is killed while the agent waits for a refund to be approved. The approval is then sent while no worker is running at all, and the server keeps it. A query, unlike a signal, is answered by a worker replaying the workflow, as Temporal documents it, so the screen that shows the amount needs one running. A worker started afterwards replays the history, finds the signal, refunds once and emails once. There was no queue, no status column and no scheduled job to write. The waiting agent was a few events on the Temporal server.
The timeout path matters as much. awaitWithTimeout is a durable timer, so "nobody answered within the limit" becomes a tool result the model reads, "A person did not approve this refund", and the customer is told so. That too survives a crash, because the timer lives on the server.
Failures need the same treatment. When an activity runs out of retries, Temporal throws an ActivityFailure into the workflow, and an agent loop that does not catch it ends as a failed workflow, with a customer who hears nothing. The loop above catches it and gives the model the error, which is right for a lookup and, as the next section shows, dangerous for a refund. Retrying is also not always useful: an order that does not exist will not appear on the fifth attempt. OrderNotFound is marked non-retryable, so the lookup runs once and the model hands the ticket to a person. With that mark removed, the same test fails, because the lookup ran five times first.
The last row is the limit of "the model is not asked again". The worker is killed after the model has answered but before the activity reported back. Temporal never saw the answer, so it retries the activity, and the stand-in model answers differently, 59.00. What Temporal guarantees is that the workflow acts only on the answer it recorded: one refund of 59.00, never 49.00 and 59.00. You pay for the second call, and you may get a different decision, but never two decisions acted on.
What you do not get: a refund paid once
The crash test kills the whole worker, RoadRunner and every PHP process under it, after the payment provider has paid and before the activity reports back. The server waits out the five-second start-to-close timeout, sees no answer, and retries the activity on the next worker. The refund runs again.

This is not a defect. An activity that started and never reported back may or may not have done its work, and the server cannot know which. I measured the same result with Camunda and RabbitMQ in what process orchestration is: an engine retries, so the side effect can happen twice. What Temporal does change is the amount. The plain loop paid 59.00 the second time because it asked the model again. Temporal paid 49.00 again, because the decision came from the history.
The fix is on the receiving side: the refund carries an idempotency key. The repository has three choices, and the first test uses the middle one, a key that is the same for every retry of this tool call and different for every other one:
// app/Temporal/AgentActivities.php
private function idempotencyKey(RefundKey $key, array $arguments): ?string
{
$info = Activity::getInfo();
return match ($key) {
RefundKey::None => null,
// The same for every retry of this activity, and different for every other tool call.
RefundKey::PerToolCall => $info->workflowExecution->getID().'/'.$info->workflowExecution->getRunID().'/'.$info->id,
// The same for anything that refunds this order: retries, a new attempt by the model, a reset.
RefundKey::PerOrder => 'refund:'.$arguments['order_id'],
};
}
The provider stores the key with the refund. For a repeated key it pays nothing and returns the first refund, and for a key reused with another amount it refuses. That is how Stripe's idempotent requests are documented to work: the first result is saved and returned for every repeat of the key. The same page has two conditions worth pairing with Temporal's retries. A repeat must send the same parameters, which a retried activity does, and which a model asked again may not: with a key per order, a second request for a different amount is refused rather than paid, a path I did not test. And keys may be pruned once they are 24 hours old, so a retry that comes later than that is a new request. Temporal's default retry policy has no attempt limit, so an activity can keep retrying for longer than a day. Give payment activities a schedule-to-close timeout shorter than the provider's key lifetime.
// app/Payments/PaymentGateway.php
public function refund(string $orderId, int $amountCents, ?string $idempotencyKey = null): int
{
$refund = ['order_id' => $orderId, 'amount_cents' => $amountCents, 'idempotency_key' => $idempotencyKey];
if (null === $idempotencyKey) {
return DB::table('refunds')->insertGetId($refund);
}
DB::table('refunds')->insertOrIgnore($refund);
$first = DB::table('refunds')->where('idempotency_key', $idempotencyKey)->first();
if ((int) $first->amount_cents !== $amountCents) {
throw new LogicException("Idempotency key {$idempotencyKey} was used for a different amount");
}
return (int) $first->id;
}
With that key, an_idempotency_key_from_the_activity_id_turns_the_retry_into_a_no_op records one refund. The retry also gets back the same text as the first attempt, "refund #1", so the email the customer receives is the one they would have got without the crash. A provider that answered "already refunded" instead would hand the model a different tool result, and the agent might act on it.
The key that is not enough
A key per tool call covers a crash. It does not cover the combination of two things this article recommends: retries with a limit, and handing a failed tool to the model. In a_refund_that_timed_out_is_asked_for_again_by_the_model_and_a_key_per_tool_call_pays_twice the provider pays and then hangs through all five attempts. The activity fails with a timeout although the money went out, the workflow tells the model that the refund failed, and the model asks for the refund again. The stand-in is scripted to do that, as a model told to resolve the ticket plausibly would. That is a new tool call with a new key, and the provider pays a second 49.00.
A timed-out refund is an unknown outcome, not a failure, and a key per tool call cannot tell the two apart. A key that says what the business rule is can. With refund:A-1001, a_key_per_order_pays_once_however_often_the_refund_is_asked_for pays 49.00 once through the same hang and the same second request. The same key also covers what I did not test: a workflow reset in Temporal's UI, which starts a new run and therefore makes new keys per tool call. If an order may legitimately be refunded twice, in parts, the key has to name the part, and the workflow is the place to count them, not the model.
Laravel habits that break in a workflow
The workflow class sits in app/, autoloads like any other class, and runs inside a fully booted Laravel. Nothing stops you from calling DB::, Log:: or Http:: in it, and each of those runs again on every replay.

A write to the database is the easy case to see. This workflow records that the agent started, once in the body and once, for comparison, through an activity:
// app/Temporal/Habits/AuditWorkflow.php
if ($throughActivity) {
yield $agent->audit($ticketId, 'agent started');
} else {
DB::table('audit_log')->insert(['ticket_id' => $ticketId, 'entry' => 'agent started']);
}
Kill the worker while the workflow waits, start another, and close the ticket. The write in the body ran twice, once on the first worker and once on replay. The same write in an activity ran once, because replay hands back its recorded result. I tested only the database write, but a log line or an HTTP call goes through the same mechanism: it would run again on every worker that rebuilds the workflow, and nothing in the history says it ever ran.
One more habit is harder to see, because nothing runs twice.
A puzzle: the ticket nobody escalated
Tickets older than the SLA go to a person. Younger ones go to the agent. The check runs at the start of the workflow:
// app/Temporal/Habits/SlaCheckWorkflow.php
$now = $workflowClock ? Workflow::now()->getTimestamp() : now()->getTimestamp();
if ($slaSeconds < $now - $openedAt) {
if ($graceTimer) {
yield Workflow::timer(1);
}
yield $agent->audit($ticketId, 'escalated to a person');
$handledBy = 'a person';
} else {
yield $agent->think($ticketId, [['role' => 'user', 'content' => "Ticket {$ticketId} about order A-1001"]]);
$handledBy = 'the agent';
}
In this run $workflowClock and $graceTimer are both false. They exist for the answer. A ticket opens and is checked at once, well inside a three-second SLA, so the agent takes it. While it waits for the ticket to be closed, the worker dies. A new worker comes up four seconds later, the ticket is closed, and the workflow returns:
closed, handled by a personThe audit log has no escalation in it. Nobody was told about the ticket. The model was asked. No workflow task failed, and the workflow is marked completed. What happened, and why did Temporal not notice?
The answer
The test is laravels_now_in_a_workflow_takes_the_other_branch_on_replay_and_nothing_notices, and now() is the cause. The first worker read the clock inside the SLA and scheduled agent.think. The new worker replayed the workflow from the top four seconds later, read a clock that was now past the SLA, and took the other branch, which asks for agent.audit. History said the first command was an activity, and the replay asked for an activity. Replay handed the recorded result of think to the call to audit, and the code went on, convinced it had escalated.

The second half is what surprised me. When the escalation branch starts with a timer instead, the_replay_check_notices_only_when_the_other_branch_starts_with_a_different_kind_of_command shows the workflow task failing and the workflow stuck as Running, which is the safe outcome: nothing wrong happens, and the stuck workflow is visible. So on a live worker, with this SDK and RoadRunner version, a different activity in the same place went through unnoticed, and a different kind of command did not. In the orchestration article, the Java SDK raised a non-determinism error when a release put a different activity first. I have not traced why the live PHP worker differs, so treat this as the behaviour I measured, not as documented behaviour.
The fix is one word. Workflow::now() returns the time recorded for the workflow task being replayed, so the replay sees the same moment as the first run. workflow_now_replays_the_time_the_decision_was_first_made passes with the same crash and the same delay, and returns "handled by the agent". The same applies to randomness and IDs: Workflow::uuid() and Workflow::sideEffect() record their value, while Str::uuid() and random_int() do not.
Since production may not fail when you get this wrong, test for it before production. The SDK ships a workflow replayer that runs a recorded history against the current code, and it is stricter than the live worker. the_replayer_rejects_the_renamed_activity_that_a_live_worker_let_through records three tickets inside the SLA, waits until the SLA has passed, and replays them. The now() version is rejected with "nondeterministic workflow: history event is ActivityTaskScheduled … agent.think … replay command is ScheduleActivityTask … agent.audit". The timer version is rejected too. The Workflow::now() version replays cleanly. So keep a few real histories in the repository, from downloadHistory() or Temporal's UI, and replay them in CI against every change to workflow code. In the SDK the replayer talks to RoadRunner over its RPC port, so the CI job starts one, as the test does.
The history is the memory, and it has limits
The model activity receives the whole conversation each time, and the server stores every activity's input. every_model_call_stores_the_whole_conversation_again_in_the_history measures the input of the four model calls in one ticket:

At this size nothing matters. Real agents read documents, though. Here is the arithmetic, not a measurement: if each step adds 20 KB of tool output, call 50 carries about 1 MB, and the history holds 1 + 2 + … + 50 copies of 20 KB, about 25 MB. Temporal's documented defaults are a warning at 512 KB for a single payload and an error at 2 MB, and a warning at 10 MB for a whole history and termination at 50 MB or 51,200 events (Workflow Execution limits). An agent that loops long enough on big tool outputs reaches them.
The usual answers apply. Keep large tool results out of the history: store them, and pass a reference that the model activity loads. Summarise old turns. And for long-running agents, end the run and start a fresh one with the summary as input, which Temporal calls Continue-As-New.
Two more settings in my workflow are test values, not advice. A five-second start-to-close timeout is far too short for a model call that can take a minute, and five attempts for everything ignore the difference between a rate limit, which is worth retrying, and a malformed request, which is not. Neither was tested here.
When to reach for it
- Reach for it when the agent waits. For a person, an approval, a webhook or a deadline. This is where the plain loop would need a queue, a status column and a scheduled job, and where Temporal needed none.
- Reach for it when decisions are expensive or must not change. A crash should not buy you a second, different refund. Temporal asks the model again only for an answer it never recorded.
- Do not reach for it to get exactly-once tools. You still need idempotency keys on everything that pays, sends or books, and they should name the business operation, not the tool call.
- Do not reach for it for a chat reply. If the user waits on the answer and a retry is acceptable, a request and a timeout are simpler.
- Budget for what comes with it. RoadRunner and
ext-grpcin your images, a Temporal server to run or pay for, workflow code that may not touch most of Laravel, replay tests in CI, because a live PHP worker may not tell you when you broke it, and an alert on failed workflow tasks, because a stuck workflow still reports Running.
Scope and how this was checked
./run.sh runs 19 PHPUnit tests in a Docker image pinned by digest, with PHP 8.5.12, Laravel 13.40.0, Temporal PHP SDK 2.20 and RoadRunner 3.0.1, against Temporal's development server 1.32.2, also pinned. A worker that has to crash kills RoadRunner and all its PHP processes with SIGKILL from inside the refund, so it is a real crash rather than an exception, and a provider that hangs sleeps past the activity timeout after paying. The tests count refunds, emails and model calls in a SQLite file that the tests and the workers share, and read Temporal's event history through the client. The suite passed three times in a row before I wrote this.
The limits matter. The model is scripted, so these tests say nothing about how a real model behaves. They only say what the surrounding code does when a decision changes or does not. The Temporal server is a single development node with SQLite, which is not what Temporal supports for production, and I did not test clustering, worker versioning, deploying changed workflow code, load, or Laravel state leaking between tasks in a long-lived worker. The replay behaviour in the puzzle, on the live worker and in the replayer, was measured with these versions only. Temporal's limits are quoted from its documentation, and the history arithmetic above is arithmetic.