Refactoring Part 1: From Monolith to Modular
Five years ago, I built an inventory management system with Laravel to support our company's early-stage operations. The initial scope was clear: procurement management, inventory tracking, and order processing. Thanks to Laravel's rapid development capabilities, the system went live quickly and gained approval.
That's the beauty of Laravel — it lets you turn ideas into working products quickly, and you get plenty of feedback early on.
However, as the business grew, so did the system requirements. Over five years, we added multi-tenancy, pharmaceutical batch tracking, vendor management, third-party invoice integration, sales reports, and gradually integrated pharmacy retail channels, e-commerce shopping carts, and POS systems. The system became larger and increasingly complex.
Today, I'm dealing with a large application containing dozens of business modules, hundreds of class files, and several scattered subsystems.
Challenges
Directory Structure
Initially, the system followed Laravel's standard directory structure. This type-based organization worked well in the early stages:
app/
├── Http/Controllers/
├── Models/
├── Jobs/
└── Listeners/But as business modules grew, problems began to emerge. Take the procurement module as an example — its code was scattered across multiple directories:
app/Http/Controllers/PurchaseOrderController.php
app/Models/PurchaseOrder.php
app/Jobs/ProcessPurchaseOrder.php
app/Listeners/PurchaseOrderCreated.php
app/Notifications/PurchaseOrderNotification.phpCode for a single business module was spread across five (or more) different directories.
Looking further at the app/Models/ directory:
app/Models/
├── User.php
├── Order.php
├── Invoice.php
├── PurchaseOrder.php
├── ScrapOrder.php
├── Stock.php
├── Product.php
└── ...(30+ Models)These Models shared nothing beyond being the same type. Invoice and ScrapOrder sat in the same directory simply because they were both Models, yet they belonged to completely different business domains.
Maintaining Subsystems
As the business expanded, we developed three subsystems:
- Admin Panel: For internal staff to manage inventory and orders
- E-commerce Platform: For customers to shop online
- Retail POS: To handle physical store sales
These subsystems started as separate projects communicating via APIs. While we extracted some shared logic into independent packages, time pressure and growing business demands led to frequent duplicate implementations:
// Main system
class InventoryService {
public function calculateStock($productId) {
// Inventory calculation logic
}
}
// POS system
class PosInventoryService {
public function calculateStock($productId) {
// Similar inventory calculation logic
}
}
// E-commerce system
class CommerceInventoryService {
public function calculateStock($productId) {
// Re-implementing similar logic again
}
}The same business logic was duplicated across different systems. Even when they differed only slightly, this increased maintenance costs and the likelihood of errors.
Attempts
I tried creating business subdirectories under each type directory:
app/
├── Http/Controllers/
│ ├── Inventory/
│ ├── Procurement/
│ └── Order/
├── Models/
│ ├── Inventory/
│ ├── Procurement/
│ └── Order/
└── Jobs/
├── Inventory/
├── Procurement/
└── Order/This approach did improve file organization, but in practice, I still had to switch between multiple type directories to complete a single feature modification. For example, when modifying the procurement feature:
Http/Controllers/Procurement/PurchaseOrderController.php
Models/Procurement/PurchaseOrder.php
Jobs/Procurement/PurchaseOrderCreated.php
Listeners/Procurement/PurchaseOrderCreated.php
Notifications/Procurement/PurchaseOrderNotification.phpI was still jumping between multiple directories — just with an extra layer added to each.
The Question
This made me wonder: Do we have to organize code by type?
When developing specific business features, I want to focus on the completeness of business logic, not on distinguishing between Controllers, Models, and Jobs. If I could group all related classes of the same business module in one directory, I could focus more on the module itself during development.
If we don't have to organize code by type, does that mean we must strictly follow Domain-Driven Design (DDD), implementing concepts like Bounded Context, Aggregate Root, and Domain Events?
Is there a middle ground that balances practical needs with architectural complexity?
Industry Practices
While searching for solutions, I found that many developers had encountered similar issues.
Jeffrey Way's experience shared on Twitter validated my thinking:
A really useful technique for slightly larger projects is to build and organize app features as if you were making a Composer package. Group all of your related models, controllers, listeners, commands, etc. under the same directory and namespace.
He suggested organizing related Models, Controllers, Listeners, Commands, etc., under the same directory and namespace, just like developing a Composer package.
Additionally, Mateus Guimarães shared his practical experience in Modularizing the monolith: a real-world experience, and Shopify's engineering team discussed similar architectural evolution in Deconstructing the Monolith.
These practical experiences convinced me that organizing code by business domain is the more sensible choice.
Restructuring
Overall Structure
Based on these ideas, I restructured the architecture as follows:
modules/
├── src/
│ ├── Apps/ # Application Layer
│ │ ├── Admin/ # Admin Panel
│ │ ├── Store/ # Retail System
│ │ └── Commerce/ # E-commerce Platform
│ ├── Core/ # Core Shared Logic
│ │ ├── User/
│ │ └── Organization/
│ ├── Customer/ # Customer Management
│ ├── Procurement/ # Procurement Management
│ ├── Inventory/ # Inventory Management
│ ├── Finance/ # Finance Management
│ └── Infrastructure/ # Infrastructure
├── config/ # Module configuration files
├── database/ # Database migrations
├── routes/ # Route definitions
├── resources/ # Views and assets
└── tests/ # Test filesIn this new architecture, config, database, routes, and other directories are not placed under individual subsystems, but unified at the modules root. The main reason is that these directories manage shared resources across modules:
- database/migrations: Database schema changes for all modules need unified version management
- routes/: Although organized by subsystem in separate files, centralized placement makes the overall routing structure easier to review
- config/: Module-level configurations that subsystems access through Service Providers
- tests/: Testing strategy is managed centrally while still organized by module
Centrally managing shared resources while maintaining module independence strikes a good balance in practice.
Module Structure
Each business module organizes all related classes internally. Take the procurement module as an example:
modules/src/Procurement/
├── Http/
│ └── Controllers/
│ ├── PurchaseOrderController.php
│ └── ReplenishmentController.php
├── Jobs/
│ └── FilterReplenishmentCandidate.php
├── Listeners/
│ └── LogReplenishmentHistory.php
├── Models/
│ ├── PurchaseOrder.php
│ └── PurchaseOrderItem.php
├── Enums/
│ ├── ReplenishmentReason.php
│ └── ReplenishmentStatus.php
└── ReplenishmentManager.phpThe namespace also becomes much clearer when using it:
use App\Models\PurchaseOrder; // Before
use Modules\Procurement\PurchaseOrder; // AfterFrom the namespace alone, it's clear that PurchaseOrder belongs to the procurement module, and all related classes can be found in the Procurement directory. This organization allows developers to focus on a single business domain during development, without having to switch back and forth between multiple type-based directories.
Subsystem Architecture
All subsystems are placed under modules/src/Apps/:
modules/src/Apps/
├── Admin/
│ ├── Http/
│ ├── Models/
│ ├── Actions/
│ └── AdminServiceProvider.php
├── Store/
│ ├── Http/
│ ├── Models/
│ └── StoreServiceProvider.php
└── Commerce/
├── Http/
├── Models/
└── CommerceServiceProvider.phpEach subsystem manages its own routes, authentication, and configuration through its Service Provider, remaining independent. At the same time, they can:
- Share core module business logic
- Inherit and extend existing functionality
- Override specific implementations when needed
All systems are maintained in the same project, effectively avoiding code duplication.
Composer Configuration
Register the module namespaces in composer.json:
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "modules/src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/",
"Modules\\Tests\\": "modules/tests/"
}
}
}Benefits
Improved Development Efficiency
Before: Modifying procurement features required switching between multiple directories
app/Http/Controllers/...
app/Models/...
app/Jobs/...
app/Listeners/...After: All related files are concentrated in a single directory
modules/src/Procurement/Code Reuse
Before: Each subsystem maintained similar logic independently*
Admin/InventoryService.php
Store/InventoryService.php
Commerce/InventoryService.phpAfter: Shared core modules
// modules/src/Inventory/InventoryService.php
namespace Modules\Inventory;
class InventoryService { ... }
// Direct usage in subsystems
use Modules\Inventory\InventoryService;Extensibility
When adding a logistics system, simply create a new directory under modules/src/Apps/:
modules/src/Apps/
└── Logistics/
├── Http/
├── Models/
└── LogisticsServiceProvider.phpThe new system can immediately reuse all existing business module logic.
Final Thoughts
Evolving from the standard Laravel directory structure to a modular architecture was a long but worthwhile process. This had to be done while keeping the company running, adding new features, and adjusting existing ones — it wasn't an uninterrupted task.
The best time to refactor is often later than we think. As we build systems, we continuously deepen our understanding of the business domain. Designing complex system architectures or microservices without domain knowledge only adds unnecessary risk and cost.
David Heinemeier Hansson also explored this topic in The Majestic Monolith. He's not against good software practices, but reminds us: don't spend weeks or even months designing a system you don't fully understand yet, especially when it might remain a small project.
Architecture is the result of evolution, not a perfect blueprint designed upfront.
The right architectural solution depends on team size, application complexity, and actual operational conditions. No matter which architecture you choose, you'll go through a painful and challenging period during evolution. But when development efficiency drops and system modifications become difficult, it might be time to rethink.
This refactoring let me experience the full cycle firsthand: appreciating the convenience of monolithic architecture, recognizing it gradually becoming a bottleneck, and finally thinking through and implementing change. This wasn't a top-down decision, but a deep understanding based on actual pain points.
The goal of refactoring isn't to pursue theoretically perfect architecture, but to make code easier to understand, maintain, and extend. There's no absolutely best architecture, only the architecture that best fits the current context and team.