Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[RFC] Attribute-based Interception for Service Methods #59730

Copy link
Copy link
Open
@yceruto

Description

@yceruto
Issue body actions

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:

Cheers!

Metadata

Metadata

Assignees

No one assigned

    Labels

    DXDX = Developer eXperience (anything that improves the experience of using Symfony)DX = Developer eXperience (anything that improves the experience of using Symfony)DependencyInjectionFeatureRFCRFC = Request For Comments (proposals about features that you want to be discussed)RFC = Request For Comments (proposals about features that you want to be discussed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      Morty Proxy This is a proxified and sanitized view of the page, visit original site.