Originally published on laravel.learnio.dev — part of the Laravel best practices course. View this lesson on the original site.

Turn URLs and verbs into clear contracts

Routes define your app's public HTTP surface. Every incoming request is matched by method and path before Laravel calls your code.

For most web pages, start in routes/web.php. These routes use the web middleware group (sessions, cookies, CSRF protection).

API endpoints usually live in routes/api.php.

Register routes by HTTP method

Laravel supports all common HTTP verbs:

use Illuminate\Support\Facades\Route;

Route::get('/products', fn () => 'List products');
Route::post('/products', fn () => 'Create product');
Route::put('/products/{id}', fn (string $id) => "Replace product {$id}");
Route::patch('/products/{id}', fn (string $id) => "Update product {$id}");
Route::delete('/products/{id}', fn (string $id) => "Delete product {$id}");

Use the verb that matches your intent:

  • GET for reads,
  • POST for creates,
  • PUT or PATCH for updates,
  • DELETE for removals.

Route to a controller action

Closures are fine for simple checks, but app code should usually go through controllers:

use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;

Route::get('/products', [ProductController::class, 'index']);
Route::post('/products', [ProductController::class, 'store']);

This keeps route files declarative and pushes logic into testable classes.

Bad way vs better way

Bad way (mixed intent and wrong verb semantics):

Route::any('/products', function () {
    // create, update, delete, and list all in one place
});

Better way (clear verb-specific routes):

Route::get('/products', [ProductController::class, 'index']);
Route::post('/products', [ProductController::class, 'store']);
Route::patch('/products/{product}', [ProductController::class, 'update']);
Route::delete('/products/{product}', [ProductController::class, 'destroy']);

Why better:

  • each endpoint has one job,
  • access control and validation are easier to reason about,
  • tests can target one behaviour at a time.

Name routes early

Named routes make URL generation and redirects reliable:

Route::get('/products/{id}', [ProductController::class, 'show'])
    ->name('products.show');

Then in code:

return redirect()->route('products.show', ['id' => $productId]);

If the path changes later, your route name stays stable.

Check your routing table often

Use this command while building:

php artisan route:list

Useful filters:

php artisan route:list --method=GET
php artisan route:list --name=products
php artisan route:list --path=products

Request matching flow

flowchart LR
  A[HTTP method and path] --> B[Route table match]
  B --> C[Closure or controller action]
  C --> D[Response]

In the next lesson, you will build on this by adding route parameters and constraints so each route can target specific resources safely.

From Laravel best practices — Routing, Controllers & Request Flow