Building an Image Generation Site with Symfony
A full image generation app with PapiAI, Google Imagen, and Symfony Messenger.
In this tutorial, we'll build a web application that generates AI images from text prompts using Symfony, PapiAI's Google provider, and the Imagen API. Users enter a prompt, the image is generated asynchronously via Symfony Messenger, and the result appears in a gallery. We'll cover the full stack: Symfony controller, Twig templates, async processing, and file storage.
Architecture overview
The app has three parts:
- Frontend — A Twig-rendered form for entering prompts and a gallery grid showing generated images
- Controller — Handles form submission, dispatches async jobs, and serves the gallery
- Message handler — A Symfony Messenger handler that calls PapiAI's Google provider to generate the image and saves it to disk
We use async processing because image generation takes 5-15 seconds — too long for a synchronous HTTP response. The user submits a prompt, sees a "generating" status, and the image appears once the background worker completes it.
Step 1: Install dependencies
# Create a new Symfony project (or use an existing one)
symfony new image-gen --webapp
cd image-gen
# Install PapiAI
composer require papi-ai/symfony papi-ai/google
# Install Messenger for async processing
composer require symfony/messenger
Add your Google API key to .env.local:
GOOGLE_API_KEY=your-google-ai-api-key
Step 2: Configure PapiAI
Create the PapiAI bundle configuration:
# config/packages/papi.yaml
papi:
default_provider: google
providers:
google:
driver: PapiAI\Google\GoogleProvider
api_key: '%env(GOOGLE_API_KEY)%'
model: gemini-2.0-flash
Step 3: Create the image generation message
Symfony Messenger uses message classes to represent async jobs. Create one for image generation:
// src/Message/GenerateImageMessage.php
namespace App\Message;
class GenerateImageMessage
{
public function __construct(
public readonly string $id,
public readonly string $prompt,
public readonly string $aspectRatio = '1:1',
) {}
}
Step 4: Create the message handler
The handler receives the message, calls PapiAI's Google provider to generate the image, and saves the result to the filesystem:
// src/MessageHandler/GenerateImageHandler.php
namespace App\MessageHandler;
use App\Message\GenerateImageMessage;
use PapiAI\Google\GoogleProvider;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class GenerateImageHandler
{
public function __construct(
private GoogleProvider $provider,
private string $imageDir,
) {}
public function __invoke(GenerateImageMessage $message): void
{
// Generate the image via PapiAI
$result = $this->provider->generateImage(
prompt: $message->prompt,
options: [
'model' => GoogleProvider::IMAGEN_4,
'aspectRatio' => $message->aspectRatio,
'numberOfImages' => 1,
],
);
if (empty($result['images'])) {
// Write an error marker so the UI can show a failure
file_put_contents(
$this->imageDir . '/' . $message->id . '.error',
'Image generation failed: no images returned',
);
return;
}
// Decode and save the image
$imageData = base64_decode($result['images'][0]['data']);
$mimeType = $result['images'][0]['mimeType'] ?? 'image/png';
$extension = $mimeType === 'image/jpeg' ? 'jpg' : 'png';
file_put_contents(
$this->imageDir . '/' . $message->id . '.' . $extension,
$imageData,
);
// Save metadata
file_put_contents(
$this->imageDir . '/' . $message->id . '.json',
json_encode([
'id' => $message->id,
'prompt' => $message->prompt,
'aspectRatio' => $message->aspectRatio,
'filename' => $message->id . '.' . $extension,
'createdAt' => date('c'),
]),
);
}
}
Register the $imageDir parameter in your services configuration:
# config/services.yaml
parameters:
image_dir: '%kernel.project_dir%/public/generated'
services:
App\MessageHandler\GenerateImageHandler:
arguments:
$imageDir: '%image_dir%'
Create the output directory:
mkdir -p public/generated
Step 5: Configure Messenger transport
For development, use the doctrine transport (or async with a database). In production you'd use Redis or RabbitMQ:
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: 'doctrine://default'
routing:
App\Message\GenerateImageMessage: async
# Create the messenger table
php bin/console messenger:setup-transports
Step 6: Build the controller
// src/Controller/GalleryController.php
namespace App\Controller;
use App\Message\GenerateImageMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class GalleryController extends AbstractController
{
public function __construct(
private MessageBusInterface $bus,
private string $imageDir,
) {}
#[Route('/', name: 'gallery')]
public function index(): Response
{
// Load all generated images from metadata files
$images = [];
$metaFiles = glob($this->imageDir . '/*.json');
foreach ($metaFiles as $file) {
$meta = json_decode(file_get_contents($file), true);
if ($meta && isset($meta['filename'])) {
$images[] = $meta;
}
}
// Sort by newest first
usort($images, fn($a, $b) => ($b['createdAt'] ?? '') <=> ($a['createdAt'] ?? ''));
return $this->render('gallery/index.html.twig', [
'images' => $images,
]);
}
#[Route('/generate', name: 'generate', methods: ['POST'])]
public function generate(Request $request): Response
{
$prompt = trim($request->request->getString('prompt'));
$aspectRatio = $request->request->getString('aspect_ratio', '1:1');
if ($prompt === '') {
$this->addFlash('error', 'Please enter a prompt.');
return $this->redirectToRoute('gallery');
}
// Dispatch async image generation
$id = uniqid('img_', true);
$this->bus->dispatch(new GenerateImageMessage(
id: $id,
prompt: $prompt,
aspectRatio: $aspectRatio,
));
$this->addFlash('success', 'Image generation started! It will appear in the gallery shortly.');
return $this->redirectToRoute('gallery');
}
}
Step 7: Create the Twig template
{# templates/gallery/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}AI Image Generator{% endblock %}
{% block body %}
<div class="container">
<header>
<h1>AI Image Generator</h1>
<p>Powered by PapiAI & Google Imagen</p>
</header>
{% for flash in app.flashes('success') %}
<div class="flash flash-success">{{ flash }}</div>
{% endfor %}
{% for flash in app.flashes('error') %}
<div class="flash flash-error">{{ flash }}</div>
{% endfor %}
<form method="post" action="{{ path('generate') }}" class="generate-form">
<div class="form-row">
<input type="text"
name="prompt"
placeholder="Describe the image you want to generate..."
required
maxlength="500"
autofocus>
<select name="aspect_ratio">
<option value="1:1">Square (1:1)</option>
<option value="16:9">Landscape (16:9)</option>
<option value="9:16">Portrait (9:16)</option>
<option value="4:3">Standard (4:3)</option>
</select>
<button type="submit">Generate</button>
</div>
</form>
{% if images is not empty %}
<div class="gallery">
{% for image in images %}
<div class="gallery-item">
<img src="/generated/{{ image.filename }}"
alt="{{ image.prompt }}"
loading="lazy">
<div class="gallery-caption">
<p>{{ image.prompt }}</p>
<time>{{ image.createdAt|date('M j, Y g:ia') }}</time>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No images yet. Enter a prompt above to generate your first image.</p>
{% endif %}
</div>
<style>
.container { max-width: 960px; margin: 0 auto; padding: 48px 24px; }
header { text-align: center; margin-bottom: 40px; }
header h1 { font-size: 32px; margin-bottom: 8px; }
header p { color: #666; }
.generate-form { margin-bottom: 48px; }
.form-row { display: flex; gap: 12px; }
.form-row input { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px; }
.form-row select { padding: 12px; border: 1px solid #ddd; border-radius: 8px; background: white; }
.form-row button { padding: 12px 28px; background: #c62828; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.form-row button:hover { background: #8e0000; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.gallery-item { border-radius: 12px; overflow: hidden; border: 1px solid #e0e0e0; background: white; }
.gallery-item img { width: 100%; display: block; }
.gallery-caption { padding: 12px 16px; }
.gallery-caption p { font-size: 14px; color: #333; margin: 0 0 4px; }
.gallery-caption time { font-size: 12px; color: #999; }
.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
.flash-success { background: #e8f5e9; color: #2e7d32; }
.flash-error { background: #ffebee; color: #c62828; }
.empty { text-align: center; color: #999; padding: 48px 0; }
</style>
{% endblock %}
Step 8: Run it
Start the Symfony development server and the Messenger worker:
# Terminal 1: Start the web server
symfony server:start
# Terminal 2: Start the async worker
php bin/console messenger:consume async -vv
Open https://127.0.0.1:8000 in your browser. Enter a prompt like "A serene mountain lake at sunset, oil painting style" and submit. The flash message confirms the job was dispatched. In your worker terminal, you'll see the Messenger handler call PapiAI's Google provider, which calls the Imagen API. Once complete, refresh the gallery to see your generated image.
What's happening under the hood
- The user submits a prompt via the form
- The controller creates a
GenerateImageMessageand dispatches it to the Messenger bus - Messenger routes the message to the
asynctransport (Doctrine in development) - The worker picks up the message and invokes
GenerateImageHandler - The handler calls
$this->provider->generateImage()— PapiAI's Google provider sends the request to Google's Imagen API - The returned base64 image data is decoded and saved to
public/generated/ - Metadata (prompt, timestamp, filename) is saved as a JSON sidecar file
- On the next page load, the controller reads all
.jsonmetadata files and renders the gallery
Production considerations
- Storage — Replace local filesystem with S3, GCS, or a CDN-backed storage service. Use Flysystem for abstraction.
- Database — Store image metadata in a database table instead of JSON files. Create a Doctrine entity for proper querying and pagination.
- Transport — Switch from Doctrine transport to Redis or RabbitMQ for better performance under load.
- Rate limiting — Add PapiAI's
RateLimitMiddlewareto respect Imagen API quotas. - Status polling — Add an AJAX endpoint that checks if an image is ready, so the gallery updates without a full page reload.
- Image editing — PapiAI's Google provider also supports
editImage()via Gemini's multimodal models. Add an "edit" button to existing gallery images. - Multiple providers — Add OpenAI's DALL-E as a second provider and let users choose which model generates their image.
The power of interface segregation
Notice that the handler type-hints GoogleProvider directly — because it needs the ImageProviderInterface methods (generateImage()), which aren't on the base ProviderInterface. This is PapiAI's interface segregation in action: the core LLM contract is separate from image generation, embedding, TTS, and transcription capabilities. You depend only on the interface you need.
If you wanted to swap to a different image generation provider in the future, you'd type-hint ImageProviderInterface instead and wire the specific implementation in your Symfony services config.