DEV Community

HomelessCoder
HomelessCoder

Posted on

Solving PHP's Module Coupling Problem: A Journey Into Modular Architecture

How I Built a Modular PHP Framework and What I Learned About Architecture

Before PowerModules Framework

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, ) {} } 
Enter fullscreen mode Exit fullscreen mode

But what happens when:

  • Someone refactors UserService without knowing OrderService 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 { } 
Enter fullscreen mode Exit fullscreen mode

OSGi (Java)

// Bundle explicitly declares what it imports/exports Import-Package: com.example.user.service Export-Package: com.example.order.service 
Enter fullscreen mode Exit fullscreen mode

The pattern was clear: successful module systems make dependencies explicit.

My Solution: PowerModules Framework ๐Ÿš€

After 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 } } 
Enter fullscreen mode Exit fullscreen mode

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! } } 
Enter fullscreen mode Exit fullscreen mode

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, ]); } } 
Enter fullscreen mode Exit fullscreen mode

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(); 
Enter fullscreen mode Exit fullscreen mode

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); 
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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), ]; } } 
Enter fullscreen mode Exit fullscreen mode

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(); 
Enter fullscreen mode Exit fullscreen mode

Microservice Evolution

// Today: Modular monolith ImportItem::create(UserModule::class, UserService::class) // Tomorrow: HTTP API call ImportItem::create(UserApiModule::class, UserService::class) 
Enter fullscreen mode Exit fullscreen mode

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), ]; } } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Try It Yourself ๐Ÿš€

composer require power-modules/framework 
Enter fullscreen mode Exit fullscreen mode

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! 
Enter fullscreen mode Exit fullscreen mode

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 ๐Ÿ“š


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)