Your agent works flawlessly in the demo. It answers product questions, pulls order details, and streams tokens into a Vue component like a well-behaved assistant. Then a real user asks something slightly outside the happy path, and the model invents a return policy, calls a tool with a malformed payload, or spins in a loop until the request times out. The frontend shows a frozen cursor. The token bill keeps climbing.
The gap between a working AI prototype and a production AI agent is not the model. It's the orchestration layer: how you persist conversation state, retrieve grounded context, execute external tools, and stream every intermediate step back to the client without blocking PHP's request lifecycle.
Laravel 13, the official AI SDK, Laravel MCP, and Vue 3 give you the pieces to close that gap without reaching for a Python microservice. This is the architecture I'd build in 2026, the trade-offs that matter, and the production concerns that tutorials usually skip.
📋 Table of Contents
- The Architecture That Actually Ships
- 1. Install the Stack and Set the Boundaries
- 2. Model the Agent as a State Machine, Not a Prompt
- 3. Build the RAG Pipeline with pgvector and HNSW
- 4. Ground the Agent with a Similarity Search Tool
- 5. Connect MCP Servers Without Rewriting Your Agent
- 6. Stream Tokens and Tool Events to Vue with SSE
- 7. Handle the Tool Loop Without Infinite Iterations
- 8. Queue, Retry, and Fail Gracefully
- 9. Test the Non-Deterministic Parts
- What Breaks First in Production
- The Decision Guide: When This Stack Fits and When It Doesn't
The Architecture That Actually Ships
An AI agent is not a model with a personality prompt. It's a controlled loop: the model receives a user message plus context, decides whether it has enough information, and either returns a response or requests a tool. Laravel owns that loop. MCP owns tool execution. RAG owns grounding. Vue owns the streaming surface.
Here's the topology that keeps each concern isolated:
Vue 3 (Pinia + EventSource)
│
│ POST /api/agent/chat
│ GET /api/agent/stream/{run} (SSE)
▼
Laravel Orchestrator (AgentRun state machine)
│
├── RAG Retriever (pgvector similarity search)
├── Laravel AI SDK Agent (model + tools + memory)
└── MCP Client Manager
│
├── Remote MCP Server (streamable HTTP)
└── Local MCP Server (STDIO)
The frontend never talks to the model directly. It talks to Laravel, and Laravel talks to everything else. That boundary is what makes the system testable, auditable, and safe to expose to real users.
1. Install the Stack and Set the Boundaries
The first mistake is treating the AI SDK as the application. The SDK is a model client and agent runtime. It is not your authorization layer, your rate limiter, or your audit log. Those stay in Laravel.
Start with the official packages:
composer require laravel/ai laravel/mcp
The Laravel AI SDK is a first-party package providing a unified API across providers including OpenAI, Anthropic, Gemini, Groq, Mistral, and Ollama. It covers agents, tools, memory, structured output, embeddings, vector stores, and streaming, with built-in fakes for testing.
Laravel MCP is the package for exposing your application as an MCP server and, as of the 2026 releases, for consuming external MCP servers as a client.
💡 Practical note: Pin both packages to a minor version in
composer.json. The MCP specification moved to a stateless core in the 2026-07-28 revision, and client behavior around sessions and discovery changed materially. You want deliberate upgrades, not acomposer updatethat silently changes the protocol surface under your agent.
The boundary rule is simple: the SDK orchestrates model interactions. Laravel handles authentication, tenant scoping, quotas, persistence, and observability. If you blur that line, you'll end up with an agent that can query any tenant's data because the prompt said so.
2. Model the Agent as a State Machine, Not a Prompt
Scenario:
A user asks the agent to "find the three customers most likely to churn and draft a retention email for each." The model calls a search tool, gets results, then calls an email-drafting tool, then decides it needs more data, and repeats. If the PHP request dies halfway through, the conversation is corrupted, and the user sees nothing.
Why it matters:
LLMs are non-deterministic. A single malformed tool call or a provider timeout shouldn't erase the entire run. You need a persistent record of where the agent is, what it has done, and what it's waiting for.
Solution:
Use an Eloquent model as the run ledger. Every iteration writes to the database before and after each model call.
namespace App\Enums;
enum AgentStatus: string
{
case Pending = 'pending';
case RetrievingContext = 'retrieving_context';
case Thinking = 'thinking';
case ExecutingTool = 'executing_tool';
case Streaming = 'streaming';
case AwaitingInput = 'awaiting_input';
case Completed = 'completed';
case Failed = 'failed';
}
namespace App\Models;
use App\Enums\AgentStatus;
use Illuminate\Database\Eloquent\Model;
class AgentRun extends Model
{
protected $casts = [
'status' => AgentStatus::class,
'tool_calls' => 'array',
'retrieved_context' => 'array',
];
public function conversation()
{
return $this->belongsTo(Conversation::class);
}
}
The orchestrator doesn't dump model output directly into the conversation. It records each state transition.
$run = AgentRun::create([
'conversation_id' => $conversation->id,
'status' => AgentStatus::Pending,
'iteration_count' => 0,
]);
$run->update(['status' => AgentStatus::RetrievingContext]);
$context = $this->ragRetriever->retrieve($userPrompt, limit: 5);
$run->update([
'status' => AgentStatus::Thinking,
'retrieved_context' => $context->pluck('id')->all(),
]);
Why this works:
The state machine turns an opaque model loop into an inspectable process. When a run fails, you know exactly which phase it reached. When a user refreshes the page, you can resume from the last persisted state instead of starting over. When a tool call needs human approval, the run sits in AwaitingInput until someone acts.
🚨 Production warning: Keep the state machine out of the model prompt. Never tell the model "you are in the
executing_toolstate." The state is for your application. The model sees messages, tool schemas, and tool results. Mixing application state into the prompt adds tokens and creates a second, unreliable source of truth.
3. Build the RAG Pipeline with pgvector and HNSW
Scenario:
The agent can look up orders and classify tickets, but ask it "what's the return policy for damaged items?" and it fabricates an answer. The model has no access to the actual policy document.
Why it matters:
Retrieval-Augmented Generation is not optional for any agent that answers questions about your business. Without grounding, the model's confidence is inversely correlated with its accuracy. The more specific the question, the more likely the hallucination.
Solution:
Store embeddings in PostgreSQL with pgvector, index them with HNSW, and retrieve by cosine similarity.
Laravel 13 provides native vector column support. Start with the migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::ensureVectorExtensionExists();
Schema::create('knowledge_articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536)->index();
$table->timestamps();
});
}
};
The index() call on the vector column creates an HNSW index with cosine distance automatically. HNSW is the right default for most RAG workloads because it gives sublinear approximate nearest-neighbor search without the training step required by IVFFlat.
Cast the column on the model:
protected function casts(): array
{
return [
'embedding' => 'array',
];
}
Generate and store embeddings with the SDK's Embeddings facade:
use Laravel\Ai\Facades\Embeddings;
$articles = KnowledgeArticle::whereNull('embedding')->get();
foreach ($articles as $article) {
$response = Embeddings::for([$article->title . "\n\n" . $article->content])
->generate();
$article->update(['embedding' => $response->embeddings[0]]);
}
Query by meaning, not by keyword:
$results = KnowledgeArticle::query()
->whereVectorSimilarTo('embedding', 'package arrived broken', minSimilarity: 0.4)
->limit(5)
->get();
The whereVectorSimilarTo method accepts either a raw embedding array or a plain string. When you pass a string, Laravel generates the embedding for you using the configured provider.
Why this works:
The embedding model compresses meaning into a vector. "How do I return a damaged item?" and "What if my package arrived broken?" use different words but land close together in vector space. Keyword search would miss the second query. Cosine similarity on a 1,536-dimension embedding catches it.
🧠 The important part: Chunk before you embed. A 4,000-word policy document embedded as a single vector produces a blurry average that retrieves poorly. Split documents into 300–500 token chunks with overlap, embed each chunk, and store metadata so you can reconstruct the source. The retrieval quality of your RAG pipeline is determined before the model ever sees a token.
4. Ground the Agent with a Similarity Search Tool
Scenario:
You've built the vector store, but the agent still doesn't use it. The model doesn't know the knowledge base exists unless you give it a tool with a clear description and a schema.
Solution:
Wrap the similarity search in a tool and attach it to the agent. The Laravel AI SDK supports custom tools with JSON schema validation and provider tools like WebSearch, WebFetch, and FileSearch.
namespace App\Ai\Tools;
use App\Models\KnowledgeArticle;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\ToolInput;
use Laravel\Ai\ToolOutput;
class SearchKnowledgeBase implements Tool
{
public function description(): string
{
return 'Search the internal knowledge base for policies, procedures, and product documentation. Use this before answering any question about return policies, shipping, or account rules.';
}
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'A natural-language search query.',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum number of articles to return.',
'default' => 5,
],
],
'required' => ['query'],
];
}
public function handle(ToolInput $input): ToolOutput
{
$articles = KnowledgeArticle::query()
->whereVectorSimilarTo('embedding', $input->query, minSimilarity: 0.35)
->limit($input->limit ?? 5)
->get(['id', 'title', 'content']);
return ToolOutput::text(
$articles->map(fn ($a) => "## {$a->title}\n{$a->content}")->join("\n\n")
);
}
}
Attach it to the agent:
namespace App\Ai\Agents;
use App\Ai\Tools\SearchKnowledgeBase;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
class SupportAgent implements Agent, HasTools
{
use Promptable;
public function instructions(): string
{
return 'You are a customer support agent. Before answering any question about policies, shipping, or returns, call the SearchKnowledgeBase tool. Never guess a policy. If the knowledge base returns nothing relevant, say so and offer to escalate.';
}
public function tools(): array
{
return [
new SearchKnowledgeBase(),
];
}
}
Why this works:
The tool description is the most important part of the schema. The model decides when to call the tool based almost entirely on that sentence. A vague description like "search documents" produces sporadic tool use. A specific description with a negative instruction ("never guess") produces consistent grounding.
⚠️ Gotcha:
minSimilarityis not a universal constant. A threshold of 0.4 works for one embedding model and produces empty results for another. Treat it as a tunable parameter, log the similarity scores of retrieved chunks in development, and adjust based on your actual embedding provider and document corpus.
5. Connect MCP Servers Without Rewriting Your Agent
Scenario:
Your agent needs to query GitHub issues, read Notion pages, and inspect a Postgres database. You could write a custom tool for each integration. Or you could connect to MCP servers that already expose those capabilities.
Why it matters:
MCP is the protocol that standardizes how AI agents connect to external tools and data sources. Before MCP, every integration was bespoke. With MCP, any compatible client can connect to any compatible server, whether it's Claude, Cursor, GitHub Copilot, or your Laravel agent.
As of the 2026-07-28 specification, MCP moved to a stateless core. Every request carries its protocol version and client capabilities in _meta, the initialize handshake is gone, and servers expose a server/discover RPC for version negotiation. The Laravel MCP client handles those protocol details, so your agent code doesn't have to.
Solution:
The Laravel AI SDK can consume tools from any MCP server with a single line. The client lives in laravel/mcp and supports both STDIO for local servers and streamable HTTP for remote servers.
public function tools(): array
{
return [
...Mcp::client('nightwatch')->withToken(config('services.nightwatch.token'))->tools(),
...Mcp::client('github')->withToken(config('services.github.token'))->tools(),
];
}
For local servers:
use Laravel\Mcp\Client;
$client = Client::local('npx', ['-y', '@modelcontextprotocol/puppeteer']);
$tools = $client->tools();
For remote servers over HTTP:
$client = Client::web('https://nightwatch.laravel.com/mcp');
$tools = $client->tools();
The agent doesn't know or care whether a tool was written in PHP or exposed by an external MCP server. It sees a tool name, a schema, and a result.
Why this works:
MCP decouples tool execution from the agent runtime. A tool can run in a separate process, on a different machine, or behind an OAuth-protected endpoint. The Laravel agent remains a stateless orchestrator; the MCP client negotiates the transport, handshake, and authentication. This is the same separation of concerns you already apply to queues and external APIs.
🔍 Why this matters: MCP starts earning its keep when the consumer of your application is not you. A solo developer can wire up a few bespoke integrations. A team of 50 cannot. A single MCP server gives every AI client the same structured access, with centralized authentication and auditing.
6. Stream Tokens and Tool Events to Vue with SSE
Scenario:
The agent takes four seconds to retrieve context, three seconds to call a tool, and six seconds to generate a response. The user sees a spinner for thirteen seconds and assumes the app is broken.
Why it matters:
LLM latency is unavoidable. Perceived latency is not. Streaming turns a thirteen-second wait into a progressive reveal: the user sees "Searching knowledge base..." at one second, "Found 3 articles" at four seconds, and tokens appearing at seven seconds. The experience feels responsive even when the model is slow.
Solution:
Use Server-Sent Events (SSE) from Laravel and an EventSource reader in Vue. SSE is a better fit than WebSockets here because the stream is unidirectional (server to client), it reuses HTTP infrastructure, and it reconnects automatically.
On the Laravel side, return a streamed response that yields events as the agent loop progresses:
use Symfony\Component\HttpFoundation\StreamedResponse;
public function stream(AgentRun $run): StreamedResponse
{
return response()->stream(function () use ($run) {
foreach ($this->orchestrator->run($run) as $event) {
echo "event: {$event['event']}\n";
echo 'data: ' . json_encode($event['data']) . "\n\n";
ob_flush();
flush();
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
The orchestrator yields structured events, not just token deltas:
yield ['event' => 'status', 'data' => ['message' => 'Searching knowledge base...']];
$context = $this->ragRetriever->retrieve($userPrompt, limit: 5);
yield ['event' => 'context', 'data' => ['articles' => $context->pluck('title')]];
yield ['event' => 'status', 'data' => ['message' => 'Thinking...']];
foreach ($this->llm->stream($messages, $tools) as $chunk) {
if ($chunk->isToken()) {
yield ['event' => 'token', 'data' => ['text' => $chunk->text]];
}
if ($chunk->isToolCall()) {
yield ['event' => 'tool_call', 'data' => ['name' => $chunk->toolName]];
}
}
On the Vue side, a composable owns the EventSource and updates a reactive store:
// composables/useAgentStream.js
import { ref, onUnmounted } from 'vue'
export function useAgentStream() {
const tokens = ref('')
const status = ref('idle')
const toolCalls = ref([])
let source = null
function connect(runId) {
source = new EventSource(`/api/agent/stream/${runId}`)
source.addEventListener('status', (e) => {
status.value = JSON.parse(e.data).message
})
source.addEventListener('token', (e) => {
tokens.value += JSON.parse(e.data).text
})
source.addEventListener('tool_call', (e) => {
toolCalls.value.push(JSON.parse(e.data).name)
status.value = `Running ${JSON.parse(e.data).name}...`
})
source.addEventListener('completed', () => {
status.value = 'done'
source.close()
})
source.addEventListener('error', () => {
status.value = 'error'
source.close()
})
}
onUnmounted(() => source?.close())
return { tokens, status, toolCalls, connect }
}
Why this works:
The frontend renders three distinct layers of information: the current phase, the tools being invoked, and the incremental text. The user never stares at an undifferentiated spinner. If a tool fails, you can emit a tool_error event and show a retry action instead of losing the entire run.
💡 Practical note: Set
X-Accel-Buffering: noif you're behind Nginx. Without it, Nginx buffers the response and the user sees nothing until the stream completes, which defeats the entire purpose of SSE.
7. Handle the Tool Loop Without Infinite Iterations
Scenario:
The model calls a tool, gets a result it doesn't understand, calls the same tool again with slightly different arguments, gets another unsatisfactory result, and repeats. Without a hard limit, the run never completes.
Why it matters:
The agent loop is a while loop driven by the model. The model decides when to stop. Sometimes it doesn't. A single misconfigured tool description can cause the model to retry indefinitely, burning tokens and holding a queue worker.
Solution:
Enforce a maximum iteration count, a token budget, and a per-tool timeout. These are application-level constraints, not prompt instructions.
class AgentOrchestrator
{
private const MAX_ITERATIONS = 8;
private const MAX_TOKENS = 16_000;
public function run(AgentRun $run): Generator
{
$iteration = 0;
$tokensUsed = 0;
while ($iteration < self::MAX_ITERATIONS && $tokensUsed < self::MAX_TOKENS) {
$iteration++;
$run->increment('iteration_count');
$response = $this->llm->generate(
messages: $run->conversation->messages,
tools: $this->toolRegistry->all(),
maxTokens: 2_000,
);
$tokensUsed += $response->usage->totalTokens;
if ($response->hasToolCalls()) {
yield ['event' => 'tool_calling', 'data' => ['iteration' => $iteration]];
foreach ($response->toolCalls as $call) {
$result = $this->executeWithTimeout($call, seconds: 10);
$run->conversation->messages()->create([
'role' => 'tool',
'tool_call_id' => $call->id,
'content' => $result,
]);
}
continue;
}
yield ['event' => 'token', 'data' => ['text' => $response->text]];
break;
}
if ($iteration >= self::MAX_ITERATIONS) {
$run->update(['status' => AgentStatus::Failed]);
yield ['event' => 'error', 'data' => ['message' => 'Iteration limit reached.']];
}
}
}
Why this works:
The limits are outside the model's control. No prompt engineering can override a while condition. When the limit is hit, the run enters a terminal Failed state with a clear reason, and you can alert on it.
🚨 Production warning: Don't set
MAX_ITERATIONSto 20 because "the agent might need it." Each iteration is a full model call. A loop of 20 with a 2,000-token response can cost more than the rest of the conversation combined. Start at 6–8, log when the limit is approached, and only raise it if you have evidence that legitimate runs need more.
8. Queue, Retry, and Fail Gracefully
Scenario:
Three users start agent runs at the same time. The first run takes twelve seconds, the second takes fifteen, and the third times out at PHP's max_execution_time. The third user gets a 504, the first two get their responses, and the queue table shows nothing because the runs were executed synchronously.
Why it matters:
LLM calls are slow, bursty, and rate-limited. Executing them inside a synchronous HTTP request couples your web server's throughput to the model provider's latency. A single slow provider response can exhaust your PHP-FPM workers.
Solution:
Run the agent loop in a queued job. The HTTP endpoint creates the AgentRun record and dispatches the job. The frontend connects to the SSE stream using the run ID.
class ExecuteAgentRun implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 3;
public int $backoff = [10, 30, 60];
public int $timeout = 120;
public function __construct(public AgentRun $run) {}
public function handle(AgentOrchestrator $orchestrator): void
{
foreach ($orchestrator->run($this->run) as $event) {
$this->run->appendEvent($event);
}
}
public function failed(Throwable $exception): void
{
$this->run->update([
'status' => AgentStatus::Failed,
'error' => $exception->getMessage(),
]);
}
}
The SSE endpoint reads persisted events from the run record or from Redis, rather than executing the loop itself:
public function stream(AgentRun $run): StreamedResponse
{
return response()->stream(function () use ($run) {
$lastEventId = 0;
while (true) {
$events = $run->events()->where('id', '>', $lastEventId)->get();
foreach ($events as $event) {
echo "id: {$event->id}\n";
echo "event: {$event->type}\n";
echo 'data: ' . json_encode($event->payload) . "\n\n";
$lastEventId = $event->id;
}
if ($run->fresh()->status->isTerminal()) {
echo "event: completed\n\n";
break;
}
ob_flush();
flush();
usleep(250_000);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
Why this works:
The queue absorbs concurrency. The retry policy handles transient provider errors without duplicating the entire run. The SSE endpoint becomes a read-only view of persisted events, which means the user can disconnect and reconnect without losing state. If the job fails permanently, the failed method writes a terminal error that the frontend can display.
⚠️ Gotcha: Set
$timeouton the job higher than the sum of your iteration timeouts. A job that times out at 60 seconds while a single tool call is allowed to run for 10 seconds and the model can iterate 8 times will be killed mid-run. The math is not obvious until it happens.
9. Test the Non-Deterministic Parts
Scenario:
Your test suite calls the real model provider on every CI run. Tests are slow, flaky, and expensive. A prompt change that improves one test case degrades another, and you can't tell which change caused it.
Why it matters:
AI features are still features. They need deterministic tests. The Laravel AI SDK ships fakes for agents, images, audio, embeddings, reranking, and file stores, so you can assert behavior without hitting a provider.
Solution:
Fake the agent and the embedding provider. Test the orchestration, not the model's creativity.
use Laravel\Ai\Facades\Ai;
it('streams a grounded answer when the knowledge base has relevant content', function () {
Ai::fake([
'SearchKnowledgeBase' => ToolResult::text('Return policy: damaged items are eligible for a full refund within 30 days.'),
'final' => AgentResponse::text('Damaged items can be returned for a full refund within 30 days.'),
]);
$run = AgentRun::factory()->create();
$orchestrator = app(AgentOrchestrator::class);
$events = iterator_to_array($orchestrator->run($run));
expect($events)
->toHaveEvent('tool_call', fn ($e) => $e['name'] === 'SearchKnowledgeBase')
->toHaveEvent('token', fn ($e) => str_contains($e['text'], '30 days'));
Ai::assertToolCalled('SearchKnowledgeBase');
});
For the RAG pipeline, fake the embedding provider and assert that the correct records are retrieved:
Embeddings::fake([
'damaged item' => [0.12, -0.45, /* ... */],
]);
$results = KnowledgeArticle::query()
->whereVectorSimilarTo('embedding', 'damaged item')
->get();
expect($results->first()->title)->toBe('Return Policy for Damaged Items');
Why this works:
The fake replaces the non-deterministic boundary. The orchestration logic, the tool schema, the state transitions, and the SSE event shape are all tested deterministically. The model's actual output quality is a separate concern, better handled with evaluation datasets than with PHPUnit assertions.
🔍 Why this matters: Without fakes, your CI becomes a slow integration test against a paid API. With fakes, you can run hundreds of tests in seconds and reserve real-model tests for a nightly evaluation suite with a curated dataset.
What Breaks First in Production
The failure modes of this architecture are predictable, and they rarely have anything to do with the model's reasoning ability.
The embedding dimension mismatch. You change providers from one embedding model to another. The new model outputs 1,024 dimensions. Your database column expects 1,536. Every insert fails. The fix is a migration and a full re-embedding of the corpus, not a config change. Plan for it: store the model name and dimension count alongside each embedding so you can detect drift.
The tool description that's too vague. The model stops calling the knowledge base tool because the description says "search documents" instead of telling it when and why. Retrieval quality drops, hallucinations increase, and the first instinct is to blame the model. The tool description is the prompt. Treat it with the same care.
The SSE connection that buffers. Everything works locally. In production, behind a load balancer and a reverse proxy, the user sees nothing until the stream completes. X-Accel-Buffering: no and the correct Content-Type are not optional. Test the stream through the same infrastructure that production uses.
The MCP server that changes its protocol version. The 2026-07-28 MCP specification removed the initialize handshake and made the protocol stateless. If you're connecting to a server that still expects the old handshake, or a client that hasn't updated, the connection fails at negotiation. The Laravel MCP client handles the current spec, but you still need to verify that every external server you depend on is compatible.
The token budget that no one owns. Each conversation has no hard limit. A single user in a loop can burn through your monthly provider quota in an afternoon. Set per-user and per-tenant token budgets at the application layer. The model will not enforce them.
The Decision Guide: When This Stack Fits and When It Doesn't
This architecture is not the right choice for every AI feature.
Use this stack when:
- You already have a Laravel application with authentication, tenancy, and business logic that the agent needs to access.
- You need audit trails, role-based access control, and data residency that a third-party agent platform can't provide.
- Your agent must query internal databases, call internal APIs, or read documents that never leave your infrastructure.
- You want the frontend to show tool execution and retrieval steps, not just a final answer.
Don't use this stack when:
- You're building a stateless text transformer with no tools, no memory, and no retrieval. A direct SDK call is simpler.
- Your team is Python-first and already has LangChain or LlamaIndex infrastructure that works. The Laravel AI SDK is excellent, but it's not a reason to rewrite a working Python service.
- You need the model to execute arbitrary code in a sandbox. MCP can expose a code-execution tool, but the orchestration and isolation concerns belong in a dedicated runtime, not in a PHP request or queue worker.
The real architectural decision is not "Laravel vs. Python." It's whether the agent belongs inside your application's trust boundary or outside it. If the agent needs to respect the same permissions, write to the same database, and appear in the same audit log as the rest of your product, keeping it in Laravel is the shortest path to a system you can actually operate. If the agent is a standalone reasoning service that only needs a clean API boundary, a separate runtime is fine.
For most full-stack teams building AI features into an existing product, the Laravel orchestrator, Vue streaming surface, MCP tool layer, and pgvector-backed RAG pipeline form a stack where every component has a clear job and a failure mode you can observe. That's the difference between an agent that demos well and an agent that survives its first real user.