Реализация генерации миниатюр (thumbnails) в нескольких размерах
Загружаемые изображения нужно показывать в разных контекстах — карточка товара, превью статьи, аватар, OG-тег. Хранить оригинал и нарезать его на лету при каждом запросе — плохая идея: CPU уходит на рендеринг, латентность растёт. Правильный путь — генерировать набор фиксированных размеров в момент загрузки и отдавать статику.
Подходы к хранению вариантов
Два устойчивых варианта архитектуры:
Eager generation — все размеры создаются сразу при загрузке файла. Подходит, когда набор размеров стабилен и не меняется. Проще логика отдачи, нет промахов кэша.
Lazy generation — размер генерируется по первому запросу, затем кэшируется. Подходит для динамических проектов, где заранее неизвестно, какие размеры понадобятся.
На большинстве проектов достаточно eager-подхода с фиксированным конфигом.
Стек инструментов
Для PHP/Laravel — библиотека intervention/image на базе GD или Imagick. Imagick предпочтительнее: лучше обрабатывает EXIF, поддерживает более широкий спектр форматов, точнее работает с цветовыми профилями.
composer require intervention/image
// config/image.php
return [
'driver' => 'imagick', // или 'gd'
];
Для Node.js — sharp (libvips под капотом), самый быстрый вариант на рынке:
npm install sharp
Конфигурация размеров
Размеры лучше хранить отдельным конфигом, а не разбрасывать по коду:
// 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 — обрезает с сохранением пропорций по центру. fit: width — масштабирует по ширине, высота пересчитывается.
Сервис генерации
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 = [];
// Сохраняем оригинал
$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(); // не увеличивать маленькие изображения
});
} 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;
}
}
Генерируем сразу в WebP — формат поддерживается всеми актуальными браузерами, даёт 25–35% выигрыш в весе по сравнению с JPEG при том же визуальном качестве. Quality 85 — хороший баланс.
Интеграция с моделью
// 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;
}
В контроллере при загрузке:
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]);
}
Регенерация при изменении конфига
Если набор размеров меняется, старые файлы нужно перегенерировать. Artisan-команда:
// app/Console/Commands/RegenerateThumbnails.php
public function handle(ThumbnailService $thumbnails): void
{
Media::whereNotNull('path')
->chunkById(100, function ($chunk) use ($thumbnails) {
foreach ($chunk as $media) {
// Берём оригинал из storage, генерируем заново
$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}");
}
});
}
Запускается разово:
php artisan thumbnails:regenerate
Ориентировочные сроки
Настройка библиотеки, конфига размеров, сервиса генерации — 4–6 часов. Интеграция с моделью, загрузчик, команда регенерации — ещё 3–4 часа. Если нужна очередь (генерация в фоне через Job) — плюс 2 часа.







