Implementing Thumbnail Generation (Thumbnails) in Multiple Sizes
Uploaded images need to be shown in different contexts — product card, article preview, avatar, OG tag. Storing original and resizing on-demand with each request — bad idea: CPU goes to rendering, latency grows. Right approach — generate fixed-size variants on upload and serve static.
Approaches to Storing Variants
Two stable architecture variants:
Eager generation — all sizes created immediately on file upload. Suitable when set of sizes is stable and doesn't change. Simpler delivery logic, no cache misses.
Lazy generation — size generated on first request, then cached. Suitable for dynamic projects where needed sizes are unknown beforehand.
For most projects, eager approach with fixed config is enough.
Tool Stack
For PHP/Laravel — intervention/image library based on GD or Imagick. Imagick preferred: better EXIF handling, wider format support, more accurate color profile work.
composer require intervention/image
// config/image.php
return [
'driver' => 'imagick', // or 'gd'
];
For Node.js — sharp (libvips under the hood), fastest option on market:
npm install sharp
Size Configuration
Store sizes in separate config, not scattered across code:
// config/thumbnails.php
return [
'sizes' => [
'thumb' => ['width' => 150, 'height' => 150, 'fit' => 'crop'],
'small' => ['width' => 320, 'height' => null, 'fit' => 'width'],
'medium' => ['width' => 640, 'height' => null, 'fit' => 'width'],
'large' => ['width' => 1280, 'height' => null, 'fit' => 'width'],
'og' => ['width' => 1200, 'height' => 630, 'fit' => 'crop'],
],
];
fit: crop — crops preserving aspect ratio by center. fit: width — scales by width, height recalculated.
Generation Service
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class ThumbnailService
{
public function generateAll(UploadedFile $file, string $basePath): array
{
$original = Image::make($file);
$sizes = config('thumbnails.sizes');
$paths = [];
// Save original
$ext = $file->getClientOriginalExtension();
$hash = sha1_file($file->getRealPath());
$originalPath = "{$basePath}/{$hash}.{$ext}";
Storage::disk('public')->put($originalPath, (string) $original->encode());
$paths['original'] = $originalPath;
foreach ($sizes as $name => $params) {
$img = clone $original;
if ($params['fit'] === 'crop') {
$img->fit($params['width'], $params['height'], function ($constraint) {
$constraint->upsize(); // don't enlarge small images
});
} else {
$img->resize($params['width'], $params['height'], function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
}
$sizePath = "{$basePath}/{$hash}_{$name}.webp";
Storage::disk('public')->put($sizePath, (string) $img->encode('webp', 85));
$paths[$name] = $sizePath;
}
return $paths;
}
}
Generate directly to WebP — supported by all modern browsers, 25–35% smaller than JPEG at same visual quality. Quality 85 — good balance.
Model Integration
// app/Models/Media.php
protected $casts = [
'variants' => 'array',
];
public function getUrlAttribute(): string
{
return Storage::url($this->path);
}
public function variantUrl(string $size): ?string
{
return isset($this->variants[$size])
? Storage::url($this->variants[$size])
: null;
}
In controller on upload:
public function store(Request $request, ThumbnailService $thumbnails): JsonResponse
{
$request->validate(['image' => 'required|image|max:10240']);
$file = $request->file('image');
$paths = $thumbnails->generateAll($file, 'images/' . date('Y/m'));
$media = Media::create([
'path' => $paths['original'],
'variants' => $paths,
'size' => $file->getSize(),
'mime' => $file->getMimeType(),
]);
return response()->json(['media' => $media]);
}
Regeneration on Config Change
If variant set changes, old files need regeneration. Artisan command:
// app/Console/Commands/RegenerateThumbnails.php
public function handle(ThumbnailService $thumbnails): void
{
Media::whereNotNull('path')
->chunkById(100, function ($chunk) use ($thumbnails) {
foreach ($chunk as $media) {
// Get original from storage, regenerate
$stream = Storage::disk('public')->readStream($media->path);
$temp = tempnam(sys_get_temp_dir(), 'thumb_');
file_put_contents($temp, stream_get_contents($stream));
$uploadedFile = new \Illuminate\Http\UploadedFile(
$temp, basename($media->path)
);
$dir = dirname($media->path);
$paths = $thumbnails->generateAll($uploadedFile, $dir);
$media->update(['variants' => $paths]);
unlink($temp);
$this->info("Regenerated: {$media->id}");
}
});
}
Runs once:
php artisan thumbnails:regenerate
Estimated Timeline
Setting up library, size config, generation service — 4–6 hours. Model integration, uploader, regeneration command — another 3–4 hours. If queue needed (background generation via Job) — plus 2 hours.







