Description
This is a post-mortem research of #58076 to understand the outcomes, what worked, and what didn't, with the goal of finding actionable insights.
The topic focuses on AOP paradigm, in simple words: how to separate common extra features—like logging, caching, or handling transactions—from the code in your service methods. Let's picture what I mean exactly:
The What
(Logging method calls example)
Instead of doing this everywhere:
class MyBusinessService
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function execute(int $arg): int
{
$result = // some complex logic that return a result...
$this->logger->debug(sprintf('Method "%s()" was called with "$arg = %s" and return "%s".', __METHOD__, var_export($arg), var_export($result)));
return $result;
}
}
I want to do this:
class ImportantBusinessService
{
#[Log]
public function execute(int $arg): int
{
return // some complex logic that return a result...
}
}
acting #[Log]
as a join point (marker), indicating that an aspect (interceptor) should wrap the method execution:
class Log extends Options
{
public function interceptedBy(): string
{
return LogMethodCall::class;
}
}
class LogMethodCall implements CallableInterceptorInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function intercept(Closure $func, Options $options): Closure
{
// prepare something before the wrapping phase
return function (mixed ...$args) use ($func) {
// do something before the call
$result = $func(...$args);
// do something after the call. $this->logger->debug('...');
return $result;
};
}
}
Consider other practical examples like caching the execution of slow, resource-intensive methods or managing database transactions (e.g. using Doctrine $em->wrapInTransaction()
). This way, it lets you add extra behavior that aren't crucial to the business logic without cluttering the core code.
The How
#58076 proposed implementing this in a new component, but I now believe it should be part of the DI component instead, since the main issue here is "how to intercept the method call" when MyBusinessService::execute()
is invoked.
Initially, I proposed a CallableWrapper
to wrap the method before it's called, like a Controller listener or Messenger middleware where a callable invocation is controlled. However, this approach would restrict the feature to controllers or message handlers instead of applying it universally.
I'm now considering that an auto-generated proxy service is a better approach, yet it's more complex to implement. Suppose another service uses our MyBusinessService
:
class OtherService
{
public function __construct(
private MyBusinessService $service,
) {
}
public function doSomething()
{
$this->service->execute(1); // do something with it
}
}
imaging now that the DI component generates a proxy for MyBusinessService
, like this:
// auto-generated proxy class (in cache)
class MyBusinessServiceProxy12345 extends MyBusinessService
{
private InterceptorManagerInterface $manager;
public static function createProxy12345(InterceptorManagerInterface $manager, array $parentArgs = []): self
{
$self = new self(...$parentArgs);
$self->manager = $manager;
return $self;
}
// only those public methods marked with interceptors would be overridden
public function execute(mixed ...$args): int
{
return isset($this->manager) ? $this->manager->call(parent::execute(...), ...$args) : parent::execute(...$args);
}
}
and it's the one injected into OtherService
as a decorator of the MyBusinessService
service. If the concrete service is injected using an interface, the auto-generated proxy should be different but can still be achieved using the decoration pattern.
Another possible solution -> #59730 (comment)
Known Limitations
This proxy approach will work if the method is called from another service (which is the most common case we aim to solve here). However, calling the method from another class excluded from the DI scope will not trigger any interceptor (as expected).
Are there any other limitations I might not be aware of?
Questions
The big questions are:
- Do we want this feature to be implemented in the framework? I believe so, due to its complexity and the level of cohesion required for integration with the DI system.
- Is the proxy solution a good alternative? So far, this is the best I can think of.
- How to implement this proxy class properly?
related issues: #47184, #57079, #58076
references for similar approach:
- https://en.wikipedia.org/wiki/Aspect-oriented_programming
- https://github.com/Ocramius/ProxyManager/blob/2.15.x/docs/access-interceptor-value-holder.md
- https://github.com/FriendsOfPHP/proxy-manager-lts/blob/1.x/src/ProxyManager/ProxyGenerator/AccessInterceptorValueHolderGenerator.php
- https://github.com/AOP-PHP/AOP
- https://github.com/okapi-web/php-aop
Cheers!