Skip to main content

Routing design and your first endpoint

The previous chapter gave us tables and data. This chapter finally makes them reachable over HTTP.

At the same time we settle several things that are hard to change later: the shape of the URL, how versions are carried, and which status codes come back. Clients you publish to depend on all of these, so changing them later breaks their code. That is worth deciding before implementation.

Building the API skeleton with install:api​

As chapter 2 showed, a fresh project has just two route files: web.php and console.php. You set up the API file yourself.

php artisan install:api

This command does three things1.

  1. Creates routes/api.php
  2. Installs Laravel Sanctum, the API token authentication package
  3. Adds api: to withRouting in bootstrap/app.php

The bootstrap/app.php from chapter 2 changes as follows.

bootstrap/app.php (after running install:api)
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // added
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
// ...

Sanctum comes along too, but we do not use it until chapter 11. For now it is enough to confirm that routes/api.php exists.

Routes written in routes/api.php automatically get two things2.

  • A /api URL prefix — write /products in the file and the actual URL is /api/products
  • The api middleware group — they are treated as stateless, with no session

Being stateless is the difference from web.php. Routes in web.php get sessions, CSRF protection, and cookie encryption; routes in api.php cannot access session state. Authentication is assumed to happen with tokens.

Your first endpoint​

Let us return a product listing. Start by writing it directly in the route.

routes/api.php
use App\Models\Product;
use Illuminate\Support\Facades\Route;

Route::get('/products', function () {
return Product::all();
});

If you ran the seeder in the previous chapter, http://localhost:8000/api/products returns 20 products as JSON.

php artisan serve
curl http://localhost:8000/api/products

The code you just wrote runs along the path we traced in chapter 2. The request entered public/index.php, the service container was created, the HTTP kernel put it through the api middleware group, and the router called this closure.

That said, writing it this way is fine only for the very first route. As the processing grows the route file becomes unreadable, so we move it into a controller.

php artisan make:controller ProductController
app/Http/Controllers/ProductController.php
<?php

namespace App\Http\Controllers;

use App\Models\Product;

class ProductController extends Controller
{
public function index()
{
return Product::all();
}
}
routes/api.php
use App\Http\Controllers\ProductController;

Route::get('/products', [ProductController::class, 'index']);
This code is not finished yet

Product::all() loads every row into memory and turns every column of the model into JSON as is. With 100,000 products it returns 100,000, and any internal column you add later gets published too. Chapter 7 designs the shape of the response and chapter 8 adds pagination.

Grouping routes with apiResource​

In a REST-style API, the operations on one resource settle into a fixed set. Laravel lets you write that set in one line.

routes/api.php
Route::apiResource('products', ProductController::class);

That one line defines five routes.

MethodURIActionRoute name
GET/api/productsindexproducts.index
POST/api/productsstoreproducts.store
GET/api/products/{product}showproducts.show
PUT / PATCH/api/products/{product}updateproducts.update
DELETE/api/products/{product}destroyproducts.destroy

There is a similar method called Route::resource(), which defines seven routes by adding create and edit. Those two exist to return HTML input forms and are not needed for an API. apiResource() excludes them3.

The matching controller can be generated from a stub.

php artisan make:controller ProductController --api --model=Product

--api produces a controller with the five methods, and --model=Product generates it with type declarations already on each method's arguments.

At this point anyone can call the write endpoints too

apiResource() also defines store, update, and destroy. There is no authentication yet, so anyone can create, update, or delete a product. This is exactly the problem raised in chapter 1, and chapters 11 (authentication) and 12 (authorization) close it.

Until then, narrow the routes to what you want public with only().

Route::apiResource('products', ProductController::class)->only(['index', 'show']);

This series stays in that form until authentication arrives in chapter 11. The reason the endpoint list from chapter 1 has almost no product write endpoints is that administrator-facing features are out of scope. The exception is the image upload added in chapter 19, protected by a dedicated token.

You can check the routes you have defined.

php artisan route:list

This command lists the routes and middleware actually registered. When "I wrote a route but I get a 404," this is the first thing to look at. A misapplied prefix or the wrong method shows up immediately.

Route model binding​

Writing the show action straightforwardly gives you this.

What you would otherwise write every time
public function show(int $id)
{
$product = Product::findOrFail($id);

return $product;
}

Laravel can take over that "receive an ID and fetch the model" work. All you do is write a type on the argument.

✅ Route model binding
public function show(Product $product)
{
return $product;
}

When the route parameter name {product} matches the argument variable name $product, Laravel looks the Product up by primary key and passes it in. If it is not found, a 404 comes back automatically.

Looking it up by something other than the primary key​

Sometimes you want to fetch a product by slug rather than id. Specify the column on the route.

Route::get('/products/{product:slug}', [ProductController::class, 'show']);

That enables URLs like /api/products/organic-coffee-beans.

Note that the products table in this series has no slug column. To try it locally, add $table->string('slug')->unique(); in a migration. Later chapters continue to fetch by id.

Narrow the scope on nested resources​

Building a nested URL, such as one that returns the lines belonging to an order, brings something to watch out for.

Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});

Written this way, {item} is looked up by primary key alone, with no regard for {order}. Hit /api/orders/1/items/999 and line 999 comes back even if it belongs to a different order. In other words, someone else's order lines are visible.

Adding scopeBindings() makes the lookup happen within the parent-child relationship.

✅ Good: look the child up within the parent
Route::scopeBindings()->group(function () {
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
});

Note that when you give the child parameter a custom key (a form like {item:code}), Laravel scopes it to the parent automatically1. The distinction is that scopeBindings() is only needed when you are not using a custom key.

This series does not use nested URLs; /api/orders/{order} returns the order together with its lines. Deeply nested URLs multiply the combinations, and that is where authorization gaps come from.

Enums can be bound too​

A backed enum such as the OrderStatus from chapter 3 can be received directly.

use App\Enums\OrderStatus;

Route::get('/orders/status/{status}', function (OrderStatus $status) {
return $status->value;
});

A value that does not exist in the enum results in a 404. It is rejected at the URL, without writing any validation.

Changing what happens when nothing is found​

The default is a 404, but you can change it.

Route::get('/products/{product:slug}', [ProductController::class, 'show'])
->name('products.show')
->missing(fn () => response()->json(['message' => 'Product not found'], 404));

For an API we want a consistent shape for error responses, so rather than writing this on every route we gather it in withExceptions, covered in chapter 9.

URL design and versioning​

Carry the version in the URL​

Once an API is published, you cannot change its shape. Change the structure of a response and the clients reading it break.

The remedy is to include a version in the URL.

bootstrap/app.php
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
// ...
)

apiPrefix is the option that changes the /api default1. Now every route takes the form /api/v1/products.

Versions only ever move upward. Add v2 and keep v1 around for a while, giving users a migration window before you stop it. Remove it without warning and your convenience stops their system.

Put resource names in the URI​

In a REST-style design the URL names a thing, and the operation on it is expressed by the HTTP method.

✅ GET /api/v1/products fetch the product listing
✅ POST /api/v1/orders create an order
✅ DELETE /api/v1/orders/{order} delete an order

❌ GET /api/v1/getProducts
❌ POST /api/v1/createOrder
❌ POST /api/v1/deleteOrder

Put verbs in a URL and the same operation acquires several spellings. Which of getProducts, fetchProducts, and productList is right cannot be settled without a convention. Standardize on nouns and there is nothing to argue about.

Resource names are plural. The URLs apiResource('products', ...) generates take that form.

HTTP methods and status codes​

The code you return is what a client uses to decide, mechanically, what happened. Design it as "return 200 for everything and put success or failure in the body," and the client has to parse the body every time.

Here are the ones this series uses.

CodeMeaningWhenChapter
200 OKSuccessA fetch or update succeededThis chapter
201 CreatedCreatedAn order was createdChapter 5
204 No ContentSuccess, no bodyA delete succeededThis chapter
401 UnauthorizedNot authenticatedMissing or invalid tokenChapter 11
403 ForbiddenNot permittedTried to cancel someone else's order; token lacks the abilityChapters 12 and 19
404 Not FoundNot foundNothing matches the IDThis chapter
409 ConflictConflicting stateOut of stock; cancelling a shipped orderChapters 12 and 14
422 Unprocessable EntityInvalid inputValidation errorChapter 5
429 Too Many RequestsOver the limitRate limit reachedChapter 13
500 Internal Server ErrorA problem on the serverAn unexpected exceptionChapter 9

Laravel returns several of these automatically. Route model binding that finds nothing gives a 404; a failed Form Request validation gives a 422.

To specify one explicitly from a controller, write it like this.

return response()->json($product, 201);

return response()->noContent(); // 204

What matters in production​

Decide whether to expose sequential IDs in URLs​

With a URL like /api/v1/orders/1, changing the number to 2 or 3 lets you try other orders. Without authorization you can walk the whole set, and even with authorization the total order count is guessable. Order 1000 in January and 1500 in February tells you the rate is 500 a month.

There are two remedies. One is to use an identifier that cannot be guessed; Laravel has mechanisms for ULID or UUID primary keys. The other is to apply authorization reliably and return 404 for other people's resources (a 403 conveys that the resource exists).

This series covers the latter in chapter 12. The former gets into primary key design, which is the Database Design Guide's territory.

Do not nest deeply​

With a URL like /api/v1/users/1/orders/2/items/3/reviews/4, every extra level adds validation of "which combinations are valid." Forget to check the parent at the third level and that is your hole.

In practice, keep it to one level and let anything deeper be fetched by the child's ID alone. Laravel supports this as shallow nesting3. If the child's ID is unique, the parent's ID need not be in the URL.

Use the route cache in production only​

php artisan route:cache gathers route definitions into one file to speed up boot. It is worth putting in the production deployment procedure, but route changes made after caching do not take effect. Do not use it locally; run it only at deployment time. Chapter 21 includes it in the procedure.

Summary​

  • php artisan install:api sets up routes/api.php and Sanctum, and adds api: to bootstrap/app.php
  • apiResource() defines five routes: resource()'s seven minus the two for HTML forms
  • Writing a type on the argument activates route model binding, and a miss returns 404
  • Nested URLs without a custom key need scopeBindings(). Without it, someone else's child resources can be fetched
  • URLs are nouns and operations are HTTP methods. Versions only move upward
  • Status codes are what a client uses to decide mechanically. Do not standardize on 200

From the next chapter, Validating input — fixing the boundary with a Form Request, we move to the writing side. We write the order creation API straightforwardly, then move the validation from the controller into a Form Request. That is also where we draw the line between shape checks and business rule checks.

Exercises​

The following route definition lets other people's data be seen. Explain the cause and the fix
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});

Sample answer

Route model binding for {item} searches the order_items table by primary key, with no regard for {order}.

Hit /api/v1/orders/1/items/500 and nothing checks whether line 500 belongs to order 1. A line belonging to another customer's order 999 comes back as is. The 1 in the URL was only used to resolve the Order; it is not part of the search condition for {item}.

From an attacker's point of view, knowing one of their own order numbers is enough to collect other people's lines by brute-forcing the items ID.

The fix is scopeBindings().

Route::scopeBindings()->group(function () {
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
});

Now {item} is looked up within $order->items(), and lines from other orders give a 404.

Note that specifying a custom key on the child parameter ({item:code}) scopes it automatically. That asymmetry is exactly what causes accidents, so when writing nested routes it is safer to state scopeBindings() explicitly.

After publishing the API, you want to change the price in the product listing response from "yen" to "yen including tax." How do you proceed?

Sample answer

Avoid changing the meaning of the existing price. Clients already calling /api/v1/products treat price as tax-exclusive, and changing only its meaning silently changes their calculations. Because nothing errors, discovery is slow.

There are two realistic options.

Add a field: leave price as it is and add price_including_tax. Additions do not break existing clients; a field they are not reading is simply ignored. This is the safest option when it applies.

Bump the version: when you want to reorganize the response structure itself, create /api/v2/products. Keep v1 alive and announce a migration window. Maintaining both costs something, so this is the tool for large changes.

The axis for the decision is "does existing client code break?" Adding a field does not; removing one and changing its meaning do. Because of that distinction, when chapter 7 designs the shape of the response we decide it on the premise that "you can add later, but you cannot remove."


Footnotes​

  1. Source: Routing (Laravel documentation 13.x). On what install:api does, changing the prefix with apiPrefix, and route model binding custom keys, scoping, enum binding, and missing. ↩ ↩2 ↩3

  2. Source: Directory Structure (Laravel documentation 13.x). On routes/api.php being stateless, assuming token authentication, and having no access to session state. ↩

  3. Source: Controllers (Laravel documentation 13.x). On the seven actions a resource controller handles, apiResource excluding the routes that return HTML templates (create / edit), and shallow nesting. ↩ ↩2