API Resources

Laravel Intermediate/Advanced
Course 2 ยท Chapter 2 ยท API Resources

๐Ÿ“ฆ API Resources

A real architectural difference from DRF, worth stating up front: a DRF ModelSerializer does double duty โ€” validating incoming data and serializing outgoing data, in one class. Laravel splits these into two: FormRequest (Course 1, Chapter 7) validates input; API Resources handle output only. Neither replaces the other.

Creating a Resource

php artisan make:resource BookResource
// app/Http/Resources/BookResource.php
namespace App\Http\Resources;

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

class BookResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'price' => $this->price,
        ];
    }
}
// app/Http/Controllers/BookController.php
use App\Http\Resources\BookResource;

public function show(Book $book)
{
    return new BookResource($book);
}

public function index()
{
    return BookResource::collection(Book::all());
}

DRF's One Class vs Laravel's Two-Class Split

DRF: One Serializer, Both Directions
class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "price"]
# Same class validates incoming data AND serializes outgoing data.
Laravel: Two Classes, One Direction Each
// StoreBookRequest (Ch.7) โ€” validates INPUT only
// BookResource (this chapter) โ€” shapes OUTPUT only
// Neither class knows about the other.

๐Ÿ”€ Conditional Attributes

whenLoaded() is a genuinely Resource-specific feature โ€” it includes a relationship only if it was already eager-loaded, and never triggers a query of its own:

class BookResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => new AuthorResource($this->whenLoaded('author')),
            'is_premium' => $this->when($this->price > 50, true),
        ];
    }
}

whenLoaded('author')

Includes the author key only if with('author') was called upstream โ€” omits it entirely (not null) if the relationship was never eager loaded.

when($condition, $value)

Includes a field only if a condition is true โ€” useful for fields that only make sense in certain states (e.g. an admin-only field, or a computed flag).

Nesting Resources

// app/Http/Resources/AuthorResource.php
class AuthorResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
        ];
    }
}

Nesting AuthorResource inside BookResource's toArray() (as shown above) produces a JSON payload with the author embedded as a sub-object โ€” the same nested-shape idea as a DRF serializer with a nested serializer field.

Resource Collections & Pagination

public function index()
{
    return BookResource::collection(Book::paginate(20));
}
// Response automatically includes "data", "links", and "meta"
// (current_page, total, per_page, ...) โ€” no manual pagination wiring needed.

โš ๏ธ Resources Don't Prevent N+1 โ€” They Just Format It

A critical, easy-to-miss trap: a Resource transforms whatever data is on the model โ€” it does nothing to prevent the N+1 problem from Chapter 6 if the underlying query never eager-loaded the relationship in the first place:

N+1 Through a Resource vs Eager-Loaded First

Still N+1
public function index()
{
    return BookResource::collection(Book::all());
    // If BookResource::toArray() accesses $this->author,
    // that's still 1 query PER book โ€” the Resource doesn't know or care.
}
Fixed at the Query, Not the Resource
public function index()
{
    return BookResource::collection(
        Book::with('author')->get()
    );
    // Eager loading happens BEFORE the Resource ever runs.
}

whenLoaded() is the safety net for this specific trap โ€” using it instead of a direct $this->author access means a forgotten with('author') silently omits the field rather than silently triggering N+1 queries.

๐Ÿ’ป Coding Challenges

Challenge 1: Write a Basic Resource

Write an AuthorResource exposing id, name, and bio, and a controller index() method returning AuthorResource::collection(Author::all()).

Goal: Practice the basic toArray() shape and collection usage.

โ†’ Solution

Challenge 2: Nest a Resource With whenLoaded()

Extend BookResource to include a nested PublisherResource using whenLoaded('publisher'), and demonstrate the difference in output between a query that eager-loads publisher and one that doesn't.

Goal: Practice the safe, N+1-avoiding pattern for including a relationship.

โ†’ Solution

Challenge 3: Fix an N+1 Hiding Behind a Resource

Given a controller index() method returning BookResource::collection(Book::all()) where BookResource::toArray() directly accesses $this->author->name, identify the N+1 problem and fix it โ€” without changing BookResource itself.

Goal: Practice recognizing that the fix belongs at the query level, not inside the Resource.

โ†’ Solution

โš ๏ธ Gotcha: A Resource Formats What's There โ€” It Doesn't Fetch Efficiently

It's tempting to assume a nicely encapsulated Resource class handles performance concerns the way it handles shape concerns โ€” it doesn't. $this->author->name inside a toArray() method triggers a real Eloquent lazy load if author wasn't eager-loaded by the query that produced the model in the first place, exactly the same N+1 trap from Chapter 6, just one layer removed and easier to miss because the Resource class itself looks completely correct in isolation. Always check the query feeding a Resource collection, not just the Resource's own code, when investigating N+1 issues on an API endpoint.

๐ŸŽฏ What's Next

With a real API taking shape, the next chapter covers proving it works: Testing Laravel Apps โ€” PHPUnit, feature tests, and factories for generating test data.