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:
- Mailables - https://laravel.com/docs/11.x/mail
- Notifications - https://laravel.com/docs/11.x/notifications
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:
- Mailables - https://laravel.com/docs/11.x/mail#testing-mailables
- Notifications - https://laravel.com/docs/11.x/notifications#testing
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.