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.
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:
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:
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.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- The group reaches the controller. The group dispatches to the controller's store method. The request is intact, with the bad body.
- Validation throws. The three rules fail. $request->validate throws a ValidationException, which the controller never catches.
- Exceptions block renders JSON. shouldRenderJsonWhen sees the path /api/* and chooses JSON. One body shape, message plus errors, goes back.
- 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
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:
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
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:
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:
<?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,
]; 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Disallowed origin: no header. The server still answers 204, but omits Access-Control-Allow-Origin, so the browser treats the preflight as refused.
- 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.
- 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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
Practise
Use your Pathshala project and add a small, consistent endpoint:
- Run
php artisan install:api --without-migration-prompton a copy of the project, readbootstrap/app.phpandroutes/api.php, and keep both in version control. - Build
GET /api/v1/batchesandPOST /api/v1/batcheswith validation. Hit the POST without a body and read back the 422 shape; hit it with a bad level and read the field message. - Publish
config/cors.phpand list exactly the origins of your two front ends. From a terminal, send anOPTIONSpreflight from a third address and confirm the response does not carryAccess-Control-Allow-Origin. - Add
/api/v1/boomthat throws, and read the 500 body with debug off, then on. Keep the server’sAPP_DEBUGset tofalseand 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.
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.
-
Fresher to mid
Pathshala's /api/v1 endpoints are called by a browser SPA and by a few webhooks. The default
config/cors.phpanswers every preflight withAccess-Control-Allow-Origin: *. Explain the fix step by step, with the exact file and the exact two or three keys you would change. -
Mid to senior
A junior developer leaves
APP_DEBUG=trueon 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. -
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.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:apiis Composer plus one stub plus one line inbootstrap/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=falseon 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; publishconfig/cors.phpand 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
- Laravel 13.x routing, API routes (Laravel (laravel.com))
- Laravel 13.x errors (Laravel (laravel.com))
- Laravel 13.x routing, CORS (Laravel (laravel.com))
- MDN: Cross-Origin Resource Sharing (CORS) (Mozilla Developer Network)
- RFC 9457, Problem Details for HTTP APIs (RFC Editor (IETF))
- Laravel 13.x Sanctum (Laravel (laravel.com))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress