google-gemini-php/symfony
Symfony integration for Google Gemini PHP, making it easy to configure the Gemini API and use text, chat, and vision features via Symfony services. Lightweight, framework-friendly setup for AI-powered apps.
The bundle is a thin Symfony integration over google-gemini-php/client. Almost everything you'll actually call lives in the client package, so budget your reading time accordingly. As of 2.1.0 the bundle supports Symfony 8, and the refreshed README now documents current models, client 2.8.0 features, autowiring and ClientFake testing — that README is the best starting point it has ever been.
Install and wire it up
composer require google-gemini-php/symfony
Update the client too — the new bundle features assume client 2.8.0, which requires PHP 8.2:
composer update google-gemini-php/client
Symfony Flex registers the bundle automatically. If you're not using Flex, add it to config/bundles.php:
return [
// ...
Gemini\Symfony\GeminiBundle::class => ['all' => true],
];
Grab an API key from Google AI Studio, then put it in .env (never in committed YAML):
GEMINI_API_KEY=your-key-here
Minimal config — config/packages/gemini.yaml:
gemini:
api_key: '%env(GEMINI_API_KEY)%'
# base_url is now a recognised option in 2.1.0+ — useful for proxies,
# regional endpoints, or pointing tests at a local stub server.
# base_url: 'https://generativelanguage.googleapis.com/'
On 2.0.0 the extension applied base_url but never declared it in the Configuration tree, so setting it failed with "Unrecognized option". Upgrade to 2.1.0 if you need it.
First use case: inject the client, generate text. The bundle's whole job is making this autowirable instead of Gemini::client($key) scattered through your code. Since 2.1.0, Gemini\Contracts\ClientContract is autowired by its interface, so you no longer need the "or the concrete class if there's no alias" hedge:
namespace App\Controller;
use Gemini\Contracts\ClientContract;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class SummaryController
{
public function __construct(private ClientContract $gemini) {}
#[Route('/summarize', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$result = $this->gemini
->generativeModel('gemini-2.0-flash')
->generateContent($request->getPayload()->get('text'));
return new JsonResponse(['summary' => $result->text()]);
}
}
Where to look first
bin/console debug:container Gemini\Contracts\ClientContract --show-private — confirms the interface alias is registered in your version (it is in 2.1.0; older releases may only expose the concrete Client).vendor/google-gemini-php/symfony/src/Resources/config/services.php — the entire bundle surface is usually a handful of service definitions. Ten minutes here tells you what's configurable.bin/console config:dump-reference gemini — authoritative list of config keys for your version, now including base_url.vendor/google-gemini-php/client/src/ — Client, Resources/GenerativeModel, Resources/ChatSession, DataTransferObjects/, Testing/. This is the real API reference, and the client README is freshly updated for 2.8.0.Inject the contract, not the concrete class. Type-hint Gemini\Contracts\ClientContract in constructors. In 2.1.0 this is guaranteed to be the autowired alias, which makes decoration, testing, and swapping implementations trivial, and it's the main reason to use the bundle over the standalone client.
Build a small domain service around it. Don't let ->generativeModel()->generateContent() calls leak into twenty controllers. One GeminiService with methods like summarizeDocument(), classifyTicket(), embedText() keeps model names, prompts, and generation config in one place:
final class TicketClassifier
{
public function __construct(private ClientContract $gemini) {}
public function classify(string $body): array
{
$result = $this->gemini->generativeModel('gemini-2.0-flash')
->withGenerationConfig(
new GenerationConfig(
responseMimeType: 'application/json',
responseSchema: new Schema(
type: SchemaType::OBJECT,
properties: [
'category' => new Schema(type: SchemaType::STRING),
'confidence' => new Schema(type: SchemaType::NUMBER),
],
required: ['category', 'confidence'],
),
),
)
->generateContent($body);
return json_decode($result->text(), true, flags: JSON_THROW_ON_ERROR);
}
}
Structured output with a response schema is far more reliable than "please reply in JSON" in the prompt — use it whenever the output is machine-consumed.
Multi-turn chat requires you to persist history. The client is stateless. For a support chatbot, store $chat->history() in the session (or a Doctrine entity backed by Redis if you need it across devices) and rebuild with startChat(history: $history):
$history = $request->getSession()->get('gemini_history', []);
$chat = $this->gemini->generativeModel('gemini-2.0-flash')->startChat(history: $history);
$response = $chat->sendMessage($userMessage);
$request->getSession()->set('gemini_history', $chat->history());
Cast history DTOs to arrays before serializing into the session; the DTOs aren't guaranteed to survive serialize()/unserialize() cleanly across client upgrades.
Embeddings + Doctrine. Store $result->embedding (a float array) as JSON in a TEXT column for small catalogues and compute cosine similarity in PHP — fine up to a few thousand rows. Past that, use pgvector or a vector extension and query with <=>:
$vector = $this->gemini->embeddingModel('text-embedding-004')
->embedContent($query)->embedding;
// Store as pgvector literal '[0.1,0.2,...]' and ORDER BY embedding <=> :v LIMIT 10
Move generation to Symfony Messenger. Gemini calls routinely take 2–15 seconds; never block a web request on a long generation. Dispatch a message, handle it in a worker, and push results to the UI via Mercure or a polling endpoint. This also gives you free retry and rate-limit backoff via Messenger's retry strategy.
Streaming with StreamedResponse. For chat UIs, use the client's streaming call inside a StreamedResponse and flush() after each chunk. Remember to send X-Accel-Buffering: no — Nginx will otherwise buffer the whole response and defeat the purpose.
Testing with ClientFake (new in 2.1.0). Client 2.8.0 ships Gemini\Testing\ClientFake, which implements ClientContract and replays canned responses, so you no longer need to wire a PSR-18 mock for the common case:
use Gemini\Responses\GenerativeModel\GenerateContentResponse;
use Gemini\Testing\ClientFake;
$gemini = new ClientFake([
GenerateContentResponse::fake(),
]);
Bind it in config/services_test.yaml by aliasing Gemini\Contracts\ClientContract to the fake, or construct it directly in a unit test. For lower-level assertions (headers, HTTP retries, exact request bodies) fall back to injecting a MockClient/MockHttpClient via php-http/discovery. Assert on prompt construction and parsing, not on the model's actual output. Never point CI at the real API with a live key. Check the refreshed README for the exact fake factories shipped with your client version — they track the client, not the bundle.
The bundle is very thin — the client is the documentation. With low adoption and no dependents, assume no community answers exist. The 2.1.0 README refresh closes a lot of the gap (current models, config, autowiring, ClientFake), but for anything it doesn't cover, read vendor/google-gemini-php/client/src/ directly; the source is small and readable, and every behaviour (retry, safety handling, streaming parsing) is visible there.
Class and service IDs drift between releases. This package tracks the client's release cadence closely and both have churned namespaces/renames. Pin a version in composer.json, and after every composer update run bin/console debug:container Gemini before assuming anything from a blog post still holds. A ContainerBuilder compile error after upgrading is usually a renamed alias, not a broken bundle. The ClientContract autowiring introduced in 2.1.0 is a good example: correct now, but not something to back-port assumptions from.
base_url was broken in 2.0.0. It was applied by the extension but absent from the Configuration tree, so gemini.yaml rejected it with "Unrecognized option". If you're pinning 2.0.0 and need a proxy or regional endpoint, you'll have to decorate the PSR-18 client instead. Upgrade to 2.1.0+ and set it in config.
text() can be empty or throw. Safety filters, MAX_TOKENS finish reasons, and RECITATION blocks all produce a response with no text. Catch Gemini\Exceptions\* exceptions and check the finish reason — a blank summary in production is usually a truncated or blocked generation, not a bug in your prompt.
Chat history must alternate roles and start with user. Passing badly ordered history (two user turns in a row, or a model turn first) yields a 400. If you're assembling history yourself from a DB log, sanity-check alternation before sending.
Timeouts are the #1 production surprise. The default PSR-18 timeout is often too low for gemini-2.5-pro on long contexts. Configure an explicit client with a 60s+ timeout and either decorate or rely on Messenger retries for transient failures.
429s are normal on the free tier. Add retry with exponential backoff — Symfony's RetryableHttpClient if you control the HTTP client, or Messenger's RetryStrategy if you're already dispatching jobs. Don't retry 400s or 403s; they're prompt/auth problems.
Don't put the API key in gemini.yaml. Use %env(GEMINI_API_KEY)% or Symfony Secrets (bin/console secrets:set GEMINI_API_KEY). If you use %env()%, be aware that in production config:cache-style warmup and container compilation resolve env at boot — changing the env var requires a container rebuild/cache clear.
Re-embedding is mandatory when you change embedding models. text-embedding-004 and older models produce different dimensions; mixing them silently breaks similarity search. Version your embedding column with the model name.
Extension points are almost all one level up. To swap the HTTP client, add request logging, or inject headers, decorate the PSR-18 client via php-http/discovery configuration rather than editing the bundle. To add application-level concerns (cost logging, token accounting, prompt versioning), decorate ClientContract in services.yaml — that's the seam the bundle actually gives you, and it's now the same seam ClientFake uses.
Free-tier data is used for training. For anything with user data, check whether you're on a paid tier before shipping; this is a config/account decision, not a code one, but it's the kind of thing that surfaces in a security review after launch.
How can I help you explore Laravel packages today?