Back to Blog
Transactional Email and Queues in Laravel, Right-Sized

Transactional Email and Queues in Laravel, Right-Sized

May 31, 2026
Stefan Mentovic
laravelqueuestransactional-emailredisbackground-jobs

Transactional email and background jobs in Laravel, sized to the work: vue-email to Blade, MailHog to SendGrid by environment, and Redis queues kept right-sized.

Email and background jobs are where applications quietly rot. Under-build them and mail silently fails, a slow provider stalls a request, a job dies with no trace. Over-build them and you have a message-queue cluster and an autoscaling worker fleet babysitting a few thousand emails a month, paying in complexity for a scale that never arrives. The skill is neither maximalism nor neglect. It is right-sizing: matching the infrastructure to the actual work. Here is how transactional email and queues are built on the live Laravel platform we rebuilt, and where we deliberately drew the line.

#Author the email once, render it to Blade

An email should look like it came from the same product as the app, because it did. So the templates are not a separate, drifting set of HTML. They are authored as vue-email components in the same front-end codebase as the interface, and a build step renders them to the Blade views the API actually sends (npm run render:emails). One design system, one source. A check in CI fails the build if a rendered template ever drifts from its component, so the two cannot quietly diverge.

On the back end, sending one is an ordinary Laravel notification pointed at that rendered template:

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

final class SubmissionReceived extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private readonly Submission $submission)
    {
        $this->onQueue('mail');
    }

    /** @return array<int, string> */
    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage())
            ->subject('We received your submission')
            ->markdown('emails.submission-received', ['submission' => $this->submission]);
    }
}

The notification carries the data the template needs and nothing more, and because it implements ShouldQueue, sending it never happens inside the web request.

#A different transport for every environment

The fastest way to email a real customer by accident is to use the same mail transport everywhere. We do not. Each environment gets the transport that fits its job.

EnvironmentTransportBehaviorReal emails?
Local developmentMailHogCatches every message in a local inboxNone leave the machine
StagingMailtrapSandbox inbox that mimics a real providerNone, safe sandbox
ProductionSendGridReal delivery, analytics, deliverabilityYes
# .env (local): MailHog catches everything and sends nothing real
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025

# .env (staging): Mailtrap, a safe sandbox that behaves like production
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525

# .env (production): SendGrid, for real delivery, analytics, deliverability
MAIL_MAILER=sendgrid

In development, MailHog catches every message in a local inbox and sends nothing to the outside world, so you can trigger a hundred test emails without one escaping. In staging, Mailtrap behaves like a real provider into a safe sandbox inbox, which is where you confirm a message looks right end to end. In production, SendGrid handles real delivery with the deliverability and analytics a transactional sender needs. The application code does not change between them. Only the transport does.

#Send mail in the background, never in the request

A user clicking submit should not wait on a mail server, and a slow or failing provider should never take a request down with it. Every email and notification is queued, so the request returns immediately and the actual send happens on a worker.

That also makes failure survivable. A queued send that fails is retried with backoff, and one that exhausts its retries lands in the failed-jobs table with its payload intact, ready to inspect and replay rather than lost. Mail that matters is not fire-and-forget. It is dispatched, retried, and accounted for.

#Tasks on a schedule and a queue

The platform does more than send mail in response to clicks. A lot of its work is time-based: follow-ups that come due, reminders, periodic housekeeping. Two mechanisms cover it.

// routes/console.php: surface tasks the moment they come due
Schedule::command('tasks:run-due')->everyMinute()->withoutOverlapping();

// Heavier follow-ups run on the queue, off the request path:
final class SendSubmissionReminder implements ShouldQueue
{
    use Queueable;

    public function __construct(private readonly Submission $submission)
    {
        $this->onQueue('default');
    }

    public function handle(): void
    {
        $this->submission->client->notify(new SubmissionReminder($this->submission));
    }
}

A scheduled command runs every minute and promotes whatever has fallen due, so nothing time-based depends on someone remembering it, and withoutOverlapping keeps a long run from colliding with the next tick. The heavier follow-ups are dispatched as queued jobs, the same background machinery the email uses, so a burst of due work drains through the workers smoothly instead of blocking anything a user is waiting on.

#Right-sized: Redis and a couple of named queues

Here is where the right-sizing happens in practice. The queue runs on Redis, which the application already uses, so it adds no new infrastructure. Jobs are split across a small number of named queues, not one undifferentiated firehose and not a dozen speculative ones.

# A single supervised worker drains mail first, then everything else:
php artisan queue:work redis --queue=mail,default --tries=3 --backoff=10

Two lanes do the job at this scale: a mail queue that workers drain first, so a notification is never stuck behind a slow import, and a default queue for everything else. The volume here is modest, a handful of email types and a handful of recurring task types, measured in thousands of messages rather than millions, so a single supervised worker keeps up comfortably.

What we did not build is as deliberate as what we did. No managed message-queue service, no autoscaling worker fleet, no dashboard stack for a load that does not need one. But the design leaves the door open: because the workloads already sit on named queues, the day one of them grows it moves to its own dedicated worker by changing a command, not by re-architecting. That is the heart of right-sizing, build for the load you have, with a clean seam to grow along when that load changes.

WorkloadRight-sized choiceMove up when
A few jobs, no urgencysync or the database queuejobs slow the request or start piling up
Modest and steady (this project)Redis with a few named queues, one workerone lane starves another, or the backlog grows
High or spiky, many workloadsRedis with Laravel Horizon, several workersyou need autoscaling, balancing, and metrics
Very high, multi-service, strict durabilitya managed queue such as SQSscale or cross-service decoupling demands it

#Build for the load you have

Email and queues reward restraint more than ambition. The goal is not the most impressive pipeline. It is mail that always sends or always tells you it did not, jobs that survive failure, and exactly enough infrastructure to carry the real volume. Authored-once templates, a transport per environment, everything queued, and a couple of Redis-backed lanes sized to the work is the entire system, and for this product it is precisely enough.

It is the same instinct we bring to every stack and tooling decision: fit the solution to the project, not to a diagram of how big it might someday become. The environment-specific transports and the workers that run them are part of the broader infrastructure and deployment story, and the whole thing is one piece of the platform rebuild.

If your email silently fails, or your queues are built for a scale you will not see for years, both are fixable, and usually by simplifying. Let's talk.