Skip to main content
Laravel, shipping fast.
// app/Http/Resources/ConsumerResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'contact_email' => $this->contact_email,
        'rate_limit' => $this->rate_limit,
        'license_limit' => $this->license_limit,
        'tokens_count' => $this->whenCounted('tokens'),
        'tokens' => TokenResource::collection(
            $this->whenLoaded('tokens'),
        ),
        'created_at' => $this->created_at,
    ];
}

With a model behind it, a Resource can reach properties directly: $this->name is forwarded to the model.

The two conditional lines are how a Resource and a query cooperate. whenCounted('tokens') includes the count only if the query asked for it with withCount('tokens'). whenLoaded('tokens') includes the tokens only if the query eager loaded them. If neither happened, the keys are simply absent. (Laravel documentation: Eloquent: API Resources › Conditional Relationships.)

That rule is what protects you from the N+1 problem in Chapter 9. A Resource that reads $this->tokens unconditionally runs a query per consumer the moment it is used on a list. A Resource that uses whenLoaded can’t trigger a query at all. The controller decides what is loaded. The Resource only reports it.

And settings is absent. It is not in the Resource, so it never leaves.

Listing: Pagination, Filtering, Sorting

A list endpoint is where a table-backed API earns or loses its performance, and where most of the input you didn’t think about arrives. Query parameters are input. Validate them like a body:

// app/Http/Requests/IndexConsumersRequest.php
public function rules(): array
{
    return [
        'search' => ['string', 'max:100'],
        'sort' => [Rule::in(['name', 'created_at'])],
        'direction' => [Rule::in(['asc', 'desc'])],
        'per_page' => ['integer', 'between:1,100'],
    ];
}
// app/Http/Controllers/ConsumerController.php
public function index(
    IndexConsumersRequest $request,
): AnonymousResourceCollection {
    $query = Consumer::query()->withCount('tokens');

    if ($request->filled('search')) {
        $term = $request->validated('search');
        $term = addcslashes($term, '\\%_');
        $query->whereLike('name', "%{$term}%");
    }

    $consumers = $query
        ->orderBy(
            $request->validated('sort', 'name'),
            $request->validated('direction', 'asc'),
        )
        ->paginate($request->integer('per_page', 25));

    return ConsumerResource::collection($consumers);
}

Each line of that request class prevents something.

sort is a whitelist. A column name can’t be bound as a query parameter the way a value can, so a sort column taken straight from the request is interpolated into SQL. Rule::in() is what makes orderBy($request->validated('sort')) safe. It also means a client can only sort by columns you have indexed.

per_page has a floor and a ceiling. Without the ceiling, a client can ask for the whole table. Without the floor, it can too: a negative limit is ignored by the query builder, and every row comes back.

search has a length. A ten-thousand-character search term is not a search. The controller also escapes %, _, and the backslash, so that a caller can’t turn its own wildcards loose on your index.

The response has the shape Chapter 2 promised, with the additions that only a paginator can provide. Each item is shortened here:

{
    "data": [
        { "id": 1, "name": "Partner A", "tokens_count": 2 },
        { "id": 2, "name": "Provisioning", "tokens_count": 1 }
    ],
    "links": { "next": ".../consumers?page=2", "prev": null },
    "meta": { "current_page": 1, "per_page": 25, "total": 40 }
}

You wrote none of links or meta. A consumer follows links.next until it is null, and never has to build a URL.

The audio could not be loaded. Try again in a moment.