What is Symfony AI?
Symfony AI is the Symfony project's own set of PHP components for working with language models: one API for many model providers, agents that call your code as tools, vector stores for retrieval, and bundles that wire all of it into a Symfony application. It lives in the symfony/ai monorepo and is documented on symfony.com and ai.symfony.com.
The timeline matters for planning. The initiative was announced in August 2025, the Symfony team took on the official MCP PHP SDK with the PHP Foundation in September 2025, and the first tagged release, v0.1.0, came out on 24 December 2025. As of October 2026 the latest release is v0.14.1 (27 September 2026), with a new minor version roughly every month.
This guide is for teams deciding whether to put Symfony AI into a real product. If you want the path that uses only long-stable components (HttpClient, Messenger, the rate limiter and Doctrine with pgvector), read our guide on how to add AI to a Symfony app first. Here we cover the Symfony AI components themselves and what you still need to add around them.
Which components make up Symfony AI, and how stable are they?
Symfony AI has five components and two bundles. Each one is a separate Composer package, and each provider or database integration is a further "bridge" package you install only if you need it.
| Package | What it does | Typical use in a product |
|---|---|---|
Platform (symfony/ai-platform) |
One interface to call OpenAI, Anthropic, Gemini, Azure, Bedrock, Mistral, Ollama and many more | Summaries, extraction, chat, embeddings |
Agent (symfony/ai-agent) |
Tool calling loop, input and output processors, memory | An assistant that reads orders or drafts replies through your services |
Store (symfony/ai-store) |
Vector store abstraction with indexers and retrievers | RAG over help articles, documents or tickets |
Chat (symfony/ai-chat) |
Conversations with stored history | Multi-turn chat with persistence |
Mate (symfony/ai-mate) |
A command-line tool that lets coding agents inspect a running Symfony app | Development only |
AI Bundle (symfony/ai-bundle) |
Wires Platform, Agent, Store and Chat from config/packages/ai.yaml |
The usual entry point |
MCP Bundle (symfony/mcp-bundle) |
MCP server and client on top of the official mcp/sdk |
Exposing your app to Claude, ChatGPT or Cursor |
As of October 2026 (v0.14.1), the requirements and status are:
- PHP 8.2 or newer for the components.
- Symfony 7.3 or newer for the bundles, including 7.4 LTS and 8.x.
- Every package is marked experimental. The README of each component says so, which means it is not covered by Symfony's backward compatibility promise. Classes, config keys and method signatures can change between minor versions, and they do: v0.14 alone moved several platform bridges to a new asynchronous job model and introduced new factories for most HTTP-based stores.
- The MCP Bundle depends on
mcp/sdk^0.8. The SDK has its own release line (v0.9.0 shipped on 18 October 2026), so expect the bundle and SDK versions to move together.
For a 0.x package, Composer's caret constraint ^0.14 already means "0.14.x only", so a plain composer update will not jump you to 0.15. Keep it that way and upgrade on purpose.
How do you install the AI Bundle and configure OpenAI or Claude?
Install the bundle plus one bridge per provider. The bundle refuses to boot with a clear error if you configure a provider whose bridge is missing.
composer require symfony/ai-bundle symfony/ai-open-ai-platform symfony/ai-anthropic-platform
Every platform uses Symfony HttpClient, and the bundle lets you pass your own client service per platform. That is where production settings belong: timeouts and retries for rate limits and server errors.
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
ai.http_client:
scope: 'https://api\.(openai|anthropic)\.com'
timeout: 120
retry_failed:
max_retries: 2
delay: 2000
multiplier: 3
# config/packages/ai.yaml
ai:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
http_client: 'ai.http_client'
anthropic:
api_key: '%env(ANTHROPIC_API_KEY)%'
http_client: 'ai.http_client'
cache_retention: short # Anthropic prompt caching: none, short or long
Each entry becomes a service, ai.platform.openai and ai.platform.anthropic. You can test the connection before writing any code with php bin/console ai:platform:invoke anthropic <model> "Say hello".
Model names come from each bridge's model catalog. The catalogs in v0.14.1 include current models such as gpt-5-mini and claude-sonnet-5; if your provider ships a model the catalog does not know yet, the bundle lets you add it to the catalog in config instead of waiting for a release.
How do you build a Symfony AI agent with tools?
An agent is a platform plus a model, a system prompt and a list of tools. Tools are ordinary services marked with #[AsTool], and the bundle builds the JSON schema the model sees from the method signature and docblock.
# config/packages/ai.yaml (continued)
ai:
agent:
support:
platform: 'ai.platform.anthropic'
model: 'claude-sonnet-5'
prompt:
text: 'You help support staff answer questions about orders. Use tools, never guess order data.'
tools:
- 'App\Ai\Tool\FindOrder'
max_tool_calls: 8 # default is 50, far too many for a support bot
namespace App\Ai\Tool;
use App\Repository\OrderRepository;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;
#[IsGrantedTool('ROLE_SUPPORT')]
#[AsTool('find_order', 'Returns status, items and shipping data of one order by its number')]
final class FindOrder
{
public function __construct(private OrderRepository $orders) {}
/**
* @param string $number Order number as printed on the invoice, for example A-10423
*/
public function __invoke(#[Schema(pattern: '^A-[0-9]{4,8}$')] string $number): array
{
$order = $this->orders->findOneByNumber($number);
return $order ? $order->toAiSummary() : ['error' => 'Order not found'];
}
}
Then inject the agent by its service id and call it:
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class SupportAssistant
{
public function __construct(
#[Autowire(service: 'ai.agent.support')] private AgentInterface $agent,
) {}
public function answer(string $question): string
{
return $this->agent->call(new MessageBag(Message::ofUser($question)))->getContent();
}
}
Three details from practice:
- Tools are opt-in. An agent without a
toolskey gets no tools at all.tools: truegives it every#[AsTool]service in the app, which is rarely what you want once you have more than one agent. #[IsGrantedTool]uses Symfony Security, so the tool is checked against the current user. In a Messenger worker there is no logged-in user; decide up front which agents run in workers and give them only tools that do not depend on a session.- Return small, plain data from tools. Whatever a tool returns goes back into the context window and is billed as input tokens on every following step of the loop.
The bundle also supports agents as tools of other agents, multi-agent orchestration and tools from remote MCP servers. Start with one agent and one to five tools, measured on real questions, before adding any of that. Our AI agent development services follow the same rule.
How do you set up a vector store with pgvector?
The Store component separates three jobs: a vectorizer turns text into embeddings, an indexer writes documents into a store, and a retriever turns a question into a vector and queries the store. With the Postgres bridge, the store is a pgvector table.
composer require symfony/ai-postgres-store
# config/packages/ai.yaml (continued)
ai:
store:
postgres:
kb:
dbal_connection: 'doctrine.dbal.default_connection'
table_name: 'ai_kb_chunk'
vector_field: 'embedding'
distance: 'cosine'
setup_options:
vector_size: 1536
index_method: 'hnsw'
index_opclass: 'vector_cosine_ops'
vectorizer:
openai_small:
platform: 'ai.platform.openai'
model: 'text-embedding-3-small'
indexer:
kb:
vectorizer: 'ai.vectorizer.openai_small'
store: 'ai.store.postgres.kb'
retriever:
kb:
vectorizer: 'ai.vectorizer.openai_small'
store: 'ai.store.postgres.kb'
php bin/console ai:store:setup creates the table and index. Index documents with a tenant id in the metadata, and filter on it in every query:
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Store\Document\TextDocument;
use Symfony\Component\Uid\Uuid;
$this->kbIndexer->index(new TextDocument(
Uuid::v7()->toRfc4122(),
$article->getText(),
new Metadata(['tenant_id' => $tenant->getId(), 'article_id' => $article->getId()]),
));
$chunks = $this->kbRetriever->retrieve($question, [
'limit' => 8,
'where' => "metadata->>'tenant_id' = :tenant",
'params' => ['tenant' => (string) $tenant->getId()],
]);
What the store docs do not stress:
- The default index method is
ivfflat. Built on an empty table, asai:store:setupdoes, it gives poor recall later. Setindex_method: hnswexplicitly, as above. - The
whereoption is raw SQL. Never put user input into it; pass values throughparams. - Metadata is a JSONB column with no index on it. Add an expression index on
(metadata->>'tenant_id')in a Doctrine migration once the table grows past a few hundred thousand rows. - The id column is a UUID, so you cannot reuse integer entity ids; keep the entity id in metadata.
- Doctrine migrations do not know this table. Add it to
schema_filteror the nextdoctrine:migrations:diffwill try to drop it.
Chunking, hybrid search and evaluation work the same as with any stack; see our RAG implementation guide and, if you want a team to build it, RAG development services.
How does the Symfony MCP bundle work?
The MCP Bundle turns your Symfony app into an MCP server that AI clients such as Claude, ChatGPT or Cursor can connect to, over HTTP or STDIO. It can also act as an MCP client, so your own agents can use tools from other servers.
composer require symfony/mcp-bundle
# config/routes.yaml
mcp:
resource: .
type: mcp
# config/packages/mcp.yaml
mcp:
servers:
app:
name: 'acme-backoffice'
transports: { http: true }
http:
path: /mcp
registry:
tools: ['App\Mcp\']
session:
store: cache
cache_pool: 'cache.mcp.sessions'
namespace App\Mcp;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
final class OrderStatusTool
{
public function __construct(private OrderRepository $orders) {}
#[McpTool(name: 'order-status', description: 'Status and ETA of one order for the signed-in customer')]
public function __invoke(#[Schema(description: 'Order number, for example A-10423')] string $number): array
{
// load through the same voter-protected service your API uses
}
}
Access control is plain Symfony Security: each server has its own route, so a firewall or access_control rule protects it. For OAuth 2.1, which Claude and ChatGPT expect from remote servers, the bundle docs point to an external identity provider or a separate OAuth bundle; it does not issue tokens itself.
Two production details. The default session store is the filesystem under var/cache, which breaks as soon as you run two PHP containers behind a load balancer; use the cache or framework session store backed by Redis, as above. And keep the MCP server's tool list short and its write tools draft-only; our guide to building an MCP server for a SaaS explains tool design, and our MCP server development services cover the build.
What do the Symfony AI docs not tell you about production?
The components give you calls, agents and stores. They do not give you cost control, background execution or a test strategy. These are the pieces we add on every project.
Cost logging per feature
Each platform dispatches events through Symfony's event dispatcher. ResultConvertedEvent fires after every model response is converted, including every step of an agent's tool loop, and the result metadata carries a token_usage object with prompt, completion, cached and thinking tokens when the provider reports them.
use Symfony\AI\Platform\Event\ResultConvertedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final class LogAiUsage
{
public function __construct(private AiUsageRepository $usage, private AiFeatureContext $context) {}
public function __invoke(ResultConvertedEvent $event): void
{
$tokens = $event->getResult()->getMetadata()->get('token_usage');
$this->usage->record(
feature: $this->context->current(), // set by the caller, e.g. "support-bot"
model: $event->getModel()->getName(),
promptTokens: $tokens?->getPromptTokens() ?? 0,
completionTokens: $tokens?->getCompletionTokens() ?? 0,
cachedTokens: $tokens?->getCachedTokens() ?? 0,
);
}
}
Multiply by a price table you keep in config and you can answer "what does the support bot cost per tenant per month". Two caveats: for streamed responses the token counts arrive only after the stream is consumed, so record those from the stream's final metadata; and the web profiler panel shows the same data in development, but it is not a log.
Retries and background work with Messenger
Symfony AI calls are synchronous. Anything the user does not watch live should go through Messenger, as in the stable-components guide. The new part is mapping Symfony AI exceptions to Messenger retry behaviour:
use Symfony\AI\Platform\Exception\BadRequestException;
use Symfony\AI\Platform\Exception\RateLimitExceededException;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
try {
$summary = $this->agent->call($messages)->getContent();
} catch (RateLimitExceededException $e) {
$delayMs = ($e->getRetryAfter() ?? 30) * 1000;
throw new RecoverableMessageHandlingException($e->getMessage(), 0, $e, $delayMs);
} catch (BadRequestException $e) {
throw new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
The retry delay argument on RecoverableMessageHandlingException exists since Symfony 7.2, so it is available everywhere the bundle runs. Keep HttpClient retries low (one or two) and let Messenger handle longer waits; stacking three layers of retries turns one rate limit into minutes of blocked workers. For provider outages, the Failover platform bridge tries a list of platforms in order and can be wired from ai.yaml.
Rate limits per user and per tenant
Provider rate limits protect the provider, not your invoice. Put Symfony's RateLimiter in front of every agent call that a user can trigger, keyed by user and by tenant, exactly as shown in the stable-components guide. Also lower max_tool_calls: an agent that loops 50 times on one question is the most common cost spike we see.
Testing without calling the model
Symfony AI ships test doubles, and they are good:
InMemoryPlatformreturns a fixed string or the result of a closure, so unit tests of your services never hit the network.MockAgentreturns scripted answers per input and records the calls for assertions.AiAssertionsTraitfor functional tests reads the profiler's AI collector:assertPlatformCallCount(),assertModelCalled(),assertToolCalled(). It needs the profiler enabled in the test environment.
$platform = new InMemoryPlatform(fn ($model, $input) => 'Order A-10423 ships tomorrow.');
$agent = new Agent($platform, 'claude-sonnet-5');
These test your wiring, not answer quality. Keep 50 to 200 real inputs with expected outputs and run them against the real model on every prompt or model change.
Upgrades
Plan a short upgrade task for every minor release you take: read the changelog, run the test suite and the evaluation set, and check your ai.yaml against the new config reference. In our experience that is typically 2 to 8 hours per minor version for an app with a few agents, which is a typical range, not a promise.
When should you use plain HttpClient instead of Symfony AI?
Use the stable approach, a scoped HttpClient behind your own interface, when any of these is true:
| Situation | Better choice |
|---|---|
| One or two AI features on one provider | Plain HttpClient + Messenger |
| Symfony older than 7.3 or PHP older than 8.2 | Plain HttpClient, or upgrade first |
| Policy against experimental or 0.x dependencies | Plain HttpClient |
| Several providers, model switching per feature | Symfony AI Platform |
| Agents with many tools and processors | Symfony AI Agent |
| Retrieval with a vector database you may change later | Symfony AI Store |
| Your app must be callable from Claude or ChatGPT | MCP Bundle |
They mix well. A common shape in our projects: Symfony AI Platform and Agent for the assistant, plain HttpClient for one batch job that uses a provider feature the bridge does not expose yet, and the same LanguageModel interface in front of both so business code does not care which one runs. If Symfony AI changes in a way you cannot absorb, only the adapter changes.
What do we recommend for Symfony teams?
Our verdict as of October 2026: Symfony AI is good enough to build real features on, provided you treat it as a fast-moving dependency. Pin the minor version, keep your own interface between business code and the agent, log tokens through ResultConvertedEvent, run every non-interactive call through Messenger, limit tool calls, and test with InMemoryPlatform plus an evaluation set. For the first feature in an older app, the stable HttpClient approach is still the shorter path.
We have shipped AI on PHP and Symfony in our own product, AI Resume Master, which reached 50,000 monthly active users, built retrieval-based memory in the AI Grief Companion, and built large Symfony back ends such as the custom ERP for a US aircraft service company.
Next step
If you want a Symfony team to add an agent, a retrieval feature or an MCP server to your product, see our Symfony development services. AI features in existing products start from $10,000 with us, and a focused first feature can run as one AI Sprint: $10,000 fixed for 4 weeks. Contact us with the feature you have in mind and your Symfony version.