An agent run is rarely one API call. The model asks for tools, your app executes them, results go back, and the loop repeats until the model is done. Until Laravel AI 0.11, that loop was a black box: you got PromptingAgent at the start and AgentPrompted at the end. Five provider round-trips looked the same as one. If the run threw halfway, AgentPrompted never fired, so a listener watching the happy path never learned the job had failed.
That is a production problem, not a nice-to-have. If you are building a client portal that calls tools, summarises documents, or fans work out to sub-agents, you need one ID for the whole run, timings per step, and a failure event when the chain dies. This is a practical rewrite of the Laravel AI 0.11 lifecycle work — what shipped, why it matters on real PHP apps, and how to listen without serialising a novel onto the queue.
The change landed as a stack of pull requests from @pushpak1300, merged as #870 through #876. Coverage on Laravel News is the source for the event list; the examples below are written for people shipping Laravel, not reading release notes.
One ID for the whole run
streamPrompt() already minted a run-level invocation ID. prompt() did not. Synchronous middleware saw $prompt->invocationId === null; streaming middleware saw a real value. Worse, the provider generated its own ID per attempt, so a run that failed over across three providers produced three unrelated IDs for what the caller treats as one job.
prompt() now generates the ID up front and the provider reuses whatever the caller supplied. Every event below takes that ID as its first constructor argument, so you can group a run on one string:
public function __construct(
public string $invocationId,
public int $stepNumber,
// ...
) {}
AgentFailedOver also gained the ID as a required first argument. Listeners are fine. Anything that constructs the event by hand needs updating — the same class of break as adding a required constructor argument anywhere else in Laravel.
Step events
StartingStep, StepCompleted and StepFailed fire around every provider round-trip on both the synchronous and streaming paths.
StartingStep carries the messages sent for that step (including tool results from previous steps) and the options resolved for the step. Those options can differ from the agent’s own once a forced tool choice has been satisfied. It also carries stepNumber and isFinalStep:
use Laravel\Ai\Events\StartingStep;
Event::listen(StartingStep::class, function (StartingStep $event) {
// $event->stepNumber, $event->model, $event->isFinalStep
// $event->messages, $event->options
});
StepCompleted carries the whole StepResponse, so a listener never has to rebuild it, plus float $time — wall time in the provider call, in milliseconds. The unit matches QueryExecuted::$time, so code that already logs query timings can treat these the same way.
use Laravel\Ai\Events\StepCompleted;
Event::listen(StepCompleted::class, function (StepCompleted $event) {
Log::info('AI step completed', [
'invocation' => $event->invocationId,
'step' => $event->stepNumber,
'ms' => $event->time,
'prompt_tokens' => $event->response->usage->promptTokens,
'finish' => $event->response->finishReason->value,
]);
});
Per-step usage used to arrive as a bulk payload on the terminal event, with no timing attached. Now cost and duration of each round-trip are reported as the run happens. StepFailed covers a step that ends without a response: the Throwable plus the time spent before it threw.
If you only need timings and token counts, listen for StepCompleted. If you open a tracing span, you probably want StartingStep — and you need to know what that does to queued listeners (see below).
Tool events
InvokingTool and ToolInvoked already existed, but they were wired through callbacks each provider registered on the generation loop. The current tool invocation ID lived in a single mutable property on the provider. That failed under nesting: because the manager returns one provider instance per name, an agent invoked as a tool overwrote the ID before the outer ToolInvoked fired. The outer event reported the inner call’s ID. That is the sort of bug that only shows up when you actually nest agents — which is exactly when you need the IDs to be right.
A RunContext now carries the run’s identity and dispatches these events directly. The tool invocation ID is generated inside executeTool(), so each invocation gets its own unique ID. Tools can read it from the request:
public function handle(Request $request): string
{
$request->toolInvocationId(); // string|null
}
The new event is ToolFailed. Previously executeTool() had no catch. If a handler threw, the exception left the generation loop: no end event, and the run aborted without recording which tool caused it. ToolFailed reports that failure with the same toolInvocationId as the opening InvokingTool. The exception is still rethrown, so behaviour is otherwise unchanged. Only the handler call is guarded — a listener that throws is not misreported as a tool failure.
ToolInvoked also gained a required float $time (wall time in the handler). Manual constructors need the duration. Both events carry the Tool instance rather than its name, so pair them on toolInvocationId and derive a label from the object:
use Laravel\Ai\Events\ToolFailed;
Event::listen(ToolFailed::class, function (ToolFailed $event) {
Log::error('AI tool failed', [
'invocation' => $event->invocationId,
'tool_invocation' => $event->toolInvocationId,
'tool' => class_basename($event->tool),
'arguments' => $event->arguments,
'ms' => $event->time,
'exception' => $event->exception->getMessage(),
]);
});
Run failure events
Every span-closing event in the package previously sat only on the happy path. If the gateway threw, AgentPrompted was never dispatched. A listener watching the run had no way to know it had failed.
AgentFailed reports that once per run, and only once the run is over. With failover configured, the first provider that throws a FailoverableException is not terminal — the event fires after the chain is exhausted. It carries the invocation ID, the prompt, and the exception.
AgentFailedOver no longer fires for the final provider in a chain. That attempt has nothing to fall back to, so it is reported as the run’s failure instead. Previously the failover event was dispatched in every catch, including the last one. If you alerted on failover, you were also alerting on the actual death of the run. Split those: failover is “we tried the next provider”; AgentFailed is “we are done, and it did not work”.
Linking a sub-agent to its parent
An agent invoked as a tool used to look like a separate run. Nothing correlated it to its parent. A tool call now tracks its run and tool invocation IDs for its duration. Any agent prompted during that tool execution receives them as parentInvocationId and parentToolInvocationId on its prompt.
That covers a hand-written tool that prompts an agent, not just AgentTool. The IDs live in a static property rather than context, which keeps the delegating invocation out of queued job payloads.
The link does not cross a queue boundary. A prompt dispatched with promptOnQueue() from inside a tool starts its own unparented run. If you queue work from a tool, log the parent IDs yourself onto the job — the package will not do it for you.
Queued listeners and message history
StartingStep carries the run’s entire message history. A listener implementing ShouldQueue will serialise all of it, along with any attachments. That is the intended trade-off: a listener that opens a span needs the request the step was sent with. It is also an easy way to blow a Redis payload or a failed-job row.
Practical rule for production:
- Synchronous listener on
StartingStepif you need the prompt body for tracing. - Queued listener on
StepCompleted/ToolInvoked/ToolFailed/AgentFailedif you only need IDs, timings and token counts. - Do not queue
StartingStepunless you have already measured the payload size with real attachments.
A listener set I would actually ship
Keep it boring. One log channel, structured fields, no clever spans on day one:
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Events\AgentFailed;
use Laravel\Ai\Events\StepCompleted;
use Laravel\Ai\Events\ToolFailed;
Event::listen(StepCompleted::class, function (StepCompleted $event) {
Log::channel('ai')->info('step', [
'invocation' => $event->invocationId,
'step' => $event->stepNumber,
'ms' => $event->time,
'tokens' => $event->response->usage->promptTokens,
]);
});
Event::listen(ToolFailed::class, function (ToolFailed $event) {
Log::channel('ai')->error('tool_failed', [
'invocation' => $event->invocationId,
'tool' => class_basename($event->tool),
'ms' => $event->time,
'error' => $event->exception->getMessage(),
]);
});
Event::listen(AgentFailed::class, function (AgentFailed $event) {
Log::channel('ai')->error('agent_failed', [
'invocation' => $event->invocationId,
'error' => $event->exception->getMessage(),
]);
});
Once that is in production for a week, you will know whether you need OpenTelemetry spans, a dashboard, or just better log alerts. Do not start with a tracing product because the events exist.
Breaking changes to watch
- Anything constructing
AgentFailedOverorToolInvokedby hand must pass the new required arguments (invocationId/$time). - Listeners are unaffected as long as they type-hint the event and read public properties.
- Tests that asserted “no
AgentFailedOveron the last provider” will now match reality; tests that expected a failover event on the final catch will fail and should be updated.
Treat agent runs like database work: one ID for the transaction, timings on each statement, and a failure event when the commit never happens.
Further reading
The events shipped in Laravel AI v0.11.0, alongside hosted tool search and wider provider failover. For reading the provider’s own HTTP response during a run — rate-limit headers, request IDs — see the raw HTTP response property added in v0.10.3.
Background: the official AI SDK announcement and coverage of human-in-the-loop tool approval. Source lives at laravel/ai on GitHub.
FAQ
Which Laravel AI version added these events?
v0.11.0. Upgrade the laravel/ai package and treat the new required constructor arguments as a minor breaking change if you build events in tests.
Should I listen to StartingStep or StepCompleted?
StepCompleted for timings and tokens. StartingStep only if you need the outbound messages to open a span — and prefer a synchronous listener so you do not serialise the full history onto a queue.
Do nested agents share an invocation ID?
No. The child run has its own ID, plus parentInvocationId and parentToolInvocationId so you can join the tree. Queued prompts from inside a tool are unparented unless you copy the IDs onto the job yourself.
What happens if a tool throws?
ToolFailed fires with the same toolInvocationId as InvokingTool, then the exception is rethrown. The run still aborts; you just get an end event this time.
Does failover still fire on the last provider?
No. The last provider has nowhere to fail over to, so that attempt is AgentFailed, not AgentFailedOver.
Need a Laravel or custom PHP build that you can actually observe?
I’m Jamie Freeman — a UK web designer and developer. I build Laravel and plain-PHP tools for small businesses: portals, integrations, and the unglamorous logging that tells you why a job died at 2am.
If you are adding AI agents to a real app and want traces, failure handling and a deploy you can reverse: get a free quote →