API-ресурсы Laravel: как отдавать данные без утечек и N+1
API-ресурсы Laravel решают три задачи: скрыть лишние поля, переименовать ключи под фронт и не отдать пароль в JSON. Разбираем, когда хватает ресурса, а когда нужен отдельный слой трансформации.

Вы отдаёте модель напрямую через return User::all() — и в JSON улетают password_hash, remember_token и внутренние флаги, о которых фронтенд знать не должен. Через пару месяцев поле в базе переименовали, и мобильное приложение сломалось, потому что имя колонки утекло в контракт API. API-ресурсы Laravel — это слой между Eloquent-моделью и JSON, который убирает оба этих класса проблем. Разбираем, когда хватает JsonResource, когда нужна коллекция и где ресурсы упираются в потолок и просят отдельный трансформер.
Материал для Laravel 10.x и 11.x — API-ресурсы в этих версиях ведут себя одинаково, различия в синтаксисе роутов не касаются самих ресурсов.
Что решает ресурс, а что нет
API-ресурс — это класс, который принимает модель (или любой объект) и превращает её в массив, готовый к сериализации в JSON. Его задача — контракт: какие поля отдаём, как называем ключи, что вычисляем на лету. Всё, что касается формы ответа, живёт здесь, а не в контроллере и не в модели.
Ресурс не занимается выборкой данных, авторизацией и бизнес-логикой. Если внутри toArray() вы делаете запрос в базу — это ошибка, которая обернётся проблемой N+1 на коллекции. Ресурс работает с тем, что ему уже передали.
Базовый ресурс
// app/Http/Resources/UserResource.php
// Laravel 11.x
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// password и remember_token просто не перечисляем — они не попадут в ответ
'registered_at' => $this->created_at->toIso8601String(),
];
}
}
Создать заготовку можно командой php artisan make:resource UserResource. Внутри toArray() вы обращаетесь к модели через $this — ресурс проксирует свойства обёрнутой модели напрямую.
В контроллере вместо голой модели возвращаете ресурс:
// Один объект
return new UserResource($user);
// Коллекция — метод collection обходит каждый элемент
return UserResource::collection($users);
По умолчанию Laravel оборачивает ответ в ключ data. Один объект даст {"data": {...}}, коллекция — {"data": [...]}. Это часть контракта: фронтенд всегда лезет в data, а не гадает, обёрнут ответ или нет.
Условные поля и связи без N+1
Главная сила ресурсов — управление полями по условию. Отдать email только владельцу профиля, показать роль только админу, включить связь только если она реально загружена.
whenLoaded — против N+1
Самая частая ошибка новичка в ресурсах: обратиться к связи внутри toArray().
// ПЛОХО: на каждый элемент коллекции — отдельный запрос posts
'posts' => PostResource::collection($this->posts),
Если контроллер не сделал with('posts'), Laravel лениво подгрузит посты для каждого пользователя отдельно. Сто пользователей — сто один запрос. Правильно — отдавать связь только когда она уже загружена:
'posts' => PostResource::collection($this->whenLoaded('posts')),
Теперь если контроллер вызвал User::with('posts')->get(), посты попадут в ответ; если нет — ключ posts просто не появится, и лишних запросов не будет. Ответственность за загрузку — на контроллере, ресурс лишь описывает, как показать уже готовые данные.
Ресурс не должен ходить в базу. Если внутри toArray() появляется запрос — вы перенесли N+1 из контроллера в слой сериализации, где его труднее заметить на код-ревью.
when и mergeWhen — поля по правилу
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// email виден только самому пользователю
'email' => $this->when(
$request->user()?->id === $this->id,
$this->email
),
// группа полей только для админа
$this->mergeWhen($request->user()?->isAdmin(), [
'internal_note' => $this->internal_note,
'login_count' => $this->login_count,
]),
];
}
when добавляет одно поле по условию, mergeWhen — сразу несколько. Если условие ложно, ключ вообще не появляется в ответе — не null, а именно отсутствует.
Ресурс, коллекция или трансформер: где граница
Пока форма ответа повторяет модель один-к-одному с фильтрацией полей — хватает JsonResource. Проблемы начинаются, когда ответ собирается из нескольких источников, когда одну модель надо отдавать в трёх разных форматах или когда логика преобразования становится тяжёлой и её хочется тестировать отдельно.
| Ситуация | Инструмент | Чем платите |
|---|---|---|
| Модель → JSON, скрыть поля, переименовать ключи | JsonResource | Ничем, это дефолт |
| Нужна обёртка коллекции с мета-данными (пагинация, счётчики) | ResourceCollection | Отдельный класс на коллекцию |
| Одна модель в нескольких форматах (краткий/полный) | Несколько ресурсов на одну модель | Дублирование полей между классами |
| Сложное преобразование, версии API, независимость от Eloquent | Отдельный слой трансформеров (DTO/сервис) | Больше кода, свой контракт вне фреймворка |
ResourceCollection для мета-данных
Если к списку нужно добавить не только элементы, но и данные о выборке, обычной ::collection() мало. Создаёте класс коллекции:
// php artisan make:resource UserCollection
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection, // каждый элемент прогонится через UserResource
'meta' => [
'total' => $this->collection->count(),
'roles_present' => $this->collection->pluck('role')->unique()->values(),
],
];
}
}
При пагинации через UserResource::collection($users), где $users — результат paginate(), Laravel сам добавит блоки links и meta со страницами. Свою коллекцию пишут, когда стандартной пагинации мало.
Когда пора уходить в отдельный слой
API-ресурс намертво привязан к жизненному циклу HTTP-ответа и к Eloquent. Как только появляется одно из условий ниже, стоит вынести преобразование в отдельные классы (DTO плюс сервис-маппер), а ресурс оставить тонкой обёрткой поверх готового DTO:
- данные для ответа собираются из нескольких моделей и внешних API, а не из одной таблицы;
- то же преобразование нужно вне HTTP — в консольной команде, очереди, экспорте;
- у вас версии API (v1, v2) с разной формой одних и тех же сущностей;
- логику маппинга надо покрывать юнит-тестами без подъёма HTTP-слоя.
Пакеты вроде league/fractal когда-то были стандартом трансформации в Laravel, но с появлением встроенных ресурсов их берут реже — обычно только под сложные версионированные API. Для большинства проектов встроенного JsonResource достаточно.
Что ломается на практике
- Двойная обёртка data. Если внутри ресурса вручную писать
'data' => ..., а Laravel добавит свою обёртку, получитеdata.data. Отключить внешнюю обёртку можно черезJsonResource::withoutWrapping()в сервис-провайдере. - Обращение к связи без whenLoaded. Тихий N+1, который не виден в ответе, но виден в логах запросов. Ловится через Laravel Debugbar или
DB::enableQueryLog(). - Даты в разном формате. Без явного
toIso8601String()формат зависит от настроек модели и может отличаться между полями. Задавайте формат явно в ресурсе. - null против отсутствия ключа.
when(false, ...)убирает ключ полностью, а не отдаётnull. Фронтенд должен быть к этому готов, иначеresponse.emailвнезапноundefined. - Логика в ресурсе. Тяжёлые вычисления и запросы в
toArray()размазывают бизнес-логику по слою представления. Считайте заранее, в ресурс передавайте готовое.
Что настроить дальше
После того как ресурсы описывают контракт, займитесь тем, что вокруг них. Включите проверку eager loading: Model::preventLazyLoading() в AppServiceProvider для локального окружения превратит скрытый N+1 в исключение ещё на этапе разработки. Настройте единый формат дат и вынесите его в базовый ресурс, от которого наследуются остальные. Если API растёт — заведите версионирование через отдельные пространства имён ресурсов (App\Http\Resources\V1) до того, как первый контракт станет неизменяемым. Инвалидация кеша ответов и авторизация полей на уровне политик — отдельные темы, здесь мы разобрали только слой сериализации.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Eloquent: API Resources — Laravel Documentation
Частые вопросы
Ресурсы работают в моей версии Laravel? У меня 9.x.
Почему в ответе появился двойной data.data и как убрать?
Как отдать поле, только если пользователь — админ?
Не сработало — связь не попадает в ответ, хотя whenLoaded стоит.
Ресурсы медленнее, чем возврат модели напрямую?
Когда переходить с JsonResource на отдельные трансформеры?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.