Laravel / Application boundaries and reliable workflows / Lesson 11
API resources and bounded pagination
Define an intentional response schema and choose pagination according to ordering, count, freshness, and resource-cost requirements.
On this page
Lesson goals
- A resource is a response contract
- Expose a small post representation
- Choose offset or cursor behavior honestly
A resource is a response contract
An Eloquent model represents application data and persistence behavior; an API resource defines what this endpoint communicates to its consumers. Returning every model attribute can leak new fields when the schema evolves. Specify public fields, types, nullability, relationship shape, and any metadata deliberately. A model’s hidden configuration is useful but does not replace endpoint review or authorization. Resources should not decide which unauthorized records to fetch and then hope serialization hides them. Establish the visible record set before transformation. The synthetic endpoint in this lesson intentionally exposes published posts and public author names, never user email addresses or internal moderation notes.
Keep relationship loading outside serialization
A resource that reads an unloaded relation can accidentally perform one query per serialized item. whenLoaded lets the resource include a relationship only when the application already loaded it. It does not fetch that relationship or authorize it. Decide loading in the controller or query service, then make the response contract clear about whether an omitted relationship differs from a loaded null relationship. Passing a relation name rather than evaluating the relation before the conditional avoids triggering the very lazy load being prevented. Test multiple records, not just one, because N+1 behavior is easy to miss in a single-row fixture.
Expose a small post representation
The first resource assumes the stated Post schema and a boolean publication contract. It emits id, title, and published, with an author object only when author was loaded. The closure is evaluated under the conditional resource behavior rather than unconditionally reading the relationship. A loaded missing author is handled by the framework’s conditional helper semantics and should be included in tests if the schema permits it. The ordinary controller fragment that follows eagerly loads author:id,name and retains the matching keys. Resource transformation still runs inside the application lifecycle, so this class is not a standalone PHP program or evidence that an HTTP response was executed.
<?php
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,
"published" => (bool) $this->published,
"author" => $this->whenLoaded("author", fn () => [
"id" => $this->author->getKey(),
"name" => $this->author->name,
]),
];
}
}
Loaded author Mira: id/title/published plus author id/name. Unloaded author: author key omitted.
The application supplies a Post; whenLoaded checks its existing relationship state and does not load data. The projection deliberately excludes email and body.
Bound page size and make order deterministic
The second controller validates per_page between one and fifty, applies the publication scope, orders by a unique id, and returns a paginated resource collection. Under the default resource response convention, pagination includes data, links, and meta. Laravel preserves pagination structure even when ordinary resource wrapping has been disabled, but custom response code can deliberately change the contract. Given two published rows with ids 1 and 3 plus one draft, per_page=1 yields one published record per page and a total of two under the same stable fixture. Query-string propagation is limited to the approved page-size parameter instead of copying arbitrary request input into navigation links.
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Http\Resources\PostResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PublicPostIndexController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$input = $request->validate(["per_page" => ["sometimes", "integer", "between:1,50"]]);
$perPage = (int) ($input["per_page"] ?? 15);
$posts = Post::query()->where("published", true)
->with("author:id,name")->orderBy("id")
->paginate($perPage)->appends(["per_page" => $perPage]);
return PostResource::collection($posts);
}
}
Fixture with published ids 1 and 3, draft id 2, per_page=1: first data row id 1; pagination total 2.
This intentionally public synthetic listing still limits fields, records, and page size. Assume defined application routing, models, schema, and resource; no query was executed.
Choose offset or cursor behavior honestly
paginate provides total-count metadata and page-number navigation, while simplePaginate avoids a total count for next/previous workflows. Cursor pagination navigates using ordered values and can suit large changing datasets when its indexing and ordering requirements are met. It needs a unique ordering column or unique combination, and its documented restrictions include nullable ordering values. None of these methods creates an immutable snapshot across separate HTTP requests: concurrent inserts and deletes can affect a browsing session. Document freshness expectations and any stable export mechanism separately. Test empty pages, invalid limits, ties in nonunique sort fields, omitted relations, and authorization exclusions before treating a compact JSON response as a complete API.
Common errors and fixes
A new database column appears in public JSON
- Why it happens
- Raw model serialization became the endpoint contract.
- How to fix it
- Define explicit resource fields and review additions.
- Prevent it next time
- Test absence of sensitive fields.
Serializing a collection causes repeated relation queries
- Why it happens
- The resource dereferences unloaded relationships.
- How to fix it
- Load deliberately and use whenLoaded by relation name.
- Prevent it next time
- Measure multiple-row serialization behavior.
Page navigation repeats or skips tied records
- Why it happens
- Ordering lacks a stable unique tie-breaker.
- How to fix it
- Add a deterministic unique order and choose suitable pagination.
- Prevent it next time
- Test ties and concurrent-change expectations.
Check your understanding
1. Choose paginate or simplePaginate for a feed that needs only Next and Previous.
Show worked solution
simplePaginate can avoid the total-count query when no total or page-number navigation is required. Keep a stable order and limit, then verify its response contract. If a large changing feed requires cursor semantics, evaluate cursorPaginate separately rather than assuming simple pagination and cursor pagination are identical.
2. Why must an API test assert that a private email is absent rather than only checking title?
Show worked solution
A response can contain the correct title and still disclose extra data. Assert the approved schema and absence of sensitive fields, including nested relationships and error responses. This checks the projection boundary rather than merely proving one expected value exists somewhere in the payload.
Key takeaways
- Resources specify intentional output.
- Conditional inclusion does not load or authorize data.
- Pagination needs bounds, ordering, and freshness policy.