skip to content

In a Laravel 13 app, how do routes/web.php and routes/api.php differ, and why does a new app have no api.php?

level: juniorimportance: must knowfreq 70%

answer

  1. two files, two middleware groups
  2. web group: session state and CSRF
  3. install:api creates the second file
  4. api group plus an automatic prefix
  5. apiPrefix argument of withRouting

basics

~10 s

routes/web.php is loaded inside the web middleware group for browser pages with sessions and CSRF protection; routes/api.php, created by php artisan install:api, is loaded inside the stateless api group under an automatic /api prefix.

solid answer

~40 s

Both files are registered in `bootstrap/app.php` through `withRouting()`. `routes/web.php` is wrapped in the `web` middleware group, which gives routes session state and CSRF protection, so it suits pages and HTML forms. A fresh Laravel 13 app has no `routes/api.php`: API routing has been opt-in since Laravel 11. Running `php artisan install:api` creates the file, adds an `api:` line to `withRouting()` and installs Sanctum. Routes in that file get the stateless `api` group and an `/api` URI prefix, which the `apiPrefix:` argument can change. So a recipe site that only had web routes and now wants a public JSON endpoint runs `install:api`, writes `Route::get('/recipes', ...)` in `routes/api.php`, and serves it at `/api/recipes`.

code

php · 14 lines
php
<?php

// bootstrap/app.php after install:api, with a custom prefix
use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
        apiPrefix: 'api/v1',
    )
    ->create();

go deeper

for a junior

Recall that web.php gets the web group with sessions and CSRF, api.php gets the api group and an /api prefix, and install:api creates api.php.

for a middle

Explain how withRouting wraps each file in a group, what install:api edits in bootstrap/app.php, and how apiPrefix changes the URL.

for a senior

Decide when a JSON endpoint belongs in web.php versus an API file, and catch knock-on effects such as the skeleton's is('api/*') JSON-error check after a prefix change.

for a principal

Weigh one app serving pages and an API against separate prefixes, route files and auth guards, and set a team rule so new endpoints land in the right file.

## Where Laravel's route files come from A Laravel application does not scan the `routes/` directory by itself. The files are registered in **`bootstrap/app.php`**, the **application builder** that replaced the old `App\Providers\RouteServiceProvider` and HTTP kernel in the slim skeleton introduced by Laravel 11 and kept in Laravel 13. The skeleton's builder call looks like this: ```php return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { // }) ->create(); ``` The `withRouting()` method wraps each file it is given in a route **group**: a set of shared attributes (middleware, URI prefix, name prefix) applied to every route defined inside the file. That group is the whole difference between the two HTTP route files. ## routes/web.php: the browser surface - Every route in `routes/web.php` is registered inside the **`web` middleware group**. - The routing docs describe that group as providing **session state and CSRF protection**, which is what server-rendered pages, logins and HTML forms need. - No URI prefix is added: `Route::get('/recipes', ...)` answers at `/recipes`. - A fresh Laravel 13 skeleton ships exactly two route files: `routes/web.php` (with a `GET /` route returning the `welcome` view) and `routes/console.php` (Artisan closure commands). There is no `routes/api.php` and no `routes/channels.php`. ## routes/api.php: opt-in since Laravel 11 API routing became **opt-in** with the Laravel 11 skeleton, and Laravel 13 keeps it that way. You turn it on with one command: ```bash php artisan install:api ``` The command, defined in the framework's `ApiInstallCommand`, does three things: 1. Copies a stub to **`routes/api.php`**, containing a sample `GET /user` route guarded by `auth:sanctum`. 2. Edits `bootstrap/app.php` so `withRouting()` receives `api: __DIR__.'/../routes/api.php'`. 3. Installs **Sanctum** (or Passport with `--passport`) and offers to run the pending migration. Routes in `routes/api.php` are registered inside the **`api` middleware group**, which the docs call **stateless**: no session cookie, no CSRF check. Token authentication only applies to routes that ask for it with `auth:sanctum`; a route without that middleware is public. ## The /api prefix and apiPrefix The builder wraps the API file as `Route::middleware('api')->prefix($apiPrefix)->group($file)`, and `apiPrefix` defaults to `'api'`. So: - `Route::get('/recipes', ...)` in `routes/api.php` answers at **`/api/recipes`**. - Writing `/api/recipes` inside the file doubles the prefix to `/api/api/recipes`. - Changing the prefix is a builder argument, for example `apiPrefix: 'api/v1'`. - Both `web:` and `api:` also accept an **array of files**, each wrapped in the same group. One knock-on effect: the skeleton's `withExceptions()` callback renders errors as JSON when `$request->is('api/*')` or the client expects JSON. If you move the prefix away from `api/`, update that check or rely on clients sending `Accept: application/json`. ## Choosing for a recipe site's first public JSON endpoint Say a recipe site has only web routes and now needs a public, read-only JSON list of recipes for a partner app. | Option | What the route gets | Fits when | |---|---|---| | `Route::get('/recipes.json', ...)` in `routes/web.php` | web group: session cookie, CSRF on writes | a one-off endpoint the site's own pages call | | `install:api`, then `Route::get('/recipes', ...)` in `routes/api.php` | api group, `/api` prefix, no session | a real API that will grow, may need tokens later | The second option is the conventional answer: it keeps browser concerns (sessions, CSRF) away from API clients, gives the API its own prefix, and puts token authentication one middleware away. Check the result with `php artisan route:list --path=api`. ## Checking what each file produced Because the difference between the files lives in the group the builder applies, the quickest way to confirm it is to look at the registered routes rather than the files: 1. `php artisan route:list --path=api -v` lists every route under the API prefix with its middleware, so you can see the `api` group on each one. 2. `php artisan route:list --except-vendor` hides package routes (Sanctum registers its own), leaving only what your files defined. 3. A request to the new endpoint with `Accept: application/json` should return the collection as JSON and normally carries no session cookie, a practical sign that the route is not in the `web` group. If the route shows up without the prefix, it was most likely written in `routes/web.php` by mistake; if it shows up twice-prefixed, the file repeats `/api` by hand. ## Common misunderstandings - **"api.php routes are authenticated automatically."** They are not; only routes carrying `auth:sanctum` (or another guard) require a token. - **"A new app already has routes/api.php."** Not since Laravel 11; `install:api` creates it. - **"The prefix must be typed in every route."** The builder adds it for the whole file. - **"Extra route files need a RouteServiceProvider."** In the current skeleton you pass more files to `withRouting()` or register them in its `then:` closure.

  • What exactly does php artisan install:api change in a Laravel 13 project?
    It copies a stub to `routes/api.php` with a sample `GET /user` route behind `auth:sanctum`, adds `api: __DIR__.'/../routes/api.php'` to `withRouting()` in `bootstrap/app.php`, and installs Sanctum (Passport with `--passport`), offering to run the new migration. If `routes/api.php` already exists it refuses to overwrite it unless you pass `--force`.
  • You change apiPrefix to 'v1'. What else in the skeleton still assumes the api/ prefix?
    The skeleton's `withExceptions()` callback calls `shouldRenderJsonWhen()` with `$request->is('api/*') || $request->expectsJson()`. After the prefix change, errors under `/v1` only render as JSON when the client sends an `Accept: application/json` header, so update that check to `is('v1/*')` as well.
  • Can a Laravel app have several web route files without writing a custom provider?
    Yes. `withRouting()` accepts an array for `web:` and for `api:`, and wraps each file in the matching group (and, for API files, the prefix). For a file that needs a different prefix or name prefix, register it in the `then:` closure with `Route::middleware('web')->prefix(...)->group(base_path(...))`.

saying these in an interview costs you the question

  • Routes in api.php require a Sanctum token even without auth:sanctum
  • A fresh Laravel 13 app already contains routes/api.php
  • Every route in api.php must spell out the /api prefix itself
  • Web routes are stateless, just like API routes
  • New route files must be registered in App\Providers\RouteServiceProvider