symfony/ai-platform
Experimental Symfony component offering an abstraction layer for AI models and their providers. It defines common contracts and interfaces, while separate bridge packages add support for platforms like Anthropic, OpenAI, Gemini, Ollama, and Mistral. Install with composer require symfony/ai-platform.
Treat the first day as a spike, not an integration. symfony/ai-platform is an experimental 0.x Symfony component with no Laravel service provider, no Laravel-facing docs, and (at time of writing) essentially zero dependent packages. You are adopting an abstraction layer plus a bridge, and wiring the container yourself.
Minimum install
composer require symfony/ai-platform
composer require symfony/ai-open-ai-platform # or symfony/ai-ollama-platform for local/dev
The bridges are thin HTTP clients built on symfony/http-client, so make sure that is present and that you are not assuming Laravel's Http facade will intercept anything. If you want local, zero-cost iteration, use Ollama or Docker Model Runner first — you get the same Platform surface without burning tokens or leaking keys.
First wiring step: one service provider, one config file, env-only secrets
// config/ai.php
return [
'default' => env('AI_PROVIDER', 'openai'),
'providers' => [
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('AI_MODEL', 'gpt-4.1-mini'),
],
'ollama' => [
'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
'model' => env('AI_MODEL', 'llama3.1'),
],
],
];
Bind the bridge's Platform (and its model catalog) in a dedicated provider, reading config — do not read env() inside the provider, config caching will bite you. Keep the binding scoped if model/base URL varies per tenant.
Where to look first
Platform class and model catalog are the only concretely useful API surface at this stage.symfony.com/doc/current/ai/components/platform.html — this is the canonical reference; the README is just the bridge index.symfony/ai-cache-platform and symfony/ai-failover-platform — these are where the abstraction actually earns its keep, not the model calls themselves.Recommended first use case: a queued job that classifies or extracts structured data from a single record (a support ticket, an inbound email). Non-streaming, low volume, one provider, typed DTO output. It exercises install, container binding, error handling, and testing without touching the messier surfaces (streaming, tool calls, embeddings) on day one.
Wrap it, don't sprinkle it. The abstraction is provider-agnostic but not framework-agnostic in ergonomics: it will still leak Symfony\AI\... types into your application layer. Put a single small adapter between your domain and the package:
interface AiClient
{
public function complete(string $prompt, array $options = []): AiResponse;
public function extract(string $prompt, string $dtoClass): object;
}
Everything below plugs into that seam, which is also your escape hatch when a 0.x minor release breaks a signature.
Provider-agnostic model selection via config, not code. One config key per feature is the pattern that survives contact with reality:
// config/ai.php
'features' => [
'ticket_classification' => ['provider' => 'openai', 'model' => 'gpt-4.1-mini', 'temperature' => 0],
'summarisation' => ['provider' => 'anthropic', 'model' => 'claude-sonnet-4-5'],
'embeddings' => ['provider' => 'openai', 'model' => 'text-embedding-3-small'],
],
Then resolve a platform per feature name from a factory. This lets you downgrade a model in .env during an incident without a deploy of code.
Structured output is the killer pattern for Laravel apps. A DTO-shaped result turns an LLM call into something that behaves like a normal repository call — validated, storable, testable:
final class TicketClassification
{
public string $category;
public string $priority;
public bool $requiresHuman;
}
Generate the JSON schema from the DTO (the component leans on Symfony Serializer/Validator metadata for this; 0.11 added serializer groups, and there is a SchemaProviderInterface for runtime fragments). Validate the returned payload against the DTO and treat validation failure as a normal application error, not an exception to swallow. Version 0.9 added server-side structured-output validation and 0.11 added streaming structured output — the fact that both arrived late tells you this area was still settling.
Streaming: route it through a Laravel streamed response, behind auth and rate limits. The bridges expose streamed deltas as semantic delta objects (0.7 replaced untyped chunks with DeltaInterface), and there are DeferredResult/BinaryResult/MultiPartResult result types. Adapt to SSE in a controller with response()->stream(), disable output buffering, and set a generous max_execution_time. Do not stream from a queue worker; use a dedicated short-lived request or a broadcasting channel.
Tool calls = your existing Laravel actions. Define tools as small invokable classes that call your own services, then register them for the model. Keep every tool idempotent and log the tool-call trace — 0.9 added tool-call trace metadata for the CLI bridges and 0.11 added tool metadata, but you still want your own audit row per invocation. Return the tool result into the conversation message bag; 0.11 fixed parallel tool calls being dropped, so if you are on <0.11 expect that bug.
Cache and failover bridges are the reason to prefer this over a bespoke client. Wrap the primary platform with the failover bridge pointing at a secondary provider, and wrap embeddings with the cache bridge keyed on hash('xxh128', $model.$text). Back the cache with Laravel's store — Illuminate\Contracts\Cache\Repository satisfies PSR-16, so a small adapter is usually all you need. This alone can cut a meaningful slice of spend on repeated RAG queries.
Everything long goes on a queue. Use jobs with $tries, $backoff, and a RateLimiter key per provider/tenant. The component throws RateLimitExceededException and a retryable ServerException for transient 5xx/overload — map both to $this->release($backoff) rather than letting them fail the job.
RAG pattern with the pieces available: embeddings → pgvector (whereVectorSimilarTo in Laravel 11+, or a raw <=> query) → platform-level reranking/hybrid retriever (added 0.7) → completion with citations. Keep chunking and the embedding model version pinned in a table; a model swap invalidates your index silently otherwise.
Testing. Use the Mock test provider in feature tests and bind it over the real binding in TestCase::setUp(). For anything that still hits symfony/http-client, use MockHttpClient — Http::fake() does nothing here. Write one contract test suite against the AiClient interface and run it against both the mock and a real Ollama platform in CI nightly.
Observability. Subscribe to ResultConvertedEvent / ResultErrorEvent (0.11) and write a row per call: feature, provider, model, prompt hash, latency, token usage, estimated cost, finish reason. Finish-reason normalisation only landed in 0.11, so normalise it yourself if you are on an older minor. This table is what makes the experiment debuggable and is your evidence for keeping or dropping the package.
Artisan surface. The component ships a model-list command (0.7) and catalog sync. Wrap it in an Artisan command or invoke the Symfony command directly so php artisan ai:sync-models fits your ops runbook. Cache the catalog in your own table rather than resolving it at request time.
The BC promise does not apply. This is explicitly experimental. Across 0.6 → 0.11 you can see AssistantMessage reworked, untyped stream chunks replaced by DeltaInterface, the OpenAI chat-completions client swapped for the Responses API, ElevenLabsApiCatalog renamed to ModelCatalog, ModelCatalog replaced by OllamaApiCatalog, and DALL·E retired for gpt-image. Pin exact versions ("symfony/ai-open-ai-platform": "0.11.0"), read the changelog before every bump, and keep the adapter seam so the blast radius is one class.
Read-only sub-tree. This repository is a split; issues and PRs go to github.com/symfony/ai. Don't file against the split and don't expect a fix to land in the package you composer required under the same name.
No Laravel integration exists. You are writing the service provider, config, key resolution, and error translation. Budget a day for that, and expect to re-do parts of it on minors.
Symfony HttpClient ≠ Laravel Http. Two consequences: Http::fake() and Http::assertSent() will not see these calls, and Laravel's global HTTP middleware (retries, logging, tracing) will not apply. Use MockHttpClient or the Mock provider; add your own middleware via a decorated HttpClientInterface if you need tracing.
Two validator/serializer worlds coexist. JSON-schema generation for structured output leans on Symfony Serializer and Validator metadata. Those are not Laravel's Validator facade or Rule objects. Keep Symfony #[Assert\...] attributes on AI DTOs and Laravel rules on request classes; do not mix them on one class. Check that serializer mapping/naming strategy produces the schema you expect before shipping — a wrong schema fails at the provider, with a confusing error.
Singleton platforms capture tenant state. If you resolve an API key or base URL per tenant, bind the platform as scoped, not singleton, and reset it between requests under Octane. A singleton that resolved tenant A's key will serve tenant B.
Streaming is where the sharp edges are. The bridges have had repeated SSE/NDJSON fixes: content-type text/event-stream was assumed and later relaxed, Gemini/Perplexity needed an SSE regression reverted, incomplete terminal events were fixed in 0.10, and truncated Anthropic streams at max output tokens now throw. Practical mitigations: test streaming against Ollama and your production provider, always handle a missing terminal chunk as a hard error, set explicit read timeouts, and make sure nginx/php-fpm buffering is off for the SSE route.
Timeouts and retries live in two places. Configure symfony/http-client timeouts explicitly (defaults are optimistic for LLM latency) and let your queue handle retries. Retrying inside a stream is meaningless — you will have already flushed partial output.
Rate limits are provider-shaped, not app-shaped. RateLimitExceededException now surfaces the provider's message (0.9), which is genuinely useful in logs. Combine with a Laravel RateLimiter per provider so you fail fast locally instead of burning queue attempts.
Tool-call pitfalls to encode as tests: tool calls with no arguments, tool results ordering in the message bag, parallel tool calls (dropped pre-0.11), and long conversations accumulating stale tool messages — use MessageBag::withoutToolMessages() before summarising. MessageBag::isLastMessageFrom() and withoutToolMessages() (0.10/0.11) exist precisely because chat loops get this wrong.
Capability metadata is unreliable. There have been repeated catalog fixes (gpt-5.5-pro capability mismatch, Bedrock catalog sync, Ollama capability insert, audio capability for Gemma). Do not gate features on catalog-reported capabilities alone; keep a small allowlist of models you have actually tested per feature.
Base URL overrides skip bridge-specific checks. Useful for proxies and self-hosted gateways, but you lose the specialised bridge validation — verify auth header shape yourself when pointing an OpenAI bridge at a compatible endpoint.
Sanitise input even though malformed UTF-8 is now handled. 0.10 replaces malformed UTF-8 in request bodies instead of aborting; that is a safety net, not a licence to send raw HTML scraped from the web.
Cost visibility is partial. Token-usage extraction exists for completions and embeddings, but it is per-bridge and has gaps. Maintain your own price table keyed by model and compute cost in your logging listener; you will need it when someone asks why the AI line item tripled.
Have an exit plan. Given 55 stars, zero dependents, monthly 0.x releases and an experimental label, the honest recommendation is: use it for a contained, queue-backed feature behind your own AiClient interface, measure cost and reliability for a quarter, and be prepared to swap the adapter for a stable client (or a Laravel-native one) if the API churn outpaces your maintenance appetite. The abstraction is worth it if you genuinely need multi-provider failover or local-model support; if you only ever call one provider, a thin hand-rolled client may cost less over a year.
How can I help you explore Laravel packages today?