
August 15, 2026
10 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Most Laravel applications eventually become tangled masses of controllers, models, and services where changing one feature breaks three others. This Modular Monolith with Laravel Complete Guide addresses that entropy by organizing code around business domains rather than technical layers. Instead of splitting into microservices prematurely, you gain architectural clarity, faster onboarding, and independent team velocity while retaining the operational simplicity of a single deployable unit.
What is a Modular Monolith with Laravel and why choose it?
A modular monolith structures your Laravel application as a collection of cohesive, loosely coupled modules aligned to business capabilities. Each module owns its database tables, routes, controllers, and domain logic, exposing only what other modules need through well-defined contracts. Unlike traditional layered architecture where features scatter across Controllers/Services/Repositories folders, a module keeps everything related to "Invoicing" or "UserManagement" together.
I have found this approach particularly valuable when building legal-tech portals like legal-tech solutions for Nepal law firms, where distinct domains such as case management, client intake, billing, and document generation must evolve independently but remain tightly integrated. The alternative—jumping straight to microservices—introduces network latency, distributed transactions, and DevOps complexity that most teams in Nepal (and globally) cannot justify until they have genuinely exhausted monolithic scaling options.
The key distinction from vanilla Laravel is intentionality. Standard Laravel encourages organizing by type (all models in app/Models, all controllers in app/Http/Controllers). A modular monolith organizes by capability. You still use Laravel's routing, Eloquent, queues, and events—but they live inside module directories, not global ones. This makes it possible for a developer to work on the Billing module for weeks without touching Case Management code, reducing merge conflicts and cognitive load.
How do you structure domain modules in Laravel 12?
Laravel 12 does not ship with a built-in module system, which is actually an advantage. Framework-enforced module structures often become straitjackets. Instead, adopt a convention-over-configuration approach using standard Laravel primitives. I recommend placing modules under app/Modules/{ModuleName}, keeping them within the main application namespace for autoloading simplicity while maintaining physical separation.
Recommended directory layout per module
app/Modules/Billing/
├── src/
│ ├── Actions/ # Business operations (CreateInvoice, CalculateTax)
│ ├── Contracts/ # Interfaces exposed to other modules
│ ├── Events/ # Domain events (InvoiceCreated, PaymentReceived)
│ ├── Http/
│ │ ├── Controllers/
│ │ ├── Middleware/
│ │ └── Requests/
│ ├── Models/ # Eloquent models (private to module)
│ ├── Services/ # Internal orchestration logic
│ └── Support/ # Module-specific helpers, enums, DTOs
├── database/
│ ├── migrations/ # Prefixed: 2026_08_15_000001_create_invoices_table
│ ├── factories/
│ └── seeders/
├── resources/
│ ├── views/ # billing::invoices.index
│ └── lang/
├── routes/
│ ├── web.php # Loaded via module service provider
│ └── api.php
├── tests/ # Feature + Unit tests scoped to module
└── BillingServiceProvider.php This structure keeps each module self-describing. When you open app/Modules/Billing, you see everything related to billing. The Contracts directory is critical—it contains the interfaces that other modules may depend on. Everything else is private implementation detail. In practice, I enforce this privacy through code review and static analysis tools like PHPStan, configured to flag cross-module references that bypass contracts.
Registering modules via service providers
Each module gets its own service provider responsible for loading routes, views, migrations, and binding contracts. Laravel 12's package auto-discovery works for modules if you add them to composer.json under a custom repository path, but for most projects, manually registering providers in config/app.php or using a simple module loader is more transparent:
// app/Modules/Billing/BillingServiceProvider.php
namespace App\Modules\Billing;
use Illuminate\Support\ServiceProvider;
use App\Modules\Billing\Contracts\InvoiceGeneratorInterface;
use App\Modules\Billing\Services\InvoiceGeneratorService;
class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
InvoiceGeneratorInterface::class,
InvoiceGeneratorService::class
);
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__ . '/routes/web.php');
$this->loadRoutesFrom(__DIR__ . '/routes/api.php');
$this->loadViewsFrom(__DIR__ . '/resources/views', 'billing');
$this->loadMigrationsFrom(__DIR__ . '/database/migrations');
}
} Keep service providers focused. Avoid putting business logic in boot(). If a module needs to listen to events from another module, register listeners here—but only reference event classes from the other module's Contracts or shared kernel, never internal models.
How should modules communicate without tight coupling?
Cross-module communication is where modular monoliths succeed or fail. Direct model references create hidden dependencies that defeat the purpose of modularity. Use these three patterns, ordered by preference:
- Contract interfaces — For synchronous, request-response interactions where Module A needs data or action from Module B at runtime. Module B exposes an interface in its
Contractsdirectory; Module A depends only on that interface. Laravel's container resolves the concrete implementation. - Domain events — For asynchronous, fire-and-forget notifications. When an invoice is paid, Billing dispatches
InvoicePaid. ClientIntake listens and updates the client's payment status. Neither module knows about the other's internals. Events are defined in the producing module'sContractsor a sharedApp\Domain\Eventsnamespace. - Shared kernel (sparingly) — For truly universal concepts like Money, Address, or UserId value objects that multiple modules need identically. Keep this tiny. Every class added here increases coupling surface area.
A common mistake I see on real client projects is defining events with rich payloads containing Eloquent models. This re-introduces coupling because consumers now depend on the producer's schema. Instead, pass only primitive identifiers and let consumers fetch what they need through contracts or their own queries. An InvoicePaid event should carry invoiceId, clientId, and paidAmount—not an Invoice model instance.
How do you manage database schemas across Laravel modules?
Database ownership is non-negotiable in a modular monolith. Each module owns its tables and migrations. No module should directly query another module's tables, even for reads. This rule prevents schema changes in Billing from silently breaking reports in Analytics.
Use migration prefixes to avoid timestamp collisions and clarify ownership:
// app/Modules/Billing/database/migrations/2026_08_15_000001_create_billing_invoices_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('billing_invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained('client_intake_clients');
$table->decimal('amount', 12, 2);
$table->enum('status', ['draft', 'sent', 'paid', 'void']);
$table->timestamps();
});
}
}; Note the table prefix billing_. This is optional but helpful for debugging and preventing accidental joins. Foreign keys referencing other modules' tables are acceptable—they enforce referential integrity at the database level, which is safer than application-level consistency. However, never join across module boundaries in queries. If Billing needs client names for invoice PDFs, call ClientRepositoryInterface::getNamesByIds() instead of joining client_intake_clients.
For high-read scenarios where contract calls become bottlenecks, consider read-model replication. Billing can maintain a denormalized billing_client_summaries table updated via ClientUpdated events. This trades eventual consistency for performance while preserving module autonomy. Document this pattern explicitly—it is a conscious architectural decision, not accidental duplication.
When should you extract a module versus keep code in core Laravel?
Not everything deserves a module. Over-modularization creates indirection without benefit. Use this decision framework based on patterns I have applied across eCommerce platforms and legal-tech systems:
| Criterion | Extract as Module | Keep in Core / Shared |
|---|---|---|
| Business domain clarity | Distinct bounded context (Billing, Inventory, CaseManagement) | Cross-cutting concerns (Auth, Notifications, Logging) |
| Team ownership | Different developers/teams own the feature | Everyone touches it equally |
| Change frequency | Evolves independently from other features | Changes correlate with framework upgrades |
| Data ownership | Owns dedicated database tables | No persistent state or uses shared tables |
| Reusability potential | Could become standalone package/service later | Tightly bound to this specific application |
| Test isolation | Can be tested without booting entire app | Requires full application context |
User authentication, for example, typically stays in core Laravel unless you are building a multi-tenant identity platform. Password resets, session management, and OAuth flows are infrastructure, not business domains. Conversely, "Appointment Scheduling" in a legal-tech portal is absolutely a module—it has its own entities, business rules, calendar integrations, and evolves separately from client intake or document generation.
If you are evaluating whether to hire help for this kind of architectural work, understanding the cost of hiring a Laravel developer in Nepal helps set realistic expectations for refactoring projects versus greenfield builds.
How do you migrate an existing Laravel app to a modular monolith?
Rewriting a working production system is rarely justified. Incremental migration preserves business continuity while improving architecture over time. Follow this sequence on real projects:
- Identify the highest-pain domain. Where do merge conflicts cluster? Which feature causes the most regression bugs? Start there, not with the "cleanest" domain. Pain indicates unclear boundaries.
- Create the module skeleton. Set up the directory structure, service provider, and empty contracts. Register it in Laravel. Deploy this change first—it adds nothing but establishes the pattern.
- Move code vertically, not horizontally. Pick one complete feature (e.g., "Generate Invoice PDF"). Move its controller, service, model, migration, view, and test into the module in a single PR. Update routes. Verify nothing broke. Do not move partial features.
- Introduce contracts before moving dependents. Before moving CaseManagement code that calls Billing, define
InvoiceGeneratorInterfacein Billing's contracts. Refactor existing callers to use the interface. Then move CaseManagement. - Add architectural tests. Use PHPStan custom rules or Pest architecture tests to prevent backsliding. Assert that
App\Modules\Billing\Modelsis never referenced outsideApp\Modules\Billing. These tests are your safety net.
Expect this process to take months, not days. That is correct. Architectural improvements compound; rushing introduces subtle bugs that erode trust in the new structure. On a legal-tech portal I maintained, extracting the Document Generation module took six weeks of incremental PRs alongside normal feature work. The result was zero downtime and dramatically fewer support tickets related to template rendering failures.
For teams considering this transition alongside broader modernization, reviewing modern Laravel architecture best practices provides complementary guidance on service containers, testing strategies, and deployment patterns that align with modular design.
Conclusion
This Modular Monolith with Laravel Complete Guide gives you a practical, battle-tested path to taming complex Laravel applications without premature distribution. Start with clear domain boundaries, enforce communication through contracts and events, protect database ownership, and migrate incrementally from your highest-pain areas. The payoff is an application that scales with your team and business—not against them. If you are planning a modular refactor or building a new domain-heavy Laravel system and want hands-on architectural guidance, reach out to discuss your project.

