symfony/ai-cohere-platform
Cohere platform bridge for Symfony AI, adding chat, embed, rerank, and audio transcription support through the official Cohere API. Links to Cohere reference docs and the main Symfony AI repo for contributing and issue reports.
Prerequisites. PHP 8.1+ (match whatever symfony/ai requires — check the constraint the bridge declares, it now moves in lockstep with the 0.15 line), Composer, and an explicit decision that you're willing to pull Symfony components into a Laravel app. This bridge is not a Laravel package — it's a Symfony AI platform bridge, so symfony/ai comes along for the ride.
composer require symfony/ai symfony/ai-cohere-platform symfony/http-client
Pin it. It's a 0.x split repo — treat the version like "0.15.*" and read the changelog on every bump. Recent releases have been low-noise: v0.15.0 is a version-constraint bump plus an automated model-catalog refresh, nothing structural. But "nothing structural" is not "nothing" — the catalog refresh changes which model strings resolve.
Where to look first — not the README. The README is a link hub: four Cohere endpoint links and a pointer to the symfony/ai monorepo for issues/PRs. There are no usage samples. Your first stop is the source, which is tiny:
vendor/symfony/ai-cohere-platform/src/ # Platform, factory, model catalog, result mapping
vendor/symfony/ai/src/ # PlatformInterface, MessageBag, Result, exceptions
Look specifically for the platform entry point (a Platform factory taking your API key and an optional Symfony HTTP client), the message classes you'll construct, the model catalog class (it changes on automated PRs — that's where deprecated model IDs disappear), and the exception type thrown on API failures. Also read the symfony/ai monorepo tests — that's where real usage examples live.
Minimal wiring. Register the platform once as a singleton and never instantiate it inline:
// config/cohere.php
return [
'api_key' => env('COHERE_API_KEY'),
'chat_model' => env('COHERE_CHAT_MODEL', 'command-r-plus'),
'embed_model' => env('COHERE_EMBED_MODEL', 'embed-english-v3.0'),
'rerank_model' => env('COHERE_RERANK_MODEL', 'rerank-v3.5'),
];
// app/Providers/CohereServiceProvider.php
public function register(): void
{
$this->app->singleton(PlatformInterface::class, function () {
// Verify the exact factory signature in vendor/ — 0.8 added provider/model routing
return PlatformFactory::create(
apiKey: config('cohere.api_key'),
httpClient: HttpClient::create(['timeout' => 30, 'max_duration' => 60]),
);
});
}
Add a facade (Cohere::invoke(...)) if your team prefers that ergonomics, but keep it accessor-only — no logic in the facade.
First use case. Spend half a day proving plumbing in php artisan tinker: one chat call, one embed call, one forced 401 to see the exception type. Chat is the right first feature because the input/output is obvious and failures are visible. Don't start with audio transcription — it's the one capability with file handling, chunking, and async requirements all at once.
Wrap per capability, not one god-service. Model names and option shapes differ sharply between chat, embed, rerank, and transcribe. A single AiService::ask() wrapper rots immediately. Use small collaborators — ChatClient, Embedder, Reranker, Transcriber — each injecting the shared PlatformInterface. This also makes them trivially mockable.
Chat with persisted conversation state. Keep the message history in your DB (or session), not in the platform object. Map Eloquent rows into the bridge's message bag each turn, and store usage metadata returned in the result so you can attribute cost per user/tenant.
final class ChatClient
{
public function __construct(private PlatformInterface $platform) {}
public function reply(Collection $history, string $prompt): string
{
$messages = MessageBag::fromArray(
$history->map(fn ($m) => $m->role === 'user'
? new UserMessage($m->content)
: new AssistantMessage($m->content))->all()
)->with(new SystemMessage(config('cohere.system_preamble')));
return $this->platform
->invoke(config('cohere.chat_model'), $messages->with(new UserMessage($prompt)))
->asText();
}
}
Structured output, then Laravel validation. Cohere supports structured/JSON responses; ask for JSON, but always run the result through Validator::make() or a DTO hydrator. Models confidently return invalid JSON roughly the rate you'd expect, and a retry-with-error-feedback loop is cheaper than a 500.
Embeddings for semantic search. Three non-negotiables: (1) store model and dimensions alongside every vector — changing models silently breaks cosine similarity, and the catalog refresh in v0.15.0 is exactly the kind of release that changes available model IDs; (2) cache by sha256(text|model|input_type) so re-indexing doesn't re-bill; (3) get input_type right (search_document for indexing, search_query for querying) — mismatch degrades recall and is invisible in tests.
Rerank is your highest-ROI integration. It slots into an existing Laravel/Scout search without reindexing anything:
$candidates = Post::search($query)->take(50)->get(); // cheap first pass
$reranked = $this->reranker->rerank($query, $candidates, topN: 10);
Map results back by array index, not by any returned ID (the API returns indices into your input array), and handle count($reranked) < topN.
Transcription belongs in a queue. Dispatch a job from the controller, store the audio on an S3/local disk, poll or webhook the job status, then broadcast() on completion. Use WithoutOverlapping keyed on the file hash, delete temp files in finally, and set a generous retryUntil — long files fail on time, not on logic.
Multi-provider via named singletons. If you're genuinely hedging against vendor lock-in, bind per-provider platforms as cohere.platform / openai.platform and resolve from config. The 0.8.0 provider-and-routing abstraction helps here, but note the naming collision: Symfony's Provider is not Laravel's ServiceProvider.
Observability is your job. The bridge has no Telescope integration and no token accounting. Decorate PlatformInterface with a logging implementation that writes {provider, model, capability, latency_ms, input_tokens, output_tokens, status} to an ai_calls table. Build this on day one — retrofitting it after three features ship is miserable.
Testing. Never hit the API in CI. Bind a stub PlatformInterface that returns a canned Result, and unit-test your prompt construction and response parsing separately from transport. For the rare end-to-end check, use HttpClient's mock response mechanism against a recorded fixture. Add one cheap guard test that asserts every model string in config/cohere.php still resolves in the bridge's catalog — the automated catalog-update PRs land on someone else's schedule, and that test is what turns a silent runtime 404 into a red build.
symfony/ai). The wild west was 0.7.0 (native bridge introduced) and 0.8.0 (Provider abstraction + model routing + catalog shuffle). Since then the cadence has settled: v0.15.0 is a constraint bump plus an automated Update model catalogs PR. The remaining risk is no longer your code breaking — it's model IDs aging out under you with no deprecation notice in your own diff.symfony/ai to the matching minor. That means a composer update can change the set of valid model strings without a single line of your code changing. Config-driven model names plus the guard test above is the whole mitigation.Provider means two different things. Don't try to register a Symfony AI provider through Illuminate\Support\ServiceProvider semantics and expect the container to sort it out.GuzzleHttp\Exception or Http_Exception. Then split retryable (429, 5xx, timeouts) from non-retryable (400, 401, 403, 422) before handing to retry() — queues cheerfully retry a bad API key four times otherwise.symfony/http-client, not Laravel's Http facade. Default timeouts are long enough to hang a web request. Bind a configured client with explicit timeout/max_duration and pass it to the platform factory. Laravel's HTTP client events, middleware, and Telescope records will not see these calls.env() only inside config/ files. config('cohere.api_key') from a cached config is fine; env('COHERE_API_KEY') at runtime in a service is not.command-r family, embed-*-v3.0), and the bridge mirrors that on an automated schedule. Keep model names in config, assert them in a test against the bridge's catalog, and never let a model string appear in more than one place.max_tokens are per-model. Long prompts get cut with no error. Chunk and assert on input length before dispatch; log a warning when you near the limit. A catalog bump can change the model you're actually hitting if your config default is a floating name rather than a pinned one.response()->eventStream(), and Octane/nginx buffering will bite you. Ship non-streamed chat first; revisit streaming with a StreamedResponse once the non-streaming path is proven.WorkerStopping/RequestTerminated if you see stale connections. Also make sure any per-request message state lives in request-scoped objects, not the singleton.symfony/ai, since this repo won't accept it. Same applies to the model catalog: if a model you need is missing, patching the catalog is a small, low-conflict change.chat and you'll never swap providers, a thin direct HTTP wrapper with Laravel's Http facade gives you Telescope, fakes, retries, and Http::pool() for free. The bridge pays off when you're using three or more capabilities, or genuinely planning multi-provider routing.How can I help you explore Laravel packages today?