symfony/ai-mistral-platform
Symfony AI bridge for the Mistral platform, providing chat completions and OCR integration through Mistral's API. Enables Symfony applications to use Mistral models for AI-powered text and document processing.
Install the Package Add the package via Composer in your Laravel project:
composer require symfony/ai-mistral-platform
v0.15.0 bumps the internal version constraints to 0.15 (see #2753). Every symfony/ai-* component in your composer.lock must move together — if another package pins ^0.14 you will get a resolver conflict, not a runtime error.
Set Up HTTP Client
Bind the Symfony HTTP client to Laravel's container in a real service provider (not config/app.php):
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(
\Symfony\Contracts\HttpClient\HttpClientInterface::class,
fn () => \Symfony\Component\HttpClient\HttpClient::create()
);
}
Configure Mistral API Key
Store your Mistral API key in Laravel's .env:
MISTRAL_API_KEY=your_api_key_here
First Use Case: Chat Completion Create a simple service to interact with Mistral's chat endpoint:
use Symfony\Ai\Mistral\MistralClient;
use Symfony\Ai\Mistral\ChatCompletion;
class MistralChatService
{
public function __construct(private MistralClient $client)
{
}
public function ask(string $question): string
{
$response = $this->client->chatCompletion(
new ChatCompletion('mistral-tiny', $question)
);
return $response->getContent();
}
}
Register the Service
Bind the MistralClient and MistralChatService in a provider:
use Symfony\Ai\Mistral\MistralClient;
public function register(): void
{
$this->app->bind(MistralClient::class, function ($app) {
return new MistralClient(
$app->make(\Symfony\Contracts\HttpClient\HttpClientInterface::class),
$app['config']['services.mistral.api_key']
);
});
$this->app->bind(MistralChatService::class);
}
Use in a Controller Inject the service into a controller and call it:
public function askQuestion(Request $request, MistralChatService $chatService)
{
$response = $chatService->ask($request->input('question'));
return response()->json(['answer' => $response]);
}
Read Token Usage (v0.14+)
Every result exposes a TokenUsage value object. Since v0.14.0 it also carries the model that produced the tokens, which is what you need for per-model cost accounting:
$result = $client->chatCompletion(new ChatCompletion('mistral-small-latest', $question));
$usage = $result->getTokenUsage();
// $usage->getModel(), $usage->getPromptTokens(),
// $usage->getCompletionTokens(), $usage->getTotalTokens()
Cap Output Length (changed in v0.15.0)
The canonical max_output_tokens option was removed in favour of each bridge's own wire field (#2622). For Mistral that is max_tokens:
$client->chatCompletion(
new ChatCompletion('mistral-small-latest', $question, ['max_tokens' => 512])
);
If you were passing max_output_tokens, this is the one thing to fix before upgrading — see Gotchas.
Provider Abstraction for Multi-Provider Support
Leverage the Provider abstraction to route requests dynamically between Mistral and other AI providers (e.g., OpenAI):
use Symfony\Ai\Provider\ProviderInterface;
use Symfony\Ai\Provider\MistralProvider;
class AiProviderRouter
{
public function __construct(
private ProviderInterface $mistralProvider,
private ProviderInterface $openAiProvider
) {}
public function getProviderForModel(string $model): ProviderInterface
{
return str_starts_with($model, 'mistral-')
? $this->mistralProvider
: $this->openAiProvider;
}
}
Streaming Responses with Laravel Queues
Process streaming responses (DeltaInterface) asynchronously using Laravel Queues:
use Symfony\Ai\Mistral\MistralStreamingClient;
public function streamChatResponse(MistralClient $client, string $question)
{
$response = $client->chatCompletion(
new ChatCompletion('mistral-tiny', $question)
);
foreach ($response->getStream() as $delta) {
ProcessStreamDelta::dispatch($delta->getContent());
}
}
Embeddings for Semantic Search Use Mistral's embeddings for document similarity or search:
use Symfony\Ai\Mistral\EmbeddingClient;
public function generateEmbedding(EmbeddingClient $client, string $text): array
{
$embedding = $client->embed($text);
return $embedding->getEmbedding();
}
Cost Accounting per Model (v0.14+)
TokenUsage reports the model it belongs to, so a single choke point can persist spend without re-reading the request:
use Symfony\Ai\Mistral\MistralClient;
use Symfony\Ai\Mistral\ChatCompletion;
final class RecordAiSpend
{
public function __construct(private MistralClient $client) {}
public function complete(string $model, string $prompt, array $options = []): string
{
$result = $this->client->chatCompletion(new ChatCompletion($model, $prompt, $options));
$usage = $result->getTokenUsage();
\DB::table('ai_usage')->insert([
'model' => $usage->getModel(), // the model actually billed
'prompt_tokens' => $usage->getPromptTokens(),
'completion_tokens' => $usage->getCompletionTokens(),
'total_tokens' => $usage->getTotalTokens(),
'cost_usd' => $this->pricing->cost(
$usage->getModel(),
$usage->getPromptTokens(),
$usage->getCompletionTokens()
),
'created_at' => now(),
]);
return $result->getContent();
}
}
Pricing Table Driven by the Model Catalog
Because the package ships an updated model catalog each release (again in v0.15.0, #2666), pair the model property with a price list keyed by model identifier:
// config/mistral-pricing.php
return [
'mistral-small-latest' => ['input' => 0.20 / 1_000_000, 'output' => 0.60 / 1_000_000],
'mistral-large-latest' => ['input' => 2.00 / 1_000_000, 'output' => 6.00 / 1_000_000],
];
public function cost(string $model, int $in, int $out): float
{
$rate = config("mistral-pricing.{$model}");
return $rate ? ($in * $rate['input']) + ($out * $rate['output']) : 0.0;
}
If $rate is null you are silently free-of-charge — log a warning rather than returning 0.0 in production.
Output-Length Limits via a Single Option Resolver (new in v0.15.0) Now that each bridge names its own wire field, centralise the mapping so a future rename touches one file:
// config/mistral.php
'options' => [
'max_tokens' => 512, // Mistral wire field; was the canonical `max_output_tokens`
];
public function complete(string $prompt)
{
return $this->client->chatCompletion(
new ChatCompletion($this->model(), $prompt, config('services.mistral.options'))
);
}
Never scatter literal option keys through jobs — that is exactly how the v0.15 rename becomes a multi-file migration.
Error Handling with Uniform Exceptions Catch Mistral-specific errors uniformly:
use Symfony\Component\Ai\Exception\AiException;
try {
$response = $client->chatCompletion($completion);
} catch (AiException $e) {
report($e);
return response()->json(['error' => $e->getMessage()], 500);
}
Configuration Management
Centralize Mistral configuration in config/services.php:
'mistral' => [
'api_key' => env('MISTRAL_API_KEY'),
'base_uri' => env('MISTRAL_API_BASE_URI', 'https://api.mistral.ai/v1'),
'timeout' => 30.0,
'models' => [
'chat' => 'mistral-tiny',
'embedding' => 'mistral-embed',
],
'options' => [
'max_tokens' => 512,
],
],
Real-Time Chatbot Integration
ChatCompletion for interactive chat interfaces.public function chat(Request $request)
{
$response = $this->client->chatCompletion(
new ChatCompletion('mistral-tiny', $request->input('prompt'))
);
return response()->stream(function () use ($response) {
foreach ($response->getStream() as $delta) {
echo $delta->getContent();
flush();
}
});
}
Batch Embedding Generation
public function generateBatchEmbeddings(array $texts)
{
foreach ($texts as $text) {
GenerateEmbeddingJob::dispatch($text);
}
}
Hybrid Search with Embeddings
public function searchDocuments(string $query)
{
$queryEmbedding = $this->generateEmbedding($query);
return $this->searchEngine->search($queryEmbedding, limit: 5);
}
Fallback Logic for Multi-Provider
public function getCompletionWithFallback(string $model, string $prompt)
{
try {
return $this->mistralProvider->complete($model, $prompt);
} catch (AiException) {
return $this->openAiProvider->complete('gpt-3.5-turbo', $prompt);
}
}
Per-User / Per-Tenant AI Budgets
TokenUsage::getModel() available, aggregate spend by tenant and enforce a soft cap before dispatching the next request:
public function guard(string $tenantId): void
{
$spent = AiUsage::where('tenant_id', $tenantId)
->whereMonth('created_at', now()->month)
->sum('cost_usd');
abort_if($spent > config('ai.monthly_budget'), 429, 'AI budget exceeded');
}
Upgrade Routine for Option Renames and Catalog Bumps (v0.15.0)
max_output_tokens → per-bridge wire field (Mistral: max_tokens).composer update "symfony/ai-*" --with-dependencies and expect all components at 0.15.grep -rn "max_output_tokens" app/ config/ — every hit must become max_tokens.vendor/symfony/ai-mistral-platform/src/Model/ against the previous tag; reconcile your pricing table and allow-list.model values and on token-limit behaviour.Laravel Facade for Cleaner Syntax
// app/Facades/Mistral.php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class Mistral extends Facade
{
protected static function getFacadeAccessor()
{
return 'mistral.client';
}
}
Usage:
use App\Facades\Mistral;
$response = Mistral::chatCompletion(new ChatCompletion('mistral-tiny', 'Hello, world!'));
Laravel Events for Observability
use Symfony\Component\Ai\Event\AiEventDispatcher;
use App\Events\MistralApiCalled;
public function __construct(private AiEventDispatcher $dispatcher)
{}
public function chatCompletion(string $model, string $prompt)
{
$response = $this->client->chatCompletion(new ChatCompletion($model, $prompt));
$this->dispatcher->dispatch(new MistralApiCalled($response));
return $response;
}
Caching Embeddings
use Illuminate\Support\Facades\Cache;
public function getEmbedding(string $text): array
{
return Cache::remember("embedding_" . sha1($text), now()->addHours(1), function () use ($text) {
return $this->client->embed($text)->getEmbedding();
});
}
Rate Limiting and Retries
use Illuminate\Cache\RateLimiter;
public function __construct(private RateLimiter $limiter)
{}
public function safeChatCompletion(string $model, string $prompt)
{
$this->limiter->hit('mistral-chat', now()->addMinutes(1));
return $this->client->chatCompletion(new ChatCompletion($model, $prompt));
}
Testing with Mocks
use Symfony\Ai\Mistral\MistralClient;
public function test_controller_returns_answer()
{
$this->mock(MistralClient::class, function ($mock) {
$mock->shouldReceive('chatCompletion')
->once()
->andReturn(new FakeResult('42', new TokenUsage(
model: 'mistral-small-latest',
promptTokens: 12,
completionTokens: 1,
totalTokens: 13,
)));
});
$this->postJson('/ask', ['question' => 'Meaning of life?'])
->assertOk()
->assertJson(['answer' => '42']);
}
Also assert the options array your service builds — a unit test on the ChatCompletion arguments is the cheapest guard against another option rename slipping through.
max_output_tokens option (#2622). Each bridge now uses its own wire field; for Mistral the correct key is max_tokens. Anything still passing max_output_tokens will not be forwarded — the request succeeds, the limit is silently ignored, and your bills (and latency) change without an error. Grep for the old key before you deploy.0.15 (#2753). Mixing a 0.15 Mistral bridge with 0.14 symfony/ai-platform/ai-store packages can produce a composer conflict, and hand-pinned constraints in unrelated packages can block the upgrade entirely. Bump the whole symfony/ai-* family in one commit.mistral-tiny and friends get deprecated upstream. Never hardcode model strings in application code — resolve them from config('services.mistral.models.*').composer update inside your constraint can quietly change which models resolve and therefore your per-token pricing. Log the model returned in TokenUsage, not the model you requested — aliases like -latest can resolve to different weights.TokenUsage::getModel() is v0.14.0+. If the package is a transitive dependency and you still have 0.13 in the lock file, the model is absent/null and your cost rows are silently wrong. Make the column nullable and backfill.TokenUsage is not a bill. It reports tokens, not currency. Every cost calculation lives in your code and must be updated when Mistral changes prices; the model property is a join key, nothing more.getTokenUsage() is populated on deltas. For accurate cost on streamed chats, either estimate from prompt + accumulated deltas or do a non-streamed echo pass.Symfony\Ai\... vs Symfony\AI\Platform\... vs the older Symfony\Component\Ai\...). Confirm imports against vendor/symfony/ai-mistral-platform/src — the 0.15.0 constraints in this release do not guarantee every class name stayed put.$this->app->singleton(HttpClientInterface::class, fn () => HttpClient::create()). HttpClient::create() is static; the array entry in older Laravel snippets is not valid config/app.php syntax.RateLimiter; a 429 from Mistral surfaces as an AiException and, on a queue worker, will burn the job unless you set $tries and $backoff.MistralClient) rather than at each call site — the model-aware TokenUsage makes this cheap, and it is the only reliable way to catch spend drift after a catalog bump.ProviderInterface abstraction is the stable seam. The v0.15 option rename is a reminder that convenience options are not stable — depend on ProviderInterface in application code so a provider or option change does not ripple through your services.src/Model and grep for renamed option keys after upgrading.How can I help you explore Laravel packages today?