JSON-ресурс и API личного кабинета
Оглавление серии «Laravel 13 и PHP: портал «Заявка»»
Портал уже умеет выдавать Blade-страницы. Добавим JSON-чтение личного списка и карточки для другого интерфейса того же приложения. Результат урока — API Resource с явными полями и теми же правилами владельца. Таблица и policy не меняются только потому, что формат ответа стал JSON. Особенно важно не отправить внутренний attachment_path вместе с объектом модели.
Используем существующий session guard и web middleware того же origin. Это ограниченный API кабинета, а не публичный API с bearer token для внешних интеграций. Поэтому новые GET-маршруты находятся в web.php и получают auth. Sanctum, выпуск токенов и OAuth в архив не добавлены; для внешнего клиента потребовался бы отдельный договор идентификации и разрешений.
Явная сериализация
Создадим полный app/Http/Resources/TicketResource.php. title и status нужны компактной карточке, created_at передаётся стандартным строковым представлением даты. Body и частные пути не включаются в этот первый ресурс. Для подробного текста можно ввести отдельный ресурс после определения назначения поля клиенту.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'status' => $this->status,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
Документация API Resources Laravel описывает трансформацию модели и paginator. Для кабинета ресурс является внешним договором, который может оставаться устойчивым при добавлении столбцов базы. Если вернуть модель автоматически, новое поле хранилища способно неожиданно попасть к клиенту. Список разрешённых полей проще оценить при чтении кода.
Полный app/Http/Controllers/TicketApiController.php ограничивает список user_id и проверяет view карточки. Фильтр status совпадает с HTML-представлением, но выполняется самостоятельно для этого запроса.
<?php
namespace App\Http\Controllers;
use App\Http\Resources\TicketResource;
use App\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class TicketApiController extends Controller
{
public function index(Request $request)
{
$filters = $request->validate(['status' => ['nullable', 'in:open,closed']]);
$status = $filters['status'] ?? null;
$tickets = Ticket::query()->where('user_id', $request->user()->id)
->when($status, fn ($query) => $query->where('status', $status))
->orderByDesc('id')->paginate(20)->withQueryString();
return TicketResource::collection($tickets);
}
public function show(Ticket $ticket)
{
Gate::authorize('view', $ticket);
return new TicketResource($ticket);
}
}
Добавьте импорт TicketApiController и новые декларации. Полный routes к этому шагу включает обычные страницы, формы, частные файлы и API.
Route::get('/api/tickets', [TicketApiController::class, 'index'])->middleware('auth')->name('api.tickets.index');
Route::get('/api/tickets/{ticket}', [TicketApiController::class, 'show'])->whereNumber('ticket')->middleware('auth')->name('api.tickets.show');
Заголовок Accept и форма отказа
Клиент должен отправлять Accept application/json, если ожидает JSON и для ошибок. Иначе middleware входа или обычная валидация могут выбрать HTML-переход. Это не отказ сериализации самого ресурса, а переговоры о формате всего HTTP-запроса. Приложение должно различать успешную карточку, 401 входа, 403 права, 404 отсутствия и 422 неправильного status, не пытаясь разобрать любой ответ как успешный data.
Создадим полный API.md с договором. В нём приведено ожидаемое тело примера, а не записанный ответ реально выполненного сервера. Квитанции и PDF по-прежнему читаются контролируемыми маршрутами заявки, их внутренние пути не становятся полями API.
# Session API «Заявки»
Тот же origin и действующая серверная сессия; Accept: application/json.
GET /api/tickets?status=open&page=1: resource collection с data, links и meta.
GET /api/tickets/{id}: 200 {"data":{"id":1,"title":"Нужен доступ к макетам","status":"open","created_at":"...ISO-8601..."}}.
Это описание ожидаемого ответа; запросы не запускались.
Список всегда ограничен текущим пользователем. Карточка проверяет TicketPolicy view.
401: требуется вход; 403: запрещён объект; 404: объект отсутствует; 422: неверный фильтр.
Нет bearer token, регистрации API, cross-origin credentials или внешнего API-клиента.
Нет write API: создание, изменение, закрытие и вложения используют существующие защищённые HTML-формы.
Нет password, user_id, attachment_path, receipt path и полного body в ресурсе.
Пагинация остаётся частью договора
Resource collection получает paginator, поэтому структура ответа содержит больше, чем голый массив. Клиент читает data для элементов и метаданные для движения. Нельзя заменить paginate get, сохранив прежнее ожидание meta и links. На малом наборе такое изменение может казаться незаметным, но контракт следующей страницы уже будет нарушен. Представление и транспорт должны согласоваться независимо от числа текущих учебных строк.
Фильтр применяется до преобразования ресурса. Он не должен просматривать сериализованные двадцать записей и отбрасывать закрытые после paginator: тогда total и страницы перестали бы соответствовать видимому списку. Те же причины рассматривались в HTML, но здесь важен внешний клиент, который доверяет meta. Один объект paginator обеспечивает согласование, если условия builder заданны правильно.
Лишнее поле и право объекта
Полезный неправильный случай — вернуть $ticket toArray и удалить password, предполагая, что этого достаточно. У Ticket нет собственного password, зато может быть attachment_path или другой частный столбец. Внешний договор должен строиться по назначению каждого поля, а не исключать один всем известный секрет. В нашем resource явно разрешены four поля, и будущая миграция не расширяет этот список сама.
Другой неправильный вариант — считать JSON-адрес «служебным» и убрать Gate. Пользователь способен вызвать его непосредственно, даже если навигация показывает только Blade. Авторизация формата не существует отдельно от объекта: одинаковая заявка требует одинакового view по любому пути. Список также остаётся ограничен request user, а произвольный user_id query не используется.
Created_at представляет момент в выбранном формате, а не заранее локализованный русский текст. Клиент может показать дату в своей зоне, но должен понимать исходное смещение. Если продукту важно хранить отдельную бизнес-дату закрытия, её следует добавить по определению события. Текущее created_at обозначает создание заявки и не становится датой квитанции только потому, что статус closed.
Чтение не изменяет кабинет
Новые GET не отмечают уведомления прочитанными и не запускают квитанцию. Это чистые пути чтения. Если ресурс позже получит ссылку на действие, её переход не должен автоматически выполнять POST. Для write API придётся отдельно определить CSRF с session guard, Form Request, policy, ответ успеха и конфликт. В этом уроке нет скрытой реализации этих операций.
Перед последней главой проследите собственную заявку через HTML и JSON. Содержимое формы может быть богаче компактного ресурса, но право одно и то же. Затем сравните внутренний путь PDF и адрес скачивания: ни один не должен автоматически сериализоваться как свободный доступ. Следующий урок передаст весь портал с версиями, процессом очереди, частными дисками и списком результатов, которые необходимо подтвердить в собственной среде перед выпуском.
Частный API и HTTP-клиент
Пример браузерного клиента должен отправлять cookies того же origin и ожидать JSON. Ручной запрос без сессии не становится вошедшим только потому, что он знает id собственной старой заявки. Именно поэтому API не рассматривается как свободный каталог вымышленных строк. Сессионная идентификация и policy действуют при каждом чтении.
В API.md нет реального created_at из выполненной базы. Запись ISO-8601 обозначает формат договорённости, а не наблюдавшееся время создания. Когда читатель выполнит собственный seed, конкретные идентификаторы и даты могут отличаться. Клиент должен использовать полученные значения и не вшивать дату из учебного примера.
Если запрос возвращает HTML, сначала проверьте Accept и путь middleware. Не исправляйте это неэкранированным выводом HTML внутри JSON-карточки и не отключайте auth. Правильный формат отказа является частью переговоров HTTP, а право на объект остаётся серверным условием независимо от него.
Для самостоятельного воспроизведения результата «Выдать ограниченную карточку через тот же session guard» выберите lesson-15 в архиве исходников. Статус выполнения этого состояния: not_run.