How I Built a Modular PHP Framework and What I Learned About Architecture
The Problem That Kept Me Up at Night ๐ด
As a PHP developer with decades of experience, I've built my fair share of complex applications. But there was always one architectural problem that frustrated me:
Module boundaries in PHP applications are often invisible and easily broken.
Here's what I mean:
// This looks innocent enough... class OrderService { public function __construct( private UserService $userService, private PaymentService $paymentService, private InventoryService $inventory, private EmailService $emailService, ) {} }
But what happens when:
- Someone refactors
UserService
without knowingOrderService
depends on it? - You want to test
OrderService
but need to mock 4 different modules? - You need to extract the Payment module into a microservice?
- A new developer joins and can't tell what depends on what?
The dependencies are implicit and hidden. Your DI container knows about them, but your code doesn't make them explicit.
The Inspiration: Learning from Other Ecosystems ๐ก
Coming from a network engineering background and working with Angular on the frontend, I've seen how other ecosystems handle this:
Angular Modules
@NgModule({ imports: [CommonModule, UserModule], // Explicit imports exports: [OrderComponent, OrderService], // Explicit exports providers: [OrderService] // Internal services }) export class OrderModule { }
OSGi (Java)
// Bundle explicitly declares what it imports/exports Import-Package: com.example.user.service Export-Package: com.example.order.service
The pattern was clear: successful module systems make dependencies explicit.
My Solution: PowerModules Framework ๐
I decided to bring these patterns to PHP. Here's what I built:
1. Each Module Gets Its Own DI Container
Instead of one global container, each module has its own isolated space:
class OrderModule implements PowerModule { public function register(ConfigurableContainerInterface $container): void { // This container is ONLY for OrderModule $container->set(OrderService::class, OrderService::class); $container->set(OrderRepository::class, OrderRepository::class); // These services are private to this module by default } }
2. Explicit Export Contracts
If you want to share a service, you must explicitly export it:
class UserModule implements PowerModule, ExportsComponents { public static function exports(): array { return [ UserService::class, // Only this is available to other modules ]; } public function register(ConfigurableContainerInterface $container): void { $container->set(UserService::class, UserService::class); $container->set(PasswordHasher::class, PasswordHasher::class); // Private! } }
3. Explicit Import Contracts
If you want to use another module's service, you must explicitly import it:
class OrderModule implements PowerModule, ImportsComponents { public static function imports(): array { return [ ImportItem::create(UserModule::class, UserService::class), ImportItem::create(PaymentModule::class, PaymentService::class), ]; } public function register(ConfigurableContainerInterface $container): void { // Now UserService and PaymentService are available for injection $container->set(OrderService::class, OrderService::class) ->addArguments([ UserService::class, PaymentService::class, ]); } }
The magic: Your dependencies are now visible in your code, not hidden in configuration files!
The PowerModuleSetup Pattern โก
Here's where it gets interesting. How do you add cross-cutting functionality (like routing, events, logging) to ALL modules without breaking encapsulation?
I created the PowerModuleSetup pattern:
class RoutingSetup implements CanSetupPowerModule { public function setup(PowerModuleSetupDto $dto): void { // This runs for EVERY module during app building if ($dto->powerModule instanceof HasRoutes) { // Pull all routes defined in this module and register them with the router $this->registerRoutes($dto->powerModule, $dto->moduleContainer); } } } // Usage $app = new ModularAppBuilder(__DIR__) ->withModules(UserModule::class, OrderModule::class) ->addPowerModuleSetup(new RoutingSetup()) // Extends ALL modules with HasRoutes interface ->build();
This pattern allows extensions to work across all modules while maintaining isolation. This's how the power-modules/router extension works, and it also forms the foundation for the framework's explicit export/import system. Both cross-cutting features and module relationships are enabled through PowerModuleSetup, ensuring encapsulation and visibility at the same time.
Building the App: The Fluent API ๐๏ธ
Putting it all together:
$app = new ModularAppBuilder(__DIR__) ->withConfig(Config::forAppRoot(__DIR__)) ->withModules( AuthModule::class, UserModule::class, OrderModule::class, PaymentModule::class ) ->addPowerModuleSetup(new RoutingSetup()) ->addPowerModuleSetup(new EventSetup()) ->build(); // Access any exported service $orderService = $app->get(OrderService::class);
What I Learned Building This ๐
1. Dependency Resolution is Complex
I had to implement topological sorting to handle module dependencies:
- Build a dependency graph from import statements
- Detect circular dependencies
- Sort modules in the correct loading order
- Cache the result for performance
2. Two-Phase Loading is Essential
- Phase 1: Register all modules and collect exports
- Phase 2: Resolve imports and apply PowerModuleSetup extensions
This ensures all exports are available before any imports try to resolve them.
3. Container Hierarchy Matters
Root Container โโโ Module A Container (isolated) โโโ Module B Container (isolated) โโโ Exported Services (aliases to module containers)
Each module's container is completely isolated, but exported services are accessible through the root container.
4. Explicit is Better Than Implicit
The import/export contracts make your architecture visible:
- New developers can see module relationships at a glance
- Refactoring becomes safer with explicit dependencies
- Testing becomes easier with clear boundaries
Real-World Impact ๐
Here's what this approach enables:
Better Team Scaling
// Team A owns AuthModule class AuthModule implements ExportsComponents { public static function exports(): array { return [ UserService::class, AuthMiddleware::class ]; } } // Team B owns OrderModule class OrderModule implements ImportsComponents { public static function imports(): array { return [ ImportItem::create(AuthModule::class, UserService::class), ]; } }
Teams can work independently with clear contracts between modules.
Easier Testing
// Test OrderModule in isolation $testApp = new ModularAppBuilder(__DIR__) ->withModules( MockUserModule::class, // Test double OrderModule::class // Real implementation ) ->build();
Microservice Evolution
// Today: Modular monolith ImportItem::create(UserModule::class, UserService::class) // Tomorrow: HTTP API call ImportItem::create(UserApiModule::class, UserService::class)
The import/export contracts naturally become API contracts.
The Technical Details ๐ง
Framework Stats:
- ~1,600 lines of core code
- PHPStan level 8 (maximum static analysis)
- PHP 8.4+ with strict types
- Comprehensive test coverage
- PSR-11 container interoperability
- MIT licensed
Key Components:
-
PowerModule
: Core module interface -
ConfigurableContainer
: Custom DI container with method chaining -
ModularAppBuilder
: Fluent app construction -
PowerModuleSetup
: Extension system -
ImportItem
: Dependency declaration
Comparison with Existing Solutions ๐
vs Symfony Bundles
// Symfony (implicit dependencies in services.yaml) class OrderController { // Dependencies configured in YAML, not visible in code } // PowerModules (explicit imports in code) class OrderModule implements ImportsComponents { public static function imports(): array { return [ ImportItem::create(UserModule::class, UserService::class), ]; } }
vs Laravel Service Providers
// Laravel (shared container) App::bind(OrderService::class, function($app) { return new OrderService($app->make(UserService::class)); }); // PowerModules (isolated containers) $container->set(OrderService::class, OrderService::class) ->addArguments([UserService::class]); // Resolved from module's container
Try It Yourself ๐
composer require power-modules/framework
Basic Example:
class MyModule implements PowerModule, ExportsComponents { public static function exports(): array { return [MyService::class]; } public function register(ConfigurableContainerInterface $container): void { $container->set(MyService::class, MyService::class); } } $app = new ModularAppBuilder(__DIR__) ->withModules(MyModule::class) ->build(); $service = $app->get(MyService::class); // $service is ready to use, and IDE autocompletion works!
What's Next? ๐ฎ
I'm working on:
- power-modules/events: Event-driven architecture extension
- Better documentation: More examples and patterns
- Performance optimizations: Caching for module routes
- ... Suggestions welcome!
Conclusion ๐ญ
Building this framework taught me more about dependency injection, module systems, and architectural patterns than years of just using existing tools.
Key takeaways:
- Explicit dependencies are better than implicit ones
- Module boundaries should be enforced, not just conventional
- Cross-cutting concerns can be added without breaking encapsulation
- Good architecture supports evolution (monolith โ microservices)
Is it revolutionary? No - these patterns exist in other ecosystems.
Is it useful? I think so - it solves real problems I've faced in complex PHP applications.
Resources ๐
- GitHub: power-modules/framework
- Packagist: power-modules/framework
- Router Extension: power-modules/router
- Use Cases & Examples: Use Cases & Examples
What architectural challenges do you face in your PHP projects? Have you tried similar approaches? I'd love to hear your thoughts in the comments! ๐ฌ
Top comments (0)