Главная/Статьи/API Resources в Laravel — правильная сериализация ответов

API Resources в Laravel — правильная сериализация ответов

API Resources трансформируют модели в JSON. Скрываем поля, добавляем computed-атрибуты, управляем связями. Версионирование API.

ДМ
Дмитрий Мещеряков
📅 3 июля 2026 г.📖 6 мин чтения

API Resources в Laravel обычно объясняют через страшилку «иначе утечёт пароль». Это не самая точная мотивация, и с неё стоит начать, потому что настоящая причина использовать Resources — другая и более важная.

Проблема: модель напрямую

php
// ❌ Плохо — отдаём модель как есть
public function show(User $user)
{
    return $user; // Утечка password, remember_token, внутренних полей
}
json
{
  "id": 1,
  "name": "John",
  "email": "john@example.com",
  "password": "$2y$10$...",
  "remember_token": "abc123",
  "email_verified_at": "2026-01-01",
  "created_at": "2026-01-01T00:00:00.000000Z",
  "updated_at": "2026-01-01T00:00:00.000000Z"
}
⚠️ Важно

Точности ради: password и remember_token из модели User так не утекут — в Laravel они перечислены в $hidden из коробки, и в JSON не попадут. Пример выше показывает худший случай, а не поведение по умолчанию.

Но настоящая проблема от этого не исчезает, она просто в другом месте: $hidden — это чёрный список. Добавили в таблицу колонку internal_notes, admin_comment или referral_source — и она немедленно поехала в публичный API, потому что никто не вспомнил дописать её в $hidden. Утечка происходит не в момент написания кода, а в момент добавления поля через полгода.

Resource переворачивает логику: наружу идёт только то, что перечислено явно. Новая колонка в таблице не появляется в ответе, пока вы этого не попросите. Именно в этом главная ценность, а не в сокрытии пароля.

💡 Совет

И вторая причина, менее заметная, но не менее важная: Resource — это контракт с потребителем API. Пока ответ формируется из модели, любое переименование колонки в миграции ломает мобильное приложение. Со слоем трансформации структура ответа перестаёт зависеть от структуры таблицы, и рефакторинг базы становится безопасным.

Создание Resource

bash
php artisan make:resource UserResource
php artisan make:resource UserCollection --collection
php
// app/Http/Resources/UserResource.php
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,
            'avatar_url' => $this->avatar_url,
            'is_verified' => $this->hasVerifiedEmail(),
            'member_since' => $this->created_at->format('Y-m-d'),
        ];
    }
}

Использование

php
// Одна модель
public function show(User $user)
{
    return new UserResource($user);
}
// Коллекция
public function index()
{
    $users = User::paginate(15);
    return UserResource::collection($users);
}
// С дополнительными данными
public function show(User $user)
{
    return (new UserResource($user))
        ->additional([
            'meta' => [
                'version' => '1.0',
            ],
        ]);
}

Условные атрибуты

php
// app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        // Только если загружена связь
        'posts' => PostResource::collection($this->whenLoaded('posts')),
        'posts_count' => $this->whenCounted('posts'),
        // Только для определённых условий
        'secret_field' => $this->when($request->user()?->isAdmin(), $this->secret),
        // Значение по умолчанию
        'role' => $this->role ?? 'user',
        // Только если не null
        'phone' => $this->whenNotNull($this->phone),
        // Вложенный ресурс
        'profile' => new ProfileResource($this->whenLoaded('profile')),
        // Pivot данные
        'membership' => $this->whenPivotLoaded('team_user', function () {
            return [
                'role' => $this->pivot->role,
                'joined_at' => $this->pivot->created_at,
            ];
        }),
    ];
}

Вложенные ресурсы

php
// app/Http/Resources/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,
            'order_number' => $this->order_number,
            'status' => $this->status,
            'total' => $this->formatMoney($this->total),
            'total_raw' => $this->total,
            // Вложенные ресурсы
            'customer' => new UserResource($this->whenLoaded('user')),
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'shipping_address' => new AddressResource($this->whenLoaded('shippingAddress')),
            // Timestamps
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
            // Ссылки
            'links' => [
                'self' => route('api.orders.show', $this->id),
                'invoice' => route('api.orders.invoice', $this->id),
            ],
        ];
    }
    private function formatMoney(int $cents): string
    {
        return number_format($cents / 100, 2, '.', ' ') . '';
    }
}
php
// app/Http/Resources/OrderItemResource.php
class OrderItemResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'product_id' => $this->product_id,
            'product_name' => $this->product->name,
            'quantity' => $this->quantity,
            'unit_price' => $this->price,
            'subtotal' => $this->price * $this->quantity,
        ];
    }
}

Resource Collection

php
// app/Http/Resources/OrderCollection.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class OrderCollection extends ResourceCollection
{
    /**
     * Указываем Resource для элементов
     */
    public $collects = OrderResource::class;
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_amount' => $this->collection->sum('total_raw'),
                'average_order' => $this->collection->avg('total_raw'),
            ],
        ];
    }
}
php
// Использование
public function index()
{
    $orders = Order::with(['user', 'items.product'])->paginate(20);
    return new OrderCollection($orders);
}

Пагинация

php
// Laravel автоматически добавляет meta и links
public function index()
{
    return UserResource::collection(User::paginate(15));
}
json
{
  "data": [
    {"id": 1, "name": "John"},
    {"id": 2, "name": "Jane"}
  ],
  "links": {
    "first": "http://example.com/api/users?page=1",
    "last": "http://example.com/api/users?page=10",
    "prev": null,
    "next": "http://example.com/api/users?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 10,
    "per_page": 15,
    "to": 15,
    "total": 150
  }
}

Оборачивание данных

php
// По умолчанию данные в "data"
// Отключить глобально:
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
    JsonResource::withoutWrapping();
}
// Или для конкретного ресурса
class UserResource extends JsonResource
{
    public static $wrap = null; // без обёртки
    // или
    public static $wrap = 'user'; // в "user"
}

Версионирование API

text
app/Http/Resources/
├── V1/
│   ├── UserResource.php
│   └── OrderResource.php
└── V2/
    ├── UserResource.php
    └── OrderResource.php
php
// app/Http/Resources/V1/UserResource.php
namespace App\Http\Resources\V1;
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
        ];
    }
}
// app/Http/Resources/V2/UserResource.php
namespace App\Http\Resources\V2;
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'full_name' => $this->name, // переименовали
            'email_address' => $this->email,
            'avatar' => $this->avatar_url, // добавили
            'created_at' => $this->created_at->toISOString(),
        ];
    }
}
php
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users', [V1\UserController::class, 'index']);
});
Route::prefix('v2')->group(function () {
    Route::get('/users', [V2\UserController::class, 'index']);
});

Оптимизация: избегаем N+1

php
// ❌ N+1 запросов
public function index()
{
    return UserResource::collection(User::all());
    // В ресурсе: $this->posts->count() — запрос для каждого user
}
// ✅ Eager loading
public function index()
{
    return UserResource::collection(
        User::with(['posts', 'profile'])->paginate(15)
    );
}
// ✅ Или withCount
public function index()
{
    return UserResource::collection(
        User::withCount('posts')->paginate(15)
    );
}
⚠️ Важно

Не забывайте whenLoaded! Если связь не загружена, не пытайтесь к ней обращаться — это вызовет lazy loading и N+1.

Итоги

МетодНазначение
toArray()Основная трансформация
whenLoaded()Условная связь
whenCounted()Условный count
when()Условный атрибут
additional()Дополнительные meta

Правила, которые я применяю:

Resource на любой публичный эндпоинт. Не ради сокрытия полей, а ради того, чтобы структура ответа была объявлена явно и не менялась вместе со схемой базы.

whenLoaded для всех связей — без исключений. Обращение к незагруженной связи внутри Resource даёт ленивую загрузку, и поскольку Resource выполняется для каждого элемента коллекции, один такой вызов превращается в N запросов. Найти это по логам тяжело: ошибки нет, ответ правильный, просто эндпоинт работает в двадцать раз медленнее.

Eager load — в контроллере, до передачи в Resource. Resource не должен ничего догружать; его задача — трансформация, а не выборка данных.

Версионируйте с первого дня. App\Http\Resources\V1\UserResource пишется один раз и ничего не стоит. Добавить версионирование, когда API пользуются два мобильных приложения, — отдельный проект.

Помните, что Resource выполняется на каждом элементе. Тяжёлые вычисления, обращения к кэшу или форматирование через внешние сервисы внутри toArray() умножаются на размер коллекции. Место таким вещам в запросе или в предварительной подготовке данных.

💡 Совет

Полезная привычка при отладке производительности API: включите Model::preventLazyLoading() в локальном окружении. Тогда любая ленивая загрузка — в том числе из забытого whenLoaded — станет исключением на разработке вместо тихой просадки в проде.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.