← Back to Blog

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:

  1. Frontend — A Twig-rendered form for entering prompts and a gallery grid showing generated images
  2. Controller — Handles form submission, dispatches async jobs, and serves the gallery
  3. 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

  1. The user submits a prompt via the form
  2. The controller creates a GenerateImageMessage and dispatches it to the Messenger bus
  3. Messenger routes the message to the async transport (Doctrine in development)
  4. The worker picks up the message and invokes GenerateImageHandler
  5. The handler calls $this->provider->generateImage() — PapiAI's Google provider sends the request to Google's Imagen API
  6. The returned base64 image data is decoded and saved to public/generated/
  7. Metadata (prompt, timestamp, filename) is saved as a JSON sidecar file
  8. On the next page load, the controller reads all .json metadata files and renders the gallery

Production considerations

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.

PapiAI is open source under the MIT license. Read the documentation to learn more.