Buy

Bonus! LoggerTrait & Setter Injection

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:

32 lines src/Service/SlackClient.php
... 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:

32 lines src/Service/SlackClient.php
... 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. Oh, but let's rename the variable to $slackMessage - having two $message variables is no fun.

32 lines src/Service/SlackClient.php
... 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);
}
}

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:

32 lines src/Service/SlackClient.php
... 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:

32 lines src/Service/SlackClient.php
... 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:

32 lines src/Service/SlackClient.php
... 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:

90 lines src/Controller/ArticleController.php
... 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:

90 lines src/Controller/ArticleController.php
... 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:

94 lines src/Controller/ArticleController.php
... 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:

40 lines src/Service/SlackClient.php
... 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:

40 lines src/Service/SlackClient.php
... 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():

40 lines src/Service/SlackClient.php
... 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:

40 lines src/Service/SlackClient.php
... 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!":

40 lines src/Service/SlackClient.php
... 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:

43 lines src/Service/SlackClient.php
... 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:

28 lines src/Helper/LoggerTrait.php
... 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:

28 lines src/Helper/LoggerTrait.php
... 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:

28 lines src/Helper/LoggerTrait.php
... 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):

28 lines src/Helper/LoggerTrait.php
... 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:

32 lines src/Service/SlackClient.php
... 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:

32 lines src/Service/SlackClient.php
... 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!

  • 2018-02-26 weaverryan

    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!

  • 2018-02-21 David Patterson

    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

  • 2018-02-21 weaverryan

    Hey David Patterson!

    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!

  • 2018-02-21 David Patterson

    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.