DEV Community

Cover image for Solving PHP's Module Coupling Problem: A Journey Into Modular Architecture
HomelessCoder
HomelessCoder

Posted on • Edited 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 PowerModuleSetup
{
    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 (4)

Collapse
 
homeless-coder profile image
HomelessCoder

I'm building this project in the open, and I'd love to connect with other developers who are passionate about software architecture. If you have questions, ideas, or just want to say hi, please feel free to reach out in the comments or connect with me on LinkedIn. Your feedback and perspective are incredibly valuable as this project grows!

Collapse
 
woodygilk profile image
Woody Gilk

The dependencies are implicit and hidden. Your DI container knows about them, but your code doesn't make them explicit.

This makes no sense at all. You took explicit dependencies (via constructor arguments) and made them hidden behind complicated container orchestration. How is this any kind of improvement?

Collapse
 
homeless-coder profile image
HomelessCoder • Edited

Totally fair to call out that constructor args make class‑level deps explicit. The problem I’m addressing is one level up: architecture-level visibility and enforceability.

  • Constructor args show Foo → Bar. Imports/exports make Module A → Module B explicit.
  • You can’t cross a module boundary unless the provider exports it and the consumer imports it. That’s enforced at boot (and can fail CI).
  • Because those contracts are first‑class, the dependency graph is generated automatically, so boundaries are reviewable (and cycles/backdoors are visible).

So, this doesn’t hide dependencies... it makes the architectural ones visible and enforceable in addition to the class‑level ones.

If you want to see it in action, I just published a follow‑up with visual renderers (see screenshots) that draw the real module graph from these contracts:
dev.to/homeless-coder/from-vision-...

Collapse
 
woodygilk profile image
Woody Gilk

Such systems make sense in (eg) Node, where there is no proper dependency injection system, as a sort of "poor man" DI providing public/private boundaries. But when applied to a fully object-oriented and dependency injected system, this only adds complexity with no obvious runtime value. At least that's what I see here, speaking from over 20 years experience with PHP.