MidПрактика7 min

API Resources

JsonResource, ResourceCollection, conditional attributes (when, mergeWhen), пагинация, relationships в ресурсах, кастомизация ответа

API Resources -- трансформационный слой между Eloquent-моделями и JSON-ответами API. Они обеспечивают единообразное форматирование данных.

JsonResource

Создание и базовое использование

<?php

declare(strict_types=1);

namespace App\Http\Resources;

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

class UserResource extends JsonResource
{
    // Transform the model into an array
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
        ];
    }
}

Использование в контроллере:

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;

class UserController
{
    // Single resource
    public function show(User $user): UserResource
    {
        return new UserResource($user);
    }

    // Collection of resources
    public function index(): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
    {
        $users = User::paginate(15);

        return UserResource::collection($users);
    }
}

Подвох на экзамене: Внутри toArray() вы обращаетесь к свойствам модели через $this, так как ресурс использует __get() для проксирования к модели. $this->id эквивалентно $this->resource->id.

JSON-обёртка

По умолчанию ресурс оборачивается в ключ data:

{
    "data": {
        "id": 1,
        "name": "John",
        "email": "[email protected]"
    }
}
<?php

// Disable wrapping globally (in AppServiceProvider)
use Illuminate\Http\Resources\Json\JsonResource;

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

// Customize wrap key for specific resource
class UserResource extends JsonResource
{
    public static $wrap = 'user'; // Instead of 'data'
}

Подвох на экзамене: withoutWrapping() влияет на ВСЕ ресурсы глобально. Если отключить wrapping, коллекции ресурсов не смогут включить метаданные пагинации на верхнем уровне.

ResourceCollection

Именованная коллекция

<?php

declare(strict_types=1);

namespace App\Http\Resources;

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

class UserCollection extends ResourceCollection
{
    // Laravel automatically resolves UserResource for each item
    // Convention: UserCollection uses UserResource

    // Add extra data to collection response
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection, // Each item is UserResource
            'meta' => [
                'total_admins' => $this->collection->where('is_admin', true)->count(),
            ],
        ];
    }
}
<?php

// In controller
public function index(): UserCollection
{
    return new UserCollection(User::paginate(15));
}

Указание кастомного ресурса

<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class AdminCollection extends ResourceCollection
{
    // Specify which resource to use for each item
    public $collects = AdminResource::class;
}

Анонимная коллекция vs именованная

<?php

// Anonymous collection (using ::collection)
UserResource::collection(User::all());
// Uses UserResource for each item, no custom collection class needed

// Named collection (using dedicated class)
new UserCollection(User::all());
// Allows adding custom meta, totals, etc.

// The key difference: named collections allow customizing
// the entire response structure, not just individual items

Conditional Attributes

when()

<?php

declare(strict_types=1);

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,

            // Include only when condition is true
            'is_admin' => $this->when(
                $request->user()?->is_admin,
                $this->is_admin,
            ),

            // With default value when condition is false
            'secret' => $this->when(
                $request->user()?->is_admin,
                $this->secret_field,
                'hidden', // Default value
            ),

            // Lazy evaluation (closure not called if condition is false)
            'expensive_data' => $this->when(
                $request->has('include_stats'),
                fn () => $this->calculateExpensiveStats(),
            ),
        ];
    }
}

Подвох на экзамене: Когда when() возвращает false и нет default значения, ключ ПОЛНОСТЬЮ удаляется из JSON (не null, а отсутствует). Это важное отличие.

mergeWhen()

<?php

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,

        // Merge multiple fields conditionally
        $this->mergeWhen($request->user()?->is_admin, [
            'internal_id' => $this->internal_id,
            'created_by' => $this->created_by,
            'ip_address' => $this->ip_address,
        ]),

        // Merge with lazy evaluation
        $this->mergeWhen($this->is_premium, fn () => [
            'premium_until' => $this->premium_until,
            'features' => $this->premiumFeatures(),
        ]),
    ];
}

whenHas() и whenNotNull()

<?php

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,

        // Include only if attribute exists on model
        'name' => $this->whenHas('name'),

        // Include only if attribute is not null
        'phone' => $this->whenNotNull($this->phone),
    ];
}

Relationships в Resources

whenLoaded()

<?php

declare(strict_types=1);

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,
            'body' => $this->body,

            // Include relationship ONLY if it was eager loaded
            'author' => new UserResource($this->whenLoaded('author')),

            // Collection relationship
            'comments' => CommentResource::collection(
                $this->whenLoaded('comments')
            ),

            // With default value
            'category' => new CategoryResource(
                $this->whenLoaded('category', null, ['name' => 'Uncategorized'])
            ),

            // Count (only if withCount was used)
            'comments_count' => $this->whenCounted('comments'),

            // Aggregate (only if withAvg/withSum was used)
            'average_rating' => $this->whenAggregated('reviews', 'rating', 'avg'),
        ];
    }
}

Подвох на экзамене: whenLoaded() проверяет, была ли связь загружена (eager loaded). Если связь не загружена, ключ полностью отсутствует в ответе. Это предотвращает N+1 запросы через API resources.

<?php

// Controller
public function show(Post $post): PostResource
{
    // Without eager loading: 'author' key absent in response
    return new PostResource($post);

    // With eager loading: 'author' key present
    return new PostResource($post->load('author', 'comments'));
}

Pivot данные

<?php

class RoleResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,

            // Include pivot data when available
            'expires_at' => $this->whenPivotLoaded('role_user', function () {
                return $this->pivot->expires_at;
            }),

            // Alternative: whenPivotLoadedAs (for custom pivot accessor)
            'joined_at' => $this->whenPivotLoadedAs(
                'membership', // Custom pivot accessor name
                'team_user',  // Pivot table name
                function () {
                    return $this->membership->joined_at;
                }
            ),
        ];
    }
}

Pagination

<?php

// In controller
public function index(): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
{
    return UserResource::collection(
        User::with('roles')->paginate(15)
    );
}

// Response automatically includes pagination meta:
// {
//     "data": [...],
//     "links": {
//         "first": "http://app.test/api/users?page=1",
//         "last": "http://app.test/api/users?page=5",
//         "prev": null,
//         "next": "http://app.test/api/users?page=2"
//     },
//     "meta": {
//         "current_page": 1,
//         "from": 1,
//         "last_page": 5,
//         "per_page": 15,
//         "to": 15,
//         "total": 72,
//         "path": "http://app.test/api/users"
//     }
// }

Кастомизация пагинации

<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class UserCollection extends ResourceCollection
{
    // Customize pagination information
    public function paginationInformation($request, $paginated, $default): array
    {
        // Remove 'links' from pagination
        unset($default['links']);

        // Customize meta
        $default['meta']['has_more'] = $paginated['current_page'] < $paginated['last_page'];

        return $default;
    }
}

Response Customization

Дополнительные метаданные

<?php

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
        ];
    }

    // Add top-level data to response
    public function with(Request $request): array
    {
        return [
            'meta' => [
                'api_version' => '1.0',
                'server_time' => now()->toISOString(),
            ],
        ];
    }
}

// Response:
// {
//     "data": { "id": 1, "name": "John" },
//     "meta": { "api_version": "1.0", "server_time": "..." }
// }

Кастомизация HTTP response

<?php

// In controller
public function show(User $user): UserResource
{
    return (new UserResource($user))
        ->response()
        ->header('X-Custom-Header', 'value')
        ->setStatusCode(200);
}

// Or in resource class
class UserResource extends JsonResource
{
    public function withResponse(Request $request, \Illuminate\Http\JsonResponse $response): void
    {
        $response->header('X-Resource-Version', '2');

        if ($this->is_admin) {
            $response->header('X-Admin', 'true');
        }
    }
}

Conditional response structure

<?php

class UserResource extends JsonResource
{
    private bool $includeTokens = false;

    // Fluent method for conditional data
    public function withTokens(): static
    {
        $this->includeTokens = true;

        return $this;
    }

    public function toArray(Request $request): array
    {
        $data = [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
        ];

        if ($this->includeTokens) {
            $data['tokens'] = $this->tokens->pluck('name');
        }

        return $data;
    }
}

// Usage
return (new UserResource($user))->withTokens();

Nested Resources и Deep Relationships

<?php

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'number' => $this->order_number,
            'total' => $this->total_amount,
            'status' => $this->status->value,

            // Nested resource
            'customer' => new CustomerResource($this->whenLoaded('customer')),

            // Nested collection resource
            'items' => OrderItemResource::collection($this->whenLoaded('items')),

            // Deep nested: items.product
            // Works if $order->load('items.product') was called
        ];
    }
}

class OrderItemResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'quantity' => $this->quantity,
            'unit_price' => $this->unit_price,
            'subtotal' => $this->quantity * $this->unit_price,

            // Nested product (loaded via items.product)
            'product' => new ProductResource($this->whenLoaded('product')),
        ];
    }
}

Тестирование API Resources

<?php

use App\Http\Resources\UserResource;
use App\Models\User;

test('user resource returns correct structure', function () {
    $user = User::factory()->create([
        'name' => 'John Doe',
        'email' => '[email protected]',
    ]);

    $resource = new UserResource($user);
    $response = $resource->toArray(request());

    expect($response)
        ->toHaveKeys(['id', 'name', 'email', 'created_at'])
        ->and($response['name'])->toBe('John Doe')
        ->and($response['email'])->toBe('[email protected]');
});

test('user resource hides admin fields for regular users', function () {
    $user = User::factory()->create();
    $resource = (new UserResource($user))->toArray(request());

    expect($resource)->not->toHaveKey('internal_id');
});

Проверь себя

Какая конвенция связывает UserCollection с UserResource?

Что делает метод `whenLoaded()` в API Resource?

Как добавить дополнительные метаданные на верхний уровень JSON ответа ресурса?

Как `JsonResource::withoutWrapping()` влияет на ответ?

Что произойдёт, если `when()` вернёт false и не указано default значение?