Preview Laravel Email Templates Locally

When creating email templates it’s helpful to see the result without having to actually send the email.

There are tools like Mailpit (with Laravel Sail) or Mailtrap, but both will require you to trigger the flow of sending the email you want to preview. For every little adjustment you’ll make this can become tedious.

A more convenient method is available by previewing mailables in the browser.

Say you create an email template for an invitation called InvitationEmail.

php artisan make:mail InvitationEmail

Now you’ve got a file at app\Mail\InvitationEmail.php that looks something like:

<?php

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 InvitationEmail extends Mailable
{
    use Queueable, SerializesModels;

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

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: 'You’re Invited!',
        );
    }

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

But for this we’d also like to include a user, to show some user details in the email.

<?php

namespace App\Mail;

use App\Models\User;
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 InvitationEmail extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     */
    public function __construct(
        protected User $user,
    ) {}

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: 'You’re Invited!',
        );
    }

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        return new Content(
            view: 'emails.invitation',
            with: [
                'recipient' => $this->user,
            ],
        );
    }
}

Now this email is expecting a view to be created at resources/views/emails/invitation.blade.php. In that file we can just have some basic output to send as the message:

<div>
  Dear {{ $recipient->name }}, you’re invited!
</div>

It will take in the recipient (as an App\Models\User) to display in the email. At this point you can add it to your routes/web.php file to make it accessible to view in the browser.

use App\Mail\InvitationEmail;

 /**
  * Invitation Email
  * http://localhost/invitation/{user}
  */
Route::get('invitation/{user?}', function (User $user = null) {
  $user ??= User::first();
  return new InvitationEmail($user);
});

This adds a route, accessible at http://localhost/invitation/{user}, that can take in an optional user or just grab the first user in the database by default. For example http://localhost/invitation/1 would get the user with ID 1 from the database, and http://localhost/invitation will grab the first user from the database.

This way you can quickly test how the email looks. Or if you need to see how a specific user appears you can do that too.

But leaving these routes out in the open is not ideal. Anyone who stumbles across them can view your emails and potentially reveal private information. It would be much better to make these routes accessible from specific environments. This can be done by adding a new route group to your app\Providers\RouteServiceProvider.php file. By default that file looks something like this:

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider
{
    /**
     * The path to the "home" route for your application.
     *
     * Typically, users are redirected here after authentication.
     *
     * @var string
     */
    public const HOME = '/home';

    /**
     * Define your route model bindings, pattern filters, and other route configuration.
     */
    public function boot(): void
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            Route::middleware('api')
                ->prefix('api')
                ->group(base_path('routes/api.php'));

            Route::middleware('web')
                ->group(base_path('routes/web.php'));
        });
    }

    /**
     * Configure the rate limiters for the application.
     */
    protected function configureRateLimiting(): void
    {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
        });
    }
}

Within the boot() function, inside the $this->routes(function () { block add the following:

if (app()->environment('local')) {
    Route::prefix('dev')
        ->middleware(['web'])
        ->group(base_path('routes/dev.php'));
}

This adds a new set of routes, prefixed by dev, only available when the app environment is set to local. Your app\Providers\RouteServiceProvider.php file now looks like:

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider
{
    /**
     * The path to the "home" route for your application.
     *
     * Typically, users are redirected here after authentication.
     *
     * @var string
     */
    public const HOME = '/home';

    /**
     * Define your route model bindings, pattern filters, and other route configuration.
     */
    public function boot(): void
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            Route::middleware('api')
                ->prefix('api')
                ->group(base_path('routes/api.php'));

            Route::middleware('web')
                ->group(base_path('routes/web.php'));

            if (app()->environment('local')) {
                Route::prefix('dev')
                    ->middleware(['web'])
                    ->group(base_path('routes/dev.php'));
            }
        });
    }

    /**
     * Configure the rate limiters for the application.
     */
    protected function configureRateLimiting(): void
    {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
        });
    }
}

Now you’ll need to create your new dev routes file at routes/dev.php. We’ll move the route created earlier in the routes/web.php file into this new dev.php file so it looks like:

<?php

use App\Mail\InvitationEmail;
use App\Models\User;
use Illuminate\Support\Facades\Route;

Route::prefix('emails')->group(function () {
    /**
     * Invitation Email
     * http://localhost/dev/emails/invitation/{user}
     */
    Route::get('invitation/{user?}', function (User $user = null) {
      $user ??= User::first();
      return new InvitationEmail($user);
    });
});

Now you’ll notice the URL to access this email preview has also changed to http://localhost/dev/emails/invitation/, and it will only be available on your local environment. Any other development URLs that would be handy for you to have can also be added here.

This provides a much faster development workflow when building out your emails, and allows you to see just the information you need by changing the URL.

Questions or comments? Hit me up on Twitter @ractoon