symfony/ai
Symfony AI provides PHP components and Symfony integration for building AI-powered applications. It includes abstractions for AI platforms, agents, tool calling, embeddings, vector stores, and RAG workflows, plus bridges to popular providers and a bundle for quick setup.
Install the bundle, not the individual components, on day one. composer require symfony/ai-bundle pulls in the platform contracts, agent loop, vector store abstractions, and the bridge packages for the providers you configure. Add provider bridges only for what you actually call — each bridge is a separate composer package and an extra HTTP dependency tree.
Configure one platform and one model first. In a Symfony app:
# config/packages/ai.yaml
ai:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
ollama:
host_url: '%env(OLLAMA_HOST)%'
Then inject the platform and resolve a model by name:
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\SystemMessage;
use Symfony\AI\Platform\Message\UserMessage;
use Symfony\AI\Platform\PlatformInterface;
final class SummaryController
{
public function __construct(private PlatformInterface $platform) {}
public function __invoke(string $text): string
{
$model = $this->platform->getModel('gpt-4o-mini');
$result = $model->invoke(new MessageBag(
new SystemMessage('Summarize the user text in one sentence.'),
new UserMessage($text),
));
return $result->getContent();
}
}
Where to look first, in order:
src/Platform/Contract (or the platform contracts package) — PlatformInterface, ModelInterface, ResultInterface, and the message classes are the whole mental model.temperature, max_tokens, tool-calling shape) are supported and how they are mapped.ai.tool, ai.agent, ai.vectorizer) and default aliases live. Run php bin/console debug:container --tag=ai.tool to see what the container actually registered.First real use case: a single-turn call with a system prompt, then structured output, then tools. Don't start with agents — start with a prompt and verify the raw response before adding any abstraction. Each layer (agent loop, toolbox, structured output) adds failure modes that are much harder to debug if you never saw the plain invocation work.
Optional but high leverage: install symfony/ai-mate as a dev dependency. It runs a local MCP server that serves package documentation to AI coding assistants, which measurably reduces hallucinated API calls when you're working in an unfamiliar 0.x API surface.
Keep PlatformInterface as a long-lived singleton and resolve models by string where the business decision lives. Do not inject a ModelInterface as a service unless the bundle exposes a named one — model names change faster than constructors.
$cheap = $platform->getModel('gpt-4o-mini');
$smart = $platform->getModel('gpt-4o');
$embed = $platform->getModel('text-embedding-3-small');
This makes per-endpoint cost/quality trade-offs explicit and testable. Route by task: classification and extraction to the small model, reasoning and tool orchestration to the large one.
MessageBag is a value object; the platform is stateless. Persist and replay the bag yourself:
$bag = $this->history->load($conversationId); // your storage
$bag->add(new UserMessage($input));
$result = $model->invoke($bag);
$bag->add(new AssistantMessage($result->getContent()));
$this->history->save($conversationId, $bag);
Store turns in a JSON column or a messages table with conversation_id, role, content, tool_calls. This is the single most common integration point with existing apps, and the place where context-window budgeting belongs.
$result = $model->invoke($bag);
return new StreamedResponse(function () use ($result) {
foreach ($result->getStream() as $chunk) {
echo $chunk->getContent();
@ob_flush(); flush();
}
}, 200, ['Content-Type' => 'text/event-stream']);
Disable output buffering and set X-Accel-Buffering: no if behind nginx. Prefer SSE over chunked plain text when you need to signal tool status or errors mid-stream.
final class Invoice
{
public function __construct(
public string $number,
public \DateTimeImmutable $issuedAt,
public float $total,
public array $lineItems = [],
) {}
}
$result = $model->invoke($bag, ['output_structure' => Invoice::class]);
$invoice = $result->as(Invoice::class);
Rules that keep this reliable: keep DTOs flat and shallow, avoid unions and mixed, use string for enums you'll normalize afterward, and always validate the hydrated object with the Symfony Validator before trusting it. Structured output is a schema constraint, not a correctness guarantee.
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
#[AsTool(name: 'order_lookup', description: 'Look up a single order by ID. Returns status and ETA. Use only when the user provides an order ID.')]
final class OrderLookupTool
{
public function __construct(private OrderRepository $orders) {}
public function __invoke(string $orderId): string
{
$order = $this->orders->find($orderId);
if (!$order) {
return "No order found for ID {$orderId}.";
}
return "Order {$orderId}: {$order->status}, ETA {$order->eta->format('Y-m-d')}";
}
}
The description is a prompt. Write it for the model, not for a human reader: say when not to call it, what the arguments mean, and what shape the return takes. Tools should be narrow, idempotent where possible, and return a string or serializable DTO — never an entity graph or a resource handle.
ai:
agent:
support_bot:
platform: 'ai.platform.openai'
model: 'gpt-4o-mini'
prompt: |
You are a support agent for Acme. You may look up orders.
If a tool returns no data, ask the user to confirm the ID.
tools:
- 'ai.tool.order_lookup'
Inject via #[Autowire(service: 'ai.agent.support_bot')]. Keep the system prompt in config (versioned, diffable) rather than in PHP, and cap the loop:
max_steps: 6
A hard step limit is the cheapest protection against runaway tool loops.
The shape is: chunk → vectorize into a store → similarity search at query time → inject as context.
foreach ($chunks as $i => $text) {
$vectorizer->vectorize(new Document("doc{$i}", $text));
}
$hits = $store->query(new VectorQuery($userQuestion), 5);
$context = implode("\n\n", array_map(fn ($h) => $h->document->content, $hits));
Build it as a console command (app:index-documents) with --reset and --batch flags so reindexing is repeatable. Use InMemoryStore in tests and one of the real backends (pgvector, Qdrant, Chroma, etc.) in production — swap through configuration, not code.
Token accounting, logging, retries, and PII redaction belong in a decorator around PlatformInterface or ModelInterface, not sprinkled through call sites:
final class MeteredPlatform implements PlatformInterface
{
public function __construct(
private PlatformInterface $inner,
private TokenMeter $meter,
) {}
// delegate, record usage from the result metadata, rethrow
}
Bind it in services.yaml with #[AsDecorator]. This works uniformly across providers and keeps vendor-specific code out of your domain services.
Use the platform test doubles so no network call is made in CI:
$platform = new MockPlatform([new MockModel([
new AssistantMessage('Mocked answer'),
])]);
$container->set(PlatformInterface::class, $platform);
Assert on the MessageBag that was sent (system prompt present, context injected, history trimmed) rather than on the exact model output. For tools, unit-test the tool class directly — it's a plain service.
Expose a curated set of tools over MCP so editors and other agents can call into your app:
ai:
mcp:
server:
tools:
- 'ai.tool.order_lookup'
Run it over stdio for local assistants, HTTP for shared services. Expose read-only tools first; add any mutating tool only with an explicit confirmation step in the tool itself.
Pin exact versions. Symfony AI is pre-1.0 and moving fast — class names, option keys, and bundle config keys have changed between minors. Use "symfony/ai-bundle": "0.x.*" and read the UPGRADE notes on every bump. Budget real time for upgrades.
Provider parity is not real. Tool calling, JSON schema enforcement, system-message support, streaming-with-tools, and image input differ per bridge. A prompt that works on OpenAI may silently degrade on Ollama because the bridge drops an unsupported option. Check $model->getCapabilities() before branching, and keep a per-provider smoke test.
Reasoning models reject parameters. Models in the o-series and equivalents reject temperature, top_p, and sometimes system messages. Don't hardcode those options globally; pass them per call site or per agent config.
Option names are platform-level, not vendor-level. You pass max_tokens, the bridge maps it. If you pass a vendor-specific key it may be forwarded verbatim or dropped — either way you won't get an error. Verify with the bridge's test fixtures.
DeferredResult is not a serializable value. If you get a deferred result, await it before returning from a controller or putting it in a queue payload. Caching a raw result object across requests will fail.
Context grows silently. Every turn appends. Add a trimming policy early: keep the system prompt, keep the last N turns, summarize older content with the cheap model. Log usage from the response metadata and alert on p95 token counts before your bill does.
Structured output and streaming don't mix. If you need both, stream a human-readable answer and run a separate non-streamed extraction call — or parse the streamed JSON incrementally, which is fragile.
Tool errors should be strings, not exceptions. An exception thrown inside a tool aborts the agent loop; a returned "Error: invalid order ID format" lets the model self-correct and retry. Reserve exceptions for infrastructure failures you want to bubble up.
Tool descriptions are the prompt. Vague descriptions cause over-calling (the model uses order_lookup for unrelated questions) and under-calling. Include negative guidance, expected argument formats, and a one-line return schema.
Vector dimension is baked into the store. Switching embedding models invalidates the index. pgvector and Qdrant will reject mismatched dimensions, sometimes with unhelpful errors. Version your index name or namespace by embedding model and keep a reindex command ready.
Don't vectorize in a request cycle. Indexing belongs in a worker/command. Do the query-time embedding inline, but wrap it in a short timeout and a fallback that answers without context rather than 500ing the page.
Retry on 429 and 5xx at the HTTP layer. The bridges use Symfony HttpClient; configure retry strategy on the client or in your decorator. Implement exponential backoff with jitter — provider rate limits are per-minute and bursty.
Multiple platforms: be explicit about the default. The bundle creates an alias for the default platform. Once you configure two, autowiring ambiguity or a silently wrong default becomes a real bug. Alias PlatformInterface explicitly in services.yaml.
Debugging order. First debug:container --tag=ai.tool to confirm registration, then a direct $model->invoke() with a hardcoded bag in bin/console or tinker, then the full agent. If the agent misbehaves, log the entire outbound MessageBag on every step — you'll immediately see whether the prompt, the tool schema, or the tool result is wrong.
Use AI Mate for API discovery. symfony/ai-mate serves accurate package docs over MCP to your editor assistant, which is faster and more reliable than pasting snippets from a blog post written against an older minor.
Log provider request IDs. When a call goes wrong, the provider's request ID is the only thing their support can act on. Capture it from response headers in your decorator alongside latency and token counts.
Security boundary: treat tool output as untrusted. Tool results are injected into the prompt; if a tool returns customer-supplied text, that's a prompt-injection vector. Never expose a tool that performs a destructive action based solely on model-supplied arguments without an authorization check inside the tool itself.
How can I help you explore Laravel packages today?