Using Delegator Factories in Laminas Service Manager to Add Cross‑Cutting Concerns
Learn how to use Laminas Service Manager delegator factories to add logging or other cross‑cutting concerns to existing services without modifying their factories or classes.
12 Oct 2025, 07:15 UTC

Problem: Adding logging to existing services without touching their code
You have a set of services defined in module.config.php that are instantiated by factories or are invokable classes. A new requirement asks that every time a service is retrieved, a log entry is written. Modifying each factory or the service class itself would be repetitive and risky, especially when the services come from third‑party modules.
Thesis: Delegator factories let you wrap service creation centrally
Laminas Service Manager’s delegator factory feature intercepts the moment a service is created, allowing you to decorate or augment the instance before it is returned to the caller. Because delegators are registered per service name and compose in registration order, you can add logging (or any cross‑cutting concern) without altering the original factory or the service class.
How delegator factories work
The Service Manager implements Psr\Container\ContainerInterface. When $container->get($name) is called, it first looks for a factory. If a delegator factory is registered for $name, the manager passes the raw instance to each delegator in the order they were declared. Each delegator receives a Psr\Container\DelegatorFactoryInterface callback that resolves the next layer (the original factory or the previous delegator). The final return value is what the caller receives.
Key interfaces
DelegatorFactoryInterface– defines__invoke(ContainerInterface $container, string $name, callable $callback, array $options): mixed.- The
$callbackresolves the next service layer; invoking it returns the currently built instance.
Worked example: Logging a database adapter
Assume you have a service named DbAdapter defined as an invokable class MyApp\Db\PdoAdapter. You want to log every time the adapter is fetched.
1. Create the delegator factory
// src/Delegator/DbAdapterLogger.php
namespace App\Delegator;
use Psr\Container\ContainerInterface;
use Psr\Container\DelegatorFactoryInterface;
use Psr\Log\LoggerInterface;
class DbAdapterLogger implements DelegatorFactoryInterface
{
public function __invoke(ContainerInterface $container, string $name, callable $callback, array $options): mixed
{
// Let the container build the original service first
$adapter = $callback();
// Retrieve a logger from the container (could also be injected via constructor)
$logger = $container->has(LoggerInterface::class)
? $container->get(LoggerInterface::class)
: null;
if ($logger) {
$logger->info('DbAdapter service retrieved', ['service' => $name]);
}
return $adapter;
}
}
2. Register the delegator in module.config.php
// config/module.config.php
return [
'service_manager' => [
'factories' => [
// DbAdapter is an invokable, no factory needed
],
'delegators' => [
DbAdapter::class => [
App\Delegator\DbAdapterLogger::class,
],
],
],
];
3. Verify the behavior
Run a simple script to confirm the logger is invoked:
// bin/test-delegator.php
require __DIR__ . '/../vendor/autoload.php';
use Laminas\ServiceManager\ServiceManager;
use Psr\Log\LoggerInterface;
$config = require 'config/module.config.php';
$container = new ServiceManager($config['service_manager'] ?? []);
// Provide a dummy logger that just echoes to stdout
$container->setService(LoggerInterface::class, new class implements LoggerInterface {
public function info($message, array $context = []): void {
echo "[INFO] $message\n";
}
// … other Psr\Log\LoggerInterface methods omitted for brevity
});
// First retrieval – should log
$adapter1 = $container->get(DbAdapter::class);
// Second retrieval – should log again (shared=true by default)
$adapter2 = $container->get(DbAdapter::class);
// Both variables reference the same instance because DbAdapter is shared
if ($adapter1 === $adapter2) {
echo "Same instance returned\n";
}
Expected output (order may vary):
[INFO] DbAdapter service retrieved [INFO] DbAdapter service retrieved Same instance returned
Trade‑off and limitation: registration order matters
If you register multiple delegators for the same service, they wrap the instance in the order they appear in the delegators array. The first delegator receives the raw service, returns a possibly decorated object, which becomes the input for the second delegator, and so on. Misunderstanding this nesting can lead to double‑wrapping or unexpected behavior (e.g., a logger that expects the original service but receives a already‑logged proxy).
Additionally, delegator factories add a small overhead to each service resolution because of the extra callback invocations. In high‑throughput applications, consider:
- Enabling proxy generation for lazy services at build time to avoid runtime reflection costs.
- Profiling the container resolution path if you notice latency spikes.
Actionable closing steps
- Identify the cross‑cutting concern you need (logging, metrics, transaction start, etc.).
- Implement a class that satisfies
DelegatorFactoryInterfaceand receives the concern via constructor injection or container lookup. - Add the delegator to the
delegatorslist for the target service name in the appropriatemodule.config.php. - Write a quick integration test (as shown) to confirm the concern fires on each retrieval and that shared/shared=false semantics are preserved.
- Run
vendor/bin/laminas-config-validate(if you have laminas-config-validator installed) to ensure your merged configuration is valid.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.