Вбудований API для роботи із зображеннями у Laravel добре справлявся із записом даних з версії 13.20: прийняти завантажений файл, трансформувати його та зберегти на диск. Проте шлях читання був менш зручним. Щоб віддати змінене зображення через HTTP, доводилося викликати toBytes(), створювати відповідь і вручну встановлювати тип контенту - три рядки шаблонного коду у кожному контролері.
Повернення зображення з маршруту
Laravel 13.25 робить клас Image сумісним із контрактом Responsable, тому екземпляр зображення можна повертати безпосередньо з маршруту чи контролера. У тому ж релізі з'явилися два пов'язані доповнення: Image::fromStream() та публічний метод toFormat(). Разом ці три можливості покривають більшість потреб для ендпоінтів із зображеннями.
use Illuminate\Support\Facades\Image;
Route::get('/avatars/{user}', function (User $user) {
return Image::fromStorage($user->avatar_path)
->cover(200, 200)
->toWebp()
->quality(80);
});
Це весь необхідний код. Фреймворк викликає toResponse(), який запускає конвеєр обробки, повертає оброблені байти зі статусом 200 і встановлює Content-Type на основі результату, а не вихідного файлу. Наведений маршрут поверне image/webp, навіть якщо збережений файл - JPEG, оскільки заголовок читається з того, що створив конвеєр.
Усе, що повертає Image, працює однаково: метод контролера, invokable-контролер або значення із замикання route model binding. Екземпляр залишається «лінивим» доти, доки щось не запросить байти, тому трансформація не виконується, коли фреймворк лише визначає тип відповіді.
Додавання заголовків кешування
Типова відповідь не містить заголовків кешування, що є правильним для фреймворка, але неправильним для ендпоінта, який змінює розмір зображення при кожному запиті. Викличте toResponse() самостійно, щоб додати власні заголовки:
Route::get('/avatars/{user}', function (Request $request, User $user) {
return Image::fromStorage($user->avatar_path)
->cover(200, 200)
->toWebp()
->quality(80)
->toResponse($request)
->setMaxAge(31536000)
->setPublic();
});
Метод toResponse() повертає Illuminate\Http\Response, тому доступний повний API відповідей: header(), setEtag(), setLastModified() та інші. Поєднайте тривалий max-age з URL, що змінюється при оновленні зображення (хеш у шляху або query-параметр на основі updated_at моделі), і браузери перестануть запитувати після першого разу.
Масштабування через кешування на диску
Для проєктів із реальним трафіком зміна розміру при кожному запиті - це робота, яку ви виконуєте знову і знову. Підхід, що масштабується: записати похідний файл при першому запиті та віддавати його з диска надалі:
Route::get('/thumbs/{photo}', function (Request $request, Photo $photo) {
$path = "thumbs/{$photo->id}-{$photo->updated_at->timestamp}.webp";
if (! Storage::disk('public')->exists($path)) {
Image::fromStorage($photo->path)
->cover(400, 400)
->toWebp()
->quality(80)
->storeAs('thumbs', basename($path), 'public');
}
return Storage::disk('public')->response($path);
});
Включення мітки часу у назву файлу означає, що оновлене фото створює новий шлях, тому старі мініатюри перестають використовуватися без потреби інвалідувати кеш.
Динамічні формати через toFormat()
Раніше ендпоінт, що приймає формат із запиту, потребував match-блоку для перетворення рядка на відповідний виклик методу. Тепер toFormat() публічний і приймає формат безпосередньо:
Route::get('/photos/{photo}.{format}', function (Photo $photo, string $format) {
return Image::fromStorage($photo->path)
->scale(width: 1200)
->toFormat($format)
->quality(80);
})->where('format', 'webp|avif|jpg');
Допустимі значення: webp, jpg, jpeg, png, gif, avif, heic, heif та bmp, де heif нормалізується до heic. Будь-яке інше значення викине ImageException із форматом у повідомленні, що дасть 500, а не 404, тому обмежте параметр у маршруті, як показано вище, або валідуйте значення перед передачею. Цей самий метод лежить в основі optimize() - версії для випадків, коли потрібно встановити якість одним викликом.
Це робить AVIF-ендпоінт із резервним форматом коротким. Віддайте той формат, який запросив клієнт, і дозвольте елементу <picture> вирішити, який URL завантажить браузер.
Створення із потоку
Метод Image::fromStream() створює екземпляр із потокового ресурсу, що покриває джерела, які не охоплюють інші фабричні методи:
$image = Image::fromStream(Storage::disk('s3')->readStream($path));
Читання є лінивим. fromStream() обгортає ресурс у замикання та не торкається його до запуску конвеєра, тому створення екземпляра, який ви не використаєте, нічого не коштує. Потік без даних викине ImageException із повідомленням "Invalid stream image data." у момент використання, а не при створенні.
Поряд із fromPath(), fromStorage(), fromUpload(), fromUrl(), fromBytes() та fromBase64(), потоковий варіант призначений для будь-чого, дескриптор чого ви вже маєте: тіло php://input на ендпоінті прямого завантаження, файл із zip-архіву або потік від іншої бібліотеки.
Повноцінний приклад ендпоінта
Поєднання всіх трьох можливостей - ендпоінт для зображень, що приймає ширину та формат, читає з S3 і кешує на рік:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
Route::get('/media/{media}', function (Request $request, Media $media) {
$validated = $request->validate([
'w' => ['integer', 'between:32,2000'],
'format' => ['in:webp,avif,jpg'],
]);
return Image::fromStream(Storage::disk('s3')->readStream($media->path))
->scale(width: $validated['w'] ?? 800)
->toFormat($validated['format'] ?? 'webp')
->quality(80)
->toResponse($request)
->setMaxAge(31536000)
->setPublic();
})->middleware('signed');
Дві деталі варто зберігати. Ширина обмежена, оскільки невалідований розмір на публічному ендпоінті - це запрошення запитувати зміну розміру до 20 000 пікселів. А маршрут підписаний, що запобігає генерації довільних варіантів за ваш рахунок у сховищі. Підписані маршрути Laravel дають це через виклик signedRoute() у в'юхі.
Корисні матеріали
Усі три зміни були внесені Caleb White у pull request'ах #61111, #61109 та #61110. Повні примітки до релізу Laravel 13.25 доступні в офіційному анонсі, де також описано паузу всіх черг та новий UI для artisan dev.