Generate and Store Images with the Laravel AI SDK

Laravel AI SDK image generation, end to end. Use Image::of() to create and transform pictures, control the aspect ratio, then store them from a queued job.

Steven Richardson
Steven Richardson
· 10 min read

A client wanted AI-generated hero images for user-submitted listings. My first pass called the provider's HTTP API straight from a controller: the request blocked for 24 seconds, and a 1.4 MB base64 string landed in a TEXT column. All three problems — the raw client, the blocking, the base64 — are solved by APIs that already ship with the Laravel AI SDK. Most people never find them because the docs open with agents and tool calling.

Laravel AI SDK image generation is the output side of the SDK. If you want a model to read a picture instead, that's analyzing images with vision and multimodal prompts — a different entry point entirely.

Configure a provider for Laravel AI SDK image generation#

Image generation runs through a separate provider list from text, so the first job is making sure the provider you configured for chat can actually produce pixels. Install the package, publish the config, and set a key for a provider that supports images.

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Six providers currently generate images: OpenAI, Gemini, xAI, Azure, Bedrock and OpenRouter. Anthropic is not one of them — if Claude is your text model, image generation needs a second key.

GEMINI_API_KEY=
OPENAI_API_KEY=

The important line in config/ai.php is the one people miss. Images have their own default, independent of ai.default:

'default' => 'openai',
'default_for_images' => 'gemini', // Not the same key as 'default'
'default_for_audio' => 'openai',

So an app configured entirely around OpenAI will still send image requests to Gemini until you change that value. Set it deliberately. The rest of the provider plumbing — keys, base URLs, model overrides — works exactly as described in the complete guide to the Laravel AI SDK.

Generate your first image with Image::of()#

Image generation does not go through an agent. It has a dedicated Laravel\Ai\Image class with a static of() method that takes the prompt and returns a pending generation you can configure before firing it.

use Laravel\Ai\Image;

$image = Image::of('A donut sitting on a kitchen counter, morning light')->generate();

$bytes = (string) $image;              // Raw decoded image bytes
$base64 = $image->firstImage()->image; // The Base64 payload as returned
$mime = $image->firstImage()->mime();  // 'image/png' when the provider omits it

generate() returns an ImageResponse. It is Stringable, so casting gives you decoded bytes rather than the base64 blob — a small detail that saves an accidental base64_decode() further down the call stack.

You can also pass the provider and model explicitly, which overrides default_for_images for that call:

$image = Image::of('A donut sitting on a kitchen counter')
    ->generate(provider: 'openai', model: 'gpt-image-2');

Response metadata tells you which provider actually answered — useful when you have a fallback chain in play:

$image->meta->provider; // 'openai'
$image->meta->model;    // 'gpt-image-2'
$image->usage->promptTokens;

Control the aspect ratio and quality#

Rather than making you memorise each provider's size strings, the SDK exposes three orientation helpers that set a normalised aspect ratio, which the provider gateway then translates.

use Laravel\Ai\Image;

$image = Image::of('A donut sitting on a kitchen counter')
    ->landscape()      // 3:2 — portrait() is 2:3, square() is 1:1
    ->quality('high')  // 'low' | 'medium' | 'high'
    ->timeout(120)     // HTTP timeout in seconds
    ->generate();

The translation is where it gets interesting, and it is worth knowing because quality does not mean the same thing everywhere. On OpenAI, the aspect ratio becomes a pixel dimension and quality maps straight through to the API's own quality parameter:

Helper OpenAI size Gemini aspect_ratio
square() 1024x1024 1:1
portrait() 1024x1536 2:3
landscape() 1536x1024 3:2

On Gemini, quality is not quality at all — it selects a resolution tier, with low, medium and high becoming 1K, 2K and 4K. Same call, different bill. Check what you are asking for before you turn high on across the board, and keep an eye on it the way you would with token usage and cost tracking.

If you need something the helpers don't cover, size() takes a raw string that is passed through untouched:

$image = Image::of('A wide banner of a mountain range')
    ->size('1792x1024') // Passed through verbatim — must be valid for the provider
    ->generate();

That passthrough is a sharp edge. A provider-specific string works on the provider you wrote it for and gets rejected everywhere else, which quietly undoes the portability the helpers give you.

Attach a reference image to transform#

Pass existing images as attachments and the model edits rather than invents. The Laravel\Ai\Files\Image class has named constructors for each source, and a raw UploadedFile is accepted directly.

use Illuminate\Http\Request;
use Laravel\Ai\Files;
use Laravel\Ai\Image;

public function store(Request $request)
{
    $request->validate([
        'photo' => ['required', 'image', 'mimes:jpg,png,webp', 'max:8192'],
    ]);

    $image = Image::of('Restyle this photo as an impressionist oil painting.')
        ->attachments([
            Files\Image::fromUpload($request->file('photo')),
            // Files\Image::fromStorage('listings/42/original.jpg'),
            // Files\Image::fromPath(storage_path('app/original.jpg')),
            // Files\Image::fromUrl('https://example.com/original.jpg'),
        ])
        ->landscape()
        ->generate();
}

Validate before you attach. Every attachment is base64-encoded into the request body, so an unbounded upload becomes an unbounded payload and a rejected request you paid latency for. And note fromUpload() — it produces an in-memory image that works fine here but will throw the moment you switch this call to queue(). More on that next.

Store the generated image on a disk#

The response knows how to store itself. There is no need to decode the base64 yourself or reach for Storage::disk()->put() — four methods on ImageResponse cover the usual cases and return the written path.

$path = $image->store('listings/42');                    // Random 40-char filename
$path = $image->storeAs('listings/42', 'hero.png');      // Explicit name
$path = $image->storePublicly('listings/42', 's3');      // visibility: public
$path = $image->storePubliclyAs('listings/42', 'hero.png', 's3');

Listing::find(42)->update(['hero_path' => $path]); // Store the path, never the payload

Pass a disk as the second argument and it works with any Flysystem driver — local, S3, Spaces, Laravel Cloud object storage. Omit it and you get the default disk from config/filesystems.php.

When you don't supply a name, the SDK generates a 40-character random string and picks the extension from the response MIME type: .jpg, .png or .webp, falling back to .png. That is usually what you want, because the provider decides the output format, not you.

One thing to watch: these methods return string|bool, and a failed write returns false rather than throwing. Assign it blindly and you'll persist false into a hero_path column.

$path = $image->storePublicly('listings/42', 's3');

if ($path === false) {
    throw new RuntimeException('Failed to store the generated image.');
}

If the images are user-facing and large, the same reasoning about keeping bytes out of your app server applies here as with direct-to-S3 temporary uploads in Livewire.

Queue the generation and handle failures#

Image generation takes anywhere from five to sixty seconds. That does not belong in a web request, and you do not need to write the job yourself — queue() dispatches the SDK's own GenerateImage job and hands back something you can attach callbacks to.

use Laravel\Ai\Image;
use Laravel\Ai\Responses\ImageResponse;
use Throwable;

Image::of("A hero image for a listing titled: {$listing->title}")
    ->landscape()
    ->queue()
    ->then(function (ImageResponse $image) use ($listingId) {
        Listing::find($listingId)->update([
            'hero_path' => $image->storePublicly("listings/{$listingId}", 's3'),
        ]);
    })
    ->catch(function (Throwable $e) use ($listingId) {
        Log::error('Hero generation failed', ['listing' => $listingId, 'error' => $e->getMessage()]);
    });

Capture scalar IDs in those closures, not Eloquent models — the callbacks are serialised onto the queue payload. That's the same discipline I use for running AI SDK agents in background jobs.

Queued generation has one hard restriction: attachments must be a StoredImage or a LocalImage. Anything else throws a LogicException before dispatch, because an in-memory base64 payload has no business being serialised into a job. Persist the upload first, then attach it from the disk:

$path = $request->file('photo')->store('listings/42', 's3');

Image::of('Restyle this photo as an impressionist oil painting.')
    ->attachments([Files\Image::fromStorage($path, 's3')])
    ->queue()
    ->then(fn (ImageResponse $image) => $image->storePublicly('listings/42', 's3'));

generate() also accepts an array of providers and fails over on rate limits and overload errors, firing a ProviderFailedOver event as it moves down the chain — the same mechanism covered in provider fallback and failover:

$image = Image::of('A donut sitting on a kitchen counter')
    ->generate(['openai', 'gemini']);

Fake the provider in a Pest test#

Nothing about this needs a live API key in CI. Image::fake() swaps in a fake gateway, and Storage::fake() gives you a disk to assert against, so the whole path from prompt to stored file is testable.

use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
use Laravel\Ai\Prompts\ImagePrompt;

it('generates and stores a landscape hero image', function () {
    Storage::fake('s3');
    Image::fake([base64_encode('fake-png-bytes')]);

    $path = Image::of('A donut sitting on a kitchen counter')
        ->landscape()
        ->generate()
        ->storePublicly('listings/42', 's3');

    Storage::disk('s3')->assertExists($path);

    Image::assertGenerated(
        fn (ImagePrompt $prompt) => $prompt->contains('donut') && $prompt->isLandscape()
    );
});

ImagePrompt gives you contains(), isSquare(), isLandscape() and isPortrait(), plus the raw size, quality and attachments if you need to be precise. Queued generations record a QueuedImagePrompt instead and are asserted with Image::assertQueued().

Add preventStrayImages() to turn any un-faked generation into an exception, which is how you guarantee a keyless CI run:

Image::fake()->preventStrayImages();

The wider faking story — agents, structured output, stray-prompt prevention — is covered in testing AI features with Agent::fake() and Pest.

Avoid these Laravel AI SDK image generation gotchas#

These are the ones that cost me time, mostly because the documentation and the source disagree in a few places.

store() needs generate() first. The docs show Image::of('...')->store(), but store() lives on the response, not on the pending generation. Without generate() you get a BadMethodCallException. Same for storeAs() and friends.

Assertion helpers only take closures. The docs show Image::assertNotGenerated('Missing prompt') with a string. The signature requires a Closure, so a string is a TypeError. Write fn (ImagePrompt $prompt) => $prompt->contains('...').

Two different timeouts. ->timeout(120) sets the HTTP timeout for the provider call. It does nothing about the queue worker killing the job first — set $timeout on the job or --timeout on the worker to something larger, or you'll see the job die mid-generation with no error from the provider.

The provider decides the format. There's no output-format option. You get PNG, JPEG or WebP depending on the provider and model, and the MIME type comes back on the response. If you need a guaranteed format, convert after storing.

quality('high') is not free. On Gemini it means 4K. On OpenAI it means the high quality tier. Both are materially more expensive than the default, and the call site looks identical.

Wrapping Up#

Start with the sync path — Image::of()->generate()->store() — get the prompt and aspect ratio right against a real provider, then move it behind queue() before it ever sees production traffic. The only code that changes is the callback.

Once generation runs on workers, the next thing to sort is visibility into those workers: monitoring Laravel queues with Horizon in production will tell you when generations are backing up, and running AI SDK agents in the background covers tracking and surfacing run status in the UI.

FAQ#

How do I generate images with the Laravel AI SDK?

Use the Laravel\Ai\Image class rather than an agent. Call Image::of($prompt)->generate() and you get back an ImageResponse containing the generated image. Cast the response to a string for the raw bytes, or call store() on it to write the file straight to a filesystem disk.

Which providers support image generation in the Laravel AI SDK?

Six providers generate images: OpenAI, Gemini, xAI, Azure OpenAI, Bedrock and OpenRouter. Anthropic and the other text-only providers do not, so a Claude-based app needs a second provider key for images. The default is set by ai.default_for_images in config/ai.php, which ships as gemini and is separate from the ai.default used for text.

How do I set the image size or aspect ratio?

Call square(), portrait() or landscape() on the pending generation. These set normalised ratios of 1:1, 2:3 and 3:2, which each provider gateway translates into its own parameter — OpenAI turns them into pixel dimensions like 1536x1024, while Gemini passes an aspect_ratio. If you need something else, size() accepts a raw string that is sent to the provider unchanged.

How do I save an AI-generated image to S3 in Laravel?

Call store(), storeAs(), storePublicly() or storePubliclyAs() on the ImageResponse and pass 's3' as the disk argument. The SDK decodes the base64 payload and writes it through Flysystem, returning the stored path. Persist that path on your model rather than the image payload itself.

Can I edit an existing image with the Laravel AI SDK?

Yes. Pass reference images to the attachments() method using Files\Image::fromStorage(), fromPath(), fromUrl() or fromUpload(), and write a prompt describing the change you want. For queued generations the attachments must be stored or local images, so save an upload to a disk first and attach it from there.

How do I handle image generation timeouts?

There are two timeouts and you need both. The timeout() method sets the HTTP timeout for the provider request in seconds, while the queue worker has its own job timeout that will kill the job regardless. Set the job timeout higher than the HTTP timeout, and use the catch() callback on a queued generation to record the failure and let the user retry.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.