Images

Validate dimensions, build thumbnails outside the request, and strip EXIF metadata before an uploaded image is served to anyone.

Syntaximagescale(GdImage $image, int $width, int $height = -1, int $mode = IMG_BILINEAR_FIXED): GdImage|false

An uploaded image needs three things done to it: checking, resizing, and cleaning. Symfony supplies the checking; the resizing and cleaning are PHP's GD or Imagick extensions, since the framework ships no image library of its own.

Check before decoding

Assert\Image extends Assert\File, so it keeps maxSize and adds maxWidth, maxHeight, maxPixels, maxRatio and detectCorrupted. maxPixels matters most: a small, highly compressed file can declare enormous dimensions, and decoding it allocates width times height times four bytes. The constraint reads the header, so the limit is enforced before anything is decompressed.

Resize out of the request

Decoding and re-encoding a large photograph takes seconds and a lot of memory. Doing that while a browser waits makes the upload look broken. Store the original, dispatch a message, and let a worker build the sizes you need, writing each to its own path. If a thumbnail is missing when a page is rendered, show a placeholder rather than generating it inline.

Strip metadata

A phone photograph carries EXIF data, which often includes GPS coordinates. Serving it publishes where the picture was taken. Re-encoding through GD writes no EXIF at all, so a GD thumbnail is already clean; the original is not. Imagick keeps metadata unless you call stripImage(), which removes every profile and comment, the ICC colour profile included.

Example

Example · php
<?php
// src/Image/Thumbnailer.php

declare(strict_types=1);

namespace App\Image;

final class Thumbnailer
{
    public function __construct(
        private readonly int $maxWidth = 320,
        private readonly int $quality = 82,
    ) {
    }

    public function toJpeg(string $bytes): string
    {
        $source = imagecreatefromstring($bytes);

        if (false === $source) {
            throw new \RuntimeException('Not a readable image.');
        }

        // Shorthand: height -1 keeps the aspect ratio.
        $thumb = imagescale($source, $this->maxWidth, -1);

        // The same scaling, written out:
        // $height = (int) round(
        //     imagesy($source) * $this->maxWidth / imagesx($source)
        // );
        // $thumb = imagescale($source, $this->maxWidth, $height);

        if (false === $thumb) {
            throw new \RuntimeException('Could not resize the image.');
        }

        ob_start();
        // A null file argument writes the encoded image to the output
        // buffer. The re-encode carries no EXIF, so GPS data is gone.
        imagejpeg($thumb, null, $this->quality);

        return (string) ob_get_clean();
    }
}

// $jpeg = (new Thumbnailer(320))->toJpeg(file_get_contents('photo.jpg'));
// echo imagesx(imagecreatefromstring($jpeg));
// Output:
// 320

When to use it

  • Refuse a 30-megapixel upload before PHP tries to decode it into memory.
  • Generate 320px and 1200px versions of a photograph in a worker so the upload returns immediately.
  • Remove GPS coordinates from holiday photographs before they appear on a public profile.
  • Reject a truncated or corrupt image at upload time rather than discovering it in a failed job.

More examples

Validating the image on the way in

POST /photos with a valid photograph returns 202 with the stored path, because the thumbnail does not exist yet. A 30-megapixel file is refused with 422 and the maxPixels message, before any decoding takes place. detectCorrupted is worth the cost on user uploads: a truncated file that passes a header check still fails later, in a worker, where nobody is watching.

Example · php
<?php
// src/Controller/PhotoUploadController.php

declare(strict_types=1);

namespace App\Controller;

use App\Message\GenerateThumbnail;
use App\Storage\DocumentStorage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;

final class PhotoUploadController extends AbstractController
{
    public function __construct(
        private readonly DocumentStorage $storage,
        private readonly MessageBusInterface $bus,
    ) {
    }

    #[Route('/photos', name: 'app_photo_upload', methods: ['POST'])]
    public function upload(
        #[MapUploadedFile([
            new Assert\Image(
                maxSize: '12M',
                maxWidth: 8000,
                maxHeight: 8000,
                // Refuses a file that claims more pixels than this,
                // read from the header rather than by decoding.
                maxPixels: 24000000,
                // Needs the gd extension: the check runs the contents
                // through imagecreatefromstring(). Imagick is not used.
                detectCorrupted: true,
            ),
        ])]
        UploadedFile $photo,
    ): JsonResponse {
        $path = 'originals/'.bin2hex(random_bytes(8)).'.'.$photo->guessExtension();
        $this->storage->store($photo, $path);

        // Resizing happens in a worker, not here.
        $this->bus->dispatch(new GenerateThumbnail($path, 'thumbs/320/'.basename($path)));

        return $this->json(['path' => $path, 'thumbnail' => 'pending'], 202);
    }
}

Building the thumbnail in a worker

The handler is an ordinary service with the services it needs injected. Run it with bin/console messenger:consume async, and give that worker a higher memory limit than the web process, because the decoded bitmap is the peak. The existence check makes a redelivered message harmless, which matters since a transport guarantees at-least-once delivery, not exactly-once.

Example · php
<?php
// src/MessageHandler/GenerateThumbnailHandler.php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Image\Thumbnailer;
use App\Message\GenerateThumbnail;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class GenerateThumbnailHandler
{
    public function __construct(
        #[Target('photos.storage')]
        private readonly FilesystemOperator $filesystem,
        private readonly Thumbnailer $thumbnailer,
    ) {
    }

    public function __invoke(GenerateThumbnail $message): void
    {
        if ($this->filesystem->fileExists($message->thumbnailPath)) {
            // Messages can be delivered more than once; writing the
            // same thumbnail twice is wasted work.
            return;
        }

        $original = $this->filesystem->read($message->path);

        $this->filesystem->write(
            $message->thumbnailPath,
            $this->thumbnailer->toJpeg($original),
            ['visibility' => 'private'],
        );
    }
}

Imagick, where stripping is explicit

Imagick reads more formats than GD and resamples better, which is why production image pipelines tend to use it. The trade is that it preserves metadata by default, so stripImage() is not optional. Dropping the ICC profile can wash out colour on wide-gamut photographs; when that matters, read the profile with getImageProfiles('icc', true) before stripping and put it back with profileImage().

Example · php
<?php
// src/Image/ImagickThumbnailer.php

declare(strict_types=1);

namespace App\Image;

final class ImagickThumbnailer
{
    public function __construct(
        private readonly int $box = 320,
        private readonly int $quality = 82,
    ) {
    }

    public function toJpeg(string $bytes): string
    {
        $image = new \Imagick();
        $image->readImageBlob($bytes);

        // bestfit: true scales to fit inside the box and keeps the ratio.
        $image->thumbnailImage($this->box, $this->box, true);

        // Removes all profiles and comments: EXIF, GPS, and the ICC
        // colour profile. Without this call they survive the resize.
        $image->stripImage();

        $image->setImageFormat('jpeg');
        $image->setImageCompressionQuality($this->quality);

        return $image->getImageBlob();
    }
}

Discussion

  • Be the first to comment on this lesson.