How to Get Parameter in Symfony Controller the Clean Way

Services are already moving to Constructor Injection in Symfony.
Now it's time for parameters to follow.

The Easy Way

final class LectureController extends SymfonyController
{
    public function registerAction()
    {
        $bankAccount = $this->container->getParameter('bankAccount');
    }
}

It works, but it breaks SOLID encapsulation of dependencies. Controller should not be aware of whole DI container and every service in it. It should take only what it needs as any other delegator.

What if we need a service to pay a registration fee to our bank account?

Since Symfony 2.8 with autowiring we can go for constructor injection with no obstacles:

<?php declare(strict_types=1);

final class LectureController extends SymfonyController
{
    /**
     * @var PaymentService
     */
    private $paymentService;

    public function __construct(PaymentService $paymentService)
    {
        $this->paymentService = $paymentService;
    }

    public function registerAction(): void
    {
        $bankAccount = $this->container->getParameter('bankAccount');

        $this->paymentService->payAmountToAccount(1000, $bankAccount);
    }
}

This can go completely wrong, not because dependency injection is better than service locator, but because code is now inconsistent. It's not clear:

At that's what we think about when we refactored code and know about it's previous state.

When your colleague will extends this code 3 months later, he might broke your window:

<?php declare(strict_types=1);

final class LectureController extends SymfonyController
{
    /**
     * @var PaymentService
     */
    private $paymentService;

    public function __construct(PaymentService $paymentService)
    {
        $this->paymentService = $paymentService;
    }

    public function registerAction(): void
    {
        $bankAccount = $this->container->getParameter('bankAccount');

        $this->paymentService->payAmountToAccount(1000, $bankAccount);
    }
+
+    public function refundAction(): void
+    {
+        $refundService = $this->container->get(RefundService::class);
+        $refundService->refundToLoggedUser(1000);
+    }
}

Consistency over Per Change Pattern

You understand your code = you know reasons why it's written this way and the boundaries. You know when to use dependency injection and when service (or pamater) locator.

But that's you. Only you. Other people don't have your experience and your memory. They read the code and learn while reading.

That's why it's important to use as less rules as possible to prevent cognitive overload. Which leads to poor understanding of the code and coding further in the same file but in own personal way, not related to original code much.

DI is the Flow – Go With It

Symfony 3.3 and 3.4/4.0 brought many new DI features and with it an evolution to developer experience paradigm. Thanks to Nicolas Grekas, and subsequently Kévin Dunglas and Martin Hasoň.

The Clean Way

Service is created in the container and passed via constructor where needed. Why not parameter, which is also loaded by the container?

<?php declare(strict_types=1);

final class LectureController extends SymfonyController
{
    /**
     * @var string
     */
    private $bankAccount;

    /**
     * @var PaymentService
     */
    private $paymentService;

    public function __construct(string $bankAccount, PaymentService $paymentService)
    {
        $this->paymentService = $paymentService;
        $this->bankAccount = $bankAccount;
    }

    public function registerAction(): void
    {
        $this->paymentService->payAmountToAccount(1000, $this->bankAccount);
    }
}

Change the Config

We need to:

It's lot of work, but it's worth it!

# config.yml
parameters:
    bankAccount: '1093849023/2013'

services:
    _defaults:
        autowire: true

    App\Controller\LectureController:
        arguments:
            - '%bankAccount%'

Would you use this approach? 5 lines for 1 parameter in 1 service? Maybe.

What about 2, 3 or 40 controllers/services using it?

services:
    autowire: true

    App\Controller\LectureController:
        arguments:
            - '%bankAccount%'

    App\Controller\ContactController:
        arguments:
            - '%bankAccount%'

    # and 40 more services with manual setup
    App\Model\PaymentService:
        arguments:
            # with care when used with another position then 1st one
            2: '%bankAccount%'

Doh, so much work :(

I find the easy way now much more likeable:

$this->container->getParameter('bankAccount');

Wait! No need to go easy and dirty. There is simpler way.

Since Symfony 3.3 we can use PSR-4 service loading and since Symfony 3.4/4.0 parameter binding.

How changed previous steps?

services:
    _defaults:
        autowire: true
        bind:
            $bankAccount: '%bankAccount%'

    App\Controller\:
        resource: ..

Now you can add 50 more services using $bankAccount as constructor dependency with no extra edit on config. Win-win!



Happy Config Fit & Slimming!


This Post is Tested, Make it Last Forever

The final code is tested (see it on Github) and is the best solution in time being.

Thanks to tests this code:

  • always run against the most recent dependencies
  • will get updates with just a little work
  • will be useful for as long as possible

Read more about tested posts as they're essential for post's lifetime.

Without tests the post won't make it through next major release and it's long tail effect would spread obsolete practise.

What do you think?


GitHub RSS @votrubaT Runs on Statie Hosted on GitHub

Like what I write about? Hire me & we can work together