Email Factory Service
Our app sends two emails: in SendBookingRemindersCommand
, and TripController::show()
. There is... a lot of duplication here. It hurts my eyes! But no worries! We can reorganize this into an email factory service. And because we have tests covering both emails, we can refactor and be confident that we haven't broken anything. I can't say it enough: I love tests!
BookingEmailFactory
Start by creating a new class: BookingEmailFactory
in the App\Email
namespace. Add a constructor, copy the $termsPath
argument from TripController::show()
, paste it here, and make it a private property:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
public function __construct( | |
#[Autowire('%kernel.project_dir%/assets/terms-of-service.pdf')] | |
private string $termsPath, | |
) { | |
} | |
// ... lines 19 - 54 | |
} |
Now, stub out two factory methods: public function createBookingConfirmation()
, which will accept Booking $booking
, and return TemplatedEmail
. Then, public function createBookingReminder(Booking $booking)
also returning a TemplatedEmail
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 19 | |
public function createBookingConfirmation(Booking $booking): TemplatedEmail | |
{ | |
// ... lines 22 - 25 | |
} | |
// ... line 27 | |
public function createBookingReminder(Booking $booking): TemplatedEmail | |
{ | |
// ... lines 30 - 33 | |
} | |
// ... lines 35 - 54 | |
} |
Create a method to house that darn duplication: private function createEmail()
, with arguments Booking $booking
and string $tag
that returns a TemplatedEmail
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 35 | |
private function createEmail(Booking $booking, string $tag): TemplatedEmail | |
{ | |
// ... lines 38 - 53 | |
} | |
} |
Jump to TripController::show()
, copy all the email creation code, and paste it here. Up top, we need two variables: $customer = $booking->getCustomer()
and $trip = $booking->getTrip()
. Remove attachFromPath()
, subject()
, and htmlTemplate()
. In this TagHeader
, use the passed $tag
variable. We can leave the metadata the same. Finally, return the $email
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 35 | |
private function createEmail(Booking $booking, string $tag): TemplatedEmail | |
{ | |
$customer = $booking->getCustomer(); | |
$trip = $booking->getTrip(); | |
$email = (new TemplatedEmail()) | |
->to(new Address($customer->getEmail())) | |
->context([ | |
'customer' => $customer, | |
'trip' => $trip, | |
'booking' => $booking, | |
]) | |
; | |
$email->getHeaders()->add(new TagHeader($tag)); | |
$email->getHeaders()->add(new MetadataHeader('booking_uid', $booking->getUid())); | |
$email->getHeaders()->add(new MetadataHeader('customer_uid', $customer->getUid())); | |
return $email; | |
} | |
} |
With our shared logic in place, use it in createBookingConfirmation()
. Write return $this->createEmail()
, passing the $booking
variable and booking
for the tag. Now, ->subject()
, copy this from TripController::show()
, changing the $trip
variable to $booking->getTrip()
. Finally, ->htmlTemplate('email/booking_confirmation.html.twig')
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 19 | |
public function createBookingConfirmation(Booking $booking): TemplatedEmail | |
{ | |
return $this->createEmail($booking, 'booking') | |
->subject('Booking Confirmation for '.$booking->getTrip()->getName()) | |
->htmlTemplate('email/booking_confirmation.html.twig') | |
; | |
} | |
// ... lines 27 - 54 | |
} |
For createBookingReminder()
, copy the insides of createBookingConfirmation()
and paste here. Change the tag to booking_reminder
, the subject to Booking Reminder
, and the template to email/booking_reminder.html.twig
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 19 | |
public function createBookingConfirmation(Booking $booking): TemplatedEmail | |
{ | |
return $this->createEmail($booking, 'booking') | |
->subject('Booking Confirmation for '.$booking->getTrip()->getName()) | |
->htmlTemplate('email/booking_confirmation.html.twig') | |
; | |
} | |
// ... lines 27 - 54 | |
} |
The Refactor
Now the fun part! Using our factory and removing a whole wack of code!
In TripController::show()
, instead of injecting $termsPath
, inject BookingEmailFactory $emailFactory
:
// ... lines 1 - 18 | |
final class TripController extends AbstractController | |
{ | |
// ... lines 21 - 29 | |
public function show( | |
// ... lines 31 - 35 | |
BookingEmailFactory $emailFactory, | |
): Response { | |
// ... lines 38 - 58 | |
} | |
} |
Delete all the email creation code and inside $mailer->send()
, write $emailFactory->createBookingConfirmation($booking)
:
// ... lines 1 - 18 | |
final class TripController extends AbstractController | |
{ | |
// ... lines 21 - 29 | |
public function show( | |
// ... lines 31 - 36 | |
): Response { | |
// ... lines 38 - 39 | |
if ($form->isSubmitted() && $form->isValid()) { | |
// ... lines 41 - 49 | |
$mailer->send($emailFactory->createBookingConfirmation($booking)); | |
// ... lines 51 - 52 | |
} | |
// ... lines 54 - 58 | |
} | |
} |
Over in SendBookingRemindersCommand
, again, remove all the email creation code. Up in the constructor, autowire private BookingEmailFactory $emailFactory
:
// ... lines 1 - 18 | |
class SendBookingRemindersCommand extends Command | |
{ | |
public function __construct( | |
// ... lines 22 - 24 | |
private BookingEmailFactory $emailFactory, | |
) { | |
// ... line 27 | |
} | |
// ... lines 29 - 48 | |
} |
Down here, inside $this->mailer->send()
, write $this->emailFactory->createBookingReminder($booking)
:
// ... lines 1 - 18 | |
class SendBookingRemindersCommand extends Command | |
{ | |
// ... lines 21 - 29 | |
protected function execute(InputInterface $input, OutputInterface $output): int | |
{ | |
// ... lines 32 - 37 | |
foreach ($io->progressIterate($bookings) as $booking) { | |
$this->mailer->send($this->emailFactory->createBookingReminder($booking)); | |
// ... line 40 | |
} | |
// ... lines 42 - 47 | |
} | |
} |
Test It
Oh yeah, that felt good! But did we break anything? We Canadians are known for being a bit wild. Check by running the tests:
bin/phpunit
Uh oh, a failure! Good thing we have these tests, eh?
The failure comes from BookingTest
:
Message does not include file with filename [Terms of Service.pdf].
Fix It
Easy fix! During our refactor, I forgot to attach the thrilling terms of service PDF to the booking confirmation email. And our customers depend on that. Find BookingEmailFactory::createBookingConfirmation()
, and add ->attachFromPath($this->termsPath,
'Terms of Service.pdf')
:
// ... lines 1 - 11 | |
class BookingEmailFactory | |
{ | |
// ... lines 14 - 19 | |
public function createBookingConfirmation(Booking $booking): TemplatedEmail | |
{ | |
return $this->createEmail($booking, 'booking') | |
// ... lines 23 - 24 | |
->attachFromPath($this->termsPath, 'Terms of Service.pdf') | |
; | |
} | |
// ... lines 28 - 55 | |
} |
Re-run the tests:
bin/phpunit
Passing! Successful refactor? Check!
Next, let's switch gears a bit and dive into two new Symfony components to consume the email webhook events from Mailtrap.