Частное вложение и контролируемая загрузка
Оглавление серии «Laravel 13 и PHP: портал «Заявка»»
К заявке полезно приложить PDF с описанием задачи. Добавим одно частное вложение открытой заявки и контролируемое скачивание. Результат урока — файл вне public, проверка загрузки и право чтения через TicketPolicy. Путь в хранилище не выдаётся пользователю как публичная ссылка и не принимается из формы: клиент передаёт содержимое, а сервер выбирает имя.
Для ясного договора разрешим вложение один раз. Повторная попытка к уже заполненному attachment_path получает отказ и удаляет новый непринятый файл. Замена и удаление вложения потребовали бы отдельного правила согласования базы и файлов, особенно при конкурирующих запросах. В этой главе не маскируем такую задачу простым переписыванием пути старой записи.
Поле и проверка файла
Создадим полный database/migrations/2026_10_11_000400_add_attachment_to_tickets.php. Это новая миграция уже существующей таблицы, а не редактирование применённого определения tickets.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void { Schema::table('tickets', fn (Blueprint $table) => $table->string('attachment_path')->nullable()); }
public function down(): void { Schema::table('tickets', fn (Blueprint $table) => $table->dropColumn('attachment_path')); }
};
Полный StoreAttachmentRequest.php требует собственную открытую заявку и PDF не больше двух мегабайт согласно правилу max в килобайтах. mimes проверяет предполагаемый тип по содержимому; это не доказательство, что документ безопасен для любого последующего обработчика.
<?php
namespace App\Http\Requests;
use App\Models\Ticket;
use Illuminate\Foundation\Http\FormRequest;
class StoreAttachmentRequest extends FormRequest
{
public function authorize(): bool
{
$ticket = $this->route('ticket');
return $ticket instanceof Ticket && ($this->user()?->can('update', $ticket) ?? false);
}
public function rules(): array { return ['attachment' => ['required', 'file', 'mimes:pdf', 'max:2048']]; }
}
Документация проверки файлов Laravel описывает эти правила, а File Storage — store и частные диски. В нашем проекте local стандартного каркаса расположен вне публичной директории. Не создавайте storage link для attachments: это нарушило бы выбранный путь проверки права перед скачиванием.
Запись файла и ссылка базы
Создадим полный TicketAttachmentController.php. Файл сохраняется перед условной записью пути. Если база отклоняет условие или выбрасывает исключение, текущий непринятый файл удаляется и отказ передаётся дальше. Успех требует own, open и ещё пустой attachment_path непосредственно в SQL.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreAttachmentRequest;
use App\Models\Ticket;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
class TicketAttachmentController extends Controller
{
public function store(StoreAttachmentRequest $request, Ticket $ticket)
{
$path = $request->file('attachment')->store('attachments', 'local');
if (!$path) throw new \RuntimeException('Attachment storage failed');
try {
$changed = Ticket::query()->whereKey($ticket->id)->where('user_id', $request->user()->id)
->where('status', 'open')->whereNull('attachment_path')->update(['attachment_path' => $path]);
abort_if($changed === 0, 409, 'Вложение уже есть или заявка закрыта.');
} catch (\Throwable $error) {
Storage::disk('local')->delete($path);
throw $error;
}
return redirect()->route('tickets.show', $ticket)->with('status', 'Вложение принято.');
}
public function download(Ticket $ticket)
{
Gate::authorize('view', $ticket);
abort_unless($ticket->attachment_path && Storage::disk('local')->exists($ticket->attachment_path), 404);
return Storage::disk('local')->download($ticket->attachment_path, "ticket-{$ticket->id}.pdf");
}
}
Ниже точные новые маршруты и фрагмент show. Полные итоговые файлы включены в архив, импорт TicketAttachmentController добавлен туда же.
Route::post('/tickets/{ticket}/attachment', [TicketAttachmentController::class, 'store'])->whereNumber('ticket')->middleware('auth')->name('attachment.store');
Route::get('/tickets/{ticket}/attachment', [TicketAttachmentController::class, 'download'])->whereNumber('ticket')->middleware('auth')->name('attachment.download');
<h2>Вложение</h2>
@if($ticket->attachment_path)
<a href="{{ route('attachment.download', $ticket) }}">Скачать PDF</a>
@else
@can('update', $ticket)
<form method="post" enctype="multipart/form-data" action="{{ route('attachment.store', $ticket) }}">@csrf
<label>PDF до 2 МБ <input type="file" name="attachment" accept="application/pdf" required></label>
@error('attachment')<p role="alert">{{ $message }}</p>@enderror
<button type="submit">Приложить PDF</button>
</form>
@endcan
@endif
Ожидаемая допустимая загрузка создаёт случайное серверное имя в частном каталоге и записывает путь в свою открытую заявку. Карточка предлагает контролируемую ссылку. Клиентское accept лишь помогает выбору файла, а окончательное правило проверяет сервер. Если убрать enctype multipart, запрос не передаст файл ожидаемым способом; такое отсутствие не исправляется разрешением любого MIME или отключением required.
Две системы хранения
SQL-транзакция не откатывает файл автоматически. Наш catch очищает обычные обнаруженные отказы базы, но процесс может завершиться между store и update. Тогда останется непривязанный файл. Для рабочего портала нужна периодическая сверка частного каталога с сохранёнными путями и осторожное удаление только достаточно старых непривязанных объектов. Учебный контроллер не обещает полную атомарность диска и базы при любом сбое.
Два одновременных допустимых upload могут сохранить разные файлы, но условие whereNull позволяет принять только один путь. Проигравший обнаруженный запрос удаляет только свой файл. Не удаляем прежний attachment_path из маршрутного снимка: у операции нет права замены. Это ограничение упрощает согласованность и объясняет, почему кнопка формы исчезает после успешного принятия, но сервер всё равно повторяет условие.
Полезный неправильный случай — вернуть Storage url path для private local и считать адрес защитой. Если каталог окажется публичным, ссылка будет доступна без policy; если он частный, URL может вообще не работать. Правильный download сперва получает Ticket и проверяет view, затем читает известный серверу путь. Нельзя принимать произвольный path в query, иначе route превратится в общий механизм выдачи файлов.
Имя скачивания и содержимое
Оригинальное имя клиента не используется для хранения или ответа. Предсказуемое ticket-id.pdf удобно для пользователя и не содержит опасной клиентской строки пути. Сам документ может иметь собственное название внутри содержимого, но это не идентификатор заявки. Если продукт хочет сохранить исходное имя как подпись, его нужно ограничить и экранировать отдельно, не разрешая влиять на каталог хранилища.
Файл выдаётся как скачивание, а не вставляется в шаблон как доверенная разметка. В зависимости от рабочего сценария PDF может потребовать дополнительную обработку и ограничения среды, но текущая серия не открывает сторонний обработчик и не утверждает проверку содержимого на все угрозы. Существенный результат уже конкретен: частное хранение и право пользователя перед выдачей, без публичного обхода.
Перед API сравните три адреса: карточку, download и внутренний attachment_path. Первые два являются маршрутом с проверкой, третий служит данным сервера и не должен попадать во внешнюю сериализацию. Следующий урок создаст JSON-ресурс с явным списком полей, чтобы безопасная граница частного файла сохранилась при новом способе чтения кабинета.
Необязательное вложение и лимиты среды
Вложение не обязательно для создания заявки: оно добавляется отдельным маршрутом позднее. Поэтому ошибка PDF не отменяет уже созданное обращение и не стирает его body. Такой путь особенно полезен при большом файле или ограничении сервера. Пользователь может сначала описать проблему, а затем повторить корректную загрузку. Сам файл нельзя автоматически восстановить через old input браузера так же, как обычную строку; после отказа его нужно выбрать снова.
Лимит Form Request действует после того, как серверная среда приняла запрос. Ограничения PHP upload_max_filesize, post_max_size и веб-сервера могут остановить большой файл раньше. При собственной подготовке среды их необходимо согласовать с заявленным размером, не устанавливая все значения произвольно огромными. Ошибка более раннего слоя не доказывает, что правило mimes было выполнено.
Путь хранится в строковом поле базы, но не входит в fillable обычного Ticket. Контроллер upload использует своё серверное update и не расширяет форму редактирования текста. Если позже добавить attachment_path в UpdateTicketRequest, внешний клиент получил бы возможность менять ссылку без загрузки. Поэтому независимые операции должны сохранять собственный вход.
При закрытии заявки уже принятое вложение остаётся доступным владельцу по view. Запрещается только новая загрузка через update. Это соответствует роли документа как части обращения, а не временного элемента редактора. Если продукт требует иной срок хранения, его следует выразить отдельным правилом и согласовать с удалением физического файла.
Для самостоятельного воспроизведения результата «Хранить вложение вне public и проверять скачивание» выберите lesson-14 в архиве исходников. Статус выполнения этого состояния: not_run.