Building APIs and API Resources

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Bookshelf gets a public catalogue API in two versions. Version 1 uses plain API resources, which give full control over the JSON. Version 2 uses the JSON:API resources added in Laravel 13. Both are tested with Pest, and every response below is real output.

Setting up the API

A new application has no routes/api.php. php artisan install:api creates it, installs Sanctum and registers the file in bootstrap/app.php. Its routes get the api middleware group and the /api prefix. They are stateless: no session, no cookies and no request forgery check, unless Sanctum’s statefulApi() is turned on for an SPA (API Authentication: Sanctum and Passport).

Bookshelf’s version 1 routes, with a version prefix and a named rate limiter:

<?php
// routes/api.php
Route::prefix('v1')->name('api.v1.')->middleware('throttle:api')->group(function () {
    Route::apiResource('books', App\Http\Controllers\Api\V1\BookController::class)->only(['index', 'show', 'store'])
        ->middlewareFor('store', 'auth:sanctum');
});

// app/Providers/AppServiceProvider.php, in boot(): signed-in clients get more
RateLimiter::for('api', fn (Request $request) => $request->user()
    ? Limit::perMinute(120)->by('user:'.$request->user()->id)
    : Limit::perMinute(30)->by('ip:'.$request->ip()));

throttle:api throws MissingRateLimiterException ("Rate limiter [api] is not defined.") until the limiter is defined, and install:api does not define one. The api group itself has no rate limit unless $middleware->throttleApi() is called in bootstrap/app.php. The 429 response and its headers are shown on Security: CSRF, Encryption and Hashing.

API resources

A resource class sits between the model and the JSON. Without one, return $book serializes every attribute that is not hidden. Any column added later then becomes part of the API. php artisan make:resource V1/BookResource creates the class, and toArray lists the fields explicitly:

<?php
// app/Http/Resources/V1/BookResource.php
namespace App\Http\Resources\V1;

use App\Enums\Role;
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,
            'isbn' => $this->isbn,
            'published_year' => $this->published_year,
            'author' => AuthorResource::make($this->whenLoaded('author')),
            'reviews_count' => $this->whenCounted('reviews'),
            'average_rating' => $this->whenAggregated('reviews', 'rating', 'avg', fn ($avg) => round((float) $avg, 1)),
            $this->mergeWhen($request->user('sanctum')?->hasRole(Role::Librarian, Role::Admin) ?? false, [
                'meta' => $this->meta,
                'created_at' => $this->created_at,
            ]),
            'links' => ['self' => route('api.v1.books.show', $this->resource)],
        ];
    }
}

The when* methods drop the key entirely when the data was not loaded. That is why the single-book response below has no reviews_count. They also mean that relationships read through whenLoaded never trigger a lazy load, so the resource cannot cause N+1 queries by itself.

The controller validates the query string before building the query. Filters and sort columns come from an allow-list, never straight from the request (Database and Query Builder):

<?php
// app/Http/Controllers/Api/V1/BookController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBookRequest;
use App\Http\Resources\V1\BookResource;
use App\Models\Book;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class BookController extends Controller
{
    private const SORTABLE = ['title', 'published_year'];

    public function index(Request $request)
    {
        $request->validate([
            'sort' => [Rule::in([...self::SORTABLE, ...array_map(fn ($c) => "-$c", self::SORTABLE)])],
            'author' => ['integer'],
            'per_page' => ['integer', 'between:1,50'],
        ]);
        $sort = $request->input('sort', 'title');

        $books = Book::query()
            ->with('author')
            ->withCount('reviews')
            ->withAvg('reviews', 'rating')
            ->when($request->integer('author'), fn ($query, int $id) => $query->where('author_id', $id))
            ->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc')
            ->orderBy('id')                                      // a stable order for pagination
            ->paginate($request->integer('per_page', 15))
            ->withQueryString();

        return BookResource::collection($books)->additional(['meta' => ['api_version' => '1']]);
    }

    public function show(Book $book): BookResource
    {
        return BookResource::make($book->load('author'));
    }

    public function store(StoreBookRequest $request): BookResource
    {
        return BookResource::make(Book::create($request->safe()->except(['format', 'ebook_url', 'tags', 'cover', 'blurb'])));
    }
}

A page of two books by one author, oldest first:

$ curl -s -H 'Accept: application/json' 'localhost:8000/api/v1/books?author=1&per_page=2&sort=published_year'
{
  "data": [
    {
      "id": 1,
      "title": "Kindred",
      "isbn": "978-0-8070-8305-4",
      "published_year": 1979,
      "author": { "id": 1, "name": "Octavia E. Butler" },
      "reviews_count": 1,
      "average_rating": 5,
      "links": { "self": "http://localhost:8000/api/v1/books/1" }
    },
    {
      "id": 24,
      "title": "Wild Seed",
      "isbn": "import-9d05ace6d1e8e7a4e795c5f1b8a0bbe6",
      "published_year": 1980,
      "author": { "id": 1, "name": "Octavia E. Butler" },
      "reviews_count": 0,
      "average_rating": null,
      "links": { "self": "http://localhost:8000/api/v1/books/24" }
    }
  ],
  "links": {
    "first": "http://localhost:8000/api/v1/books?author=1&per_page=2&sort=published_year&page=1",
    "last": "http://localhost:8000/api/v1/books?author=1&per_page=2&sort=published_year&page=2",
    "prev": null,
    "next": "http://localhost:8000/api/v1/books?author=1&per_page=2&sort=published_year&page=2"
  },
  "meta": {
    "current_page": 1, "from": 1, "last_page": 2, "links": ["…"], "path": "http://localhost:8000/api/v1/books",
    "per_page": 2, "to": 2, "total": 4, "api_version": "1"
  }
}
  • additional() merges into meta: api_version ends up next to the paginator’s own keys. It does not replace them.

  • withQueryString() keeps the filters in the page links. Without it, next drops author and sort.

  • meta.links is the paginator’s list of page buttons, which is useful to an HTML client and noise to most API clients. Switching to simplePaginate or cursorPaginate removes it, along with the COUNT(*) query (Collections and Pagination, Pagination Strategies).

Who is asking

The mergeWhen fields only appear for librarians. The show route is public, though, so it has no auth:sanctum middleware. The first version of the resource called $request->user(), which asks the default web guard. That guard knows nothing about bearer tokens, so the response was the same with and without a librarian’s token. Asking the Sanctum guard by name fixes it:

$ curl -s -H 'Accept: application/json' localhost:8000/api/v1/books/1
{"data":{"id":1,"title":"Kindred","isbn":"978-0-8070-8305-4","published_year":1979,"author":{"id":1,"name":"Octavia E. Butler"},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/books\/1"}}}
$ curl -s -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/books/1   # with $request->user('sanctum')
{"data":{"id":1,"title":"Kindred",…,"meta":null,"created_at":"2026-10-05T18:51:25.000000Z","links":{…}}}

Creating and errors

A resource wrapping a model that was just created responds with 201 Created automatically, because the model’s wasRecentlyCreated flag is set. Errors come from the framework’s exception handler:

$ curl -s -w ' %{http_code}\n' -H 'Accept: application/json' -d 'title=x' localhost:8000/api/v1/books
{"message":"Unauthenticated."} 401
$ curl -s -w ' %{http_code}\n' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" -d 'title=&author_id=999' localhost:8000/api/v1/books
{"message":"The selected author is invalid. (and 3 more errors)","errors":{"author_id":["The selected author is invalid."],"title":["The title field is required."],"isbn":["The isbn field is required."],"format":["The format field is required."]}} 422
$ curl -s -i -H "Authorization: Bearer $TOKEN" --json '{"author_id":1,"title":"Fledgling","isbn":"978-0-446-69616-6","published_year":2005,"format":"paperback"}' localhost:8000/api/v1/books
HTTP/1.1 201 Created
{"data":{"id":27,"title":"Fledgling","isbn":"978-0-446-69616-6","published_year":2005,"meta":null,"created_at":"2026-10-05T21:36:47.000000Z","links":{"self":"http:\/\/localhost:8000\/api\/v1\/books\/27"}}}
$ curl -s -i localhost:8000/api/v1/books/999 | grep Content-Type
Content-Type: application/problem+json

The 404 body is the RFC 9457 problem+json object that Bookshelf’s exception renderer builds for api/* (Error Handling and Logging). The 401 and 422 bodies are Laravel’s own format. The 201 response has no Location header. A client that expects one gets it from BookResource::make($book)->response()->header('Location', route('api.v1.books.show', $book)).

JSON errors depend on the Accept header. Bookshelf sets $exceptions->shouldRenderJsonWhen(fn ($request) => $request->is('api/*') || $request->expectsJson()) in bootstrap/app.php. With that line commented out, the same requests without Accept: application/json behaved like a browser form: the unauthenticated POST got 302 to /login, and the invalid ?sort=x got 302 back with errors in the session. A mobile client that forgets the header then follows a redirect to an HTML page. The line belongs in every application that has an API.

JSON:API resources

Laravel 13’s JsonApiResource writes the JSON:API format: type and string id, attributes, relationships with resource identifiers, a top-level included array, sparse fieldsets, and the Content-Type: application/vnd.api+json header. php artisan make:resource V2/BookResource --json-api generates a class with $attributes and $relationships properties:

<?php
// app/Http/Resources/V2/BookResource.php (AuthorResource and ReviewResource list name, and rating and body)
namespace App\Http\Resources\V2;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class BookResource extends JsonApiResource
{
    public $attributes = ['title', 'isbn', 'published_year'];

    public $relationships = ['author' => AuthorResource::class, 'reviews' => ReviewResource::class];

    public function toLinks(Request $request): array
    {
        return ['self' => route('api.v2.books.show', $this->resource)];
    }
}

// routes/api.php
Route::prefix('v2')->name('api.v2.')->middleware('throttle:api')->group(function () {
    Route::get('books', fn () => App\Http\Resources\V2\BookResource::collection(App\Models\Book::paginate(10)))->name('books.index');
    Route::get('books/{book}', fn (App\Models\Book $book) => $book->toResource(App\Http\Resources\V2\BookResource::class))->name('books.show');
});

Relationships appear only when the client asks for them with include:

$ curl -s 'localhost:8000/api/v2/books/1?include=author,reviews'
{
  "data": {
    "id": "1",
    "type": "books",
    "attributes": { "title": "Kindred", "isbn": "978-0-8070-8305-4", "published_year": 1979 },
    "relationships": {
      "author": { "data": { "id": "1", "type": "authors" } },
      "reviews": { "data": [ { "id": "1", "type": "reviews" } ] }
    },
    "links": { "self": "http://localhost:8000/api/v2/books/1" }
  },
  "included": [
    { "id": "1", "type": "authors", "attributes": { "name": "Octavia E. Butler" } },
    { "id": "1", "type": "reviews", "attributes": { "rating": 5, "body": "Unforgettable." } }
  ]
}

fields[books]=title trims the attributes to title (curl -g stops curl from treating the brackets as a glob). Counting the queries for a page of ten books showed what the convenience costs:

/api/v2/books                                  2 queries
/api/v2/books?include=author                  12 queries
/api/v2/books?include=author,reviews          22 queries
/api/v2/books-eager?include=author,reviews     4 queries

The resource loads each requested relationship separately for each book (where "authors"."id" in (1), once per row), which is the N+1 problem again (Query Performance and N+1). The books-eager variant eager-loads the requested relationships that are on an allow-list. The resource then finds them already loaded, and the response is the same:

<?php
Route::get('books-eager', function (Illuminate\Http\Request $request) {
    $include = array_intersect(explode(',', $request->query('include', '')), ['author', 'reviews']);

    return App\Http\Resources\V2\BookResource::collection(App\Models\Book::with($include)->paginate(10));
});

The run also showed two smaller gaps:

  • Pagination links drop fields and include unless the paginator gets ->withQueryString().

  • An unknown include is ignored. ?include=nope and ?include=tags returned 200 without the relationship. The JSON:API specification asks for a 400 Bad Request here. Validating include against the allow-list produces that response.

JSON:API resources only format output. For filtering and sorting in JSON:API style (filter[author]=1, sort=-published_year), the Laravel documentation points to spatie/laravel-query-builder (7.3 supports Laravel 12 and 13), which applies the same allow-list idea as the version 1 controller.

Versioning

Bookshelf versions in the URI (/api/v1, /api/v2) with separate resource namespaces. The two versions share models, validation and policies, and only the representation differs. Other schemes work with the same structure. A header such as Accept: application/vnd.bookshelf.v2+json can be read in a middleware that picks the resource class. A date version in a header, as Stripe uses, can do the same. URI versions are the easiest to cache, log and test with curl. When version 1 is retired, the Deprecation (RFC 9745) and Sunset (RFC 8594) response headers tell clients the dates in advance, and a middleware on the v1 group can add them.

Documentation and GraphQL

Laravel has no built-in OpenAPI generator. Two packages are common, and both support Laravel 13:

  • dedoc/scramble (0.13) generates an OpenAPI 3.1 document by static analysis of routes, Form Requests and resources, with no annotations.

  • darkaonline/l5-swagger (11.1) serves Swagger UI from swagger-php attributes written on the controllers.

The generated document can feed contract tests and client generators. For a GraphQL API instead of REST, nuwave/lighthouse (6.71) is the schema-first server. Types are written in SDL with directives such as @paginate, @belongsTo and @can, and it batches relationship loading to avoid N+1. The concepts are on GraphQL, in particular Schema Design, Performance and N+1 and Security and Demand Control.

Testing the API

getJson and postJson send Accept: application/json. AssertableJson checks the structure step by step, and Sanctum::actingAs signs in without a real token:

<?php
// tests/Feature/BookApiTest.php
use App\Enums\Role;
use App\Models\{Author, Book, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Sanctum\Sanctum;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->author = Author::factory()->create(['name' => 'Octavia E. Butler']);
    Book::factory()->for($this->author)->create(['title' => 'Kindred', 'published_year' => 1979]);
    Book::factory()->for($this->author)->create(['title' => 'Wild Seed', 'published_year' => 1980]);
});

test('lists books newest first with pagination meta', function () {
    $this->getJson('/api/v1/books?sort=-published_year')
        ->assertOk()
        ->assertJson(fn (AssertableJson $json) => $json
            ->has('data', 2)
            ->has('data.0', fn (AssertableJson $book) => $book
                ->where('title', 'Wild Seed')
                ->where('author.name', 'Octavia E. Butler')
                ->where('reviews_count', 0)
                ->missing('meta')
                ->etc())
            ->where('meta.total', 2)
            ->where('meta.api_version', '1')
            ->has('links'));
});

test('rejects an unknown sort column', function () {
    $this->getJson('/api/v1/books?sort=password')
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['sort' => 'The selected sort is invalid.']);
});

test('librarians see catalogue fields and can create books', function () {
    Sanctum::actingAs(User::factory()->create(['role' => Role::Librarian]));

    $this->getJson('/api/v1/books/1')->assertJsonPath('data.meta', null)->assertJsonStructure(['data' => ['created_at']]);

    $this->postJson('/api/v1/books', [
        'author_id' => $this->author->id, 'title' => 'Fledgling', 'isbn' => '978-0-446-69616-6', 'format' => 'paperback',
    ])->assertCreated()->assertJsonPath('data.title', 'Fledgling');
});

test('guests cannot create books', function () {
    $this->postJson('/api/v1/books', ['title' => 'x'])->assertUnauthorized();
});

test('json:api includes are compound documents', function () {
    $this->get('/api/v2/books/1?include=author', ['Accept' => 'application/vnd.api+json'])
        ->assertHeader('Content-Type', 'application/vnd.api+json')
        ->assertJsonPath('data.type', 'books')
        ->assertJsonPath('data.relationships.author.data', ['id' => (string) $this->author->id, 'type' => 'authors'])
        ->assertJsonPath('included.0.attributes.name', 'Octavia E. Butler');
});
$ php artisan test tests/Feature/BookApiTest.php

   PASS  Tests\Feature\BookApiTest
  ✓ lists books newest first with pagination meta                        0.23s
  ✓ rejects an unknown sort column                                       0.02s
  ✓ librarians see catalogue fields and can create books                 0.03s
  ✓ guests cannot create books                                           0.01s
  ✓ json:api includes are compound documents                             0.02s

  Tests:    5 passed (33 assertions)

->etc() allows other keys at that level. Without it, AssertableJson fails on any key the test did not check, which catches fields that leak into the API by accident.

Where older books and tutorials are outdated

  • Returning models or toArray() straight from controllers (Stauffer ch. 13 starts this way) ties the API to the table. Every new column is published. Resources have been the answer since Laravel 5.5.

  • routes/api.php present in every application is no longer the case. Since Laravel 11 it is opt-in through install:api, which also installs Sanctum.

  • fractal transformers and hand-written JSON:API serializers are replaced by JsonResource and, from Laravel 13, JsonApiResource.

  • Route::middleware('auth:api') with the api_token column is replaced by Sanctum tokens (API Authentication: Sanctum and Passport).

  • A rate limit on the api group by default (throttle:60,1, then throttle:api with a limiter in RouteServiceProvider up to Laravel 10) is gone. The group has no limit until throttleApi() or a throttle middleware adds one, with limiters defined by RateLimiter::for.