В Laravel есть удобные API ресурсы, с которыми легко и приятно работать в области трансформации данных для ответа на запрос. Но что делать когда возникает необходимость изменить их структуру в соответствии с бизнес-потребностями? Разберёмся вместе!
Вводная
Laravel предоставляет возможность транформации данных для ответа на запрос при помощи API ресурсов. По-умолчанию ответ будет иметь примерно следующий вид для возврата одного инстанса:
{ "data": { "id": 123, "title": "Some title" } }
Для коллекций:
{ "data": [ { "id": 123, "title": "Some title" } ] }
И для пагинатора:
{ "data": [ { "id": 123, "title": "Some title" } ], "links": { "first": "http://localhost:8000/?page=1", "last": "http://localhost:8000/?page=2", "prev": null, "next": "http://localhost:8000/?page=2" }, "meta": { "current_page": 1, "from": 1, "last_page": 2, "links": [ { "url": null, "label": "« Previous", "active": false }, { "url": "http://localhost:8000/?page=1", "label": "1", "active": true }, { "url": "http://localhost:8000/?page=2", "label": 2, "active": false }, { "url": "http://localhost:8000/?page=2", "label": "Next »", "active": false } ], "path": "http://localhost:8000/", "per_page": 2, "to": 2, "total": 3 } }
И данный ответ выглядит уже устрашающе в случая, когда Laravel работает в режиме API и фронтенд к нему разрабатывается снаружи другой командой.
Преобразование snake_case в camelCase
В последнее время всё больше приложений переходят на именование ключей в формате camelCase. Не будем вдаваться в разговоры насколько это оправдано, просто примем факт.
В случае с данными всё просто - при создании ресурсов пишем имена ключей в нужном стиле и радуемся. Но как быть с пагинатором?
Мне известно три пути решения проблемы: очень костыльный, условно неудобный и мудрёный.
Костыльный способ
Пример костыльного метода, который я видел, некоторых людей приводит в ужас. Просто посмотрите на это:
<?php public function index(Request $request) { $items = Some::paginate(); $resource = SomeResource::collection($items); $result = $this->convertToArray($resource->toArray($request)); return response()->json($result); } protected function convertToArray(array $items): array { $result = []; foreach ($items as $key => $value) { $newKey = Str::camel($key); $result[$newKey] = is_array($value) ? $this->convertToArray($value) : $value; } return $result; }
Здесь даже говорить ничего не надо - все проблемы на лицо. Поэтому перейдём к неудобному способу.
Условно неудобный
Laravel позволяет легко изменять ключи пагинатора путём объявления метода paginationInformation в классе ресурса. Для удобства можно вынести его в общий абстрактный класс и наслаждаться.
<?php public function paginationInformation($request, $paginated, $default) { $default['links']['custom'] = 'https://example.com'; return $default; }
Такой подход довольно простой в реализации, полностью соответствует логике фреймворка и не требует дополнительных затрат по сопровождению. Но почему он неудобный?
Всё дело в том, что если придёт задача на изменение структуры ресурсов, этот метод придётся обрабатывать вручную и переписывать. Именно в этом случае в одном из проектов появился третий способ - мудрёный.
Мудрёный способ
Мудрёный он потому, что помимо превращения имён ключей в camelCase возникла бизнес-потребность дополнительно оборачивать ключи массивов в дополнительный ключ.
Так, например, если обычная коллекция имеет следующую структуру:
{ "data": [ { "id": 123, "title": "Some title" } ] }
То по новым условиям нужно возвращать в такой:
{ "data": { "somes": [ { "id": 123, "title": "Some title" } ] } }
Где somes - это множественная форма имени ключа и у каждого ресурса она своя.
Спросите зачем? Ответ прост - мета-данные. Периодически возникают потребности отдать какие-либо жёстко связанные именно с этим ответом данные, когда проще объединить их в один ответ нежели запрашивать из двух мест. В данном случае имена ключей не будут конфликтовать, т.к. внутри data у каждого будет, скажем так, своя область видимости.
Вдобавок, можно расширять ответы, чем мы ниже и займёмся.
Бизнес-потребность
Итак, нам прилетела необходимость выполнения следующих преобразований в респонсах:
В случае возврата одного инстанса тело размещать внутри объекта
data;В случае возврата коллекции, в том числе пагинации:
тело размещать во вложенном в объект
dataключе, имя которого имеет множественное значение (например,users,posts);выводить объект пагинатора в определённом формате;
Приводить все даны на выходе к формату
Y-m-d\TH:i.
Конечный вид объектов должен соответствовать следующему виду:
{ "data": { "id": 123, "title": "Some title", "createdAt": "2024-03-16T18:54" }, "status": "OK" }
{ "data": { "somes": [ { "id": 123, "title": "Some title", "createdAt": "2024-03-16T18:54", "category": { "id": 1, "title": "Category name" } }, { "id": 456, "title": "Another title", "createdAt": "2024-03-16T18:55", "category": null } ] }, "pagination": { "total": 2, "perPage": 2, "currentPage": 1, "lastPage": 2 }, "status": "OK" }
Выглядит просто? Это просто так выглядит 🙂 Перейдём к разработке.
Подготовительные работы
Начнём с самого простого - добавить вывод ключа status со значением OK. На первый взгляд никаких проблем нет в том чтобы добавить его в переменную $additional ресурса:
<?php public $additional = [ 'status' => 'OK', ];
Но он не будет пробрасываться в коллекции потому что они вызывают кастомный класс и нам нужно либо создавать класс коллекции, либо плясать с бубном для проброса. Но создавать лишний класс ради класса не наш метод, поэтому модернизируем исходный ресурс.
Дополнительные данные
Итак, мы помним что нельзя просто взять и пробросить дополнительные параметры снаружи используя метод additional у ресурса, так как эти данные напрямую будут выводиться в ответе, а нам это не нужно. Поэтому создадим новые методы и положим их для удобства в трейт:
<?php namespace App\Concerns\Resources; trait HasAppends { protected array $appends = []; public function append(string $key, mixed $value): static { $this->appends[$key] = $value; return $this; } public function setAppends(array $appends): static { $this->appends = array_merge($this->appends, $appends); return $this; } protected function getAppend(string $key): mixed { return $this->appends[$key]; } }
Преобразование дат
Есть два способа преобразования дат в респонсах - ручное и автоматическое. С ручным всё понятно - для каждого объекта даты пробрасываем форматирование ->format('Y-m-d\TH:i'). Но что если таких дат много или вовсе можно забыть про это? В этом случае приходит на помощь автоформат.
Работает он рекурсивным методом по результату коллекции, вызываемой методом jsonSerialize:
<?php namespace App\Concerns\Resources; use Carbon\Carbon; trait HasJsonDates { protected static ?string $dateFormat = null; public function jsonSerialize(): array { return $this->resolveDates( parent::jsonSerialize() ); } protected function resolveDates(array $items): array { foreach ($items as &$item) { if (is_array($item)) { $item = $this->resolveDates($item); continue; } if ($item instanceof Carbon) { $item = $item->format($this->dateFormat()); } } return $items; } protected function dateFormat(): string { return static::$dateFormat ?? config('resources.date.format'); } }
В данном случае мы сразу закладываем возможность проброса изменённого формата для преобразования дат внутри конкретного ресурса, а статическое объявление позволяет взаимодействовать с коллекциями избегая создания дополнительных классов коллекций.
JSON флаги
Также вынесем в трейт определение флагов для JSON объектов:
<?php namespace App\Concerns\Resources; trait HasJsonOptions { public function jsonOptions(): int { return JSON_UNESCAPED_SLASHES ^ JSON_UNESCAPED_UNICODE; } }
Думаю, не стоит говорить о том, что данный код может быть также модифицирован в зависимости от того запускается ли он на продакшене или нет с целью добавления "красивого вывода" ( JSON_PRETTY_PRINT).
Реализация
Теперь можно переходить к основной реализации и начнём с трансформации ресурса.
Трансформация ресурса
Задачи, которые должен выполнять слой:
Добавление ключа
statusсо значениемOKв ответе;Преобразование дат по переданному формату;
Формирование объекта пагинации;
Формирование конечного JSON без преобразования кириллицы в юникод и отказ от добавления экранирования.
Меньше слов, больше дела:
<?php namespace App\Services\Resources; use App\Concerns\Resources\HasJsonDates; use App\Concerns\Resources\HasJsonOptions; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Support\Responsable; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\Response; class ResourceResponseService implements Responsable { use HasJsonDates; use HasJsonOptions; protected array $with = [ 'status' => 'OK', ]; public function __construct( protected JsonResource $resource, protected ?string $wrap, ?string $dateFormat ) { static::$dateFormat = $dateFormat; } public function with(array $data): static { $this->with = array_merge($this->with, $data); return $this; } public function toResponse($request): JsonResponse { return tap( response()->json( data: $this->wrap( $this->resource->resolve($request), $this->resource->with($request), $this->resource->additional ), status: $this->status(), options: $this->jsonOptions() ), function ($response) use ($request) { $response->original = $this->resource->resource; $this->resource->withResponse($request, $response); } ); } protected function wrap(mixed $data, array $with, array $additional): array { Arr::set($result, $this->wrapper(), $this->resolveData($data)); return array_merge($result, $additional, $this->withPagination(), $this->with, $with); } protected function resolveData(mixed $data): array { if ($data instanceof Collection) { $data = $data->all(); } return $this->resolveDates($data); } protected function wrapper(): string { return 'data' . ($this->wrap ? '.' . $this->wrap : ''); } protected function status(): int { if ($this->resource->resource instanceof Model && $this->resource->resource->wasRecentlyCreated) { return Response::HTTP_CREATED; } return Response::HTTP_OK; } protected function isPagination(): bool { return $this->resource->resource instanceof LengthAwarePaginator; } protected function withPagination(): array { return $this->isPagination() ? $this->paginationInformation($this->resource->resource) : []; } protected function paginationInformation(LengthAwarePaginator $paginated): array { return [ 'pagination' => [ 'total' => $paginated->total(), 'perPage' => $paginated->perPage(), 'currentPage' => $paginated->currentPage(), 'lastPage' => $paginated->lastPage(), ], ]; } }
Думаю, нет необходимости в том чтобы расписывать что должна делать каждая строчка, поэтому перейдём дальше
Трансформация коллекции
<?php namespace App\Http\Resources; use App\Concerns\Resources\HasAppends; use App\Services\Resources\ResourceResponseService; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Str; final class Collection extends JsonResource { use HasAppends; public function __construct( mixed $resource, protected string $collects, protected ?string $wrapKey, protected ?string $dateFormat ) { parent::__construct($resource); } public function toResponse($request): JsonResponse { return (new ResourceResponseService( $this, $this->wrapper(), $this->dateFormat ))->toResponse($request); } public function toArray(Request $request): array { return $this->resource instanceof Paginator ? $this->forwardAppends($this->resource->items())->toArray() : $this->forwardAppends($this->resource)->toArray(); } protected function forwardAppends(mixed $items): BaseCollection { return collect($items)->map( fn (mixed $item) => (new $this->collects($item))->setAppends($this->appends) ); } protected function wrapper(): string { return $this->wrapKey ?? Str::of($this->collects) ->afterLast('\\') ->beforeLast('Resource') ->snake() ->plural() ->toString(); } }
По пробросу внешних данных внутрь ресурсов при помощи метода setAppends понятно, как и про вызов выше описанного абстрактного ресурса для трансформации. Но что за wrapper? Для чего он?
Метод wrapper разрешает нам не указывать значение атрибута $wrapKey в классе коллекции и, используя механику Laravel, мы сами его сформируем из названия класса. Например:
Класс | Имя |
|
|
|
|
|
|
|
|
Либо можно явно указать. То же самое касается и свойства $dateFormat. Например:
<?php namespace App\Http\Resources; use Illuminate\Http\Request; /** @mixin App\Models\Some */ class SomeResource extends Resource { protected static ?string $wrapKey = 'qwerty'; protected static ?string $dateFormat = DATE_ATOM; public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'createdAt' => $this->created_at, ]; } }
На выходе получим нечто вроде:
{ "data": { "qwerty": [ { "id": 123, "title": "Some title", "createdAt": "2024-03-16T18:54:12+00:00" } ] }, "status": "OK" }
Основной абстрактный класс
Пришло время объединить два вышеуказанных хелпера в основной абстрактный класс API ресурса:
<?php namespace App\Http\Resources; use App\Concerns\Resources\HasAppends; use App\Concerns\Resources\HasJsonDates; use App\Services\Resources\ResourceResponseService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; abstract class Resource extends JsonResource { use HasAppends; use HasJsonDates; protected static ?string $wrapKey = null; public static function collection($resource): Collection { return new Collection($resource, static::class, static::$wrapKey, static::$dateFormat); } public function toResponse($request): JsonResponse { return (new ResourceResponseService( $this, null, static::$dateFormat ))->with($this->with)->toResponse($request); } }
Данный класс позволяет избежать создания лишних классов коллекций. Осталось заменить наследования у созданных ресурсов и будет счастье!
<?php namespace App\Http\Resources; use Illuminate\Http\Request; /** @mixin App\Models\Some */ class SomeResource extends Resource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, ]; } }
Шаблоны Laravel
Laravel Stubs
Опубликуйте шаблоны при помощи консольной команды:
php artisan stub:publish
Файлы появятся в папке stubs в корне проекта. В файле stubs/resource.stub измените наследуемый класс на созданный. Лишние шаблоны можно удалить при необходимости.
Laravel Idea
Это платный плагин для PhpStorm и с ним всё проще:

С какими проблемами пришлось столкнуться
В ходе разработки данного решения сталкивался с такими проблемами, когда ключ статуса пробрасывался внутрь каждого вложенного ресурса, как и уведомления, не исключая пагинацию. В итоге получался такой монстр:
{ "data": { "somes": [ { "id": 123, "title": "Some title", "createdAt": "2024-03-16T18:54", "category": { "data": { "id": 1, "title": "Category name" }, "pagination": { "total": 2, "perPage": 2, "currentPage": 1, "lastPage": 2 }, "status": "OK" } } ] }, "pagination": { "total": 2, "perPage": 2, "currentPage": 1, "lastPage": 2 }, "status": "OK" }
Всё это было при попытках по-максимуму оставить коробочный функционал обработки. В итоге пришёл к тому, что свой обработчик работает стабильнее.
