Sending email in Laravel with Mailgun

Sending email from a web app is a common feature. But what happens when you need to send an email to 5,000 recipients? In this article, we'll cover how to do this using Mailgun's "batch sending" feature.

As a web developer, you will often need to send emails from your application. In fact, if you've been a developer for more than a few months, you've probably already had to do this.

You might want to send emails for a variety of reasons, such as sending a welcome email to new users, sending a password reset email, or sending a notification to a user. Laravel makes it easy to send these types of emails to single recipients, thanks to some handy features such as mailables and notifications.

However, there might be times when you need to send emails to many recipients at once. For example, you might want to send a newsletter to the 5,000 users of your application. In these cases, you may need to consider using "bulk sending" (or sometimes referred to as "batch sending") rather than sending individual emails.

In this article, we're going to look at how to send emails from a Laravel application using Mailgun, a popular email service provider. We'll then cover how to bulk send emails using Mailgun, and how to test that the emails are sent correctly.

By the end of the article, you should feel confident in sending both single and bulk emails from your Laravel application using Mailgun.

What is Mailgun?

Mailgun is a popular email service provider that allows you to send, receive, and track emails from your Laravel application.

They provide the ability to send both single and bulk emails, and also provide analytics on the emails you send. This means you can track statistics such as open rates, click rates, and bounce rates.

To send an email from your application, you send an API request to Mailgun with the details of the email (such as the recipient, subject, and body), and Mailgun will then send the email for you. This is a great way to send emails from your application as it means you don't need to worry about managing your own email server, and you can be confident that the emails will be delivered reliably.

Mailgun is used by over 150,000 businesses, including Lyft, Microsoft, Wikipedia, and American Express. So you can be confident that it's a reliable service.

I have used Mailgun for many years myself, and I've always found it to be reliable, easy to use, and well-documented.

Alternatives to Mailgun

In this article, we're going to be discussing how to send single and bulk emails using Mailgun. However, there are other providers that you might want to consider depending on things such as your application's requirements, your budget, and your personal preferences.

Some popular alternatives to Mailgun include:

  • Postmark
  • SendGrid
  • Amazon SES
  • Mailtrap
  • Mailcoach
  • Resend

If you feel confident managing your own email server, you may even want to consider using your own server to send emails. However, if you do choose to do this, you should be aware of the potential pitfalls, such as deliverability issues and the added complexity of managing your own server. For most Laravel developers, using an external service, such as Mailgun, is a great choice because it takes care of all the complexities for you.

Setting up Mailgun

Before we take a look at any code, we'll first need to set up Mailgun and prepare our Laravel application.

Creating your Mailgun domain

To get started, you'll need to sign up for a Mailgun account at https://signup.mailgun.com/new/signup.

Once you've signed up, on your Mailgun dashboard you'll want to head to the "Sending" dropdown in the left-hand navigation menu and then click on "Domains".

You'll then want to press the "Add New Domain" button on the right of the screen. Here's where you'll want to define the domain you'll be sending emails from. For example, let's say your application's domain is my-awesome-app.com, you might want to fill in the form with the following details:

  • Domain name: mail.my-awesome-app.com
  • Domain region: EU
  • IP assignment option: Shared IP
  • DKIM key length: 2048 bits

After you've submitted the form, you'll be presented with some DNS record information. You'll need to go to your DNS provider and add these records. This will allow Mailgun to send emails on behalf of your domain.

Don't worry if this part sounds complicated. I know when I was new to web development, this part confused me a little. Thankfully, Mailgun has some handy guides and videos to help you through this part of the process.

After you've added the records to your DNS provider, you'll need to press the "Verify DNS settings" button in the Mailgun dashboard. This will check that the records have been added correctly.

After this, you'll then want to head to the Sending dropdown in the left-hand navigation menu and then click on the "Domain settings" option. From here, you'll want to select the "Sending API keys" tab in the middle of the screen. You can then select the "Add sending key" button to create a new API key.

You can call this whatever you'd like, but I'd recommend calling it something that makes it clear what it's for. For example, you might want to call it mail.my-awesome-app.com. It's important that you keep this key safe as you'll only see it once and you need it for your Laravel application. This sending key is the API key that your Laravel application will use to send emails via Mailgun.

Preparing your Laravel application

We're nearly ready to start sending emails from our app. But first, we need to prepare our Laravel project to use Mailgun.

You'll need to install the Symfony Mailgun Mailer transport package via Composer. You can do this by running the following command in your project's root directory:

composer require symfony/mailgun-mailer symfony/http-client

You'll then need to add the following configuration to the mailers field in your config/mail.php file:

'mailgun' => [
    'transport' => 'mailgun',
],

Your config/mail.php file should look something like this:

return [
    // ...

    'mailers' => [
        // ...
        'mailgun' => [
            'transport' => 'mailgun',
        ],
    ],

    // ...
];

You'll then need to add the following configuration to your config/services.php file:

'mailgun' => [
    'domain' => env('MAILGUN_DOMAIN'),
    'secret' => env('MAILGUN_SECRET'),
    'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
    'scheme' => 'https',
],

This will result in your config/services.php file looking something like this:

return [
    // ...

    'mailgun' => [
        'domain' => env('MAILGUN_DOMAIN'),
        'secret' => env('MAILGUN_SECRET'),
        'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
        'scheme' => 'https',
    ],

    // ...
];

By adding these config fields, we're instructing Laravel to read the MAILGUN_DOMAIN and MAILGUN_SECRET environment variables from our .env file. So let's get these added to our .env file now.

You'll want to add the following to your .env file:

MAIL_MAILER=mailgun
MAILGUN_DOMAIN=your-mailgun-domain
MAILGUN_SECRET=your-mailgun-secret

As you can see, we've set the MAIL_MAILER environment variable to mailgun. This tells Laravel to use the Mailgun mailer as the default mailer for our application. We've then set the MAILGUN_DOMAIN and MAILGUN_SECRET environment variables to the domain and secret that we created in the Mailgun dashboard.

That's it! We should now be ready to start sending emails from our Laravel application using Mailgun.

Sending a single email

In your Laravel applications, there are generally two different approaches to sending single emails: notifications and mailables.

The Laravel documentation does a great job of explaining these two approaches, so we won't spend too much time on them here. But I'd highly recommend reading the following documentation to see how to use these two approaches:

Let's quickly cover how to send a single email using each of these approaches.

Sending a single email using mailables

Using mailables is a great way to send single emails from your Laravel application. Mailables allow you to define a class that represents an email, and then use this class to send the email to the recipient.

They also provide you with a lot of flexibility, such as the ability to define the email's subject, HTML and plain text content, and the ability to attach files to the email. In comparison to notifications, I feel like mailables are sometimes the better choice when you need to send a more complex email that may require a lot of dynamic content or customization.

Let's say our web application allows users to download PDF reports of their data. We might want to send an email to the user when the report is ready to download. We'll take a look at a simple example of how to do this using mailables.

First, we'll need to create a new mailable class. You can do this by running the following command in your terminal:

php artisan make:mail DownloadReport

This will create a new app/Mail/DownloadReport.php file that looks something like this:

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class DownloadReport extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the message envelope.
     */
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Download Report',
        );
    }

    /**
     * Get the message content definition.
     */
    public function content(): Content
    {
        return new Content(
            view: 'view.name',
        );
    }

    /**
     * Get the attachments for the message.
     *
     * @return array<int, \Illuminate\Mail\Mailables\Attachment>
     */
    public function attachments(): array
    {
        return [];
    }
}

In the class above, the envelope method is used to define things such as who the email is being sent to, the subject of the email, and the from address. The content method is used to define the content of the email, such as the HTML and plain text content. The attachments method is used to define any files that should be attached to the email.

Let's make some changes to the file to prepare it for sending the download report email and then discuss what we've done. The updated file may look something like this:

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class DownloadReport extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        private readonly string $downloadUrl
    ) {
        //
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Download Report',
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.download-report',
            with: [
                'downloadUrl' => $this->downloadUrl,
            ]
        );
    }
}

In the example above, we've updated the class so that it now implements the Illuminate\Contracts\Queue\ShouldQueue interface. By doing this, we're instructing Laravel to always queue the email by default when it's being sent. This is a good idea because it means that the email will be sent in the background, rather than holding up the user's request.

We've also added a new $downloadUrl property to the class (defined in the constructor). This will be the URL in the email that the user can use to download the report.

We've also deleted the attachments method as we don't need to attach any files to the email in this example.

Finally, We've then updated the content method to use a Markdown view called emails.download-report and passed the $downloadUrl to the view. This will allow us to define the email's content in a separate markdown file, which is a great way to keep our code clean and maintainable. In this particular example, we're using the Markdown approach because it allows us to use Laravel's built-in templates. However, you can also choose to use a plain text or HTML view if you prefer.

Let's take a look at what the resources/views/emails/download-report.blade.php file might look like:

<x-mail::message>
# Download Report

Your report is ready to download!

<x-mail::button :url="$downloadUrl">
    Download Report
</x-mail::button>

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

In the example above, we've defined a simple Markdown file that contains the content of the email. We've used Laravel's pre-built x-mail::message component to define the email's content, and we've used the x-mail::button component to define a button that the user can click to download the report.

Now let's take a look at how to send the email from our application. We can do this by using the Mail facade. Let's imagine we have a GenerateReport job that generates the report and then sends the email. We might do something like this:

declare(strict_types=1);

namespace App\Jobs;

use App\Mail\DownloadReport;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class GenerateReport implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(
        public User $generatedBy
    ) {
        //
    }

    public function handle(): void
    {
        // Generate the report here...

        $downloadUrl = 'the-url-of-the-report-we-just-generated';

        Mail::to($this->generatedBy)->send(
            mailable: new DownloadReport($downloadUrl),
        );
    }
}

In the example above, we're passing a User model to the GenerateReport class constructor. We'll imagine this is the user that requested the report and we'll be sending the download link to.

In the handle method of the job, we'll assume we're generating the report, storing it somewhere (such as AWS S3), and then sending an email to the user with the download link. As you can see we've done this using the Mail facade and the send method. We've passed the DownloadReport mailable class to the send method, along with the $downloadUrl that we want to pass to the mailable class.

Assuming that everything is configured correctly, the user should receive an email with the download link.

Sending a single email using notifications

The other, arguably easier, way of sending a single email from your Laravel application is to use notifications.

Notifications are similar to mailables in that they allow you to define a class that represents the content that should be sent to the user. However, they can be used to send a variety of different types of notifications, such as emails, SMS messages, and Slack messages. So if you intend on sending a particular type of notification to multiple channels, notifications might be the better choice.

Let's take our previous code example, and look at how we might send the download report email using notifications instead of mailables.

First, we'll need to create a new notification class. You can do this by running the following command in your terminal:

php artisan make:notification DownloadReport

Running this command will create a new app/Notifications/DownloadReport.php file that looks something like this:

namespace App\Notifications;

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

class DownloadReport extends Notification
{
    use Queueable;

    /**
     * Create a new notification instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the notification's delivery channels.
     *
     * @return array<int, string>
     */
    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    /**
     * Get the mail representation of the notification.
     */
    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
                    ->line('The introduction to the notification.')
                    ->action('Notification Action', url('/'))
                    ->line('Thank you for using our application!');
    }

    /**
     * Get the array representation of the notification.
     *
     * @return array<string, mixed>
     */
    public function toArray(object $notifiable): array
    {
        return [
            //
        ];
    }
}

In the class above, the via method is used to define the delivery channels that the notification should use. In this case, we're telling Laravel to send the notification via email. The toMail method is used to define the content of the email, such as the subject, HTML and plain text content, and any action buttons that should be included in the email. The toArray method is used to define the content of the notification for other channels, such as if we wanted to store the notification in the database.

Let's make some changes to the file to prepare it for sending the download report email and then discuss what we've done. The updated file may look something like this:

namespace App\Notifications;

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

class DownloadReport extends Notification implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new notification instance.
     */
    public function __construct(
        private string $downloadUrl
    ) {
        //
    }

    /**
     * Get the notification's delivery channels.
     *
     * @return array<int, string>
     */
    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    /**
     * Get the mail representation of the notification.
     */
    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
                    ->line('Your report is ready to download!')
                    ->action('Download Report', $this->downloadUrl);
    }
}

In the example above, we've updated the class so that it now implements the Illuminate\Contracts\Queue\ShouldQueue interface, similar to the mailable class from earlier. By doing this, we're instructing Laravel to always queue the notification by default when it's being sent.

We've then added a new $downloadUrl property to the class (defined in the constructor). This will be the URL in the email that the user can use to download the report.

We've then updated the toMail method to define the content of the email. We've used the line method to add a new line of text, and the action method to define a button that the user can click to download the report.

As you can see, the notification class is a lot simpler than the mailable class and is a great choice when you need to send a simple email to the user.

If we wanted to send the notification from our GenerateReport job, we might do something like this:

declare(strict_types=1);

namespace App\Jobs;

use App\Notifications\DownloadReport;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class GenerateReport implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(
        public User $generatedBy
    ) {
        //
    }

    public function handle(): void
    {
        // Generate the report here...

        $downloadUrl = 'the-url-of-the-report-we-just-generated';

        $this->generatedBy->notify(
            new DownloadReport($downloadUrl)
        );
    }
}

As we can see in the code above, we've called the notify method on the User model, passing the DownloadReport notification class to the method, along with the $downloadUrl that we want to pass to the notification class.

Bulk sending emails

So far, we've only covered how to send single emails from your Laravel application. Using these approaches is great when you only need to send an email to a small number of recipients.

However, there may be times when you want to send an email to a large number of recipients at once. For example, let's say your application is a newsletter service and you want to send a newsletter email to 5,000 users.

If you were to use the approaches we've already covered, you'd need to make 5,000 individual API requests to Mailgun to send the emails. This would be slow and inefficient, could potentially cause performance issues, and will likely result in your application being rate-limited by Mailgun.

The alternative approach is to use "bulk sending" (sometimes referred to as "batch sending"). This approach allows you to send a single API request to Mailgun to send an email in bulk to up to 1,000 recipients at once. This means you'd only need to make 5 API calls to Mailgun to send the newsletter to your 5,000 users. This is a much more efficient way of sending emails to a large number of recipients.

However, bulk sending is more involved than sending single emails and requires a different approach to sending emails.

Let's look at a basic example of how to send emails in bulk. We'll first start by creating a new app/Services/Mail/BatchSender.php file that will be responsible for bulk sending the emails. The file may look something like this:

declare(strict_types=1);

namespace App\Services\Mail;

use Illuminate\Support\Collection;
use Mailgun\Mailgun;
use Mailgun\Message\BatchMessage;
use Mailgun\Message\MessageBuilder;

class BatchSender
{
    /**
     * @param Collection<array> $recipients
     * @param string $subject
     * @param string $body
     * @return void
     */
    public function send(
        Collection $recipients,
        string $subject,
        string $body,
    ): void
    {
        $recipients->chunk(MessageBuilder::RECIPIENT_COUNT_LIMIT)
            ->each(function (Collection $chunk) use ($subject, $body): void {
                $this->sendToChunk($chunk, $subject, $body);
            });
    }

    private function sendToChunk(
        Collection $recipients,
        string $subject,
        string $body
    ): void {
        $batch = $this->createBatch();

        $batch->setFromAddress(
            address: config('mail.from.address'),
            variables: ['full_name' => config('mail.from.name')],
        );

        $batch->setSubject($subject);
        $batch->setHtmlBody($body);

        // Build up the list of recipients and map their data to the
        // variables in the email template.
        foreach ($recipients as $recipient) {
            $batch->addToRecipient(
                address: $recipient['email'],
                variables: $recipient['variables'],
            );
        }

        // Send the
        $batch->finalize();
    }

    private function createBatch(): BatchMessage
    {
        return Mailgun::create(
            apiKey: config('services.mailgun.secret'),
            endpoint: 'https://' . config('services.mailgun.endpoint'),
        )
            ->messages()
            ->getBatchMessage(
                domain: config('services.mailgun.domain')
            );
    }
}

Let's break down what's being done in the BatchSender class above. We've created a public method called send that accepts a collection of recipients (we'll discuss a bit more soon) that will receive the email, a subject, and a body. Inside this method, we're then chunking the recipients into groups of 1,000 (the maximum number of recipients that Mailgun allows you to send to at once). We're then calling the sendToChunk method for each chunk of recipients.

In the sendToChunk method, we're creating a new BatchMessage instance using the Mailgun class and the config values that we set earlier on. We're then setting the from address, subject, and body of the email. After this, we're then looping through each recipient and adding them to the email. We're also mapping the recipient's data to variables in the email template. Finally, we're calling the finalize method to send the email.

An important part of the code above to take note of is this part that adds the recipients to the email:

$batch->addToRecipient(
    address: $recipient['email'],
    variables: $recipient['variables'],
);

In this method call, we define the recipient's email address and any variables that we want to pass to the email template. To fully understand how this works and how the email templates work, let's look at how we might call this BatchSender class in our code:

use App\Models\User;
use App\Services\Mail\BatchSender;

$recipients = User::all()->map(function (User $user): array {
    return [
        'email' => $user->email,
        'variables' => [
            'full_name' => $user->name.' '.$user->last_name,
            'email_address' => $user->email,
        ],
    ];
});

app(BatchSender::class)->send(
    recipients: $recipients,
    subject: 'My First Bulk Email!',
    body: '<p>Hi %recipient.full_name%, this was a bulk email sent to %recipient.email_address%!</p>',
);

In the example above, we're going to be sending an email to each user in our database, so we grab all the User models. We're then mapping over each user and returning an array that contains the user's email address and any variables that we want to pass to the email template. We're then passing this collection of recipients to the send method of the BatchSender class, along with the subject and body of the email.

The body of our email is:

<p>Hi %recipient.full_name%, this was a bulk email sent to %recipient.email_address%!</p>

In the body of the email, we're using %recipient.full_name% and %recipient.email_address% placeholders to define variables that we want to pass to the email template. This is how we can pass dynamic data to the email template and is why we are returning a full_name and email_address in the array of each recipient.

Before Mailgun sends the email to the recipient, it will replace the placeholders with the values we've defined. Whatever is in the full_name field of the array will replace %recipient.full_name% in the email template, and whatever is in the email_address field of the array will replace %recipient.email_address% in the email template. For instance, let's imagine we returned the following array for a user:

[
    'email' => 'mail@ashallendesign.co.uk',
    'variables' => [
        'full_name' => 'Ash Allen',
        'email_address' => 'hello@example.com',
    ],
]

This would trigger an email to be sent to mail@ashallendesign.co.uk with the following body:

<p>Hi Ash Allen, this was a bulk email sent to hello@example.com!</p>

As you can see, being able to build up the email body dynamically like this is a powerful feature of bulk sending emails. However, it can sometimes require a bit more work to set up than sending single emails as you'll need to ensure that you're building up the data for the template in an efficient way.

It's worth noting that you may want to consider sending using a data transfer object (DTO) rather than just a collection of arrays. This can help to ensure that the data you're passing to the BatchSender class is always in the correct format and provides a bit more type safety. We haven't done this in this particular article to keep things simple, but it's something to consider for your own applications.

Testing the emails are sent correctly

Just like with any other part of your code, it's important to test that your Laravel application is sending emails correctly. So let's take a look at how we can test all the approaches we've covered in this article.

We won't spend too long covering the mailables and notifications since the Laravel documentation does a great job of explaining how to test these. If you're interested in reading more about how to test mailables and notifications, I'd recommend reading the following documentation:

Testing single emails using mailables

Laravel provides a handy way of testing whether mailables have been sent and what their content is. To get an understanding of how to do this, let's take our GenerateReport job class (that uses mailables) from our earlier examples and write a basic test for it:

namespace Tests\Feature\Jobs;

use App\Jobs\GenerateReport;
use App\Mail\DownloadReport;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class GenerateReportTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function report_can_be_generated_and_mail_is_sent(): void
    {
        Mail::fake();

        $user = User::factory()->create();

        $job = new GenerateReport($user);
        $job->handle();

        // Make more assertions here about the job...

        Mail::assertQueued(DownloadReport::class);
    }
}

In the test above, we're starting by calling Mail::fake(). This is an important part of the testing process as it sets up a fake mailer that will prevent any emails from being sent. Instead, they will be recorded so that we can make assertions about them later in the test. Without calling Mail::fake(), you won't be able to test any mailables that are being sent.

We're then creating a new User model, passing it to the GenerateReport job, and then calling the handle method to generate the report and send the email. Typically, you would have some assertions in your test to ensure the report was generated and stored correctly, but we've left these out to keep the example simple.

Finally, we're calling Mail::assertQueued(DownloadReport::class) to assert that the DownloadReport mailable was queued to be sent. This is a simple way of testing that the email was sent correctly.

If we weren't queuing the email, we could use Mail::assertSent(DownloadReport::class) instead. This would assert that the email was sent immediately rather than being queued.

It's also possible for you to make more complex assertions about the email that was sent. For example, you might want to assert that the email was sent to the correct recipient, had the correct subject, and contained the correct content. You can do this by passing a callback to the assertQueued method:

Mail::assertQueued(
    mailable: DownloadReport::class,
    callback: function (DownloadReport $mail) use ($user): bool {
        return $mail->hasSubject('Download Report')
            && $mail->hasTo($user->email)
            && $mail->assertSeeInHtml('the-url-of-the-report-we-just-generated');
    }
);

You may also want to assert that the email was only sent (or queued) a given number of times. You can do this by passing an integer as the second argument to the assertQueued and assertSent methods:

// If the email is queued:
Mail::assertQueued(DownloadReport::class, 3);

// If the email is not queued:
Mail::assertSent(DownloadReport::class, 3);

There may be times when you want to assert that an email wasn't sent:

Mail::assertNotSent(DownloadReport::class);

You may also want to assert that no emails were sent or queued at all:

Mail::assertNothingSent();
Mail::assertNothingQueued();

Laravel also provides a helper method that allows you to assert that no emails were sent or queued at all (instead of calling each individual method like above):

Mail::assertNothingOutgoing();

Testing single emails using notifications

Similar to testing mailables, Laravel provides a handy way of testing whether notifications have been sent and what their content is. To get an understanding of how to do this, let's take our GenerateReport job class (that uses notifications) from our earlier examples and write a basic test for it:

namespace Tests\Feature\Jobs;

use App\Jobs\GenerateReport;
use App\Models\User;
use App\Notifications\DownloadReport;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class GenerateReportTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function report_can_be_generated_and_mail_is_sent(): void
    {
        Notification::fake();

        $user = User::factory()->create();

        $job = new GenerateReport($user);
        $job->handle();

        // Make more assertions here about the job...

        Notification::assertSentTo(
            notifiable: $user,
            notification: DownloadReport::class,
        );
    }
}

In the same way as we did with mailables, we're starting by calling Notification::fake(). This is an important part of the testing process as it sets up a fake notification system that will prevent any notifications from being sent. Instead, they will be recorded so that we can make assertions about them later in the test. Without calling Notification::fake(), you won't be able to test any notifications that are being sent.

We're then creating a user, passing them to the job, and then asserting that the DownloadReport notification was sent to the user. This is a simple way of testing that the notification was sent correctly.

You can also make more complex assertions with notifications, such as ensuring that the notification was sent to the expected recipient, had the correct content, and was sent via the correct channel (e.g. - email). You can do this by passing a callback to the assertSentTo method:

Notification::assertSentTo(
    notifiable: $user,
    notification: DownloadReport::class,
    callback: function (DownloadReport $notification, array $channels): bool {
        return $channels === ['mail']
            && $notification->downloadUrl === 'the-url-of-the-report-we-just-generated';
    }
);

You can also assert that the notification was sent a given number of times:

Notification::assertCount(3);

You may also want to assert that the notification wasn't sent to the user:

Notification::assertNotSentTo($user, DownloadReport::class);

Similarly, you may also want to assert that no notifications were sent at all:

Notification::assertNothingSent();

Testing bulk emails

Thanks to the useful Mail::fake() and Notification::fake() methods, testing single emails in Laravel is relatively straightforward. However, there currently isn't any built-in way to test bulk sending emails, so you'll need to come up with your own approach to testing this.

One way that I like is to create a "test double" that will replace the real class in our tests. For example, in our tests, instead of using the BatchSender class, we could use a BatchSenderFake class. This class can prevent any emails from being sent and instead record them so that we can make assertions about them later in the test. This is similar to how Mail::fake() and Notification::fake() work.

Before we start on any tests, we'll need to make some changes to our application code so that we can use the BatchSender in our actual code at runtime, and the BatchSenderFake in our tests. So we'll be making use of interfaces and Laravel's service container.

Both of the classes (BatchSender and BatchSenderFake) should have the same public methods and the same method signatures. So to enforce this, we'll create a new interface that both of the classes can implement. For this example, we'll call it BatchMailSender and we'll place it in a new app/Interfaces/Mail/BatchMailSender.php file:

declare(strict_types=1);

namespace App\Interfaces\Mail;

use Illuminate\Support\Collection;

interface BatchMailSender
{
    public function send(Collection $recipients, string $subject, string $body): void;
}

As we can see in the example above, we've defined a send public method that will need to be included in both classes.

Interfaces can't be instantiated in PHP. So we'll need to instruct Laravel that whenever we ask for an instance of BatchMailSender, it should give us an instance of BatchSender. We can do this by binding the interface to the concrete class in the app/Providers/AppServiceProvider:

namespace App\Providers;

use App\Interfaces\Mail\BatchMailSender;
use App\Services\Mail\BatchSender;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            abstract: BatchMailSender::class,
            concrete: BatchSender::class,
        );
    }
}

As a result of doing this, it means that instead of creating a new BatchSender class using new BatchSender(), we can instead use app(BatchMailSender::class).

Let's now update our GenerateReport job class from earlier to send the email using the BatchMailSender interface:

declare(strict_types=1);

namespace App\Jobs;

use App\Interfaces\Mail\BatchMailSender;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;

class GenerateReport implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    /**
     * @param Collection<User> $recipients
     */
    public function __construct(
        public Collection $recipients
    ) {
        //
    }

    public function handle(): void
    {
        // Generate the report here...

        $downloadUrl = 'the-url-of-the-report-we-just-generated';

        $recipients = $this->recipients->map(function (User $user): array {
            return [
                'email' => $user->email,
                'variables' => [
                    'full_name' => $user->name.' '.$user->last_name,
                ],
            ];
        });

        app(BatchMailSender::class)->send(
            recipients: $recipients,
            subject: 'Download Report!',
            body: '<p>Hi %recipient.full_name%! Download link:'.$downloadUrl.'!</p>',
        );
    }
}

As we can see in the example above, we've updated the job class to accept a collection of recipients in the constructor rather than a single recipient. We've then mapped over each recipient and returned an array that contains the recipient's email address and any variables that we want to pass to the email template. We're then passing this collection of recipients to the send method of the BatchMailSender interface, along with the subject and body of the email.

These are all the changes we need to make to our application code. So let's move on to the testing side of things!

We'll create a new BatchSenderFake class. We'll use this class to provide custom assertions about the emails that were sent (such as those seen in the mailables and notifications tests). We'll also use this class to prevent any emails from being sent and instead record them so that we can make assertions about them later in the test. The file may look something like this:

declare(strict_types=1);

namespace App\Services\Mail;

use App\Interfaces\Mail\BatchMailSender;
use Illuminate\Support\Collection;
use Mailgun\Message\MessageBuilder;
use PHPUnit\Framework\Assert;

class BatchSenderFake implements BatchMailSender
{
    private Collection $mailToSend;

    public function __construct()
    {
        $this->mailToSend = collect();
    }

    public function send(
        Collection $recipients,
        string $subject,
        string $body,
    ): void {
        $recipients->chunk(MessageBuilder::RECIPIENT_COUNT_LIMIT)
            ->each(function (Collection $chunk) use ($subject, $body): void {
                $this->mailToSend->push($chunk);
            });
    }

    public function assertSentTo(string $email): void
    {
        $exists = $this->mailToSend
            ->flatten(1)
            ->where('email', $email)
            ->isNotEmpty();

        Assert::assertTrue(
            condition: $exists,
            message: 'The expected recipient was not found in the mail to send.',
        );
    }

    public function assertSentToCount(int $count): void
    {
        Assert::assertCount(
            expectedCount: $count,
            haystack: $this->mailToSend->flatten(1),
        );
    }

    public function assertBatchCount(int $count): void
    {
        Assert::assertCount(
            expectedCount: $count,
            haystack: $this->mailToSend,
        );
    }
}

In the example above, we've created a new BatchSenderFake class that implements the BatchMailSender interface. We've also created a new mailToSend property that will be used to record the emails that we attempt to send. So each time we attempt to send a batch of emails, they'll be added to this collection.

We've then created a new send method (enforced by the BatchMailSender interface) that will be used to mimic the real method and record the emails that were sent.

We've also created some assertions so that we can ensure the emails were sent correctly:

  • assertSentTo - This method will assert that the email was sent to a given email address.
  • assertSentToCount - This method will assert that the email was sent a given number of times.
  • assertBatchCount - This method will assert that we attempted to send a given number of batches (remember, batches have a maximum of 1,000 recipients).

You can write as many or as few assertions as you like. The example above is just a starting point.

We can then write a new test for our GenerateReport job class that uses the BatchSenderFake class. The test may look something like this:

namespace Tests\Feature\Jobs;

use App\Interfaces\Mail\BatchMailSender;
use App\Jobs\GenerateReport;
use App\Models\User;
use App\Services\Mail\BatchSenderFake;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class GenerateReportTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function report_can_be_generated_and_mail_is_sent(): void
    {
        // Create our test double.
        $batchSenderFake = new BatchSenderFake();

        // Replace the bindings with our test double.
        $this->swap(BatchMailSender::class, $batchSenderFake);

        $users = User::factory()
            ->count(2)
            ->create();

        $job = new GenerateReport(recipients: $users);
        $job->handle();

        // Make more assertions here about the job...

        $batchSenderFake->assertSentTo($users[0]->email);
        $batchSenderFake->assertSentTo($users[1]->email);

        $batchSenderFake->assertBatchCount(1);

        $batchSenderFake->assertSentToCount(2);
    }
}

In the test above, we're first creating a new instance of the BatchSenderFake class. We're then using the swap method to instruct Laravel that when we try to resolve a new instance of BatchMailSender from the service container, it should give us the BatchSenderFake instance instead of the usual BatchSender instance.

We're then creating the recipient users, passing them to the job, and then executing it. Using our custom assertions, we're then asserting that the emails were sent correctly.

As you may have noticed, these tests are only asserting that we're calling the classes that implement the BatchMailSender interface. We're not actually testing what's happening inside the BatchSender class and whether it works properly. In this case, you may want to also consider writing some smaller unit tests that test the BatchSender class in isolation from the GenerateReport job class.

Conclusion

In this article, we've looked at how to send emails from a Laravel application using Mailgun. We've covered how to send single emails and how to send bulk emails. We've also looked at how to test that the emails are sent correctly.

You should now feel confident in sending both single and bulk emails from your Laravel application using Mailgun.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Ashley Allen

    Ashley is a freelance Laravel web developer who loves contributing to open-source projects and building exciting systems to help businesses succeed.

    More articles by Ashley Allen
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial