DEV Community

Bianca Rus
Bianca Rus

Posted on

Optimizing images stored on S3 with ShortPixel: a Laravel walkthrough

If your Laravel app lets users upload images and sends them straight to S3, there is a good chance you are storing files much larger than they need to be.

This is easy to miss. The upload works, the image renders, and nobody thinks about it again. But over time, unoptimized JPEGs and PNGs can quietly increase page weight, CDN traffic, and storage costs.

In this walkthrough, we will build a small image optimization pipeline for a Laravel app:

User upload ──▶ S3 ──▶ queued Laravel job ──▶ ShortPixel ──▶ optimized image ──▶ S3
Enter fullscreen mode Exit fullscreen mode

The idea is simple: store the upload quickly, return a response to the user, then optimize the image in the background and overwrite the S3 object with the smaller version.

This keeps uploads fast while still making sure the files you serve are not larger than they need to be.

We will cover:

  1. Setting up the S3 disk
  2. Creating a small ShortPixel service
  3. Uploading images from a Laravel controller
  4. Moving optimization to a queued job
  5. Handling private S3 buckets
  6. A few production notes around retries, idempotency, and WebP

I am assuming Laravel 10/11 and PHP 8.2+.

1. Setup

First, install the AWS S3 Flysystem adapter:

composer require league/flysystem-aws-s3-v3 "^3.0"
Enter fullscreen mode Exit fullscreen mode

Your S3 disk will usually look something like this in config/filesystems.php:

'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'visibility' => 'public',
    ],
],
Enter fullscreen mode Exit fullscreen mode

And in .env:

AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=my-app-uploads

SHORTPIXEL_API_KEY=your-shortpixel-key
Enter fullscreen mode Exit fullscreen mode

For the main example, I will assume the uploaded image is publicly reachable after it lands in S3. That lets ShortPixel fetch it by URL, optimize it, and return the optimized version.

If your bucket is private, the same idea still works. We will adjust the upload and use temporary signed URLs later in the article.

2. A small ShortPixel service

ShortPixel's Reducer API accepts one or more image URLs and returns metadata for the optimized files. We will wrap that in a service so the rest of the app does not need to know about the HTTP request format.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class ShortPixelOptimizer
{
    private string $endpoint = 'https://api.shortpixel.com/v2/reducer.php';

    public function __construct(private readonly string $apiKey)
    {
    }

    public function optimizeUrl(string $url): string
    {
        $response = Http::timeout(45)
            ->acceptJson()
            ->post($this->endpoint, [
                'key' => $this->apiKey,
                'plugin_version' => 'LRV01',
                'lossy' => 1,
                'wait' => 30,
                'resize' => 0,
                'convertto' => '+webp',
                'refresh' => 0,
                'urllist' => [$url],
            ]);

        $response->throw();

        $payload = $response->json();
        $item = $payload[0] ?? null;

        if (! $item) {
            throw new RuntimeException('ShortPixel returned an empty response.');
        }

        $statusCode = (int) ($item['Status']['Code'] ?? 0);

        if ($statusCode !== 2) {
            throw new RuntimeException(
                'ShortPixel did not return an optimized image: '
                . ($item['Status']['Message'] ?? 'unknown error')
            );
        }

        $optimizedUrl = $item['LossyURL'] ?? null;

        if (! $optimizedUrl || $optimizedUrl === 'NA') {
            throw new RuntimeException('ShortPixel did not return a LossyURL.');
        }

        $optimizedResponse = Http::timeout(45)->get($optimizedUrl);
        $optimizedResponse->throw();

        return $optimizedResponse->body();
    }
}
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • lossy = 1 is usually a good default for strong compression with good visual quality.
  • lossy = 2 enables glossy compression, which is useful when you care more about preserving fine details.
  • convertto = '+webp' asks ShortPixel to optimize the original format and also generate a WebP version.
  • wait = 30 tells the API to wait up to 30 seconds for the result before returning.

For brevity, the service above only downloads the optimized original format from LossyURL. In a real app, you may also want to store WebPLossyURL as a second object in S3.

Now bind the service in a provider:

// AppServiceProvider::register()

use App\Services\ShortPixelOptimizer;

$this->app->singleton(ShortPixelOptimizer::class, function () {
    return new ShortPixelOptimizer(config('services.shortpixel.key'));
});
Enter fullscreen mode Exit fullscreen mode

And add the config entry:

// config/services.php

'shortpixel' => [
    'key' => env('SHORTPIXEL_API_KEY'),
],
Enter fullscreen mode Exit fullscreen mode

3. Uploading the image

The controller should do as little as possible: validate the image, store it, dispatch a job, and return the URL.

<?php

namespace App\Http\Controllers;

use App\Jobs\OptimizeS3Image;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ImageUploadController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'image' => ['required', 'image', 'max:10240'], // 10 MB
        ]);

        $path = $request->file('image')->storePublicly('uploads', 's3');

        OptimizeS3Image::dispatch($path);

        return response()->json([
            'url' => Storage::disk('s3')->url($path),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, the user gets a working image URL immediately. The optimization happens in the background.

The important detail is that the controller does not call the optimization API directly. Image optimization is network-bound and can take a few seconds, so it should not block the original upload request.

4. Optimizing in a queued job

Now we can create a job that reads the S3 object, sends its public URL to ShortPixel, downloads the optimized result, and overwrites the original object only if the optimized file is smaller.

<?php

namespace App\Jobs;

use App\Services\ShortPixelOptimizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;

class OptimizeS3Image implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public array $backoff = [30, 120, 300];

    public function __construct(public string $path)
    {
    }

    public function handle(ShortPixelOptimizer $optimizer): void
    {
        $disk = Storage::disk('s3');

        if (! $disk->exists($this->path)) {
            return;
        }

        $original = $disk->get($this->path);
        $originalSize = strlen($original);

        $url = $disk->url($this->path);

        $optimized = $optimizer->optimizeUrl($url);
        $optimizedSize = strlen($optimized);

        if ($optimizedSize > 0 && $optimizedSize < $originalSize) {
            $disk->put($this->path, $optimized, 'public');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run a queue worker locally with:

php artisan queue:work
Enter fullscreen mode Exit fullscreen mode

In production, use a real queue driver like Redis, SQS, or another worker-backed setup.

5. What about private S3 buckets?

The example above uses storePublicly(), which makes the uploaded object publicly reachable. That keeps the walkthrough simple because ShortPixel's Reducer API can fetch the image by URL.

If your application uses a private S3 bucket, switch the upload code from:

$path = $request->file('image')->storePublicly('uploads', 's3');
Enter fullscreen mode Exit fullscreen mode

to:

$path = $request->file('image')->store('uploads', 's3');
Enter fullscreen mode Exit fullscreen mode

Then, inside the queued job, generate a temporary signed URL instead of using the public object URL.

So this line:

$url = $disk->url($this->path);
Enter fullscreen mode Exit fullscreen mode

becomes:

$url = $disk->temporaryUrl(
    $this->path,
    now()->addMinutes(10)
);
Enter fullscreen mode Exit fullscreen mode

This gives ShortPixel enough time to fetch the image without making your bucket public.

When overwriting the optimized object in a private bucket, you should also remove the public visibility argument:

$disk->put($this->path, $optimized);
Enter fullscreen mode Exit fullscreen mode

So for private buckets, the relevant part of the job becomes:

$url = $disk->temporaryUrl(
    $this->path,
    now()->addMinutes(10)
);

$optimized = $optimizer->optimizeUrl($url);

if (strlen($optimized) > 0 && strlen($optimized) < strlen($original)) {
    $disk->put($this->path, $optimized);
}
Enter fullscreen mode Exit fullscreen mode

Another option is ShortPixel's POST Reducer API, which lets you upload the image directly in a multipart request instead of giving ShortPixel a URL. For S3-heavy apps, I usually prefer signed URLs because S3 remains the source of truth and the optimizer only gets short-lived read access.

6. Storing WebP too

The code above requests WebP generation with convertto = '+webp', but it only stores the optimized original format.

If you want to store WebP as well, you can read WebPLossyURL from the API response and save it next to the original object:

uploads/example.jpg
uploads/example.jpg.webp
Enter fullscreen mode Exit fullscreen mode

Then your frontend, CDN, or middleware can serve the WebP version to browsers that support it.

This can be a bigger win than compression alone, especially for large JPEG uploads.

7. Production notes

A few details are worth handling before using this pattern in a real app.

Track optimization state

If your images belong to a model, add fields like:

optimized_at
original_size
optimized_size
Enter fullscreen mode Exit fullscreen mode

This makes the job idempotent and helps you avoid spending credits on the same file repeatedly. You can also check this before dispatching the job if the image is attached to a database record.

Do not delete the upload before optimization succeeds

In this pipeline, the original file stays in S3 until the optimized version is ready. That is intentional.

If the API request fails, the queue retries. If all retries fail, the user still has a valid image. Worst case, it is just not optimized yet.

Be careful with very large backfills

If you already have thousands of images in a bucket, do not dispatch all jobs at once without rate limiting. List the bucket, dispatch jobs in chunks, and use queue concurrency limits so you do not overload your workers or hit API limits.

Decide how aggressive compression should be

Lossy compression is a good default for most user-uploaded photos. For portfolios, product photography, or other visual-heavy apps, glossy compression may be a safer default.

The right choice depends on your users and the kind of images they upload.

Consider keeping originals for sensitive workflows

This walkthrough overwrites the original object once a smaller optimized version is available. That is fine for many apps, but not all of them.

If users upload source assets, legal documents, medical images, design files, or anything where the original has long-term value, store the optimized version under a separate key instead of replacing the original. For example:

uploads/original/example.jpg
uploads/optimized/example.jpg
uploads/optimized/example.jpg.webp
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The pattern is straightforward:

  1. Upload the image to S3.
  2. Dispatch a queued job.
  3. Let ShortPixel optimize the image.
  4. Overwrite the S3 object only when the optimized version is smaller.

That gives you smaller image payloads without making users wait during upload.

If you want less application code, an image CDN approach can also make sense. For example, FastPixel can optimize and serve images at the edge, so your Laravel app does not need to run an upload-time optimization pipeline at all.

For a Laravel app that already stores uploads on S3, the API approach gives you more control. For teams that want the fewest moving parts, the CDN approach may be the simpler trade-off.

Either way, the goal is the same: stop serving images that are bigger than they need to be.

Top comments (0)