ArticleLaravel

Building a Laravel Modular Monolith: Why nwidart/laravel-modules Changed How I Ship Features

D

Dinesh Wijethunga

May 11, 2026 4 min readBeginner 478 views
📝

When the Dineshstack ride-booking platform grew past five feature domains, the first thing that broke was developer velocity. A new engineer would open the codebase and find controllers, models, routes, and migrations scattered across flat directories. No clear ownership. No obvious starting point. "Where does the fare calculation live?" — a question that took ten minutes to answer instead of ten seconds.

The fix wasn't a rewrite. It was a structural decision: adopt nwidart/laravel-modules and give every feature domain its own isolated module.

What a Module Looks Like

A module is a self-contained directory under Modules/. It has its own controllers, models, migrations, routes, DTOs, transformers, and service providers. The platform today has 16 active modules:

Modules/
  Booking/
  BookingConfig/
  VehicleFleet/
  Services/
  User/
  Notifications/
  Documents/
  Language/
  Locations/
  Gallery/
  Messenger/
  Review/

Each one maps to a feature domain. The Booking module owns everything about a ride from creation to completion. The BookingConfig module owns zones, fares, surge rules, and blackout periods. They don't reach into each other's tables.

Module Layout

Every module follows the same internal structure:

Modules/BookingConfig/
  app/
    Http/
      Controllers/        API controllers
      Requests/           FormRequests per action
      Middleware/         Module-specific middleware
    Models/               Eloquent models
    Transformers/         API resources (response shaping)
    DTOs/                 Typed value objects
    Enums/                PHP 8.1 backed enums
    Services/             Domain logic
    Providers/
      RouteServiceProvider.php
      BookingConfigServiceProvider.php
  database/
    migrations/
    seeders/
  routes/
    admin.php
    customer.php
    internal.php
  config/
    config.php
  module.json
  composer.json

The module.json registers the module with nwidart. The module's composer.json is auto-merged into the root via wikimedia/composer-merge-plugin — each module can declare its own dependencies.

Route Ownership Per Module

The most visible benefit of modules is route ownership. Instead of one giant routes/apiAdmin.php that imports from everywhere, each module's RouteServiceProvider registers its own routes:

// 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 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'));
}

protected function mapApiCustomerRoutes(): void
{
    Route::middleware(['api', 'auth:api'])
        ->prefix('api/v1/customer')
        ->name('api.customer.')
        ->group(module_path($this->name, '/routes/customer.php'));
}

When you see api/v1/admin/booking-config/surge-rules, you know exactly where the code lives before running php artisan route:list.

Enabling and Disabling Modules

Module status is toggled in a single file at the root:

// modules_statuses.json
{
  "Booking": true,
  "BookingConfig": true,
  "VehicleFleet": true,
  "Notifications": false
}

Disabling a module removes all its routes, models, and service providers from the application in one line change. No code deletion required.

Module Commands

# Scaffold a new module
php artisan module:make Payments

# Add a controller to a specific module
php artisan module:make-controller ZoneController BookingConfig

# Create a migration inside a module
php artisan module:make-migration create_surge_events_table BookingConfig

# Run migrations for one module only
php artisan module:migrate BookingConfig

What Stays Global

Not everything belongs in a module. The root-level route files still own:

  • routes/api.php — Public auth (register, login, OTP)
  • routes/apiAdmin.php — Admin auth, user CRUD, roles, permissions, teams
  • routes/apiCustomer.php — Customer auth actions (logout)
  • routes/apiDealer.php / apiAgency.php — Cross-cutting user management per role

These are concerns that span modules. The decision rule: if a route doesn't map to a specific domain model, it stays global.

The Payoff

Three months in, the modular structure delivers exactly what was promised. A developer working on the surge pricing feature touches only Modules/BookingConfig/. A developer adding a new driver endpoint touches only Modules/Booking/routes/driver.php and the controller it points to. Pull requests have natural scope. Code reviews are fast because the reviewer knows where to look.

The two questions that drove the decision — "where does this live?" and "what does this module own?" — are now answered by directory structure, not tribal knowledge.

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.

Laravel Modular Monolith with nwidart/laravel-modules: A Production Gu | DineshStack