Fix Your Slow Laravel App: Automate Image Conversion to AVIF

speed-up laravel application,avif,image compression

Supercharge your PHP & Laravel app! Optimize and compress images to boost performance and save storage space, all while cutting costs!

The problem

If your application allows users to upload images freely, you might end up having the problem of users uploading images of 2MB or even more. I’ve seen it countless times, even working with more experienced users.

Yes, I know you can set the validation in Laravel using the max attribute, but that’s a limitation with nowadays phones, almost all images are greater than 2MB, which is bad for the application speed. And if you set the validation, that’s bad for UX. And people could leave your application for something else.

People are in a rush to finish the work, they don’t have time to resize their images. Let’s use the power of automation to help them and by helping them we help us.

Say for example you have a recipe application, where the user can upload the images to the gallery. When they create the recipe you will use those images for the recipes on the front end. If you let the users upload the images freely with no limitations, you will clutter your application pretty fast.

Then you need to add caching, and lazy-load of the images, etc. But let’s try to fix the Root Cause not the Symptoms!

The solution

Automation! The solution of our age. Automation is everything. This will allow the user to upload the big, uncompressed images in the following formats: jpeg, png, jpg, and webp. After they do we will automatically convert them to the AVIF format, with a lower quality. If you are not familiar with AVIF images, read my post: Improve your website loading speed by using AVIF images.

Supported image formats:

PNG
WEBP
JPG
JPEG

Creating the Image Trait

To respect the dry principle, since we want this conversion in the multiple places of our application, we can create ImageTrait.

Then we need to create three functions to keep our code clean:

<?php

namespace App\Traits;
trait ImageTrait
{
  /**
   * Convert, save new AVIF image, delete the old one.
   * @param $source
   * @param $quality
   * @param $removeOld
   * @return string
   */
  private function imageToAvif($source, $quality = 80, $removeOld = false): string
  {
  
  }

  /**
   * Converts the existing image to the GD object based on the type of the image
   * @param string $source
   * @param string $imageType
   * @return \GdImage|false
   */
  private function makeImageObject(string $source, string $imageType): \GdImage|false
  {

  }
  
  /**
   * Fixes up the rotation on the GD Image object
   * @param \GdImage $image
   * @param int $imageOrientation
   * @return \GdImage|false
   */
  private function keepImageOrientation(\GdImage $image, int $imageOrientation): \GdImage|false
  {
  
  }
}

Then in our main function, we need to get the type of the image, and we can do that by using the getimagesize() function and passing the $source of the image which is our path to the image.

The function will return the array and we are interested in the mime key of that array which holds the image type.

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
}

Next up, if the user has uploaded the AVIF image, we will just return the path to the image:

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    if ($imageType == 'image/avif') {
      return $source;
    }
}

After we have the image type, and we are sure that it’s not the AVIF format, we need to create the path for our new image. Using the pathinfo() function we will get the image name and the directory name. Then, using string concatenation we can concatenate the strings to form a new path for the AVIF image.

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
}

To prepare the data for the keepImageOrientation() function we will use the exif_read_data() function that takes our old path as the source.

🪳Heads up!

This doesn’t work for PNG, GIF, or WebP images. It works only for the JPEG images.

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
    
    $imageInfo = null;
    if ($imageType == 'image/jpeg') {
      $imageInfo = exif_read_data($source);
    }
}

To create a GdImage object we can use for our conversion we will use the makeImageObject() function to which we will pass the following arguments like so:

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
    
    $imageInfo = null;
    if ($imageType == 'image/jpeg') {
      $imageInfo = exif_read_data($source);
    }
    
    $image = $this->makeImageObject($source, $imageType);
}

After we create the image object we can restore the image orientation, by using our function keepImageOrientation() that we will create later. So we first check if $imageInfo is not null, and the Orientation key is not empty, then we call our keepImageOrientation() function and pass the GdImage object along with whatever the Orientation key value is.

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
    
    $imageInfo = null;
    if ($imageType == 'image/jpeg') {
      $imageInfo = exif_read_data($source);
    }
    
    $image = $this->makeImageObject($source, $imageType);
    
    if (!is_null($imageInfo) && !empty($imageInfo['Orientation'])) {
      // Fix the image rotation that could be messed up by the conversion
      $image = $this->keepImageOrientation($image, $imageInfo['Orientation']);
    }
}

Then we finally convert and save the new AVIF image. As you can see we are passing the $image (GdImage object) then the $destination (the new path that we created up above) and the $quality (int value from 0 to 100).

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
    
    $imageInfo = null;
    if ($imageType == 'image/jpeg') {
      $imageInfo = exif_read_data($source);
    }
    
    $image = $this->makeImageObject($source, $imageType);
    
    if (!is_null($imageInfo) && !empty($imageInfo['Orientation'])) {
      // Fix the image rotation that could be messed up by the conversion
      $image = $this->keepImageOrientation($image, $imageInfo['Orientation']);
    }
    
    imageavif($image, $destination, $quality);
}

After we are finished with this, we can go and remove the old image if the param $removeOld is true. And return the path to the new AVIF image to be saved to the database.

private function imageToAvif($source, $quality = 80, $removeOld = false): string
{
    $imageType = getimagesize($source)['mime'];
    
    if ($imageType == 'image/avif') {
      return $source;
    }
    
    $dir = pathinfo($source, PATHINFO_DIRNAME);
    $name = pathinfo($source, PATHINFO_FILENAME);
    
    $destination = $dir . DIRECTORY_SEPARATOR . $name . '.avif';
    
    $imageInfo = null;
    if ($imageType == 'image/jpeg') {
      $imageInfo = exif_read_data($source);
    }
    
    $image = $this->makeImageObject($source, $imageType);
    
    if (!is_null($imageInfo) && !empty($imageInfo['Orientation'])) {
      // Fix the image rotation that could be messed up by the conversion
      $image = $this->keepImageOrientation($image, $imageInfo['Orientation']);
    }
    
    imageavif($image, $destination, $quality);
    
    if ($removeOld) {
      unlink($source);
    }
    
    return $destination;
}

Since this is the main function, I wanted it explained first and then the other two.

Convert Uploaded Image to the GD Image

To manipulate the image in any way we need to convert it to the GD object. Let’s break down the makeImageObject() function:

private function makeImageObject(string $source, string $imageType): \GdImage|false
{
  return match ($imageType) {
    'image/jpeg' => imagecreatefromjpeg($source),
    'image/webp' => imagecreatefromwebp($source),
    'image/png' => $this->createFromPng($source),
    default => false,
  };
}

It takes two arguments $source (which is the path to the uploaded image) and then the $imageType (which is the image’s mime). Using match we return the appropriate function result based on the image type. And the default value is false because we are covering only three image types.

❗Heads up!

We set our validation in Laravel, like this:

'required|image|mimes:jpeg,png,jpg,webp|max:8000',

therefore we would never get to return false, it’s just a default return value. Because even if someone tries to upload the image of let’s say gif, validation kicks in and we reject it. It’s not that imageavif() can’t convert gif, it’s just that I didn’t found real purpose for it for this post.

As you can see we have one more function we need to create for the making of PNG GdImage object. For some PNG images you could use the following function only imagecreatefrompng($source); and it might work. But you also can get an error.

🪳Possible Error Ahead!

Some PNG images are palette images, as it appears. And imageavif() function doesn’t support them. It supports only true color images. So we need to do a bit more to create a GdImage object that imageavif() function needs to be able to convert them.

So, we are going to create this createFromPng() function in our trait like so:

  /**
   * @param string $source
   * @return \GdImage
   */
  private function convertPNG(string $source) :\GdImage
  {
    $image = imagecreatefrompng($source);
    imagepalettetotruecolor($image);
    imagealphablending($image, true);
    imagesavealpha($image, true);

    return $image;
  }

It takes the source, as previous function and converts the image to Gd Object but then it converts palette images to true color images. After that it also adds the alpha blending and saves that alpha blending using these two functions imagealphablending() and imagesavealpha(). Not to get into nitty-gritty details you can checkout the PHP docs for that.

One thing I noticed though, it’s not needed (meaning you won’t get an error) and the file size is the same.

Restore the Orientation for JPEG Images using PHP

Then we can look at the keepImageOrientation() function which takes two arguments: $image (GdImage object) and the $imageOrientation (int which is the orientation we get for the JPEG images).

private function keepImageOrientation(\GdImage $image, int $imageOrientation): \GdImage|false
{
  return match ($imageOrientation) {
    8 => imagerotate($image, 90, 0),
    3 => imagerotate($image, 180, 0),
    6 => imagerotate($image, -90, 0),
    default => $image,
  };
}

Once again, we utilize the match in PHP and return the result of the function based on the $imageOrientation parameter.
The image below explains the weird Orientation values that we get from exif_read_data() function.

JPEG orientation
JPEG orientation

Now I’ve never come across use cases for the other numbers but the ones that I included.

Use Image Trait in your Laravel project

Lastly, we just need to call our function in the controller when the user submits the image.

class ImageController extends Controller
{
  use ImageTrait;
  
  public function store(Request $request)
  {
    $request->validate([
      'image' => 'required|image|mimes:jpeg,png,jpg,webp|max:8000',
      'name' => 'nullable|string',
    ]);
    $user = User::find($request->user()->id);
    $fileName = str_replace(' ', '-', $request->file('image')->getClientOriginalName());

    if (!is_null($request->get('name'))) {
      $fileName = str_replace(' ', '-', $request->get('name'));
    }

    $filePath = $request->file('image')->storePubliclyAs("public/images/user-$user->id", $fileName);
    $dbFilePath = str_replace('public', 'storage', $filePath);
    $filePath = $this->imageToAvif($dbFilePath, 20, true);

    $newImage = Image::create([
      'path' => $filePath,
      'user_id' => $user->id,
    ]);
    
    if ($request->get('fullGallery')) {
      return to_route('images.index');
    }
    
    return Response::json(['status' => true, 'newImage' => new ImageResource($newImage)]);
  }
}

Firstly we store the image the user wants to upload, secondly, we make the change in the path of the image, lastly, we call our function and pass to it the path of the image, the quality that we want, and the option if we want to remove the old image.

Conclusion

That’s all folks, now you’ve successfully automated the image conversion and have a library of almost lossless compressed images.

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Scroll to Top