Skip to content

Commit 06438a1

Browse files
feature #60857 [FrameworkBundle] Add ControllerHelper; the helpers from AbstractController as a standalone service (nicolas-grekas)
This PR was merged into the 7.4 branch. Discussion ---------- [FrameworkBundle] Add `ControllerHelper`; the helpers from `AbstractController` as a standalone service | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT This PR is a follow up of symfony/symfony#16863 by `@derrabus` almost 10 years ago 😱, which was seeking for a solution to reuse helpers provided by `AbstractController` that'd be free from coupling by inheritance and that'd allow for more granular cherry-picking of the desired helpers. At the time, granular traits were proposed as the reusability unit. In this PR, I'm proposing to add a `ControllerHelper` class that'd allow achieving both goals using functions as the reusability unit instead. To achieve true decoupling and granular helper injection, one would have to use the `#[AutowireMethodOf]` attribute (see symfony/symfony#54016). Here is the chain of thoughts and concepts that underpin the proposal. It should be noted that this reasoning should be read as an example that could be extended to any helper-like class, e.g it fits perfectly for cherry-picking query functions from entity repositories. So, here is the chain for controllers: 1. The Problem: The Monolithic Base Class Symfony's `AbstractController` offers a convenient set of helper methods for common controller tasks. However, by relying on inheritance, our controllers become tightly coupled to the framework. This can make them more difficult to test in isolation and provides them with a broad set of methods, even when only a few are needed. 2. The Initial Goal: Reusability without Inheritance The long-standing goal has been to decouple controllers from this base class while retaining easy access to its valuable helper methods. The ideal solution would allow for a more granular, "à la carte" selection of these helpers. 3. The Path Not Taken: Granular Traits The original proposal in PR #16863 explored the use of granular traits (e.g., `RenderTrait`, `RedirectTrait`). This was a step towards greater modularity, allowing a developer to use only the necessary functionalities. However, a trait-based approach has its own set of challenges: - Implicit Dependencies: The services required by the traits (like the templating engine or the router) are not explicitly declared as dependencies of the controller. - A Different Form of Coupling: While avoiding vertical inheritance, traits introduce a form of horizontal coupling. 4. A More Modern Approach: The Injectable Helper Service This PR introduces a `ControllerHelper` service. This class extends `AbstractController` to leverage its existing, battle-tested logic but exposes all of its protected methods as public ones. This aligns with modern dependency injection principles, where services are explicitly injected rather than inherited. A controller can now inject the `ControllerHelper` and access the helper methods through it. 5. The Final Step: True Granularity with `#[AutowireMethodOf]` While injecting the entire `ControllerHelper` is a significant improvement, it still provides the controller with access to all helper methods. The introduction of the `#[AutowireMethodOf]` attribute (see #54016) is the final piece of the puzzle, enabling the ultimate goal of using individual functions as the unit of reuse. With `#[AutowireMethodOf]`, we can inject just the specific helper method we need as a callable: ```php class MyController { public function __construct( #[AutowireMethodOf(ControllerHelper::class)] private \Closure $render, #[AutowireMethodOf(ControllerHelper::class)] private \Closure $redirectToRoute, ) { } public function showProduct(int $id): Response { if (!$id) { return ($this->redirectToRoute)('product_list'); } return ($this->render)('product/show.html.twig', ['product_id' => $id]); } } ``` This solution provides numerous benefits: - Maximum Decoupling: The controller has no direct dependency on `AbstractController` or even the `ControllerHelper` class in its methods. It only depends on the injected callables. - Explicit and Granular Dependencies: The controller's constructor clearly and precisely declares the exact functions it needs to operate. - Improved Testability (less relevant for controllers but quite nice for dependents of e.g. entity repositories): Mocking the injected callables in unit tests is straightforward and clean. 6. Bonus: Auto-generated Adapters for Functional Interfaces For even better type-safety and application-level contracts, `#[AutowireMethodOf]` can generate adapters for functional interfaces. One can define an interface within their application's domain to achieve better type-coverage without any new coupling: ```php interface RenderInterface { public function __invoke(string $view, array $parameters = [], ?Response $response = null): Response; } ``` Then update the previous controller example to use this interface: ```diff #[AutowireMethodOf(ControllerHelper::class)] - private \Closure $render, + private RenderInterface $render, ```` Symfony's DI container will automatically create an adapter that implements `RenderInterface` and whose `__invoke` method calls the `render` method of the `ControllerHelper`. This gives full static analysis and autocompletion benefits with zero extra boilerplate code. This pull request, therefore, not only provides a solution to a long-standing desire for more reusable controller logic but does so in a way that is modern, flexible, and fully embraces the power of Symfony's dependency injection container (while still preserving really good usability when the DIC is not used, as is the case when unit testing.) Commits ------- c2020bb3a6c [FrameworkBundle] Add `ControllerHelper`; the helpers from AbstractController as a standalone service
2 parents 4e677eb + 1d8af87 commit 06438a1

File tree

6 files changed

+557
-13
lines changed

6 files changed

+557
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `ControllerHelper`; the helpers from AbstractController as a standalone service
78
* Allow using their name without added suffix when using `#[Target]` for custom services
89
* Deprecate `Symfony\Bundle\FrameworkBundle\Console\Application::add()` in favor of `Symfony\Bundle\FrameworkBundle\Console\Application::addCommand()`
910
* Add `assertEmailAddressNotContains()` to the `MailerAssertionsTrait`

Controller/AbstractController.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,6 @@ public function setContainer(ContainerInterface $container): ?ContainerInterface
6767
return $previous;
6868
}
6969

70-
/**
71-
* Gets a container parameter by its name.
72-
*/
73-
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
74-
{
75-
if (!$this->container->has('parameter_bag')) {
76-
throw new ServiceNotFoundException('parameter_bag.', null, null, [], \sprintf('The "%s::getParameter()" method is missing a parameter bag to work properly. Did you forget to register your controller as a service subscriber? This can be fixed either by using autoconfiguration or by manually wiring a "parameter_bag" in the service locator passed to the controller.', static::class));
77-
}
78-
79-
return $this->container->get('parameter_bag')->get($name);
80-
}
81-
8270
public static function getSubscribedServices(): array
8371
{
8472
return [
@@ -96,6 +84,18 @@ public static function getSubscribedServices(): array
9684
];
9785
}
9886

87+
/**
88+
* Gets a container parameter by its name.
89+
*/
90+
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
91+
{
92+
if (!$this->container->has('parameter_bag')) {
93+
throw new ServiceNotFoundException('parameter_bag.', null, null, [], \sprintf('The "%s::getParameter()" method is missing a parameter bag to work properly. Did you forget to register your controller as a service subscriber? This can be fixed either by using autoconfiguration or by manually wiring a "parameter_bag" in the service locator passed to the controller.', static::class));
94+
}
95+
96+
return $this->container->get('parameter_bag')->get($name);
97+
}
98+
9999
/**
100100
* Generates a URL from the given parameters.
101101
*

0 commit comments

Comments
 (0)