Главная/Статьи/Работа с файлами в Laravel — S3, оптимизация изображений

Работа с файлами в Laravel — S3, оптимизация изображений

Загрузка файлов, хранение в S3, оптимизация изображений на лету. Intervention Image, Spatie Media Library, очереди для обработки.

ДМ
Дмитрий Мещеряков
📅 8 июня 2026 г.📖 6 мин чтения

Загрузка файлов — то место, где приложение принимает данные, полностью контролируемые пользователем, и сохраняет их на диск. Отсюда простое правило, определяющее всю тему: любой загруженный файл считается враждебным, пока не доказано обратное.

Разберём работу с дисками, S3 и очередями — и параллельно то, что отличает рабочую загрузку от опасной.

Filesystem в Laravel

Laravel абстрагирует работу с файлами через Filesystem. Можно переключаться между локальным хранилищем и S3 без изменения кода.

php
// Один и тот же код работает везде
Storage::disk('local')->put('file.txt', 'content');
Storage::disk('s3')->put('file.txt', 'content');
💡 Совет

Диски: local — папка storage/app, public — storage/app/public с симлинком, s3 — Amazon S3 или совместимые (Minio, DigitalOcean Spaces).

Настройка S3

bash
composer require league/flysystem-aws-s3-v3 "^3.0"
bash
# .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=your-bucket
AWS_URL=https://your-bucket.s3.eu-central-1.amazonaws.com
php
// config/filesystems.php
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),  // Для Minio/DO Spaces
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        'throw' => false,
    ],
],

Загрузка файлов

Контроллер

php
// app/Http/Controllers/FileController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller
{
    public function upload(Request $request)
    {
        $request->validate([
            'file' => 'required|file|max:10240', // 10MB
        ]);
        $file = $request->file('file');
        // Автоматическое имя (hash)
        $path = $file->store('uploads', 'public');
        // Своё имя
        $path = $file->storeAs(
            'uploads',
            time() . '_' . $file->getClientOriginalName(),
            'public'
        );
        return response()->json([
            'path' => $path,
            'url' => Storage::disk('public')->url($path),
        ]);
    }
    public function uploadToS3(Request $request)
    {
        $request->validate([
            'file' => 'required|file|max:102400', // 100MB
        ]);
        $file = $request->file('file');
        $path = $file->store('uploads/' . date('Y/m'), 's3');
        return response()->json([
            'path' => $path,
            'url' => Storage::disk('s3')->url($path),
        ]);
    }
}

Прямая загрузка в S3 (Presigned URL)

php
// Для больших файлов — загрузка напрямую в S3
public function getPresignedUrl(Request $request)
{
    $request->validate([
        'filename' => 'required|string',
        'content_type' => 'required|string',
    ]);
    $key = 'uploads/' . date('Y/m') . '/' . uniqid() . '_' . $request->filename;
    $client = Storage::disk('s3')->getClient();
    $command = $client->getCommand('PutObject', [
        'Bucket' => config('filesystems.disks.s3.bucket'),
        'Key' => $key,
        'ContentType' => $request->content_type,
    ]);
    $presignedUrl = $client->createPresignedRequest($command, '+30 minutes');
    return response()->json([
        'url' => (string) $presignedUrl->getUri(),
        'key' => $key,
    ]);
}
⚠️ Важно

Три правила безопасной загрузки, которые нужно применять всегда.

Никогда не используйте оригинальное имя файла. getClientOriginalName() — строка от клиента, и в ней может быть что угодно, включая ../ и управляющие символы. Генерируйте имя сами (Str::uuid()), а исходное — если оно нужно для скачивания — храните отдельным полем в базе.

Проверяйте тип по содержимому, а не по расширению. Валидация mimes:jpg,png в Laravel опирается на реальный MIME-тип файла, и это правильно; а вот самописная проверка по getClientOriginalExtension() обходится переименованием. shell.php с расширением .jpg — классика.

Загруженные файлы не должны выполняться. Директория загрузок либо вне корня сайта (диск local вместо public), либо с запретом на выполнение скриптов в конфиге веб-сервера. Если файлы отдаются через контроллер, а не напрямую, эта проблема снимается сама.

Оптимизация изображений

Intervention Image

bash
composer require intervention/image-laravel
php artisan vendor:publish --provider="Intervention\Image\Laravel\ServiceProvider"
php
use Intervention\Image\Laravel\Facades\Image;
class ImageController extends Controller
{
    public function upload(Request $request)
    {
        $request->validate([
            'image' => 'required|image|max:5120',
        ]);
        $file = $request->file('image');
        // Оптимизация
        $image = Image::read($file);
        // Ресайз с сохранением пропорций
        $image->scaleDown(width: 1200);
        // Конвертация в WebP
        $encoded = $image->toWebp(quality: 80);
        // Сохранение
        $filename = uniqid() . '.webp';
        $path = 'images/' . date('Y/m') . '/' . $filename;
        Storage::disk('public')->put($path, $encoded);
        return response()->json([
            'url' => Storage::disk('public')->url($path),
        ]);
    }
    public function uploadWithThumbnails(Request $request)
    {
        $request->validate([
            'image' => 'required|image|max:10240',
        ]);
        $file = $request->file('image');
        $baseName = pathinfo($file->hashName(), PATHINFO_FILENAME);
        $folder = 'images/' . date('Y/m');
        $image = Image::read($file);
        // Original (max 2000px)
        $original = $image->scaleDown(width: 2000)->toWebp(90);
        Storage::disk('s3')->put("{$folder}/{$baseName}_original.webp", $original);
        // Large (1200px)
        $large = $image->scaleDown(width: 1200)->toWebp(85);
        Storage::disk('s3')->put("{$folder}/{$baseName}_large.webp", $large);
        // Medium (600px)
        $medium = $image->scaleDown(width: 600)->toWebp(80);
        Storage::disk('s3')->put("{$folder}/{$baseName}_medium.webp", $medium);
        // Thumbnail (300px, crop)
        $thumb = $image->cover(300, 300)->toWebp(75);
        Storage::disk('s3')->put("{$folder}/{$baseName}_thumb.webp", $thumb);
        return response()->json([
            'original' => Storage::disk('s3')->url("{$folder}/{$baseName}_original.webp"),
            'large' => Storage::disk('s3')->url("{$folder}/{$baseName}_large.webp"),
            'medium' => Storage::disk('s3')->url("{$folder}/{$baseName}_medium.webp"),
            'thumb' => Storage::disk('s3')->url("{$folder}/{$baseName}_thumb.webp"),
        ]);
    }
}

Обработка в очереди

php
// app/Jobs/ProcessUploadedImage.php
namespace App\Jobs;
use App\Models\Image as ImageModel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
class ProcessUploadedImage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public int $tries = 3;
    public int $timeout = 120;
    public function __construct(
        public ImageModel $imageModel,
        public string $tempPath
    ) {}
    public function handle(): void
    {
        $file = Storage::disk('local')->get($this->tempPath);
        $image = Image::read($file);
        $folder = 'images/' . date('Y/m');
        $variants = [
            'original' => ['width' => 2000, 'quality' => 90],
            'large' => ['width' => 1200, 'quality' => 85],
            'medium' => ['width' => 600, 'quality' => 80],
            'thumb' => ['width' => 300, 'quality' => 75, 'crop' => true],
        ];
        $urls = [];
        foreach ($variants as $name => $settings) {
            $processed = clone $image;
            if ($settings['crop'] ?? false) {
                $processed->cover($settings['width'], $settings['width']);
            } else {
                $processed->scaleDown(width: $settings['width']);
            }
            $encoded = $processed->toWebp($settings['quality']);
            $path = "{$folder}/{$this->imageModel->id}_{$name}.webp";
            Storage::disk('s3')->put($path, $encoded);
            $urls[$name] = Storage::disk('s3')->url($path);
        }
        // Обновляем модель
        $this->imageModel->update([
            'urls' => $urls,
            'processed_at' => now(),
        ]);
        // Удаляем временный файл
        Storage::disk('local')->delete($this->tempPath);
    }
    public function failed(\Throwable $exception): void
    {
        $this->imageModel->update(['processing_error' => $exception->getMessage()]);
        Storage::disk('local')->delete($this->tempPath);
    }
}

Spatie Media Library

Популярный пакет для управления файлами:

bash
composer require spatie/laravel-medialibrary
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
php
// app/Models/Product.php
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Product extends Model implements HasMedia
{
    use InteractsWithMedia;
    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('images')
            ->useFallbackUrl('/images/placeholder.jpg');
        $this->addMediaCollection('documents')
            ->singleFile(); // Только один файл
    }
    public function registerMediaConversions(Media $media = null): void
    {
        $this->addMediaConversion('thumb')
            ->width(300)
            ->height(300)
            ->sharpen(10);
        $this->addMediaConversion('large')
            ->width(1200)
            ->performOnCollections('images');
    }
}
php
// Использование
// Добавление файла
$product->addMedia($request->file('image'))
    ->toMediaCollection('images');
// Из URL
$product->addMediaFromUrl('https://example.com/image.jpg')
    ->toMediaCollection('images');
// Получение
$product->getFirstMediaUrl('images');           // Original
$product->getFirstMediaUrl('images', 'thumb');  // Thumbnail
$product->getMedia('images');                   // Все изображения
⚠️ Важно

Безопасность: Валидируйте типы файлов. Не доверяйте расширению — проверяйте MIME-тип. Сканируйте на вирусы критичные загрузки.

Итоги

ЗадачаРешение
Простая загрузка$file->store()
S3Настроить диск, то же API
Большие файлыPresigned URLs
ОптимизацияIntervention Image
ThumbnailsJobs + очереди
Полное решениеSpatie Media Library

Чек-лист, по которому я проверяю загрузку файлов перед выкладкой.

Имя файла генерируется на сервере. Пользовательское — только в базе, для показа.

Тип проверяется по содержимому. Расширение ничего не значит.

Есть ограничение размера — и на двух уровнях. Валидация в Laravel даст понятную ошибку, но до неё файл уже будет принят PHP: настройки upload_max_filesize и post_max_size должны быть согласованы, иначе большой файл превращается в невнятную ошибку без сообщения.

Обработка изображений — в очереди. Изменение размера тяжёлого изображения занимает секунды и память; в веб-запросе это либо таймаут, либо исчерпанный memory_limit. Заодно проверьте, что задача переживает падение: файл сохранён, миниатюры генерируются потом, и их отсутствие не должно ломать отображение.

S3 для продакшена, если приложение работает больше чем на одном сервере. Не ради масштабирования само по себе, а потому что локальный диск на двух серверах означает, что половина запросов не найдёт файл.

Удаление файла при удалении записи. Осиротевшие файлы — тихая утечка дискового пространства, которую обнаруживают через год по счёту за хранилище.

⚠️ Важно

Отдельно про библиотеки обработки изображений. Они работают с недоверенными данными и исторически являются источником уязвимостей — специально сформированное изображение может привести к выполнению кода в обработчике. Отсюда: держите библиотеки обновлёнными, обрабатывайте изображения в фоновом процессе с ограничениями по памяти и времени, и не давайте пользователю контролировать параметры обработки (размеры, формат) без валидации по белому списку.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.