Implementing Automatic Image Optimization (WebP/AVIF)
Unoptimized images — usually the main source of unnecessary traffic on sites. 4 MB PNG instead of 300 KB WebP — real story from any project where file upload was added without conversion. Backend automation solves problem regardless of what user uploads.
Formats and Their Application
WebP — supported by all browsers since 2020. Lossy compression 25–35% more efficient than JPEG, lossless 25–34% better than PNG. Good default option.
AVIF — based on AV1, even more efficient than WebP by 20–30%, especially on photos. Downsides: encoding slower (5–10× on libavif), Safari support appeared in v16.4. As of 2025, browser coverage ~93%.
Practical approach: generate both formats, serve via <picture> with type="image/avif" in first <source>.
Stack
For server processing, better sharp (Node.js) or Intervention Image with Imagick (PHP). For pure server optimization without framework — squoosh-cli or cjpeg/cwebp from Google as system utilities.
For PHP projects intervention/image works with Imagick, which supports WebP, AVIF (from ImageMagick 7.0.25+).
Check version on server:
convert --version
# ImageMagick 7.1.x ...
php -r "echo Imagick::getVersion()['versionString'];"
If Imagick version below 7 — AVIF unavailable. Then for AVIF use libavif via avifenc:
apt install libavif-bin
avifenc --speed 6 --quality 50 input.png output.avif
Optimization Service (PHP/Laravel)
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class ImageOptimizationService
{
private const WEBP_QUALITY = 82;
private const AVIF_QUALITY = 55; // AVIF scale different, 55 ≈ JPEG 85
private const MAX_WIDTH = 2560;
public function optimize(UploadedFile $file, string $storagePath): array
{
$img = Image::make($file);
// Don't enlarge small images
if ($img->width() > self::MAX_WIDTH) {
$img->resize(self::MAX_WIDTH, null, function ($c) {
$c->aspectRatio();
$c->upsize();
});
}
// Remove EXIF (private data + weight)
$img->orientate(); // apply EXIF orientation before stripping
$hash = hash('xxh3', file_get_contents($file->getRealPath()));
$dir = rtrim($storagePath, '/');
$result = [];
// WebP
$webpPath = "{$dir}/{$hash}.webp";
Storage::disk('public')->put(
$webpPath,
(string) $img->encode('webp', self::WEBP_QUALITY)
);
$result['webp'] = $webpPath;
// AVIF via avifenc if available, otherwise via Imagick
$avifPath = "{$dir}/{$hash}.avif";
if ($this->avifEncAvailable()) {
$result['avif'] = $this->encodeAvifViaCli(
$img, $avifPath, self::AVIF_QUALITY
);
} elseif ($this->imagickSupportsAvif()) {
$imagick = new \Imagick();
$imagick->readImageBlob((string) $img->encode('png'));
$imagick->setImageFormat('avif');
$imagick->setImageCompressionQuality(self::AVIF_QUALITY);
Storage::disk('public')->put($avifPath, $imagick->getImageBlob());
$result['avif'] = $avifPath;
}
return $result;
}
private function avifEncAvailable(): bool
{
return !empty(shell_exec('which avifenc 2>/dev/null'));
}
private function imagickSupportsAvif(): bool
{
return in_array('AVIF', (new \Imagick())->queryFormats('AVIF'), true);
}
private function encodeAvifViaCli(\Intervention\Image\Image $img, string $storagePath, int $quality): string
{
$tmpIn = tempnam(sys_get_temp_dir(), 'avif_in_') . '.png';
$tmpOut = tempnam(sys_get_temp_dir(), 'avif_out_') . '.avif';
file_put_contents($tmpIn, (string) $img->encode('png'));
exec("avifenc --speed 6 --quality {$quality} {$tmpIn} {$tmpOut} 2>&1");
Storage::disk('public')->put($storagePath, file_get_contents($tmpOut));
unlink($tmpIn);
unlink($tmpOut);
return $storagePath;
}
}
Background Processing via Job
AVIF conversion — slow operation (2–5 seconds per photo). Keep HTTP request open all that time — not needed. Pattern: save original first, respond immediately, run optimization in background.
// app/Jobs/OptimizeImageJob.php
class OptimizeImageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private int $mediaId,
private string $originalPath
) {}
public function handle(ImageOptimizationService $service): void
{
$media = Media::findOrFail($this->mediaId);
// Create UploadedFile from existing file
$fullPath = Storage::disk('public')->path($this->originalPath);
$file = new \Illuminate\Http\UploadedFile($fullPath, basename($fullPath));
$variants = $service->optimize($file, dirname($this->originalPath));
$media->update([
'variants' => array_merge($media->variants ?? [], $variants),
'optimized_at' => now(),
]);
}
}
Dispatch after original upload:
$media = Media::create(['path' => $originalPath, ...]);
OptimizeImageJob::dispatch($media->id, $originalPath)->onQueue('media');
Serving Correct Format
At Blade/frontend level use <picture>:
<picture>
@if($media->variantUrl('avif'))
<source srcset="{{ $media->variantUrl('avif') }}" type="image/avif">
@endif
<source srcset="{{ $media->variantUrl('webp') }}" type="image/webp">
<img src="{{ $media->url }}" alt="{{ $alt }}" loading="lazy" decoding="async">
</picture>
Nginx variant for automatic WebP serving without code changes (if file exists):
location ~* \.(jpe?g|png)$ {
add_header Vary Accept;
try_files
$uri.webp
$uri
=404;
}
Works only if WebP file lies next to original with .webp added to name.
Timeline
Setup and config stack, optimization service — 5–7 hours. Background Job, model integration, <picture> component — another 4–5 hours. Nginx variant without template changes — 1–2 hours separately.







