Skip to content

Symfony AI - Platform Component

Edit this page

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

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:

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 text
  • getSignature() (?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 TTL
  • long: 1-hour cache window (only available on api.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\AI\Platform\StructuredOutput\PlatformSubscriber 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\AI\Platform\StructuredOutput\Streaming\PartialJsonParser 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:

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.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version