Skip to content

[FrameworkBundle] Add ControllerHelper; the helpers from AbstractController as a standalone service #60857

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

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from

Conversation

nicolas-grekas
Copy link
Member

@nicolas-grekas nicolas-grekas commented Jun 20, 2025

Q A
Branch? 7.4
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

This PR is a follow up of #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 #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 [FrameworkBundle] Split abstract Controller class into traits #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 [DependencyInjection] Add #[AutowireMethodOf] attribute to autowire a method of a service as a callable #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:

    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:

    interface RenderInterface
    {
        public function __invoke(string $view, array $parameters = [], ?Response $response = null): Response;
    }

    Then update the previous controller example to use this interface:

             #[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.)

@nicolas-grekas nicolas-grekas force-pushed the fwb-controller-helper branch from 7226057 to 53e9574 Compare June 20, 2025 15:49
@nicolas-grekas nicolas-grekas force-pushed the fwb-controller-helper branch from 53e9574 to 4bcbaab Compare June 20, 2025 15:55
@GromNaN
Copy link
Member

GromNaN commented Jun 20, 2025

One can define an interface within their application's domain to achieve better type-coverage without any new coupling.

Since the use-case is well-known, this interfaces should be provided by the FrameworkBundle itself. Otherwise the DX is broken as you don't have the signature of the closure for auto-completion and static analysis.

Continuing in this logic, having an invokable class for each helper would be more transparent: unique type for DI of each helper and method signature to call them. Less magic with the AutowireMethodOf attribute.

namespace Symfony\Bundle\FrameworkBundle\Controller\Helper;

class Render
{
    // Explicit dependency injection
    public function __construct(private Environment $twig) {} 

    public function __invoke(string $view, array $parameters = [], ?Response $response = null): Response
    {
        // ...
    }
}

So there is no need to use the magic attribute.

use Symfony\Bundle\FrameworkBundle\Controller\Helper\Render;
use Symfony\Bundle\FrameworkBundle\Controller\Helper\RedirectToRoute;

class MyController
{
    public function __construct(
        private Render $render,
        private RedirectToRoute $redirectToRoute,
    ) {
    }

    // Same function showProduct
}

@chalasr
Copy link
Member

chalasr commented Jun 20, 2025

I agree with Jérôme. I wouldn't want to see code using ControllerHelper elsewhere than in a Controller, even though it's only about pointing #[AutowireMethodOf] it feels just wrong conceptually.
So I'd either add as many high-level helpers as AbstractController methods in their respective components as suggested or keep things as they are today.

@nicolas-grekas
Copy link
Member Author

nicolas-grekas commented Jun 20, 2025

@GromNaN thanks for the comments. This proposal is about providing innovative ways to decouple from helper classes, with AbstractController and entity repositories as primary use cases.

Personally, I don't think maintaining a myriad of helper classes will improve anything: from a maintainer's perspective, it's an explosion of class, and from a DX perspective, it's a discoverability nightmare. Having one compact class with helper methods has been the natural solution for these reasons. This PR doesn't try to disrupt this practice - that's its main point and benefit: providing a solution to granularity without forcing us to use patterns that'd lead to even more classes/files to jungle with.

@nicolas-grekas
Copy link
Member Author

(Ignoring the likely too high migration costs, I think AbstractController could be favorably deprecated in favor of ControllerHelper, regardless of considerations related to AutowireMethodOf.)

@GromNaN
Copy link
Member

GromNaN commented Jun 20, 2025

If all the helper classes are in the same namespace, there is no discoverability issue.

The solution you propose is a less practical than the current AbstractController:

  • You don't know the method signature, and you don't have any easy access to the method (until IDE/tools add support for introspection of AutowireMethodOf). It feels like magic __call.
  • You have to repeat #[AutowireMethodOf(ControllerHelper::class)] private \Closure $<method> for every helper you want to use.
  • The PHP syntax for calling a closure from a property is unusual ($this->redirectToRoute)('product_list').

The advantage of the closure is for mocking, but as soon as you add invokable interfaces you lose this advantage.

@nicolas-grekas
Copy link
Member Author

If all the helper classes are in the same namespace, there is no discoverability issue.

Typing $foo-> and seeing the possibilities is way more DX-friendly than having to see sibling classes in a folder/namespace.

The solution you propose is a less practical than the current AbstractController: [...]

I 💯 agree with you, those drawbacks are real (except the use of the "magic" adjective to highlight that you don't like it - any attributes can be described as such by nature of declarativeness - until the behavior is accepted, so that's not really helpful...).

Yet, all these drawbacks apply to AutowireMethodOf.
And the proposal is about ControllerHelper ;)

What I wrote in the PR description needs some visionary thinking: from an architectural PoV, the design that's allowed by AutowireMethodOf is far superior. The good thing about the drawbacks is that they all can be removed by improving either the tooling or the syntax. We're here to build the future - we're not bound by the current state of the art.

(If you don't share the vision completely, then you can focus on the meat of the proposal: ControllerHelper is already an improvement over AbstractController, eg it can be used with invokable controllers.)

The advantage of the closure is for mocking, but as soon as you add invokable interfaces you lose this advantage.

You don't, anonymous classes to the rescue.

Copy link
Member

@GromNaN GromNaN left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, the new ControllerHelper service, injectable into controllers is an improvement for decoupling from the AbstractController inheritance. And you get all the helpers in a single service.

I hope tooling will improve to detect the signature of the injected closure with the attribute.

@derrabus
Copy link
Member

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:

Why don't we create interfaces for those helper methods then and make those autowirable?

@afilina
Copy link
Contributor

afilina commented Jun 21, 2025

This is how I would approach all these questions and concerns, and then you decide.

Typing $foo-> and seeing the possibilities is way more DX-friendly than having to see sibling classes in a folder/namespace.

We need to consider the audience of this feature. Are the same people who care about decoupling also comfortable discovering through namespaces? Likely.

ControllerHelper is already an improvement over AbstractController, eg it can be used with invokable controllers.

Nothing prevents Symfony from offering this new option, see how people use it, learn, iterate. It's definitely an improvement, so it's worthwhile.

from a maintainer's perspective, it's an explosion of class

That's a very important consideration and I agree that maintainers should not be asked to bite off more than they can chew.

Why don't we create interfaces for those helper methods then and make those autowirable?

That is best for long-term maintenance of an app, if you want to be able to keep up with upgrades and BC breaks. We shouldn't inject interfaces we don't own into our controllers. With that in mind, the proposed solution makes it easier for devs to create the individual adapters which implement those interfaces.

Copy link

@phcorp phcorp Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: now that it exists, why don't you use and subscribe to the service controller.helper in the AbstractController?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants