Файлы и S3 в Laravel: чек-лист перед продакшеном
Локально загрузка файлов работает, а на проде отдаёт приватные ссылки навсегда, тормозит на выгрузке и роняет память на больших файлах. Разбираем, что проверить в конфиге дисков до релиза.

Приложение на Laravel 11.x загружает аватары и документы. Локально всё летает: Storage::put(), Storage::url(), картинка в браузере. На проде с S3 всплывает три вещи сразу — приватные объекты отдают ссылку, которая через сутки вернёт 403; выгрузка отчёта на 800 МБ кладёт воркер по памяти; а половина сгенерированных URL ведёт на http:// вместо https:// и ломается за прокси. Ниже — чек-лист по конфигурации файловых дисков, который стоит пройти до релиза, а не в ночь инцидента.
Формат — практика: у вас уже есть загрузка файлов, вопрос в том, как её довести до боевого S3-совместимого хранилища (AWS S3, Yandex Object Storage, MinIO) без сюрпризов. Инвалидацию CDN и обработку изображений не трогаем — здесь только диски, видимость и генерация ссылок.
Один диск на всё — первая ошибка
В config/filesystems.php по умолчанию есть диски local, public и s3. Соблазн — завести один s3 и складывать туда и публичные аватары, и приватные договоры. На проде это бьёт по видимости объектов: либо всё публично (утекают документы), либо всё приватно (аватар не грузится без подписи).
Правильнее — минимум два диска с разной видимостью:
// config/filesystems.php, Laravel 11.x
'disks' => [
// публичные файлы: аватары, обложки
's3_public' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET_PUBLIC'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => 'public',
'throw' => true, // бросать исключение вместо тихого false
],
// приватные файлы: документы, экспорты
's3_private' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET_PRIVATE'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => 'private',
'throw' => true,
],
],
Флаг 'throw' => true — то, что многие пропускают. По умолчанию методы фасада Storage при ошибке возвращают false, и запись, которая не удалась из-за прав или сети, проходит незамеченной до момента, когда пользователь не найдёт свой файл.
Приватные ссылки: временные, а не постоянные
Для приватного диска Storage::url() либо вернёт неработающий URL, либо (если бакет открыт) отдаст объект всем. Для приватных объектов нужен подписанный временный URL:
use Illuminate\Support\Facades\Storage;
// ссылка живёт 15 минут
$url = Storage::disk('s3_private')->temporaryUrl(
'contracts/2024/act-152.pdf',
now()->addMinutes(15)
);
Срок подписи выбирают под сценарий. Ссылка в письме, которое пользователь откроет через день, не должна жить 15 минут; ссылка на скачивание из личного кабинета — наоборот, не должна жить неделю. Практика: короткий срок плюс редирект через свой контроллер, который проверяет права и генерирует свежую подпись на каждый запрос.
Постоянная публичная ссылка на приватный документ — это не удобство, а утечка, которую невозможно отозвать: URL уже в истории браузера, в логах прокси и в чужой переписке.
Большие файлы: не читать целиком в память
Экспорт на сотни мегабайт через Storage::get() плюс return response($content) загружает весь файл в оперативную память воркера. Три параллельных скачивания — и PHP упирается в memory_limit. Для отдачи используйте потоковую выгрузку, для приёма — загрузку по частям.
Потоковая отдача из приватного диска через контроллер:
use Illuminate\Support\Facades\Storage;
public function download(string $path)
{
// проверка прав опущена намеренно — добавьте свою
abort_unless(Storage::disk('s3_private')->exists($path), 404);
// отдаём поток, не загружая файл в память целиком
return Storage::disk('s3_private')->download($path);
}
Метод download() у S3-драйвера возвращает потоковый ответ. Для очень больших файлов и активной раздачи лучше не проксировать байты через своё приложение вовсе, а отдать пользователю временную подписанную ссылку и увести трафик мимо PHP.
Приём больших файлов — отдельная проблема: PHP ограничен upload_max_filesize и post_max_size в php.ini, а веб-сервер — своими лимитами (в Nginx это client_max_body_size). Проверьте эти три значения до релиза: файл на 200 МБ упрётся в них раньше, чем дойдёт до вашего кода.
HTTPS и path-style: почему ссылки ломаются за прокси
Если приложение стоит за балансировщиком, который терминирует TLS, PHP видит запрос как http и генерирует ссылки на http://. Лечится настройкой TrustProxies — в Laravel 11.x доверенные прокси настраиваются в bootstrap/app.php через $middleware->trustProxies(). Точный синтаксис для вашей версии сверьте в разделе документации о доверенных прокси.
Второй момент — use_path_style_endpoint. AWS S3 работает с virtual-hosted стилем (bucket.s3.region.amazonaws.com). MinIO и часть S3-совместимых хранилищ требуют path-style (endpoint/bucket/key). Перепутаете — получите ошибки резолва или 403. Значение выносите в .env и выставляйте под конкретное хранилище.
Сравнение подходов к отдаче файлов
| Подход | Нагрузка на PHP | Контроль прав | Когда выбирать |
|---|---|---|---|
Публичный URL (Storage::url) | Нет | Нет | Открытые файлы: аватары, обложки |
Временный URL (temporaryUrl) | Минимальная | На момент генерации | Приватные файлы, трафик минуя PHP |
| Потоковая отдача через контроллер | Средняя | Полный, на каждый запрос | Строгий аудит доступа, небольшие файлы |
Загрузка в память + response() | Высокая | Полный | Не выбирать для больших файлов |
Секреты и права доступа
Ключи от бакета живут только в переменных окружения, не в репозитории и не в config/*.php напрямую. В коде — только env() внутри конфига и заглушки вида YOUR_ACCESS_KEY в примерах.
На стороне облака выдайте ключу минимальные права: доступ к нужным бакетам, а не ко всему аккаунту. Если хранилище поддерживает IAM-политики — ограничьте операции (чтение, запись, удаление) по префиксам путей. Утекший ключ с правами на весь аккаунт и утекший ключ на один бакет с чтением — разница между инцидентом и катастрофой.
Чек-лист перед релизом
- Разделены публичный и приватный диски, у каждого явно задана
visibility. - Для приватных файлов ссылки генерируются через
temporaryUrl, а неurl. - У S3-дисков стоит
'throw' => true, ошибки записи не проглатываются. - Большие файлы отдаются потоком или подписанной ссылкой, не через
get()в память. - Проверены
upload_max_filesize,post_max_sizeи лимит тела запроса на веб-сервере. - Настроены доверенные прокси — ссылки генерируются с
https. - Значение
use_path_style_endpointсоответствует хранилищу (AWS vs MinIO). - Ключи только в окружении; права ключа ограничены нужными бакетами.
- Проверена реальная запись и чтение на боевом бакете, а не только локально.
Что делать дальше
После релиза следите за двумя вещами. Первая — время ответа эндпоинтов, отдающих файлы: рост говорит о том, что байты идут через PHP там, где стоило бы отдать подписанную ссылку. Вторая — пиковое потребление памяти воркеров при выгрузках. Если тема доходит до раздачи статики на масштаб — следующий шаг это CDN перед бакетом и настройка заголовков кеширования, но это уже отдельный разбор.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel — File Storage, Laravel — Requests: Trusted Proxies
Частые вопросы
У меня другая версия Laravel, конфиг подойдёт?
temporaryUrl бросает исключение — что не так?
Файл на 150 МБ не загружается, ошибки в логах нет
Ссылки генерируются с http вместо https, хотя сайт на https
Можно ли хранить приватные файлы в публичном бакете и просто не давать ссылку?
MinIO вместо AWS — что менять?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.