The subtle differences between Laravel Mailables and Notifiables

Email

When I started working with Laravel the first time, and I wanted to send an email, I naturally reached out for the mail documentation.

Subsequently I started working on a project that grew in size, and with time the client just requested more and more emails to be added to the system. With time, I found that my code didn't scale. Especially in parts where I had workflow triggers to send emails.

TL;DR?

Use Notifications for transactional emails, and use Mail for once offs.

The main problem is that there are basically two types of emails: 

  • Once-off emails
  • Recurring emails

For some applications, once-off emails are 100%. For larger applications, you will be dealing with transactional emails. Transactional emails come in many shapes and sizes, for example:

  • Send repeatedly forever, and allow the user to unsubscribe if they so please
  • Send three times, one week spaced apart
  • Send three times, but the first one should be delayed by 5 days
  • Send three times, but if X or Y changes then stop sending

The possible permutations for transactional emails is far and wide and can lead to convoluted code if you don't watch out.

Back to Laravel...Laravel doesn't provide unsubscribe. Unsubscribe would require a database and tracking. Nor does Laravel provide transactional emails. Our heros at Spatie provide transactional emails using via their Mailcoach Self-Hosted solution, but the overhead is more attuned to someone who wants to replace Mailchimp to send marketing emails. Of course, the reason why Laravel doesn't provide unsubscribe and transactional emails is because although it's an opinionated framework, and it's not going to provide these wide opinions for how to create emails in your application. There's no scaffolding and the heavy lifting is up to you.

Here is the caveat which I found after a few years in my big project:

If you carefully think about it, email is so 2000s and these days real-time tools that notify such as Telegram and Slack and WhatsApp is often more appropriate than email. And what if you design a back office, perhaps you need database notifications? So you might need email for your web application, but push notifications for your mobile app.

That's where Notifications come it. Notifications encompass email and database and Slack and Telegram and....the list is big and you can find up to 55 notifications channels here. (ps. No WhatsApp).

In Laravel terms, I call Notifications a superset of Mail, because it encompasses Mail and so much more. When I read the docs it even appears that Mail stood still and most work in the source is now being done for Notifications instead. That there is was a diversion at one stage...

Notifications in Laravel is actually a lot more powerful than Mail (email notifications). And if you study the documentation obsessively, and you're also obsessed with testing, you'll soon start seeing some very important differences between the two implementations.

Here are some differences to think about in making a decision about choosing mail or notifications:

Delayed Mail and Notifications

With Mail, the syntax is `->later()`:

Mail::to($request->user())
    ->cc($moreUsers)
    ->bcc($evenMoreUsers)
    ->later(now()->addMinutes(10), new OrderShipped($order));
lang-php

With Notifications, the syntax is `->delay()` and looks like this:

$delay = now()->addMinutes(10);
 
$user->notify((new InvoicePaid($invoice))->delay($delay));
lang-php

Testing Mail and Notifications

The golden rule of bug free software and building at scale with a team or solo is automated testing. 

But before we move on to differences when testing, you'll need to fully grasp this part of the Mail manual:

We suggest testing the content of your mailables separately from your tests that assert that a given mailable was "sent" to a specific user. Typically, the content of mailables is not relevant to the code you are testing, and it is sufficient to simply assert that Laravel was instructed to send a given mailable.

I'll paste the same section from the Notifications manual since it's just as relevant:

Typically, sending notifications is unrelated to the code you are actually testing. Most likely, it is sufficient to simply assert that Laravel was instructed to send a given notification.

Grasping these two concepts is crucial to succeed in testing mail and notifications.

Let's move on to some more differences. There are some of the automated test helper differences that caught me off-guard:

Testing Mail

There are two sections in the manual for testing and an import subsection about queueing.

Testing Mailable Content

Here's some common things one would test against:

test('mailable content', function () {
    $user = User::factory()->create();
 
    $mailable = new InvoicePaid($user);
    ...
    $mailable->assertTo('taylor@example.com');
    ... 
    $mailable->assertHasSubject('Invoice Paid');
    ...
    $mailable->assertSeeInHtml($user->email);
    ...
});
lang-php

Note: Faking not needed.

Testing Mailable Sending

test('orders can be shipped', function () {
    Mail::fake();
 
    // Perform order shipping...
 
    // Assert that no mailables were sent...
    Mail::assertNothingSent();
 
    // Assert that a mailable was sent...
    Mail::assertSent(OrderShipped::class);
 
    // Assert a mailable was sent twice...
    Mail::assertSent(OrderShipped::class, 2);
 
    // Assert a mailable was sent to an email address...
    Mail::assertSent(OrderShipped::class, 'example@laravel.com');
 
    // Assert a mailable was sent to multiple email addresses...
    Mail::assertSent(OrderShipped::class, ['example@laravel.com', '...']);
 
    // Assert a mailable was not sent...
    Mail::assertNotSent(AnotherMailable::class);
 
    // Assert 3 total mailables were sent...
    Mail::assertSentCount(3);
});
lang-php

Note: Faking needed.

Testing Mailable Queuing

Of course, nobody in their right mind would freeze the UI whilst sending email, so we all use queueing for this. Testing mail sending can be really futile if you're queueing emails, so you should rather focus on the latter part of the manual:

Mail::assertQueued(OrderShipped::class);
Mail::assertNotQueued(OrderShipped::class);
Mail::assertNothingQueued();
Mail::assertQueuedCount(3);
lang-php

Testing Notifications

When testing notifications, the first thing you'll notice is the testing section is incredibly small compared to mail. 

And here are some methods you may frequently use when testing notifications:

test('orders can be shipped', function () {
    Notification::fake();
 
    // Perform order shipping...
 
    // Assert that no notifications were sent...
    Notification::assertNothingSent();
 
    // Assert a notification was sent to the given users...
    Notification::assertSentTo(
        [$user], OrderShipped::class
    );
 
    // Assert a notification was not sent...
    Notification::assertNotSentTo(
        [$user], AnotherNotification::class
    );
 
    // Assert that a given number of notifications were sent...
    Notification::assertCount(3);
});
lang-php

Notification Truth Tests

Notification::assertSentTo(
    $user,
    function (OrderShipped $notification, array $channels) use ($order) {
        return $notification->order->id === $order->id;
    }
);
lang-php

What really threw me off the Notification tests is I couldn't find any queueing tests. Surely by default notification queuing testing should be available?

This  is one the biggest lesson I learnt in testing Notifications. When testing notification queueing, you're not testing the notification but rather the queueing. In fact, you are somewhat limited in testing compared to what can be achieved with email testing.

Aborting Sending

Another big difference in Mail versus Notifications is aborting sending. What if you've queued email messages that are no longer relevant?

For example, instead of writing a lot of boilerplate such as `last_notification_sent_at` and creating many to many tables for all your notifications, you could do the below the first time a workflow event happens:

$product->user->notify(new ProductIsOnSpecial($product));

$product->user->notify((new ProductIsOnSpecial($product))
      ->delay(now()->addDays(7)
    ));

$product->user->notify((new ProductIsOnSpecial($product))
    ->delay(now()->addDays(14)
    ));
lang-php

But what now if the supplier of the product informs you the product is out of stock, say 10 days into the promotion? In that case you may reach for `shouldSend()`, which only exists in Notifications:

/**
 * Determine if the notification should be sent.
 */
public function shouldSend(object $notifiable, string $channel): bool
{
    return $this->product->onSpecial();
}
lang-php

When I started noticing convoluted code issues in my large application, I took a long journey into fully understanding the eventing system for Mail. The appropriate place in the manual is here:

Laravel dispatches two events while sending mail messages. The MessageSending event is dispatched prior to a message being sent, while the MessageSent event is dispatched after a message has been sent. 

So one can use the MessageSending event for workflow detection to abort sending. But to be honest, the extra abstraction with eventing sometimes makes code a lot more hard to follow so I eventually gave up on that idea, even after writing an entire library dedicated to transactional notifications.

Note: As an aside I find that the AI in Cursor IDE struggles a lot with understanding some of the deeper abstractions of Laravel, for example when events are happening in the background. It often doesn't pick them up in the first place Maybe with Project Rules one can override that behaviour?

When studying the Laravel manual for Notifications Events, it's more complicated but basically it also comes down to it comes to `NotificationSending` and `NotificationSent` events and then some:

/**
 * Handle the event.
 */
public function handle(NotificationSent $event): void
{
    // $event->channel
    // $event->notifiable
    // $event->notification
    // $event->response
}
lang-php

Mail sent via a Notification

And now we come to an even more confusing aspect of Mail versus Notifications - you can send Mail via a Notification and there is an entire section dedicated to it. For example, you could do a quick non-markdown `MailMessage` like this:

/**
 * Get the mail representation of the notification.
 */
public function toMail(object $notifiable): MailMessage
{
    $url = url('/invoice/'.$this->invoice->id);
 
    return (new MailMessage)
        ->greeting('Hello!')
        ->line('One of your invoices has been paid!')
        ->lineIf($this->amount > 0, "Amount paid: {$this->amount}")
        ->action('View Invoice', $url)
        ->line('Thank you for using our application!');
}
lang-php

Or you could even call your `Mailable` like so (noting the different return type):

/**
 * Get the mail representation of the notification.
 */
public function toMail(object $notifiable): Mailable
{
    return (new InvoicePaidMailable($this->invoice))
        ->to($notifiable->email);
}
lang-php

When you're sending a Mailable, this part of the documentation is also very  important:

When returning a Mailable instead of a MailMessage, you will need to specify the message recipient using the mailable object's to method:
/**
 * Get the mail representation of the notification.
 */
public function toMail(object $notifiable): Mailable
{
    return (new InvoicePaidMailable($this->invoice))
        ->to($notifiable->email);
}
lang-php

Feature Testing with Mail and Notifications

The challenge I found is in workflow terms you might want to test something like this:

Create a new product
Receive a message from the supplier that the product is on special
Queue three notifications to all users who subscribed to this product notification 7 days apart
`->travel()` for 10 days
Get a message from the supplier that the product is not on special anymore
Abort the sending for all users

For a while at least with mail testing `->travel()` was incredibly helpful...until...I remembered this important note in the manual:

Typically, sending notifications is unrelated to the code you are actually testing. 

This was, and is, still a bit hard to grasp when your feature test must look like I just described. To compound matters, notifications are typically queued and so are emails, so if you're trying to test end-to-end things really get hairy and complicated. I found after many repeated attempts and the way the Framework is structured, for queueing, testing and using Notifications that send Mail such as the example is simply not doable. The abstractions are too deep and the the test helpers do not reach deep enough to write user friendly tests like I just mentioned example. Not finding queueing helpers in the Notification part of the manual was a big setback.

Other Tips

  • Be sure to understand that `$notifiable` exists in Notifications but not in Mail.
  • Check out Spatie's AI Guidelines. They clearly state Mailables should be postfixed with `Mail`:
- Mailables: purpose + `Mail` suffix (`AccountActivatedMail`)

I fully agree with that! It's a specific item whereby a Notification could be applied to many items.

Final Words

Refactoring my client's application to work properly with transactional emails was a 40+ hour exercise since we never did things right from the start. I've learnt a lot of new things about Laravel also, and went over my code many times restructuring it to be more readable. Naming conventions between Mail and Notifications is a real challenge at times!

One side effect was that I learnt that when you touch Mail or Notifications in many parts of your application, it's better to use fluent methods in the model rather than relying too much on service classes. I'm not sure if this is relevant to a senior coder, but I still have some ways to go!