laravel-gemini maintained by anmolmishra23-dev
Laravel Gemini
An unofficial Laravel SDK for Google Gemini, built for image and video generation first, with text generation included.
use AnmolMishra\Gemini\Facades\Gemini;
Gemini::image('A cozy cabin in snowy mountains at sunset')
->aspectRatio('16:9')
->generate()
->store('images'); // images/Xy3...png
Refine an image over several turns, and Gemini keeps the same scene:
$chat = Gemini::imageChat()->aspectRatio('1:1');
$chat->send('A small red barn in a green field under a blue sky, photorealistic');
$chat->send('Cover everything in deep snow and make it a winter evening. Keep the same barn and composition.');
| Turn 1 | Turn 2 |
|---|---|
![]() |
![]() |
- Images: generate, edit with reference images, and refine over several turns
- Batches: generate a whole product catalog concurrently, or on the queue
- Queues: generate in the background, with events when the image or video is ready
- Video: text-to-video and image-to-video with Gemini Omni
- Text: system instructions, vision and structured JSON output
- Cost tracking: an estimated USD cost for every response
- Storage: save to S3, Google Cloud Storage, Cloudflare R2, Azure or any Laravel disk, and get public or expiring URLs
- Return generated files straight from routes
- Automatic retries on 429 / 5xx, with clear exceptions
Gemini::fake()and assertions for testing your app
This package is not affiliated with or endorsed by Google.
Contents
- Installation
- Image generation
- Image editing
- Saving to S3, Google Cloud and other buckets
- Multi-turn editing
- Batch generation
- Queues and events
- Video generation
- Text generation
- Cost tracking
- Error handling
- Artisan commands
- Testing your app
- Configuration
Requirements
- PHP 8.2+
- Laravel 12 or 13
Installation
composer require anmolmishra23-dev/laravel-gemini
Add your API key (get one at Google AI Studio) to .env:
GEMINI_API_KEY=your-api-key
Optionally publish the config file:
php artisan vendor:publish --tag=gemini-config
Image generation
use AnmolMishra\Gemini\Facades\Gemini;
$response = Gemini::image('A cozy cabin in snowy mountains at sunset')
->model('gemini-3-pro-image') // optional, defaults to config('gemini.models.image')
->aspectRatio('16:9') // 1:1, 16:9, 9:16, 4:3, 3:4, ...
->size('2K') // 1K, 2K, 4K (supported models only)
->generate();
Working with the result
$image = $response->image(); // first image
$image->store('images'); // random name on the default disk, returns the path
$image->store('images', 's3'); // on a specific disk (S3, GCS, R2, ...)
$image->save('images/cabin.png'); // exact path
$image->url(); // URL of the saved image
$image->bytes(); // raw binary
$image->base64(); // base64 string
$image->dataUri(); // data:image/png;base64,... for <img src>
$image->mimeType; // image/png
$response->store('images'); // shortcut for ->image()->store()
$response->images(); // all returned images
$response->usage->totalTokens; // token usage
$response->cost(); // estimated cost in USD
Return an image straight from a route:
Route::get('/cabin', fn () => Gemini::image('A cozy cabin')->generate()->image());
Getting text with the image
$response = Gemini::image('Design a logo for a coffee shop and explain it')
->withText()
->generate();
$response->text; // the model's explanation
Image editing and reference images
Attach one or more images. They are sent before the prompt, as Gemini recommends.
Gemini::image('Change the sky to a starry night')
->withImage(storage_path('app/photo.jpg')) // local path
->generate()
->store('edits');
Gemini::image('Put this product on a marble kitchen counter')
->withImage($request->file('product')) // uploaded file
->withImageFromDisk('brand/logo.png', 's3') // storage disk
->withImageUrl('https://example.com/style.jpg') // remote URL
->withImageData($bytes, 'image/png') // raw bytes
->generate();
Disk and URL images are read when the request is sent, so queued requests stay small.
Saving to S3, Google Cloud and other buckets
Generated images and videos are saved with Laravel's own storage system, so any disk your app has in config/filesystems.php works. Pass the disk name as the second argument:
$response = Gemini::image('A cozy cabin')->generate();
$response->store('images', 's3'); // Amazon S3
$response->store('images', 'gcs'); // Google Cloud Storage
$response->store('images', 'r2'); // Cloudflare R2
$response->store('images', 'local'); // your server's disk
Or set a default for everything in .env:
GEMINI_DISK=s3
Setting up a bucket
You only need to do this once per app. Pick your provider:
| Provider | Install | Disk driver |
|---|---|---|
| Amazon S3 | composer require league/flysystem-aws-s3-v3 "^3.0" |
s3 |
| Cloudflare R2, DigitalOcean Spaces, Wasabi, MinIO, Backblaze B2 | same as S3 | s3 with that provider's endpoint |
| Google Cloud Storage | composer require spatie/laravel-google-cloud-storage |
gcs |
| Azure Blob Storage | composer require azure-oss/storage-blob-laravel |
azure-storage-blob |
| FTP / SFTP | composer require league/flysystem-ftp "^3.0" or league/flysystem-sftp-v3 "^3.0" |
ftp / sftp |
| Your server | nothing | local |
For example, Amazon S3 is already in Laravel's default config/filesystems.php. Just fill in .env:
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=my-bucket
Cloudflare R2 uses the S3 driver with its own endpoint. Add a disk to config/filesystems.php:
'r2' => [
'driver' => 's3',
'key' => env('R2_ACCESS_KEY_ID'),
'secret' => env('R2_SECRET_ACCESS_KEY'),
'region' => 'auto',
'bucket' => env('R2_BUCKET'),
'endpoint' => env('R2_ENDPOINT'), // https://<account-id>.r2.cloudflarestorage.com
'url' => env('R2_PUBLIC_URL'), // your public bucket URL, for url()
],
For Google Cloud Storage and Azure, follow the setup in each package's README, then use the disk name they give you.
Getting the URL
After saving, ask the image (or video) for its URL:
$response = Gemini::image('A cozy cabin')->generate();
$response->store('images', 's3');
$response->url(); // https://my-bucket.s3.ap-south-1.amazonaws.com/images/Xy3...png
$response->temporaryUrl(now()->addHour()); // a link that expires, for private buckets
$response->image()->path(); // images/Xy3...png
$response->image()->disk(); // s3
url() needs the disk to have a public URL (the url setting in config/filesystems.php). For private buckets, use temporaryUrl().
Public or private files
Some buckets keep files private unless you say otherwise. Save a file publicly, so anyone with the URL can see it:
$response->storePublicly('images', 's3');
// Same thing:
$response->store('images', 's3', 'public');
Or keep it private:
$response->store('images', 's3', 'private');
Set a default for everything with GEMINI_VISIBILITY=public (or private) in .env. When it isn't set, each disk uses its own default.
Cloudflare R2 and buckets with "uniform access" (common on Google Cloud Storage) ignore per-file visibility. On those, make the whole bucket public or private in the provider's dashboard.
Batches and queues
Batches and queued jobs work the same way:
// Batch: save every image publicly and get all the URLs
$results = Gemini::batch(['mug' => 'A white mug', 'shirt' => 'A black t-shirt'])->generate();
$results->storePublicly('catalog', 's3');
$results->urls(); // ['mug' => 'https://...', 'shirt' => 'https://...']
// Queue: the event tells you where the file went
Gemini::image('A white mug')->queue('products', 's3', meta: ['product_id' => 5], visibility: 'public');
Event::listen(function (ImageGenerated $event) {
Product::find($event->meta['product_id'])->update(['image_url' => $event->url()]);
});
Multi-turn editing
Refine the same image step by step, like a conversation. Each message edits the result of the previous one.
$chat = Gemini::imageChat()->aspectRatio('1:1');
$chat->send('A cozy cabin in the mountains')->store('cabin');
$chat->send('Add snow on the roof')->store('cabin');
$chat->send('Make it night with warm light in the windows')->store('cabin');
Attach images to a message with the usual methods. They go with the next message only:
$chat->withImage($request->file('logo'))->send('Paint this logo on the cabin door');
Continue a conversation across HTTP requests by storing its history:
session(['cabin-chat' => $chat->history()]);
// Later...
Gemini::imageChat()
->withHistory(session('cabin-chat'))
->send('Add a chimney with smoke');
The history contains the generated images, so store it somewhere that can hold a few MB per conversation, such as the cache, a file or a database column.
send() returns the response even when the model replies with text only (for example, to ask a question). Check $response->images(), or use generate() to throw when no image comes back.
Batch generation
Generate many images at once. Requests run concurrently, and one failure doesn't stop the rest:
$results = Gemini::batch([
'mug' => 'A white coffee mug on a wooden table, studio lighting',
'shirt' => 'A black t-shirt on a hanger, studio lighting',
'hat' => Gemini::image('A red baseball cap')->aspectRatio('1:1'),
])->concurrency(5)->generate();
$paths = $results->store('catalog', 's3'); // ['mug' => 'catalog/...png', ...]
foreach ($results->failed() as $key => $exception) {
logger()->warning("Could not generate {$key}: {$exception->getMessage()}");
}
$results->get('mug'); // ImageResponse or GeminiException
$results->successful(); // successful responses, keyed like the requests
$results->totalCost(); // estimated USD for the whole batch
Plain strings are image prompts. You can also mix in Gemini::text() requests, for example to write product descriptions in the same batch. Keep concurrency() within your Gemini rate limits.
For hundreds or thousands of images, use queued jobs with Laravel's job batching instead:
use Illuminate\Support\Facades\Bus;
use AnmolMishra\Gemini\Jobs\GenerateMedia;
Bus::batch(
$products->map(fn ($product) => new GenerateMedia(
Gemini::image("Studio photo of {$product->name}"),
directory: 'catalog',
meta: ['product_id' => $product->id],
))
)->name('Catalog images')->dispatch();
Queues and events
Image generation takes several seconds and video takes minutes, so you'll usually want them off the request cycle. queue() generates in the background, stores the result and fires an event:
Gemini::image('Studio photo of a white coffee mug')
->aspectRatio('1:1')
->queue('products', 's3', meta: ['product_id' => $product->id]);
Listen for the result:
use AnmolMishra\Gemini\Events\GenerationFailed;
use AnmolMishra\Gemini\Events\ImageGenerated;
Event::listen(function (ImageGenerated $event) {
Product::find($event->meta['product_id'])->update(['image' => $event->path]);
// Also: $event->url(), $event->temporaryUrl(...), $event->disk, $event->model, $event->text, $event->cost
});
Event::listen(function (GenerationFailed $event) {
// $event->type ('image' or 'video'), $event->exception, $event->meta
});
Videos work the same way and fire VideoGenerated.
queue() returns Laravel's pending dispatch, so you can chain ->onQueue('ai') or ->delay(...). Set a default connection and queue with GEMINI_QUEUE_CONNECTION and GEMINI_QUEUE. Jobs time out after 300 seconds for images and 900 for videos, so make sure your queue connection's retry_after is longer than that.
The API key is never written into the queued job. The worker reads it from its own config.
Video generation
Videos are generated with Gemini Omni (gemini-omni-1.1-flash), and audio is included.
$response = Gemini::video('Slow dolly shot of a lighthouse at dusk, waves crashing, no dialogue')
->aspectRatio('16:9') // 16:9 (default) or 9:16
->resolution('1080p') // 360p, 720p (default), 1080p or 4k
->generate();
$response->store('videos'); // videos/Xy3...mp4
Generation takes a few minutes, so in web apps use ->queue('videos') instead of generate().
There's no separate negative-prompt or audio option. Describe what you want, and what to avoid, in the prompt.
Animate an image:
Gemini::video('The waves start moving and the clouds drift by')
->withImageFromDisk('photos/beach.jpg', 's3')
->generate()
->store('videos');
Edit or extend a video you generated earlier, using its interaction id:
$first = Gemini::video('A paper boat floating down a stream')->generate();
Gemini::video('Continue: the boat drifts into a small waterfall')
->after($first->id)
->generate();
The video is returned as a file and downloaded when you first access it (store(), save(), bytes()). For very short videos, ->inline() returns the bytes in the response instead.
Text generation
$response = Gemini::text('Write a tagline for a coffee shop')
->system('You are a witty copywriter.')
->temperature(0.8)
->maxTokens(200)
->generate();
echo $response; // or $response->text()
Vision
Gemini::text('Describe this image in one sentence')
->withImage(storage_path('app/photo.jpg'))
->generate()
->text();
Structured JSON
$response = Gemini::text('Suggest 3 names for a coffee shop')
->asJson([
'type' => 'array',
'items' => ['type' => 'string'],
])
->generate();
$response->json(); // ['Bean There', 'Daily Grind', 'Brew Haven']
Cost tracking
Every image and text response estimates its cost in USD from the token usage:
$response = Gemini::image('A cozy cabin')->generate();
$response->cost(); // 0.06884
$response->usage->promptTokens;
$response->usage->outputTokens;
$response->usage->imageTokens;
$response->usage->thoughtTokens;
Prices live in the pricing section of config/gemini.php, in USD per million tokens. They come from Google's pricing page as of October 2026. Google changes prices, so publish the config and keep them current. cost() returns null for models without a price. Add any model like this:
'pricing' => [
'my-model' => ['input' => 0.50, 'output' => 3.00, 'image_output' => 60.00],
],
Costs are estimates for the standard paid tier. Video cost isn't tracked yet. As a guide, Gemini Omni costs about $0.10 per second at 720p and a third of that at 360p.
Typical costs from a real test run (October 2026):
| Request | Model | Cost |
|---|---|---|
| 1 image (1K) | gemini-3.1-flash-image |
~$0.068 |
| 1 image edited from a reference | gemini-nano-banana-2.1 |
~$0.044 |
| 1 image (1K) | gemini-3.1-flash-lite-image |
~$0.034 |
| 10-second video, 360p | gemini-omni-1.1-flash |
~$0.34 |
Advanced
Pass any generationConfig option that has no dedicated method:
Gemini::image('A cabin')->withConfig(['seed' => 42])->generate();
Build conditionally:
Gemini::image($prompt)
->when($ratio, fn ($request) => $request->aspectRatio($ratio))
->generate();
Send a raw payload with the low-level client:
$data = Gemini::client()->generateContent('gemini-3.5-flash', [
'contents' => [['parts' => [['text' => 'Hello']]]],
]);
Inspect the payload without sending it:
Gemini::image('A cabin')->aspectRatio('1:1')->toPayload();
Error handling
Every failure throws AnmolMishra\Gemini\Exceptions\GeminiException:
use AnmolMishra\Gemini\Exceptions\GeminiException;
try {
$image = Gemini::image($prompt)->generate()->image();
} catch (GeminiException $e) {
$e->getMessage(); // readable error from the API
$e->status; // HTTP status, if any
$e->isRateLimited(); // true on 429
$e->response; // the raw Laravel HTTP response, if any
}
It is thrown when:
- the API returns an error (after retries on 429 / 5xx)
- the prompt is blocked by safety filters
- an image or video request returns no image or video (for example
finishReason: SAFETY) - a generated video file fails to process
- the API key is missing
In batches, failures are collected in the results instead of being thrown.
Artisan commands
php artisan gemini:image "A cozy cabin at sunset" --aspect-ratio=16:9 --size=2K --disk=public --dir=gemini
php artisan gemini:video "A lighthouse at dusk" --resolution=720p --image=storage/app/photo.jpg
Testing your app
Gemini::fake() intercepts every Gemini API call. No real requests are sent and no credits are used.
use AnmolMishra\Gemini\Facades\Gemini;
use AnmolMishra\Gemini\Testing\FakeResponse;
it('creates a product image', function () {
Storage::fake('public');
Gemini::fake([
FakeResponse::image(), // 1st call returns a 1x1 PNG
FakeResponse::text('A nice caption'), // 2nd call returns text
FakeResponse::video(), // 3rd call returns a fake video
FakeResponse::error(429, 'Quota hit'), // 4th call fails
]);
// ... call your code ...
Gemini::assertSent(fn (array $payload, string $model) =>
$model === 'gemini-3.1-flash-image'
&& $payload['generationConfig']['imageConfig']['aspectRatio'] === '1:1'
);
Gemini::assertSentCount(1);
});
With no responses, Gemini::fake() answers image and text requests with fake text and an image, and video requests with a fake video. Faked requests are not retried. Also available: assertNotSent() and assertNothingSent().
To test queued generation, use Bus::fake() and assert GenerateMedia was dispatched, or run the sync queue with Event::fake() and assert ImageGenerated was fired.
Configuration
| Key | Env | Default |
|---|---|---|
api_key |
GEMINI_API_KEY |
none |
base_url |
GEMINI_BASE_URL |
https://generativelanguage.googleapis.com/v1beta |
models.image |
GEMINI_IMAGE_MODEL |
gemini-3.1-flash-image |
models.text |
GEMINI_TEXT_MODEL |
gemini-3.5-flash |
models.video |
GEMINI_VIDEO_MODEL |
gemini-omni-1.1-flash |
timeout |
GEMINI_TIMEOUT |
120 seconds |
retry.times |
GEMINI_RETRY_TIMES |
3 attempts |
retry.sleep |
GEMINI_RETRY_SLEEP |
1000 ms |
video.timeout |
GEMINI_VIDEO_TIMEOUT |
600 seconds |
disk |
GEMINI_DISK |
default disk |
visibility |
GEMINI_VISIBILITY |
each disk's default |
queue.connection |
GEMINI_QUEUE_CONNECTION |
default connection |
queue.name |
GEMINI_QUEUE |
default queue |
pricing |
none | see Cost tracking |
Contributing
See CONTRIBUTING.md. Bug reports and pull requests are welcome.
Security
Please report security issues privately. See SECURITY.md.
License
MIT. See LICENSE.

