Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 9 – APIs

Building JSON APIs with Laravel

Install API routes with install:api, keep error bodies consistent, lock down CORS for browsers, and version endpoints so your API can grow.

  • Intermediate
  • 10 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Add API routes with `php artisan install:api` and structure versioned endpoints under /api/v1
  • Render consistent JSON for a 404, a 405, a 422 and a 500, with debug off
  • Replace the open CORS defaults with an allowlist of exact origins
  • Compare URL-prefix and header versioning for an API that a cache and shared links reach

Before you start

What you’ll learn · Before you start

What you will learn

  • Add API routes with `php artisan install:api` and structure versioned endpoints under /api/v1
  • Render consistent JSON for a 404, a 405, a 422 and a 500, with debug off
  • Replace the open CORS defaults with an allowlist of exact origins
  • Compare URL-prefix and header versioning for an API that a cache and shared links reach

Before you start

On this page

Pathshala’s web app shows classes to parents in a browser, and the same data must soon fly to a React Native app on a phone. The browser already reaches Laravel through routes/web.php, but a phone wants plain JSON, and a browser from another address wants a cross-origin check before it even sends the request. A single artisan command, install:api, adds both.

This lesson installs the API routes file, builds a /api/v1/batches endpoint, and runs probes against it: a missing URL, the wrong method, a bad body and a server error. It then shows CORS twice, once with the open defaults and once with an allowlist of two front ends, so you can read the headers a browser would see.

install:api adds a routes file and switches error rendering

Laravel 13 keeps API scaffolding behind one command so the base install stays small. php artisan install:api does three things, read from the The code of a Laravel release, published on GitHub under the laravel/framework repository. (ApiInstallCommand.php): it installs Sanctum (composer require laravel/sanctum:^4.0, then it publishes Sanctum’s config and its token migration), it copies stubs/api-routes.stub into routes/api.php, and it edits bootstrap/app.php to pass that file to ->withRouting(api: ...). Unless you pass --without-migration-prompt, it ends by asking whether to run the migrations. A real run on a fresh project, with that prompt switched off, shows all three:

php artisan install:api on a fresh Laravel 13 project Python · install_api_session.py
from session import run

# install:api on a fresh Laravel 13 project, with the migration prompt switched off. Composer installs laravel/sanctum,
# Sanctum's config and token migration are published, routes/api.php is written and registered in bootstrap/app.php;
# the migration is left pending because --without-migration-prompt skips the question that would run it.
run(
    """
php artisan install:api --without-migration-prompt
cat routes/api.php
sed -n '7,15p' bootstrap/app.php
ls config
ls database/migrations
""",
    app=True,
)

Output

$ php artisan install:api --without-migration-prompt
./composer.json has been updated
Running composer update laravel/sanctum --with-all-dependencies
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 4 updates, 0 removals
  - Upgrading guzzlehttp/guzzle (8.2.0 => 8.3.0)
  - Upgrading guzzlehttp/promises (3.0.2 => 3.1.0)
  - Upgrading guzzlehttp/psr7 (3.1.0 => 3.2.0)
  - Locking laravel/sanctum (v4.3.3)
  - Upgrading league/commonmark (2.10.3 => 2.10.4)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 4 updates, 0 removals
  - Upgrading guzzlehttp/psr7 (3.1.0 => 3.2.0): Extracting archive
  - Upgrading guzzlehttp/promises (3.0.2 => 3.1.0): Extracting archive
  - Upgrading guzzlehttp/guzzle (8.2.0 => 8.3.0): Extracting archive
  … and 1 more lines like these
  - Installing laravel/sanctum (v4.3.3): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

   INFO  Discovering packages.

  laravel/pail .......................................................... DONE
  laravel/pao ........................................................... DONE
  laravel/sanctum ....................................................... DONE
  laravel/tinker ........................................................ DONE
  nesbot/carbon ......................................................... DONE
  nunomaduro/collision .................................................. DONE
  nunomaduro/termwind ................................................... DONE

79 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan vendor:publish --tag=laravel-assets --ansi --force

   INFO  No publishable resources for tag [laravel-assets].

No security vulnerability advisories found.

   INFO  Published API routes file.

   INFO  API scaffolding installed. Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model.
$ cat routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');
$ sed -n '7,15p' bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
$ ls config
app.php
auth.php
cache.php
database.php
filesystems.php
logging.php
mail.php
queue.php
sanctum.php
services.php
session.php
$ ls database/migrations
0001_01_01_000000_create_users_table.php
0001_01_01_000001_create_cache_table.php
0001_01_01_000002_create_jobs_table.php
YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table.php

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 install_api_session.py

Composer’s lines come first and Laravel’s after them. The stub’s one route sits in routes/api.php, bootstrap/app.php gained its api: line, config/sanctum.php appeared, and the token migration waits in database/migrations because the prompt that would run it was switched off. The command’s last message is a reminder to put Sanctum’s HasApiTokens trait on your User model.

The bootstrap/app.php of a new project already holds the exceptions block, and it does the second half of the job on its own. It calls shouldRenderJsonWhen with a closure that returns true for /api/* or for a request that A request whose Accept header asks for application/json, or whose body is JSON. Laravel reads the header and chooses the format of the response., so Laravel renders every exception as a JSON body for those requests, without a view in sight. The probes that follow use an overlay: the same bootstrap/app.php lines that install:api leaves, and a routes/api.php that holds this lesson’s batch routes in place of the Sanctum stub, so that they need no package. The probe prints both files and the routes they register:

The overlay's two files, and the routes they add Python · api_setup_session.py
from session import run

# install:api in Laravel 13 does three things: (1) composer require laravel/sanctum, (2) copy an API routes stub into
# routes/api.php, (3) uncomment or add api: __DIR__.'/../routes/api.php' in bootstrap/app.php. This session shows the
# effect, with the sanctum step cited from the Laravel source but the files themselves written by an overlay so the
# probe can run on the pinned fixture. The overlay replaces the fixture's bootstrap/app.php and adds a routes/api.php,
# the controller, the model and a migration.
run(
    """
# The overlay stands in for the three files install:api would create or change.
cat routes/api.php
cat bootstrap/app.php
php artisan route:list --path=api --except-vendor
php artisan migrate --no-interaction
php artisan tinker --execute="\\\\App\\\\Models\\\\Batch::create(['level' => 10, 'starts_on' => '2026-02-01', 'seats' => 30]); echo \\\\App\\\\Models\\\\Batch::count(), PHP_EOL;"
""",
    app=True,
    overlay=("api-probe",),
)

Output

# The overlay stands in for the three files install:api would create or change.
$ cat routes/api.php
<?php

// routes/api.php for Pathshala. Every URL in this file is served at /api/<path>, under the api middleware group.
// This file replaces the Sanctum stub install:api copies in, so the lesson can show the error shapes without
// requiring a package in the composer repo. The /user route is kept here as noted content, but is not reached by any
// probe in this lesson.
use App\Http\Controllers\Api\V1\BatchController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    // GET /api/v1/batches?level=10 — list batches, filtered by level.
    Route::get('/batches', [BatchController::class, 'index']);
    // GET /api/v1/batches/{batch} — show one batch.
    Route::get('/batches/{batch}', [BatchController::class, 'show']);
    // POST /api/v1/batches — create a batch (validation returns 422).
    Route::post('/batches', [BatchController::class, 'store']);
    // GET /api/v1/boom — a route that throws, to see what a 500 looks like with debug off and on.
    Route::get('/boom', fn () => throw new \RuntimeException('planned boom'));
});

// The /user route the stub installs, kept so route:list matches what install:api leaves (not reached in probes).
Route::get('/user', function (Request $request) {
    return $request->user();
});
$ cat bootstrap/app.php
<?php

// bootstrap/app.php as `php artisan install:api` leaves it: the only change is the api: line that points at
// routes/api.php. The exceptions block is the Laravel 13 default and already renders JSON for /api/* and for any
// request that asks for JSON.
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();
$ php artisan route:list --path=api --except-vendor
  GET|HEAD   api/user ...................................... routes/api.php:23
  GET|HEAD   api/v1/batches ..................... Api\V1\BatchController@index
  POST       api/v1/batches ..................... Api\V1\BatchController@store
  GET|HEAD   api/v1/batches/{batch} .............. Api\V1\BatchController@show
  GET|HEAD   api/v1/boom ................................... routes/api.php:19

                                                            Showing [5] routes
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_01_000000_create_batches_table ................................ DONE
$ php artisan tinker --execute="\\App\\Models\\Batch::create(['level' => 10, 'starts_on' => '2026-02-01', 'seats' => 30]); echo \\App\\Models\\Batch::count(), PHP_EOL;"
1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 api_setup_session.py

The route:list output shows the four /api/v1/batches routes and the /user route, numbered next to the file and line they come from. The database has three migrations now, and tinker inserts a batch straight into it.

Key idea

install:api is a bundle of edits: a Composer install of Sanctum with its files published, one stub copied, one line added to bootstrap/app.php. The JSON error rendering it needs is already in the exceptions block of the default bootstrap/app.php.

Interactive · message by message A /api/v1 request from kernel to JSON Step through a POST with a bad body from the client to the JSON response.
One POST, four layers, one JSON body, step 5 of 5. Messages so far: Client to api group: POST /v1/batches; api group to Controller: store(); Controller to api group: ValidationException; api group to Client: 422 + JSON.Clientcurl or appapi groupJSON errorControllerthrows 422POST /v1/batchesstore()ValidationException422 + JSON

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The request arrives. The client posts an empty body to /api/v1/batches with Accept application/json. The kernel sees /api/ and loads routes/api.php, which runs under the api group.
  2. The group reaches the controller. The group dispatches to the controller's store method. The request is intact, with the bad body.
  3. Validation throws. The three rules fail. $request->validate throws a ValidationException, which the controller never catches.
  4. Exceptions block renders JSON. shouldRenderJsonWhen sees the path /api/* and chooses JSON. One body shape, message plus errors, goes back.
  5. The client reads the 422. The body is JSON with message and errors. Every error body for /api/* has this shape, so one parser handles them all.

Checkpoint

Check: What install:api does

  1. Question 1 of 2 Which of these does php artisan install:api do on a Laravel 13 project?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Installs laravel/sanctum through Composer
    • Adds or uncomments the api: argument in bootstrap/app.php
    • Copies the API routes stub to routes/api.php

    install:api is a Composer install of Sanctum with its files published, one stub copied and one line added to bootstrap/app.php. The JSON renderer in the exceptions block is already in a new Laravel 13 project.

  2. Question 2 of 2 Why does a request to /api/v1/missing receive a JSON 404 instead of the HTML page?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The exceptions closure in bootstrap/app.php returns true for /api/*, so the renderer picks JSON

    The exceptions block is the single switch. For a path that does not match /api/* and a client that did not ask for JSON, the same 404 would render as the HTML page.

Error bodies have one shape across statuses

Every API client parses one body, so an error that is a page of HTML on one URL and a two-line JSON object on the next means two parsers. Laravel’s exceptions block, with the closure above, already keeps the shapes consistent. The probe sends four requests and reads back four JSON bodies, debug off:

404, 405, 422 and 500 with debug off, then 422 and 500 with debug on Python · error_shapes_session.py
from session import run

# Four requests show four JSON error shapes with debug off: 404, 405, 422 and 500. Then the same two that reveal
# differences — a 500 and a 422 — repeated with debug on, to show what the server adds.
run(
    """
php artisan migrate --no-interaction --quiet
php ask.php GET /api/v1/missing
php ask.php DELETE /api/v1/batches
php ask.php POST /api/v1/batches --json '{"level":99,"starts_on":"","seats":0}'
php ask.php GET /api/v1/boom
php ask.php POST /api/v1/batches --json '{"level":99,"starts_on":"","seats":0}' --debug
php ask.php GET /api/v1/boom --debug
""",
    app=True,
    overlay=("api-probe",),
)

Output

$ php artisan migrate --no-interaction --quiet
$ php ask.php GET /api/v1/missing
GET /api/v1/missing  →  404
content-type: application/json
access-control-allow-origin: *
{
    "message": "The route api/v1/missing could not be found."
}
---
$ php ask.php DELETE /api/v1/batches
DELETE /api/v1/batches  →  405
content-type: application/json
access-control-allow-origin: *
{
    "message": "The DELETE method is not supported for route api/v1/batches. Supported methods: GET, HEAD, POST."
}
---
$ php ask.php POST /api/v1/batches --json '{"level":99,"starts_on":"","seats":0}'
POST /api/v1/batches  →  422
content-type: application/json
access-control-allow-origin: *
{
    "message": "The level field is required. (and 2 more errors)",
    "errors": {
        "level": [
            "The level field is required."
        ],
        "starts_on": [
            "The starts on field is required."
        ],
        "seats": [
            "The seats field is required."
        ]
    }
}
---
$ php ask.php GET /api/v1/boom
GET /api/v1/boom  →  500
content-type: application/json
access-control-allow-origin: *
{
    "message": "Server Error"
}
---
$ php ask.php POST /api/v1/batches --json '{"level":99,"starts_on":"","seats":0}' --debug
POST /api/v1/batches  →  422
content-type: application/json
access-control-allow-origin: *
{
    "message": "The level field is required. (and 2 more errors)",
    "errors": {
        "level": [
            "The level field is required."
        ],
        "starts_on": [
            "The starts on field is required."
        ],
        "seats": [
            "The seats field is required."
        ]
    }
}
---
$ php ask.php GET /api/v1/boom --debug
GET /api/v1/boom  →  500
content-type: application/json
access-control-allow-origin: *
{
    "message": "planned boom",
    "exception": "RuntimeException",
    "file": "routes/api.php",
    "line": 19,
    "trace": [
        {
            "file": "vendor/laravel/framework/src/Illuminate/Routing/CallableDispatcher.php",
            "line": 39,
            "function": "{closure:{closure:routes/api.php:11}:19}",
            "class": "Illuminate\\Routing\\RouteFileRegistrar",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Routing/Route.php",
            "line": 254,
            "function": "dispatch",
            "class": "Illuminate\\Routing\\CallableDispatcher",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Routing/Route.php",
            "line": 219,
            "function": "runCallable",
            "class": "Illuminate\\Routing\\Route",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Routing/Router.php",
            "line": 822,
            "function": "run",
            "class": "Illuminate\\Routing\\Route",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
            "line": 180,
            "function": "{closure:Illuminate\\Routing\\Router::runRouteWithinStack():821}",
            "class": "Illuminate\\Routing\\Router",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Routing/Middleware/SubstituteBindings.php",
            "line": 52,
            "function": "{closure:Illuminate\\Pipeline\\Pipeline::prepareDestination():178}",
            "class": "Illuminate\\Pipeline\\Pipeline",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
            "line": 219,
            "function": "handle",
            "class": "Illuminate\\Routing\\Middleware\\SubstituteBindings",
            "type": "->"
        },
        {
            "file": "vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
            "line": 137,

Text longer than 4,000 characters is cut here; run the example yourself to see all of it.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 error_shapes_session.py

The 404 answers {"message": "The route api/v1/missing could not be found."}, the 405 lists the methods the route accepts, and the 422 carries the keys message and errors, where errors is a map from each field to an array of messages. The 500 answers {"message": "Server Error"} and nothing else, because APP_DEBUG=false.

With debug on, the 422 does not change at all, since a validation failure is not a programming error. The 500 grows four keys: exception, file, line and a trace array that lists every frame all the way up through the pipeline, and its message is now the exception’s own text, planned boom, instead of Server Error. Both bodies are plain JSON, both arrive with Content-Type: application/json, and both read the same way.

A 500 with debug on, on a server that other people can reach

A trace is a map of your application’s folders, class names and frameworks. A copy of the trace in a bug tracker is normal; a copy of it sent to a browser request from the internet is not. Keep APP_DEBUG=false on any server that is not your own machine.

The problem details (RFC 9457): A standard shape for an HTTP error body: a type URL, a title, a status, a detail and an instance URL. Services that want their errors parsed by many clients adopt it. standard names a different set of members: type, title, status, detail and instance, with the media type application/problem+json. It lets a service add members of its own, and its validation example adds an errors list. Laravel’s default differs in names and in shape, since its errors is a map from field to messages. Adopting the standard means replacing the renderer in the exceptions block, and what matters until then is that one shape covers every validation call.

Checkpoint

Check: One JSON shape for every error

  1. Question 1 of 2 The lesson's 422 body has two top-level keys. What are they?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: message and errors (a map from field names to arrays of messages)

    Laravel's validation renderer returns a message string and an errors map. The shape is the same for every validation call, so one parser handles them all.

  2. Question 2 of 2 With APP_DEBUG=true, which keys appear in a 500 body that are not there with debug off?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • file
    • line
    • trace
    • exception

    Four keys appear only with debug on: exception, file, line and trace. A 500 that reaches the internet with these keys is a map of your folders.

CORS defaults let any origin preflight

A browser that loads a page on one address and sends a request to another runs a CORS preflight: A short HTTP OPTIONS request a browser sends before a cross-origin request, to ask the server whether the method and the headers it plans to use are allowed.: an OPTIONS request with the method and the headers it means to use. The server answers with Access-Control-Allow-* headers, and the browser compares them with the origin and the method on the preflight. The server does not refuse a preflight; it answers, and the browser decides. Laravel’s default config/cors.php, read from the vendor folder, says allowed_origins: ['*'] for /api/* and /sanctum/csrf-cookie. The probe asks from a disallowed and from a known origin:

Preflight from https://evil.example and from https://pathshala.example with the defaults Python · cors_default_session.py
from session import run

# A browser never sees config/cors.php, but it sees the Access-Control-Allow-* headers Laravel sends in response to a
# preflight. The default is paths ['api/*', 'sanctum/csrf-cookie'] with allowed_origins ['*']: a disallowed origin and
# a malicious one both receive the same answer. The strict overlay publishes config/cors.php and lists exact origins.
run(
    """
php artisan migrate --no-interaction --quiet
# The default: no config/cors.php published, the framework uses its defaults with allowed_origins ['*'].
php ask.php OPTIONS /api/v1/batches --origin https://evil.example --access-control-request-method POST
php ask.php OPTIONS /api/v1/batches --origin https://pathshala.example --access-control-request-method POST
""",
    app=True,
    overlay=("api-probe",),
)

Output

$ php artisan migrate --no-interaction --quiet
# The default: no config/cors.php published, the framework uses its defaults with allowed_origins ['*'].
$ php ask.php OPTIONS /api/v1/batches --origin https://evil.example --access-control-request-method POST
OPTIONS /api/v1/batches  →  204
access-control-allow-origin: *
access-control-allow-methods: POST
access-control-allow-headers: content-type
vary: Access-Control-Request-Method, Access-Control-Request-Headers
(empty body)
---
$ php ask.php OPTIONS /api/v1/batches --origin https://pathshala.example --access-control-request-method POST
OPTIONS /api/v1/batches  →  204
access-control-allow-origin: *
access-control-allow-methods: POST
access-control-allow-headers: content-type
vary: Access-Control-Request-Method, Access-Control-Request-Headers
(empty body)
---

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cors_default_session.py

Both answers are 204 No Content with Access-Control-Allow-Origin: * and Access-Control-Allow-Methods: POST. A browser reads the star and lets the real request go, whether it came from pathshala.example or from evil.example. The second line is a 204 because the preflight was answered, not because the request would be allowed.

Publish config/cors.php with php artisan config:publish cors, keep the paths small, list the exact origins, and send only the methods and headers the API uses:

config/cors.php, tightened PHP · api-probe-strict/config/cors.php
<?php

// config/cors.php, tightened for Pathshala's two front ends.
// Only the names in allowed_origins may send browser requests to our API; everything else is refused.
return [

    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' => ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],

    'allowed_origins' => [
        'https://pathshala.example',
        'https://staff.pathshala.example',
    ],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With'],

    'exposed_headers' => [],

    'max_age' => 3600,

    'supports_credentials' => false,

];
The same preflights after publishing the restricted config Python · cors_strict_session.py
from session import run

# config/cors.php published and tightened: allowed_origins lists exactly two names. A preflight from a disallowed
# origin receives a response without an Access-Control-Allow-Origin header, which the browser treats as a refusal;
# a preflight from an allowed origin gets the header set to that origin.
run(
    """
php artisan migrate --no-interaction --quiet
php ask.php OPTIONS /api/v1/batches --origin https://evil.example --access-control-request-method POST
php ask.php OPTIONS /api/v1/batches --origin https://pathshala.example --access-control-request-method POST
php ask.php OPTIONS /api/v1/batches --origin https://staff.pathshala.example --access-control-request-method POST
""",
    app=True,
    overlay=("api-probe", "api-probe-strict"),
)

Output

$ php artisan migrate --no-interaction --quiet
$ php ask.php OPTIONS /api/v1/batches --origin https://evil.example --access-control-request-method POST
OPTIONS /api/v1/batches  →  204
vary: Origin, Access-Control-Request-Method
(empty body)
---
$ php ask.php OPTIONS /api/v1/batches --origin https://pathshala.example --access-control-request-method POST
OPTIONS /api/v1/batches  →  204
access-control-allow-origin: https://pathshala.example
access-control-allow-methods: GET, POST, PATCH, DELETE, OPTIONS
access-control-allow-headers: content-type, accept, authorization, x-requested-with
vary: Origin, Access-Control-Request-Method
(empty body)
---
$ php ask.php OPTIONS /api/v1/batches --origin https://staff.pathshala.example --access-control-request-method POST
OPTIONS /api/v1/batches  →  204
access-control-allow-origin: https://staff.pathshala.example
access-control-allow-methods: GET, POST, PATCH, DELETE, OPTIONS
access-control-allow-headers: content-type, accept, authorization, x-requested-with
vary: Origin, Access-Control-Request-Method
(empty body)
---

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cors_strict_session.py

Now the disallowed origin receives a 204 without an Access-Control-Allow-Origin header, which the browser treats as a refusal. The two allowed origins each receive the header set to their own name, so the browser lets the real request through. Vary: Origin tells any cache that the answer depends on the request’s origin, which keeps a shared cache: A cache that stores responses for many users, such as a CDN or a proxy in front of the application, unlike a browser's private cache, which serves one user. More in “Caching data, responses and the framework” from giving one tenant’s answer to another.

Interactive · stage by stage A preflight the browser likes, and one it does not Follow the preflight from the browser through the server's answer.
What a browser does with a preflight answer, step 6 of 6. Showing Browser asks first, Reads cors config, Origin is listed (allow-origin: set), Origin not listed (no header), Real request goes, Browser refuses. In focus: Browser refuses.header presentheader missingBrowser asks firstOPTIONS preflightReads cors configpathsOrigin is listedallow-origin: setOrigin not listedno headerReal request goessends POSTBrowser refusesno real request

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The browser prepares a preflight. Before a cross-origin POST, the browser sends OPTIONS with Origin, Access-Control-Request-Method and the headers the real request will use.
  2. Laravel runs the CORS middleware. The config paths list says api/* is in scope. With the defaults, allowed_origins is ['*'] and the origin check always passes.
  3. Response carries origin header. With the strict config, allowed_origins lists exact names, and only a request from one of them gets Access-Control-Allow-Origin set to that name.
  4. Disallowed origin: no header. The server still answers 204, but omits Access-Control-Allow-Origin, so the browser treats the preflight as refused.
  5. Allowed origin sends the POST. The browser compares the response against the Origin it sent and the method it asked about, and lets the real request through.
  6. A disallowed origin stops here. The real POST is never sent. A server-side script could still call the API from any address, so this is a browser-only promise.

A 204 preflight is not an authorization

The server will keep answering preflights with 204, and the real request still has to pass authentication and validation. The CORS layer is a browser-side promise; a server-side script can call your API from any address regardless of the headers.

Checkpoint

Check: CORS defaults and the restricted config

  1. Question 1 of 2 The default config/cors.php has allowed_origins ['*']. A browser from https://evil.example sends an OPTIONS preflight to /api/v1/batches. What does the response carry?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Access-Control-Allow-Origin "*"

    With allowed_origins ['*'] every preflight succeeds. The server does not refuse a disallowed origin; it is up to the browser, and the browser is told everything is allowed.

  2. Question 2 of 2 The restricted config lists two origins. Which preflights, from the recorded run, carry Access-Control-Allow-Origin set to the request's origin?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • An OPTIONS from https://pathshala.example
    • An OPTIONS from https://staff.pathshala.example

    The restricted config maps each exact origin to a header value, and omits the header for a disallowed origin. The 204 status does not change; the headers do.

Version with a URL prefix or a header

An API that leaves clients on an old shape while new fields appear needs a version in the request. The probe uses /api/v1/batches, the shape every client has agreed to. A later /api/v2/batches can add or rename fields without surprising the old client. The URL prefix is one choice; a header such as Accept: application/vnd.pathshala.v1+json is another. Both are in wide use; the trade-off is a short list:

Interactive · answer and compare URL-prefix versus header versioning Answer the two questions. The table marks each choice and its reasons.
Who calls this API?
Does a shared cache or a signed URL carry the request?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Two ways to pin an API's version against four everyday concerns
Choice Developer tools Caches and signed URLs Finding v2 later Client code
URL prefix How a client calls it: GET /api/v1/batches Good The URL carries the version, so curl, a browser and a log line all read it. Good A cache keys on the URL, so v1 and v2 are separate pages; a signed URL keeps its version too. Good Add routes/api-v2.php or a /v2 prefix group; v1 keeps answering. Good One line to change, in one place.
Request header How a client calls it: GET /api/batches with Accept: application/vnd.pathshala.v1+json Mixed curl needs an extra -H, a browser bar does not show the version. Mixed A cache must vary on Accept, or every client gets one random version. Mixed Content negotiation needs code; a wrong header is a client bug that is hard to see. Mixed The version lives in each client, which is easier to miss than a URL.
How the answers decide
  • “Who calls this API?” Browsers and third parties: rules out Request header. A URL in a help page, a bug report or a browser bar must show the version, so a third party knows what they are calling.
  • “Does a shared cache or a signed URL carry the request?” Yes: rules out Request header. A cache keys on the URL by default; a header versioning needs Vary: Accept or a custom key, which is more code and more mistakes.
  • “Who calls this API?” Only inside our team and “Does a shared cache or a signed URL carry the request?” No: adds a caveat to Request header. Header versioning fits an internal API with few, careful clients and no shared cache; even then, the URL prefix is the lower-maintenance choice.

A URL prefix is read by every tool a developer uses: curl, a browser’s address bar, a log line, a cache’s key. A header keeps each resource at one stable URL, but it hides the version from those tools, and a cache keyed on the URL alone must also vary on the header or it will serve one client’s version to another. A URL that is signed and shared by SMS carries its version only when the version is in the path. The lesson’s probe uses the prefix because it reads more clearly on a web page. Whichever you pick, keep the version explicit; a client that assumes the latest will break the next time you ship.

OpenAPI documents are a third concern. Laravel does not generate them; a third-party package such as dedoc/scramble reads your routes and controllers and writes the OpenAPI: A machine-readable description of an HTTP API: its paths, methods, request bodies, responses and security schemes. Tools read it to make clients, mocks and documentation.. The api-design track covers the design side (resource shapes, error standards, pagination). This lesson stops at making the endpoints answer with consistent JSON; the next lesson covers resources and pagination.

Checkpoint

Check: Versioning with a prefix or a header

  1. One question Pathshala's API answers from a CDN cache for 15 seconds and shares short signed URLs over SMS. Which versioning fits best, and why?

    Choose one answer.

    Show the answer: One question

    Answer: A URL prefix (/api/v1 and later /api/v2), because the cache keys on the URL and a signed URL carries its version in the path

    Caches and signed URLs reach the version through the URL, so a URL prefix separates versions by cache key and makes the version visible to a developer and a log line.

Practise

Use your Pathshala project and add a small, consistent endpoint:

  1. Run php artisan install:api --without-migration-prompt on a copy of the project, read bootstrap/app.php and routes/api.php, and keep both in version control.
  2. Build GET /api/v1/batches and POST /api/v1/batches with validation. Hit the POST without a body and read back the 422 shape; hit it with a bad level and read the field message.
  3. Publish config/cors.php and list exactly the origins of your two front ends. From a terminal, send an OPTIONS preflight from a third address and confirm the response does not carry Access-Control-Allow-Origin.
  4. Add /api/v1/boom that throws, and read the 500 body with debug off, then on. Keep the server’s APP_DEBUG set to false and read the 500 only in your local .env.

Then work through the self-check.

Practice that adapts to you

Practise this lesson

10 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 What is the first thing install:api does when it runs on a fresh Laravel 13 project?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Runs composer require laravel/sanctum

    Composer first, so the stub it copies does not reference a missing class.

  2. Question 2 of 10 You run install:api on a project where routes/api.php already exists from an earlier run. What happens?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The command reports that the file exists and leaves it alone

    Without --force the command keeps your routes file and reports an error. The Sanctum install ran before that check, but the stub copy and the edit of bootstrap/app.php belong to the same branch, so neither runs.

  3. Question 3 of 10 Your team wants to add a request id to every error body. Where in Laravel 13 does the change go?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: In the exceptions block of bootstrap/app.php, using $exceptions->respond(...) to add the key

    Shape error bodies in one place and every status gets the extra key. The exceptions block is the one place Laravel 13 names for exception rendering.

  4. Question 4 of 10 You push an app to a server, keep APP_DEBUG=true to debug a problem, and leave it. What is the risk?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: A 500 body carries file paths, class names and a full stack trace to any caller

    APP_DEBUG=true is for a local run. On a server, the exception detail reaches whoever triggers a 500.

  5. Question 5 of 10 Where in the config/cors.php does the paths key appear, and what does it control?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: paths lists the URL prefixes the CORS layer applies to; the default is api/* and sanctum/csrf-cookie

    paths is a per-URL opt-in. A route outside the list sends none of the Access-Control-* headers at all, regardless of the other keys.

  6. Question 6 of 10 Your SPA needs to send a cookie-based login, so you turn supports_credentials to true in config/cors.php. What must change on the same line as allowed_origins?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: allowed_origins can no longer be ['*']; it must list exact origins

    Credentials and a wildcard origin are incompatible by design. The sanctum lesson of this module covers the cookie mode.

  7. Question 7 of 10 Which of these is NOT a reason to version an API?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: To hide implementation details from clients

    Resource shapes are the public contract; versions let that contract change without a surprise. Internals are hidden by the resource, not by the version.

  8. Question 8 of 10 You add v2 of /api/batches. A legacy phone app still asks for /api/batches with no prefix. How do you keep both working?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Keep /api/v1/batches as the current behaviour, and register the old /api/batches path over the same controller

    A legacy app that cannot be updated needs its shape preserved. A URL alias keeps the old path pointing at v1 while new clients opt into v2.

  9. Question 9 of 10 You decide to adopt RFC 9457 for all error bodies. What are the two biggest costs you have taken on, compared with the Laravel default?

    Choose every answer that is right.

    Show the answer: Question 9 of 10

    Answer:

    • Every exception renderer has to be customised to produce title, status and detail instead of message
    • Clients that read the Laravel default (message, errors) break and must be updated

    RFC 9457 is a sensible choice when many teams must integrate; its cost is a renderer per status and a breaking change for every current client.

  10. Question 10 of 10 Why does the restricted CORS response carry Vary Origin?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: So a shared cache does not serve one origin's answer to another

    A shared cache that keyed on the URL alone would mix tenants' answers. Vary: Origin keeps them apart.

Interview questions

Warm-up: what happens, step by step, when install:api runs on a fresh Laravel 13 project?

The command is one of Laravel’s installers. It calls Composer to install laravel/sanctum:^4.0, so the generated routes file can use auth:sanctum without a missing-class error, and it publishes Sanctum’s config and token migration. It then copies stubs/api-routes.stub into routes/api.php; the stub holds one route, GET /user, behind the auth:sanctum middleware. Last, it edits bootstrap/app.php to add an api: argument to withRouting, which points at the new file, so the kernel loads those routes into the api middleware group. The exceptions block that was already there renders JSON for /api/* or any JSON-accepting request. A later lesson of this module adds tokens with Sanctum; this one stops at the installer.

Three longer questions come from this lesson:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Pathshala's /api/v1 endpoints are called by a browser SPA and by a few webhooks. The default config/cors.php answers every preflight with Access-Control-Allow-Origin: *. Explain the fix step by step, with the exact file and the exact two or three keys you would change.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A junior developer leaves APP_DEBUG=true on a staging server people outside the team can reach. A probe hits a broken route and copies the response body. Describe what is in the body, how it reaches an attacker, and what you change so it cannot happen again.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    The lead has built /api/batches without a version. A new field must change its name and type. Lay out the plan for picking a versioning scheme, keeping the existing clients, and shipping the change.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does php artisan install:api change in a fresh Laravel 13 project?

    It installs laravel/sanctum with Composer and publishes Sanctum's config and token migration, copies a stub into routes/api.php (one GET /user route behind auth:sanctum), and passes that file to withRouting in bootstrap/app.php. Adding the HasApiTokens trait to User stays your edit.

  2. Why does an /api route answer its errors as JSON without any extra setup?

    The exceptions block of the default bootstrap/app.php calls shouldRenderJsonWhen with a closure that is true for /api/* or for a request that asks for JSON, so every exception on those requests renders as a JSON body.

  3. What do the 404, 405, 422 and 500 JSON bodies of a Laravel API look like with debug off?

    Each has a message key; the 405 also lists the allowed methods, the 422 adds an errors map from each field to its messages, and the 500 says only Server Error. One shape means one parser in every client.

  4. What does a 500 JSON body add when APP_DEBUG is true?

    The exception class, the file, the line and the full stack trace, with the exception's own message, a map of your code for whoever sent the request. Keep APP_DEBUG=false on every server that is not your own machine.

  5. How does Laravel's default CORS configuration answer a preflight from any origin?

    With 204 and Access-Control-Allow-Origin: *, so a browser lets an attacker's page call the API as freely as your own. Publish config/cors.php with php artisan config:publish cors and list the exact origins; Vary: Origin then keeps shared caches from mixing answers.

  6. Does a CORS allowlist stop a script on a server from calling your API?

    No. CORS headers are read and obeyed only by browsers; a program running on some other server ignores them entirely, so authentication and validation still decide every request.

  7. How do you version an API, and what does each choice cost?

    With a URL prefix such as /api/v1, which every tool, log line and cache key shows, or a header such as Accept: application/vnd.pathshala.v1+json, which keeps one URL but hides the version and makes caches vary on the header. Either way, keep the version explicit.

Key takeaways

  • install:api is Composer plus one stub plus one line in bootstrap/app.php, and the exceptions closure that renders JSON is already in the file.
  • Four error statuses, 404, 405, 422 and 500, each have one JSON shape, so a client parses one body for every error it meets.
  • A 500 body with debug on carries the exception, the file, the line and the full trace; keep APP_DEBUG=false on any server a stranger can reach.
  • The default CORS config answers every preflight with Access-Control-Allow-Origin: *, so a browser trusts an attacker’s page as much as your own; publish config/cors.php and list exact origins.
  • Version endpoints explicitly, with a URL prefix or a header, and keep the version in the request even when there is only one; OpenAPI is a separate step owned by a third-party package.

Terms in this lesson

CORS preflight
A short HTTP OPTIONS request a browser sends before a cross-origin request, to ask the server whether the method and the headers it plans to use are allowed.
OpenAPI
A machine-readable description of an HTTP API: its paths, methods, request bodies, responses and security schemes. Tools read it to make clients, mocks and documentation.
problem details (RFC 9457)
A standard shape for an HTTP error body: a type URL, a title, a status, a detail and an instance URL. Services that want their errors parsed by many clients adopt it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.