If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe'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.
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.
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!
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
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!
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
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!
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.
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!
// 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
}
}
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
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