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

URL: https://dmeshcheryakov.ru/blog/laravel-file-storage/
Раздел: Laravel
Теги: Laravel, Files, S3, Images
Опубликовано: 2026-06-08
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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

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

## Как устроена работа с файлами в Laravel

Filesystem в Laravel — это абстракция над хранилищем: один и тот же код работает с локальной папкой, с публичным диском или с 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 |
| Thumbnails | Jobs + очереди |
| Полное решение | Spatie Media Library |

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

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

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

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

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

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

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

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