Route Organization — Splitting Routes by Domain and Role

Route Organization — Splitting Routes by Domain and Role

Reading Time: 3 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 7 of 35 | Level: Beginner–Intermediate | Prerequisites: Thin Controllers


What You’ll Learn

  • Why a single web.php breaks down at scale
  • How to split routes by role and domain
  • Loading additional route files cleanly
  • URL and route name conventions
  • API versioning with separate route files

The Problem with One Route File

Every Laravel project starts with three route files: web.phpapi.php, and console.php. For a while this is fine. Then you add an admin panel. Then a separate merchant portal. Then a mobile API. Then a set of webhook endpoints.

Six months in, web.php has 400 lines, no one can find anything, and route name collisions are a daily debugging session.

The fix is to split routes by the audience or domain they serve — not arbitrarily, but by natural boundaries that already exist in your system.


The Split Used Across These Projects

Here’s the route structure from the most organized projects:

routes/
├── web.php           # Public-facing pages (guests + authenticated users)
├── admin.php         # Admin panel (/admin/*)
├── api.php           # REST API (v1)
├── api_v2.php        # REST API (v2) — when versioning is needed
├── merchandiser.php  # Merchandiser portal
├── employee.php      # Employee portal
├── qr.php            # QR code scanning endpoints
└── console.php       # Artisan commands (schedule)

Each file is small, focused, and named after who uses it.


Loading Route Files

In Laravel 11+, all route files are registered in bootstrap/app.php:

// bootstrap/app.php
->withRouting(
    web: __DIR__ . '/../routes/web.php',
    api: __DIR__ . '/../routes/api.php',
    commands: __DIR__ . '/../routes/console.php',
    health: '/up',
    then: function (): void {
        Route::middleware('web')
             ->group(base_path('routes/admin.php'));

        Route::middleware(['web', 'auth:merchandiser'])
             ->prefix('merchandiser')
             ->name('merchandiser.')
             ->group(base_path('routes/merchandiser.php'));

        Route::middleware(['web', 'auth:employee'])
             ->prefix('employee')
             ->name('employee.')
             ->group(base_path('routes/employee.php'));
    },
)

In Laravel 10 and below, use RouteServiceProvider:

// app/Providers/RouteServiceProvider.php
public function boot(): void
{
    $this->routes(function (): void {
        Route::middleware('web')
             ->group(base_path('routes/web.php'));

        Route::middleware('api')
             ->prefix('api')
             ->name('api.')
             ->group(base_path('routes/api.php'));

        Route::middleware('web')
             ->prefix('admin')
             ->name('admin.')
             ->group(base_path('routes/admin.php'));

        Route::middleware(['web', 'auth:merchandiser'])
             ->prefix('merchandiser')
             ->name('merchandiser.')
             ->group(base_path('routes/merchandiser.php'));
    });
}

What Goes in Each File

web.php — Public and authenticated user routes

// routes/web.php
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/about', [PagesController::class, 'about'])->name('about');

Route::middleware('guest')->group(function (): void {
    Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
    Route::post('/login', [AuthController::class, 'login']);
    Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
    Route::post('/register', [AuthController::class, 'register']);
});

Route::middleware('auth')->group(function (): void {
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    Route::resource('orders', OrdersController::class);
    Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
});

admin.php — Admin panel

// routes/admin.php
// Note: this file is already wrapped in ->prefix('admin')->name('admin.')
// so no need to repeat it here

Route::middleware('guest:admin')->group(function (): void {
    Route::get('/login', [AdminAuthController::class, 'showLogin'])->name('login');
    Route::post('/login', [AdminAuthController::class, 'login']);
});

Route::middleware('auth:admin')->group(function (): void {
    Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');
    Route::resource('users', AdminUsersController::class);
    Route::resource('orders', AdminOrdersController::class);
    Route::post('/logout', [AdminAuthController::class, 'logout'])->name('logout');
});

api.php — REST API

// routes/api.php
// Already wrapped with ->prefix('api')->middleware('api') by Laravel

Route::prefix('v1')->name('api.v1.')->group(function (): void {
    Route::post('/auth/login', [ApiAuthController::class, 'login'])->name('auth.login');

    Route::middleware('auth:sanctum')->group(function (): void {
        Route::get('/me', [ProfileController::class, 'show'])->name('profile.show');
        Route::apiResource('products', ProductsController::class);
        Route::apiResource('orders', OrdersController::class);
    });
});

Naming Conventions

Consistent naming makes route() calls predictable throughout the codebase.

URL paths: kebab-case

/service-requests          ✅
/serviceRequests           ❌
/service_requests          ❌

Route names: camelCase, dot-separated hierarchy

Route::get('/service-requests', ...)->name('serviceRequests.index');
Route::post('/service-requests', ...)->name('serviceRequests.store');
Route::get('/service-requests/{id}', ...)->name('serviceRequests.show');
Route::put('/service-requests/{id}', ...)->name('serviceRequests.update');
Route::delete('/service-requests/{id}', ...)->name('serviceRequests.destroy');

Route parameters: camelCase

Route::get('/orders/{orderId}', ...);
Route::get('/users/{userId}/orders/{orderId}', ...);

Admin routes with prefix:

// With prefix 'admin.' already applied:
'admin.dashboard'          // → /admin
'admin.users.index'        // → /admin/users
'admin.orders.show'        // → /admin/orders/{id}

API Versioning

When a breaking API change is needed, add a second file rather than breaking existing clients:

// routes/api_v2.php
Route::prefix('v2')->name('api.v2.')->group(function (): void {
    Route::middleware('auth:sanctum')->group(function (): void {
        Route::apiResource('products', V2\ProductsController::class);
        // New response format for products in v2
    });
});

Load it alongside api.php:

->withRouting(
    then: function (): void {
        Route::middleware('api')
             ->group(base_path('routes/api_v2.php'));
    }
)

Both v1 and v2 coexist. When all clients are on v2, deprecate v1 with a warning middleware, then remove.


Route Caching

In production, cache routes for a significant performance boost:

php artisan route:cache

Requirements for route caching:

  • No closures in route files (they can’t be serialized — always use controller classes)
  • Run after every deploy: php artisan route:cache

Inspect the full route list at any time:

php artisan route:list --except-vendor
php artisan route:list --path=admin
php artisan route:list --method=POST

Key Takeaways

  • Split route files by the audience they serve: admin.phpmerchandiser.phpemployee.php.
  • Apply middleware and prefix at the loader level — route files themselves should be clean groups.
  • URL paths are kebab-case. Route names are camelCase, dot-separated.
  • API versioning: add api_v2.php instead of breaking existing routes.
  • No closures in route files — always reference controller classes so routes can be cached.

Tips and Gotchas

⚠️ Warning: Avoid adding Route::resource() to routes/web.php for admin routes. When all routes share one file, you lose the ability to apply different middleware groups cleanly. Split the files, then apply middleware at the group level.

💡 Tip: Use ->only(['index', 'store', 'show']) or ->except(['destroy']) on resource routes. Registering all 7 RESTful routes and only implementing 3 of them creates dead routes that could be exploited if you forget to add authorization to the missing ones.

🔥 Expert Note: Named routes are your API contract with the frontend. The name admin.orders.index is stable even if the URL path changes from /admin/orders to /dashboard/orders. Frontend devs using Ziggy or route() helpers are shielded from URL refactors.

Further Reading


← Form Requests | Next: Service Classes →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment