DEV Community

Cover image for Cross-Platform Modularity in PHP
Anton Ukhanev
Anton Ukhanev

Posted on • Updated on

Cross-Platform Modularity in PHP

Notice: There's an on-going discussion in support of the underlying Service Provider standard. Join the effort!

Background

For over 12 years now, I have been working with various engines and frameworks, from CodeIgniter to Magento, but the greatest amount of time I have spent on projects related to WordPress. One of my biggest passions is re-usability, and so by far, I have spent most of my WordPress time on plugins, working with companies and teams to produce high-quality, customizable solutions.

Of course, there's no quality and re-usability without interoperability, and without design principles like SOLID or DDD. That's why they are my other great passion. I have been in trouble countless times for pushing for better standards and refusing to write low-quality code.

In PHP, when we think "interoperability", we think "FIG". And I have been a fan of FIG for a long time, even making it into a workgroup. Although I don't always like or agree with everything FIG does, it is an organization that takes interop standards very seriously. And we need interop standards.

In fact, we need them yesterday, and there simply aren't enough resources at FIG to provide for everyone. At the same time, FIG is quite a rigid organization (for good reason), which is why I created Dhii: to serve as a more flexible source of standards and compliant implementations, providing a testing ground for ideas and contributing to interoperability in more niche areas. For example, it is not uncommon to use a __toString() method for converting objects to their string representation, but for some reason, PHP is missing a native interface that would allow us to type-hint (or at least document) that a stringable object is an allowed type. The same goes for many other things, like ID-aware objects, objects with human-readable data like labels and captions, templates and other output-related objects, and many more. At Dhii, we address these concerns in hope that our solutions would help packages to be more interoperable.

Modularity

One of these concerns is modularity. Many systems lack the notion of modules. WordPress allows for plugins, themes, drop-ins, etc., but nothing unifies them. I describe the problem and solutions in detail in my article "Package Management in WordPress". Other systems, like Symfony, support modules, but they are coupled to those systems, and therefore cannot be used anywhere else. Moreover, if your WordPress plugin needs to be modular itself — perhaps because you want to support add-ons that would extend its core functionality — there is no available standard in WordPress or anywhere else to make that possible.

With all the interop standards that already exist and have yet to come, why not have modules that can work in any system, provided that it has a compatible loader? Wouldn't it be great if a piece of functionality that addresses a business problem in WordPress could also be used in Drupal? Why do we have to write the same thing so many times, over and over again? I cannot even imagine how much time and money gets spent on re-inventing the wheel every day.

Meet dhii/module-interface: a standard for cross-platform modules. Dependent on a FIG experimental service-provider standard, it allows you to quickly and easily define a cross-platform module for use in any system with a compatible loader. How you program the module internals, including the level of granularity for declared services, is entirely up to you; but when done correctly, it automatically allows every individual part of your module to be infinitely extendable from outside. One module can completely change the way another module works. And yet, the application comprised of these modules is ultimately in control. Here's how this is achieved.

Note: If you don't understand why you would want cross-platform modules with easily changeable components, you may want to learn about Separation of Concerns.

Inversion of Control

Traditional Approach

In most cases, a modular application includes some entry points which can be used by modules to interrupt or extend the flow of the application. In such systems, modules are made for the application, and can change only the aspects open for change by the application or other modules. Often, this is done via an event mechanism: the application would trigger an event which can be handled by module handlers; those would change some parameters of the event; these parameters would then be returned and used by the original code. For example, WordPress uses hooks to de-couple plugin code from its internal flow. These are some of the problems such a mechanism suffers from:

  • The event system is often proprietary, making any consumers (plugins) coupled to it.
  • The consumers can only modify the data that is exposed via the events, leaving all other aspects set in stone, which greatly limits what modules can do.

Even if a system does not use events, but relies on some other mechanism, such as service definitions, the module standard would be specific to that system, and you would not be able to re-use the same module somewhere else.

A Better Approach

Ideally, the application would have no more than the following responsibilities:

  • Combining individual parts. That is, implementing a mechanism for loading interoperable modules. This includes setting the load order.
  • Linking with a bigger system, if necessary. For example, a modular WordPress plugin is a part of WordPress, and so it would provide a bridge between the services of application modules, and WordPress.
  • Satisfying module dependencies. For example, modules may depend on an HTTP client, and the application can satisfy that dependency, thus deciding the HTTP client implementation used by the modules.

With such responsibility distribution, the application chooses which modules to load, how and when. For example, If an application needs logging capabilities, it may choose to use a module that addresses that concern. But that logging module may require a filesystem abstraction, and the application would be responsible for satisfying that requirement. The application may choose to load another module or library which provides that abstraction. Thus, the application is for modules, inverting control, taking it from modules into its own hands.

Module Loading

Module Loading

One of the main responsibilities of the application is loading the selected modules, and a crucial part of that is determining the module load order. This order has a direct effect on the services that will effectively be used in the application. The module standard relies on service-provider implementations exposed by the modules. These Service Providers expose service definitions in two ways:

  1. Factories. The definitions returned by getFactories() completely override definitions of the same service factories that were declared in the modules loaded before. In other words, the last definition wins. All other service factories are not used in the application.
  2. Extensions. The definitions returned by getExtensions() are applied on top of the same service extensions that were declared in the modules loaded before. In other words, later definitions extend earlier ones. All extensions are always applied.

And here is how the modules are loaded by a compliant system:

  1. All module instances are retrieved.
  2. The setup() method of all modules is invoked, and the exposed service providers are used to configure a single DI container.
  3. The run() method of all modules is invoked, providing all configuration to each module via the DI container.

This creates an extremely simple yet powerful mechanism for modules to influence other modules in a way that is completely predictable, with one DI container being the single source of truth for all configuration.

  • A module's service provider exposed by setup() declares all services used by it.
  • These services are consumed by the run() method via a standard container.
  • Any module can access any service declared by any other module, because they are all available via the single source of truth.
  • Any module can extend or completely override any service declared by any other module, and the application decides the priority via the module load order.
  • A module may explicitly delegate a service to another module by not declaring it, and the application will then satisfy that dependency.

Here is an illustration of how service definitions would resolve according to the rules described above.

Service Resolution

The following can be gathered from this illustration:

  • The modules are loaded in order: first Module1, then Module2.
  • When requesting services B, D, and E, they would be retrieved from definitions of modules 1, 2, and 2, respectively. This is because they are declared there, and not overridden or extended anywhere later.
  • The service C is first declared in Module, but then overridden by that in Module2.
  • The service A is declared in Module1, and then extended in Module2.

Out of the box, there is complete customizability and flexibility, without sacrificing centralization and control. Modules are self-contained, and depend on outside services through the DI container.

Getting started

So, how would one start working in a modular system like this, and what would it take to make a simple application?

Bootstrap

To fulfill its main responsibility, the application must load modules in an order pre-determined by it; the application must bootstrap the module system. If the module classes do not require any parameters in their constructors, the bootstrap could be as simple as this:

use Dhii\Modular\Module\ModuleInterface;
use Interop\Container\ServiceProviderInterface;
use Some\Container;
use MyPackage\Module as ModuleA;
use OtherPackage\Module as ModuleB;

// Module classes
$modulesToLoad = [
    ModuleA::class,
    ModuleB::class,
];

// A container implementation that supports service providers
$container = new Container();

/* @var ModuleInterface[] */
$modules = [];

// Get the modules' services
foreach ($modulesToLoad as $moduleClassName) {
    // Instantiate the module
    $module = new $moduleClassName();
    // Keep track of module instances
    $modules[$moduleClassName] = $module;

    // Retrieve the services
    $provider = $module->setup();
    // Add the services to the container
    $container->register($provider);
}

// Run the modules
foreach ($modules as $module) {
    $module->run($container);
}
Enter fullscreen mode Exit fullscreen mode

That's all; barring comments, imports, and whitespace, all it takes to have a simple modular system is 20 lines of code. Module classes in this example get autoloaded like any other class.

A module

A module may provide services, consume services, or both. Let's create a simple module.

namespace MyPackage;

use Dhii\Modular\Module\ModuleInterface;
use Psr\Container\ContainerInterface;

class Module implements ModuleInterface
{
    public function setup(): ServiceProviderInterface
    {
        return new ServiceProvider([
            'my_service' => function (ContainerInterface $c): MyService {
                // A dependency
                $otherService = $c->get('other_service');
                // Perhaps a class declared in this package
                return new MyService($otherService);
            },
        ], []);
    }

    public function run(ContainerInterface $container)
    {
        // Retrieve a service - may have been overridden or extended elsewhere
        $myService = $container->get('my_service');
        $myService->doSomething();
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we have a module that declares a service my_service of type MyPackage\MyService and consumes it. That service depends on a service other_service, and because it is not declared in this module, it is a dependency. The ServiceProvider class was omitted here for brevity, because implementations are extremely simple, and can even be easily created in the same package, including anonymously.

Additional Possibilities

The possibilities opened up by such an architecture are limitless, including:

  1. Submodules. A module may contain a loading mechanism itself, which would load other modules that would therefore become its submodules. This fact allows for a module hierarchy of arbitrary depth, while each module remains agnostic of its environment.
  2. Usage in non-modular applications. A modular WordPress plugin made in this way could be hooked up to the WordPress system through the DIC container, and thus may consume engine-specific things like settings, without breaking interop.
  3. Dependency graphs. Service dependencies can be analyzed statically, producing a list of services that must be added to a system for the module to work. Combined with similar lists from other modules, it is possible to search for or suggest other modules that satisfy dependencies of any particular module.
  4. Medium-agnostic configuration access. Dependency containers that draw configuration from different sources, like files, the database, or even network, can be combined into the single source of truth consumed by modules, allowing them to consume configuration values that are resolved based on the availability of the key in the above-mentioned sources. For example, service configuration is overridden by YAML configuration, which is overridden by settings stored in a database.

Discussion (0)