Serving, caching and resizing S3-Hosted Images with Laravel

Jethro Solomon • April 30, 2023

I recently needed to serve scaled (resized) versions of images hosted in an Amazon S3 bucket. Access control was also needed, as the images uploaded by users should only be accessible to authorized users.

That said, I am going to share a method for both public and private images.

I'm going to add all of the code in the route callback for brevity, though I suggest you make use of a controller where necesary.

Public Images (no access control)

With this method, I am going to assume the image is hosted in an S3 bucket that you have already configured as a Laravel disk.

We will do 3 things:

  1. Fetch the image from S3
  2. Resize and save the image locally.
  3. Serve the static image.
// Add to the top of your route file.
use Illuminate\Support\Facades\Storage;

Route::get("/thumbnails/{imagename}", function ($imageName) {
    // Load image from S3.
    $file = Storage::disk('my-s3-bucket')->get('my-image-path/'.$imageName);

    // Ensure the image exists, else return a 404.
    if (! $file) {
        abort(404);
    }

    // Store it locally
    Storage::disk('public')->put('thumbnails/'.$imageName, $file);

    // Resize the locally stored image
    // ...resize code here...see end of article

    // Tell the browser to try again.
    return redirect(url("/thumbnails/{$imageName}"));
});

The above code will only be called if a local copy of the image does not already exist in our public directory. That means you save on AWS costs on fetching the image, and save on time + processing power to resize and serve the image.

If a copy of the image does not exist, our code will create a local copy, then redirect the browser to the static image.

Now let's look at an example of displaying images to only authorized users.

Private Images (with access control)

Once again, I will be cramming way too much logic into a route callback - use a controller.

With this example, we will still be caching the image, but not in our docroot or public folder. Instead, we serve the image with Laravel every time in order to do an access check. The resized images will be 'cached' in a local disk outside of the docroot.

This example will include the user ID in the URL, and store each user's images in a unique subdirectory.


// Add to the top of your route file. use Illuminate\Support\Facades\Storage; Route::get("/thumbnails/{userid}/{imagename}", function ($userId, $imageName) { // Do some access control. This is just an example. if (!request()->user()->id === $userId) { abort(403); } // Path of the image in our local disk. $local_path = 'thumbnails/'. $userId . '/' . $imageName; // If we don't have the image locally, let's fetch it. if (!Storage::disk('private')->exists($local_path)) { // Load image from S3. $file = Storage::disk('my-s3-bucket')->get('my-image-path/'. $userId . '/' . $imageName); // Ensure the image exists, else return a 404. if (! $file) { abort(404); } // Store it locally in our 'private' disk - outside the web root. Storage::disk('private')->put($local_path, $file); // Resize the locally stored image // ...resize code here...see end of article } // Serve the image directly from laravel. return Storage::disk('private')->response($local_path); });

That's it! Our thumbnails will be cached locally, and thus cut down calls to AWS.

If local storage becomes an issue (which is why you may be using AWS), you can routinely delete local copies when they reach a certain age.

Lastly, let's look at resizing the images once we have stored them.

Resizing Local Images

I used a packaged called Intervention Image to resize the local image.

Now, you could also resize the image before storing them on AWS, but I always prefer to keep the original files should I ever need to serve a larger version in the future.

Internvention Image comes with Laravel integration, so check out the official documentation to get up and running.

Once installed, you can resize a local copy of an image in any number of ways. In my case, my code looked similar to what you see below.

 // At the top of your file.
 use Intervention\Image\Facades\Image;
 use Illuminate\Support\Facades\Storage;

 // Scale the image (locally).
 $file_path = Storage::disk('public')->path('thumbnails/'.$imageName);
 $image = Image::make($file_path);

 // Resize to a max height and width, whilst maintaining the aspect ration, and
 // avoiding any upscaling.
 $image->resize(250, 250, function ($constraint) {
    $constraint->aspectRatio();
    $constraint->upsize();
 });

 $img->save();

Taking it Further

The code outlined here can be extended in many ways to fit your use-case.

For example, you may need several thumbnail sizes, in which case your image paths can include the thumbnail size. Then it's just a matter of conditionally applying different sizing based on a key.

Route::get("/thumbnails/{size}/{imagename}", function ($size, $imageName) {

}

That's it, easy short and sweet.