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');
});