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.
- Creates
routes/api.php - Installs Laravel Sanctum, the API token authentication package
- Adds
api:towithRoutinginbootstrap/app.php
The bootstrap/app.php from chapter 2 changes as follows.
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
/apiURL prefix — write/productsin the file and the actual URL is/api/products - The
apimiddleware 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.
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
<?php
namespace App\Http\Controllers;
use App\Models\Product;
class ProductController extends Controller
{
public function index()
{
return Product::all();
}
}
use App\Http\Controllers\ProductController;
Route::get('/products', [ProductController::class, 'index']);
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.
Route::apiResource('products', ProductController::class);
That one line defines five routes.
| Method | URI | Action | Route name |
|---|---|---|---|
| GET | /api/products | index | products.index |
| POST | /api/products | store | products.store |
| GET | /api/products/{product} | show | products.show |
| PUT / PATCH | /api/products/{product} | update | products.update |
| DELETE | /api/products/{product} | destroy | products.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.
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.
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.
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.
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.
->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.
| Code | Meaning | When | Chapter |
|---|---|---|---|
| 200 OK | Success | A fetch or update succeeded | This chapter |
| 201 Created | Created | An order was created | Chapter 5 |
| 204 No Content | Success, no body | A delete succeeded | This chapter |
| 401 Unauthorized | Not authenticated | Missing or invalid token | Chapter 11 |
| 403 Forbidden | Not permitted | Tried to cancel someone else's order; token lacks the ability | Chapters 12 and 19 |
| 404 Not Found | Not found | Nothing matches the ID | This chapter |
| 409 Conflict | Conflicting state | Out of stock; cancelling a shipped order | Chapters 12 and 14 |
| 422 Unprocessable Entity | Invalid input | Validation error | Chapter 5 |
| 429 Too Many Requests | Over the limit | Rate limit reached | Chapter 13 |
| 500 Internal Server Error | A problem on the server | An unexpected exception | Chapter 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:apisets uproutes/api.phpand Sanctum, and addsapi:tobootstrap/app.phpapiResource()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
What to read next
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."