Laravel Authentication Scaffolding: A Deep Dive into Laravel Breeze & Jetstream
Implement authentication quickly with Laravel's starter kits.
Laravel API Resources provide a transformative layer between your Eloquent models and JSON responses returned to your API consumers. They give you complete control over how your data is presented, including which attributes to include, how to format them, and how to handle relationships.
php artisan make:resource UserResource
This creates app/Http/Resources/UserResource.php:
<?php
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
<?php
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
<?php
// app/Http/Controllers/UserController.php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index()
{
$users = User::all();
return UserResource::collection($users);
}
public function show(User $user)
{
return new UserResource($user);
}
public function store(Request $request)
{
$user = User::create($request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]));
return new UserResource($user);
}
}
<?php
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->when($request->user() && $request->user()->isAdmin(), $this->email),
'phone' => $this->when($this->phone_verified_at, $this->phone),
'profile' => [
'avatar' => $this->avatar_url,
'bio' => $this->bio,
'website' => $this->when($this->website, $this->website),
],
'timestamps' => $this->when($request->user() && $request->user()->isAdmin(), [
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'email_verified_at' => $this->email_verified_at,
]),
'stats' => $this->when($request->has('include_stats'), [
'login_count' => $this->login_count,
'last_login' => $this->last_login,
]),
];
}
}
<?php
// app/Http/Resources/ProductResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'price' => [
'amount' => $this->price / 100, // Convert from cents
'currency' => 'USD',
'formatted' => '$' . number_format($this->price / 100, 2),
],
'inventory' => [
'stock' => $this->stock_quantity,
'status' => $this->stock_quantity > 0 ? 'in_stock' : 'out_of_stock',
'low_stock' => $this->stock_quantity <= 10,
],
'images' => $this->getImageUrls(),
'category' => $this->whenLoaded('category', function () {
return [
'id' => $this->category->id,
'name' => $this->category->name,
'slug' => $this->category->slug,
];
}),
'metadata' => [
'weight' => $this->when($this->weight, $this->weight . ' kg'),
'dimensions' => $this->when($this->dimensions, $this->dimensions),
'sku' => $this->sku,
],
'timestamps' => [
'created' => $this->created_at->toISOString(),
'updated' => $this->updated_at->toISOString(),
'human_readable' => [
'created' => $this->created_at->diffForHumans(),
'updated' => $this->updated_at->diffForHumans(),
],
],
];
}
protected function getImageUrls(): array
{
return collect($this->images)->map(function ($image) {
return [
'url' => asset("storage/products/{$image}"),
'thumbnail' => asset("storage/products/thumbnails/{$image}"),
'alt' => $this->name,
];
})->toArray();
}
}
<?php
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'content' => $this->when(
$request->routeIs('posts.show') || $request->user()?->isAdmin(),
$this->content
),
'excerpt' => $this->excerpt ?? str($this->content)->limit(150),
'status' => $this->status,
'featured_image' => $this->featured_image_url,
// Conditional relationships
'author' => new UserResource($this->whenLoaded('author')),
'categories' => CategoryResource::collection($this->whenLoaded('categories')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments' => $this->when(
$request->has('include_comments'),
CommentResource::collection($this->whenLoaded('comments'))
),
'stats' => $this->when(
$request->user()?->isAdmin(),
[
'views' => $this->views,
'likes' => $this->likes_count,
'shares' => $this->shares_count,
]
),
'meta' => [
'url' => route('posts.show', $this->slug),
'edit_url' => $this->when($request->user()?->can('update', $this),
route('admin.posts.edit', $this)
),
],
'timestamps' => [
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
],
];
}
}
<?php
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
$query = Post::with(['author', 'categories', 'tags']);
if ($request->has('include_comments')) {
$query->with('comments');
}
$posts = $query->latest()->paginate(10);
return PostResource::collection($posts);
}
public function show(Request $request, Post $post)
{
$post->load(['author', 'categories', 'tags', 'comments.user']);
return new PostResource($post);
}
}
<?php
// app/Http/Resources/OrderResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'total' => [
'amount' => $this->total_amount / 100,
'currency' => $this->currency,
'formatted' => $this->formatted_total,
],
// Customer information
'customer' => new UserResource($this->whenLoaded('customer')),
// Shipping information
'shipping_address' => $this->whenLoaded('shippingAddress', function () {
return [
'name' => $this->shippingAddress->full_name,
'street' => $this->shippingAddress->address_line_1,
'city' => $this->shippingAddress->city,
'state' => $this->shippingAddress->state,
'postal_code' => $this->shippingAddress->postal_code,
'country' => $this->shippingAddress->country,
];
}),
// Order items with products
'items' => OrderItemResource::collection($this->whenLoaded('items')),
// Payment information
'payment' => $this->whenLoaded('payment', function () {
return [
'method' => $this->payment->method,
'status' => $this->payment->status,
'transaction_id' => $this->payment->transaction_id,
'paid_at' => $this->payment->paid_at?->toISOString(),
];
}),
// Timestamps
'dates' => [
'placed_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'estimated_delivery' => $this->estimated_delivery_date?->toISOString(),
],
// Actions (conditional based on user permissions)
'actions' => $this->when($request->user(), function () use ($request) {
return [
'can_cancel' => $request->user()->can('cancel', $this),
'can_return' => $request->user()->can('return', $this),
'can_view_invoice' => $request->user()->can('viewInvoice', $this),
];
}),
];
}
}
<?php
// app/Http/Resources/OrderItemResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderItemResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product' => new ProductResource($this->whenLoaded('product')),
'product_name' => $this->product_name, // In case product is deleted
'quantity' => $this->quantity,
'unit_price' => [
'amount' => $this->unit_price / 100,
'formatted' => '$' . number_format($this->unit_price / 100, 2),
],
'total_price' => [
'amount' => $this->total_price / 100,
'formatted' => '$' . number_format($this->total_price / 100, 2),
],
'variants' => $this->when($this->variants, function () {
return json_decode($this->variants, true);
}),
];
}
}
php artisan make:resource UserCollection
<?php
// app/Http/Resources/UserCollection.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
],
'links' => [
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
],
];
}
/**
* Customize the response for the resource.
*/
public function withResponse($request, $response)
{
$response->header('X-API-Version', '1.0');
$response->header('X-API-Environment', config('app.env'));
}
}
<?php
// app/Http/Resources/ProductCollection.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProductCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*/
public function toArray(Request $request): array
{
return [
'data' => ProductResource::collection($this->collection),
'meta' => $this->getMeta(),
'filters' => $this->getFilters($request),
'links' => $this->getLinks(),
];
}
protected function getMeta(): array
{
return [
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
],
'sorting' => [
'field' => request('sort', 'created_at'),
'direction' => request('direction', 'desc'),
],
];
}
protected function getFilters(Request $request): array
{
return [
'applied' => [
'category' => $request->category,
'price_min' => $request->price_min,
'price_max' => $request->price_max,
'search' => $request->search,
],
'available' => [
'categories' => \App\Models\Category::all()->pluck('name', 'id'),
'price_ranges' => [
'0-25' => 'Under $25',
'25-50' => '$25 to $50',
'50-100' => '$50 to $100',
'100-9999' => 'Over $100',
],
],
];
}
protected function getLinks(): array
{
return [
'self' => $this->url($this->currentPage()),
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
];
}
/**
* Customize the outgoing response for the resource.
*/
public function withResponse($request, $response)
{
$response->header('Content-Type', 'application/json');
$response->header('X-API-Version', '1.0');
$response->header('X-Total-Count', $this->total());
}
}
<?php
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Http\Resources\ProductCollection;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$query = Product::with(['category', 'brand']);
// Search
if ($request->has('search')) {
$query->where('name', 'like', '%' . $request->search . '%');
}
// Filter by category
if ($request->has('category')) {
$query->where('category_id', $request->category);
}
// Price range
if ($request->has('price_min')) {
$query->where('price', '>=', $request->price_min * 100);
}
if ($request->has('price_max')) {
$query->where('price', '<=', $request->price_max * 100);
}
// Sorting
$sortField = $request->get('sort', 'created_at');
$sortDirection = $request->get('direction', 'desc');
$query->orderBy($sortField, $sortDirection);
$products = $query->paginate($request->get('per_page', 15));
return new ProductCollection($products);
}
}
<?php
// app/Http/Resources/PaginatedResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
abstract class PaginatedResource extends ResourceCollection
{
public function toArray($request)
{
return [
'data' => $this->getResourceClass()::collection($this->collection),
'meta' => $this->meta(),
'links' => $this->links(),
];
}
abstract protected function getResourceClass(): string;
protected function meta(): array
{
return [
'current_page' => $this->currentPage(),
'from' => $this->firstItem(),
'last_page' => $this->lastPage(),
'links' => $this->getPaginatedLinks(),
'path' => $this->path(),
'per_page' => $this->perPage(),
'to' => $this->lastItem(),
'total' => $this->total(),
];
}
protected function links(): array
{
return [
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
];
}
protected function getPaginatedLinks(): array
{
$links = [];
$currentPage = $this->currentPage();
$lastPage = $this->lastPage();
// Show up to 5 pages around current page
$start = max(1, $currentPage - 2);
$end = min($lastPage, $currentPage + 2);
for ($page = $start; $page <= $end; $page++) {
$links[] = [
'url' => $this->url($page),
'label' => $page,
'active' => $page === $currentPage,
];
}
return $links;
}
}
// Usage for specific resources
class PaginatedProductResource extends PaginatedResource
{
protected function getResourceClass(): string
{
return ProductResource::class;
}
}
<?php
// app/Http/Resources/CartResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CartResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'session_id' => $this->session_id,
'items' => CartItemResource::collection($this->whenLoaded('items')),
'summary' => [
'items_count' => $this->items_count,
'unique_products' => $this->unique_products_count,
'subtotal' => [
'amount' => $this->subtotal / 100,
'formatted' => '$' . number_format($this->subtotal / 100, 2),
],
'tax' => [
'amount' => $this->tax_amount / 100,
'formatted' => '$' . number_format($this->tax_amount / 100, 2),
'rate' => $this->tax_rate,
],
'shipping' => [
'amount' => $this->shipping_cost / 100,
'formatted' => '$' . number_format($this->shipping_cost / 100, 2),
'method' => $this->shipping_method,
],
'total' => [
'amount' => $this->total_amount / 100,
'formatted' => '$' . number_format($this->total_amount / 100, 2),
],
],
'coupon' => $this->whenLoaded('coupon', function () {
return [
'code' => $this->coupon->code,
'discount' => [
'amount' => $this->coupon_discount / 100,
'formatted' => '$' . number_format($this->coupon_discount / 100, 2),
'type' => $this->coupon->type,
],
];
}),
'timestamps' => [
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'abandoned_after' => $this->created_at->addHours(24)->diffForHumans(),
],
];
}
}
<?php
// app/Http/Resources/CartItemResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CartItemResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'cart_id' => $this->cart_id,
'product' => new ProductResource($this->whenLoaded('product')),
'quantity' => $this->quantity,
'unit_price' => [
'amount' => $this->unit_price / 100,
'formatted' => '$' . number_format($this->unit_price / 100, 2),
],
'total_price' => [
'amount' => $this->total_price / 100,
'formatted' => '$' . number_format($this->total_price / 100, 2),
],
'customizations' => $this->when($this->customizations, function () {
return json_decode($this->customizations, true);
}),
'notes' => $this->notes,
'added_at' => $this->created_at->toISOString(),
];
}
}
<?php
// app/Http/Resources/BlogPostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BlogPostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when(
$this->shouldIncludeContent($request),
$this->content
),
'featured_image' => $this->getFeaturedImage(),
'status' => $this->status,
'reading_time' => $this->reading_time . ' min read',
// Author information
'author' => new UserResource($this->whenLoaded('author')),
// Categories and tags
'categories' => CategoryResource::collection($this->whenLoaded('categories')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
// Comments (with pagination)
'comments' => $this->when(
$request->has('include_comments'),
function () use ($request) {
$comments = $this->comments()
->with('user')
->where('approved', true)
->latest()
->paginate(10);
return CommentResource::collection($comments);
}
),
// SEO metadata
'seo' => [
'meta_title' => $this->meta_title,
'meta_description' => $this->meta_description,
'canonical_url' => $this->canonical_url,
'og_image' => $this->og_image_url,
],
// Engagement metrics
'engagement' => $this->when(
$request->user()?->can('view_analytics', $this),
[
'views' => $this->views,
'likes' => $this->likes_count,
'shares' => $this->shares_count,
'comments_count' => $this->comments_count,
]
),
// Timestamps
'dates' => [
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
],
// URLs
'urls' => [
'public' => route('blog.show', $this->slug),
'api' => route('api.posts.show', $this->id),
'edit' => $this->when($request->user()?->can('update', $this),
route('admin.posts.edit', $this)
),
],
];
}
protected function shouldIncludeContent(Request $request): bool
{
return $request->routeIs('api.posts.show') ||
$request->user()?->can('view_draft', $this);
}
protected function getFeaturedImage(): ?array
{
if (!$this->featured_image) {
return null;
}
return [
'url' => asset("storage/posts/{$this->featured_image}"),
'alt' => $this->title,
'caption' => $this->featured_image_caption,
'sizes' => [
'thumbnail' => asset("storage/posts/thumbnails/{$this->featured_image}"),
'medium' => asset("storage/posts/medium/{$this->featured_image}"),
'large' => asset("storage/posts/large/{$this->featured_image}"),
],
];
}
}
<?php
// app/Http/Resources/CachedResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Cache;
abstract class CachedResource extends JsonResource
{
protected $cacheKey;
protected $cacheTtl = 3600; // 1 hour
public function toArray($request)
{
$this->cacheKey = $this->getCacheKey($request);
return Cache::remember($this->cacheKey, $this->cacheTtl, function () use ($request) {
return $this->getData($request);
});
}
abstract protected function getData($request): array;
abstract protected function getCacheKey($request): string;
/**
* Clear the cached resource
*/
public static function clearCache($model): void
{
$resource = new static($model);
Cache::forget($resource->getCacheKey(request()));
}
}
// Usage
class CachedProductResource extends CachedResource
{
protected function getData($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price / 100,
// ... other fields
];
}
protected function getCacheKey($request): string
{
return "product:{$this->id}:v2:" . md5(serialize($request->all()));
}
}
<?php
// app/Http/Resources/v1/UserResource.php
namespace App\Http\Resources\v1;
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,
'created_at' => $this->created_at->toISOString(),
];
}
}
<?php
// app/Http/Resources/v2/UserResource.php
namespace App\Http\Resources\v2;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'type' => 'users',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'email' => $this->email,
'profile' => [
'avatar' => $this->avatar_url,
'bio' => $this->bio,
],
],
'relationships' => [
'posts' => [
'links' => [
'related' => route('api.v2.users.posts', $this->id),
],
],
],
'links' => [
'self' => route('api.v2.users.show', $this->id),
],
'meta' => [
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
],
];
}
}
<?php
// app/Http/Resources/ApiResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class ApiResource extends JsonResource
{
/**
* Customize the response for the resource.
*/
public function withResponse($request, $response)
{
$response->header('X-API-Version', $this->getApiVersion());
$response->header('X-API-Environment', config('app.env'));
$response->header('X-Content-Type-Options', 'nosniff');
// Add CORS headers if needed
if (config('app.env') === 'local') {
$response->header('Access-Control-Allow-Origin', '*');
}
}
/**
* Get additional data that should be returned with the resource array.
*/
public function with($request)
{
return [
'meta' => [
'version' => $this->getApiVersion(),
'timestamp' => now()->toISOString(),
'copyright' => '© ' . date('Y') . ' Your Company',
],
'links' => [
'documentation' => 'https://api.example.com/docs',
],
];
}
abstract protected function getApiVersion(): string;
}
<?php
// tests/Unit/UserResourceTest.php
namespace Tests\Unit;
use Tests\TestCase;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserResourceTest extends TestCase
{
use RefreshDatabase;
public function test_user_resource_returns_correct_structure()
{
$user = User::factory()->create([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$resource = new UserResource($user);
$request = Request::create('/');
$response = $resource->toArray($request);
$this->assertEquals([
'id' => $user->id,
'name' => 'John Doe',
'email' => 'john@example.com',
'created_at' => $user->created_at->toISOString(),
'updated_at' => $user->updated_at->toISOString(),
], $response);
}
public function test_user_resource_hides_email_for_non_admins()
{
$user = User::factory()->create();
$nonAdmin = User::factory()->create(['is_admin' => false]);
$request = Request::create('/');
$request->setUserResolver(function () use ($nonAdmin) {
return $nonAdmin;
});
$resource = new UserResource($user);
$response = $resource->toArray($request);
$this->assertArrayNotHasKey('email', $response);
}
public function test_user_resource_shows_email_for_admins()
{
$user = User::factory()->create();
$admin = User::factory()->create(['is_admin' => true]);
$request = Request::create('/');
$request->setUserResolver(function () use ($admin) {
return $admin;
});
$resource = new UserResource($user);
$response = $resource->toArray($request);
$this->assertArrayHasKey('email', $response);
$this->assertEquals($user->email, $response['email']);
}
}
<?php
// tests/Unit/UserCollectionTest.php
namespace Tests\Unit;
use Tests\TestCase;
use App\Http\Resources\UserCollection;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
class UserCollectionTest extends TestCase
{
use RefreshDatabase;
public function test_user_collection_returns_paginated_structure()
{
User::factory()->count(15)->create();
$paginator = User::paginate(10);
$collection = new UserCollection($paginator);
$response = $collection->toArray(request());
$this->assertArrayHasKey('data', $response);
$this->assertArrayHasKey('meta', $response);
$this->assertArrayHasKey('links', $response);
$this->assertCount(10, $response['data']);
$this->assertEquals(15, $response['meta']['total']);
$this->assertEquals(2, $response['meta']['last_page']);
}
public function test_user_collection_includes_custom_headers()
{
User::factory()->count(5)->create();
$paginator = User::paginate(10);
$collection = new UserCollection($paginator);
$request = request();
$response = response()->json($collection->toArray($request));
$collection->withResponse($request, $response);
$this->assertEquals('1.0', $response->headers->get('X-API-Version'));
$this->assertEquals(config('app.env'), $response->headers->get('X-API-Environment'));
}
}
<?php
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
$query = Post::with([
'author' => function ($query) {
$query->select('id', 'name', 'avatar_url');
},
'categories' => function ($query) {
$query->select('id', 'name', 'slug');
},
'tags' => function ($query) {
$query->select('id', 'name', 'slug');
},
]);
// Only load comments if explicitly requested
if ($request->has('include_comments')) {
$query->with(['comments' => function ($query) {
$query->with(['user' => function ($query) {
$query->select('id', 'name');
}])->where('approved', true);
}]);
}
$posts = $query->latest()->paginate(15);
return PostResource::collection($posts);
}
}
<?php
// app/Http/Resources/OptimizedUserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OptimizedUserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
// Only load specific fields based on request
$fields = $request->get('fields', 'default');
return match($fields) {
'minimal' => $this->getMinimalFields(),
'profile' => $this->getProfileFields(),
'admin' => $this->getAdminFields(),
default => $this->getDefaultFields(),
};
}
protected function getMinimalFields(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'avatar' => $this->avatar_url,
];
}
protected function getDefaultFields(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->when($request->user()?->isAdmin(), $this->email),
'avatar' => $this->avatar_url,
'bio' => $this->bio,
'joined_at' => $this->created_at->diffForHumans(),
];
}
protected function getProfileFields(): array
{
return [
...$this->getDefaultFields(),
'website' => $this->website,
'location' => $this->location,
'social_links' => $this->social_links,
];
}
protected function getAdminFields(): array
{
return [
...$this->getProfileFields(),
'email_verified_at' => $this->email_verified_at,
'last_login_at' => $this->last_login_at,
'login_count' => $this->login_count,
'is_admin' => $this->is_admin,
];
}
}
1. What are Laravel API Resources?
API Resources provide a transformation layer that sits between your Eloquent models and the JSON responses returned by your API. They allow you to customize and control exactly how your data is presented.
2. What's the difference between JsonResource and ResourceCollection?
JsonResource is for transforming a single model instance, while ResourceCollection is for transforming a collection of models (including paginated results).
3. How do you handle relationships in API Resources?
Use conditional loading with whenLoaded() method and nest other resources within your main resource to include related data.
4. What's the purpose of the when() method?
The when() method conditionally includes attributes in the response based on a given condition, helping you create dynamic responses.
5. How do you customize pagination responses?
Create a custom ResourceCollection class and override the toArray() method to customize the pagination structure.
6. How can you improve performance with API Resources?
Use eager loading to prevent N+1 queries, implement selective field loading, and consider caching for frequently accessed resources.
7. What's the difference between make:hidden() and conditional attributes?
make:hidden() permanently hides attributes from the model, while conditional attributes in resources give you dynamic control over what's included in the API response.
8. How do you handle API versioning with resources?
Create versioned resource classes in separate directories (like v1/, v2/) and use the appropriate version in your controllers based on the API version.
9. How can you test API Resources?
Create unit tests that instantiate resources with model data and assert the transformed output matches your expectations.
10. What are some best practices for API Resources?
Keep transformation logic in resources, not controllers
Use consistent response structures
Implement proper error handling
Document your API responses
Consider performance implications
Use meaningful status codes and messages
You've now mastered Laravel API Resources! From basic transformations to advanced patterns like caching, versioning, and performance optimization, you have the complete toolkit for building robust, scalable APIs with clean, consistent JSON responses.