symfony/ai-deep-seek-platform
DeepSeek platform bridge for Symfony AI, enabling chat completions, function calling, and multi-round conversations through the DeepSeek API. Integrates DeepSeek models into Symfony AI applications.
1. Understand what you're actually installing. This is not a Laravel package — it's a thin Symfony AI bridge. It contributes DeepSeek to the symfony/ai-platform abstraction (platform factory, model client, model catalog, request/response mapping). Everything you'll do day-to-day happens against Symfony\AI\Platform\*, not against anything DeepSeek-specific. Read the bridge source as documentation; the README here is essentially a link stub.
2. Install with the correct constraint.
composer require symfony/ai-deep-seek-platform symfony/ai-platform symfony/http-client
Pin it — Symfony's BC promise does not apply below 1.0, and this package has already shipped Provider abstraction changes, DeltaInterface replacements, and a TokenUsage shape change inside 0.x:
"symfony/ai-deep-seek-platform": "~0.14.0"
3. Where to look first, in order:
| Source | Why |
|---|---|
vendor/symfony/ai-deep-seek-platform/src/ |
The PlatformFactory, model catalog, and ModelClient — the real spec |
| symfony/ai monorepo | Tests and sibling bridges (OpenAI, Anthropic, Mistral) are near-identical; they're better docs than this repo |
| DeepSeek API docs (linked in README) | Auth, response_format, function-calling constraints, reasoning_content |
symfony/http-client docs |
You will configure timeouts and inject MockHttpClient in tests |
4. Wire it into the container. There is no service provider for you — write one:
// config/services.php (credentials pattern Laravel expects)
'deepseek' => [
'key' => env('DEEPSEEK_API_KEY'),
'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),
],
// app/Providers/AiServiceProvider.php
public function register(): void
{
$this->app->singleton(PlatformInterface::class, function ($app) {
return DeepSeekPlatformFactory::create(
apiKey: config('services.deepseek.key'),
baseUrl: config('services.deepseek.base_url'),
httpClient: $app->make(HttpClientInterface::class), // MockHttpClient in tests
);
});
}
Register Symfony\Component\HttpClient\HttpClient::create([...]) as HttpClientInterface. Do not try to reuse Laravel's Http facade — the bridge talks to Symfony\Contracts\HttpClient\HttpClientInterface.
5. First use case — a single completion. Get one round trip working before touching tools or streaming:
$platform = app(PlatformInterface::class);
$result = $platform->invoke('deepseek-chat', 'Summarise this ticket in one sentence: ...');
echo $result->asText();
That's the whole happy path. Everything below is elaboration on it.
Given 1 star, 0 dependents, and a 0.x version line, the single highest-value pattern is not letting PlatformInterface leak into your domain code:
interface CompletionsContract
{
public function complete(string $prompt, array $options = []): string;
public function stream(string $prompt, callable $onDelta): CompletionResult;
}
Your DeepSeekCompletions implements it today; swapping to OpenAI, Anthropic, or a local model later is a binding change. The Provider abstraction added in v0.8.0 gives you multi-provider routing within Symfony AI, but your app-level seam is what makes the pre-1.0 churn survivable.
Keep the message history in your own DTO/entity — the bridge maps messages, it does not persist them:
$messages = MessageBag::create()
->withSystem('You are a support agent for Acme.')
->withUser($request->input('message'));
// append prior turns from your Conversation model before invoking
$result = $platform->invoke('deepseek-chat', $messages);
Persist turns in a conversations + messages table. You need them anyway for auditing, and you'll need them to trim context (see Gotchas).
The tool loop is yours to write, and it must be bounded — a model that keeps requesting tools will burn tokens and block the worker:
$toolbox = new Toolbox([new LookupOrderTool(), new RefundOrderTool()]);
$messages = MessageBag::create()->withUser($prompt);
for ($turn = 0; $turn < 5; $turn++) {
$result = $platform->invoke('deepseek-chat', $messages, ['tools' => $toolbox]);
if (! $result->hasToolCalls()) {
return $result->asText();
}
$messages = $result->appendToolResults($messages, $toolbox); // execute + feed back
$this->usage->record($result->getTokenUsage()); // count EVERY round trip
}
throw new ToolLoopExceededException();
Two things that bite here: usage must be recorded per round trip, not per user request, and tool schemas are prompt tokens — every token in every tool description is billed on every turn.
DeltaInterface (v0.7.0) gives you semantic chunks instead of raw JSON, and v0.11.0 exposes streamed finish_reason as metadata. Under PHP-FPM this is where naive implementations fail — a 40-second generation sits on a web worker and gets killed by max_execution_time.
Two workable shapes:
SSE through Octane/RoadRunner (lowest latency, requires Octane):
return response()->stream(function () use ($platform, $prompt) {
$platform->invoke('deepseek-chat', $prompt, ['stream' => true])
->each(function (DeltaInterface $delta) {
echo "data: " . json_encode(['t' => $delta->getContent()]) . "\n\n";
ob_flush(); flush();
});
echo "data: [DONE]\n\n";
}, 200, ['Content-Type' => 'text/event-stream', 'X-Accel-Buffering' => 'no']);
Queue + broadcast (default recommendation): job consumes the stream, accumulates text and TokenUsage, and broadcasts deltas over a private channel via Laravel Echo. Survives client disconnects, retries, and gives you a natural place to commit usage exactly once.
// inside the job, after the stream ends — including on failure
TokenUsageRecorded::dispatch($usage->accumulate($model));
v0.14.0 added model to TokenUsage, so a single listener can attribute spend without correlating back to the request payload:
class RecordTokenUsage
{
public function handle(TokenUsageRecorded $event): void
{
UsageEvent::create([
'model' => $event->usage->getModel(),
'prompt_tokens' => $event->usage->getPromptTokens(),
'completion_tokens' => $event->usage->getCompletionTokens(),
'cost_usd' => $this->rateCard->cost($event->usage),
'tenant_id' => $event->tenantId,
]);
}
}
The rate card is yours — the package ships no prices. Put it in config/ai-pricing.php keyed by model ID with an effective_from column, and version it. Prices change; stored costs must be reproducible.
The bridge throws distinct exception types — map them rather than catching \Throwable:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ModelNotFoundException $e) {
report($e); // almost always a config/catalog drift bug, not a user error
return response()->json(['error' => 'AI model unavailable'], 503);
});
$exceptions->render(function (ExceedContextSizeException $e) {
return response()->json(['error' => 'Conversation too long'], 422);
});
$exceptions->render(function (ServerException $e) {
return response()->json(['error' => 'Upstream busy, retry'], 503)
->header('Retry-After', 5);
});
})
->withMiddleware(/* ... */);
ServerException is explicitly retryable (v0.11.0) — safe to put behind a queued job's $backoff/$tries.
try {
return $deepSeek->complete($prompt);
} catch (ServerException|ExceedContextSizeException $e) {
report($e);
return $openAi->complete($prompt); // same contract, different binding
}
This only works if you've done the interface wrap above. Don't build fallback on PlatformInterface directly — you'll drag Symfony AI types into your callers.
Never hit the live API in tests. Inject Symfony's MockHttpClient with recorded DeepSeek fixtures and swap the container binding:
$this->app->instance(HttpClientInterface::class, new MockHttpClient([
new MockResponse(json_encode($fixture), ['response_headers' => ['content-type' => 'application/json']]),
]));
Test three things specifically, because they're where regressions hide: (1) usage accumulation across a multi-round tool conversation, (2) usage committed when a stream aborts mid-flight, (3) your exception mapping for 404/context-limit/5xx.
Model IDs are configuration, not constants. The catalog is updated by CI (#2141, #2526) and can rename or drop entries on a routine composer update. A wrong ID surfaces as ModelNotFoundException — which looks like an auth or network problem and isn't. Assert your configured IDs against the catalog at boot:
// AppServiceProvider::boot(), non-production: throw. production: log critical.
foreach (config('ai.models') as $id) {
throw_unless($catalog->has($id), new RuntimeException("Model {$id} missing from catalog"));
}
deepseek-reasoner is not a drop-in for deepseek-chat. Thinking support landed in v0.4.0, and the model emits reasoning_content alongside the answer — it costs tokens you didn't budget for, and DeepSeek's documented constraints differ between the two models (function calling and JSON output in particular). Verify current support against DeepSeek's API docs before assuming a tool-calling workflow will work on the reasoning model.
Token usage on function calling is the undercount trap. Each tool round trip is a separate billed completion with its own TokenUsage. If you record usage only on the final answer, you'll undercount the expensive conversations by 2–5×. Same for streams that end in a client disconnect — accumulate deltas and commit in a finally block.
Streaming under PHP-FPM will bite you. ob_flush() + flush() behind nginx requires X-Accel-Buffering: no and often fastcgi_read_timeout tuning. Long generations also collide with max_execution_time. Default to queue + broadcast unless you're on Octane and have tested the proxy chain.
You are using Symfony HttpClient, not Guzzle. Don't try to shim Laravel's Http facade into HttpClientInterface — configure timeouts on the Symfony client instead (timeout, max_duration, retry_failed). Streaming responses need max_duration, not timeout, or long generations get killed at 30 seconds.
Context size is your problem. ExceedContextSizeException (v0.10.0) is thrown after you've paid for the request attempt. Implement trimming before invoke: drop oldest turns, keep the system prompt, and summarise rather than truncating mid-message.
No pricing table, no cost observability out of the box. TokenUsage::model enables per-request attribution but performs no calculation. Budget a small, permanent maintenance surface: a versioned rate card, currency handling, and periodic reconciliation against DeepSeek's billing export. Expect to find a gap; expect it to be in function-calling round trips.
No retry on ExceedContextSizeException, always retry on ServerException. They're semantically distinct and the bridge is deliberate about it. Retrying a context-size error just burns money.
UTF-8 sanitisation is handled in the client (v0.10.0), but your inputs aren't. Malformed bytes from a PDF extract or a legacy database column will still corrupt prompts in ways that show up as garbled model output, not exceptions. Sanitise at ingestion.
Don't cache config closures that capture the API key. If you resolve the key inside a config:cache-serialised closure, you'll ship a stale credential. Resolve env() → config() → constructor at boot, never at invocation.
Treat every minor bump as a reviewable change. 0.6.0 changed exception behaviour, 0.7.0 replaced streamed chunk types, 0.8.0 introduced the Provider abstraction, 0.11.0 renamed exception semantics. Read the changelog diff and run your mock-fixture tests before merging a Composer update. There is no deprecation layer in 0.x.
Data residency. DeepSeek processes prompts outside your jurisdiction. Persisting UsageEvent rows is safe (tokens and model IDs, no content), but if you store prompt/response pairs for auditing, confirm that's inside your retention and privacy policy before you ship — retrofitting that decision is expensive.
Plan for the integration glue to be yours permanently. With 0 dependents and pre-1.0 versioning, there is no community Laravel wrapper to lean on and no guarantee one appears. The interface-wrapping pattern above is the mitigation: keep the blast radius of a bridge change to a single class.
How can I help you explore Laravel packages today?