Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Service Action Injection

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

You may have noticed that I seem to be avoiding "action" injection. For both QuestionRepository and ChartBuilderInterface, normally, when I'm in a controller, I'll like to be lazy and autowire them directly into the controller method.

The Problem with Action Injection

Let's actually try that, at least for ChartBuilderInterface. Remove ChartBuilderInterface from the constructor... and, instead add it to the method: ChartBuilderInterface $chartBuilder.

... lines 1 - 24
class DashboardController extends AbstractDashboardController
... lines 27 - 28
public function __construct(QuestionRepository $questionRepository)
$this->questionRepository = $questionRepository;
... lines 33 - 35
public function index(ChartBuilderInterface $chartBuilder = null): Response
... lines 38 - 49
... lines 51 - 125

And now... I need to pass $chartBuilder into createChart()... because, down here we can't reference the property anymore. So add ChartBuilderInterface $chartBuilder... and use that argument.

... lines 1 - 35
public function index(ChartBuilderInterface $chartBuilder = null): Response
... lines 38 - 44
return $this->render('admin/index.html.twig', [
... lines 46 - 47
'chart' => $this->createChart($chartBuilder),
... lines 51 - 99
private function createChart(ChartBuilderInterface $chartBuilder): Chart
$chart = $chartBuilder->createChart(Chart::TYPE_LINE);
... lines 103 - 124
... lines 126 - 127

Cool. So in theory, this should work... because this is a normal controller and... this is how action injection works! But you might already notice that PhpStorm is pretty mad. And, it's right! If we refresh, huge error!

DashboardController::index must be compatible with AbstractDashboardController::index.

The problem is that our parent class - AbstractDashboardController - has an index() method with no arguments. So it's not legal for us to override that and add a required argument.

The Workaround

But if you do want action injection to work, there is a workaround: allow the argument to be optional. So add = null.

That makes PHP happy and, in practice, even though it's optional, Symfony will pass the chart builder service. So this will work... but to code defensively just in case, I'm going to add a little assert() function.

This may or may not be a function you're familiar with. It comes from PHP itself. You put an expression inside like null !== $chartBuilder - and if that expression is false, an exception will be thrown.

... lines 1 - 35
public function index(ChartBuilderInterface $chartBuilder = null): Response
assert(null !== $chartBuilder);
... lines 39 - 49
... lines 51 - 127

So now we can confidently know that if our code gets this far, we do have a ChartBuilderInterface object.

Refresh now and... got it! So action injection does still work... but it's not as awesome as it normally is. Though, it does have one concrete advantage over constructor injection: the ChartBuilderInterface service won't be instantiated unless the index() method is called. So if you were in a normal Crud controller with multiple actions, action injection allows you to make sure that a service is only instantiated for the action that needs it, instead of in all situations.

Next: let's learn how to override templates, like EasyAdmin's layout template, or how an IdField is rendered across our entire admin area.

Leave a comment!

Login or Register to join the conversation
yaroslavche Avatar
yaroslavche Avatar yaroslavche | posted 10 months ago | edited

I have a kind of question. How about the Required attribute?

// ...
use Symfony\Contracts\Service\Attribute\Required;
use Symfony\UX\Chartjs\Builder\ChartBuilderInterface;
// ...

class DashboardController extends AbstractDashboardController
    #[Required] public ChartBuilderInterface $chartBuilder;
    // ...

Personally, I prefer that way to inject services.

  • short and straightforward
  • don't need to touch ctor signature (or can be promoted as well)
  • simple capability to mock dependencies in tests


  • can't be readonly and since it's public - theoretically can be reassigned (I didn't face yet such things)
  • implicit dependencies, when you'd like to instantiate object manually (rare case)
  • maybe some overhead because of reflection?

Have you other thoughts about DI via ctor arguments vs Required attribute?


Hey Yaroslavche,

Interesting idea, though as you said it's public property so it cannot be read-only and probably violates some SOLID principles. Also, in case you inject it directly into the method name - it's a method dependency. But if you make it a property - it probably would mean it's a class dependency now, so it seems you increase its scope.

But in general, it is just a matter of taste and mostly depends on the specific use case. :)


1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", //
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0