Easy Image Optimization in PHP

May 23, 2023
Written by
Reviewed by

Easy Image Optimization in PHP

Images. We use them each and every day on websites for a wide variety of purposes. From product photos on an e-commerce site to staff images on a consulting firm's website, modern websites would be far less engaging without them. Disable images for any modern website and you'll see what I mean!

However, images come at a cost. In 2023, images make up the largest proportion of modern websites, behind videos, a proportion that has been growing for several years, since images were added to the HTML spec in 1995.

While they can make pages and sites more engaging and attractive, though, images can significantly slow down page load speed and increase storage costs.

So in this, reasonably short, tutorial I'm going to show you how to optimise images in PHP with only a negligible loss of quality — if any.

Prerequisites

To follow along with the tutorial, you will need the following:

How do you optimise images?

There are two key ways to optimise images: choose the best file format, and use image compression.

There are a number of file formats common to the web, with the primary ones being PNG, JPEG, and GIF. However, there are newer formats as well, such as SVG, and WebP. Here's a quick summary:

  • AVIF: One of the two newer image formats, it is open and royalty-free. What's more, it's supported on almost every major browser. It's ideal if you really need to save bandwidth.
  • GIF: The other main format for the web and also very widely supported. They're great for small images and simple animations. However, they're limited to a maximum of 256 colours and don't offer great performance.
  • JPEG/JPG: This is one of the oldest image formats for the web, and arguably the most widely supported one available today. It can store images at much smaller file sizes with a minimal reduction in quality.
  • PNG: PNG supports both lossy and lossless compression, and was designed as a modern replacement to the GIF and JPEG formats. It also has an animated variant, called APNG or Animated PNG.
  • SVG: SVG files are plain text, not binary. Given that, they can achieve a high compression ratio and can be edited in any text editor on any operating system. What's more, being a vector graphic, they're very scalable, in contrast to raster graphics, such as PNG and JPG.
  • WebP: The second of the two newer image formats, it was created by Google in 2011. WebP files can be compressed more than JPG and PNG, yet with no discernible loss of quality. It supports both lossless and lossy compression, transparency, and animation.

If you'd like a deeper dive into AVIF and WebP, check out this article from Smashing Magazine.

Image compression, if you're not familiar with it, is:

> a process applied to a graphics file to minimise its size in bytes without degrading image quality below an acceptable threshold.

There are two types of image compression:

  • Lossy compression removes parts of an image that the algorithm determines can be removed and which are probably indiscernible to the human eye. Given that, every time a file is compressed, more of the original image is irretrievably lost. JPEGs and lossy WebP use lossy compression.
  • Lossless compression, doesn't remove or lose any data from the original image during compression but does remove unrequired metadata, such as some of its EXIF data. Given that, the original image data can always be retrieved, no matter how many times an image was compressed. This is helpful for work such as photography, However, file sizes are often larger than when using lossy compression. Some image formats that use lossless compression are PNG, GIF, and lossless WebP.

Take the two images below. The lossy-compressed JPEG isn't quite as clear as the original lossless PNG, but the difference is only negligible. And, depending on your needs, that loss of quality is likely acceptable — especially for the notable reduction in the file's size.

A screenshot of Audacity in PNG format and a compressed JPEG equivalent, showing the quality and size differences of using both file formats.

With that in mind, let's now build an application that can compress images in both lossy and lossless formats.

How will the application work?

There's not much to it. The application will allow an image to be uploaded and compressed, whether in the same file format or in a different one. It will have one route (/). This route will accept POST requests and look for three properties in the submitted form or POST data:

PropertyDescription
imageAn image to be uploaded and compressed. There are no checks on the uploaded image's type. Ideally, though, the file type should be restricted to a specific set, such as those supported by modern browsers.
file_nameThe desired name for the new image file, including its extension. The filename's extension determines the new file's format.
image_qualityThe desired quality, or compression level, for the new image. This is an integer ranging from 0 to 100, where 0 indicates the least amount of quality and 100 the most.It's worth noting that this setting only takes effect if the desired file format uses lossless compression.

Scaffold the application

The first thing to do is to scaffold the application. We're going to save some time by using the Mezzio Skeleton project to do this. Run the following commands, wherever you store your PHP projects, to scaffold the application, change into the project's top-level directory, and create the image upload directory (data/uploads).

composer create-project mezzio/mezzio-skeleton mezzio-image-compression
cd mezzio-image-compression
mkdir data/uploads
composer development-enable

When prompted, answer the questions as follows:

What type of installation would you like? 3 (Modular)
Which container do you want to use for dependency injection? 2 (laminas-servicemanager)
Which router do you want to use? 1 (FastRoute)
Which template engine do you want to use? n (None)
Which error handler do you want to use during development? 1 (Whoops)
Please select which config file you wish to inject 'Laminas\HttpHandlerRunner\ConfigProvider' into: 1 (config/config.php)
Remember this option for other packages of the same type? (Y/n) Y

If you see the following at the end of the script output, run composer update to correct the issue.

- Required package "laminas/laminas-servicemanager" is not present in the lock file.
- Required package "mezzio/mezzio-fastroute" is not present in the lock file.
- Required package "mezzio/mezzio-twigrenderer" is not present in the lock file.
This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.
Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md
and prefer using the "require" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r

If this is your first time working with Mezzio, and you'd like to learn more about it, check out the documentation, or my Mezzio book.

Install the required packages

The next thing to do is install the required packages. Gladly, there are only two:

To install it, run the command below.

composer require -W \
    flynsarmy/image-optimizer \
    intervention/image

Create a handler class

Next, it's time to create a handler class to contain the logic for processing the uploaded image. Handler classes are analogous to Controllers in other frameworks, such as Laravel.

To create it, run the command below.

vendor/bin/laminas –ansi mezzio:handler:create \
    --no-factory --no-register \
    "App\Handler\ImageCompressionHandler"

Now, open the new class, src/App/src/Handler/ImageCompressionHandler.php, and update it to match the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use ImageOptimizer\OptimizerFactory;
use Intervention\Image\ImageManager;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ImageCompressionHandler implements RequestHandlerInterface
{
    public const DEFAULT_IMAGE_QUALITY = 80;
    public const IMAGE_UPLOAD_PATH = __DIR__ . '/../../../../data/uploads/';

    public function handle(ServerRequestInterface $request) : ResponseInterface
    {
        $newFileName = $request->getParsedBody()['file_name'] 
            ?? 'untitled.jpg';
        $imageQuality = $request->getParsedBody()['image_quality'] 
            ?? self::DEFAULT_IMAGE_QUALITY;
        $newFile = self::IMAGE_UPLOAD_PATH . "{$newFileName}";

        $image = $request->getUploadedFiles()['image'];
        $image->moveTo(self::IMAGE_UPLOAD_PATH . $image->getClientFilename());

        $manager = new ImageManager(['driver' => 'imagick']);
        try {
            $processedImage = $manager->make(
                self::IMAGE_UPLOAD_PATH . $image->getClientFilename()
            );
            $processedImage->save($newFile, $imageQuality);

            (new OptimizerFactory())
                ->get()
                ->optimize($newFile);
        } catch (\Exception $e) {
            return new JsonResponse(
                "Oops. Something went wrong. Reason: {$e->getMessage()}"
            );
        }

        return new JsonResponse(
            [
                'original file' => [
                    'File name' => self::IMAGE_UPLOAD_PATH . $image->getClientFilename(),
                    'File size' => $image->getSize(),
                    'File type' => $image->getClientMediaType(),
                ],
                'new file' => [
                    'File name' => $newFile,
                    'File size' => filesize($newFile),
                    'File type' => mime_content_type($newFile),
                ]
            ],
        );
    }
}

The class defines two constants, one for the image upload base path (IMAGE_UPLOAD_PATH) and the other for the default image quality level (DEFAULT_IMAGE_QUALITY).

Then, in the handle() method, it starts off by attempting to retrieve the file_name and image_quality properties from the submitted form data. After that, it retrieves the uploaded image and moves it to the image upload directory (data/uploads), to help avoid security vulnerabilities.

Then, an ImageManager object ($manager) from the Intervention package is instantiated and initialised to use the Imagick extension for manipulating images. The object then creates a new image from the original, compressing it to the desired quality level. This defaults to the default quality level if the quality level was not specified. After that, it uses the Image Optimizer package to perform further image optimisations to reduce the file to the smallest file size.

The method finishes by returning a JSON response containing the name, size, and file type for both the original and the new image.

Register the handler with the DI container

Next, we need to register the handler with the DI (Dependency Injection) container so that it can be used to handle requests to the route when required. To do that, update the getDependencies() method in src/App/src/ConfigProvider.php to match the following.

public function getDependencies(): array
{
    return [
        'factories'  => [
            Handler\ImageCompressionHandler::class => \Laminas\ServiceManager\Factory\InvokableFactory::class,
        ],
    ];
}

Update the routing table

Finally, it's time to update the routing table so that requests to the default route are handled by the new handler class. To do that, update the anonymous function at the end of config/routes.php to match the following code.


return static function (
    Application $app, MiddlewareFactory $factory, 
    ContainerInterface $container): void 
{
    $app->post('/', App\Handler\ImageCompressionHandler::class);
};

Test the application

With the application complete, it's time to test it. Before that can happen, start it by running the following command from the project's top-level directory.

composer serve

Alternatively, start it using Docker Compose, by running the following command.

docker compose up -d

Regardless of how you launch the application, it will be available on port 8080 on localhost.

Now, compress an image by running the following command. Replace the image on line 2 with an image that you have handy, and change the file name and image quality as desired.


curl --silent \
    -F image=@Audacity.png http://localhost:8080 \
    -F file_name="Audacity.jpg" \
    -F image_quality="20" | jq

After running the command, you should see JSON printed to the terminal, similar to the example below.

{
    "original file": {
        "File name": "/var/www/html/data/uploads/Audacity.png",
        "File size": 89742,
        "File type": "image/png"
    },
    "new file": {
        "File name": "/var/www/html/data/uploads/Audacity.jpg",
        "File size": 35958,
        "File type": "image/jpeg"
    }
}

In addition to seeing the difference in file sizes, have a look at the original and new images and see if you can discern any noticeable quality difference. If not, play with the value of image_quality until you can.

That's how to compress images in PHP

Admittedly, this tutorial has only scratched the surface, but it shows just how easy it can be to compress an image in PHP, when using the Intervention Image package. What other functions would you use? Let me know on Twitter.

Matthew Setter is a PHP Editor in the Twilio Voices team and a PHP, Go, and Rust developer. He’s also the author of Mezzio Essentials and Deploy With Docker Compose. When he's not writing PHP code, he's editing great PHP articles here at Twilio. You can find him at msetter[at]twilio.com, on LinkedIn, Twitter, and GitHub.

"Desert" by Michael Sutton is not licensed but available in the public domain.