Урок 03

API-ресурсы

Зачем нужны ресурсы

Без ресурса:

return Post::find(1);

В JSON попадёт всё:

{
    "id": 1,
    "title": "...",
    "body": "...",
    "user_id": 5,
    "created_at": "2026-10-15T12:00:00Z",
    "updated_at": "...",
    "deleted_at": null,
    "meta": "...",
    "internal_notes": "..."
}

Проблемы:

  • Лишние поля — размер ответа.
  • Приватные данные — утечка.
  • Формат БД — не подходит клиенту.
  • Нет вычисляемых полей — приходится считать на клиенте.

Resource

Resource — класс, который преобразует модель в массив для JSON.

Плюсы:

  • Выбор полей — только нужные.
  • Переименование — snake_case → camelCase.
  • Вычисляемые поля.
  • Вложенные связи.
  • Условные поля.
  • Метаданные.

Создание

php artisan make:resource PostResource

Файл app/Http/Resources/PostResource.php.

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'body' => $this->body,
            'published_at' => $this->published_at?->toISOString(),
            'created_at' => $this->created_at->toISOString(),
        ];
    }
}

$this — модель. Доступ через свойства, не $this->resource->title.

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

Один:

public function show(Post $post): PostResource
{
    return new PostResource($post);
}

Коллекция:

public function index()
{
    return PostResource::collection(Post::paginate(15));
}

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

return [
    'id' => $this->id,
    'title' => $this->title,
    'author' => new UserResource($this->whenLoaded('user')),
    'comments' => CommentResource::collection($this->whenLoaded('comments')),
];

whenLoaded — включает, только если связь загружена.

whenLoaded

Проблема: если в контроллере нет with('user'), обращение к $this->user вызовет N+1.

Решение: whenLoaded.

'user' => new UserResource($this->whenLoaded('user')),

Что происходит:

  • Загружено → включает в ответ.
  • Не загружено → поле не появляется.

В контроллере:

return PostResource::collection(
    Post::with('user')->paginate(15)
);

whenCounted

'comments_count' => $this->whenCounted('comments'),

Работает, если в контроллере:

Post::withCount('comments')->get();

when

Условные поля:

return [
    'id' => $this->id,
    'title' => $this->title,
    'email' => $this->when(
        $request->user()?->isAdmin(),
        $this->email,
    ),
];

Что делает: если условие true — поле в ответе, иначе — нет.

С альтернативой:

'secret' => $this->when($isAdmin, 'value', 'hidden'),

mergeWhen

Группа полей:

return [
    'id' => $this->id,
    $this->mergeWhen($request->user()?->isAdmin(), [
        'email' => $this->email,
        'phone' => $this->phone,
        'internal_notes' => $this->internal_notes,
    ]),
];

whenHas

Если атрибут есть:

'deleted_at' => $this->whenHas('deleted_at'),

Условные вычисляемые

'excerpt' => $this->when(
    $request->boolean('with_excerpt'),
    fn () => mb_substr($this->body, 0, 100),
),

fn — ленивое вычисление (только если нужно).

Обёртка data

По умолчанию:

{
    "data": {
        "id": 1,
        "title": "..."
    }
}

Коллекция:

{
    "data": [
        { "id": 1 },
        { "id": 2 }
    ]
}

Отключить глобально:

// AppServiceProvider::boot()
use Illuminate\Http\Resources\Json\JsonResource;

public function boot(): void
{
    JsonResource::withoutWrapping();
}

Для одного ресурса:

class PostResource extends JsonResource
{
    public static $wrap = null;
}

Метаданные

with:

public function with(Request $request): array
{
    return [
        'meta' => [
            'version' => '1.0',
            'generated_at' => now()->toISOString(),
        ],
    ];
}

Ответ:

{
    "data": { ... },
    "meta": { "version": "1.0" }
}

Ресурс коллекции

php artisan make:resource PostCollection
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class PostCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
            ],
        ];
    }
}

Полезно для кастомного формата пагинации.

Пагинация

PostResource::collection(Post::paginate(15)) вернёт:

{
    "data": [...],
    "links": {
        "first": "...",
        "last": "...",
        "prev": null,
        "next": "..."
    },
    "meta": {
        "current_page": 1,
        "per_page": 15,
        "total": 73
    }
}

Работает из коробки.

Полный пример

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => mb_substr($this->body, 0, 100),
            'body' => $this->when(
                $request->routeIs('posts.show'),
                $this->body,
            ),
            'status' => $this->status,
            'views' => $this->views,

            'author' => new UserResource($this->whenLoaded('user')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),

            'published_at' => $this->published_at?->toISOString(),
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),

            'links' => [
                'self' => route('api.posts.show', $this->id),
                'comments' => route('api.posts.comments.index', $this->id),
            ],
        ];
    }

    public function with(Request $request): array
    {
        return [
            'meta' => [
                'version' => 'v1',
            ],
        ];
    }
}

Контроллер:

public function index()
{
    $posts = Post::query()
        ->with(['user:id,name,avatar', 'tags:id,name'])
        ->withCount('comments')
        ->latest()
        ->paginate(15);

    return PostResource::collection($posts);
}

Практика

Создай PostResource:

php artisan make:resource PostResource

Опиши поля:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'excerpt' => mb_substr($this->body, 0, 100),
        'author' => new UserResource($this->whenLoaded('user')),
        'created_at' => $this->created_at->toISOString(),
    ];
}

Используй в контроллере:

public function show(Post $post): PostResource
{
    return new PostResource($post->load('user'));
}

public function index()
{
    return PostResource::collection(
        Post::with('user')->paginate(15)
    );
}

Проверь через curl:

curl http://localhost:8000/api/posts/1
curl http://localhost:8000/api/posts

Частые ошибки

1. N+1 без whenLoaded:

'user' => new UserResource($this->user),   // N+1 при списке

// Правильно:
'user' => new UserResource($this->whenLoaded('user')),

2. Забыли ->toISOString():

"created_at": { "date": "2026-10-15 12:00:00.000000" }

Решение: $this->created_at?->toISOString().

3. Забыли про data-обёртку:

{ "data": { ... } }

Клиент ждёт без обёртки — сломается. Или отключи обёртку, или используй её на клиенте.

4. Открытые приватные поля:

return ['email' => $this->email];   // если это админ-поле

Решение: $this->when($request->user()->isAdmin(), $this->email).

5. Много данных из модели:

return $this->resource->toArray();   // всё, включая служебное

Правило: явно перечисляй поля.

Итог

  • Resource — преобразование модели в JSON.
  • toArray — что вернуть.
  • whenLoaded — только загруженные связи.
  • whenCounted — счётчики.
  • when — условные поля.
  • PostResource::collection — список.
  • Обёртка data — по умолчанию.
  • Пагинация — из коробки.

Дальше — Resource-контроллеры для API.