Реалізація генерації мініатюр (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', // or '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 години.







