В 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"
}
Всё это было при попытках по-максимуму оставить коробочный функционал обработки. В итоге пришёл к тому, что свой обработчик работает стабильнее.