Organizing Emails Logic into a Service

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

We're sending two emails: one from a command and the other from src/Controller/SecurityController.php. The logic for creating and sending these emails is fairly simple. But even still, I prefer to put all my email logic into one or more services. The real reason for this is that I like to have all my emails in one spot. That helps me remember which emails we're sending and what they contain. After all, emails are a strange part of your site: you send a lot of them... but rarely or never see them! Like, how often do you do a "password reset" on your own site to check out what that content looks like? Keeping things in one spot... at least helps with this.

Creating a Mailer Service

So what we're going to do is, in the Service/ directory, create a new class called FileThatWillSendAllTheEmails... ah, or, maybe just Mailer... it's shorter.

... lines 1 - 2
namespace App\Service;
... lines 4 - 6
class Mailer
{
... lines 9 - 14
}

The idea is that this class will have one method for each email that our app sends. Now, if your app sends a lot of emails, instead of having just one Mailer class, you could instead create a Mailer/ directory with a bunch of service classes inside - like one per email. In both cases, you're either organizing your email logic into a single service or multiple services in one directory.

Start by adding an __construct() method. The one service that we know we're going to need is MailerInterface $mailer... because we're going to send emails. I'll hit Alt + Enter and go to "Initialize fields" to create that property and set it.

... lines 1 - 2
namespace App\Service;
use Symfony\Component\Mailer\MailerInterface;
class Mailer
{
private $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
}

Ok, let's start with the registration email inside of SecurityController. Ok... to send this email, the only info we need is the User object. Create a new public function sendWelcomeMessage() with a User $user argument.

... lines 1 - 4
use App\Entity\User;
... lines 6 - 12
class Mailer
{
... lines 15 - 27
public function sendWelcomeMessage(User $user)
{
... lines 30 - 40
}
... lines 42 - 63
}

Then, grab the logic from the controller... everything from $email = to the sending part... and paste that here. It looks like this class is missing a few use statements... so I'll re-type the "L" on TemplatedEmail and hit tab, then re-type the S on NamedAddress and hit tab once more... to add those use statements to the top of this file. Then change $mailer to $this->mailer.

Tip

In Symfony 4.4 and higher, use new Address() - it works the same way as the old NamedAddress.

... lines 1 - 6
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
... line 8
use Symfony\Component\Mime\NamedAddress;
... lines 10 - 12
class Mailer
{
... lines 15 - 27
public function sendWelcomeMessage(User $user)
{
$email = (new TemplatedEmail())
->from(new NamedAddress('alienmailcarrier@example.com', 'The Space Bar'))
->to(new NamedAddress($user->getEmail(), $user->getFirstName()))
->subject('Welcome to the Space Bar!')
->htmlTemplate('email/welcome.html.twig')
->context([
// You can pass whatever data you want
//'user' => $user,
]);
$this->mailer->send($email);
}
... lines 42 - 63
}

I love it! This will simplify life dramatically in SecurityController. Delete all the logic and then above... replace the MailerInterface argument with our shiny new Mailer class.

... lines 1 - 8
use App\Service\Mailer;
... lines 10 - 20
class SecurityController extends AbstractController
{
... lines 23 - 50
public function register(Mailer $mailer, Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator)
{
... lines 53 - 89
}
}

Below, it's as simple as $mailer->sendWelcomeMessage($user).

... lines 1 - 8
use App\Service\Mailer;
... lines 10 - 20
class SecurityController extends AbstractController
{
... lines 23 - 50
public function register(Mailer $mailer, Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator)
{
... lines 53 - 55
if ($form->isSubmitted() && $form->isValid()) {
... lines 57 - 74
$em->flush();
$mailer->sendWelcomeMessage($user);
... lines 78 - 84
}
... lines 86 - 89
}
}

That looks really nice! Our controller is now more readable.

Let's repeat the same thing for our weekly report email. In this case, the two things we need are the $author that we're going to send to - which is a User object - and the array of articles. Ok, over in our new Mailer class, add a public function sendAuthorWeeklyReportMessage() with a User object argument called $author and an array of Article objects.

... lines 1 - 12
class Mailer
{
... lines 15 - 42
public function sendAuthorWeeklyReportMessage(User $author, array $articles)
{
... lines 45 - 62
}
}

Time to steal some code! Back in the command, copy everything related to sending the email... which in this case includes the entrypoint reset, Twig render, PDF code and the actual email logic. Paste that into Mailer.

... lines 1 - 12
class Mailer
{
... lines 15 - 42
public function sendAuthorWeeklyReportMessage(User $author, array $articles)
{
$this->entrypointLookup->reset();
$html = $this->twig->render('email/author-weekly-report-pdf.html.twig', [
'articles' => $articles,
]);
$pdf = $this->pdf->getOutputFromHtml($html);
$email = (new TemplatedEmail())
->from(new NamedAddress('alienmailcarrier@example.com', 'The Space Bar'))
->to(new NamedAddress($author->getEmail(), $author->getFirstName()))
->subject('Your weekly report on the Space Bar!')
->htmlTemplate('email/author-weekly-report.html.twig')
->context([
'author' => $author,
'articles' => $articles,
])
->attach($pdf, sprintf('weekly-report-%s.pdf', date('Y-m-d')));
$this->mailer->send($email);
}
}

This time, we need to inject a few more services - for entrypointLookup, twig and pdf. Let's add those on top: Environment $twig, Pdf $pdf and EntrypointLookupInterface $entrypointLookup. I'll do my Alt + Enter shortcut and go to "Initialize fields" to create those three properties and set them.

... lines 1 - 5
use Knp\Snappy\Pdf;
... lines 7 - 9
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment;
... line 12
class Mailer
{
... line 15
private $twig;
private $pdf;
private $entrypointLookup;
public function __construct(MailerInterface $mailer, Environment $twig, Pdf $pdf, EntrypointLookupInterface $entrypointLookup)
{
$this->mailer = $mailer;
$this->twig = $twig;
$this->pdf = $pdf;
$this->entrypointLookup = $entrypointLookup;
}
... lines 27 - 63
}

Back in the method... oh... that's it! We're already using the properties... and everything looks happy! Oh, and it's minor, but I'm going to move the "entrypoint reset" code below the render. This is subtle... but it makes sure that the Encore stuff is reset after we render our template. If some other part of our app calls this methods and then renders its own template, Encore will now be ready to do work correctly for them.

... lines 1 - 12
class Mailer
{
... lines 15 - 42
public function sendAuthorWeeklyReportMessage(User $author, array $articles)
{
$html = $this->twig->render('email/author-weekly-report-pdf.html.twig', [
'articles' => $articles,
]);
$this->entrypointLookup->reset();
... lines 49 - 62
}
}

Anyways, let's use this in the command. Delete all of this logic and... in the constructor, change the $mailer argument to Mailer $mailer. Now we get to delete stuff! Take off the $twig, $pdf and $entrypointLookup arguments, clear them from the constructor and remove their properties. If you really want to make things squeaky-clean, we now have a bunch of "unused" use statements that are totally useless.

... lines 1 - 6
use App\Service\Mailer;
... lines 8 - 14
class AuthorWeeklyReportSendCommand extends Command
{
... lines 17 - 20
private $mailer;
public function __construct(UserRepository $userRepository, ArticleRepository $articleRepository, Mailer $mailer)
{
parent::__construct(null);
$this->userRepository = $userRepository;
$this->articleRepository = $articleRepository;
$this->mailer = $mailer;
}
... lines 31 - 61
}

Back down, call the method with $this->mailer->sendWeeklyReportMessage() passing $author and $articles.

... lines 1 - 2
namespace App\Tests;
use PHPUnit\Framework\TestCase;
class MailerTest extends TestCase
{
public function testSomething()
{
$this->assertTrue(true);
}
}

Phew! This really simplifies the controller & command... and now I know exactly where to look for all email-related code. Let's... just make sure I didn't break anything. Run:

php bin/console app:author-weekly-report:send

No errors... and in Mailtrap... yep! 2 emails... with an attachment!

Next, sending emails is scary! So let's add some tests. We'll start by adding a unit test and later, an integration test, functional test... and a final exam that will be worth 50% of your grade for the semester. Ok... no final exam - but we will do that other stuff.

Leave a comment!

This tutorial is built on Symfony 4.3, but will work well with Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.6.0
        "knplabs/knp-time-bundle": "^1.8", // v1.9.1
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.1,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.1.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.4.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.3.4
        "symfony/console": "^4.0", // v4.3.4
        "symfony/flex": "^1.0", // v1.6.2
        "symfony/form": "^4.0", // v4.3.4
        "symfony/framework-bundle": "^4.0", // v4.3.4
        "symfony/mailer": "4.3.*", // v4.3.4
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.3.4
        "symfony/sendgrid-mailer": "4.3.*", // v4.3.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.3.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "^4.0", // v4.3.4
        "symfony/web-server-bundle": "^4.0", // v4.3.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.6.2
        "symfony/yaml": "^4.0", // v4.3.4
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.5
        "symfony/debug-bundle": "^3.3|^4.0", // v4.3.4
        "symfony/dotenv": "^4.0", // v4.3.4
        "symfony/maker-bundle": "^1.0", // v1.13.0
        "symfony/monolog-bundle": "^3.0", // v3.4.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.3.4
    }
}