-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[CallableWrapper] Add CallableWrapper component and framework integration #58076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
724a9f9
to
03e909b
Compare
@94noni In principle, yes, but not by default for every service. It would require a middleware layer to hook into it before the method is called. Currently, only proxies can do that for services. Somehow, collect the class/method decorators for each service and call them from within? but not sure about performance though. |
✅ Added Framework integration and controller decoration support ✅ As well as a new Doctrine transaction decorator through #[Route('/tasks', methods: 'POST')]
class CreateTaskController
{
public function __construct(private TaskRepositoryInterface $repository)
{
}
#[Serialize(format: 'json')]
#[Transactional]
public function __invoke(#[MapRequestPayload] TaskPayload $payload): Task
{
$task = new Task($payload->description, $payload->dueDate);
$this->repository->add($task); // $this->entityManager->persist($task);
return $task;
}
} Keeping the controller code free of persistence and presentation deps. The This is just an example. There are more approaches and architectures where decorators can be useful. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a big fan on putting readonly
on each and every class. I'm not sure it does make much sense on services, it makes more sense on DTO/VOs to me. But that's just my two cents. 🙂
However, I like very much the idea behind this component. Thank you for proposing it!
3dc5c9c
to
8017d9c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New round 😄
src/Symfony/Bridge/Doctrine/Decorator/DoctrineTransactionDecorator.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Tests/Decorator/DoctrineTransactionDecoratorTest.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Tests/Decorator/DoctrineTransactionDecoratorTest.php
Outdated
Show resolved
Hide resolved
src/Symfony/Bridge/Doctrine/Tests/Decorator/DoctrineTransactionDecoratorTest.php
Outdated
Show resolved
Hide resolved
I updated the PR description to include the Pros and Cons |
Interesting! If I understand correctly, I see another useful decorators like “memoize” to cache the result of a method. Looking forward to seeing more feedback 😉 |
29a47fc
to
b1a6ae6
Compare
d2a7c34
to
4e0e297
Compare
333c3a5
to
222ee58
Compare
@ostrolucky I understand that this might be a bit confusing at first, especially since we’re already familiar with a decoration method that addresses this kind of problem. However, this new component and its integration are intentionally designed to solve the decoration need in a different way for two main reasons:
In other words, instead of decorating the service using inheritance (as we used to do by making the decorator aware of the target class and methods), this decoration method targets any callable. This means that multiple decorators can be applied to any callable, and these decorators can be reused without needing to know the details of the classes or methods they’re decorating. Of course, this approach is best suited for situations where this kind of decoration is beneficial, such as in handlers or controllers. |
I (at least I think so) like this! 🤩 #[CollectCacheTags]
#[Serialize(format: 'json')]
#[Validate]
public function __invoke(#[MapRequestPayload] TaskPayload $payload): Task Is there a way to combine these three to eg |
@RobinHoutevelts great idea! It’s definitely possible to add such a nice feature. In the meantime, I’m backporting it into yceruto/decorator-bundle with support for |
I think this could be useful to integrate locking. But if the key is dynamic would it be possible to use something similar like with |
It's possible because you have control over the controller's arguments during the decoration phase. At this point, all arguments are already resolved, and their values are the same as what you'll receive in the controller method. However, it's harder to manipulate the arguments since they are just a simple list of values at this stage (not named arguments). I think that for use cases where you want to add logic based on the controller’s arguments, using a I'd say that a Callable Decorator is a more generic & versatile solution for prepending or appending logic around controllers or any other callable, allowing you to combine decorators or reuse them for different callables. The |
I added the Messenger/handler integration since the changes are minimal. Here's what this feature allows you to do: namespace App\Handler;
use Symfony\Bridge\Doctrine\Decorator\Transactional;
#[AsMessageHandler]
class ProductCreatedEventHandler
{
#[Transactional]
public function __invoke(ProductCreatedEvent $event): void
{
// update other related data...
}
} Similar to controllers, callable decorators are helpful when you need to decorate the implementation of specific handlers. However, if the decorator's logic applies to all handlers on the message bus, it's better to create a middleware instead. |
After an internal discussion about the component's name, we decided to rename it to "CallableWrapper". PR and description updated. |
I'm closing this for now. I'll revisit this topic soon via an RFC issue with more details on how to implement this feature in a way I believe is better than the one proposed here. Thanks all for your inputs! |
This is an attempt to solve the linked issue in a generic way, making it applicable in other areas where it might be useful as well.
Note
Inspired by Python decorators
This component implements the Decorator Pattern around any PHP callable, allowing you to:
It solves issues related to subclass explosion and inflexibility in inheritance-based designs. The pattern is useful for extending object behavior without modifying the original code, making it ideal for scenarios where different combinations of behaviors are needed.
The component contains two main building blocks:
CallableWrapperInterface
adapter (the wrapper): contains the wrapping implementation, essentially defining what should be done before and after the targeted callable is executed.CallableWrapperAttribute
orCallableWrapperAttributeInterface
: links a callable to a wrapper and collects its options if needed.Example:
The
Debug
attribute holds all metadata needed for the linked wrapper, which must be referenced using theCallableWrapperAttributeInterface::wrappedBy()
method. By default, the abstractCallableWrapperAttribute
class returnsstatic::class.'CallableWrapper'
as convention.Wrappers can be nested, so the order in which you define them around the targeted callable really matters. This is a visual representation of multiple wrappers around a function and how they wrap around each other:
In short, the closer a wrapper is to the targeted callable, the higher its execution priority.
Callable wrappers might require some options. To handle this, you can define them using the constructor method and add the metadata attribute as a new argument in your wrapper implementation:
Final usage:
The component requires a middleware layer to wrap the targeted callable before it's actually called. So, after the framework integration, a service named
callable_wrapper
->CallableWrapper
-> aliasing toCallableWrapperInterface
will be available with all collected wrapper services tagged withcallable_wrapper
(seeCallableWrapperPass
). Then, you'll do:callable
into multiple wrapperscallable
that implements many possible variants of behavior into several smallercallable
.Refer to the tests for more details on use cases and how wrappers are executed in the final stage.
Important
Why create a new component? Because it can be implemented across various components of our Symfony project and application, such as HttpKernel/Controllers, Messenger/Handlers, and potentially other custom producer/consumer mechanisms that contain a middleware layer.
Cheers!