<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE

Як віддавати API-відповіді через JsonResource і чому не повертати моделі напряму?

Модель серіалізується через `toArray()`, тож форма відповіді — це набір колонок таблиці плюс випадково завантажені звʼязки й `$appends`: нова колонка публікується сама, а `$hidden` захищає лише те, що ви згадали. `JsonResource` — це білий список полів у явному класі, де `whenLoaded()` прибирає ключ замість ліниво тягнути звʼязок, а `Resource::collection($paginator)` сам додає `links` і `meta`.

У контролері `return $user;` — що з цим не так, крім стилю?
Додали в таблицю `posts` колонку `internal_note` — чому вона наступного дня зʼявилась у мобільному застосунку?
Чому `PostResource::collection(Post::paginate(20))` дає `links` і `meta`, а `PostResource::collection(Post::all())` — лише `data`?
У ресурсі написано `whenLoaded('author')`, звʼязок не завантажили — у JSON буде `"author": null` чи ключа не буде взагалі?
API JsonResource пагінація серіалізація whenLoaded

Коли з контролера повертають 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 додасть сам
    }
}
Що `return $post;` — це `toJson()` → `toArray()`: усі колонки з `$attributes`, усі завантажені звʼязки й усе з `$appends`; контракт API стає дзеркалом схеми БД, а міграція — публічною зміною.
Що `$hidden` — чорний список: він ховає перелічене, а нову колонку публікує; `JsonResource::toArray()` — білий список, де за замовчуванням не віддається нічого.
Що `whenLoaded('author')` повертає `MissingValue`, і `removeMissingValues()` видаляє ключ із масиву — це не `null`, а відсутність поля, і саме тому ресурс не породжує N+1 при `Model::preventLazyLoading()`.
Що `Resource::collection($paginator)` віддає відповідь через `PaginatedResourceResponse`: `links` (first/last/prev/next) і `meta` (`current_page`, `per_page`, `total`, `last_page`, `from`, `to`, `path`), а звичайна колекція — лише `data`.
Що `paginate()` — це два запити (спершу `select count(*)`, потім вибірка) і що на глибоких офсетах та великих таблицях відповіддю є `simplePaginate()` або `cursorPaginate()`, а не індекс.
Покладатися на `$hidden = ['password']` як на захист: колонку `salary` чи `internal_note`, додану через півроку, ніхто в `$hidden` не допише.
Писати в ресурсі `'author' => new AuthorResource($this->author)` замість `whenLoaded('author')`: на колекції з 50 елементів це 50 додаткових запитів, і ресурс сам стає джерелом N+1.
Ставити `whenLoaded('author')` і забути `with('author')` у запиті — ключ мовчки зникає з відповіді, клієнт бачить не помилку, а «поля немає».
Обгортати вручну: `return ['data' => PostResource::collection($posts)]` — виходить `data.data`, бо ресурс уже загорнутий у `data` (`static $wrap`).
Пагінувати з `orderByDesc('created_at')` без унікального тайбрейкера: рядки з однаковою секундою стрибають між сторінками — щось видно двічі, щось не видно взагалі.
Втрачати фільтри в посиланнях: без `withQueryString()` у `next_page_url` не буде ні `?status=`, ні `?q=`, і друга сторінка покаже інший набір.
Чекати від `cursorPaginate()` полів `total` і `last_page`: у `meta` там лише `path`, `per_page`, `next_cursor`, `prev_cursor` — намалювати «сторінка 7 з 340» неможливо.
Робити запити всередині `toArray()` (`$this->comments()->count()`, `Cache::get(...)`) — код виконається для кожного елемента колекції.
ПОРАДА

Сформулюйте це як межу: модель — це схема БД, ресурс — це контракт із клієнтом, і між ними має бути явний клас, інакше `ALTER TABLE` автоматично стає зміною публічного API. Далі назвіть три речі, за які ресурс і любили: білий список полів, `whenLoaded()` (ключ зникає, а не тягнеться зайвий запит) і `links`/`meta`, які `collection($paginator)` додає сам.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`$fillable` керує масовим заповненням, а не серіалізацією: у JSON іде все з `$attributes` мінус `$hidden` плюс `$appends` і завантажені звʼязки. `links`/`meta` додає `PaginatedResourceResponse`, тобто лише тоді, коли ресурсу передали пагінатор; звичайна колекція дає самий `data`. `cursorPaginate()` навмисно не рахує `count(*)`, тому ні `total`, ні `last_page` не існує — у `meta` лежать `next_cursor` і `prev_cursor`. Правильний варіант описує саме те, як працює фільтрація `MissingValue` і як формується пагінована відповідь.