API Resources в Laravel обычно объясняют через страшилку «иначе утечёт пароль». Это не самая точная мотивация, и с неё стоит начать, потому что настоящая причина использовать Resources — другая и более важная.
Проблема: модель напрямую
// ❌ Плохо — отдаём модель как есть
public function show(User $user)
{
return $user; // Утечка password, remember_token, внутренних полей
}{
"id": 1,
"name": "John",
"email": "john@example.com",
"password": "$2y$10$...",
"remember_token": "abc123",
"email_verified_at": "2026-01-01",
"created_at": "2026-01-01T00:00:00.000000Z",
"updated_at": "2026-01-01T00:00:00.000000Z"
}Точности ради: password и remember_token из модели User так не утекут — в Laravel они перечислены в $hidden из коробки, и в JSON не попадут. Пример выше показывает худший случай, а не поведение по умолчанию.
Но настоящая проблема от этого не исчезает, она просто в другом месте: $hidden — это чёрный список. Добавили в таблицу колонку internal_notes, admin_comment или referral_source — и она немедленно поехала в публичный API, потому что никто не вспомнил дописать её в $hidden. Утечка происходит не в момент написания кода, а в момент добавления поля через полгода.
Resource переворачивает логику: наружу идёт только то, что перечислено явно. Новая колонка в таблице не появляется в ответе, пока вы этого не попросите. Именно в этом главная ценность, а не в сокрытии пароля.
И вторая причина, менее заметная, но не менее важная: Resource — это контракт с потребителем API. Пока ответ формируется из модели, любое переименование колонки в миграции ломает мобильное приложение. Со слоем трансформации структура ответа перестаёт зависеть от структуры таблицы, и рефакторинг базы становится безопасным.
Создание Resource
php artisan make:resource UserResource
php artisan make:resource UserCollection --collection// app/Http/Resources/UserResource.php
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,
'avatar_url' => $this->avatar_url,
'is_verified' => $this->hasVerifiedEmail(),
'member_since' => $this->created_at->format('Y-m-d'),
];
}
}Использование
// Одна модель
public function show(User $user)
{
return new UserResource($user);
}
// Коллекция
public function index()
{
$users = User::paginate(15);
return UserResource::collection($users);
}
// С дополнительными данными
public function show(User $user)
{
return (new UserResource($user))
->additional([
'meta' => [
'version' => '1.0',
],
]);
}Условные атрибуты
// app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// Только если загружена связь
'posts' => PostResource::collection($this->whenLoaded('posts')),
'posts_count' => $this->whenCounted('posts'),
// Только для определённых условий
'secret_field' => $this->when($request->user()?->isAdmin(), $this->secret),
// Значение по умолчанию
'role' => $this->role ?? 'user',
// Только если не null
'phone' => $this->whenNotNull($this->phone),
// Вложенный ресурс
'profile' => new ProfileResource($this->whenLoaded('profile')),
// Pivot данные
'membership' => $this->whenPivotLoaded('team_user', function () {
return [
'role' => $this->pivot->role,
'joined_at' => $this->pivot->created_at,
];
}),
];
}Вложенные ресурсы
// app/Http/Resources/OrderResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'total' => $this->formatMoney($this->total),
'total_raw' => $this->total,
// Вложенные ресурсы
'customer' => new UserResource($this->whenLoaded('user')),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shipping_address' => new AddressResource($this->whenLoaded('shippingAddress')),
// Timestamps
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
// Ссылки
'links' => [
'self' => route('api.orders.show', $this->id),
'invoice' => route('api.orders.invoice', $this->id),
],
];
}
private function formatMoney(int $cents): string
{
return number_format($cents / 100, 2, '.', ' ') . ' ₽';
}
}// app/Http/Resources/OrderItemResource.php
class OrderItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_id' => $this->product_id,
'product_name' => $this->product->name,
'quantity' => $this->quantity,
'unit_price' => $this->price,
'subtotal' => $this->price * $this->quantity,
];
}
}Resource Collection
// app/Http/Resources/OrderCollection.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class OrderCollection extends ResourceCollection
{
/**
* Указываем Resource для элементов
*/
public $collects = OrderResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_amount' => $this->collection->sum('total_raw'),
'average_order' => $this->collection->avg('total_raw'),
],
];
}
}// Использование
public function index()
{
$orders = Order::with(['user', 'items.product'])->paginate(20);
return new OrderCollection($orders);
}Пагинация
// Laravel автоматически добавляет meta и links
public function index()
{
return UserResource::collection(User::paginate(15));
}{
"data": [
{"id": 1, "name": "John"},
{"id": 2, "name": "Jane"}
],
"links": {
"first": "http://example.com/api/users?page=1",
"last": "http://example.com/api/users?page=10",
"prev": null,
"next": "http://example.com/api/users?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 10,
"per_page": 15,
"to": 15,
"total": 150
}
}Оборачивание данных
// По умолчанию данные в "data"
// Отключить глобально:
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}
// Или для конкретного ресурса
class UserResource extends JsonResource
{
public static $wrap = null; // без обёртки
// или
public static $wrap = 'user'; // в "user"
}Версионирование API
app/Http/Resources/
├── V1/
│ ├── UserResource.php
│ └── OrderResource.php
└── V2/
├── UserResource.php
└── OrderResource.php// app/Http/Resources/V1/UserResource.php
namespace App\Http\Resources\V1;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}
// app/Http/Resources/V2/UserResource.php
namespace App\Http\Resources\V2;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'full_name' => $this->name, // переименовали
'email_address' => $this->email,
'avatar' => $this->avatar_url, // добавили
'created_at' => $this->created_at->toISOString(),
];
}
}// routes/api.php
Route::prefix('v1')->group(function () {
Route::get('/users', [V1\UserController::class, 'index']);
});
Route::prefix('v2')->group(function () {
Route::get('/users', [V2\UserController::class, 'index']);
});Оптимизация: избегаем N+1
// ❌ N+1 запросов
public function index()
{
return UserResource::collection(User::all());
// В ресурсе: $this->posts->count() — запрос для каждого user
}
// ✅ Eager loading
public function index()
{
return UserResource::collection(
User::with(['posts', 'profile'])->paginate(15)
);
}
// ✅ Или withCount
public function index()
{
return UserResource::collection(
User::withCount('posts')->paginate(15)
);
}Не забывайте whenLoaded! Если связь не загружена, не пытайтесь к ней обращаться — это вызовет lazy loading и N+1.
Итоги
| Метод | Назначение |
|---|---|
toArray() | Основная трансформация |
whenLoaded() | Условная связь |
whenCounted() | Условный count |
when() | Условный атрибут |
additional() | Дополнительные meta |
Правила, которые я применяю:
Resource на любой публичный эндпоинт. Не ради сокрытия полей, а ради того, чтобы структура ответа была объявлена явно и не менялась вместе со схемой базы.
whenLoaded для всех связей — без исключений. Обращение к незагруженной связи внутри Resource даёт ленивую загрузку, и поскольку Resource выполняется для каждого элемента коллекции, один такой вызов превращается в N запросов. Найти это по логам тяжело: ошибки нет, ответ правильный, просто эндпоинт работает в двадцать раз медленнее.
Eager load — в контроллере, до передачи в Resource. Resource не должен ничего догружать; его задача — трансформация, а не выборка данных.
Версионируйте с первого дня. App\Http\Resources\V1\UserResource пишется один раз и ничего не стоит. Добавить версионирование, когда API пользуются два мобильных приложения, — отдельный проект.
Помните, что Resource выполняется на каждом элементе. Тяжёлые вычисления, обращения к кэшу или форматирование через внешние сервисы внутри toArray() умножаются на размер коллекции. Место таким вещам в запросе или в предварительной подготовке данных.
Полезная привычка при отладке производительности API: включите Model::preventLazyLoading() в локальном окружении. Тогда любая ленивая загрузка — в том числе из забытого whenLoaded — станет исключением на разработке вместо тихой просадки в проде.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.