S3 в Laravel: типичные ошибки хранения файлов
Публичные ссылки, которые возвращают 403, гигабайты в оперативке при загрузке архива и утечка приватных файлов через угадываемый URL — разбираем частые ошибки работы с файловым хранилищем в Laravel и как их не допустить.

Приложение на Laravel работает локально, но после переезда на S3 картинки перестают открываться, Storage::url() отдаёт ссылку с localhost, а загрузка большого файла кладёт контейнер по памяти. Всё это — не баги фреймворка, а следствие того, как настроены диски и что именно вы передаёте в фасад Storage. Разбираем на Laravel 11.x и пакете league/flysystem-aws-s3-v3 ^3.0, какие ошибки повторяются чаще всего и чем каждая из них платится.
Диски и драйверы: где всё начинается
Laravel работает с файлами через абстракцию Flysystem. В config/filesystems.php вы описываете диски, каждый диск — это драйвер (local, public, s3) плюс его настройки. Код обращается к диску по имени: Storage::disk('s3')->put(...). Идея в том, чтобы поменять хранилище правкой конфига, не трогая бизнес-логику. На практике абстракция протекает ровно в тех местах, где разработчик считает её волшебной.
Storage::url() не делает HTTP-запрос и не проверяет, доступен ли файл. Он просто склеивает строку из настроек диска. Если ссылка не открывается — проблема в конфигурации диска или в правах на бакет, а не в методе.
Ошибка 1. Путаница public и приватного бакета
Самая частая история. Разработчик загружает файл на диск s3, зовёт Storage::disk('s3')->url($path), вставляет ссылку в <img> — и получает 403 Access Denied от S3.
Причина: бакет приватный (это дефолт и правильное поведение для большинства данных), а url() вернул прямую ссылку вида https://bucket.s3.region.amazonaws.com/path без подписи. Такой объект S3 отдаст только по временной подписанной ссылке.
Что делать, зависит от природы файла:
- Файл действительно публичный (аватар, обложка товара) — тогда либо настройте отдельный публичный бакет и раздачу через CDN, либо храните такие файлы отдельно от приватных. Делать весь бакет публичным ради аватарок — способ однажды выложить наружу и договоры пользователей.
- Файл приватный (счёт, скан паспорта, экспорт данных) — отдавайте временную ссылку через
temporaryUrl(), а не постоянную.
// Приватный файл: подписанная ссылка на 5 минут
$url = Storage::disk('s3')->temporaryUrl(
$path,
now()->addMinutes(5)
);
// В шаблоне используем именно $url, а не Storage::url($path)
Проверка, что получилось: подписанная ссылка содержит параметры X-Amz-Signature и X-Amz-Expires. Если их нет — вы всё ещё зовёте url(), а не temporaryUrl().
Ошибка 2. Приватные файлы за угадываемым путём
Даже с приватным бакетом можно утечь. Типичный путь загрузки:
// Опасно: имя предсказуемо
$path = 'invoices/' . $user->id . '/' . $invoice->id . '.pdf';
Storage::disk('s3')->put($path, $content);
Если где-то в приложении такой файл отдаётся публичной ссылкой (например, публичный бакет или проксирующий контроллер без проверки прав), сосед перебором invoice->id вытащит чужие счета. Права на объект решают, но полагаться только на них рискованно.
Дайте файлам непредсказуемые имена и проверяйте владельца при выдаче:
use Illuminate\Support\Str;
$path = 'invoices/' . $user->id . '/' . Str::uuid() . '.pdf';
Storage::disk('s3')->put($path, $content);
// Ссылку на скачивание строим только после проверки,
// что $invoice->user_id === auth()->id()
Ошибка 3. Чтение большого файла в память
Пользователь загружает архив на 800 МБ, а вы делаете так:
// Загружает весь файл в оперативку
$content = Storage::disk('s3')->get($path);
return response($content);
При нескольких одновременных запросах PHP-процессы съедают память и падают с Allowed memory size exhausted. То же самое при загрузке: put($path, $file->get()) тянет весь загружаемый файл в строку.
Правильно — работать потоком. Для отдачи используйте Storage::download() или response()->streamDownload(); для загрузки — putFile, который читает из временного файла порциями:
// Отдача: стрим, память не растёт с размером файла
return Storage::disk('s3')->download($path, 'archive.zip');
// Загрузка через putFile — Flysystem льёт потоком
$path = Storage::disk('s3')->putFile('uploads', $request->file('archive'));
Для очень крупных объектов настройте multipart-загрузку через опции клиента S3 — Flysystem умеет её через нижележащий AWS SDK, но конкретные ключи опций смотрите в актуальной документации league/flysystem-aws-s3-v3, они менялись между мажорными версиями.
Ошибка 4. Тесты и локальная разработка бьют по боевому бакету
В .env прописан продовый бакет, тесты создают и удаляют файлы прямо там. Одно неудачное Storage::deleteDirectory() в тесте — и вы чистите чужие данные.
В тестах подменяйте диск фейком:
use Illuminate\Support\Facades\Storage;
Storage::fake('s3');
// теперь put/get/exists работают в памяти, S3 не трогается
Storage::disk('s3')->assertExists($path);
А локально держите отдельный диск local или локальный S3-совместимый сервис (MinIO) с отдельными ключами. Боевые ключи в .env разработчика — отдельная беда, о которой ниже.
Ошибка 5. Кастомный endpoint без нужных опций
При работе с MinIO, Yandex Object Storage, VK Cloud или другим S3-совместимым хранилищем прямая ссылка часто ломается: клиент строит URL в стиле https://bucket.endpoint/ (virtual-hosted), а сервис ждёт https://endpoint/bucket/ (path-style). Результат — 404 или SSL-ошибка на неправильном хосте.
Для таких хранилищ в конфиге диска s3 задаются endpoint и флаг path-style. Точные имена ключей конфигурации диска сверяйте с документацией вашей версии Laravel и Flysystem — набор опций use_path_style_endpoint и endpoint поддерживается, но передаются они в блок диска, а не глобально. Не подставляйте наугад: неверный ключ молча игнорируется, и вы будете час искать, почему ничего не поменялось.
Что чем платится: сравнение способов выдать файл
| Способ | Когда подходит | Цена |
|---|---|---|
Публичный бакет + url() | Аватары, публичные картинки, статика | Файл виден всем, кто знает URL; риск выложить лишнее |
temporaryUrl() | Приватные файлы, разовое скачивание | Ссылка живёт ограниченно; клиент ходит в S3 напрямую |
Прокси через контроллер + download() | Нужна проверка прав на каждый запрос | Трафик идёт через ваш сервер, нагрузка на PHP и канал |
Проще говоря: публичное — через публичный бакет и CDN, приватное разовое — через temporaryUrl, приватное со сложной логикой доступа — через свой контроллер со стримом. Смешивать в одном бакете не стоит.
Секреты и ключи доступа
Ключи S3 живут только в .env и читаются через config('filesystems.disks.s3.key'), никогда не хардкодятся в коде и не коммитятся. В репозитории держите .env.example с заглушками вида AWS_ACCESS_KEY_ID=YOUR_KEY. Для доступа выдавайте IAM-политику с минимально нужными действиями на конкретный бакет, а не полный доступ ко всему аккаунту — тогда утёкший ключ ограничен одним хранилищем.
Что настроить дальше
- Разделите диски:
publicдля открытой статики (через CDN) иs3приватный — для документов. Не тащите всё в один бакет. - Поставьте на бакет lifecycle-правила для временных файлов, чтобы забытые загрузки не копили счёт за хранение.
- В мониторинге смотрите на память PHP-процессов при пиках загрузок — рост памяти линейно с размером файла означает, что где-то остался
get()вместо стрима. - Прогоните
grepпо проекту наStorage::urlи->get(: это два места, где чаще всего прячутся ошибки 1 и 3.
Дефектный акт по фото: Осмотр по фотографиям: классификация дефектов, ссылки на нормы, требования к устранению. Готовый акт в Word.
Спросить за 80 ₽Источники: Laravel — File Storage (официальная документация), league/flysystem-aws-s3-v3 (GitHub)
Частые вопросы
У меня Laravel 10, а не 11 — примеры подойдут?
temporaryUrl падает с ошибкой, что метод не поддерживается драйвером
Файл на публичном бакете всё равно отдаёт 403
Нужен ли Storage::fake, если тесты и так гоняются на отдельном бакете
Как отдать большой файл, но с проверкой прав на каждый запрос?
Работает ли всё это с MinIO и российскими S3-хранилищами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.