Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

8
Login or Register to join the conversation
Kiuega Avatar
Kiuega Avatar Kiuega | posted 2 years ago | edited

Hello ! In case we have a .pdf file already ready in our assets, and which has been copied to the public/build/files/manuel_utilisateur.pdf (and is therefore versioned) with Webpack Encore, how can I attach this file to send it?

I know how to attach a file that is directly in the assets. Like this


           $email = (new TemplatedEmail())
            ->to(new Address($this->getUser()->getEmail(), $this->getUser()->getNom()))
            ->subject('Welcome to the Space Bar !')
            ->htmlTemplate('stripe/email/welcome.html.twig')
            ->attachFromPath(sprintf('%s/assets/app/files/manuels/manuel_utilisateur_salarie.pdf', $projectDir)); //where $projectDir is the project directory

But if the file is versioned, in the public folder, what can I do?
Besides, is it a good solution to attach a file from the public folder, or is it better to leave it in the assets?
This is a user manual that will be sent to each new user

Reply

Hey Kiuega

You'll need to get the Symfony\Component\Asset\Packages service and call getUrl() on it, passing the path to your build, final asset (ignoring that's versioned), e.g. $packages->getUrl('build/files/manuel.pdf')

Besides, is it a good solution to attach a file from the public folder, or is it better to leave it in the assets?
Well, that depends on your needs. If you need that asset to be accessible through the web, then, yes, it should live in the public directory. If not, then, making it private makes more sense

Cheers!

1 Reply
Thibault V. Avatar
Thibault V. Avatar Thibault V. | posted 3 years ago | edited

Hello guys,

How would you go about implementing "email notification preferences" ? I'de like users to be able to opt-in/out of different email categories (each user's preference is stored in a line in a DB table and each column is a different email category :

| user_id (int) | category_email_one (bool) | category_email_two (bool) | ... |

I would like to centralize this in the service or in a messenger middleware but I cannot get my head around which is best. So there are actually two questions here :
1 - With your system, could/should I categorize emails using multiple services ? (or should i simply define a <i>category</i> in the <i>context </i>of the TemplatedEmail and create a method call checkPrefEmailAndSend in which i check the category and user preferences and then call $this->mailer->send() ?)
2 - How can I check for every user's mail preferences before sending the email to messenger and if the category is set to "false" for a user, then no email is sent.

I'm in a blur on what to do and how to do it efficiently... :/

Thanks in advance for your help !
Thibault

Reply

Hey Thibault V.

That's a good question and this is how I would do it

I would create 2 entities. User and EmailCategory
A User would have a ManyToOne relationship to EmailCategory, so a User can subscribe to many categories

Then, depending on the situation, I would create an interface or a command where I can specify which categories I want to send my email. Then, I would create a query that fetches all the users that are related to any of the categories I specified, and from this point, you already know the rest, you only have to create the email body and send it

I hope it helps. Cheers!

Reply
Thibault V. Avatar
Thibault V. Avatar Thibault V. | Thibault V. | posted 3 years ago | edited

FYI: I currently decided to do the following
1 - Define a parent Mailer service with send() method which includes $category private param (with default value) and a verification for the category with user settings inside it.
2 - Define a child Service that extends Mailer for every group of emails. Example :
<br />Account updates emails => AccountUpdatesMailer extends Mailer<br />Blog emails => BlogMailer extends Mailer<br />Security emails => SecurityMailer extends Mailer<br />...<br />
3 - Each child service defines $this->category to the corresponding group (for my example: account_updates, blog ...)

What's your opinion on this organization with services ?

thanks in advance for your opinion

Reply

Hey Thibault V.!

Sorry for never replying on this! For some reason, your comment got marked as SPAM and we missed it! In general, I like your solution. There is no need to hook into Messenger or Mailer in some fancy way. Instead, your solution simply creates 3 new services: each which contains the special logic you need, and then calls the normal Mailer. We do this kind of thing all the time.

The one modification I would make is this: instead of extending Mailer, I would make your classes extend nothing and instead, accept Mailer as an argument to your constructor. The only difference would be that instead of calling parent::send(), you would eventually call $this->mailer->send().

The reason for this is just that having a separate class (that doesn't extend Mailer) keeps things cleaner and simpler. You don't need to worry about the parent Mailer constructor or even using service decoration: your BlogMailer would be a normal service that automatically autowires the mailer service thanks to the MailerInterface type-hint in your constructor. This is also an example of "composition over inheritance" if you want to get really nerdy about it ;). What you would be doing is called object composition.

Cheers!

Reply
Default user avatar
Default user avatar geoB | posted 3 years ago | edited

You might want to add to the Mailer service following the line $this->mailer->send($email); the line return $email;. This can make the test of the service in the following chapter successful.

Reply

Hey geoB,

Yep, we will do it later in this course. The final code from this course do have "return $email" line, I just double-checked :)

Cheers!

Reply
Cat in space

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

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
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.9", // v1.17.6
        "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
    }
}
userVoice