Article

Why I Prefix Every API Route with Its Module Name in Laravel β€” and the Standards That Make It Work

D

Dinesh Wijethunga

May 2, 2026 7 min readIntermediate 796 views
πŸ“

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/zones
api/admin/base-fares
api/admin/surge-rules
Every booking configuration endpoint now lives under:

api/v1/admin/booking-config/zones
api/v1/admin/booking-config/base-fares
api/v1/admin/booking-config/surge-rules/current-multiplier
And booking-specific endpoints:

api/v1/customer/bookings
api/v1/customer/bookings/active
api/v1/driver/bookings/{booking}/start
api/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.php

The 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 prefixRoute name prefix
api/v1/admin/booking-configapi.admin.booking-config.
api/v1/customer/bookingsapi.customer.bookings.
api/v1/driver/bookingsapi.driver.bookings.
api/v1/internal/booking-configapi.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-config

Auth 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 response

This 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, teams
  • routes/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

SegmentRule
apiAlways the first segment
v1Version number β€” required on all new endpoints
admin / customer / driver / internal / publicUser role or access type
booking-config / bookings / reviewsKebab-case module name
zones / base-fares / {booking}/cancelResource 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.

D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

Module-Prefixed API Routes in Laravel: Why and How | DineshStack