← Writing

Integrating Claude into Laravel in Production (Laravel AI SDK + MCP, No Python)

I help founders ship AI features without rebuilding their stack. Most of them are already on Laravel — a Postgres database, a Stripe integration, a queue, an admin panel. Then they read every AI tutorial and conclude they need to stand up a separate Python service, a vector DB, a FastAPI gateway, and glue it all back to the app they already have. They don't. As of 2026 you can build a tool-calling Claude agent and a Model Context Protocol server inside the same Laravel app, sharing the same Eloquent models, gates, and queue. This is what I run in production on eduardocruz.com. Below is the real shape of it — the actual classes, the actual route definitions, and the gotchas that cost me time.

Why Laravel (not Python) for AI agents in 2026

The interesting part of an AI agent is almost never the model call. It's the tools the model can call — and those tools are your business logic. Your customers, your billing, your auth, your authorization. If that logic lives in Laravel and your agent lives in Python, every tool is a network hop into your real app, which means a second auth layer, a second deploy target, and a second place for things to drift out of sync.

When the agent lives inside Laravel, a tool is just a class that runs an Eloquent query. It already has your DB connection, your gates, your service container. No HTTP boundary, no duplicated models, no token-passing dance between services. You deploy one app.

The objection used to be that the tooling wasn't there. That changed. Laravel's first-party laravel/ai package gives you agents, tools, structured output, conversation memory, and streaming. laravel/mcp lets you expose an MCP server straight off a route. Both are real, both are in my composer.json, and the rest of this article is the code I actually ship with them.

One caveat up front: this requires PHP 8.4+ and Laravel 12+. The SDK leans on attributes and modern type features. If you're on Laravel 9, this isn't your afternoon.

Setup — Laravel AI SDK + Claude, the minimal config

Install the package and publish its config and migrations:

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

The migration creates the conversation-memory tables (agent_conversations, agent_conversation_messages) — you only use those if you opt into memory, covered later.

config/ai.php declares providers as name/driver/key triples. The Anthropic block is all you strictly need for Claude:

'providers' => [
    'anthropic' => [
        'driver' => 'anthropic',
        'key' => env('ANTHROPIC_API_KEY'),
        'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'),
    ],
],

Set ANTHROPIC_API_KEY in .env and you're wired. The config also exposes a default provider, but I don't rely on the global default for agents — I pin the provider and model on each agent class so there's no ambient "which model ran this?" question. More on that next.

Your first agent — a tool the model can call

An agent is a class. You declare its provider, model, and step budget with attributes, write its system prompt in instructions(), and list its tools. Here's the real COO agent that answers questions about how my business is running — it reads a goal cascade and follows up on meeting action items:

use Laravel\Ai\Attributes\{MaxSteps, Model, Provider};
use Laravel\Ai\Contracts\{Agent, HasMiddleware, HasTools};
use Laravel\Ai\Promptable;

#[Provider('anthropic')]
#[Model('claude-sonnet-4-6')]
#[MaxSteps(8)]
class CooAgent implements Agent, HasMiddleware, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return <<<'PROMPT'
        You are the COO agent for twentysix (Eduardo Cruz's solo consulting business).
        ... surface alignment between strategy (yearly goals) and execution ...
        resolve_action_item is the ONLY write tool you have. Call it only when the
        user has explicitly confirmed an item is done/cancelled ...
        PROMPT;
    }

    public function tools(): iterable
    {
        return [
            new ListProjects,
            new ListTrackedCustomers,
            new ListCustomerMeetings,
            new ListMeetingActionItems,
            new ResolveActionItem,
        ];
    }
}

#[MaxSteps(8)] is your tool-loop budget — how many times the model may call a tool and come back before it must answer. Cap it. An unbounded agent that keeps deciding it needs "one more lookup" is how you get a surprise bill.

A tool implements Laravel\Ai\Contracts\Tool with three methods: a description() the model reads to decide when to call it, a handle() that runs, and a schema() for its input parameters. This is the actual ListTrackedCustomers tool the agent above uses:

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

class ListTrackedCustomers implements Tool
{
    public function description(): Stringable|string
    {
        return 'List the curated tracked Stripe customers (id, label, name, email, MRR). '
            .'Use this to resolve a customer by name/email when the user mentions one '
            .'(e.g. "Emerson", "John from MosaicNotes") — then pass the tracked_id to ListCustomerMeetings.';
    }

    public function handle(Request $request): Stringable|string
    {
        $tracked = TrackedCustomer::orderByDesc('created_at')->get();

        $rows = $tracked->map(fn ($row) => [
            'tracked_id' => $row->id,
            'label' => $row->label,
            // ... enriched with live Stripe MRR ...
        ])->values()->all();

        return json_encode($rows);
    }

    public function schema(JsonSchema $schema): array
    {
        return [];
    }
}

Note what the tool is: an Eloquent query, in-process, against the same database the rest of my app uses. No service boundary. The description() is prompt engineering — it tells the model that "Emerson" or "John from MosaicNotes" should trigger this call. An empty schema() means the tool takes no input parameters; for tools that take an ID you'd return ['tracked_id' => $schema->integer()->required()].

Running it is one line:

$response = CooAgent::make()->prompt('Did I close out the action items from my last meeting with Emerson?');

return (string) $response;

For structured output — when you want typed JSON back instead of prose — the agent implements HasStructuredOutput and defines a schema(). You then read fields by key off the response:

public function schema(JsonSchema $schema): array
{
    return [
        'feedback' => $schema->string()->required(),
        'score'    => $schema->integer()->min(1)->max(10)->required(),
    ];
}

// $response['score']  →  7

Building an MCP server in Laravel (the CustomersServer pattern)

Agents are for when your app drives the model. MCP is the inverse: it exposes your tools so an external client — Claude Desktop, my own coding agent, a customer's agent — can call them. Same business logic, different consumer. laravel/mcp (^0.6.7 in my repo) makes a server a class and a route.

The server declares its tools, resources, and prompts. This is the real CustomersServer that lets me read tracked Stripe customers and attach Granola meetings to them from inside Claude:

use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\{Instructions, Name, Version};

#[Name('twentysix Customers')]
#[Version('0.3.0')]
#[Instructions('Tools to read tracked Stripe customers, attach conversations (Granola meetings, Gmail threads) and follow up on meeting action items. Workflow: (1) list_tracked_customers → tracked_id; (2) for meetings: extract decisions/action_items/attendees ... then attach_meeting ...')]
class CustomersServer extends Server
{
    protected array $tools = [
        ListTrackedCustomers::class,
        AttachMeeting::class,
        AttachEmailThread::class,
        UpdateMeeting::class,
        ListCustomerMeetings::class,
        ListMeetingActionItems::class,
        ResolveActionItem::class,
    ];

    protected array $resources = [];
    protected array $prompts = [];
}

The #[Instructions] attribute is not decoration — it's the orchestration prompt the connecting model reads. Mine spells out the exact workflow (list_tracked_customers → tracked_id → attach_meeting) so the agent doesn't guess the call order.

An MCP tool is almost identical to an AI SDK tool, but it lives under a different namespace (Laravel\Mcp\Server\Tool) and returns a Laravel\Mcp\Response:

use Laravel\Mcp\{Request, Response};
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('List the curated tracked Stripe customers (id, label, name, email, MRR). Use this to find the customer ID/email needed by attach_meeting.')]
class ListTrackedCustomers extends Tool
{
    public function handle(Request $request): Response
    {
        $rows = TrackedCustomer::orderByDesc('created_at')->get()
            ->map(fn ($row) => [
                'tracked_id' => $row->id,
                'label' => $row->label,
                // ... live MRR ...
            ])->values()->all();

        return Response::json($rows);
    }

    public function schema(JsonSchema $schema): array
    {
        return [];
    }
}

You expose the server over HTTP with the Mcp facade in a route file. This is where it gets interesting, and where the next two sections come from:

use Laravel\Mcp\Facades\Mcp;

Mcp::web('/mcp/customers', CustomersServer::class)
    ->middleware(['auth:sanctum', 'can:view-stripe-admin']);

Mcp::web('/mcp/throughline', ThroughlineServer::class)
    ->middleware('auth:agent');

A working MCP server is a server class, a few tool classes, and one Mcp::web() line. That's the whole surface area.

Conversation memory + streaming in production

Two things you want the moment a real user touches an agent: it should remember the last turn, and it shouldn't make them stare at a spinner for fifteen seconds.

Memory is a trait. Add RemembersConversations, implement Conversational, and the SDK persists turns into the agent_conversations tables and replays them automatically:

use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\{Agent, Conversational};

class CooAgent implements Agent, Conversational
{
    use Promptable, RemembersConversations;
    // ...
}

You start a conversation scoped to a user, keep the returned id, and continue against it later:

$response = (new CooAgent)->forUser($user)->prompt('How are my Q2 goals tracking?');
$conversationId = $response->conversationId;

// next request, same thread:
$response = (new CooAgent)
    ->continue($conversationId, as: $user)
    ->prompt('Which one is most at risk?');

The as: $user on continue() is the part people skip and regret — it scopes the history to that user. Don't continue a conversation without it, or you risk replaying one user's history to another.

Streaming returns a stream you can hand straight back from an HTTP route:

Route::get('/coach', function () {
    return (new CooAgent)->stream('Analyze my pipeline...');
});

If you need to do something after the stream finishes — persist usage, log cost — chain ->then():

return (new CooAgent)->stream('Analyze my pipeline...')
    ->then(function (StreamedAgentResponse $response) {
        // $response->text, $response->usage available here
    });

For a React/Next.js frontend, ->usingVercelDataProtocol() emits the wire format the Vercel AI SDK's useChat expects, so the frontend "just works" with a Laravel backend.

Gotchas I hit shipping this for real

Auth is not authorization. Look back at that route file. The Customers MCP server is gated ['auth:sanctum', 'can:view-stripe-admin'] — two middlewares, deliberately. Sanctum proves the request carries some valid user token. It does not prove that user is an admin. I shipped an MCP route once with auth:sanctum alone, reasoned "only I have a token," and that was wrong on principle: any user who could mint a personal access token could hit admin tooling. The can: gate is what restricts it to a stakeholder who may actually read Stripe data. Every Mcp::web that touches privileged data needs the gate on top of the auth.

Scope agent tokens to one tenant. The second server, /mcp/throughline, uses a custom agent guard instead of Sanctum. It resolves an Agent model from an Authorization: Bearer <token> and — this is the load-bearing part — that agent is scoped to exactly one project. A founder I work with gets an agent token; that token can only ever see that founder's project. Cross-tenant authorization isn't something you bolt on later in a multi-tenant agent system; the guard has to enforce it on every call, because the model will happily follow a prompt that asks for someone else's data.

Cost control is a class, not a hope. I attach a TrackAgentCalls middleware to the COO agent that writes an AgentCall row after every prompt — model, provider, input/output tokens, cache-read and cache-write tokens, and an invocation id:

return $next($prompt)->then(function (AgentResponse $response) use ($invokedAt) {
    AgentCall::create([
        'model'      => $response->meta->model ?? 'unknown',
        'tokens_in'  => $response->usage->promptTokens,
        'tokens_out' => $response->usage->completionTokens,
        'metadata'   => [
            'cache_read_input_tokens'  => $response->usage->cacheReadInputTokens,
            'cache_write_input_tokens' => $response->usage->cacheWriteInputTokens,
            'invocation_id'            => $response->invocationId,
        ],
        // ...
    ]);
});

The MaxSteps cap bounds a single run; this middleware gives me the ledger across all runs. Without it, "why is the Anthropic bill up this month" is a guess. With it, it's a query.

Private media, signed URLs, throttled. The Throughline agent can upload audio attachments to conversations. Those files never touch a public disk. The upload route stores on a private disk and returns only a relative path; the file is served exclusively through a separate route guarded by Laravel's signed middleware, so it's reachable only via a temporary signed URL minted server-side on the operator's page — links expire, no anonymous access, path traversal guarded in the controller. The upload route is also throttle:30,1. That throttle exists specifically to blunt a compromised or forged agent token from filling my disk. When you give an automated agent a write primitive, assume the token can leak and rate-limit the blast radius.

When you'd want help shipping this

None of this is exotic, but the failure modes are quiet. An agent that loops one step too many, a Sanctum route missing its gate, an agent token that can read across tenants, a media route that's one missing signed middleware away from public — each is a one-line fix and a real incident if you ship it wrong. The patterns above are the ones I run in production, not a demo.

If you're a founder on Laravel who wants AI features without standing up a parallel Python stack, that intersection — deep Laravel plus shipped AI agents and MCP servers — is narrow, and it's exactly what I do. You can hire someone who's already shipped this, or look at pricing if you want a fixed scope to start.

Work with me See pricing