Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Bonus! LoggerTrait & Setter 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 $10.00

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

Login Subscribe

What if we wanna send Slack messages from somewhere else in our app? This is the same problem we had before with markdown processing. Whenever you want to re-use some code - or just organize things a bit better - take that code and move it into its own service class.

Since this is such an important skill, let's do it: in the Service/ directory - though we could put this anywhere - create a new class: SlackClient:

... lines 1 - 2
namespace App\Service;
... lines 4 - 7
class SlackClient
{
... lines 10 - 30
}

Give it a public function called, how about, sendMessage() with arguments $from and $message:

... lines 1 - 7
class SlackClient
{
... lines 10 - 18
public function sendMessage(string $from, string $message)
{
... lines 21 - 29
}
}

Next, copy the code from the controller, paste, and make the from and message parts dynamic:

... lines 1 - 7
class SlackClient
{
... lines 10 - 18
public function sendMessage(string $from, string $message)
{
... lines 21 - 24
$message = $this->slack->createMessage()
->from($from)
->withIcon(':ghost:')
->setText($message);
$this->slack->sendMessage($message);
}
}

Oh, but let's rename the variable to $slackMessage - having two $message variables is no fun.

At this point, we just need the Slack client service. You know the drill: create a constructor! Type-hint the argument with Client from Nexy\Slack:

... lines 1 - 5
use Nexy\Slack\Client;
class SlackClient
{
... lines 10 - 13
public function __construct(Client $slack)
{
... line 16
}
... lines 18 - 30
}

Then press Alt+Enter and select "Initialize fields" to create that property and set it:

... lines 1 - 5
use Nexy\Slack\Client;
class SlackClient
{
... lines 10 - 11
private $slack;
public function __construct(Client $slack)
{
$this->slack = $slack;
}
... lines 18 - 30
}

Below, celebrate! Use $this->slack:

... lines 1 - 5
use Nexy\Slack\Client;
class SlackClient
{
... lines 10 - 11
private $slack;
public function __construct(Client $slack)
{
$this->slack = $slack;
}
public function sendMessage(string $from, string $message)
{
... lines 21 - 28
$this->slack->sendMessage($message);
}
}

In about one minute, we have a completely functional new service. Woo! Back in the controller, type-hint the new SlackClient:

... lines 1 - 5
use App\Service\SlackClient;
... lines 7 - 13
class ArticleController extends AbstractController
{
... lines 16 - 36
public function show($slug, MarkdownHelper $markdownHelper, SlackClient $slack)
{
... lines 39 - 75
}
... lines 77 - 88
}

And below... simplify: $slack->sendMessage() and pass it the from - Khan - and our message. Clean up the rest of the code:

... lines 1 - 5
use App\Service\SlackClient;
... lines 7 - 13
class ArticleController extends AbstractController
{
... lines 16 - 36
public function show($slug, MarkdownHelper $markdownHelper, SlackClient $slack)
{
if ($slug === 'khaaaaaan') {
$slack->sendMessage('Kahn', 'Ah, Kirk, my old friend...');
}
... lines 42 - 75
}
... lines 77 - 88
}

And I don't need to, but I'll remove the old use statement:

... lines 1 - 5
use Nexy\Slack\Client;
... lines 7 - 94

Yay refactoring! Does it work? Refresh! Of course - we rock!

Setter Injection

Now let's go a step further... In SlackClient, I want to log a message. But, we already know how to do this: add a second constructor argument, type-hint it with LoggerInterface and, we're done!

But... there's another way to autowire your dependencies: setter injection. Ok, it's just a fancy-sounding word for a simple concept. Setter injection is less common than passing things through the constructor, but sometimes it makes sense for optional dependencies - like a logger. What I mean is, if a logger was not passed to this class, we could still write our code so that it works. It's not required like the Slack client.

Anyways, here's how setter injection works: create a public function setLogger() with the normal LoggerInterface $logger argument:

... lines 1 - 5
use Psr\Log\LoggerInterface;
class SlackClient
{
... lines 10 - 21
public function setLogger(LoggerInterface $logger)
{
... line 24
}
... lines 26 - 38
}

Create the property for this: there's no shortcut to help us this time. Inside, say $this->logger = $logger:

... lines 1 - 5
use Psr\Log\LoggerInterface;
class SlackClient
{
... lines 10 - 14
private $logger;
... lines 16 - 21
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
... lines 26 - 38
}

In sendMessage(), let's use it! Start with if ($this->logger). And inside, $this->logger->info():

... lines 1 - 7
class SlackClient
{
... lines 10 - 14
private $logger;
... lines 16 - 26
public function sendMessage(string $from, string $message)
{
if ($this->logger) {
$this->logger->info('Beaming a message to Slack!');
}
... lines 32 - 37
}
}

Bah! No auto-complete: with setter injection, we need to help PhpStorm by adding some PHPDoc on the property: it will be LoggerInterface or - in theory - null:

... lines 1 - 5
use Psr\Log\LoggerInterface;
class SlackClient
{
... lines 10 - 11
/**
* @var LoggerInterface|null
*/
private $logger;
... lines 16 - 38
}

Now it auto-completes ->info(). Say, "Beaming a message to Slack!":

... lines 1 - 7
class SlackClient
{
... lines 10 - 11
/**
* @var LoggerInterface|null
*/
private $logger;
... lines 16 - 26
public function sendMessage(string $from, string $message)
{
if ($this->logger) {
$this->logger->info('Beaming a message to Slack!');
}
... lines 32 - 37
}
}

In practice, the if statement isn't needed: when we're done, Symfony will pass us the logger, always. But... I'm coding defensively because, from the perspective of this class, there's no guarantee that whoever is using it will call setLogger().

So... is Symfony smart enough to call this method automatically? Let's find out - refresh! Our class still works... but check out the profiler and go to "Logs". Bah! Nothing is logged yet!

The @required Directive

Yep, Symfony's autowiring is not that magic - and that's on purpose: it only autowires the __construct() method. But... it would be pretty cool if we could somehow say:

Hey container! How are you? Oh, I'm wonderful - thanks for asking. Anyways, after you instantiate SlackClient, could you also call setLogger()?

And... yeah! That's not only possible, it's easy. Above setLogger(), add /** to create PHPDoc. You can keep or delete the @param stuff - that's only documentation. But here's the magic: add @required:

... lines 1 - 7
class SlackClient
{
... lines 10 - 21
/**
* @required
*/
public function setLogger(LoggerInterface $logger)
{
... line 27
}
... lines 29 - 41
}

As soon as you put @required above a method, Symfony will call that method before giving us the object. And thanks to autowiring, it will pass the logger service to the argument.

Ok, move over and... try it! There's the Slack message. And... in the logs... yes! We are logging!

The LoggerTrait

But... I have one more trick to show you. I like logging, so I need this service pretty often. What if we used the @required feature to create... a LoggerTrait? That would let us log messages with just one line of code!

Check this out: in src/, create a new Helper directory. But again... this directory could be named anything. Inside, add a new PHP Class. Actually, change this to be a trait, and call it LoggerTrait:

... line 1
namespace App\Helper;
... lines 3 - 5
trait LoggerTrait
{
... lines 8 - 26
}

Ok, let's move the logger property to the trait... as well as the setLogger() method. I'll retype the "e" on LoggerInterface and hit Tab to get the use statement:

... lines 1 - 3
use Psr\Log\LoggerInterface;
trait LoggerTrait
{
/**
* @var LoggerInterface|null
*/
private $logger;
/**
* @required
*/
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
... lines 20 - 26
}

Next, add a new function called logInfo() that has two arguments: a $message and an array argument called $context - make it optional:

... lines 1 - 5
trait LoggerTrait
{
... lines 8 - 20
private function logInfo(string $message, array $context = [])
{
... lines 23 - 25
}
}

We haven't used it yet, but all the log methods - like info() - have an optional second argument where you can pass extra information. Inside the method: let's keep coding defensively: if ($this->logger), then $this->logger->info($message, $context):

... lines 1 - 5
trait LoggerTrait
{
... lines 8 - 20
private function logInfo(string $message, array $context = [])
{
if ($this->logger) {
$this->logger->info($message, $context);
}
}
}

Now, go back to SlackClient. Thanks to the trait, if we ever need to log something, all we need to do is add use LoggerTrait:

... lines 1 - 4
use App\Helper\LoggerTrait;
... lines 6 - 7
class SlackClient
{
use LoggerTrait;
... lines 11 - 30
}

Then, below, use $this->logInfo(). Pass the message... and, let's even pass some extra information - how about a message key with our text:

... lines 1 - 4
use App\Helper\LoggerTrait;
... lines 6 - 7
class SlackClient
{
use LoggerTrait;
... lines 11 - 18
public function sendMessage(string $from, string $message)
{
$this->logInfo('Beaming a message to Slack!', [
'message' => $message
]);
... lines 24 - 29
}
}

And that's it! Thanks to the trait, Symfony will automatically call the setLogger() method. Try it! Move over and... refresh!

We get the Slack message and... in the profiler, yes! And this time, the log message has a bit more information.

I hope you love the LoggerTrait idea.

Leave a comment!

24
Login or Register to join the conversation

But why make a setter required, if you could just add it to the constructor and achieve the same result?
Is there a way to only inject dependancies that you actually used when calling service functions?

2 Reply

Hey boykodev

> But why make a setter required, if you could just add it to the constructor and achieve the same result?

Yep, you're correct but it was done that way so you end up with a reusable trait for logging messages and for teaching purposes.

> Is there a way to only inject dependancies that you actually used when calling service functions?

That's totally doable! it's called "Lazy Services", you can find more detailed info at the docs: https://symfony.com/doc/cur...

Cheers!

3 Reply

I'm not that "excited" about as setter injection are supposed to be optional and now you are making it required.

Any chance to see @required annotation accept somehow a condition using expression language or a fonction from the class itself?

1 Reply

Hey mickaelandrieu!

Yea, this is the key thing that I don't like about setter injection too. When I use it, I still code defensively - i.e. I check that the property IS populated before using it. And so, I only use it for true optional dependencies. Honestly, the logger is one of the few :). You could totally create a system full of traits like this so that you could easily "fetch" them via setter injection... but even for me, I think that's not a great world :).

Any chance to see @required annotation accept somehow a condition using expression language or a fonction from the class itself?

Can you explain this a bit more? What kind of expressions would you use? The @required is really more of a flag to Symfony's container that says: "Hey! I actually want you to call this setter and autowire it". The annotation was invented so that the container wouldn't try to call & autowire EVERY method starting with "set" :)

Cheers!

1 Reply

Hello weaverryan!

I'm thinking about something like:

/**
* @required("kernel.debug == true") // need to think about what we could make available here...
*/
public function setLogger(LoggerInterface $logger = null) {}

Because the way I understand it, once you add the @required annotation the injection is not optional anymore!

So the only valid code (for me) would be:

/**
* @required()
*/
public function setLogger(LoggerInterface $logger) // will never be null in reality
{
// so no need to check for existence here!
}

Am I right, or there is some edge cases when - even with the @required annotation, the Logger could be null.?

11 Reply

Hey mickaël!

Sorry my slow reply on this - I got a little behind.

No, you’re totally right that once you have @required Symfony will call this 100% of the time. So, in practice, you don’t need to check for the existence of the property before you call a method on it.

It’s just that, from an object oriented standpoint, if you look st this class, nothing enforces that this settee was called. This could have practical implications in unit tests: if you forget to call setLogger, your code will fail. And actually, because the Logger really shouldn’t be needed to make the class work, you shouldn’t *need* to call setLogger(). So, by checking for existence, it just feels better from an OO perspective. And, I don’t need to unnecessarily call setLogger in a unit test.

Anyways, your idea about adding a little bit of logic in @required makes total sense! And that would totally be possible to implement. But, I don’t think it’s probably something that we need. Side note: if, for some reason, the Logger service were available in debug mode but not in prod mode, you could just make the argument optional (Logger $logger = null) as then it would not be set if the service didn’t exist (when the arg is required and the service doesn’t exist, you get an error).

Cheers!

1 Reply

That logger trait was awesome, I'm working on a Symfony 4.3 project so I'm a bit late but thanks for the valuable tips! Hoppefuly I can keep on getting jobs on Symfony with higher versions so I can learn with the latest tutorials versions of Symfony 6!

Reply

Hey Guilherme,

Thank you for your feedback! Well, maybe someday you will *upgrade* your project to Symfony 6? :) We do have a few tutorials about upgrading major version of Symfony, you need to upgrade to the latest 4.4 in your case, then drop all the deprecations you have and upgrade to the 5.x, then repeat the same steps but with a different Symfony version. It's totally doable, but it might depend on the bundles you're using, and how big your project is, but it's doable ;) I'd recommend you to take a look at this course: https://symfonycasts.com/sc... - it might help you to make a decision.

Cheers!

Reply

Yeah that's my plan for the future, I don't get to take archithecture decisions yet but I'm certainly very much interested in the topic and with SymfonyCasts I can acquire the necessary baggage to start participating the discussions and cave my road. W'ell get there in time hehe (don't mind my English haha)

Reply

Hey Guilherme,

Good plan! ;) And good luck with your project, I hope someday you will upgrade it

Cheers!

Reply
Robertas Š. Avatar
Robertas Š. Avatar Robertas Š. | posted 3 years ago

Last edit of ArticleController.php has incorrect code. Have a look. :)

Reply

Hey Robertas,

Thank you for this report! Unfortunately, it's difficult to spot the problem, could you point us, please? What exactly code is incorrect? DO you have a solution about how to fix it? I would be happy to fix the incorrect code for you :)

Cheers!

Reply
Robertas Š. Avatar
Robertas Š. Avatar Robertas Š. | Victor | posted 3 years ago

Sure, not so obvious, when the problem is not in the the code you don't have to change.

At line 37, there is old code:

public function show($slug, MarkdownHelper $markdownHelper, Client $slack)
{
if ($slug === 'khaaaaaan') {
$message = $slack->createMessage()
->from('Khan')
->withIcon(':ghost:')
->setText('Ah, Kirk, my old friend...');
$slack->sendMessage($message);
}

But it should be:

public function show($slug, MarkdownHelper $markdownHelper, SlackClient $slack)
{
if ($slug === 'khaaaaaan') {
$slack->sendMessage('Kahn', 'Ah, Kirk, my old friend...');
}

Just minor issue.

Reply

Hey Robertas,

Thanks for more details about it! Though I still can't find the code block on current page where we show that invalid code. I only see this one: https://symfonycasts.com/sc... . The code you mentioned is from SlackClient, i.e. we moved it from the controller to the service: https://symfonycasts.com/sc...

Or are you talking about a different chapter page? Could you link with me with the exact code block? You can click on filename in code block to generate a link to the code block.

Cheers!

1 Reply
Default user avatar
Default user avatar Raito Akehanareru | posted 3 years ago | edited

You can also do this to even simplify your trait:

`
/**

  • Trait LoggerAwareTrait
    */
    trait LoggerAwareTrait
    {
    use \Psr\Log\LoggerAwareTrait;

    /**

    • @required
      *
    • @param LoggerInterface $logger
      */
      public function setLogger(LoggerInterface $logger)
      {
      $this->logger = $logger;
      }

}
`

Reply

Hey Raito,

Thank for mentioning this "Psr\Log\LoggerAwareTrait". Moreover, you can just use this trait instead of creating your own one ;)

Cheers!

Reply
Bohan Y. Avatar
Bohan Y. Avatar Bohan Y. | posted 3 years ago | edited

Trick to refactor MarkdownHelper to use LoggerTrait but log into markdown channel:

Step 1: refactor MarkdownHelper as what you've done in SlackClient
Step 2: Add following under services key of services.yaml to use markdown channel for MarkdownHelper``

App\Service\MarkdownHelper:

    tags:
        - name: monolog.logger
          channel: markdown

Done! You can optionally remove the additional channels definition in `config/packages/monolog.yaml`

See Also: https://symfony.com/doc/current/reference/dic_tags.html#dic-tags-monolog
Reply

Hey Bohan Y.!

Nice tip! You're on fire :)

Cheers!

1 Reply
Helmi Avatar

Thanks guys, seems that Symfony is inspired a lot on Java Spring :D

Reply
Mike P. Avatar
Mike P. Avatar Mike P. | posted 5 years ago

It was awesome that you repeated how to code an service (Service/SlackClient.php), it felt so great to do it on my own, without looking something up in the video/script and it worked instantly. Really motivating way of teaching Ryan!

One question, is it best practice to use:

return $this->slack->sendMessage($slackMessage);

instead of

$this->slack->sendMessage($slackMessage);

as last line inside the public sendMessage() function? (Video: 6:13 minutes left)

Reply
David P. Avatar
David P. Avatar David P. | posted 5 years ago

I'm running into a problem using this concept where I have a console command that uses trait X and service Y.

Service Y also uses trait X.
Trait X has a setXxx() method just like here.
The problem is that setXxx() is only called for the instatiation of the command, not for the service.

This results in all of the trait properties in the instantiated Y service being null.

Reply

Hey David P.!

Hmm. That is indeed strange - it should *not* work that way. Very simply, for each service (and both your console command AND service Y are services), the container looks to see if any of the public methods have the @required annotation above it. If it does, then it adds that as a "call" to the service. The *only* reason that it would not be doing this, is if service Y was not set to be autowired. But if you're using the default Symfony 4 config, all services that you register are autowired.

So, I'm at a loss, but I *am* interested: I just can't think of why it would *not* work. Would you mind posting some real (or at least realistic - you can change names) code? I might be able to spot the problem then :).

Cheers!

Reply
David P. Avatar

Oops. Failed to mention that the service, command, and trait are all part of a custom bundle (more about how I'm making that happen later).
I can post some code or turn this into a SO post. What would be best?
Thanks

Reply

Hey David!

Sorry for my slow reply! Ah, this help! The key thing is that the service needs to be autowired - the whole automatic setter injection thing happens thanks to autowiring. So, it makes me wonder if you might not be using autowiring in your custom bundle (actually, we usually don't use autowiring for shareable bundles, but if it's just your own code, it's totally fine). To find out, try running:


php bin/console debug:container id_of_your_service --show-private

This has an "Autowired" line to tell you if your service is autowired :).

Cheers!

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.4
        "symfony/asset": "^4.0", // v4.0.4
        "symfony/console": "^4.0", // v4.0.14
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/framework-bundle": "^4.0", // v4.0.14
        "symfony/lts": "^4@dev", // dev-master
        "symfony/twig-bundle": "^4.0", // v4.0.4
        "symfony/web-server-bundle": "^4.0", // v4.0.4
        "symfony/yaml": "^4.0" // v4.0.14
    },
    "require-dev": {
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.4
        "symfony/dotenv": "^4.0", // v4.0.14
        "symfony/maker-bundle": "^1.0", // v1.0.2
        "symfony/monolog-bundle": "^3.0", // v3.1.2
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.0.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.0.4
    }
}
userVoice