PRODUCTION-GRADE AI ARCHITECTURE
AUTONOMOUS AGENT COORDINATION, TOOL CALLING AND MCP INFRASTRUCTURE

Building a Laravel AI Agent (II): Orchestration, Tool Execution, and State Management

A laravel ai agent is not just a chat completion wrapped in a controller method. A wrapper sends one prompt, gets one response, and stops. An agent reasons across multiple steps: it decides whether it needs a tool, calls that tool, reads the result, and decides again, until it has enough to answer or it hits a hard limit. That loop, the decision-execute-reflect cycle, is what separates an agent from a fancy autocomplete.

The good news for Laravel AI Architecture work in 2026: you don’t have to build that loop. The first-party laravel/ai SDK ships it. This article picks up from Building a Laravel AI Agent: Human-in-the-Loop Approval; if you haven’t put an approval gate in front of your agent’s sensitive actions yet, start there, then come back here for the orchestration layer around it. Your job in this half is to define what the agent knows, what it can touch, and how it runs in production: off the request thread, streamed to the browser, tested without burning API credits.

Why You Shouldn’t Hand-Roll the Agent Loop

[Architect’s Note] Before laravel/ai shipped in February 2026, teams built this themselves: a messages table, a step counter on a job, a switch statement dispatching tool calls through app()->call(). It worked. It also meant every team reinvented conversation persistence, approval flows, and step-limit enforcement slightly differently, and every one of those custom implementations was a maintenance liability the moment the underlying provider changed its tool-calling format.

The SDK now owns that surface. An Agent class defines instructions, tools, and (optionally) a structured output schema. A Tool class defines a handle method and a JSON schema for its arguments. The framework resolves tools through the Service Container, so constructor dependencies just work. Conversation state persists to two tables the SDK migrates for you. Step limits are a PHP attribute, not a manually incremented counter you have to remember to check.

If you’re still running a pre-2026 implementation with a custom loop, migrating to the native Agent contract is worth the sprint. You lose bespoke code, not functionality; everything below (memory, approval, streaming, queueing) has fewer moving parts under the SDK than under a custom build.

Contrast this with Prism PHP for agentic apps, which operates a layer lower: laravel/ai is actually built on top of Prism’s provider abstraction. If you need to drop below the Agent contract for exotic provider behavior Prism exposes but the SDK doesn’t yet wrap, that article covers the raw layer. For the vast majority of production agents, you want the SDK layer, not the Prism layer.

Defining the Agent

Generate the agent class with Artisan:

php artisan make:agent SupportAgent

This scaffolds a class implementing the Agent contract. Instructions, conversation context, and tools all live here as methods:

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\LookupOrder;
use App\Ai\Tools\SearchKnowledgeBase;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
#[MaxSteps(8)]
#[MaxTokens(4096)]
#[Timeout(90)]
class SupportAgent implements Agent, Conversational, HasTools
{
    use Promptable, RemembersConversations;

    public function instructions(): string
    {
        return 'You help customers with order status and product questions. '
            .'Use tools to look up real data before answering. Never guess an order status.';
    }

    /**
     * @return \Laravel\Ai\Contracts\Tool[]
     */
    public function tools(): iterable
    {
        return [
            new LookupOrder,
            new SearchKnowledgeBase,
        ];
    }
}

[Efficiency Gain] #[MaxSteps(8)] is the single most important line in this class. Without it, a poorly-scoped tool set can loop until it hits the provider’s own step ceiling, and you pay for every one of those calls. Set it explicitly and set it low. Eight is generous for a support agent; a single-purpose data lookup agent rarely needs more than three.

Adding Conversation Memory

Notice RemembersConversations in the use statement above. That trait, combined with the Conversational interface, is what gives this agent multi-turn memory without a custom schema. It persists to the agent_conversations and agent_conversation_messages tables created when you ran the SDK’s migrations.

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Starting and resuming a conversation is two method calls:

// Start a new conversation for the authenticated user
$response = (new SupportAgent)->forUser($user)->prompt('Where is my order #4471?');
$conversationId = $response->conversationId;

// Resume it later
$response = (new SupportAgent)
    ->continue($conversationId, as: $user)
    ->prompt('Has it shipped yet?');

[Production Pitfall] Do not define a messages() method on an agent that also uses RemembersConversations. If both are present, the manual messages() method wins silently and the trait’s database-backed history never loads. This is the single most common bug reported against agents that “forget” context after the first request; the fix is to delete the manual method, not to add logging around it.

If you need cross-session recall beyond what a conversation ID gives you (summarized long-term memory, user preference extraction), pair this with the patterns in Laravel AI agent memory persistence, which covers building a summarization layer on top of the SDK’s raw message history.

Building Tools

Tools follow the same scaffold-then-implement pattern:

php artisan make:tool LookupOrder
<?php

namespace App\Ai\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class LookupOrder implements Tool
{
    public function description(): Stringable|string
    {
        return 'Look up an order by its order number and return its current status.';
    }

    public function handle(Request $request): Stringable|string
    {
        $order = Order::query()
            ->where('order_number', $request['order_number'])
            ->orderByDesc('created_at')
            ->first();

        if (! $order) {
            return "No order found with number {$request['order_number']}.";
        }

        return "Order {$order->order_number} is currently: {$order->status}.";
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_number' => $schema->string()->required(),
        ];
    }
}

Guarding Against Hallucinated Arguments

The schema() method isn’t optional decoration. It’s a JSON Schema contract the provider is required to satisfy before the tool is invoked, and it’s your first line of defense against a model inventing an argument shape that doesn’t match your database. For anything beyond simple string/integer arguments, apply the stricter validation patterns from hardening agentic workflows against schema hallucinations, particularly around enum constraints and nested object validation, both of which the JsonSchema builder supports directly.

[Edge Case Alert] orderByDesc('created_at') above matters more than it looks. If your order_number column isn’t unique (some systems reuse numbers across a returns/reorder flow), an unordered query returns whichever row the database feels like returning that day. Always pin an explicit sort direction on any tool query that could plausibly return more than one row, even if your schema currently guarantees uniqueness. Schemas change; tool bugs from an unpinned orderBy are quiet and hard to reproduce.

Sensitive Actions Need Approval, Not Just a Schema

A read-only lookup tool is safe to auto-execute. A tool that cancels an order or issues a refund is not. The first article in this series builds the full confirmation-gate pattern for exactly this problem: a PendingApproval record, an operator review step, a queued execution job, and expiry handling. That pattern holds regardless of which layer you’re building on.

If you’re working with the native Agent contract this article covers, the SDK gives you the same guarantee with less code to maintain. Implement Approvable and use the InteractsWithApprovals trait on the tool:

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class IssueRefund implements Approvable, Tool
{
    use InteractsWithApprovals;

    public function description(): Stringable|string
    {
        return 'Issue a refund for an order.';
    }

    public function handle(Request $request): Stringable|string
    {
        // Refund logic here...
        return "Refund issued for order {$request['order_number']}.";
    }

    protected function needsApproval(Request $request): Approval|bool
    {
        return Approval::required('This will issue a real refund.');
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_number' => $schema->string()->required(),
            'amount' => $schema->number()->required(),
        ];
    }
}

When the agent calls this tool, execution pauses. Your application inspects $response->pendingApprovals, surfaces the reason and arguments to a human, and resumes the conversation with Decision::approve() or Decision::reject(). This requires a Conversational agent with persisted history, since the pause has to survive until a human acts on it, which is what RemembersConversations gives you automatically.

[Architect’s Note] The operator-facing side, the notification, the review interface, the audit trail, is the same job either way. The SDK’s Approvable contract just replaces the PendingApproval table, the lock-guarded controller, and the match-expression execution job from the first article with framework-managed state. If you’re already running that pattern against Prism PHP directly, there’s no urgency to migrate it; both are production-correct. Reach for the native version when you’re starting a new agent on the Agent contract from scratch.

Running Agents Off the HTTP Thread

Every step in the loop above (prompt, tool call, tool execution, reflection) is a round trip to an LLM provider. Multi-step agents routinely exceed the 30 to 60 second timeout most web servers and proxies enforce on a single HTTP request. Prompting an agent synchronously from a controller is fine for a quick single-tool lookup. It is the wrong default for anything with a MaxSteps above two or three.

use App\Ai\Agents\SupportAgent;
use Illuminate\Http\Request;
use Laravel\Ai\Responses\AgentResponse;
use Throwable;

Route::post('/support/ask', function (Request $request) {
    (new SupportAgent)
        ->forUser($request->user())
        ->queue($request->input('question'))
        ->then(function (AgentResponse $response) {
            // Persist, notify, or broadcast the final result
        })
        ->catch(function (Throwable $e) {
            report($e);
        });

    return back();
});

Queued agents need workers that survive the same load spikes as any other AI workload. If you haven’t tuned your worker configuration specifically for long-running, retry-prone AI jobs, walk through configuring Laravel Horizon for AI queue workloads before shipping this in front of real traffic. Timeout and retry defaults tuned for a typical mail-sending queue worker are wrong for a job that might legitimately take 45 seconds across three tool calls.

Streaming Agent State to the Browser

Queueing solves the timeout problem but introduces a UX problem: the user is now staring at a spinner with no feedback for however long the loop takes. The SDK’s streaming and broadcasting methods exist specifically to close that gap.

For a synchronous SSE stream direct from a route:

use App\Ai\Agents\SupportAgent;

Route::get('/support/stream', function () {
    return (new SupportAgent)->stream('Where is my order #4471?');
});

For a queued agent whose progress needs to reach a specific channel:

use App\Ai\Agents\SupportAgent;
use Illuminate\Broadcasting\Channel;

(new SupportAgent)->broadcastOnQueue(
    'Where is my order #4471?',
    new Channel('support-conversation.'.$conversationId),
);

This is the piece that lets you show live states like “Looking up your order” or “Checking the knowledge base” instead of a static spinner. For the frontend side of consuming these events over a WebSocket connection, Laravel Reverb’s token-by-token WebSocket delivery covers the Echo listener setup this pairs with.

[Production Pitfall] Tool results can be large: a LookupOrder call that returns a full order history, for instance. Most WebSocket transports cap individual messages around 10KB, and a broadcast that exceeds that limit fails silently on some setups. Apply #[WithoutBroadcasting(ToolCall::class, ToolResult::class)] at the class level if your tools return anything heavier than short text. The events are still persisted to agent_conversation_messages, so the frontend can fetch the full tool payload after the stream completes; you’re only skipping the live broadcast of the oversized event, not losing the data.

Governing Cost, Failover, and Observability

A laravel/ai agent dispatches events at every meaningful point in its lifecycle: PromptingAgent, ToolInvoked, AgentPrompted, ToolApprovalRequested, and more. Listening to these is how you build token cost tracking and audit trails without touching the SDK’s internals:

use Laravel\Ai\Events\AgentPrompted;

Event::listen(AgentPrompted::class, function (AgentPrompted $event) {
    Log::channel('ai-telemetry')->info('Agent prompted', [
        'agent' => $event->agent::class,
        'usage' => $event->response->usage,
    ]);
});

For provider resilience, pass an array to provider when prompting, and the SDK fails over automatically on rate limits or provider overload:

$response = (new SupportAgent)->prompt(
    'Where is my order #4471?',
    provider: [Lab::Anthropic, Lab::OpenAI],
);

Failover only triggers on FailoverableException types (RateLimitedException, ProviderOverloadedException, InsufficientCreditsException); a validation error in your own tool schema won’t trigger a silent retry against a different provider, which is the correct behavior since that class of error is your bug, not the provider’s.

If you’re building this out into a full governance layer with dashboards, per-agent cost breakdowns, and audit trails, that’s the exact territory covered in the production-grade Laravel AI architecture pillar, which treats these events as the foundation of a telemetry system rather than one-off log lines.

Testing the Agent Without Burning API Credits

Agent::fake() intercepts every prompt, stream, and queue call:

use App\Ai\Agents\SupportAgent;

public function test_agent_answers_order_status(): void
{
    SupportAgent::fake([
        'Your order has shipped and is on the way.',
    ]);

    $response = (new SupportAgent)->prompt('Where is my order?');

    $this->assertStringContainsString('shipped', (string) $response);

    SupportAgent::assertPrompted(fn ($prompt) => $prompt->contains('order'));
}

Call SupportAgent::fake()->preventStrayPrompts() in your test suite’s base setup if you want any un-faked agent call to throw instead of silently hitting a real provider during CI. This has caught more than one accidental live API call in test suites that forgot to fake a newly added agent.

System Architecture Synthesis and Trade-offs

The pattern across all of this is the same one Laravel has applied to queues, auth, and search before: take a thing every team was building slightly wrong by hand, and make the correct version the default. An agent built on MaxSteps, RemembersConversations, queue(), and the event system costs you less code than a custom loop and gives you approval flows, failover, and testability you’d otherwise have to build separately.

The real engineering decisions left to you aren’t about the loop mechanics; they’re about tool design and guardrails. How many tools does an agent actually need before its decision quality degrades. Where does synchronous prompting stop being acceptable and queueing become mandatory. Which tools are read-only and which need Approvable. Get those three right, wire in telemetry from day one, and the rest of the architecture is largely settled by the framework rather than by you.



Frequently Asked Questions

Does the laravel/ai SDK replace Prism PHP?

Not exactly. laravel/ai is built on top of Prism’s provider layer and is the right default for most application-level agent work. Prism remains useful when you need direct control over provider-specific request shaping that the SDK hasn’t wrapped yet.

Why use MaxSteps instead of just trusting the model to stop?

Models occasionally get stuck in unproductive tool-calling loops, especially when a tool result is ambiguous. MaxSteps is a hard ceiling enforced by the framework regardless of what the model decides, so a misbehaving loop has a bounded cost instead of an open-ended one.

Can I use the SDK’s Agent class with a provider that isn’t officially in the pinned list?

Yes, provided it’s one of the SDK’s supported drivers (OpenAI, Anthropic, Gemini, Groq, Mistral, DeepSeek, xAI, Ollama, or an OpenAI-compatible endpoint). Always verify current model names against your provider’s active model list before deploying; provider deprecation cycles move faster than this article’s publish date.

Dewald Hugo

A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Scroll to Top