Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Modular Monolith with Laravel Complete Guide

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.

Traditional LayeredControllers (All Features Mixed)Services (Cross-Cutting Logic)Models (Shared Database Access)Repositories / HelpersModular MonolithBilling ModuleRoutes + ControllersServices + ModelsMigrations + TestsCase Mgmt ModuleRoutes + ControllersServices + ModelsMigrations + TestsClient Intake ModuleRoutes + ControllersServices + ModelsMigrations + TestsDocuments ModuleRoutes + ControllersServices + ModelsMigrations + TestsExplicit Contracts Between Modules
Traditional layered architecture scatters features across technical folders; modular monolith groups them by business domain with explicit boundaries.

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.

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:

  1. 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 Contracts directory; Module A depends only on that interface. Laravel's container resolves the concrete implementation.
  2. 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's Contracts or a shared App\Domain\Events namespace.
  3. 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.
Billing ModuleInvoiceGeneratorInterfaceInvoicePaid EventInternal Models (Private)ClientIntake ModuleClientRepositoryInterfaceListens: InvoicePaidInternal Models (Private)Documents ModuleDocumentRendererInterfaceListens: InvoicePaidInternal Models (Private)Shared KernelMoney VO · UserId · TenantId · Common EnumsContract CallAsync Events via Laravel QueueRed borders = private internals. Green/Blue lines = allowed communication paths.
Modules communicate through contract interfaces for sync calls, domain events for async notifications, and a minimal shared kernel for universal value objects.

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:

CriterionExtract as ModuleKeep in Core / Shared
Business domain clarityDistinct bounded context (Billing, Inventory, CaseManagement)Cross-cutting concerns (Auth, Notifications, Logging)
Team ownershipDifferent developers/teams own the featureEveryone touches it equally
Change frequencyEvolves independently from other featuresChanges correlate with framework upgrades
Data ownershipOwns dedicated database tablesNo persistent state or uses shared tables
Reusability potentialCould become standalone package/service laterTightly bound to this specific application
Test isolationCan be tested without booting entire appRequires 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:

  1. 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.
  2. 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.
  3. 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.
  4. Introduce contracts before moving dependents. Before moving CaseManagement code that calls Billing, define InvoiceGeneratorInterface in Billing's contracts. Refactor existing callers to use the interface. Then move CaseManagement.
  5. Add architectural tests. Use PHPStan custom rules or Pest architecture tests to prevent backsliding. Assert that App\Modules\Billing\Models is never referenced outside App\Modules\Billing. These tests are your safety net.
1. IdentifyHighest-PainDomain2. SkeletonModule Dir +Service Provider3. Vertical MoveComplete Featurein Single PR4. Contracts FirstInterfaces BeforeDependent Moves5. GuardArch Tests +PHPStan RulesLegacy Structure During Migrationapp/Http/ControllersGradually emptiesapp/ServicesRefactored → Modulesapp/Modules/BillingFirst extracted moduleapp/Modules/...Next candidatesShared Kernel + Core Laravel (Auth, Config, Infrastructure) — Always RemainsCode migrates vertically
Incremental migration moves complete features into modules one at a time while legacy directories gradually shrink and architectural guards prevent regression.

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.

Frequently Asked Questions

A Laravel application organized into self-contained domain modules with explicit boundaries, shared kernel, and internal APIs instead of a flat MVC structure.

When team size is under ten engineers, domain complexity is moderate, and operational overhead must stay low for Nepal-based budgets.

Refactoring typically costs NPR 150,000–400,000 (USD 1,100–3,000) depending on codebase size and test coverage.

nWidart/laravel-modules remains the most mature option for Laravel 12, offering module scaffolding, service provider registration, and asset publishing out of the box. In my experience shipping legal-tech portals like Court Marriage In Nepal, this package reduces boilerplate significantly compared to custom solutions. It supports PHP 8.2+ and integrates cleanly with Spatie packages for permissions and media handling. Always pin to a specific major version in composer.json to avoid breaking changes during upgrades, and run php artisan module:make to generate consistent directory structures across teams.

Define explicit contracts in a shared Core or Contracts module that other modules depend on, never directly referencing sibling modules. Use Laravel's service container to bind interfaces to implementations within each module's service provider. On production Laravel applications I maintain, we enforce this via PHPStan rules and CI checks that fail builds when forbidden namespaces are imported. This prevents tight coupling while allowing modules to communicate through well-defined APIs. Document dependency graphs in README files so new developers understand allowed communication paths before writing code.

Yes, if you enforced strict boundaries and communicated only through contracts or events from day one. The module's public API becomes the future microservice interface, and internal implementation details remain hidden. In practice, extraction still requires replacing local calls with HTTP or message queue calls, adding retry logic, and handling distributed transactions. I have seen this work smoothly on eCommerce systems where payment processing was isolated early, but poorly bounded modules require significant refactoring regardless of initial architecture claims. Plan extraction points deliberately during design.

Each module owns its migrations in a dedicated Migrations folder, prefixed with module name to avoid conflicts. Foreign keys crossing module boundaries should reference UUIDs or natural keys rather than auto-increment IDs to reduce coupling. On projects like Nepal Gift Card, we use separate migration paths registered in module service providers and run php artisan migrate --path=Modules/ModuleName/Database/Migrations during deployment. Avoid shared tables between modules; duplicate denormalized data when necessary and sync via events. This keeps modules independently deployable and testable even within a single database.

Write unit tests inside each module testing only that module's logic with mocked dependencies, plus integration tests verifying cross-module contracts. Feature tests live at the application level validating end-to-end workflows. In my experience maintaining booking systems like Adventure Third Pole Trek, this layered approach catches boundary violations faster than monolithic test suites. Use Pest or PHPUnit with module-specific test directories and configure parallel execution in Laravel 12's native test runner. Never let integration tests bypass module boundaries by directly accessing another module's models or services.

Each module publishes its own config file to config/modules/module-name.php with sensible defaults, merging with environment overrides at runtime. Avoid polluting the root .env with module-specific keys; instead namespace them like MODULENAME_API_KEY. During deployment with Deployer 7, ensure shared .env contains all required namespaced variables before symlink swap. On sister sites sharing infrastructure like notarykathmandu.com and translationnepal.com, this pattern prevents configuration drift across environments. Validate required config values in service providers using throw_if to fail fast during bootstrap rather than at runtime.

No, modularity improves developer velocity and maintainability, not runtime performance directly. Performance gains come from deliberate caching, query optimization, and lazy loading enabled by clear module boundaries. In technical SEO work for legal portals, I have seen Core Web Vitals improve only after adding Redis caching and eliminating N+1 queries within modules, not from restructuring alone. Use Laravel Octane or RoadRunner if request latency matters, and profile with Debugbar or Telescope to identify actual bottlenecks. Architecture enables optimization but does not replace it.

Add static analysis rules via PHPStan or Rector that forbid imports outside allowed namespaces, failing builds on violations. Configure GitLab CI to run these checks before deployment alongside standard test suites. On client projects using Deployer 7, we include a lint stage that validates module isolation before any server interaction occurs. Supplement automated checks with code review checklists documenting approved cross-module communication patterns. Boundary enforcement must be automated because manual reviews inevitably miss violations under deadline pressure, especially in small teams common in Nepal's tech ecosystem.

Creating too many fine-grained modules prematurely, sharing Eloquent models across boundaries, and neglecting contract documentation. Teams often mistake folder reorganization for true modularity without enforcing dependency rules. In my experience upgrading legacy Laravel applications, the hardest fixes involve untangling shared state and implicit couplings discovered months after initial refactor. Start with three to five coarse modules aligned to business domains, refine only when pain emerges, and invest heavily in integration tests covering module interactions. Resist copying microservice granularity into a monolithic runtime.

Centralize user identity and session management in an Auth or User module, exposing read-only user contracts to other modules. Use Spatie Laravel Permission for role-based access control with permissions scoped per module namespace like orders.create or invoices.view. On legal-tech platforms like Mijar Law Associates, this allows granular access policies without leaking auth logic into business modules. Never let modules define their own user tables or authentication mechanisms. Middleware applied at route group level ensures consistent enforcement, and policy classes reference module-specific permission strings validated during testing.

Yes, register module-specific Blade views, Livewire components, or Vue assets through the module's service provider using loadViewsFrom and publishable asset tags. Prefix component names with module namespace to avoid collisions across the application. For frontend-heavy modules in eCommerce systems like Petals Nepal, this keeps UI logic colocated with backend domain code while maintaining global accessibility. Ensure Vite or Mix builds include module asset paths, and commit compiled artifacts if your production server lacks Node.js as in our standard Deployer 7 workflow. Test component rendering in isolation before integrating.

Absolutely, especially for teams of two to eight developers balancing budget constraints with long-term maintainability needs. Microservices demand DevOps maturity and infrastructure spending often unrealistic for NPR-denominated projects. Modular monoliths deliver organizational benefits without Kubernetes complexity or inter-service debugging overhead. In my experience building platforms for Nepali businesses, this architecture scales adequately until revenue justifies dedicated platform engineering staff. Pair with Ubuntu servers, PHP-FPM, and MySQL for predictable hosting costs around NPR 5,000–15,000 monthly. Prioritize simplicity that survives team turnover and client handoffs.

Share this article

Quick Contact Options
Choose how you want to connect me: