Laravel has everything you need to send emails “out of the box”. In the official Laravel documentation, you can find very detailed instructions on how to prepare and send any kind of emails.

If you are familiar with Laravel Mailables, you know that for every specific email you have to have a responsible class. Also you need a Blade template file for the email content.

With the growth of the project, the number of email templates is also growing. And developers need to spend more and more time on managing Mailables and their templates.

In this article we are going to cover a little bit of a different approach on how to manage email templates. We’ll show you how we can manage any amount of email templates with one Mailable class and zero Blade templates with the help of Templid service.

Generating Mailable class

First let’s run default Artisan command to create Mailable class:

php artisan make:mail TransactionalEmail

Because we are going to get email content from API, we need to set a custom string instead of Blade view. Also we need to add flexibility to the envelope method.

So let’s modify our TransactionalEmail to be like the code below:

<?php

namespace App\Mail;

use App\Dto\EnvelopeDto;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Support\HtmlString;
use Illuminate\Queue\SerializesModels;
use Illuminate\Mail\Mailables\Envelope;

class TransactionalEmail extends Mailable
{
    use Queueable, SerializesModels;

    /** 
     * @var EnvelopeDto|null
     */
    protected ?EnvelopeDto $envelopeDto = null;

    /**
     * @return Envelope
     */
    public function envelope(): Envelope
    {
        if ($this->envelopeDto === null) {
            return new Envelope();
        }

        return new Envelope(
            from:     $this->envelopeDto->getFrom(),
            to:       $this->envelopeDto->getTo(),
            cc:       $this->envelopeDto->getCc(),
            bcc:      $this->envelopeDto->getBcc(),
            replyTo:  $this->envelopeDto->getReplyTo(),
            tags:     $this->envelopeDto->getTags(),
            metadata: $this->envelopeDto->getMetadata(),
            using:    $this->envelopeDto->getUsing()
        );
    }

    /**
     * @param string $html
     * @param string $plain
     * 
     * @return self
     */
    public function setContent(
        string $html = '',
        string $plain = ''
    ): self {
        if ($plain === '') {
            $this->html($html);

            return $this;
        }

        $this->view = [
            'html' => new HtmlString($html),
            'raw'  => $plain,
        ];

        return $this;
    }

    /**
     * @param EnvelopeDto|null $envelopeDto
     * 
     * @return self
     */
    public function setEnvelope(
        ?EnvelopeDto $envelopeDto = null
    ): self {
        $this->envelopeDto = $envelopeDto;

        return $this;
    }
}

We’ve added the method “setEnvelope”, this allows us to customise an envelope “on the fly” without creating new Mailable classes.

And the content will be set with the help of the “setContent” method.

Data transfer objects

Now let’s create two data transfer objects, one for the envelope and one for the template content, which we are going to use later on.

Create a directory called “Dto” inside our App folder.

Inside this folder create a class called “EnvelopeDto”. Purpose of this class will be to transfer all the data needed for the Envelope in the Mailable class.

<?php

declare(strict_types=1);

namespace App\Dto;

use Illuminate\Mail\Mailables\Address;

class EnvelopeDto
{
    /**
     * @param Address|null             $from
     * @param array<string, Address>   $to
     * @param array<string, Address>   $cc
     * @param array<string, Address>   $bcc
     * @param array<string, Address>   $replyTo
     * @param string[]                 $tags
     * @param array<mixed>             $metadata
     * @param \Closure|array<\Closure> $using
     */
    public function __construct(
        protected Address|null   $from     = null,
        protected array          $to       = [],
        protected array          $cc       = [],
        protected array          $bcc      = [],
        protected array          $replyTo  = [],
        protected array          $tags     = [],
        protected array          $metadata = [],
        protected \Closure|array $using    = [],
    ) {}

    /**
     * @return Address|null
     */
    public function getFrom(): ?Address
    {
        return $this->from;
    }

    /**
     * @return array<string, Address>
     */
    public function getTo(): array
    {
        return $this->to;
    }

    /**
     * @return array<string, Address>
     */
    public function getCc(): array
    {
        return $this->cc;
    }

    /**
     * @return array<string, Address>
     */
    public function getBcc(): array
    {
        return $this->bcc;
    }

    /**
     * @return array<string, Address>
     */
    public function getReplyTo(): array
    {
        return $this->replyTo;
    }

    /**
     * @return string[]
     */
    public function getTags(): array
    {
        return $this->tags;
    }

    /**
     * @return array<mixed>
     */
    public function getMetadata(): array
    {
        return $this->metadata;
    }

    /**
     * @return \Closure|array<\Closure>
     */
    public function getUsing(): \Closure|array
    {
        return $this->using;
    }
}

Next create simple class “TemplidTemplateDto”, it will be responsible for transferring email content, such as subject, HTML and plain text.

<?php

declare(strict_types=1);

namespace App\Dto;

class TemplidTemplateDto
{
    /**
     * @param string $subject
     * @param string $html
     * @param string $text
     */
    public function __construct(
        protected string $subject = '',
        protected string $html    = '',
        protected string $text    = '',
    ) {}

    /**
     * @return string
     */
    public function getSubject(): string
    {
        return $this->subject;
    }

    /**
     * @return string
     */
    public function getHtml(): string
    {
        return $this->html;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }
}

Template fetcher

Because all our templates are stored in the cloud, we need to render them and fetch using the REST API.

For this we’ll create “TemplidTemplateFetcher” class with method “fetch” and it will return our content as a “TemplidTemplateDto”:

<?php

declare(strict_types=1);

namespace App\Fetcher;

use Exception;
use App\Dto\TemplidTemplateDto;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\Response;
use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\PendingRequest;
use App\Fetcher\Validator\TemplidFetcherResponseValidator;

class TemplidTemplateFetcher
{
    protected const API_URL = 'https://api.templid.com/v1/templates/%s/render';

    /**
     * @param PendingRequest                  $request
     * @param TemplidFetcherResponseValidator $validator
     */
    public function __construct(
        protected PendingRequest                  $request,
        protected TemplidFetcherResponseValidator $validator
    ){}

    /**
     * @param int             $templatedId
     * @param Collection|null $data
     *
     * @return TemplidTemplateDto
     */
    public function fetch(
        int $templatedId,
        ?Collection $data = null
    ): TemplidTemplateDto {
        /** @var array{subject: string, html: string, text: string} $responseData */
        $responseData = $this->sendRequest(
            $templatedId,
            $data?->toArray() ?? []
        )->json();

        return new TemplidTemplateDto(
            subject: $responseData['subject'],
            html:    $responseData['html'],
            text:    $responseData['text']
        );
    }

    /**
     * @param int          $templatedId
     * @param array<mixed> $data
     * 
     * @return Response
     */
    protected function sendRequest(
        int $templatedId,
        array $data = []
    ): Response {
        $attempts = 0;
        $retry    = 3;

        $url = sprintf(self::API_URL, $templatedId);

        do {
            try {
                $response = $this->request
                    ->withToken(env('TEMPLID_TOKEN'))
                    ->post($url, $data);

                $this->validator->validate($response);

                return $response;
            } catch (RateLimitException $e) {
                Log::notice($e->getMessage());

                sleep(1);

                $attempts++;
            }
        } while ($attempts < $retry);

        throw new Exception('Unable to fetch template');
    }
}

Also the above class has retry logic inside the “sendRequest” method, in case we will hit the rate limit. Here we are catching “RateLimitException”, so let’s create this exception class:

<?php

declare(strict_types=1);

namespace App\Exceptions;

use Exception;

class RateLimitException extends Exception
{}

And let’s create a validator to validate responses:

<?php

declare(strict_types=1);

namespace App\Fetcher\Validator;

use Exception;
use Illuminate\Http\Client\Response;
use App\Exceptions\RateLimitException;

class TemplidFetcherResponseValidator
{
    /**
     * @param Response $response
     *
     * @return void
     */
    public function validate(Response $response): void
    {
        if ($response->status() === 401) {
            throw new Exception('Unauthorized');
        }

        if ($response->status() === 404) {
            throw new Exception('Template not found');
        }

        if ($response->status() === 429) {
            throw new RateLimitException('Too many requests');
        }

        if ($response->status() !== 200) {
            throw new Exception('API error');
        }
    }
}

Mailable builder

Now when we have our email template fetcher, let’s build our Mailable class so we can send it:

<?php

declare(strict_types=1);

namespace App\Builder;

use App\Dto\EnvelopeDto;
use Illuminate\Mail\Mailable;
use App\Mail\TransactionalEmail;
use Illuminate\Support\Collection;
use App\Fetcher\TemplidTemplateFetcher;

class TransactionalMailableBuilder
{
    /**
     * @param TemplidTemplateFetcher $fetcher
     * @param TransactionalEmail     $transactionalEmail
     */
    public function __construct(
        protected TemplidTemplateFetcher $fetcher,
        protected TransactionalEmail     $transactionalEmail,
    ) {}

    /**
     * @param int              $templateId
     * @param Collection|null  $data
     * @param EnvelopeDto|null $envelopeDto
     * 
     * @return Mailable
     */
    public function build(
        int          $templateId,
        ?Collection  $data = null,
        ?EnvelopeDto $envelopeDto = null
    ): Mailable {
        $template = $this->fetcher->fetch($templateId, $data);

        return $this->transactionalEmail
            ->subject($template->getSubject())
            ->setContent($template->getHtml(), $template->getText())
            ->setEnvelope($envelopeDto);
    }
}

Here we have only one method, where we fetch an email template and set all the necessary parameters to our “TransactionalEmail” class.

Sending emails as a queued job

Basically, we already can send our emails using Laravel Mail.

As you know it is a good practice to send emails in the background as a separate job so users don’t need to wait till email will be sent.

Laravel Mail class has a specific method called “queue”. But even using this method we still need to wait for the API call to render the email template.

So tet’s create a standard Laravel job class to queue all the processes above:

php artisan make:job TransactionalEmail

And our code for this job will look following:

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Dto\EnvelopeDto;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Mail;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Builder\TransactionalMailableBuilder;

class TransactionalEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * @var int
     */
    public int $tries = 3;

    /**
     * @param Address          $to
     * @param int              $templateId
     * @param Collection|null  $data
     * @param EnvelopeDto|null $envelopeDto
     */
    public function __construct(
        protected Address $to,
        protected int $templateId,
        protected ?Collection $data = null,
        protected ?EnvelopeDto $envelopeDto = null
    ) {}

    /**
     * @param TransactionalMailableBuilder $mailableBuilder
     *
     * @return void
     */
    public function handle(
        TransactionalMailableBuilder $mailableBuilder
    ): void {
        $email = $mailableBuilder->build(
            $this->templateId,
            $this->data,
            $this->envelopeDto
        );

        Mail::to($this->to)->send($email);
    }

    /**
     * @param Address          $to
     * @param int              $templateId
     * @param Collection|null  $data
     * @param EnvelopeDto|null $envelopeDto
     *
     * @return void
     */
    public static function send(
        Address $to,
        int $templateId,
        ?Collection $data = null,
        ?EnvelopeDto $envelopeDto = null
    ): void {
        self::dispatch($to, $templateId, $data, $envelopeDto);
    }
}

In the “handle” method we are building and sending an email to the recipients.

Also, we have created an extra method “send” and specified parameters for this class constructor. By using this method IDE will hint what parameters we need to set for this job class.

To send transactional email, you can use following code anywhere in your app:

<?php

use App\Jobs\TransactionalEmail;

TransactionalEmail::send(
    to:          new Address($emailAddress), //required parameter
    templateId:  $templateId,                //required parameter
    data:        collect($dynamicTemplateDataArray),
    envelopeDto: new EnvelopeDto(...$envelopeParameters)
);

Testing

Another advantage for developers is that having fewer files in the system means writing fewer unit tests.

As we are using only one Mailbale class for all our transactional emails, this means we have less unit tests to write.

Final word

With this implementation we don’t need to think about email templates in our codebase. All we need to know is an ID of the template and the data we need to pass to render the final email.

This also eliminates the tedious review and deployment process for developers, such as correcting grammar mistakes, adding content, or modifying HTML, that has no connection to development.

You can find all source files with tests in this repository.