Originally published on laravel.learnio.dev — part of the Laravel best practices course. View this lesson on the original site.
Build a complete product routing surface
This exercise combines chapter 2 skills into one coherent route/controller design.
Goal
Create a CRUD route surface for products with:
- clear HTTP verbs,
- named routes,
- controller actions,
- parameter constraints,
- route model binding on show/update/delete paths.
Step 1: Create a controller
php artisan make:controller ProductController
Add placeholder methods: index, store, show, update, destroy.
Step 2: Define CRUD routes in routes/web.php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
Route::prefix('products')->name('products.')->group(function () {
Route::get('/', [ProductController::class, 'index'])->name('index');
Route::post('/', [ProductController::class, 'store'])->name('store');
Route::get('/{product}', [ProductController::class, 'show'])
->whereNumber('product')
->name('show');
Route::patch('/{product}', [ProductController::class, 'update'])
->whereNumber('product')
->name('update');
Route::delete('/{product}', [ProductController::class, 'destroy'])
->whereNumber('product')
->name('destroy');
});
Step 3: Use route model binding in controller methods
use App\Models\Product;
public function show(Product $product)
{
return response()->json(['data' => $product]);
}
Apply similar signatures for update and destroy.
Step 4: Verify your route table
php artisan route:list --path=products -v
Check:
- five routes exist,
- names use
products.*, - methods are correct,
- middleware stack is what you expect.
Step 5: Test the routes with sample requests
Run your app and send these example requests. Confirm the response status and shape match the expected result.
# list products
curl -i http://127.0.0.1:8000/products
# create product
curl -i -X POST http://127.0.0.1:8000/products \
-H "Content-Type: application/json" \
-d '{"name":"Demo Product","price":19.99}'
# show product (replace 1 with an existing id)
curl -i http://127.0.0.1:8000/products/1
# update product
curl -i -X PATCH http://127.0.0.1:8000/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Updated Product"}'
# delete product
curl -i -X DELETE http://127.0.0.1:8000/products/1
# invalid id should fail constraint or binding
curl -i http://127.0.0.1:8000/products/not-a-number
Expected checks:
GET /productsreturns success (200) and your list payload,POST /productsreturns success (200/201depending on your implementation),GET /products/1returns success (200) when id exists,PATCH /products/1returns success (200) and updated data,DELETE /products/1returns success (200/204) in your implementation,GET /products/not-a-numberreturns404.
If any response does not match expectations, update routes, constraints, or controller signatures and rerun the same request until results are consistent.
Bad way vs better way
Bad way (mixed intent in one any route):
Route::any('/products/{product?}', [ProductController::class, 'handle']);
Better way (explicit CRUD contract):
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
Route::patch('/products/{product}', [ProductController::class, 'update'])->name('products.update');
Why better:
- clearer endpoint intent,
- easier policy and validation boundaries,
- simpler automated testing.
Done criteria
- route names and methods are consistent,
- model binding is used where needed,
- no duplicated route logic in controllers,
- route list output matches your expected surface,
- sample requests return expected statuses and shapes.
Exercise flow
flowchart LR
A[Define CRUD routes] --> B[Wire controller actions]
B --> C[Apply constraints and model binding]
C --> D[Verify with route list]
After this exercise, you are ready for the chapter quiz with a production-like routing mental model.
From Laravel best practices — Routing, Controllers & Request Flow