AI agents by framework / PHP

How to add an AI agent to a Laravel app

An AI agent in a Laravel app is an agent class that gives a model tools, a route in the web middleware group that runs it for the signed-in user, and a streamed response the browser renders as it arrives. The first-party Laravel AI SDK covers the model loop, tool approval and streaming; you supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in a Laravel app is made of

  • A server endpoint: a route in routes/web.php, so the session, the auth middleware and CSRF protection apply, that streams the agent's reply. Provider keys live in .env and config/ai.php, never in VITE_ variables, which Vite bundles into client code.
  • Tools: classes in app/Ai/Tools, each wrapping an Eloquent query or a service your app already has, constructed with the authenticated user.
  • The agent loop: an agent class in app/Ai/Agents. The SDK calls the model, runs the tools it asks for and repeats until the model answers; the #[MaxSteps] attribute caps the loop.
  • A chat UI: with a React or Vue starter kit, the AI SDK's useChat hook, which speaks the stream protocol the Laravel AI SDK can emit; with Blade, a small script that reads the event stream.
  • Authorization and approval: the auth middleware on the route, every tool query scoped to the user, and approvable tools that pause until the user approves the change.

Ways to add an agent to Laravel

ApproachGood forWhat you take on
Laravel AI SDK (laravel/ai)A custom agent with your own tools, provider and UI, written as Laravel agent and tool classes with Artisan generators and test fakes.Tool classes, authorization in every tool, approval UI, rate and cost limits.
Prism or a provider client (for example openai-php/laravel)Lower-level model calls when you want to own the message format, streaming and conversation handling.Tool definitions, authorization, approval and UI integration; with a raw provider client, the tool loop too.
An agent framework (for example Neuron AI)Long-running or multi-agent workflows with memory and orchestration.Another layer to learn and deploy, plus everything in the first row.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with the Laravel AI SDK

The Laravel AI SDK is Laravel's first-party package for agents, tools, structured output and streaming across providers such as OpenAI, Anthropic and Gemini. The examples below use laravel/ai 1.3 on Laravel 13 with the React starter kit. Install the package, publish its configuration and add the AI SDK's React hook:

Terminal
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
npm install ai @ai-sdk/react

Set ANTHROPIC_API_KEY in .env, or the key of the provider you choose. The publish step also adds migrations for the SDK's stored conversations; you only need to run them if you use that feature, because in this example the browser sends the conversation history with each request.

1. Tools scoped to the user

Generate tool classes with php artisan make:tool and pass each one the authenticated user through its constructor. ListOrders queries through the user's orders relationship, so the model can filter by status but never reach another user's orders. The schema method describes the arguments the model may send.

app/Ai/Tools/ListOrders.php
<?php

namespace App\Ai\Tools;

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

class ListOrders implements Tool
{
    public function __construct(private User $user) {}

    public function description(): string
    {
        return "List the signed-in user's recent orders";
    }

    public function handle(Request $request): string
    {
        return $this->user->orders()
            ->when($request['status'] ?? null, fn ($query, $status) => $query->where('status', $status))
            ->latest()
            ->limit(20)
            ->get(['id', 'status', 'total', 'created_at'])
            ->toJson();
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string()->enum(['open', 'shipped', 'cancelled']),
        ];
    }
}

CancelOrder implements Approvable and uses the InteractsWithApprovals trait, so the agent stops before running it and asks the user. Its handle method still looks the order up among the user's open orders: the approved call comes back from the browser, so the tool must not trust the ID it receives.

app/Ai/Tools/CancelOrder.php
<?php

namespace App\Ai\Tools;

use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;

class CancelOrder implements Approvable, Tool
{
    use InteractsWithApprovals;

    public function __construct(private User $user) {}

    public function description(): string
    {
        return "Cancel one of the signed-in user's open orders";
    }

    public function handle(Request $request): string
    {
        // Scoped to the user's own open orders, whatever ID the model or the browser sends.
        $order = $this->user->orders()
            ->where('status', 'open')
            ->find($request['order_id']);

        if ($order === null) {
            return 'No open order with that ID.';
        }

        $order->update(['status' => 'cancelled']);

        return "Cancelled order {$order->id}.";
    }

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

2. The agent and the route

The agent class sets the instructions, the tools and its configuration through attributes: #[MaxSteps(5)] caps the tool loop and #[Timeout(120)] sets the HTTP timeout for each provider request. "claude-sonnet-5-5" is Anthropic's model ID; swap the provider and model attributes to use another one.

app/Ai/Agents/OrderAssistant.php
<?php

namespace App\Ai\Agents;

use App\Ai\Tools\CancelOrder;
use App\Ai\Tools\ListOrders;
use App\Models\User;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5-5')]
#[MaxSteps(5)]
#[Timeout(120)]
class OrderAssistant implements Agent, HasTools
{
    use Promptable;

    public function __construct(public User $user) {}

    public function instructions(): string
    {
        return 'You help the signed-in user manage their orders. Use the tools; never guess order data.';
    }

    public function tools(): iterable
    {
        return [
            new ListOrders($this->user),
            new CancelOrder($this->user),
        ];
    }
}

The route sits in routes/web.php, so the session, the auth middleware and CSRF protection apply, and throttle:20,1 allows each signed-in user 20 requests a minute. Vercel::chat() reads the request useChat sends: the history, the newest message and any approval decisions. The agent resumes a paused call when the request carries decisions and starts a new turn otherwise, and usingProtocol() streams the reply as server-sent events in the AI SDK's UI message stream format.

routes/web.php (excerpt)
use App\Ai\Agents\OrderAssistant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Ai\Vercel\Vercel;

Route::post('/chat', function (Request $request) {
    $chat = Vercel::chat($request);

    return (new OrderAssistant($request->user()))
        ->withMessages($chat->history())
        ->stream($chat)
        ->usingProtocol($chat->protocol());
})->middleware(['auth', 'throttle:20,1']);

3. The chat component

In a React starter kit page, useChat from @ai-sdk/react posts to /chat through DefaultChatTransport, with the session's CSRF token read from the layout's csrf-token meta tag. Laravel names a tool after its class, so a call to CancelOrder arrives as a tool-CancelOrder part in the state approval-requested. addToolApprovalResponse records the user's answer, and sendAutomaticallyWhen sends it back so Laravel runs or rejects the call.

resources/js/components/order-agent.tsx
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { useState } from "react";

const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? "";

export function OrderAgent() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, addToolApprovalResponse } = useChat({
    transport: new DefaultChatTransport({ api: "/chat", headers: { "X-CSRF-TOKEN": csrfToken } }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  return (
    <div>
      {messages.map((message) =>
        message.parts.map((part, i) => {
          const key = `${message.id}-${i}`;
          if (part.type === "text") return <p key={key}>{part.text}</p>;
          if (part.type === "tool-CancelOrder" && part.state === "approval-requested") {
            const { id } = part.approval;
            return (
              <div key={key}>
                <p>Cancel order {JSON.stringify(part.input)}?</p>
                <button onClick={() => addToolApprovalResponse({ id, approved: true })}>Approve</button>
                <button onClick={() => addToolApprovalResponse({ id, approved: false })}>Deny</button>
              </div>
            );
          }
          return null;
        }),
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input aria-label="Message" value={input} onChange={(e) => setInput(e.currentTarget.value)} />
      </form>
    </div>
  );
}

Production checklist for Laravel

  • Authorize in the route and in every tool. The auth middleware keeps guests out; each tool must still scope its queries to the user, through relationships as above or a policy checked with Gate::forUser($user). The history and the approval decisions come from the browser, so a user can send an approval the model never asked for: tools that change data re-check the record themselves.
  • Keep CSRF protection on. Leave the chat route in the web group and out of the except list. Laravel 13's PreventRequestForgery middleware accepts same-origin requests by their Sec-Fetch-Site header and otherwise checks the token, which the component sends as X-CSRF-TOKEN (see CSRF protection).
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with the request's validate method and your own business rules, and require approval for anything irreversible.
  • Limit cost. Cap steps with #[MaxSteps] and output with #[MaxTokens], rate-limit the route per user with the throttle middleware or a named rate limiter, and set spending limits with your model provider.
  • Allow for long streams. A streamed reply holds a PHP worker for its whole duration. Under PHP-FPM, size the pool for concurrent chats, raise the request and proxy timeouts, and turn off proxy buffering for the route (in nginx, fastcgi_buffering off or an X-Accel-Buffering: no response header), or the events arrive all at once. Octane with FrankenPHP, Swoole or RoadRunner keeps workers booted between requests.
  • Test without a provider. OrderAssistant::fake() and AgentResponse::fakeWithPendingApprovals() let feature tests post to the route and assert on the stream, including the approval request, without calling a model.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Laravel app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Laravel backend keeps enforcing its own authorization on every request.

In Laravel, add the identity hook and the loader to the <head> of your main Blade layout, before your application's own scripts, so they load once per document. For signed-in users, add a GET /api/agent-identity route in routes/web.php whose controller signs a short-lived token for $request->user(). Do not put the auth middleware on it: the endpoint must answer 401 rather than redirect to your login page. The signing secret stays in .env.

resources/views/layouts/app.blade.php (excerpt)
<head>
    {{-- Before your application's own scripts --}}
    <script src="/agent-identity.js"><\/script>
    <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
</head>

The tested Laravel integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your Laravel app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Laravel app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Laravel page at http://localhost:8000.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.