Design & Development16 min read

Building Multi-Agent Systems in Laravel: Sub-Agents, Orchestrators & When Not to Bother

Ritesh PatelBy Ritesh Patel|July 7, 2026

Late last year we shipped a support agent for a client platform — Laravel on the backend, one agent, a growing list of tools. It started with four: look up an order, check the refund policy, search the docs, escalate to a human. By the time it hit fifteen tools, something strange happened. The agent got worse.

Not catastrophically worse. Quietly worse:

  • It would call the refund-policy tool to answer a shipping question — roughly one interaction in eight.
  • It would pull the full documentation search when a one-line account lookup was the obvious move.

Latency crept up because every wrong tool call meant another round trip to recover from. Nothing was broken; everything was slightly off.

The fix wasn't a smarter prompt, and it wasn't a bigger model. It was admitting that one generalist with fifteen tools is a worse employee than a small team of specialists with three or four each. The Laravel AI SDK has quietly grown everything you need to build that team in pure PHP — and most of it hides behind a single, elegant idea. This post is the deep dive: how sub-agents actually work under the hood, what crosses the boundary between agents and what deliberately doesn't, and — just as important — when you should refuse to build any of this.

In This Article

The short version — if you only read one section:

  • Adding tools makes an agent dumber past a point. Tool selection accuracy degrades as the list grows; specialization — not prompt engineering — is the fix.
  • In the Laravel AI SDK, a sub-agent is just an agent returned from tools(). One match statement wraps agents, MCP tools, and your own tools into the same contract. There is no separate orchestration framework to learn.
  • Sub-agents run in deliberate isolation. No parent history crosses the boundary — which is both the design's best privacy property and its most common failure mode.
  • The discipline matters more than the feature. Every delegation is an extra LLM round trip. Split when instructions, tools, or models genuinely diverge — not because the diagram looks better with more boxes.

When One Agent Stops Being Enough

The failure mode we hit is well understood, even if it's rarely discussed in framework docs: tool overload. An agent picks its next action by reading the names and descriptions of every tool you've given it, every single turn. Each additional tool is another candidate to weigh, another description competing for attention in the context window, another chance to pick almost-right.

With four tools, the choice is nearly always obvious. Past ten or twelve, the descriptions start to blur — especially when tools are related. "Look up the order," "look up the refund policy for an order," and "check refund eligibility" read very differently to you and dangerously similarly to a model deciding in a few hundred milliseconds. Our one-in-eight wrong-tool rate wasn't the model being weak. It was us handing it a junk drawer and calling it a toolbox.

Here's the counter-intuitive part: the instinct when an agent underperforms is to add — more instructions, more examples, more clarifying tools. Almost always, the winning move is to subtract. Give the agent fewer, sharper choices, and push the specialized judgment down into agents built for exactly one job. That's what sub-agents are for: each specialist carries its own instructions, its own small tool set, its own model — and the parent only ever has to make an easy decision: who does this belong to?

In practice: before reaching for architecture, audit your agent's tool list the way you'd audit a bloated service class. If you can't explain in one sentence why the model would pick tool A over tool B for a given input, the model can't either. That confusion is the signal to split — and the Laravel AI SDK makes the split almost embarrassingly small.

The Core Mechanic: An Agent Is Just a Tool

Sub-agent support arrived quietly — a community PR merged into laravel/ai v0.6.8 back in May 2026, with barely a release-note bullet. The design it landed on is the best kind of framework decision: instead of inventing an orchestration layer, Laravel made agents fit an interface that already existed.

A sub-agent is an agent returned from another agent's tools() method. That's the whole API:

PHP
1<?php
2
3namespace App\Ai\Agents;
4
5use Laravel\Ai\Contracts\Agent;
6use Laravel\Ai\Contracts\HasTools;
7use Laravel\Ai\Promptable;
8
9class CustomerSupportAgent implements Agent, HasTools
10{
11    use Promptable;
12
13    public function instructions(): string
14    {
15        return 'You help customers with account, order, and billing questions. '
16            .'Delegate refund policy questions to the refunds specialist.';
17    }
18
19    public function tools(): iterable
20    {
21        return [
22            new RefundsAgent,
23        ];
24    }
25}

No registration, no config file, no orchestrator class. You put an agent where a tool goes, and the SDK does the rest. The "rest" is one match statement inside the text-generation pipeline, and it's worth reading because it explains the entire composition story of the Laravel AI SDK in nine lines:

PHP
1protected function resolveTool(mixed $tool): mixed
2{
3    return match (true) {
4        $tool instanceof Agent => new AgentTool($tool),
5        $tool instanceof Tool => $tool,
6        McpTool::supports($tool) => new McpTool($tool),
7        McpServerTool::supports($tool) => new McpServerTool($tool),
8        default => $tool,
9    };
10}

Anything in your tools() array gets normalized to the same contract. A hand-written tool passes through untouched. An MCP tool gets wrapped — MCP being the Model Context Protocol, the open standard that lets AI models use tools exposed by external servers, which Laravel's SDK gained in June. And an Agent gets wrapped in AgentTool — the adapter that makes an entire agent look, to the parent model, like any other function it can call. Everything is a tool; the model never learns the difference.

If you're starting fresh, php artisan make:agent scaffolds the class — and because agents are resolved through the container, they can take constructor dependencies like any other Laravel service. Your specialist can inject a repository the same way a controller would.

What the Parent Sees — and What It Never Sees

This is where the design earns its keep, and where you need to build an accurate mental model before shipping anything.

When AgentTool wraps your sub-agent, the parent model receives a tool with exactly one parameter — a string called task:

PHP
1public function schema(JsonSchema $schema): array
2{
3    return [
4        'task' => $schema->string()
5            ->description('The task to delegate to this agent.')
6            ->required(),
7    ];
8}
9
10public function handle(Request $request): string
11{
12    try {
13        return $this->agent->prompt((string) $request['task'])->text;
14    } catch (Throwable $e) {
15        return 'Agent failed: '.$e->getMessage();
16    }
17}

Read handle() closely, because it defines the runtime behavior of every multi-agent system you'll build on this SDK. When the parent delegates, the sub-agent is invoked with a plain prompt() call — a synchronous, self-contained LLM invocation. The sub-agent runs its own loop, uses its own tools, and returns only its final text. The parent gets that text back as an ordinary tool result and carries on reasoning.

Just as interesting is what doesn't cross the boundary — in either direction:

  • Down: the sub-agent receives only the task string. No parent conversation history, no prior tool results, no user metadata. If the specialist needs the order number, the parent has to put it in the task.
  • Up: the parent never sees the sub-agent's instructions. The SDK's test suite pins this down explicitly — a sub-agent's tool description must not leak its internal instructions. The parent sees a name and a one-line description, nothing more.

That second point is a genuinely useful privacy property. Your refunds specialist might carry detailed internal policy in its instructions — thresholds, exception rules, escalation criteria you'd rather not expose. None of it enters the parent's context window. The specialist's system prompt stays private to the specialist, which also means it isn't burning the parent's tokens on every turn.

In practice: treat the task string like a work order handed to a contractor who has never seen your project. We write parent instructions that explicitly say what to include when delegating — "always pass the order ID and the customer's country" — because the model won't reliably infer it. The one-line change to the parent's instructions eliminated most of our early delegation failures.

Naming the Specialist: CanActAsTool

By default, the SDK derives the tool identity from the class itself: the tool name is the agent's class basename, and the description is generic — it tells the parent it "delegates a task to the RefundsAgent sub-agent" and instructs it to pass a clear, self-contained task description because the sub-agent runs in isolation.

That default is fine for a prototype. For production, you want to control both fields, because the description is effectively the specialist's job posting — it's the only information the parent model uses to decide when to delegate. You do that by implementing CanActAsTool:

PHP
1<?php
2
3namespace App\Ai\Agents;
4
5use App\Ai\Tools\LookupOrder;
6use Laravel\Ai\Attributes\Provider;
7use Laravel\Ai\Contracts\Agent;
8use Laravel\Ai\Contracts\CanActAsTool;
9use Laravel\Ai\Contracts\HasTools;
10use Laravel\Ai\Enums\Lab;
11use Laravel\Ai\Promptable;
12
13#[Provider(Lab::Anthropic)]
14class RefundsAgent implements Agent, CanActAsTool, HasTools
15{
16    use Promptable;
17
18    public function instructions(): string
19    {
20        return 'You are a refunds specialist. Use order details and the '
21            .'refund policy to give concise eligibility guidance.';
22    }
23
24    public function name(): string
25    {
26        return 'refunds_specialist';
27    }
28
29    public function description(): string
30    {
31        return 'Determine whether an order is eligible for a refund and explain the next step.';
32    }
33
34    public function tools(): iterable
35    {
36        return [
37            new LookupOrder,
38        ];
39    }
40}

The contract is two methods — name() and description() — and the difference they make is disproportionate. A parent choosing between RefundsAgent ("delegates a task to the RefundsAgent sub-agent") and refunds_specialist ("determine whether an order is eligible for a refund and explain the next step") is making a much easier decision in the second case. Same specialist, better job posting, fewer misroutes.

In practice: write sub-agent descriptions the way you'd write a great commit message — specific about what it does and silent about how. Include the boundary in the description when tools are adjacent: "refund eligibility only; does not process the refund" saved us from the parent delegating actions the specialist was never meant to take.

Per-Agent Brains: Model, Provider and Cost Control

Notice the attribute on RefundsAgent above: #[Provider(Lab::Anthropic)]. Every agent resolves its own provider and model — independently of whoever calls it. This, more than anything, is the practical argument for multi-agent over one-agent-many-tools.

The SDK gives you a small vocabulary of attributes for it, all resolved per agent at invocation time:

PHP
1use Laravel\Ai\Attributes\Provider;
2use Laravel\Ai\Attributes\UseCheapestModel;
3use Laravel\Ai\Attributes\UseSmartestModel;
4use Laravel\Ai\Enums\Lab;
5
6#[UseCheapestModel]
7class SupportRouterAgent implements Agent, HasTools { /* routes work */ }
8
9#[Provider(Lab::Anthropic)]
10#[UseSmartestModel]
11class ContractAnalysisAgent implements Agent, CanActAsTool { /* hard reasoning */ }

UseCheapestModel and UseSmartestModel pick from whatever provider the agent resolves to; a Model attribute (or a model() method) pins an exact model; a Timeout attribute overrides the 60-second default for long-running specialists. Prefer methods over attributes when the choice is dynamic — the SDK checks for provider() and model() methods first.

The pattern that pays for itself: cheap orchestrator, expensive specialist. The parent that only routes — "is this a refund question or a shipping question?" — doesn't need frontier-model reasoning, so it runs on the cheapest model and answers fast. The one specialist doing genuinely hard work gets the smartest model available. You spend where the difficulty is instead of paying frontier prices for traffic-cop decisions.

Two more production details come free with Promptable. First, provider failover applies per agent: configure a failover chain, and a specialist can fail over from one provider to another mid-workflow, firing an AgentFailedOver event you can log — the parent never knows. Second, every agent carries the full invocation surface — prompt(), stream(), queue(), broadcast() — so the same RefundsAgent you use as a sub-agent can also be dispatched to a queue as a standalone worker. Just remember: when it's invoked as a tool, it's always the synchronous prompt() path.

In practice: we route on data residency as much as cost. A client with strict data-locality requirements runs the specialist that touches customer PII on one provider while the general orchestrator runs on another. Per-agent provider attributes turn what used to be an infrastructure conversation into a one-line annotation.

Orchestrators and Multi-Level Hierarchies

Because a sub-agent is just an agent, and any agent can have sub-agents in its tools(), hierarchies nest as deep as you want. The SDK's own test fixtures model a three-level chain — an orchestrator that delegates to a middle manager that delegates to a researcher:

PHP
1class OrchestratorAgent implements Agent, CanActAsTool, HasTools
2{
3    use Promptable;
4
5    public function name(): string
6    {
7        return 'orchestrator';
8    }
9
10    public function instructions(): string
11    {
12        return 'You are an orchestrator that breaks down complex tasks '
13            .'and delegates to the middle_manager sub-agent.';
14    }
15
16    public function tools(): iterable
17    {
18        return [new MiddleManagerAgent];
19    }
20}
21
22class MiddleManagerAgent implements Agent, CanActAsTool, HasTools
23{
24    use Promptable;
25
26    public function name(): string
27    {
28        return 'middle_manager';
29    }
30
31    public function tools(): iterable
32    {
33        return [new ResearchAgent];
34    }
35}
36
37class ResearchAgent implements Agent, CanActAsTool
38{
39    use Promptable;
40
41    public function name(): string
42    {
43        return 'research_agent';
44    }
45
46    public function description(): string
47    {
48        return 'Research a topic in depth and return a summary.';
49    }
50
51    public function instructions(): string
52    {
53        return 'You are a research agent. Summarize your findings concisely.';
54    }
55}

Note the leaf: ResearchAgent implements no HasTools at all. A specialist that only reasons — summarize, classify, draft — needs nothing but instructions. Tasks flow down the tree as strings; answers flow back up as text; each level re-frames the work for the level below.

The delegation tree in the Laravel AI SDK — a parent CustomerSupportAgent on the cheapest model routes work to two isolated specialists, refunds_specialist (own instructions, Anthropic, LookupOrder tool) and research_agent (own instructions, smartest model, no tools); task strings flow down, response text flows up, and every delegation is one full LLM round trip

Two honest caveats before you build an org chart out of agents:

  • Every level is a full additional LLM round trip. A three-level chain means at minimum three sequential model calls before the user sees anything.
  • Each delegation shrinks the context. The middle manager only knows what the orchestrator put in its task string.

Yes, the fixture naming is a wink — a middle_manager that mostly re-describes work and passes it down is exactly the layer you should be suspicious of, in software as in companies.

In practice: the shape that has earned its complexity for us is wide-and-shallow, not deep. One orchestrator, three or four specialists, one level. We use a two-level chain in a content pipeline — planner delegating to researcher and writer specialists — and even there we flattened an early three-level design because the middle layer added a round trip without adding judgment.

Mixing Sub-Agents, MCP Tools and Your Own

Remember resolveTool() from earlier — the match that wraps whatever it finds? Its real payoff is composition. Sub-agents, MCP server tools, and hand-written tools all live in the same tools() array, and the parent model treats them identically:

PHP
1use Laravel\Mcp\Client;
2
3public function tools(): iterable
4{
5    return [
6        // A specialist agent, wrapped in AgentTool automatically
7        new RefundsAgent,
8
9        // Every tool from a remote MCP server (bearer-authenticated)
10        ...Client::web('https://nightwatch.example.com/mcp')
11            ->withToken($token)
12            ->tools(),
13
14        // A tool you wrote by hand
15        new SendSlackMessage,
16    ];
17}
One tools() array, three kinds of capability — a sub-agent wrapped in AgentTool, MCP server tools wrapped in McpTool, and your own hand-written tool all normalized by resolveTool() into one contract that the AI agent treats identically

We covered the MCP side in depth in last month's roundup — the client landed in laravel/mcp v0.8.0 this June, a month after sub-agents. Put the two together and the composition story is complete: your agent's capabilities can come from agents you built, servers anyone built, and functions you wrote, all through one method returning one array.

This is the quiet strategic point about building agents in PHP right now. There's no separate orchestration framework to adopt, no graph DSL to learn, no Python sidecar to deploy. The entire multi-agent surface is: return agents from tools(). Everything else — Laravel's container, queues, events, testing — you already know.

In practice: our ops agent for one client is exactly this mix — a diagnostics specialist (sub-agent) that reads Nightwatch through MCP, plus a hand-written escalation tool that opens a ticket. When the client asks "why was checkout slow last night?", one agent consults another agent, which consults an MCP server, and the answer comes back through a single conversation. Nobody wrote an integration layer.

Isolation, Failure and Testing

Four runtime behaviors decide whether your multi-agent system is production-grade or a demo. All four are pinned down in the SDK's source and tests.

Isolation is total, and it's on you to bridge it. A sub-agent gets the task string and nothing else. The default tool description literally warns the parent model about this — it asks for "a clear, self-contained task description" because the sub-agent "runs in isolation and has no access to the parent conversation history." Design for it: parents must be instructed to pass identifiers and context explicitly. The bugs this prevents are worse than the verbosity it costs — shared mutable context is exactly how multi-agent systems in other ecosystems become undebuggable.

Failure degrades, it doesn't crash. Look back at AgentTool::handle() — the entire sub-agent invocation is wrapped in a try/catch, and a throwable becomes the string Agent failed: ... returned as the tool result. Your parent agent survives a specialist blowing up; the model reads the failure text and can retry, rephrase, or apologize. The flip side: nothing throws. If you're monitoring a production agent, watch for that string in tool results, because that's your only signal a specialist is failing.

Tracing is events, not magic. The honest weakness of deep hierarchies is debuggability: when the answer comes back wrong, which level got it wrong? The SDK's answer is its event surface. Every tool call — and a sub-agent call is just a tool call — dispatches InvokingTool before and ToolInvoked after, each carrying the agent, the tool, the arguments, the result, and two correlation UUIDs (invocationId for the run, toolInvocationId for the specific call). One listener gives you a full delegation log:

PHP
1use Illuminate\Support\Facades\Event;
2use Illuminate\Support\Facades\Log;
3use Laravel\Ai\Events\ToolInvoked;
4
5Event::listen(ToolInvoked::class, function (ToolInvoked $event) {
6    Log::info('agent tool call', [
7        'invocation' => $event->invocationId,
8        'tool_call'  => $event->toolInvocationId,
9        'agent'      => $event->agent::class,
10        'tool'       => $event->tool->name(),
11        'arguments'  => $event->arguments, // for a sub-agent: the task string
12        'result'     => $event->result,    // for a sub-agent: its response text
13    ]);
14});

Because sub-agents run through the same pipeline, PromptingAgent and AgentPrompted fire for each specialist's own run too — so you can reconstruct the whole tree: who delegated what to whom, with which task string, and what came back. Be clear about the limit, though: you're logging what crossed each boundary, not the model's internal reasoning about why it delegated. That's still inference from the task strings. In production we log every boundary crossing and alert on the Agent failed: prefix in tool results — between the two, most misroutes are diagnosable from the log alone.

Every agent fakes independently — test the delegation, not the model. This is the part that made us comfortable shipping multi-agent to clients. The SDK's faking layer works per agent class, so you can script the parent's tool call and the specialist's answer, then assert on exactly what crossed the boundary:

PHP
1use Laravel\Ai\Prompts\AgentPrompt;
2use Laravel\Ai\Responses\Data\ToolCall;
3
4DelegatingAgent::fake([
5    new ToolCall('call_123', 'research_agent', ['task' => 'Research Laravel']),
6    'Research delegated.',
7]);
8
9ResearchAgent::fake(['Research result']);
10
11$response = (new DelegatingAgent)->prompt('Delegate research about Laravel');
12
13ResearchAgent::assertPrompted(
14    fn (AgentPrompt $prompt) => $prompt->prompt === 'Research Laravel'
15);
16
17expect($response->toolCalls->first()->name)->toBe('research_agent')
18    ->and($response->toolResults->first()->result)->toBe('Research result');

That assertPrompted on the sub-agent is the assertion that matters: it verifies the parent passed a well-formed, self-contained task across the isolation boundary. No live provider, no network, and the tool-call plumbing still runs the real code path.

In practice: we write one delegation test per specialist as a contract test — "when the user asks X, the parent delegates to Y with a task containing Z." When someone edits the parent's instructions three months later and breaks routing, the test fails before the client notices. It's the multi-agent equivalent of testing that your route hits the right controller.

When Not to Reach for Sub-Agents

Now the discipline. Having spent this whole post on the machinery, here's the take that matters more: most Laravel apps that think they need multi-agent need one good agent.

Every delegation costs you a full LLM round trip — latency and tokens — plus the re-explaining tax of the isolation boundary. A parent that delegates three times to answer one question has made at least four model calls where a well-scoped single agent might have made one. That's not an optimization detail; it's often the difference between a two-second answer and a nine-second one.

Split when the divergence is real:

  • A task needs different instructions long or sensitive enough that they shouldn't live in (or leak into) the parent's context.
  • A task needs a different model or provider — the cheap-router/smart-specialist pattern, or a data-residency boundary.
  • The tool list has grown past ten or twelve tools and you can watch selection accuracy degrade.
  • A capability needs to exist standalone as well — the same specialist serving as a sub-agent and as a queued worker.

Don't split because the diagram looks more impressive, because a tutorial did, or because "agents delegating to agents" demos well. We flattened one of our own hierarchies this year; the two-level version was faster, cheaper, and easier to debug, and the client couldn't tell the difference — except in latency, where they could.

Step back and the trajectory is clear, though. In three releases across two months, the Laravel AI SDK went from tools, to MCP servers, to agents-as-tools — all converging on one array in one method. For teams already running Laravel or Moodle LMS platforms with Laravel-based services around them, the case for building agents next to the app they serve, in the language the team already ships, keeps getting stronger. The primitives are boring in the best way. What's left is the judgment about when to use them — which, conveniently, is the part you can't composer require.

Tip
Building an AI agent into a Laravel application — or deciding whether a multi-agent setup is worth it for your use case? We've spent 11+ years shipping Laravel systems and the last two building production agent architectures on them — support agents, ops agents, and e-learning platforms on Moodle LMS. Get a free quote or schedule a call and we'll tell you honestly if one agent is enough.

Related reading:

Frequently Asked Questions

What is a sub-agent in the Laravel AI SDK?
A sub-agent is an agent returned from another agent's tools() method. Since laravel/ai v0.6.8, the SDK wraps any Agent instance found in that array in an AgentTool, which exposes it to the parent model as an ordinary tool with a single task parameter. When the parent decides to delegate, the SDK invokes the sub-agent's prompt() method with that task string and hands the resulting text back as the tool result. The sub-agent keeps its own instructions, tools, model, and provider — so a general-purpose agent can hand specialized work to a specialist without inheriting any of its complexity.
Do sub-agents share conversation history with the parent agent?
No — and this is the most important design constraint to understand. Each sub-agent invocation runs in complete isolation. It receives only the task string the parent passes and has no access to the parent's conversation history, prior tool results, or user context. The SDK's own default tool description even instructs the parent model to pass a clear, self-contained task description for exactly this reason. If your sub-agent needs an order ID or a customer name to do its job, the parent has to include it in the task string explicitly. Assuming shared memory is the most common way multi-agent setups fail quietly.
Can each sub-agent use a different model or provider?
Yes, and this is one of the strongest practical reasons to split. Every agent resolves its own provider and model independently — via the Provider and Model attributes, a provider() or model() method, or the UseCheapestModel and UseSmartestModel attributes that pick from whatever provider the agent runs on. A common production pattern is a cheap, fast model on the orchestrator that only routes work, with a stronger model reserved for the one specialist doing genuinely hard reasoning. Provider failover also applies per agent, so a specialist can fail over across providers without the parent ever noticing.
When should I split one agent into multiple agents?
Later than you think. Every sub-agent call is a full additional LLM round trip — more latency, more tokens, more cost — and the isolation boundary means you pay a re-explaining tax on every delegation. Split when specialties genuinely diverge: when a task needs its own instructions, its own tool set, or its own model and provider, or when a single agent's tool list has grown past roughly ten to twelve tools and selection accuracy is visibly degrading. Do not split because the architecture diagram looks better with more boxes. One well-scoped agent with eight tools beats a five-agent hierarchy doing the same job slower.

Ready to start your project?

Tell us about your requirements and we'll get back with a clear plan within 24 hours. No sales pitch — just an honest conversation.

Ritesh Patel
About the Author
Ritesh Patel
Co-Founder & CTO, Treesha Infotech

Co-founded Treesha Infotech and leads all technology decisions across the company. Full-stack architect with deep expertise in Laravel, Next.js, AI integrations, cloud infrastructure, and SaaS platform development. Ritesh drives engineering standards, code quality, and product innovation across every project the team delivers.

Let's Work Together

Ready to build something
remarkable?

Tell us about your project — we'll get back with a clear plan and honest quote.

Free Consultation
No Commitment
Reply in 24 Hours
WhatsApp Us