vijoni / organized-modules
Organize PHP modules into isolated business units.
Requires
- php: >=8.0
Requires (Dev)
- codeception/codeception: ^4.1
- codeception/lib-asserts: ^2.0
- fakerphp/faker: ^1.19
- phpstan/phpstan: ^1.4
- squizlabs/php_codesniffer: *
This package is auto-updated.
Last update: 2025-02-27 12:53:06 UTC
README
Organize PHP modules into isolated business units.
Imagine Service Oriented Architecture (SOA) in Monolith code base.
This library handles creation and dependency injection of modules nested inside a business
service or in a bounded context (term from DDD). This concept could be also known as Majestic Monolith.
Let's take as an example room renting application. In your business you could identify following units and related with them use cases:
- booking; room reservation, confirmation, cancellation,
- sales; payment, refund, discount
- operations; login management, user management, room management
- marketing; notification, campaign
In either SOA, Microservices, Monolith approach you need to identify your business cases. The difference is that in case of SOA and Microservices you need to take into account the complexity of a distributed system. With this library you can isolate and group business requirements inside single repository.
Structure
Check acceptance tests for details
The image presents Sales
unit with Order
and Payment
modules.
Actions/Controllers
You have to define your Action/Controller classes in a directory inside the Module. This is important, as
it is assumed that ModuleFacade
, ModuleFactory
, ModuleConfig
, DependencyProvider
exist directly
in the Module's directory, one-level above the Action/Controller class. The name of the directory is not important.
Dependency injection logic discovers the classes following this directory structure.
Gain access to $this->moduleFactory()
.
UnitFacade
Every Unit should contain a UnitFacade
class. It should access ONLY its own Unit's ModuleFacades
.
UnitFacade
exposes the methods/functionality you need in other Units.
For example in Booking Unit you may need to access a price calculation for the renting a room.
ModuleFacade
Every Module, should contain a ModuleFacade
. It should access ONLY its own Module's ModuleFactory
.
ModuleFacade
exposes methods/functionality you need in other Modules inside its own Unit.
For example Order Module may need to access Payment Module's functionality.
Gain access to $this->moduleFactory()
.
ModuleFactory
Here you define object creation methods and handle their dependencies. Inside ModuleFactory
you should manually
instantiate ONLY classes defined in its own Module. Dependencies coming from outside the Module should be defined
in ModuleDependencyProvider
. ModuleFactory
should not access its own ModuleFacade
.
Every factory also has access to application configuration object.
Factories can also be runtime variant specific, more on that later.
Gain access to $this->dependencyProvider()
, $this->config()
.
DependencyProvider
It exposes external dependencies, like UnitFacades, ModuleFacades and other objects like database connection, api client, logger etc.
ModuleConfig
Here you define methods returning Module specific configuration values or expose application configuration values.
Every ModuleConfig
has access to global AppConfig
object.
ModuleConfigs can also be runtime variant specific, more on that later.
Usage
Initialize Action/Controller
There are different ways to load the dependencies.
Through Interface
Action/Controller class has to implement ModuleActionInterface
, use the ModuleActionDependency
trait and call
DependencyProvider::fillActionDependencies($this)
on itself.
class CreateOrderAction implements ModuleActionInterface
{
use ModuleActionDependency;
public function __construct()
{
DependencyProvider::getInstance()->fillActionDependencies($this);
}
}
Through Inheritance
Action/Controller class has to extend ModuleAction
class.
class CreateOrderAction extends ModuleAction
{
public function __construct()
{
parent::__construct();
}
}
Custom integration
Action/Controller class has to implement ModuleActionInterface
and use the ModuleActionDependency
trait.
You can overwrite your framework's controller resolution class or register your own autoloader and use
the ModuleActionInterface
to identify classes upon which DependencyProvider::fillActionDependencies()
should be called.
Expose configuration to ModuleFactories
Call DependencyProvider::setConfig([])
on the application bootstrap.
// app_config.php
<?php
return [
'database' => [
'host' => '127.0.0.1',
'user' => 'dbuser',
],
'stripe' => [
'api_key' => 'xxx-xxx-xxx',
]
];
---------------------------------------
// index.php
$appConfig = AppConfig::fromFile(__DIR__ . '/app_config.php');
$dependencyProvider = DependencyProvider::getInstance();
$dependencyProvider->setConfig($appConfig);
Action/Controller dependencies
Every Action/Controller has access to its Module's ModuleFactory
.
class CreateOrderAction extends ModuleAction
{
public function __invoke(): void
{
$orderService = $this->moduleFactory()->newCreateOrderService();
}
}
ModuleFactory dependencies
Every ModuleFactory has access to its Module's ModuleConfig
and DependencyProvider
.
Factory methods can create new object instance with every call or cache the instance for future usages.
You can use your own naming convention. Here methods are prefixed with new
or share
.
Shared instances will always be the same. Anonymous function is called only once per ModuleFactory instance.
/**
* @method ModuleDependencyProvider dependencyProvider()
* @method ModuleConfig config()
*/
class ModuleFactory extends BaseModuleFactory
{
public function newCreateOrderService(): CreateOrderService
{
return new CreateOrderService($this->shareOrderRepository(), $this->shareOrderValidator());
}
protected function shareOrderRepository(): OrderRepository
{
/** @var OrderRepository */
return $this->share(
OrderRepository::class,
fn () => new OrderRepository($this->shareOrderReadGateway(), $this->shareOrderWriteGateway())
);
}
protected function shareOrderValidator(): OrderValidator
{
$paymentFacade = $this->dependencyProvider()->sharePaymentFacade();
/** @var OrderValidator */
return $this->share(
OrderValidator::class,
fn () => new OrderValidator($paymentFacade)
);
}
}
Variant specific ModuleFactory
By default, classes named ModuleFactory
are looked for, but you may need different logic for every country in which
you run your application. For example You serve DE, GB application and for GB you need different behaviour than the
default one or than the one for DE.
For this you could create separate Factory classes and overwrite the default method. To make this to work you need to set
a variant on the DependencyProvider::setVariant
in your application's bootstrap.
If variant specific ModuleFactory does not exist, the default one will be used.
// index.php
$dependencyProvider = DependencyProvider::getInstance();
// for example, you could grab the value from environment variable defined
// in your http server domain specific configuration
$dependencyProvider->setVariant(env(APP_STORE));
---------------------------------------
// ModuleFactory.php
/**
* @method ModuleDependencyProvider dependencyProvider()
* @method ModuleConfig config()
*/
class ModuleFactory extends BaseModuleFactory
{
public function newCreateOrderService(): CreateOrderService
{
return new CreateOrderService();
}
}
---------------------------------------
// ModuleFactoryGB.php
/**
* @method ModuleDependencyProvider dependencyProvider()
* @method ModuleConfig config()
*/
class ModuleFactoryGB extends ModuleFactory
{
public function newCreateOrderService(): CreateOrderService
{
return new CreateOrderServiceAllForFree();
}
}
ModuleConfig dependencies
Every ModuleConfig has access to AppConfig
, set using DependencyProvider::setConfig()
.
class ModuleConfig extends BaseModuleConfig
{
public function readStripeApiKey(): string
{
return $this->config()->getString('stripe.api_key');
}
}
Variant specific ModuleConfig
By default, classes named ModuleConfig
are looked for, but you may need different values for every country in which
you run your application. For example You serve DE, GB application and for GB you need different setting than
default one or than the one for DE.
For this you could create separate Config classes and overwrite the default values. To make this to work you need to set
a variant on the DependencyProvider::setVariant
in your application's bootstrap.
If variant specific ModuleConfig does not exist, the default one will be used.
Alternative could also be to load a country specific configuration file in the first place.
// index.php
$dependencyProvider = DependencyProvider::getInstance();
// for example, you could grab the value from environment variable defined
// in your http server domain specific configuration
$dependencyProvider->setVariant(env(APP_STORE));
---------------------------------------
class ModuleConfig extends BaseModuleConfig
{
public function isPurchaseEnabled(): bool
{
return true;
}
}
---------------------------------------
class ModuleConfigGB extends ModuleConfig
{
public function isPurchaseEnabled(): bool
{
return false;
}
}
ModuleDependencyProvider dependencies
Objects like database connection or api clients are defined outside the Module or maybe in different Units.
ModuleDependencyProvider
can expose any ModuleFacade, UnitFacade or other objects registered in DepedencyProvider
.
This creates a wall between Modules, Units and "outside world".
// index.php
$dp = DependencyProvider::getInstance();
$dp->register(
DatabaseConnection::class
fn => new DatabaseConnection();
);
---------------------------------------
class ModuleDependencyProvider extends BaseModuleDependencyProvider
{
public function sharePaymentFacade(): PaymentModuleFacade
{
/** @var PaymentModuleFacade */
return $this->dependencyProvider()->shareModuleFacade(PaymentModuleFacade::class);
}
public function shareDatabaseConnection(): DatabaseConnection
{
/** @var DatabaseConnection */
return $this->dependencyProvider()->share(DatabaseConnection::class);
}
}