AI agents by framework / PHP
How to add an AI agent to a Symfony app
An AI agent in a Symfony app is an agent service configured in ai.yaml, tool services that act for the user in the security token, and a controller that streams the reply to the browser as server-sent events. The Symfony AI bundle provides the model clients, the tool loop and tool registration; you supply the tools, voters and limits. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in a Symfony app is made of
- A server endpoint: a controller action behind your session firewall that returns an
EventStreamResponse. The provider key is an environment variable or a Symfony secret referenced fromconfig/packages/ai.yaml, never a value rendered into Twig. - Tools: services marked with
#[AsTool]that call your repositories and read the current user from theSecurityservice. - The agent loop: an agent defined in
ai.yaml. Symfony AI calls the model, runs the tool calls and repeats until the model answers;max_tool_callscaps the loop. - A chat UI: a Twig template and a small script (or a Stimulus controller) that reads the event stream.
- Authorization and confirmation:
#[IsGranted]on the controller, voters for decisions about a specific order,#[IsCsrfTokenValid]on every POST, and a confirm endpoint the user triggers before data changes.
Ways to add an agent to Symfony
| Approach | Good for | What you take on |
|---|---|---|
Symfony AI (symfony/ai-bundle) | A custom agent with your own tools and provider, wired through the service container and configuration. | Tool services, authorization in every tool, confirmation UI, rate and cost limits, and API changes while the components are pre-1.0. |
A provider SDK (for example openai-php/client or anthropic-ai/sdk) | Direct API access when you want to own the tool loop. | The tool loop, streaming and history, plus everything in the row above. |
| 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 widget | Questions 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 Symfony AI
Symfony AI is the Symfony project's set of AI components: Platform for model clients, Agent for the tool loop, and the AI bundle that configures both in the container. The components are pre-1.0 (0.15 is the current stable release and a 1.0 beta is out), so read the upgrade notes when you update. The examples below use Symfony 8.1. Install the bundle, the agent component and a platform bridge:
composer require symfony/ai-bundle symfony/ai-agent symfony/ai-anthropic-platformThe Flex recipe adds an empty ANTHROPIC_API_KEY to .env. Set the real value with bin/console secrets:set ANTHROPIC_API_KEY or in the server environment, not in a committed file.
1. The agent and its tools
Define the agent in config/packages/ai.yaml. Tools are opt-in per agent, so list the tool service. max_tool_calls caps the loop; the default is 50, and when the cap is exceeded the agent throws a MaxIterationsExceededException.
ai:
platform:
anthropic:
api_key: '%env(ANTHROPIC_API_KEY)%'
agent:
orders:
platform: 'ai.platform.anthropic'
model: 'claude-sonnet-5-5'
prompt: 'You help the signed-in user manage their orders. Use the tools; never guess order data.'
tools:
- 'App\Agent\OrderTools'
max_tool_calls: 5A tool is a service with the #[AsTool] attribute; one class can expose several tools through its method argument. Symfony AI builds each tool's JSON schema from the method signature, the @param descriptions and #[Schema]. The tools take the user from the Security service, never from the model. cancel_order only checks the voter and reports back: Symfony AI can deny a call from a ToolCallRequested listener, but in 0.15 it has no built-in approval step for web requests, so the change itself goes through a separate endpoint the user confirms.
<?php
namespace App\Agent;
use App\Repository\OrderRepository;
use App\Security\OrderVoter;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;
use Symfony\Bundle\SecurityBundle\Security;
#[AsTool('list_orders', "List the signed-in user's recent orders", method: 'listOrders')]
#[AsTool('cancel_order', "Ask the signed-in user to confirm cancelling one of their open orders", method: 'cancelOrder')]
final readonly class OrderTools
{
public function __construct(
private Security $security,
private OrderRepository $orders,
) {
}
/**
* @param string|null $status Only orders with this status
*/
public function listOrders(#[Schema(enum: ['open', 'shipped', 'cancelled'])] ?string $status = null): array
{
return $this->orders->findRecentForUser($this->security->getUser(), $status);
}
/**
* @param int $orderId The order to cancel
*/
public function cancelOrder(int $orderId): string
{
$order = $this->orders->find($orderId);
if (null === $order || !$this->security->isGranted(OrderVoter::CANCEL, $order)) {
return 'No open order with that ID.';
}
// Nothing is cancelled here: the chat UI asks the user to confirm.
return "Asked the user to confirm cancelling order {$orderId}.";
}
}A voter holds the rule for who may cancel which order, so the tool and the confirm endpoint below apply the same check.
<?php
namespace App\Security;
use App\Entity\Order;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** @extends Voter<string, Order> */
final class OrderVoter extends Voter
{
public const CANCEL = 'ORDER_CANCEL';
protected function supports(string $attribute, mixed $subject): bool
{
return self::CANCEL === $attribute && $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $subject->getOwnerIdentifier() === $token->getUserIdentifier() && 'open' === $subject->getStatus();
}
}2. The streaming controller
#[IsGranted('IS_AUTHENTICATED')] and #[IsCsrfTokenValid] protect both actions; the CSRF attribute reads the token from the X-CSRF-Token header. The bundle registers each agent for autowiring by name, so the agent called orders arrives as AgentInterface $orders. With the stream option, iterating the execution yields Progress updates: text deltas go to the browser as text events, and a cancel_order call becomes a confirm event. EventStreamResponse (Symfony 7.3 and later) sets the event-stream headers, including X-Accel-Buffering: no. The cancel action loads the order, asks the voter and only then changes it.
<?php
namespace App\Controller;
use App\Repository\OrderRepository;
use App\Security\OrderVoter;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Agent\Execution\Update\Progress;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\Stream\Delta\TextDelta;
use Symfony\AI\Platform\Result\ToolCall;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\EventStreamResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerEvent;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED')]
#[IsCsrfTokenValid('agent', tokenKey: 'X-CSRF-Token', tokenSource: IsCsrfTokenValid::SOURCE_HEADER)]
final class AssistantController extends AbstractController
{
#[Route('/chat', methods: ['POST'])]
public function chat(Request $request, AgentInterface $orders): EventStreamResponse
{
// The browser sends the text transcript; tools still check every ID against the session user.
$messages = array_map(
static fn (array $turn) => 'assistant' === $turn['role'] ? Message::ofAssistant($turn['text']) : Message::ofUser($turn['text']),
$request->getPayload()->all('messages'),
);
$execution = $orders->call(new MessageBag(...$messages), ['stream' => true]);
return new EventStreamResponse(static function () use ($execution) {
foreach ($execution as $update) {
$payload = $update instanceof Progress ? $update->getPayload() : null;
if ($payload instanceof TextDelta) {
yield new ServerEvent(json_encode($payload->getText()), type: 'text');
}
if ($payload instanceof ToolCall && 'cancel_order' === $payload->getName()) {
yield new ServerEvent(json_encode($payload->getArguments()), type: 'confirm');
}
}
});
}
#[Route('/orders/{id}/cancel', methods: ['POST'])]
public function cancel(int $id, OrderRepository $orders): JsonResponse
{
$order = $orders->find($id) ?? throw $this->createNotFoundException();
$this->denyAccessUnlessGranted(OrderVoter::CANCEL, $order);
$orders->cancel($order);
return $this->json(['status' => 'cancelled']);
}
}3. The chat template
Render the token for the agent ID into the form, and read the stream with fetch, because EventSource can only send GET requests. The script keeps the transcript and sends it with each message; the server rebuilds it as a MessageBag.
<div id="agent-log" aria-live="polite"></div>
<form id="agent-form" data-csrf="{{ csrf_token('agent') }}">
<input name="message" aria-label="Message" required>
</form>
<script src="/assistant.js" defer><\/script>const form = document.querySelector("#agent-form");
const log = document.querySelector("#agent-log");
const history = [];
const post = (url, body) =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": form.dataset.csrf },
body: JSON.stringify(body),
});
function confirmCancel(orderId) {
const button = log.appendChild(document.createElement("button"));
button.textContent = `Cancel order ${orderId}`;
button.onclick = async () => {
const response = await post(`/orders/${orderId}/cancel`, {});
button.replaceWith(response.ok ? `Order ${orderId} cancelled.` : "The order was not cancelled.");
};
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
history.push({ role: "user", text: form.elements.message.value });
form.reset();
const reply = log.appendChild(document.createElement("p"));
const response = await post("/chat", { messages: history });
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
buffer += chunk.value;
const events = buffer.split("\n\n");
buffer = events.pop();
for (const sse of events) {
const type = sse.match(/^event: (.*)$/m)?.[1];
const data = JSON.parse(sse.match(/^data: (.*)$/m)?.[1] ?? "null");
if (type === "text") reply.textContent += data;
if (type === "confirm") confirmCancel(data.orderId);
}
}
history.push({ role: "assistant", text: reply.textContent });
});Production checklist for Symfony
- Authorize in the controller and in every tool. Keep the chat route under the same session firewall as your login, not a
statelessAPI firewall, because the tools read the user from the security token. Put per-object rules in voters and call them from tools and actions alike. The transcript comes from the browser, so treat it like any other request input. - Check CSRF tokens explicitly. Symfony has no global CSRF check for controllers. Add
#[IsCsrfTokenValid]to every action that changes data, with the same token ID that Twig'scsrf_token()renders (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 against your business rules, and keep irreversible changes behind a confirmation the user makes.
- Limit cost. Keep
max_tool_callslow, rate-limit the chat action per user with the RateLimiter component, and set spending limits with your model provider. - Allow for long streams. A streamed reply holds a PHP-FPM or FrankenPHP worker for its whole duration, so size the pool for concurrent chats and raise the request and proxy timeouts. Symfony saves and closes the session before the streamed body is sent, so a long reply does not block the user's other requests.
- Keep secrets server-side. Store the provider key with Symfony secrets or server environment variables and reference it as
%env(ANTHROPIC_API_KEY)%.
Option: embed an in-app agent with Bunkhouse
If the agent's job is to help signed-in users do work your Symfony 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 Symfony backend keeps enforcing its own authorization on every request.
In Symfony, add the identity hook and the loader to the <head> of templates/base.html.twig, before your application's own scripts, so they load once per document. For signed-in users, add a GET /api/agent-identity action that signs a short-lived token for $this->getUser(). The route must sit behind the same session firewall as your sign-in, not a stateless API firewall, and the signing secret stays in a server environment variable.
<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 Symfony 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 Symfony 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 Symfony 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:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Symfony page at http://127.0.0.1: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.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other PHP frameworks: CodeIgniter, Laravel