Laravel's HasLocalePreference: Writing PHPUnit Tests for Notifiable's Default Locale

Background: How to define default Locale for Notifiable

Since Laravel 5.7.7, you can set the preferred locale on a Notifiable. In other words, you can define the default locale that will be used for every Notification (or email) sent to a given User or whatever else you may send Notifications to in your app (Customer, Client, etc.). Quoting from Laravel v10 docs, it works like this:

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Model implements HasLocalePreference
{
    /**
     * Get the user's preferred locale.
     */
    public function preferredLocale(): string
    {
        return $this->locale;
    }
}

With such a definition, when we send a Notification to a User, we do not need to call the locale method anymore. So, assuming that $user->locale === 'es' , instead of doing this:

$user->notify((new InvoicePaid($invoice))->locale('es'));

we can do simply:

$user->notify(new InvoicePaid($invoice));

and the notification is still going to be sent in Spanish.

Problem: why the "obvious" way to test it doesn't work

If you want to test with PHPUnit that default locale is actually used by calling the locale() method on Notification instance, you'll be out of luck. The reason is that locale is never set on it. The below test will fail:

public function test_notification_is_in_spanish_for_spanish_speakers()
{
    Notification::fake();

    $user = User::factory()->create(['locale' => 'es']);

    $invoice = Invoice::factory()->for($user)->create();

    $user->notify(new InvoicePaid($invoice));

    Notification::assertSentTo(
        $user,
        function (InvoicePaid $notification, array $channels) {
            // null !== 'es'
            return $notification->locale() === 'es';
        }
    );
}

Instead of interacting with the locale of notifiable, Laravel temporarily swaps the locale of the app. If you want to source-dive to understand it better, check the definition of sendNow() method in NotificationSender.

Solution

Luckily, we can pass 2 more arguments to our closure in asserSentTo assertion, one of which is "locale". If we refactor our test to:

public function test_notification_is_in_spanish_for_spanish_speakers()
{
    Notification::fake();

    $user = User::factory()->create(['locale' => 'es']);

    $invoice = Invoice::factory()->for($user)->create();

    $user->notify(new InvoicePaid($invoice));

    Notification::assertSentTo(
        $user,
        function (InvoicePaid $notification, array $channels, $notifiable, $locale) {
            return $locale == 'es';
        }
    );
}

we'll "get to green" ✅.

At the time of writing, this is not a documented feature. It relies on the order of keys passed to array of notifications in NotificationFake object (source):

$this->notifications[get_class($notifiable)][$notifiable->getKey()][get_class($notification)][] = [
    'notification' => $notification,
    'channels' => $notifiableChannels,
    'notifiable' => $notifiable,
    'locale' => $notification->locale ?? $this->locale ?? value(function () use ($notifiable) {
       if ($notifiable instanceof HasLocalePreference) {
           return $notifiable->preferredLocale();
       }
   }),
];

If Laravel changes the keys in that array in future updates, or switches their order around, all we'll need to do is tweak what we pass to our closure. That way, our test will keep working.