Laravel: оптимизация загруженных фото в очереди
JPEG или HEIC на 10–30 МБ не стоит конвертировать внутри HTTP-запроса. Контроллер сохраняет оригинал и ставит Job после commit, а воркер уже читает EXIF, исправляет ориентацию и создаёт WebP.
HTTP-запрос: только сохранить
$path = $request->file('image')->store('photos/originals', 'public');
$photo = DB::transaction(fn () => Photo::create([
'image' => $path,
'processing_status' => 'pending',
]));
OptimizePhoto::dispatch($photo->id)->afterCommit();
В Job передаётся ID записи, а не UploadedFile и не объект Imagick. Такие объекты нельзя надёжно сериализовать в очередь. afterCommit() снимает гонку, когда воркер стартует раньше появления строки в БД.
Процессор изображения
Пример рассчитан на intervention/image 4.x с драйвером Imagick:
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Imagick\Driver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
final class ImageProcessor
{
public function optimize(string $source, string $disk = 'public'): string
{
$storage = Storage::disk($disk);
$image = (new ImageManager(new Driver))
->decodePath($storage->path($source));
$image->orient()->scaleDown(width: 2400, height: 2400);
$directory = pathinfo($source, PATHINFO_DIRNAME);
$basename = pathinfo($source, PATHINFO_FILENAME);
$target = $directory.'/'.$basename.'.webp';
$storage->put(
$target,
(string) $image->encode(
new WebpEncoder(quality: 80, strip: true),
),
);
return $target;
}
}
orient() вызывается до удаления метаданных, иначе вертикальное фото с iPhone останется повёрнутым. scaleDown() не растягивает маленькие файлы.
Job с ограниченным retry
final class OptimizePhoto implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 300;
public array $backoff = [60, 300, 900];
public function __construct(public int $photoId) {}
public function handle(ImageProcessor $processor): void
{
$photo = Photo::find($this->photoId);
if (!$photo || !$photo->image) {
return;
}
$target = $processor->optimize($photo->image);
$photo->updateQuietly([
'optimized_image' => $target,
'processing_status' => 'ready',
]);
}
}
Исходник не удаляется до успешной записи WebP и обновления модели. Если Job упадёт между этими действиями, повтор просто перезапишет тот же target — операция остаётся идемпотентной.
Для полного pipeline EXIF читается до encode(... strip: true). Метаданные камеры и даты сохраняются выборочно, GPS — только если он действительно нужен продукту.
Проверка Imagick внутри контейнера
php -r 'var_dump(extension_loaded("imagick"));'
php -r 'print_r(Imagick::queryFormats("WEBP"));'
php -r 'print_r(Imagick::queryFormats("HEIC"));'
Наличие расширения не гарантирует поддержку HEIC: формат зависит от того, с какими delegates собран ImageMagick. Если queryFormats("HEIC") возвращает пустой массив, загрузку HEIC нужно отклонить ещё на валидации.
Что проверить
- вертикальный JPEG с EXIF Orientation отображается правильно;
- маленькое изображение не увеличивается, большое укладывается в 2400×2400;
- повтор одного Job не создаёт новые имена файлов;
- удаление или замена фото чистит оригинал, WebP и responsive-варианты;
- воркер перезапускается после deploy с новым кодом.
Настройка самого воркера разобрана в заметке «Очереди Laravel на Redis».