Why I Prefix Every API Route with Its Module Name in Laravel β and the Standards That Make It Work
Dinesh Wijethunga
When you first build a Laravel API, the standard approach looks something like this:
// routes/apiAdmin.php
Route::prefix('api/admin')->group(function () {
Route::apiResource('zones', ZoneController::class);
Route::apiResource('vehicles', VehicleController::class);
Route::apiResource('services', ServiceController::class);
Route::apiResource('users', UserController::class);
});It works. URLs are short and clean: api/admin/zones, api/admin/vehicles. No complaints from the frontend team.
But as the application grows β multiple developers, multiple feature domains, 15+ modules β that flat list becomes a maintenance nightmare. You end up with a file that imports from everywhere, conflicts between similarly-named resources, and no way to know which part of the system owns what.
This is what I ran into building DStackRide, a Laravel 12 modular monolith for a ride-booking platform, and it's why I changed the URL design to include the module name as a path segment.
The New Structure: api/v1/{role}/{module}/{resource}
Instead of:
api/admin/zonesapi/admin/base-faresapi/admin/surge-rules
Every booking configuration endpoint now lives under:
api/v1/admin/booking-config/zonesapi/v1/admin/booking-config/base-faresapi/v1/admin/booking-config/surge-rules/current-multiplier
And booking-specific endpoints:
api/v1/customer/bookingsapi/v1/customer/bookings/activeapi/v1/driver/bookings/{booking}/startapi/v1/admin/bookings/stats
The URL tells you four things immediately: the API version, the user role accessing it, the module it belongs to, and the resource being acted on. No ambiguity, no guessing.
Rule 1 β Every Module Owns Its Routes
The project uses nwidart/laravel-modules, which gives each feature its own self-contained directory:
Modules/
BookingConfig/
app/
Http/Controllers/
Providers/
RouteServiceProvider.php β owns all route registration
routes/
admin.php
customer.php
internal.phpThe module's RouteServiceProvider registers all routes for that module, grouped by role:
// Modules/BookingConfig/app/Providers/RouteServiceProvider.php
protected function mapApiAdminRoutes(): void
{
Route::middleware(['api', 'auth:api'])
->prefix('api/v1/admin/booking-config')
->name('api.admin.booking-config.')
->group(module_path($this->name, '/routes/admin.php'));
}
protected function mapApiCustomerRoutes(): void
{
Route::middleware(['api', 'auth:api'])
->prefix('api/v1/customer/fare-estimate')
->name('api.customer.fare-estimate.')
->group(module_path($this->name, '/routes/customer.php'));
}
protected function mapApiInternalRoutes(): void
{
Route::middleware(['api', InternalApiKeyMiddleware::class])
->prefix('api/v1/internal/booking-config')
->name('api.internal.booking-config.')
->group(module_path($this->name, '/routes/Internal.php'));
}The global routes/apiAdmin.php file no longer manages these routes at all. It only handles generic cross-cutting concerns (auth login, user management, permissions) that don't belong to any single module.
Why this matters for beginners: When a junior developer is told to add a new booking config endpoint, they go to Modules/BookingConfig/routes/admin.php. Full stop. They don't need to understand the whole application β just their module.
Rule 2 β Separate Route Files Per Role
Inside each complex module, there is a separate route file for each role that accesses it:
Modules/Booking/routes/
admin.php β api/v1/admin/bookings/*
api.php β api/v1/customer/bookings/*
driver.php β api/v1/driver/bookings/*A customer hitting GET api/v1/customer/bookings and a driver hitting GET api/v1/driver/bookings both get list endpoints β but the controllers, the queries, and the data shape are different. Putting them in the same file invites confusion.
Splitting by role file makes the intent clear before you read a single line of controller code. The file name tells you who the audience is.
Rule 3 β Named Routes Follow the URL Structure
Every route group has a ->name() prefix that mirrors the URL prefix, using dots:
| URL prefix | Route name prefix |
|---|---|
api/v1/admin/booking-config | api.admin.booking-config. |
api/v1/customer/bookings | api.customer.bookings. |
api/v1/driver/bookings | api.driver.bookings. |
api/v1/internal/booking-config | api.internal.booking-config. |
So a named route like api.admin.booking-config.zones.index maps exactly to GET api/v1/admin/booking-config/zones. The name is predictable β no need to run php artisan route:list to find what a named route resolves to.
Rule 4 β Middleware Is Scoped at the Group Level, Not the Controller
Each role group applies its own middleware stack. You don't guard individual controllers or methods with $this->middleware() β the route group handles it:
// Admin β requires OAuth token from an authenticated admin user
Route::middleware(['api', 'auth:api'])
->prefix('api/v1/admin/booking-config')
...
// Internal β uses an API key, not a user token
Route::middleware(['api', InternalApiKeyMiddleware::class])
->prefix('api/v1/internal/booking-config')
...
// Public β no authentication required
Route::middleware(['api'])
->prefix('api/public/documents')
...
This means adding or swapping middleware for a whole role group is a one-line change in the RouteServiceProvider. Controllers stay clean β they assume they're already authorized by the time a request arrives.
Rule 5 β The URL Prefix Uses Kebab-Case Module Names
Module class names are PascalCase (BookingConfig, VehicleFleet), but URL segments use kebab-case:
BookingConfig β booking-config
VehicleFleet β vehicle-fleet (but also vehiclefeatures, vehicletype β older naming)The newer modules are consistent: booking-config, fare-estimate. The older modules (vehicletype, brand) predate the standard and are left as-is rather than introduced a breaking URL change. The rule for new modules is: kebab-case, always.
Rule 6 β Version the API at the Path Level
Every URL contains v1:
api/v1/admin/booking-config/zones
api/v1/customer/bookings
api/v1/driver/bookings/{booking}/complete
api/v1/internal/booking-configAuth endpoints (login, token refresh) and legacy public document routes intentionally omit v1 β they predate the standard and changing them would break existing integrations. All new endpoints must include the version prefix.
When the time comes to introduce breaking changes β different response shapes, removed fields, new required parameters β a v2 prefix lets both versions coexist without a big-bang migration.
Rule 7 β The Request Flow Is Always the Same
Regardless of module, every API request follows the same path through the codebase:
FormRequest (validation)
β DTO (typed data transfer object)
β Service or Action (business logic)
β Transformer / Resource (response shape)For example, creating a booking zone:
StoreZoneRequest validates the incoming fields
β ZoneDTO converts validated data to a typed object
β ZoneService creates the zone, handles geo data
β ZoneTransformer shapes the JSON responseThis is not optional convention β it is how every feature is built, so a developer who has read one module can navigate any other.
The Payoff
Here's what module-prefixed routing gives you in practice:
For developers: A URL tells you exactly where to find the code. api/v1/admin/booking-config/surge-rules β Modules/BookingConfig/routes/admin.php β SurgeRuleController. No hunting.
For the frontend team: API endpoints group naturally by feature in the documentation. All booking config endpoints share a common prefix. All customer booking endpoints share a common prefix.
For code review: A PR that touches Modules/BookingConfig/ only adds or changes api/v1/*/booking-config/* routes. Scope is obvious.
For future growth: Adding a new module means adding a new path segment. It never collides with existing routes. Two developers can add endpoints on the same day without a merge conflict in a shared route file.
For deletion: When a module is removed or disabled, all of its routes disappear with it. No orphaned route entries in a global file.
What Stays Global
Not everything belongs in a module. The root-level route files still own:
routes/api.phpβ Public auth (register, login, OTP, social)routes/apiAdmin.phpβ Admin auth, user management, roles, permissions, teamsroutes/apiCustomer.phpβ Customer-level auth actions (logout)routes/apiDealer.php/apiAgency.phpβ Dealer and agency user management
These are cross-cutting concerns that don't map to a single feature module. Everything else is owned by its module.
Summary: The Full URL Contract
| Segment | Rule |
|---|---|
api | Always the first segment |
v1 | Version number β required on all new endpoints |
admin / customer / driver / internal / public | User role or access type |
booking-config / bookings / reviews | Kebab-case module name |
zones / base-fares / {booking}/cancel | Resource and action |
When every URL in your API follows this contract, you spend less time explaining structure and more time building features. That's the only reason this exists.
Reviews & Ratings
Sign in to leave a review.
