Коли з контролера повертають return $post;, Laravel бачить обʼєкт, що реалізує JsonSerializable, і викликає toJson() → toArray(). Усередині — attributesToArray() плюс relationsToArray(): у відповідь іде кожна колонка з $attributes, кожен акцесор із $appends і кожен звʼязок, який на цей момент виявився завантаженим. Наслідків три, і всі неприємні. Перший: контракт API стає дзеркалом схеми БД — ALTER TABLE ADD COLUMN internal_note мовчки публікує нове поле, а перейменування колонки ламає мобільний застосунок. Другий: $hidden — це чорний список, він рятує від password і remember_token, бо їх туди вписали в скелеті, і не рятує від колонки, доданої іншою людиною через півроку. Третій, найпідступніший: форма відповіді залежить від того, які звʼязки випадково завантажилися раніше по коду — додали $post->load('comments') заради перевірки в middleware, і відповідь виросла на кілограм JSON, якого ніхто не просив.
JsonResource перевертає логіку: замість «віддаємо все, крім забороненого» — «віддаємо рівно те, що перелічено в toArray(Request $request)». Клас створюють через php artisan make:resource PostResource, усередині доступний $this->resource (модель), а звертання $this->title проксіюється до неї трейтом DelegatesToResource. Далі результат toArray() проходить через filter(): ключі, значення яких є MissingValue, викидаються; вкладений ресурс, чий resource дорівнює null, перетворюється на null. Готовий масив загортається в data — це public static $wrap = 'data' у JsonResource, який знімається глобально викликом JsonResource::withoutWrapping(). Одиничний ресурс можна повернути прямо з контролера (Responsable), а якщо потрібен код 201 чи заголовок — через ->response()->setStatusCode(201).
Ключова для продуктивності частина — умовні поля. whenLoaded('author') перевіряє relationLoaded() і сам у базу не ходить: якщо звʼязку немає в памʼяті, повертається MissingValue і ключ просто зникає з JSON. Це принципово відрізняється від new AuthorResource($this->author), яке для кожного елемента колекції зробить окремий select — тобто ресурс, написаний наївно, сам стає генератором N+1. Тому пара завжди така: with('author') у запиті плюс whenLoaded('author') у ресурсі; забули перше — поле тихо зникне, і краще зловити це тестом assertJsonStructure(), ніж клієнтом. Поруч живуть whenCounted('comments') (працює після withCount()), whenAggregated() (після withSum()/withAvg()), when() для прав і mergeWhen() для вливання блоку полів на верхній рівень. Разом із Model::preventLazyLoading() у dev/test це дає жорстку гарантію: скільки запитів у контролері написано, стільки їх і буде, скільки б полів ресурс не описував.
Пагінація вбудована в ту саму механіку. PostResource::collection($posts) повертає AnonymousResourceCollection, і якщо $posts — пагінатор, відповідь формує PaginatedResourceResponse: поруч із data зʼявляються links (first, last, prev, next) і meta — усе, що є в $paginator->toArray(), крім data і чотирьох url: current_page, from, to, last_page, per_page, path, total і масив links для нумерації. Власні поля з ->additional(['meta' => [...]]) зливаються з цим через array_merge_recursive, тому нічого не затирають. Два практичні моменти: ->withQueryString() на пагінаторі, щоб next_page_url зберіг фільтри, і обовʼязковий унікальний тайбрейкер у сортуванні — orderByDesc('published_at')->orderByDesc('id'), бо LIMIT/OFFSET без стабільного порядку дає дублі на одній сторінці й пропуски на іншій. Памʼятайте також, що paginate() — це два запити: спершу select count(*) (при total = 0 другий взагалі не виконується), і саме цей count(*) разом із глибоким офсетом стає вузьким місцем на мільйонах рядків; тоді беруть simplePaginate() або keyset-пагінацію cursorPaginate(), свідомо відмовляючись від total і номерів сторінок.
Межі й компроміси теж варто назвати вголос. Ресурс — не механізм авторизації: він вирішує, які поля показати, а право на сам обʼєкт перевіряють policy й authorize(), інакше «приховане» поле легко дістається сусіднім ендпоїнтом. Усередині toArray() не місце запитам і виклику зовнішніх сервісів — цей код виконується для кожного елемента колекції; усе потрібне має прийти з with()/withCount(). Шар справді додає файлів, і для внутрішнього ендпоїнта на два поля дешевше повернути явний масив, ніж заводити клас; у Laravel 12+ рутину скорочують $post->toResource() і Post::all()->toResourceCollection(), які знаходять клас за неймспейсом або за атрибутом #[UseResource]. І нарешті, JsonResource — це трансформація, а не типізований DTO: він не описує схему для OpenAPI й не дає гарантій типів, тому проєкти, де контракт API важливіший за швидкість написання, або доповнюють ресурси генератором специфікації, або замінюють їх на явні DTO з spatie/laravel-data. Але в кожному з цих варіантів залишається та сама межа, за яку й ставлять плюс на співбесіді: модель описує таблицю, окремий клас описує відповідь, і зміна першого не повинна автоматично змінювати друге.
final class PostResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
// Білий список: нова колонка в таблиці не потрапить у відповідь сама.
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title,
'published_at' => $this->published_at?->toIso8601String(),
// Звʼязок не завантажений -> MissingValue -> ключа в JSON не буде.
// Жодного лінивого запиту з циклу по колекції.
'author' => AuthorResource::make($this->whenLoaded('author')),
// Зʼявиться, лише якщо в запиті був withCount('comments').
'comments_count' => $this->whenCounted('comments'),
// Замикання, а не значення: інакше вираз рахується й тоді, коли умова false.
'internal_note' => $this->when(
(bool) $request->user()?->can('update', $this->resource),
fn () => $this->internal_note,
),
];
}
}
final class PostController
{
public function index(): ResourceCollection
{
$posts = Post::query()
->with('author') // один запит на всіх авторів замість N
->withCount('comments')
->where('is_published', true)
->orderByDesc('published_at')
->orderByDesc('id') // тайбрейкер: інакше рядки стрибають між сторінками
->paginate(20) // +1 запит select count(*) заради total
->withQueryString(); // фільтри лишаються в next/prev
return PostResource::collection($posts); // links і meta додасть сам
}
}
Сформулюйте це як межу: модель — це схема БД, ресурс — це контракт із клієнтом, і між ними має бути явний клас, інакше `ALTER TABLE` автоматично стає зміною публічного API. Далі назвіть три речі, за які ресурс і любили: білий список полів, `whenLoaded()` (ключ зникає, а не тягнеться зайвий запит) і `links`/`meta`, які `collection($paginator)` додає сам.