Symfony AI - Platform Component
The Platform component provides an abstraction for interacting with different models, their providers and contracts.
Installation
1
$ composer require symfony/ai-platform
Purpose
The Platform component provides a unified interface for working with various AI models, hosted and run by different providers. It allows developers to easily switch between different AI models and providers without changing their application code. This is particularly useful for applications that require flexibility in choosing AI models based on specific use cases or performance requirements.
Usage
The instantiation of the Platform class is usually delegated to a provider-specific factory, with a provider being OpenAI, Anthropic, Google, Replicate, and others.
For example, to use the OpenAI provider, you would typically do something like this:
1 2 3
use Symfony\AI\Platform\Bridge\OpenAi\Factory;
$platform = Factory::createPlatform(env('OPENAI_API_KEY'));
With this PlatformInterface instance you can now interact with the LLM:
1 2 3 4 5
// Generate a vector embedding for a text, returns a Symfony\AI\Platform\Result\VectorResult
$vectorResult = $platform->invoke('text-embedding-3-small', 'What is the capital of France?');
// Generate a text completion with GPT, returns a Symfony\AI\Platform\Result\TextResult
$result = $platform->invoke('gpt-4o-mini', new MessageBag(Message::ofUser('What is the capital of France?')));
Depending on the model and its capabilities, different types of inputs and outputs are supported, which results in a very flexible and powerful interface for working with AI models.
To use several backends behind a single Platform and route model invocations automatically,
see Providers and Multi-Provider Platforms.
Models
The component provides a model base class Model which is a combination of a model name, a set of capabilities, and additional options. Usually, bridges to specific providers extend this base class to provide a quick start for vendor-specific models and their capabilities.
Capabilities are a list of strings defined by Capability, which can be used to check if a model
supports a specific feature, like Capability::INPUT_AUDIO, Capability::OUTPUT_IMAGE, or Capability::THINKING.
Options are additional parameters that can be passed to the model, like temperature or max_output_tokens, and are
usually defined by the specific models and their documentation.
Model Size Variants
For providers like Ollama, you can specify model size variants using a colon notation (e.g., qwen3:32b, llama3:7b).
If the exact model name with size variant is not found in the catalog, the system will automatically fall back to the base
model name (qwen3, llama3) and use its capabilities while preserving the full model name for the provider.
You can also combine size variants with query parameters:
1 2 3 4 5 6 7 8 9
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog;
$catalog = new ModelCatalog();
// Get model with size variant
$model = $catalog->getModel('qwen3:32b');
// Get model with size variant and query parameters
$model = $catalog->getModel('qwen3:32b?temperature=0.5&top_p=0.9');
Custom models
For providers like Ollama, you can use custom models (built on top of Modelfile), as those models are not listed in
the default catalog. The ModelCatalog automatically queries the model information from the Ollama API:
1 2 3 4 5 6 7 8 9
use Symfony\AI\Platform\Bridge\Ollama\Factory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
$platform = Factory::createPlatform('http://127.0.0.1:11434');
$platform->invoke('your_custom_model_name', new MessageBag(
Message::ofUser(...)
));
Passing a Model Instance
Instead of a model name string, you can hand a fully defined model instance to Platform::invoke(). This
skips the catalog lookup entirely and is useful when a provider ships a model that is not (yet) part of the
shipped catalog, without registering it or replacing the catalog:
1 2 3 4 5 6 7 8 9 10 11 12
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
$model = new Gpt('gpt-newest', [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::TOOL_CALLING,
], ['temperature' => 0.5]);
$result = $platform->invoke($model, new MessageBag(Message::ofUser(...)));
Note
You must pass a bridge-specific model subclass (e.g. Gpt, Claude, Gemini), not the base
Model. Model clients, result converters, and contract normalizers select
the right implementation via the concrete class, so a bare Model instance has no client to handle it.
The platform routes the instance to the first provider whose model clients accept it; in multi-provider
setups where the same class is shared (e.g. OpenAI and Azure both use Gpt), the first matching provider
wins.
Supported Models & Platforms
- Language Models OpenAI's GPT with OpenAI, Azure and OpenRouter as Platform Anthropic's Claude with Anthropic and AWS Bedrock as Platform Meta's Llama with Azure, Ollama, Replicate, AWS Bedrock and OpenRouter as Platform Gemini with Google, Vertex AI and OpenRouter as Platform Vertex AI Gen AI with Vertex AI as Platform DeepSeek's R1 with OpenRouter as Platform Amazon's Nova with AWS Bedrock as Platform Mistral's Mistral with Mistral and OpenRouter as Platform Albert API models with Albert as Platform (French government's sovereign AI gateway) LiteLLM as unified Platform
- Embeddings Models Gemini Text Embeddings with Google and OpenRouter Vertex AI Text Embeddings with Vertex AI OpenAI's Text Embeddings with OpenAI, Azure and OpenRouter as Platform Voyage's Embeddings with Voyage as Platform Mistral Embed with Mistral and OpenRouter as Platform Qwen with OpenRouter as Platform
- Other Models
OpenAI's GPT Image with OpenAI as Platform (generation and editing)
OpenAI's Whisper with OpenAI and Azure as Platform
Mistral OCR with Mistral as Platform
LM Studio Catalog and HuggingFace Models with LM Studio as Platform.
* All models provided by HuggingFace can be listed with a command in the examples folder,
and also filtered, e.g.
php examples/huggingface/_model.php --provider=hf-inference --task=object-detection Voice Models ElevenLabs TTS with ElevenLabs as Platform ElevenLabs STT with ElevenLabs as Platform Cartesia TTS with Cartesia as Platform Cartesia STT with Cartesia as Platform Deepgram TTS with Deepgram as Platform Deepgram STT with Deepgram as Platform
For complete Deepgram setup and usage guide (TTS + STT), see Deepgram.
- Image/Video Models Decart T2I with Decart as Platform Decart T2V with Decart as Platform
Generic Platforms
Platforms like LiteLLM or OpenRouter provide a unified API to access multiple models from different providers. Therefore, they rely on endpoint and contract design, that is inspired by OpenAI's original GPT API - an implicit standard in the industry. Platforms using this de facto standard can be used with the generic bridge:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\Bridge\Generic\Factory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
$platform = Factory::createPlatform('https://api.example.com', 'sk-xxxxxx', $httpClient, $modelCatalog);
$messages = new MessageBag(
Message::forSystem('You are a pirate and you write funny.'),
Message::ofUser('What is the Symfony framework?'),
);
$result = $platform->invoke('model-name', $messages);
echo $result->asText();
This requires to configure a ModelCatalog explicitly, using CompletionsModel or EmbeddingsModel, see LiteLLM example for more details.
Alternatively, use the models.dev bridge to auto-discover model capabilities for many providers without manually curating model catalogs.
See Working with Model Catalogs for keeping catalogs current, adding custom models, or bypassing the catalog.
Providers and Multi-Provider Platforms
A Platform is a router over one or more
ProviderInterface instances. A provider encapsulates
everything needed to talk to a single inference backend (model clients, result
converters, contract, model catalog). The standalone Factory::createPlatform()
method is a convenience that wraps a single provider in a Platform.
For multi-provider setups, build the platform manually from multiple providers. The CatalogBasedModelRouter (the default) routes each invocation to the first provider whose catalog knows the requested model:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\AI\Platform\Bridge\Anthropic\Factory as AnthropicFactory;
use Symfony\AI\Platform\Bridge\OpenAi\Factory as OpenAiFactory;
use Symfony\AI\Platform\Platform;
$platform = new Platform([
OpenAiFactory::createProvider(apiKey: env('OPENAI_API_KEY')),
AnthropicFactory::createProvider(apiKey: env('ANTHROPIC_API_KEY')),
]);
$platform->invoke('gpt-4o', $messages); // → OpenAI
$platform->invoke('claude-3-5-sonnet', $messages); // → Anthropic
Provider instances also support an optional $name parameter for connection-level
identity, useful when running several instances of the same bridge (e.g. one OpenAI
connection per region):
1 2
OpenAiFactory::createProvider(apiKey: env('OPENAI_EU_KEY'), name: 'openai-eu');
OpenAiFactory::createProvider(apiKey: env('OPENAI_US_KEY'), name: 'openai-us');
Custom routing strategies (load balancing, model-pattern matching, input-based
selection) are implemented as additional
ModelRouterInterface implementations passed as the
second Platform constructor argument.
Routing Events
Before resolving a model to a provider, Platform dispatches a
ModelRoutingEvent. Listeners can modify the
model name, input, or options, or short-circuit routing entirely by setting a
provider directly:
1 2 3 4 5 6 7
use Symfony\AI\Platform\Event\ModelRoutingEvent;
$eventDispatcher->addListener(ModelRoutingEvent::class, function (ModelRoutingEvent $event) use ($customProvider) {
if ('priority-model' === $event->getModel()) {
$event->setProvider($customProvider); // skip router, use this provider
}
});
Provider-level events (InvocationEvent and ResultEvent) still fire inside the selected provider for per-invocation concerns.
Result Conversion Events
ResultEvent fires when the deferred result object is created, before the raw
result is converted. Because DeferredResult is
lazy, the actual result is only available later. To act on the resolved result, listen
to ResultConvertedEvent, which is dispatched once
the result has been converted (and ResultErrorEvent
when conversion fails):
1 2 3 4 5 6 7 8 9 10
use Symfony\AI\Platform\Event\ResultConvertedEvent;
use Symfony\AI\Platform\Event\ResultErrorEvent;
$eventDispatcher->addListener(ResultConvertedEvent::class, function (ResultConvertedEvent $event) {
// $event->getResult() is the resolved result; a listener may replace it via setResult()
});
$eventDispatcher->addListener(ResultErrorEvent::class, function (ResultErrorEvent $event) {
// $event->getError() carries the conversion exception (still rethrown to the caller)
});
A listener exception from ResultConvertedEvent propagates to the caller and does
not trigger ResultErrorEvent, which fires only when the conversion itself fails.
For a streamed result, ResultConvertedEvent fires when the stream result is created,
not when it is fully consumed.
Options
The third parameter of the invoke()
method is an array of options, which basically wraps the options of the corresponding
model and platform, like temperature or max_output_tokens:
1 2 3 4
$result = $platform->invoke('gpt-4o-mini', $input, [
'temperature' => 0.7,
'max_output_tokens' => 100,
]);
Note
For model- and platform-specific options, please refer to the respective documentation.
Language Models and Messages
One central feature of the Platform component is the support for language models and easing the interaction with them. This is supported by providing an extensive set of data classes around the concept of messages and their content.
Messages can be of different types, most importantly UserMessage, SystemMessage, or AssistantMessage, can have different content types, like Text, Image or Audio, and can be grouped into a MessageBag:
1 2 3 4 5 6 7 8 9
use Symfony\AI\Platform\Message\Content\Image;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Create a message bag with a user message
$messageBag = new MessageBag(
Message::forSystem('You are a helpful assistant.'),
Message::ofUser('Please describe this picture?', Image::fromFile('/path/to/image.jpg')),
);
Message Unique IDs
Each message automatically receives a unique identifier (UUID v7) upon creation. This provides several benefits:
- Traceability: Track individual messages through your application
- Time-ordered: UUIDs are naturally sortable by creation time
- Timestamp extraction: Get the exact creation time from the ID
- Database-friendly: Sequential nature improves index performance
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\Message\Message;
$message = Message::ofUser('Hello, AI!');
// Access the unique ID
$id = $message->getId(); // Returns Symfony\Component\Uid\Uuid instance
// Extract creation timestamp
$createdAt = $id->getDateTime(); // Returns \DateTimeImmutable
echo $createdAt->format('Y-m-d H:i:s.u'); // e.g., "2025-06-29 15:30:45.123456"
// Get string representation
echo $id->toRfc4122(); // e.g., "01928d1f-6f2e-7123-a456-123456789abc"
Message Templates
Message templates allow dynamic variable substitution in messages. Both system and user messages support templates, enabling reusable message patterns with runtime variables.
String Templates
String templates use curly braces for variable placeholders:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\Template;
// System message with template
$messages = new MessageBag(
Message::forSystem(Template::string('You are a {role} assistant.')),
Message::ofUser('What is PHP?')
);
$result = $platform->invoke('gpt-4o-mini', $messages, [
'template_vars' => ['role' => 'programming'],
]);
User messages also support templates:
1 2 3 4 5 6 7 8
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant.'),
Message::ofUser(Template::string('Tell me about {topic}'))
);
$result = $platform->invoke('gpt-4o-mini', $messages, [
'template_vars' => ['topic' => 'PHP'],
]);
Multiple messages can use the same variable set:
1 2 3 4 5 6 7 8 9 10 11
$messages = new MessageBag(
Message::forSystem(Template::string('You are a {domain} assistant.')),
Message::ofUser(Template::string('Calculate {operation}'))
);
$result = $platform->invoke('gpt-4o-mini', $messages, [
'template_vars' => [
'domain' => 'math',
'operation' => '2 + 2',
],
]);
Object Variables
Template variables are not restricted to scalar values - objects can be passed as well. They are normalized into an array first, and since the string renderer flattens nested arrays into dot-paths, the object's properties are addressable with dotted placeholders:
1 2 3 4 5 6 7 8
$messages = new MessageBag(
Message::forSystem('You are a product copywriter.'),
Message::ofUser(Template::string('Write a teaser for {product.name}, priced at {product.price} EUR.'))
);
$result = $platform->invoke('gpt-4o-mini', $messages, [
'template_vars' => ['product' => $product],
]);
By default, every property the normalizer can read ends up in the prompt. To
control which ones are exposed - for example to keep internal fields out of the
prompt - pass a normalizer context via the template_options option, such as
serialization groups:
1 2 3 4 5 6 7 8
$result = $platform->invoke('gpt-4o-mini', $messages, [
'template_vars' => ['product' => $product],
'template_options' => [
'normalizer_context' => [
'groups' => ['prompt'],
],
],
]);
With that context, only properties within the prompt serialization group are
normalized, and therefore only those can be referenced in the template.
Note
Object variables require a normalizer, see Setup below. Objects implementing
\Stringable are an exception: they are used as-is and not normalized.
Expression Templates
For advanced use cases, expression templates provide dynamic evaluation using Symfony's Expression Language:
1
$template = Template::expression('price * quantity');
Note
Expression templates require the symfony/expression-language component to be installed.
Setup
To use templates, register the TemplateRendererListener with your platform's event dispatcher:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
use Symfony\Component\EventDispatcher\EventDispatcher;
$eventDispatcher = new EventDispatcher();
$rendererRegistry = new TemplateRendererRegistry([
new StringTemplateRenderer(),
]);
$templateListener = new TemplateRendererListener($rendererRegistry);
$eventDispatcher->addSubscriber($templateListener);
$platform = Factory::createPlatform($apiKey, eventDispatcher: $eventDispatcher);
To use objects as template variables, the listener needs a normalizer as second argument - without it, object variables are rejected with an exception:
1 2 3
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
$templateListener = new TemplateRendererListener($rendererRegistry, new ObjectNormalizer());
Note
When using the AI Bundle, template rendering is automatically configured and available without manual setup,
including the serializer service as normalizer, so object variables work out of the box.
Result Streaming
Since LLMs usually generate a result word by word, most of them also support streaming the result using Server Side Events. Symfony AI supports that by abstracting the conversion and yielding semantic DeltaInterface deltas as content of the result.
The simplest way to consume a stream is asTextStream(), which filters for
TextDelta deltas only:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Platform and LLM
$messages = new MessageBag(
Message::forSystem('You are a thoughtful philosopher.'),
Message::ofUser('What is the purpose of an ant?'),
);
$result = $platform->invoke($model, $messages, [
'stream' => true, // enable streaming of response text
]);
foreach ($result->asTextStream() as $delta) {
echo $delta;
}
If you need access to all delta types (e.g. tool calls, thinking, metadata), use
asStream() instead:
1 2 3 4 5 6 7 8 9 10 11 12
use Symfony\AI\Platform\Result\Stream\Delta\TextDelta;
use Symfony\AI\Platform\Result\Stream\Delta\ToolCallComplete;
foreach ($result->asStream() as $delta) {
if ($delta instanceof TextDelta) {
echo $delta;
}
if ($delta instanceof ToolCallComplete) {
// handle tool calls
}
}
The following delta types are available:
- TextDelta -- a chunk of generated text
- ThinkingDelta -- a chunk of model reasoning
- ThinkingComplete -- signals thinking is complete, includes accumulated thinking text and optional signature
- ThinkingSignature -- a cryptographic signature for a thinking block
- ToolCallStart -- signals the start of a tool call
- ToolInputDelta -- a chunk of tool call input data
- ToolCallComplete -- signals all tool calls are complete and ready for execution
- MetadataDelta -- metadata associated with the stream
- ChoiceDelta -- a choice delta (e.g. multiple completions)
- BinaryDelta -- a chunk of binary data
Finish Reason
Every bridge whose provider reports why generation stopped exposes it as the finish_reason
result metadata, for both buffered and streamed results. This is what tells a complete answer
apart from one that was cut off by the output token limit:
1 2 3 4 5 6 7 8 9
use Symfony\AI\Platform\FinishReason\FinishReasonCase;
$result = $platform->invoke($model, $messages, ['max_tokens' => 50]);
$finishReason = $result->getMetadata()->get('finish_reason');
if ($finishReason?->is(FinishReasonCase::LENGTH)) {
// the answer is truncated -- continue generation or raise the limit
}
Providers spell the reason differently, so the value is a FinishReason object that normalizes it into a FinishReasonCase while keeping the provider's own wording available:
1 2
$finishReason->getCase(); // FinishReasonCase::LENGTH
$finishReason->getRaw(); // "max_tokens" on Anthropic, "MAX_TOKENS" on Gemini, "length" on OpenAI
The normalized cases are STOP, LENGTH, TOOL_CALL, CONTENT_FILTER,
STOP_SEQUENCE and OTHER. A provider-specific reason without an equivalent -- such as
Gemini's RECITATION -- normalizes to OTHER, and getRaw() tells those apart.
The translation itself lives in the bridge, in a FinishReasonMapper next to its result converter,
the same way TokenUsageExtractor is provided per bridge. A new bridge maps its own vocabulary onto
the cases above without touching the Platform component.
Note
The metadata is only set when the provider reports a reason, so guard against null.
The normalized case reflects what the provider actually reported: a provider that ends a
tool-call turn with a plain stop surfaces as STOP, not TOOL_CALL.
When streaming, the reason is only known once the stream has been consumed. It is emitted as
the final MetadataDelta, which asStream() promotes into the result metadata and skips
from the visible deltas:
1 2 3 4 5 6
foreach ($result->asTextStream() as $delta) {
echo $delta;
}
// available after the stream has been fully consumed
$finishReason = $result->getMetadata()->get('finish_reason');
Note
A streamed LENGTH is not surfaced by every bridge. Some providers -- Anthropic among them --
treat a truncation at the output token limit as an error mid-stream and throw a
MaxOutputTokensException instead of emitting the reason,
so the same max_tokens case that surfaces as LENGTH on a buffered result raises an exception
when streamed. Wrap the consumption loop in a try/catch when you need to handle truncation
of a streamed response.
Streaming in a Symfony Controller
To stream AI responses directly to the browser, wrap the consumption loop in a
StreamedResponse. This sends output to the client as soon as each chunk arrives, without
buffering the entire response in memory:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
final class ChatController
{
#[Route('/chat/stream', name: 'chat_stream')]
public function stream(PlatformInterface $platform): StreamedResponse
{
$messages = new MessageBag(
Message::ofUser('Tell me about Symfony.'),
);
$result = $platform->invoke('gpt-5-mini', $messages, [
'stream' => true,
]);
return new StreamedResponse(function () use ($result) {
foreach ($result->asTextStream() as $text) {
echo $text;
flush();
}
});
}
}
For JSON-based streaming (useful with JavaScript frontends), use StreamedJsonResponse instead,
which formats each chunk as a JSON event that can be consumed by an EventSource or fetch
reader. For more robust real-time delivery — automatic reconnection or multiplexing across
clients — an additional layer like Mercure can be used.
Thinking / Extended Reasoning
Some models support "extended thinking" or "reasoning" where the model
explicitly works through a problem step by step before producing its final
answer. This is exposed through the Capability::THINKING capability and
the streaming delta types ThinkingDelta
and ThinkingComplete.
Enabling Thinking
To enable thinking, pass the thinking option when invoking the model. For
Anthropic, the option configures the thinking budget (maximum tokens the model
may use for reasoning):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Anthropic Platform
$messages = new MessageBag(
Message::forSystem('You are a helpful math tutor.'),
Message::ofUser('What is the sum of the first 100 prime numbers?'),
);
$result = $platform->invoke('claude-sonnet-4-5', $messages, [
'stream' => true,
'thinking' => [
'type' => 'enabled',
'budget_tokens' => 10000,
],
]);
Consuming Thinking in Streams
When streaming, the generator yields thinking-related deltas alongside TextDelta and ToolCallComplete deltas:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
use Symfony\AI\Platform\Result\Stream\Delta\TextDelta;
use Symfony\AI\Platform\Result\Stream\Delta\ThinkingComplete;
use Symfony\AI\Platform\Result\Stream\Delta\ThinkingDelta;
foreach ($result->asStream() as $delta) {
if ($delta instanceof ThinkingDelta) {
// Incremental reasoning chunk (not shown to the user in most UIs)
echo '[thinking] ' . $delta->getThinking();
continue;
}
if ($delta instanceof ThinkingComplete) {
// The full thinking block is complete
echo '[thinking done] ' . $delta->getThinking() . "\n";
// Anthropic includes a cryptographic signature for verification
if (null !== $delta->getSignature()) {
// Store signature if you need to echo the thinking block
// back in subsequent requests
}
continue;
}
if ($delta instanceof TextDelta) {
echo $delta;
}
}
The ThinkingComplete delta has two methods:
getThinking()(string): the model's accumulated reasoning textgetSignature()(?string): a cryptographic signature (Anthropic only), required when echoing thinking blocks back in multi-turn conversations
Multi-Turn Conversations with Thinking
When using thinking in multi-turn conversations, Anthropic requires that thinking blocks from previous assistant turns be included in the conversation history. The AssistantMessage accepts a variadic list of ContentInterface parts, including Thinking blocks that carry the original reasoning text and its provider-specific signature:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
use Symfony\AI\Platform\Message\AssistantMessage;
use Symfony\AI\Platform\Message\Content\Text;
use Symfony\AI\Platform\Message\Content\Thinking;
// Include the model's thinking from a previous turn
$assistant = new AssistantMessage(
new Thinking('Let me work through this step by step...', 'sig_abc123...'),
new Text('The answer is 42.'),
);
$messages = new MessageBag(
Message::ofUser('What is the meaning of life?'),
$assistant,
Message::ofUser('Can you elaborate?'),
);
In practice you usually do not have to build the parts yourself. ofAssistant() accepts strings, content parts, and result objects, and unwraps them into the matching content parts (including thinking blocks with their signatures). Passing the result of a previous invocation back into the message bag is therefore a one-liner:
1 2 3 4 5
use Symfony\AI\Platform\Message\Message;
$result = $platform->invoke($model, $messages)->getResult();
$messages->add(Message::ofAssistant($result));
MultiPartResult is unwrapped recursively, so a result that contains a ThinkingResult followed by a TextResult (and any tool calls) is replayed in the same order on the next turn.
Checking for Thinking Support
You can check if a model supports thinking before enabling it:
1 2 3 4 5 6 7
use Symfony\AI\Platform\Capability;
$model = $catalog->getModel('claude-sonnet-4-5');
if ($model->supports(Capability::THINKING)) {
$options['thinking'] = ['type' => 'enabled', 'budget_tokens' => 10000];
}
Prompt Caching (Anthropic)
Anthropic supports prompt caching, which can significantly reduce costs and
latency for repeated prompts. Symfony AI automatically enables prompt caching
when using the Anthropic bridge by annotating the most cacheable regions of the
request with cache_control markers: the system prompt, the last tool
definition, and the last user message. The system prompt is typically the
largest and most stable region, making it the most effective caching target.
The caching behavior is configured via the cacheRetention parameter on the
ModelClient:
1 2 3 4 5 6 7 8 9 10
use Symfony\AI\Platform\Bridge\Anthropic\Factory;
// Using the Factory (defaults to 'short')
$platform = Factory::createPlatform($apiKey);
// Explicitly setting the cache retention
$platform = Factory::createPlatform($apiKey, cacheRetention: 'long');
// Disabling prompt caching
$platform = Factory::createPlatform($apiKey, cacheRetention: 'none');
Supported values:
short(default): 5-minute cache window using Anthropic's ephemeral TTLlong: 1-hour cache window (only available onapi.anthropic.com)none: disables prompt caching entirely
Note
OpenAI caches prompt prefixes automatically without any configuration needed.
Image Processing
Some LLMs also support images as input, which Symfony AI supports as content type within the UserMessage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use Symfony\AI\Platform\Message\Content\Image;
use Symfony\AI\Platform\Message\Content\ImageUrl;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Platform, LLM & agent
$messages = new MessageBag(
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
Message::ofUser(
'Describe the image as a comedian would do it.',
Image::fromFile(dirname(__DIR__).'/tests/fixtures/image.jpg'), // Path to an image file
Image::fromDataUrl('data:image/png;base64,...'), // Data URL of an image
new ImageUrl('https://foo.com/bar.png'), // URL to an image
),
);
$result = $agent->call($messages);
Document Processing
Models that support document understanding can receive PDF files through the Document content type within a UserMessage. This is useful for extracting information, summarizing content, or answering questions about a document:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\Message\Content\Document;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Platform, LLM & agent
$messages = new MessageBag(
Message::ofUser(
'Summarize the key points of this document.',
Document::fromFile('/path/to/report.pdf'), // Path to a PDF file
),
);
$result = $platform->invoke('gpt-5-mini', $messages);
Audio Processing
Similar to images, some LLMs also support audio as input, which is just another content type within the UserMessage:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\Message\Content\Audio;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Platform, LLM & agent
$messages = new MessageBag(
Message::ofUser(
'What is this recording about?',
Audio::fromFile('/path/audio.mp3'), // Path to an audio file
),
);
$result = $agent->call($messages);
Text-to-Speech
Beyond consuming audio, some models can generate audio from text. Pass a plain string as input and configure the voice and instructions through options. The result exposes the generated audio as binary data, which can be written to a file:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\AI\Platform\Bridge\OpenAi\TextToSpeech\Voice;
// Initialize Platform
$result = $platform->invoke('gpt-4o-mini-tts', 'Welcome to Symfony AI!', [
'voice' => Voice::CORAL,
'instructions' => 'Speak in a cheerful and positive tone.',
]);
// Write the audio binary to a file
file_put_contents('output.mp3', $result->asBinary());
Speech-to-Text
Some models can transcribe audio into text. Pass an
Audio as input and read the transcript
through asText():
1 2 3 4 5 6 7 8 9 10 11
use Symfony\AI\Platform\Bridge\ElevenLabs\Factory;
use Symfony\AI\Platform\Message\Content\Audio;
// Initialize Platform
$result = $platform->invoke(
model: 'scribe_v2',
input: Audio::fromFile('/path/audio.mp3'),
);
echo $result->asText(); // "Hello there"
ElevenLabs Scribe accepts provider-specific options that are forwarded to the
/v1/speech-to-text request, such as language_code, diarize,
num_speakers, timestamps_granularity and additional_formats. When
additional_formats is requested, the result is exposed as a structured
Transcript object through
asObject() instead of a plain text result:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
$result = $platform->invoke(
model: 'scribe_v2',
input: Audio::fromFile('/path/audio.mp3'),
options: [
'language_code' => 'en',
'diarize' => true,
'timestamps_granularity' => 'word',
'additional_formats' => [
['format' => 'srt', 'include_timestamps' => true],
],
],
);
echo $result->asObject()->getText(); // the plain transcript
echo $result->asObject()->asSubRipText(); // the SRT subtitles
echo $result->asObject()->getAdditionalFormat('srt'); // the SRT subtitles
Document OCR
Mistral's mistral-ocr-latest model uses a dedicated /v1/ocr endpoint that extracts text
(as markdown), layout images and per-page annotations from a document or image. Unlike chat
completions, it is invoked with a single document content object - a
DocumentUrl,
Document (binary PDF) or
ImageUrl - and returns a typed
OcrResult:
1 2 3 4 5 6 7 8 9 10 11 12
use Symfony\AI\Platform\Bridge\Mistral\Factory;
use Symfony\AI\Platform\Bridge\Mistral\Ocr\Result\OcrResult;
use Symfony\AI\Platform\Message\Content\DocumentUrl;
$platform = Factory::createPlatform($apiKey);
$result = $platform->invoke('mistral-ocr-latest', new DocumentUrl('https://example.com/document.pdf'));
$ocr = $result->asObject();
\assert($ocr instanceof OcrResult);
echo $ocr->getMarkdown();
The result exposes every Page with its markdown, dimensions, extracted layout images
(with bounding boxes) and optional annotations.
Embeddings
Creating embeddings of word, sentences, or paragraphs is a typical use case around the interaction with LLMs.
The standalone usage results in a Vector instance:
1 2 3 4 5 6 7
use Symfony\AI\Platform\Bridge\OpenAi\Embeddings;
// Initialize platform
$vectors = $platform->invoke('text-embedding-3-small', $textInput)->asVectors();
dump($vectors[0]->getData()); // returns something like: [0.123, -0.456, 0.789, ...]
Structured Output
A typical use-case of LLMs is to classify and extract data from unstructured sources, which is supported by some models by features like Structured Output or providing a Response Format.
PHP Classes as Output
Symfony AI supports that use-case by abstracting the hustle of defining and providing schemas to the LLM and converting the result back to PHP objects.
To achieve this, the Symfony needs to be registered with the platform:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use Symfony\AI\Platform\Bridge\Mistral\Factory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\MathReasoning;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new PlatformSubscriber());
$platform = Factory::createPlatform($apiKey, eventDispatcher: $dispatcher);
$messages = new MessageBag(
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
Message::ofUser('how can I solve 8x + 7 = -23'),
);
$result = $platform->invoke('mistral-small-latest', $messages, ['response_format' => MathReasoning::class]);
dump($result->asObject()); // returns an instance of `MathReasoning` class
Array Structures as Output
Also PHP array structures as response_format are supported, which also requires the event subscriber mentioned above. On
top this example uses the feature through the agent to leverage tool calling:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
// Initialize Platform, LLM and agent with processors and Clock tool
$messages = new MessageBag(Message::ofUser('What date and time is it?'));
$result = $agent->call($messages, ['response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'clock',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'],
'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'],
],
'required' => ['date', 'time'],
'additionalProperties' => false,
],
],
]]);
dump($result->getContent()); // returns an array
Populating Existing Object Instances
Instead of a class name, response_format also accepts an existing object
instance. The model populates the instance's missing fields while preserving the
values already set, and the very same instance is returned. This is useful for
enriching database records, completing incomplete records, or collecting data
progressively across multiple invocations using the same object.
Provide the object both as a template_vars entry (to give the model context
about the already known values) and as the response_format (to populate it).
This relies on the TemplateRendererListener being registered with a normalizer
so the object's properties can be rendered into the prompt template:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\Template;
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
$registry = new TemplateRendererRegistry([new StringTemplateRenderer()]);
$dispatcher->addSubscriber(new TemplateRendererListener($registry, new ObjectNormalizer()));
$dispatcher->addSubscriber(new PlatformSubscriber());
$city = new City(name: 'Berlin');
$messages = new MessageBag(
Message::ofUser(Template::string('Research missing data for: {city.name}')),
);
$result = $platform->invoke($model, $messages, [
'template_vars' => ['city' => $city],
'response_format' => $city,
]);
// The same instance is returned with its missing fields filled in
assert($city === $result->asObject());
To limit which properties are exposed to the model (for example to avoid leaking
internal fields), pass a normalizer context through template_options, such as
serialization groups:
1 2 3 4 5 6 7 8 9
$result = $platform->invoke($model, $messages, [
'template_vars' => ['product' => $product],
'template_options' => [
'normalizer_context' => [
'groups' => ['public'],
],
],
'response_format' => $product,
]);
Scoping the Schema to Serializer Groups
Structured output and tools are often built on top of existing domain objects, where only a subset of the properties
should be exposed to the model. The Factory accepts an optional
context on both buildProperties() and buildParameters(). Its serializer_groups key scopes the generated
schema to the given Symfony Serializer groups:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
use Symfony\Component\Serializer\Attribute\Groups;
final class Product
{
#[Groups(['read', 'write'])]
public string $name = '';
#[Groups(['write'])]
public ?int $price = null;
#[Groups(['read'])]
public string $slug = '';
public string $internalNote = '';
}
Without a context, all properties are described, which is the default behavior:
1 2 3 4 5 6
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
$factory = new Factory();
$factory->buildProperties(Product::class);
// properties: name, price, slug, internalNote
Passing serializer_groups limits the schema to the properties tagged with one of the given groups:
1 2 3 4 5
$factory->buildProperties(Product::class, ['serializer_groups' => ['write']]);
// properties: name, price
$factory->buildProperties(Product::class, ['serializer_groups' => ['read', 'write']]);
// properties: name, price, slug
The same context is accepted by buildParameters() for tool method arguments, and it is propagated into nested
schemas, so discriminated sub-schemas (anyOf) are scoped the same way.
Validating Structured Output
When using structured output, you might want to validate the generated data against some constraints. Symfony AI
provides a ValidatorSubscriber that uses the Symfony Validator component for this purpose.
To enable validation, register the ValidatorSubscriber with your platform's event dispatcher:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use Symfony\AI\Platform\Exception\ValidationException;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\AI\Platform\StructuredOutput\Validator\ValidatorSubscriber;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new PlatformSubscriber());
$dispatcher->addSubscriber(new ValidatorSubscriber());
$platform = Factory::createPlatform($apiKey, eventDispatcher: $dispatcher);
try {
$result = $platform->invoke('gpt-4o', $messages, ['response_format' => MathReasoning::class]);
} catch (ValidationException $e) {
$violations = $e->getViolations();
// handle violations
}
The ValidatorSubscriber will automatically validate any ObjectResult produced
by the PlatformSubscriber. To use this feature, make sure `symfony/validator` is installed in your project.
Streaming Partial Objects
When stream: true is combined with response_format: SomeClass::class, the platform yields a
progressively-populated instance of the target class on every chunk that materially changes the
recovered structure. Use DeferredResult::asStreamedObject() to iterate the snapshots and
DeferredResult::asObject() to obtain the final, validated object once the stream completes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
// Initialize platform with PlatformSubscriber registered on the event dispatcher.
$messages = new MessageBag(Message::ofUser('Give me a recipe for a Margherita pizza.'));
$result = $platform->invoke('gpt-4o-mini', $messages, [
'stream' => true,
'response_format' => Recipe::class,
]);
foreach ($result->asStreamedObject() as $recipe) {
render($recipe); // progressively populated Recipe instance (name, then ingredients, then steps)
}
$final = $result->asObject(); // fully materialized Recipe; runs ValidatorSubscriber if registered
asStreamedObject() yields the typed object directly. Under the hood each snapshot is emitted as a
PartialObjectDelta carrying both the typed object ($delta->getObject()) and the raw JSON buffer
accumulated so far ($delta->getBuffer()); iterate asStream() instead if you need the raw buffer.
Snapshots are de-duplicated — the listener only emits when the parsed structure actually changes. If the
ValidatorSubscriber is registered, validation runs once on the final object only; partial snapshots
are never validated, since they are by definition incomplete.
Parsing Partial JSON from Streams
When consuming structured output as a stream, every delta only contains a fragment of the final JSON payload. To
render incremental UI updates (e.g. progressively filling a form, showing a partial list of items, etc.) you need a
parser that can recover the largest valid structure from an incomplete payload. The
Symfony provides exactly that.
The parser first attempts a strict json_decode and, if that fails, applies best-effort fixes in order:
trailing commas, unclosed strings, dangling colons, partial true/false/null literals, and unclosed
{ / [ structures:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\AI\Platform\StructuredOutput\Streaming\PartialJsonParser;
$buffer = '';
foreach ($chunks as $chunk) {
$buffer .= $chunk;
$partial = PartialJsonParser::parse($buffer, $errorMessage);
if (null !== $partial) {
// render the partial structure (array/object/scalar)
}
}
The method is static, stateless, and dependency-free. It returns null and sets $errorMessage to the
json_last_error_msg() text only when the input is unrecoverable. On success $errorMessage is reset to null.
When you already have a streaming DeferredResult, asPartialJsonStream() wires the parser to the text-delta
stream for you and yields the recovered structure each time it changes:
1 2 3 4 5
$deferred = $platform->invoke('gpt-5-mini', $messages, ['stream' => true]);
foreach ($deferred->asPartialJsonStream() as $partial) {
// render the partial structure (array/object/scalar)
}
The generator skips deltas that leave the recovered structure unchanged and silently swallows buffers that are not recoverable yet, so a consumer can treat each iteration as a denser snapshot of the same logical value.
Server Tools
Some platforms provide built-in server-side tools for enhanced capabilities without custom implementations:
- OpenAI Server Tools - Web Search, File Search, Code Interpreter, Image Generation, MCP, Computer Use
- Anthropic Server Tools - Bash, Text Editor, Code Execution
- Gemini Server Tools - URL Context, Google Search, Code Execution
- Vertex AI Server Tools - URL Context, Google Search, Code Execution
For complete Vertex AI setup and usage guide, see Vertex AI.
Parallel Platform Calls
Since the Platform sits on top of Symfony's HttpClient component, it supports multiple model calls in parallel,
which can be useful to speed up the processing:
1 2 3 4 5 6 7 8 9
// Initialize Platform
foreach ($inputs as $input) {
$results[] = $platform->invoke('gpt-4o-mini', $input);
}
foreach ($results as $result) {
echo $result->asText().PHP_EOL;
}
Cached Platform Calls
Thanks to Symfony's Cache component, platform calls can be cached to reduce calls and resources consumption:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
use Symfony\AI\Agent\Agent;
use Symfony\AI\Platform\Bridge\Cache\CachePlatform;
use Symfony\AI\Platform\Bridge\OpenAi\Factory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\HttpClient\HttpClient;
$platform = Factory::createPlatform($apiKey, HttpClient::create());
$cachePlatform = new CachePlatform($platform, cache: new TagAwareAdapter(new ArrayAdapter()));
$firstResult = $cachePlatform->invoke('gpt-4o-mini', new MessageBag(Message::ofUser('What is the capital of France?')));
echo $firstResult->getContent().\PHP_EOL;
$secondResult = $cachePlatform->invoke('gpt-4o-mini', new MessageBag(Message::ofUser('What is the capital of France?')));
echo $secondResult->getContent().\PHP_EOL;
High Availability
As most platform exposes a REST API, errors can occurs during generation phase due to network issues, timeout and more.
To prevent exceptions at the application level and allows to keep a smooth experience for end users, the FailoverPlatform can be used to automatically call a backup platform:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
use Symfony\AI\Platform\Bridge\Ollama\Factory as OllamaFactory;
use Symfony\AI\Platform\Bridge\OpenAi\Factory as OpenAiFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
$rateLimiter = new RateLimiterFactory([
'policy' => 'sliding_window',
'id' => 'failover',
'interval' => '3 seconds',
'limit' => 1,
], new InMemoryStorage());
// # Ollama will fail as 'gpt-4o' is not available in the catalog
$platform = new FailoverPlatform([
OllamaFactory::createPlatform(env('OLLAMA_HOST_URL'), HttpClient::create()),
OpenAiFactory::createPlatform(env('OPENAI_API_KEY'), HttpClient::create()),
], $rateLimiter);
$result = $platform->invoke('gpt-4o', new MessageBag(
Message::forSystem('You are a helpful assistant.'),
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
));
echo $result->asText().\PHP_EOL;
This platform can also be configured when using the bundle:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
# config/packages/ai.yaml
ai:
platform:
openai:
# ...
ollama:
# ...
failover:
ollama_to_openai:
platforms:
- 'ai.platform.ollama'
- 'ai.platform.openai'
rate_limiter: 'limiter.failover_platform'
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
failover_platform:
policy: 'sliding_window'
limit: 100
interval: '60 minutes'
Note
Platforms are executed in the order they're injected into FailoverPlatform.
Note
FailoverPlatform reacts to runtime errors by falling back to the next platform.
For catalog-based routing (e.g. sending gpt-4o to OpenAI and claude-* to Anthropic
in the same Platform instance), see Providers and Multi-Provider Platforms.
Testing Tools
For unit or integration testing, you can use the InMemoryPlatform, which implements PlatformInterface without calling external APIs.
It supports returning either:
- A fixed string result
A callable that dynamically returns a simple string or any ResultInterface based on the model, input, and options:
1 2 3 4 5 6 7 8
use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Test\InMemoryPlatform; $platform = new InMemoryPlatform('Fake result'); $result = $platform->invoke('gpt-4o-mini', 'What is the capital of France?'); echo $result->asText(); // "Fake result"
Dynamic Text Results
1 2 3 4 5 6
$platform = new InMemoryPlatform(
fn($model, $input, $options) => "Echo: {$input}"
);
$result = $platform->invoke('gpt-4o-mini', 'Hello AI');
echo $result->asText(); // "Echo: Hello AI"
Vector Results
1 2 3 4 5 6 7 8 9
use Symfony\AI\Platform\Result\VectorResult;
use Symfony\AI\Platform\Vector\Vector;
$platform = new InMemoryPlatform(
fn() => new VectorResult([new Vector([0.1, 0.2, 0.3, 0.4])])
);
$result = $platform->invoke('gpt-4o-mini', 'vectorize this text');
$vectors = $result->asVectors(); // Returns Vector object with [0.1, 0.2, 0.3, 0.4]
Binary Results
1 2 3 4 5 6 7 8
use Symfony\AI\Platform\Result\BinaryResult;
$platform = new InMemoryPlatform(
fn() => new BinaryResult('fake-pdf-content', 'application/pdf')
);
$result = $platform->invoke('gpt-4o-mini', 'generate PDF document');
$binary = $result->asBinary(); // Returns the binary data as string
You can also save binary results directly to a file using asFile():
1 2
$result = $platform->invoke('gemini-2.5-flash-image', $messages);
$result->asFile('/path/to/output.png'); // Saves the binary content to a file
The method throws a RuntimeException if the target directory does not exist or is not writable.
Raw Results
The platform automatically uses the getRawResult() from any ResultInterface returned by closures. For string results, it creates an InMemoryRawResult to simulate real API response metadata.
This allows fast and isolated testing of AI-powered features without relying on live providers or HTTP requests.
Note
This requires `cURL` and the `ext-curl` extension to be installed.
Routing-Aware Mock Provider
InMemoryPlatform replaces the whole platform, so it ignores routing and returns text only.
When a test needs to go through real model routing, coexist with real providers, return non-text
results, or assert on exactly what was sent, use the provider-level mock from
MockPlatformFactory instead. It registers as a regular provider and
threads a scripted ResultInterface through unchanged, so
every result type is supported.
The scripted response can be a fixed string, a map keyed by model name, or a closure:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
use Symfony\AI\Platform\Test\MockPlatformFactory;
// 1. Fixed string - every call returns a TextResult
$platform = MockPlatformFactory::createPlatform('Mock result');
echo $platform->invoke('gpt-4o-mini', 'What is the capital of France?')->asText(); // "Mock result"
// 2. Map keyed by model name - per-model response
$platform = MockPlatformFactory::createPlatform([
'gpt-4o-mini' => 'cheap answer',
'gpt-4o' => 'expensive answer',
]);
// 3. Closure - full control, branch on the payload or options
$platform = MockPlatformFactory::createPlatform(
fn ($model, $payload, $options) => "Echo: {$payload}"
);
Because the result is passed through verbatim, structured output, embeddings, streams and tool calls work the same way:
1 2 3 4 5 6 7 8 9 10
use Symfony\AI\Platform\Result\ObjectResult;
use Symfony\AI\Platform\Result\VectorResult;
use Symfony\AI\Platform\Test\MockPlatformFactory;
use Symfony\AI\Platform\Vector\Vector;
$platform = MockPlatformFactory::createPlatform(fn () => new ObjectResult((object) ['city' => 'Paris']));
echo $platform->invoke('gpt-4o-mini', 'extract the city')->asObject()->city; // "Paris"
$platform = MockPlatformFactory::createPlatform(fn () => new VectorResult([new Vector([0.1, 0.2, 0.3])]));
$vectors = $platform->invoke('text-embedding-3-small', 'vectorize')->asVectors();
The mock records every call, so a test can assert on the exact payload and options the platform built (tool option translation, merged model options, and so on). Build the provider yourself to keep a reference to its MockModelClient:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
use Symfony\AI\Platform\Platform;
use Symfony\AI\Platform\Provider;
use Symfony\AI\Platform\Test\MockModelClient;
use Symfony\AI\Platform\Test\MockResultConverter;
$client = new MockModelClient('ok');
$provider = new Provider('mock', [$client], [new MockResultConverter()], new FallbackModelCatalog());
$platform = new Platform([$provider]);
$platform->invoke('gpt-4o-mini', 'Hello', ['temperature' => 0.5]);
$calls = $client->getCalls();
// $calls[0]['model']->getName() === 'gpt-4o-mini'
// $calls[0]['options'] === ['temperature' => 0.5]
By default the factory uses a FallbackModelCatalog, so any model name resolves to the mock. To gate which model names route to the mock - for example in a multi-provider routing test - pass a MockModelCatalog with explicit models instead:
1 2 3 4 5 6 7 8 9 10
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Test\MockModelCatalog;
use Symfony\AI\Platform\Test\MockPlatformFactory;
$provider = MockPlatformFactory::createProvider('mock answer', new MockModelCatalog([
'mock-model' => ['class' => Model::class, 'capabilities' => [Capability::INPUT_MESSAGES]],
]));
// $provider->supports('mock-model') === true
// $provider->supports('gpt-4o') === false
Code Examples
Note
Please be aware that some embedding models also support batch processing out of the box.