Skip to content
ArticleLaravel

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

Dinesh Wijethunga

Dinesh Wijethunga

May 2, 2026Updated Aug 16, 2026 7 min readIntermediate
ShareX / TwitterLinkedIn
Why I Prefix Every API Route with Its Module Name in Laravel — and the Standards That Make It Work

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-rulesModules/BookingConfig/routes/admin.phpSurgeRuleController. 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.

Dinesh Wijethunga
Dinesh Wijethunga

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

Was this post helpful?

Add a comment

Comments

Guest comments are held for moderation.

You might also like

PHP 8.4 Property Hooks: The Feature That Changes EverythingFeatured
ArticleIntermediatePHP

PHP 8.4 Property Hooks: The Feature That Changes Everything

PHP 8.4 property hooks put get/set logic directly on the property — no more getter/setter boilerplate. Full syntax, virtual properties, asymmetric visibility, why they don't work on Eloquent models, and the rest of what shipped in 8.4.

Dinesh WijethungaDinesh Wijethunga5 months ago
7m