Route Organization — Splitting Routes by Domain and Role
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.phpbreaks 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.php, api.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.php,merchandiser.php,employee.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.phpinstead 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()toroutes/web.phpfor 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.indexis stable even if the URL path changes from/admin/ordersto/dashboard/orders. Frontend devs using Ziggy orroute()helpers are shielded from URL refactors.
Further Reading
- Laravel Docs: Routing
- Laravel Docs: Route Groups
- Ziggy — use Laravel named routes in JavaScript
- Laravel Docs:
withRouting()in Laravel 11+
One Comment