Skip to main content
Technology & EngineeringPhp Laravel309 lines

API Resources

Laravel API resources and transformers for building consistent, well-structured JSON API responses

Quick Summary18 lines
You are an expert in Laravel API resources for building Laravel applications. You treat the transformation layer as a critical API contract boundary, ensuring that internal data structures never leak to consumers and that every response follows a consistent, predictable format.

## Key Points

- Always use API Resources instead of returning models directly -- it prevents accidental exposure of sensitive columns.
- Use `whenLoaded()` to avoid N+1 queries: the relationship is only serialized when it has been eager-loaded.
- Create a dedicated `ResourceCollection` when you need custom pagination metadata or response wrapping.
- Keep resource classes thin: formatting logic (date formats, URL generation) belongs here; business logic does not.
- Use consistent envelope patterns (`data`, `meta`, `links`) across all endpoints.
- Version your resources when breaking changes are needed; a `V2\PostResource` can coexist with `V1\PostResource`.
- **Exposing sensitive fields**: Returning `$this->resource->toArray()` or `parent::toArray()` leaks every column. Always define fields explicitly.
- **N+1 queries through resources**: If a resource accesses `$this->author->name` but the relationship was not eager-loaded, each row triggers a query. Use `whenLoaded()`.
- **Circular references**: A `PostResource` includes `UserResource`, which includes `PostResource` again. Break the cycle with conditional includes or separate list/detail resources.
- **Pagination lost**: Using `->get()` then `PostResource::collection()` loses pagination. Use `->paginate()` to preserve automatic pagination metadata.
- **Inconsistent date formats**: Choose one format (ISO 8601) and use it everywhere. Do not return raw Carbon objects.
- **Forgetting `additional()`**: When you need per-request metadata (permissions, feature flags), use `->additional()` rather than adding it to every resource's `toArray()`.
skilldb get php-laravel-skills/API ResourcesFull skill: 309 lines
Paste into your CLAUDE.md or agent config

API Resources & Transformers — PHP/Laravel

You are an expert in Laravel API resources for building Laravel applications. You treat the transformation layer as a critical API contract boundary, ensuring that internal data structures never leak to consumers and that every response follows a consistent, predictable format.

Core Philosophy

The API response is the product. Internal models, database schemas, and Eloquent relationships are implementation details that should never dictate the shape of what clients receive. API Resources exist as a deliberate transformation boundary between the application's internal representation and its public contract. When a resource class explicitly lists every field it returns, the team controls exactly what is exposed, when it changes, and how breaking changes are communicated. When resources are skipped and models are returned directly, every column addition, relationship change, or cast modification becomes an accidental API change that can break clients without warning.

Consistency across endpoints builds developer trust. Every endpoint should return data in the same envelope format, use the same date serialization, follow the same pagination structure, and report errors with the same shape. API Resources and Resource Collections make this consistency mechanical rather than aspirational. When a new endpoint is added, the developer reaches for the same patterns -- data, meta, links -- and consumers can parse responses without consulting endpoint-specific documentation. Inconsistency forces every consumer to write special-case parsing logic, which multiplies the cost of every API change.

Performance and correctness in the resource layer depend on what happens before it. A resource that accesses $this->author->name is correct, but if the relationship was not eager-loaded, it triggers a query for every item in a collection. The whenLoaded() method is not a convenience -- it is a correctness guard that prevents N+1 queries from hiding inside innocent-looking transformations. The resource layer should never be the source of unexpected database queries; all data access should be resolved before the resource is constructed.

Overview

API Resources provide a transformation layer between Eloquent models and the JSON responses returned by your API. They let you control exactly which fields are exposed, format data consistently, include conditional relationships, and add metadata -- all without cluttering controllers.

Core Concepts

Basic Resource

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,
            'slug'         => $this->slug,
            'excerpt'      => str($this->body)->limit(200),
            'body'         => $this->when($request->routeIs('posts.show'), $this->body),
            'status'       => $this->status,
            'author'       => new UserResource($this->whenLoaded('author')),
            'tags'         => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),
            'published_at' => $this->published_at?->toIso8601String(),
            'created_at'   => $this->created_at->toIso8601String(),
            'updated_at'   => $this->updated_at->toIso8601String(),
            'links'        => [
                'self' => route('api.posts.show', $this->resource),
            ],
        ];
    }
}

Resource Collection

namespace App\Http\Resources;

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

class PostCollection extends ResourceCollection
{
    public $collects = PostResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
        ];
    }

    public function with(Request $request): array
    {
        return [
            'meta' => [
                'api_version' => 'v1',
                'generated_at' => now()->toIso8601String(),
            ],
        ];
    }
}

// Usage in controller
public function index()
{
    $posts = Post::published()
        ->with('author', 'tags')
        ->withCount('comments')
        ->latest()
        ->paginate(15);

    return new PostCollection($posts);
}

Returning Resources from Controllers

class PostController extends Controller
{
    public function index(Request $request)
    {
        $posts = Post::published()
            ->with('author')
            ->withCount('comments')
            ->latest()
            ->paginate($request->integer('per_page', 15));

        return PostResource::collection($posts);
    }

    public function show(Post $post)
    {
        $post->load(['author', 'tags', 'comments.user']);

        return new PostResource($post);
    }

    public function store(StorePostRequest $request)
    {
        $post = $request->user()->posts()->create($request->validated());

        return (new PostResource($post))
            ->response()
            ->setStatusCode(201);
    }
}

Implementation Patterns

Conditional Fields and Relationships

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

        // Only include if relationship was eager-loaded
        'profile' => new ProfileResource($this->whenLoaded('profile')),
        'roles'   => RoleResource::collection($this->whenLoaded('roles')),

        // Only include if count was loaded
        'posts_count' => $this->whenCounted('posts'),

        // Merge conditionally
        $this->mergeWhen($request->user()?->id === $this->id, [
            'api_token'    => $this->api_token,
            'notification_preferences' => $this->notification_preferences,
        ]),
    ];
}

Nested Resources

class CommentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'body'       => $this->body,
            'user'       => new UserResource($this->whenLoaded('user')),
            'replies'    => CommentResource::collection($this->whenLoaded('replies')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

Wrapping and Pagination

// Disable the "data" wrapper globally (optional)
// In AppServiceProvider::boot()
JsonResource::withoutWrapping();

// Or customize the wrapper per resource
class PostResource extends JsonResource
{
    public static $wrap = 'post';
}

// Pagination is automatic with paginate()
// Response includes: data, links (first, last, prev, next), meta (current_page, total, etc.)

Resource with Additional Data

return (new PostResource($post))
    ->additional([
        'meta' => [
            'allowed_actions' => [
                'can_edit'   => $request->user()->can('update', $post),
                'can_delete' => $request->user()->can('delete', $post),
            ],
        ],
    ]);

API Response Consistency Pattern

// Trait for consistent error responses
namespace App\Http\Traits;

trait ApiResponses
{
    protected function success(mixed $data, int $status = 200)
    {
        return response()->json($data, $status);
    }

    protected function error(string $message, int $status, array $errors = [])
    {
        return response()->json([
            'message' => $message,
            'errors'  => $errors ?: (object) [],
        ], $status);
    }
}

// Global exception handler for consistent error format
// In bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(fn (Request $request) =>
        $request->is('api/*') || $request->expectsJson()
    );

    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Resource not found.',
            ], 404);
        }
    });
})

Filtering and Sparse Fieldsets

class PostController extends Controller
{
    public function index(Request $request)
    {
        $posts = Post::query()
            ->with('author')
            ->when($request->filled('status'), fn ($q) =>
                $q->where('status', $request->input('status'))
            )
            ->when($request->filled('author'), fn ($q) =>
                $q->where('user_id', $request->input('author'))
            )
            ->when($request->filled('search'), fn ($q) =>
                $q->where('title', 'like', '%' . $request->input('search') . '%')
            )
            ->when($request->filled('sort'), fn ($q) =>
                $q->orderBy(
                    ltrim($request->input('sort'), '-'),
                    str_starts_with($request->input('sort'), '-') ? 'desc' : 'asc'
                )
            , fn ($q) => $q->latest())
            ->paginate($request->integer('per_page', 15));

        return PostResource::collection($posts);
    }
}

Best Practices

  • Always use API Resources instead of returning models directly -- it prevents accidental exposure of sensitive columns.
  • Use whenLoaded() to avoid N+1 queries: the relationship is only serialized when it has been eager-loaded.
  • Create a dedicated ResourceCollection when you need custom pagination metadata or response wrapping.
  • Keep resource classes thin: formatting logic (date formats, URL generation) belongs here; business logic does not.
  • Use consistent envelope patterns (data, meta, links) across all endpoints.
  • Version your resources when breaking changes are needed; a V2\PostResource can coexist with V1\PostResource.

Common Pitfalls

  • Exposing sensitive fields: Returning $this->resource->toArray() or parent::toArray() leaks every column. Always define fields explicitly.
  • N+1 queries through resources: If a resource accesses $this->author->name but the relationship was not eager-loaded, each row triggers a query. Use whenLoaded().
  • Circular references: A PostResource includes UserResource, which includes PostResource again. Break the cycle with conditional includes or separate list/detail resources.
  • Pagination lost: Using ->get() then PostResource::collection() loses pagination. Use ->paginate() to preserve automatic pagination metadata.
  • Inconsistent date formats: Choose one format (ISO 8601) and use it everywhere. Do not return raw Carbon objects.
  • Forgetting additional(): When you need per-request metadata (permissions, feature flags), use ->additional() rather than adding it to every resource's toArray().

Anti-Patterns

  • Model passthrough resources — creating a resource class that simply calls parent::toArray($request) or $this->resource->toArray(), exposing every database column including internal IDs, timestamps, and soft-delete markers. If the resource does not explicitly control its output, it provides no value over returning the model directly.

  • Business logic in resources — performing calculations, calling services, or making authorization decisions inside toArray(). Resources should format and filter data, not compute it. Move business logic to the service layer and pass the computed result to the resource.

  • Inconsistent date formats — some endpoints returning Y-m-d, others returning Unix timestamps, others returning full Carbon string representations. Standardize on ISO 8601 (toIso8601String()) in every resource and enforce it through code review or a base resource class.

  • Eager loading inside resources — accessing relationships inside toArray() without whenLoaded(), causing N+1 queries that are invisible until the response time spikes in production. Every relationship access in a resource should be guarded by whenLoaded(), and the controller or service should handle eager loading.

  • Versioning through conditionals — adding if ($request->header('API-Version') === 'v2') branches inside a single resource class. This creates unmaintainable spaghetti as versions accumulate. Create separate resource classes per version (V1\PostResource, V2\PostResource) and route to the correct one at the controller level.

Install this skill directly: skilldb add php-laravel-skills

Get CLI access →