Вчера я опубликовал перевод статьи на тему оптимизации использования респонсов в Laravel с "простейшими" данными. То есть когда в ответ нужно отдать какое-то число, строку, массив или объект. Но что делать если приложение построено на использовании Json Resource? Или ещё больше - нужно изменить уровень вложенности данных, возвращаемых коллекцией? Давайте разбираться!
Начнём с вводных данных и, чтобы не плодить сущности, будем все примеры смотреть на одном конкретном ресурсе так как остальные будут следовать той же самой логике. Вот он:
<?php
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'firstName' => $this->first_name,
'lastName' => $this->last_name,
];
}
}Если мы вернём этот ресурс из контроллера, то на выходе получим объект со следующей структурой:
{
"data": {
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
}
}Возвращая коллекцию, вывод будет таким:
{
"data": [
{
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
}
]
}Вроде всё хорошо, но вот прилетела задача добавить в ответ "status": "OK". Сделать это можно двумя способами: первый - добавить вызов метода additional(['status' => 'OK']) в конструкторе или добавить определение переменной внутри класса:
<?php
return UserResource::collection($user)->additional(['status' => 'OK']);<?php
class UserResource extends Resource
{
public $with = [
'status' => 'OK'
];
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'firstName' => $this->first_name,
'lastName' => $this->last_name,
];
}
}На выходе получим объект {"data": ..., "status": "OK"}.
Вроде справились. А теперь приходит разнарядка что нужно в коллекциях обернуть ответ в какой-либо ключ, например, "users". Зачем? Чтобы можно было добавить ещё какие-то связанные данные в ответ. Таким образом, JSON на выходе должен выглядеть примерно так:
// для обычного объекта
{
"data": {
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
},
"status": "OK"
}
// для коллекции
{
"data": {
"users": [
{
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
}
]
},
"status": "OK"
}Но как это сделать? И как не забыть указывать тот самый ключ "users"? Что будет если забыть? Давайте разберёмся.
На случай "забытых ключей" можно поступить двумя способами - требовать его заполнение или использовать магию Laravel. Первый способ решается простой типизацией параметра, например, protected static string $dataKey;. В этом случае сам PHP вернёт ошибку при попытке чтения параметра без его инициализации. Но это стреляет в ногу в тех случаях, когда в ресурсах не нужно его указывать. Поэтому ниже рассмотрим второй способ - магию.
Laravel позволяет вызывать JSON ресурс как для конкретного набора данных, так и для коллекции, вызывая методы make и collection у одного объекта. Подкапотная магия устроена так, что если рядом с классом лежит такой же по названию, но с суффиксом Collection, то будет взят именно он. Например, UserResourceCollection будет использоваться при вызове UserResource::collection(). Мы учтём этот момент и вначале создадим общий класс для работы с коллекциями:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
final class Collection extends AnonymousResourceCollection
{
public function __construct(
mixed $resource,
string $collects,
protected ?string $dataKey
) {
$this->dataKey ??= $this->resolveDataKey($collects);
parent::__construct($resource, $collects);
}
public function toArray(Request $request): array
{
return [
'data' => [
$this->dataKey => $this->collection,
],
'status' => 'OK',
];
}
protected function resolveDataKey(string $collection): string
{
return Str::of($collection)
->afterLast('\\')
->beforeLast('Resource')
->snake()
->plural()
->toString();
}
}
Далее создадим базовый класс для самого ресурса:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class Resource extends JsonResource
{
protected static ?string $dataKey = null;
public $with = [
'status' => 'OK',
];
public static function collection($resource): Collection
{
return new Collection($resource, static::class, static::$dataKey);
}
}
Обратите внимание, что класс Collection в данном случае ссылается на тот что мы создали раннее, а не на одноимённый класс коллекции Laravel.
Теперь мы можем использовать класс ресурса для расширения. Модифицируем начальный ресурс, изменив наследование:
<?php
use App\Http\Resources\Resource;
class UserResource extends Resource
{
protected static ?string $dataKey = 'users';
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'firstName' => $this->first_name,
'lastName' => $this->last_name,
];
}
}Вот и всё. Теперь при вызове UserResource::make($user) на выходе получим "простой" объект без дополнительной вложенности, а при вызове UserResource::collection($users) - ключ "users" внутри "data", как и требовалось выше.
Как это работает?
Работает данная механика очень просто. При вызове создания "обычного" объекта хоть через new UserResource($user), хоть через UserResource::make($user), Laravel создаст обычный объект не используя никаких обработчиков и вложенностей. Единственное что сделает - это добавит 'status' => 'OK' в ответ, а вот при возврате коллекции запустится наш созданный класс и, если в ресурсе определить параметр $dataKey, то на выходе JSON объект будет содержать именно ��го значение, иначе вызовет метод resolveDataKey который возьмёт переданную ссылку на класс самого ресурса, далее отсеит всё что находится перед последним слешем, далее возьмёт всё что находится перед словом Resource, затем превратит в snake_case и сделает во множественном числе.
Звучит сложно, а на самом деле всё проще некуда. Так как мы явно указали в классе юзеров параметр, то данная механика не будет вызываться, а значит нам нужен новый класс для примера и, чтобы долго не думать над названием, напишем банально:
<?php
namespace App\Http\Resources;
class MyWorkResource extends Resource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
];
}
}Таким образом, для получения имени ключа будут произведены следующие шаги:
App\Http\Resources\MyWorkResource
MyWorkResource
MyWork
my_work
my_worksНа выходе получим имя ключа - my_works:
{
"data": {
"my_works": []
},
"status": "OK"
}Всё проще некуда. И красиво! ?
Бонус
Довольно часто разработчики меняют написание возвращаемых JSON ключей со snake_case на camelCase и, зачастую проблема оказывается в преобразовании данных пагинации. Один из вариантов, который я встречал, выглядел жутко:
<?php
class PaginatorCamelCaseResource extends PaginatorResource
{
public function toArray($request): array
{
return collect(parent::toArray($request))
->put('total', $this->total())
->mapWithKeys(
fn (mixed $item, string $key) => [Str::camel($key) => $item]
)->toArray();
}
}Но как сделать по-человечески? - спросите вы. Легко! Возвращаемся в созданный нами класс Collection и добавляем в него нехитрый метод paginationInformation со следующим содержимым:
<?php
public function paginationInformation(Request $request, array $paginated, array $default): array
{
return [
'pagination' => [
'total' => $default['meta']['total'] ?? 0,
'perPage' => $default['meta']['per_page'] ?? 0,
'currentPage' => $default['meta']['current_page'] ?? 0,
'lastPage' => $default['meta']['last_page'] ?? 0,
],
];
}В данном случае из всех данных пагинации нужны лишь номера страниц и общее количество записей, но Вы можете легко расширить данный список. На выходе такой приём вернёт объект:
<?php
$users = User::paginate();
return UserResource::collection($users);{
"data": {
"users": [
{
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
},
...
]
},
"pagination": {
"total": 27,
"perPage": 5,
"currentPage": 1,
"lastPage": 6
}
"status": "OK"
}А теперь сравним с тем, что было:
{
"data": {
"users": [
{
"id": 1,
"firstName": "Нина",
"lastName": "Дроздова"
},
...
]
},
"status": "OK",
"links": {
"first": "http:\/\/127.0.0.1:8000\/api?page=1",
"last": "http:\/\/127.0.0.1:8000\/api?page=6",
"prev": null,
"next": "http:\/\/127.0.0.1:8000\/api?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 6,
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=1",
"label": "1",
"active": true
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=2",
"label": "2",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=3",
"label": "3",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=4",
"label": "4",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=5",
"label": "5",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=6",
"label": "6",
"active": false
},
{
"url": "http:\/\/127.0.0.1:8000\/api?page=2",
"label": "Next »",
"active": false
}
],
"path": "http:\/\/127.0.0.1:8000\/api",
"per_page": 5,
"to": 5,
"total": 27
}
}Плюсы и простота очевидны.
Один класс, что правит всеми
Не зря в начале статьи я упомянул о предыдущем посте, в котором рассказывается кейс решения проблемы работы с кодами ответов. Давайте применим его и сюда.
Конечно, можно вызвать метод toJson у ресурса передав в его параметр нужный флаг, например, JSON_PRETTY_PRINT, но это добавит дубляж кода по приложению. Поэтому мы пойдём простейшим правильным способом - переопределением.
Дополним наш базовый класс Response переопределив публичный метод jsonOptions:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class Resource extends JsonResource
{
protected static ?string $dataKey = null;
public $with = [
'status' => 'OK',
];
public static function collection($resource): Collection
{
return new Collection($resource, static::class, static::$dataKey);
}
public function jsonOptions(): int
{
return JSON_UNESCAPED_UNICODE ^ JSON_PRETTY_PRINT;
}
}Вот и всё. На выходе получаем красиво отформатированный JSON:

Копируем тот же самый метод в соседний класс Collection и вуаля:

Laravel Idea + artisan make:resource
Но как же сделать так, чтобы наш созданный ресурс автоматически прописывался при создании классов будь то через консольную команду php artisan make:resource или вызовом хоткея в плагине Laravel Idea для PhpStorm? И здесь нет никаких проблем!
Laravel Templates
Для того чтобы изменить базовый шаблон для файлов, создаваемых консольными командами фреймворка нужно их опубликовать командой:
php artisan stub:publishLaravel опубликует все изменяемые файлы шаблонов в папку stubs в корне Вашего приложения. Нас в данном списке интересует файл stubs/resource.stub. Все остальные можете удалить, если не планируете их изменять.
Теперь приведём шаблон к следующему виду:
<?php
namespace {{ namespace }};
use Illuminate\Http\Request;
use App\Http\Resources\Resource;
class {{ class }} extends Resource
{
public function toArray(Request $request): array
{
//
}
}
И всё. После вызова создания ресурса консольной командой класс будет наследоваться от созданного нами.
Laravel Idea
Изменить настройки плагина также легко. Для этого перейдите в пункт меню Laravel > Laravel Idea Settings... (также можно через пункт меню File > Settings и дальше File | Settings | Languages & Frameworks | Laravel Idea | General), затем в подменю Code Generation > Standard Generations, найдите в списке Create Json Resource и в поле Base class замените базовый класс на свой. В нашем случае это App\Http\Resources\Resource.


И всё. Теперь все создаваемые любым способом ресурсы будут иметь ссылку на базовый класс.
