Building an Image Optimization Pipeline in Laravel & Intervention Package

By Amas
Building an Image Optimization Pipeline in Laravel & Intervention Package

Images are crucial for modern web applications, but unoptimized images can slow down your site and impact user experience. 

In this guide, we’ll cover how to set up a workflow to handle image optimization in your Laravel application, making use of Intervention Image's awesome features.

First of all, Why Optimize Images?

  1. Performance Improvement: Smaller images load faster, improving site speed.
  2. SEO Boost: Faster loading pages rank better in search engines.
  3. Reduced Bandwidth: Optimized images save bandwidth, especially crucial for mobile users.
  4. Enhanced User Experience: Faster sites lead to happier users.

Already conviced? Let's dive into it 👇

Building the Workflow

Step 1: Install the Intervention Image Package

To begin, ensure you have a Laravel application set up. Then, install the Intervention Image package via Composer:

composer require intervention/image

Step 2: Set Up Image Upload Logic

Depending on the type of your application, you'd need to let your users upload the images and store them somewhere to be ready for the optimization workflow. 

To keep things simple, we will just assume that you have 1 image that you need to optimize, and that image is stored in the app's public storage directory. 

Step 3: Set up the ProcessedImage Class

We will need a class where we can store the information about the image that we are going to process, which will need to be passed between the different pipeline steps. For that we will create a DTO (Data Transfer Object) class and call it "ProcessedImage"

<?php

namespace App\Dto;

class ProcessedImage
{
    protected ?string $thumbnailPath = null;

    public function __construct(
        private string $filePath
    ) {
    }

    public function setThumbnailPath(?string $thumbnailPath): ProcessedImage
    {
        $this->thumbnailPath = $thumbnailPath;

        return $this;
    }

    public function getThumbnailPath(): ?string
    {
        return $this->thumbnailPath;
    }

    public function getFilePath(): string
    {
        return $this->filePath;
    }

    public function setFilePath(string $filePath): ProcessedImage
    {
        $this->filePath = $filePath;

        return $this;
    }
}

This class has two fields $filePath and $thumbnailPath

$filePath will be contain the path of the image we will process, while $thumbnailPath will be used to store the path of the thumbnail after it's generated. 

 

Step 4: Implement the Pipeline

Laravel Pipelines provide a beautiful way to process data through a series of tasks, or “pipes,” each responsible for a specific operation. It simplifies workflows where data needs to be processed in multiple steps, making your code cleaner, more modular, and easier to maintain. Pipelines shine in scenarios like data transformations, complex processing chains, or implementing middleware-like functionality.

Interestingly, Laravel itself uses the power of pipelines in its HTTP kernel to handle middleware. When a request is made to a Laravel application, it travels through a pipeline of middleware layers before reaching your routes or controllers. This same mechanism is exposed to developers through the Pipeline class, enabling you to build similar, reusable workflows tailored to your application’s needs.

For our image optimization workflow Pipelines fit perfectly, as we will need to do a series of operation on the image to optimize it.

To optimize the images, we would like to do the following steps:

  • Convert the image to a web-optimized type (webp)
  • Make sure that an image doesn't exceed a certain width (1920px)
  • Generate a smaller version of the image (thumbnail) 

Let's do each of those as its own step. 

Convert To WebP Step

<?php

namespace App\Pipelines;

use App\Dto\ProcessedImage;
use Intervention\Image\ImageManager;

class ConvertToWebP
{
    public function handle(ProcessedImage $image, \Closure $next)
    {
        $webpImage = ImageManager::imagick()->read($image->getFilePath())->toWebp();
        $webpImage->save($image->getFilePath() . '.webp');
        $image->setFilePath($image->getFilePath() . '.webp');

        return $next($image);
    }
}

Each pipeline step will need to implement a function called "handle", which receives the "ProcessedImage" which we created earlier, and a closure, and at the end it should call that closure and pass to it the $image after processing it. 

ImageManager is a utility class provided by the Intervention package, which offers ability to use either "Imagick" or "GD" image handling drivers. For this tutorial we will stick to "Imagick", and in general, "Imagick" supports more image extensions so using it would be a good idea. 

We used the handy "toWebp()" function that Intervention offers, which converts the image to webp format. Intervention also offers other handy functions that convert the type of the image like "toJpeg()", "toPng()", "toBmp()", "toTiff()", "toHeic()" and "toGif()".

Scale Down Image Size Step

<?php

namespace App\Pipelines;

use App\Dto\ProcessedImage;
use Closure;
use Intervention\Image\ImageManager;

class ScaleImage
{
    protected int $maxWidth = 1920;

    public function handle(ProcessedImage $image, Closure $next)
    {
        $scaledImage = ImageManager::imagick()->read($image->getFilePath())
            ->scaleDown($this->maxWidth);

        $scaledImage->save();

        return $next($image);
    }
}

In this step we defined the "ScaleImage" class (pipeline step), which also needs to implement the handle function.

In the handle() function, we load the image using the Imagick driver, then use the "scaleDown()" function to make sure that we scale the image down to a maximum width of "1920px".

Generate a Thumbnail Step

<?php

namespace App\Pipelines;

use App\Dto\ProcessedImage;
use Intervention\Image\ImageManager;

class GenerateThumbnail
{
    protected int $thumbnailWidth = 300;

    public function handle(ProcessedImage $image, \Closure $next)
    {
        $thumbnail = ImageManager::imagick()->read($image->getFilePath())
            ->scaleDown($this->thumbnailWidth)->toWebp();

        $thumbnailPath = $image->getFilePath() . '_thumbnail.webp';
        $thumbnail->save($thumbnailPath);

        $image->setThumbnailPath($thumbnailPath);

        return $next($image);
    }
}

Here we wrote a pipeline step where we load the image with the Imagick driver, then scale it down to a maximum of 300px width (using the scaleDown() function), then convert it to a webp (using toWebp() function), and afterwards we store the image in a separate file, and set the path of the thumbnail in the "ProcessedImage" object via setThumbnailPath(), so that we can retrieve that thumbnail later if we want. 

Putting the Pipeline Together

As the pipeline will take some time to handle the image optimization, it's best to call it from a background job, or via command. Let's create a quick command for that:

<?php

namespace App\Console\Commands;

use App\Dto\ProcessedImage;
use App\Pipelines\ConvertToWebP;
use App\Pipelines\GenerateThumbnail;
use App\Pipelines\ScaleImage;
use Illuminate\Console\Command;
use Illuminate\Pipeline\Pipeline;

class OptimizeImages extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'app:optimize-images';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        $pathToImage = storage_path('app/public/image.jpg'); // or any other path

        $image = new ProcessedImage($pathToImage);
        $processedImage = app(Pipeline::class)
            ->send($image)
            ->through([
                ConvertToWebP::class,
                ScaleImage::class,
                GenerateThumbnail::class,
            ])
            ->thenReturn();

        // do something with $processedImage

        $this->info('Image optimized');
    }
}

In the command, we set the image path, then create the ProcessedImage object, passing in the image path. 

After that we use the app(Pipeline::class)->send($image)->through([...])->thenReturn(); to run the pipeline. 

If you run the above command, the image will run through the pipeline from step to step, and all the optimizations will be do on the image. 

What Else Can We Do?

We can extend our pipeline with extra steps as we wish, all that we will need to do is to implement the step, then add it into our pipeline in the command. 

Let's say we want to change the image to greyscale, and add a water mark to it. 

Greyscale Step

<?php

namespace App\Pipelines;

use App\Dto\ProcessedImage;
use Intervention\Image\ImageManager;

class ApplyFilters
{
    public function handle(ProcessedImage $image, \Closure $next)
    {
        $img = ImageManager::imagick()->read($image->getFilePath());

        $img->greyscale();
        // and other filters
//        $img->blur();
//        $img->pixelate(12);
//        $img->invert();
//        $img->brightness(50);
//        $img->contrast(50);
//        $img->colorize(100, 0, 0);
//        $img->rotate(45);
//        $img->flip('v');

        $img->save();

        return $next($image);
    }
}

In this step we used the "greyscale()" function to turn the image into greyscale. Intervention offers also many handy filters and manipulation function that you can use like blur(), pixelate(), invert(), brightness(), contrast(), colorize(), rotate(), flip(), and more. 

Add a Water Mark Step

<?php

namespace App\Pipelines;

use App\Dto\ProcessedImage;
use Intervention\Image\ImageManager;

class AddWaterMark
{
    protected $watermarkPath = 'app/public/water-mark.png';

    public function handle(ProcessedImage $image, \Closure $next)
    {
        $img = ImageManager::imagick()->read($image->getFilePath());

        // create a new resized watermark instance and insert at bottom-right
        // corner with 10px offset and an opacity of 25%
        $img->place(
            storage_path($this->watermarkPath),
            'bottom-right',
            20,
            20,
            35
        );

        $img->save();

        return $next($image);
    }
}

In this step, we load the image then use the "place()" function to add a water mark image on top of it controlling the position, padding and opacity of the watermark image. 

Adding New Steps to the Pipeline

Now all we need to do is add the new steps to our pipeline.

<?php

namespace App\Console\Commands;

use App\Dto\ProcessedImage;
use App\Pipelines\AddWaterMark;
use App\Pipelines\ApplyFilters;
use App\Pipelines\ConvertToWebP;
use App\Pipelines\GenerateThumbnail;
use App\Pipelines\ScaleImage;
use Illuminate\Console\Command;
use Illuminate\Pipeline\Pipeline;

class OptimizeImages extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'app:optimize-images';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        $pathToImage = storage_path('app/public/image.jpg'); // or any other path

        $image = new ProcessedImage($pathToImage);
        $processedImage = app(Pipeline::class)
            ->send($image)
            ->through([
                ConvertToWebP::class,
                ScaleImage::class,
                AddWaterMark::class,
                ApplyFilters::class,
                GenerateThumbnail::class,
            ])
            ->thenReturn();

        // do something with $processedImage

        $this->info('Image optimized');
    }
}

Notice that we made sure to add the "GenerateThumbnail" step at the end to make sure the thumbnail captures all the steps that have been done in the other steps. 

When you run that pipeline on an image like this:

It will export an image like this:

A smaller version will be generated from this image as a thumbnail as well. 

Code Implementations

You can find all code implementation for the whole project including all the pipeline steps implemented in this article on this Github repository.


Conclusion

Laravel Pipelines offer a powerful and flexible way to manage complex workflows by breaking them into modular, reusable steps. In this article, we demonstrated how to use Pipelines to build an image optimization workflow, using the capabilities of the Intervention Image package to transform images step by step.

From converting images to WebP format and resizing them to generating thumbnails and applying filters, the pipeline approach ensures each operation remains clean and focused. This modularity makes your workflows easier to extend, debug, and maintain.

Beyond image processing, the same concept can be applied to various scenarios in your Laravel applications, such as data validation, content transformations, or even business process automation. 

If you haven't used pipelines in your application yet, I'd highly encourage you to! 

Happy coding! 🤘

Share this post.
Liked that? Subscribe to our newsletter for more
Ship fast & don't reinvent the wheel

Build your SaaS using SaaSykit

SaaSykit is a SaaS boilerplate that comes packed with all components required to run a modern SaaS software.

Don't miss this

You might also like