Building a Laravel Modular Monolith: Why nwidart/laravel-modules Changed How I Ship Features
Dinesh Wijethunga
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, teamsroutes/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.
Reviews & Ratings
Sign in to leave a review.
