Laravel Mailable Preview System: Streamline Email Development and Testing
Developing and testing email templates in Laravel can be tedious when you have to send actual emails to see how they look. Laravel's mailable preview system allows you to view rendered email templates directly in your browser, making email development faster, more efficient, and easier to debug.
Basic Mailable Preview
The simplest way to preview a mailable is to return it directly from a route:
Route::get('/mailable/preview', function () {
$user = App\Models\User::first();
return new App\Mail\WelcomeEmail($user);
});
This renders the mailable in your browser exactly as it would appear in an email client, complete with styling and dynamic content.
Comprehensive Preview System
Preview Route Structure
// routes/web.php (only in development environments)
if (app()->environment(['local', 'staging'])) {
Route::prefix('mail-preview')
->middleware(['auth', 'role:developer'])
->name('mail.preview.')
->group(function () {
Route::get('/', [MailPreviewController::class, 'index'])->name('index');
Route::get('{mailable}', [MailPreviewController::class, 'show'])->name('show');
Route::get('{mailable}/plain', [MailPreviewController::class, 'plain'])->name('plain');
Route::get('{mailable}/raw', [MailPreviewController::class, 'raw'])->name('raw');
Route::post('{mailable}/send-test', [MailPreviewController::class, 'sendTest'])->name('send-test');
});
}
Mail Preview Controller
// app/Http/Controllers/MailPreviewController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\View;
use App\Services\MailPreviewService;
class MailPreviewController extends Controller
{
protected $previewService;
public function __construct(MailPreviewService $previewService)
{
$this->previewService = $previewService;
}
public function index()
{
$mailables = $this->previewService->getAvailableMailables();
return view('mail-preview.index', compact('mailables'));
}
public function show(Request $request, $mailable)
{
if (!$this->previewService->isValidMailable($mailable)) {
abort(404, 'Mailable not found');
}
$mailableInstance = $this->previewService->createMailableInstance($mailable, $request->all());
if (!$mailableInstance) {
abort(404, 'Unable to create mailable instance');
}
// Add development toolbar with mailable info
$metadata = [
'mailable_class' => get_class($mailableInstance),
'subject' => $mailableInstance->subject ?? 'No subject set',
'from' => $mailableInstance->from ?? config('mail.from'),
'to' => $this->previewService->getPreviewRecipient($mailable),
'view' => $this->previewService->getMailableView($mailableInstance),
'data' => $this->previewService->getMailableData($mailableInstance),
];
// Wrap the mailable output with development tools
$mailableOutput = $mailableInstance->render();
return view('mail-preview.wrapper', [
'mailable_output' => $mailableOutput,
'metadata' => $metadata,
'mailable_name' => $mailable,
]);
}
public function plain(Request $request, $mailable)
{
if (!$this->previewService->isValidMailable($mailable)) {
abort(404, 'Mailable not found');
}
$mailableInstance = $this->previewService->createMailableInstance($mailable, $request->all());
// Force plain text view
$mailableInstance->text($this->previewService->getPlainTextView($mailable));
return response($mailableInstance->render())
->header('Content-Type', 'text/plain');
}
public function raw(Request $request, $mailable)
{
if (!$this->previewService->isValidMailable($mailable)) {
abort(404, 'Mailable not found');
}
$mailableInstance = $this->previewService->createMailableInstance($mailable, $request->all());
// Return raw HTML source
return response($mailableInstance->render())
->header('Content-Type', 'text/plain');
}
public function sendTest(Request $request, $mailable)
{
$request->validate([
'email' => 'required|email',
]);
if (!$this->previewService->isValidMailable($mailable)) {
abort(404, 'Mailable not found');
}
$mailableInstance = $this->previewService->createMailableInstance($mailable, $request->all());
try {
Mail::to($request->email)->send($mailableInstance);
return response()->json([
'message' => "Test email sent to {$request->email}",
'status' => 'success'
]);
} catch (Exception $e) {
return response()->json([
'message' => 'Failed to send test email: ' . $e->getMessage(),
'status' => 'error'
], 500);
}
}
}
Mail Preview Service
// app/Services/MailPreviewService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use App\Models\User;
use App\Models\Order;
use App\Models\Invoice;
use ReflectionClass;
class MailPreviewService
{
protected $mailables = [
'welcome' => [
'class' => \App\Mail\WelcomeEmail::class,
'name' => 'Welcome Email',
'description' => 'Sent to new users after registration',
'category' => 'Authentication',
'preview_data' => 'getSampleUser',
],
'password-reset' => [
'class' => \App\Mail\PasswordResetEmail::class,
'name' => 'Password Reset',
'description' => 'Password reset instructions',
'category' => 'Authentication',
'preview_data' => 'getPasswordResetData',
],
'order-confirmation' => [
'class' => \App\Mail\OrderConfirmation::class,
'name' => 'Order Confirmation',
'description' => 'Sent after successful order placement',
'category' => 'E-commerce',
'preview_data' => 'getSampleOrder',
],
'invoice' => [
'class' => \App\Mail\InvoiceGenerated::class,
'name' => 'Invoice Generated',
'description' => 'Monthly invoice for subscription',
'category' => 'Billing',
'preview_data' => 'getSampleInvoice',
],
'newsletter' => [
'class' => \App\Mail\NewsletterEmail::class,
'name' => 'Monthly Newsletter',
'description' => 'Monthly newsletter with updates',
'category' => 'Marketing',
'preview_data' => 'getNewsletterData',
],
'support-ticket' => [
'class' => \App\Mail\SupportTicketCreated::class,
'name' => 'Support Ticket Created',
'description' => 'Confirmation of new support ticket',
'category' => 'Support',
'preview_data' => 'getSampleTicket',
],
'account-verification' => [
'class' => \App\Mail\AccountVerification::class,
'name' => 'Account Verification',
'description' => 'Email verification for new accounts',
'category' => 'Authentication',
'preview_data' => 'getVerificationData',
],
'subscription-renewal' => [
'class' => \App\Mail\SubscriptionRenewal::class,
'name' => 'Subscription Renewal',
'description' => 'Subscription renewal notification',
'category' => 'Billing',
'preview_data' => 'getSubscriptionData',
],
];
public function getAvailableMailables()
{
$grouped = collect($this->mailables)
->groupBy('category')
->map(function ($mailables, $category) {
return [
'category' => $category,
'mailables' => $mailables->toArray(),
];
})
->values();
return $grouped;
}
public function isValidMailable($mailable)
{
return isset($this->mailables[$mailable]);
}
public function createMailableInstance($mailable, $params = [])
{
if (!$this->isValidMailable($mailable)) {
return null;
}
$config = $this->mailables[$mailable];
$class = $config['class'];
// Get sample data for the mailable
$sampleDataMethod = $config['preview_data'];
$sampleData = $this->$sampleDataMethod($params);
// Create mailable instance with sample data
return new $class(...$sampleData);
}
public function getPreviewRecipient($mailable)
{
// Return a default preview recipient
return [
'name' => 'Preview User',
'email' => 'preview@example.com'
];
}
public function getMailableView($mailable)
{
$reflection = new ReflectionClass($mailable);
// Try to extract view name from mailable
if ($reflection->hasProperty('view')) {
$property = $reflection->getProperty('view');
$property->setAccessible(true);
return $property->getValue($mailable);
}
return 'Unknown';
}
public function getMailableData($mailable)
{
$reflection = new ReflectionClass($mailable);
$data = [];
foreach ($reflection->getProperties() as $property) {
if ($property->isPublic()) {
$data[$property->getName()] = $property->getValue($mailable);
}
}
return $data;
}
public function getPlainTextView($mailable)
{
$config = $this->mailables[$mailable];
$viewName = Str::slug($config['name']);
return "emails.{$viewName}.plain";
}
// Sample data methods
private function getSampleUser($params = [])
{
$user = User::factory()->make([
'name' => $params['name'] ?? 'John Doe',
'email' => $params['email'] ?? 'john@example.com',
'created_at' => now(),
]);
return [$user];
}
private function getPasswordResetData($params = [])
{
$user = $this->getSampleUser($params)[0];
$token = 'sample_reset_token_' . Str::random(60);
return [$user, $token];
}
private function getSampleOrder($params = [])
{
$user = $this->getSampleUser($params)[0];
$order = Order::factory()->make([
'order_number' => 'ORD-' . strtoupper(Str::random(8)),
'total' => 99.99,
'status' => 'confirmed',
'created_at' => now(),
]);
$order->setRelation('customer', $user);
$order->setRelation('items', collect([
(object) [
'product_name' => 'Sample Product 1',
'quantity' => 2,
'price' => 29.99,
'total' => 59.98,
],
(object) [
'product_name' => 'Sample Product 2',
'quantity' => 1,
'price' => 39.99,
'total' => 39.99,
],
]));
return [$order];
}
private function getSampleInvoice($params = [])
{
$invoice = Invoice::factory()->make([
'invoice_number' => 'INV-' . strtoupper(Str::random(8)),
'amount' => 49.99,
'due_date' => now()->addDays(30),
'created_at' => now(),
]);
return [$invoice];
}
private function getNewsletterData($params = [])
{
$newsletter = (object) [
'subject' => 'Monthly Newsletter - ' . now()->format('F Y'),
'articles' => [
(object) [
'title' => 'New Feature: Enhanced Dashboard',
'excerpt' => 'We\'ve improved the dashboard with new analytics...',
'url' => 'https://example.com/blog/enhanced-dashboard',
],
(object) [
'title' => 'Customer Spotlight: Success Story',
'excerpt' => 'Learn how our customer increased their revenue...',
'url' => 'https://example.com/blog/customer-spotlight',
],
],
'stats' => (object) [
'new_users' => 1234,
'active_projects' => 5678,
'completed_tasks' => 91011,
],
];
return [$newsletter];
}
private function getSampleTicket($params = [])
{
$user = $this->getSampleUser($params)[0];
$ticket = (object) [
'id' => 12345,
'subject' => 'Help with account setup',
'message' => 'I need assistance with setting up my account...',
'priority' => 'medium',
'created_at' => now(),
];
$ticket->user = $user;
return [$ticket];
}
private function getVerificationData($params = [])
{
$user = $this->getSampleUser($params)[0];
$verificationUrl = 'https://example.com/verify?token=' . Str::random(60);
return [$user, $verificationUrl];
}
private function getSubscriptionData($params = [])
{
$subscription = (object) [
'plan_name' => 'Pro Plan',
'amount' => 29.99,
'renewal_date' => now()->addMonth(),
'billing_cycle' => 'monthly',
];
$user = $this->getSampleUser($params)[0];
return [$user, $subscription];
}
}
Enhanced Preview Interface
Preview Dashboard View
{{-- resources/views/mail-preview/index.blade.php --}}
@extends('layouts.admin')
@section('title', 'Email Template Preview')
@section('content')
<div class="max-w-7xl mx-auto py-6 px-4">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Email Template Preview</h1>
<p class="mt-2 text-gray-600">Preview and test your email templates without sending actual emails.</p>
</div>
<div class="grid gap-8">
@foreach($mailables as $group)
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">{{ $group['category'] }}</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach($group['mailables'] as $key => $mailable)
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-gray-900">{{ $mailable['name'] }}</h3>
<p class="text-sm text-gray-600 mt-1">{{ $mailable['description'] }}</p>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-4">
<a href="{{ route('mail.preview.show', $key) }}"
class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-blue-100 text-blue-800 hover:bg-blue-200">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview
</a>
<a href="{{ route('mail.preview.plain', $key) }}"
class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800 hover:bg-gray-200">
Plain Text
</a>
<button onclick="showTestModal('{{ $key }}')"
class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-green-100 text-green-800 hover:bg-green-200">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
Test
</button>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endforeach
</div>
</div>
<!-- Test Email Modal -->
<div id="testModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-4">Send Test Email</h3>
<form id="testForm">
<div class="mb-4">
<label for="testEmail" class="block text-sm font-medium text-gray-700">Email Address</label>
<input type="email" id="testEmail" name="email" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideTestModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Send Test
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script>
let currentMailable = null;
function showTestModal(mailable) {
currentMailable = mailable;
document.getElementById('testModal').classList.remove('hidden');
}
function hideTestModal() {
document.getElementById('testModal').classList.add('hidden');
currentMailable = null;
}
document.getElementById('testForm').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('testEmail').value;
try {
const response = await fetch(`/mail-preview/${currentMailable}/send-test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ email })
});
const result = await response.json();
if (result.status === 'success') {
alert('Test email sent successfully!');
hideTestModal();
} else {
alert('Failed to send test email: ' + result.message);
}
} catch (error) {
alert('Error sending test email: ' + error.message);
}
});
</script>
@endpush
@endsection
Preview Wrapper View
{{-- resources/views/mail-preview/wrapper.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Preview: {{ $metadata['subject'] ?? 'No Subject' }}</title>
<style>
.preview-toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #1f2937;
color: white;
padding: 12px 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.toolbar-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-info {
display: flex;
align-items: center;
gap: 24px;
}
.toolbar-actions {
display: flex;
gap: 12px;
}
.toolbar-button {
background: #374151;
border: none;
color: white;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.toolbar-button:hover {
background: #4b5563;
}
.email-content {
margin-top: 60px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background: white;
}
.metadata-panel {
background: #f9fafb;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
font-family: monospace;
font-size: 12px;
}
.metadata-row {
display: flex;
margin-bottom: 8px;
}
.metadata-label {
font-weight: bold;
width: 120px;
color: #6b7280;
}
.metadata-value {
color: #1f2937;
}
body {
margin: 0;
background: #f3f4f6;
}
</style>
</head>
<body>
<div class="preview-toolbar">
<div class="toolbar-content">
<div class="toolbar-info">
<strong>Email Preview: {{ $mailable_name }}</strong>
<span>Subject: {{ $metadata['subject'] }}</span>
<span>From: {{ $metadata['from']['address'] ?? $metadata['from'] ?? 'Not set' }}</span>
</div>
<div class="toolbar-actions">
<a href="{{ route('mail.preview.plain', $mailable_name) }}" class="toolbar-button">Plain Text</a>
<a href="{{ route('mail.preview.raw', $mailable_name) }}" class="toolbar-button">View Source</a>
<button onclick="toggleMetadata()" class="toolbar-button">Toggle Info</button>
<a href="{{ route('mail.preview.index') }}" class="toolbar-button">← Back</a>
</div>
</div>
</div>
<div class="email-content">
<div id="metadata-panel" class="metadata-panel" style="display: none;">
<div class="metadata-row">
<div class="metadata-label">Class:</div>
<div class="metadata-value">{{ $metadata['mailable_class'] }}</div>
</div>
<div class="metadata-row">
<div class="metadata-label">View:</div>
<div class="metadata-value">{{ $metadata['view'] }}</div>
</div>
<div class="metadata-row">
<div class="metadata-label">To:</div>
<div class="metadata-value">{{ $metadata['to']['name'] }} <{{ $metadata['to']['email'] }}></div>
</div>
@if(!empty($metadata['data']))
<div class="metadata-row">
<div class="metadata-label">Data:</div>
<div class="metadata-value">
<details>
<summary>View Variables</summary>
<pre style="margin-top: 8px; white-space: pre-wrap;">{{ json_encode($metadata['data'], JSON_PRETTY_PRINT) }}</pre>
</details>
</div>
</div>
@endif
</div>
<div id="email-body">
{!! $mailable_output !!}
</div>
</div>
<script>
function toggleMetadata() {
const panel = document.getElementById('metadata-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
// Auto-adjust iframe heights if present
document.addEventListener('DOMContentLoaded', function() {
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.onload = function() {
try {
iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px';
} catch(e) {
// Handle cross-origin restrictions
console.log('Cannot adjust iframe height due to cross-origin restrictions');
}
};
});
});
</script>
</body>
</html>
Advanced Features
Email Template Variants
// Support for different email template variants
class MailPreviewService
{
public function createMailableInstance($mailable, $params = [])
{
// Support variant parameter for A/B testing
$variant = $params['variant'] ?? 'default';
$config = $this->mailables[$mailable];
$class = $config['class'];
// Get sample data
$sampleDataMethod = $config['preview_data'];
$sampleData = $this->$sampleDataMethod($params);
$instance = new $class(...$sampleData);
// Apply variant if supported
if (method_exists($instance, 'variant')) {
$instance->variant($variant);
}
return $instance;
}
public function getAvailableVariants($mailable)
{
if (!$this->isValidMailable($mailable)) {
return [];
}
$config = $this->mailables[$mailable];
return $config['variants'] ?? ['default'];
}
}
Localization Support
// Preview emails in different locales
Route::get('mail-preview/{mailable}/{locale?}', function($mailable, $locale = 'en') {
if (!in_array($locale, config('app.available_locales'))) {
abort(404, 'Locale not supported');
}
// Set locale for this request
app()->setLocale($locale);
$previewService = app(MailPreviewService::class);
$mailableInstance = $previewService->createMailableInstance($mailable);
if (!$mailableInstance) {
abort(404, 'Mailable not found');
}
// Add locale information to the wrapper
$metadata = [
'locale' => $locale,
'available_locales' => config('app.available_locales'),
// ... other metadata
];
return view('mail-preview.wrapper', [
'mailable_output' => $mailableInstance->render(),
'metadata' => $metadata,
'mailable_name' => $mailable,
]);
});
Responsive Preview Testing
// Add responsive testing capabilities
class MailPreviewController extends Controller
{
public function responsive(Request $request, $mailable)
{
$device = $request->get('device', 'desktop');
$devices = [
'desktop' => ['width' => '800px', 'name' => 'Desktop'],
'tablet' => ['width' => '600px', 'name' => 'Tablet'],
'mobile' => ['width' => '375px', 'name' => 'Mobile'],
];
if (!isset($devices[$device])) {
$device = 'desktop';
}
$mailableInstance = $this->previewService->createMailableInstance($mailable);
return view('mail-preview.responsive', [
'mailable_output' => $mailableInstance->render(),
'current_device' => $device,
'devices' => $devices,
'mailable_name' => $mailable,
]);
}
}
Best Practices
1. Environment Protection
// Always restrict preview routes to development environments
if (app()->environment(['local', 'staging'])) {
Route::prefix('mail-preview')->group(function () {
// Preview routes here
});
}
// Or use middleware for additional security
Route::middleware(['auth', 'role:developer'])->group(function () {
// Preview routes
});
2. Sample Data Management
// Create realistic sample data
private function getSampleOrder($params = [])
{
// Use factories for consistent data
$order = Order::factory()
->has(OrderItem::factory()->count(3), 'items')
->make();
// Override with preview-specific data if needed
if (isset($params['total'])) {
$order->total = $params['total'];
}
return [$order];
}
3. Error Handling
public function show(Request $request, $mailable)
{
try {
$mailableInstance = $this->previewService->createMailableInstance($mailable, $request->all());
if (!$mailableInstance) {
return view('mail-preview.error', [
'error' => 'Unable to create mailable instance',
'mailable' => $mailable,
]);
}
return view('mail-preview.wrapper', [
'mailable_output' => $mailableInstance->render(),
// ... other data
]);
} catch (Exception $e) {
return view('mail-preview.error', [
'error' => $e->getMessage(),
'mailable' => $mailable,
'trace' => app()->environment('local') ? $e->getTraceAsString() : null,
]);
}
}
Conclusion
A comprehensive Laravel mailable preview system dramatically improves email development workflow by allowing you to:
- Preview emails instantly without sending actual messages
- Test different data scenarios with customizable sample data
- Debug email issues with detailed metadata and source views
- Test responsive designs across different device sizes
- Support localization for multi-language applications
- A/B test variants before deployment
This system saves time, reduces email delivery costs during development, and ensures your email templates look perfect before reaching your users. Implement this preview system early in your Laravel projects to streamline email development and create better user experiences.