Ресурсы API в Laravel: разбор на живом проекте
API Resources в Laravel экономят десятки строк, но легко превращают один список в сотни запросов к базе. Разбираем, где ресурсы помогают, а где убивают производительность.

Эндпоинт /api/orders отдаёт список из 50 заказов. В браузере — 200 миллисекунд, а в логе Laravel Debugbar — 214 запросов к базе. Причина: каждый заказ в OrderResource обращается к $this->user->name и $this->items, а связи не подгружены заранее. Классическая N+1, которую ресурсы прячут за красивым синтаксисом. Разбираем, как устроены API Resources в Laravel 11.x, где они экономят время, а где ломают приложение под нагрузкой.
Что такое ресурс и зачем он нужен
Ресурс — это слой между моделью Eloquent и JSON, который уходит клиенту. Без него разработчики либо отдают модель напрямую (return $order;), либо собирают массив руками в контроллере. Первый способ протекает: в ответ попадают все поля таблицы, включая служебные created_at и внутренние флаги. Второй способ размазывает логику формирования ответа по контроллерам, и один и тот же заказ в трёх местах выглядит по-разному.
Ресурс собирает эту логику в одном классе. Генерируется командой:
php artisan make:resource OrderResource
Минимальный вид класса выглядит так:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'total' => $this->total_amount,
'status' => $this->status,
'created_at' => $this->created_at->toIso8601String(),
];
}
}
В контроллере одиночный объект оборачивается в ресурс, а коллекция — в ::collection():
use App\Http\Resources\OrderResource;
// один заказ
return new OrderResource($order);
// список заказов
return OrderResource::collection($orders);
Пока связей нет, всё работает предсказуемо. Проблемы начинаются, когда в toArray() появляются обращения к другим таблицам.
Где ресурсы ломают производительность
Добавим в ответ имя пользователя и список позиций:
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'customer' => $this->user->name, // +1 запрос на каждый заказ
'items' => $this->items->map(fn ($i) => [ // ещё +1 на каждый заказ
'product' => $i->product_name,
'qty' => $i->quantity,
]),
];
}
Контроллер при этом выглядит невинно:
public function index()
{
return OrderResource::collection(Order::latest()->paginate(50));
}
Ресурс сам по себе загрузку связей не инициирует. Он просто читает $this->user, а Eloquent, обнаружив, что связь не загружена, тихо делает отдельный запрос. Пятьдесят заказов — пятьдесят запросов на пользователей плюс пятьдесят на позиции. Отсюда те самые 200+ запросов из первого абзаца.
Ресурс не виноват в N+1. Он лишь делает её невидимой: код читается как обращение к свойству, а под капотом уходит SQL-запрос. Ответственность за жадную загрузку остаётся на контроллере.
Как чинить: eager loading в контроллере
Связи грузятся заранее через with(). Тогда Laravel делает три запроса вместо двухсот: сами заказы, все нужные пользователи одним whereIn и все позиции одним whereIn.
public function index()
{
$orders = Order::with(['user', 'items'])
->latest()
->paginate(50);
return OrderResource::collection($orders);
}
Проверить результат можно через Laravel Debugbar или, в тесте, через счётчик запросов. Ещё жёстче — заставить приложение падать при ленивой загрузке в разработке. В AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
После этого любое обращение к незагруженной связи в dev-окружении бросит исключение LazyLoadingViolationException с именем модели и связи. Забытый with() вы увидите на первом же запросе, а не в проде на трёхстах RPS.
Условные связи: whenLoaded
Проблема: один ресурс используется на двух эндпоинтах. На списке связи не нужны, на детальной странице — нужны. Если toArray() всегда читает $this->user, вы либо тащите связь везде, либо ловите N+1 там, где её не грузили.
Решает метод whenLoaded(): поле попадёт в ответ, только если связь реально загружена в модели.
return [
'id' => $this->id,
'number' => $this->number,
// ключ 'customer' появится в JSON, только если сделали with('user')
'customer' => new UserResource($this->whenLoaded('user')),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
];
Теперь контроллер списка не грузит связи и получает лёгкий ответ, а контроллер детали добавляет with(['user', 'items']) — и те же поля появляются. Ни одного лишнего запроса, ни одной N+1.
Ресурс против ручного массива: когда что
Ресурс — не единственный способ. Иногда быстрее и честнее собрать ответ вручную. Разница по критериям:
| Критерий | API Resource | Ручной массив в контроллере |
|---|---|---|
| Переиспользование формы ответа | Один класс на все эндпоинты | Дублируется, легко разъехаться |
| Вложенные связи | whenLoaded + вложенные ресурсы | Руками, многословно |
| Контроль над N+1 | Скрыт, нужна дисциплина с with() | Виден прямо в коде |
| Разовый нестандартный ответ | Избыточен | Проще и короче |
Мета-данные, обёртка data | Из коробки | Собирать самому |
Правило простое: если форму ответа отдают больше чем из одного места — ресурс. Если это единственный служебный эндпоинт вроде /api/health, ресурс только добавит файл на ровном месте.
Обёртка data и пагинация
По умолчанию Laravel заворачивает ответ ресурса в ключ data. Для одиночного объекта это {"data": {...}}, для коллекции с пагинацией добавляются ещё links и meta с номерами страниц. Фронтенд, который ждёт голый объект, на этом спотыкается.
Убрать обёртку глобально — в AppServiceProvider::boot():
use Illuminate\Http\Resources\Json\JsonResource;
JsonResource::withoutWrapping();
Но с пагинацией это работает не полностью: meta и links нужны фронту для навигации, и их лучше не отключать. Договоритесь с командой фронтенда о формате до того, как напишете десять ресурсов, — переделывать контракт потом дороже.
Что ревьюить в пул-реквесте с ресурсами
- Каждое обращение к связи в
toArray()($this->user,$this->items) имеет парныйwith()в контроллере или обёрнуто вwhenLoaded(). - В ответ не утекают служебные поля: пароли, токены, внутренние флаги. Ресурс перечисляет поля явно, а не возвращает
parent::toArray($request)целиком. - Даты приведены к единому формату (
toIso8601String()или что принято в проекте), а не отдаются в виде объекта Carbon как попало. - Вложенные коллекции идут через
SomeResource::collection(...), а не через ручнойmap()с дублированием логики. - Форматирование денег и чисел одинаково во всех ресурсах — вынесено в трейт или базовый класс, если повторяется.
Что настроить дальше
Первым делом включите preventLazyLoading в dev — это дешёвая страховка от N+1, которая ловит ошибку до код-ревью. Затем поставьте Laravel Debugbar локально и смотрите на счётчик запросов при каждом новом эндпоинте: цифра должна расти при добавлении полей, а не при добавлении строк в списке. В проде смотрите на p95-время ответа тяжёлых листингов и на количество запросов к БД на один HTTP-запрос через APM — рост числа SQL-запросов пропорционально размеру страницы означает пропущенный with(). Отдельная тема — версионирование контракта ответа при изменении полей ресурса; здесь мы её не разворачиваем.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Laravel — Eloquent: API Resources, Laravel — Preventing Lazy Loading
Частые вопросы
У меня Laravel 9, а не 11 — примеры подойдут?
Почему поле связи иногда пропадает из JSON?
Ресурсы сами защищают от N+1?
Как отличить N+1, если Debugbar не показывает счётчик?
Стоит ли отключать обёртку data глобально?
Можно ли отдавать модель напрямую вместо ресурса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.