name: laravel-mail-development description: Build and work with Laravel Mail features including email logging, database templates (spatie/laravel-translatable), webhook tracking with idempotency, pixel tracking (provider-independent open & click tracking), suppression lists, inline CSS, List-Unsubscribe headers, browser preview, statistics, notification channels, retry, attachment storage, prune policies, and CLI commands.
Laravel Mail Development
When to use this skill
Use this skill when:
- Setting up email logging and configuring what gets stored
- Creating database email templates with multi-locale support (spatie/laravel-translatable)
- Configuring webhook delivery tracking for email providers
- Setting up pixel tracking (provider-independent open & click tracking)
- Implementing suppression lists for bounced/complained addresses
- Setting up browser preview for sent emails and templates
- Querying email statistics and building dashboards
- Using the notification channel with database templates
- Configuring email retry for failed/bounced sends
- Associating mail logs with models via polymorphic relations
- Configuring inline CSS for email client compatibility
- Setting up List-Unsubscribe headers for Gmail/Yahoo compliance
- Storing email attachment files to disk
- Configuring per-status prune retention policies
- Using CLI commands (mail:send-test, mail:templates, mail:stats)
Database Schema
mail_logs
uuid id PK
string mailer
string subject
json from, to, cc, bcc, reply_to -- [{email, name}]
longText html_body, text_body
json headers, attachments, metadata
string status (pending|sent|delivered|bounced|complained|failed)
string provider_message_id (indexed)
nullableUuidMorphs mailable (mailable_type, mailable_id)
foreignUuid mail_template_id -- auto-set when sent via TemplateMailable
string tenant_id
timestamps
mail_templates
uuid id PK
string key (unique)
string name
string mailable_class
json subject, html_body, text_body -- spatie/laravel-translatable (HasTranslations trait)
json variables -- [{name, type, example}]
string layout
string tenant_id
boolean is_active
timestamps
mail_template_versions
uuid id PK
foreignUuid mail_template_id (cascade delete)
json subject, html_body, text_body -- snapshots via getTranslations()
string change_note, author
unsignedInteger version_number
timestamp created_at
mail_tracking_events
uuid id PK
foreignUuid mail_log_id (cascade delete)
string type (delivered|bounced|opened|clicked|complained|deferred)
string provider (ses|sendgrid|postmark|mailgun|resend|pixel)
string provider_event_id (nullable, indexed) -- idempotency key
json payload
string recipient, url, bounce_type
timestamp occurred_at, created_at
mail_suppressions
uuid id PK
string email (indexed)
string reason (hard_bounce|complaint|manual)
string provider
foreignUuid mail_log_id (nullable)
timestamp suppressed_at
string tenant_id
unique(email, tenant_id)
Creating Templates
use JeffersonGoncalves\LaravelMail\Models\MailTemplate;
$template = MailTemplate::create([
'key' => 'order-confirmation',
'name' => 'Order Confirmation',
'subject' => [
'en' => 'Order #{{ $order_number }} confirmed',
'pt_BR' => 'Pedido #{{ $order_number }} confirmado',
],
'html_body' => [
'en' => '<h1>Thank you, {{ $name }}</h1><p>Order #{{ $order_number }}</p>',
'pt_BR' => '<h1>Obrigado, {{ $name }}</h1><p>Pedido #{{ $order_number }}</p>',
],
'variables' => [
['name' => 'name', 'type' => 'string', 'example' => 'John'],
['name' => 'order_number', 'type' => 'string', 'example' => '12345'],
],
'is_active' => true,
]);
Translation API (spatie/laravel-translatable)
// MailTemplate uses HasTranslations trait
// Translatable fields: subject, html_body, text_body
// Get for current locale
$template->subject; // Returns string for app()->getLocale()
// Get for specific locale (with fallback)
$template->getSubjectForLocale('pt_BR');
$template->getHtmlBodyForLocale('en');
$template->getTextBodyForLocale('es');
// Get all translations as array
$template->getTranslations('subject'); // ['en' => '...', 'pt_BR' => '...']
// Set translation for a specific locale
$template->setTranslation('subject', 'fr', 'Bienvenue !');
$template->save();
Creating a TemplateMailable
use JeffersonGoncalves\LaravelMail\Mail\TemplateMailable;
use Illuminate\Mail\Mailables\Content;
class OrderConfirmationEmail extends TemplateMailable
{
public function __construct(
public Order $order,
) {}
public function templateKey(): string
{
return 'order-confirmation';
}
public function templateData(): array
{
return [
'name' => $this->order->customer->name,
'order_number' => $this->order->number,
];
}
protected function fallbackSubject(): string
{
return "Order #{$this->order->number} confirmed";
}
protected function fallbackContent(): Content
{
return new Content(view: 'emails.order-confirmation', with: ['order' => $this->order]);
}
}
TemplateMailable features:
- Auto-resolves template by key from database
- Renders Blade syntax in subject/body with provided data
- Applies layout wrapping (per-template or default from config)
- Inlines CSS automatically when
templates.inline_cssis enabled - Injects
X-LaravelMail-TemplateIDheader for auto-association with MailLog - Adds
List-Unsubscribeheaders whentemplates.unsubscribe.enabledis true - Supports
Queueable(implementsShouldQueuepattern) - Falls back to view-based content when template not found
Listening to Tracking Events
use JeffersonGoncalves\LaravelMail\Events\MailBounced;
use JeffersonGoncalves\LaravelMail\Events\MailDelivered;
// In EventServiceProvider or listener
Event::listen(MailBounced::class, function (MailBounced $event) {
// $event->mailLog — the MailLog record
// $event->trackingEvent — the MailTrackingEvent with bounce_type, recipient, etc.
$recipient = $event->trackingEvent->recipient;
$bounceType = $event->trackingEvent->bounce_type;
Log::warning("Bounce ({$bounceType}): {$recipient}");
});
Available events: MailDelivered, MailBounced, MailComplained, MailOpened, MailClicked, MailDeferred.
Webhook Idempotency
Each webhook handler extracts a provider_event_id:
- SES: SNS
MessageId - SendGrid:
sg_event_id - Postmark:
{MessageID}-{RecordType} - Mailgun:
event-data.id - Resend: Svix
svix-idheader
Duplicate webhooks are detected via firstOrCreate on provider_event_id. When a duplicate is found, no tracking event is created, no status update, no Laravel event dispatched.
Pixel Tracking (Provider-Independent)
Track opens and clicks without provider webhooks. Works with any mailer including plain SMTP.
// config/laravel-mail.php
'tracking' => [
'pixel' => [
'open_tracking' => env('LARAVEL_MAIL_PIXEL_OPEN_TRACKING', false),
'click_tracking' => env('LARAVEL_MAIL_PIXEL_CLICK_TRACKING', false),
'route_prefix' => 'mail/t',
'route_middleware' => [],
'signing_key' => env('LARAVEL_MAIL_PIXEL_SIGNING_KEY'), // null = uses APP_KEY
],
],
How It Works
InjectTrackingPixellistener onMessageSendingmodifies HTML body- Open tracking: Injects
<img>1x1 transparent GIF before</body> - Click tracking: Rewrites
<a href>links through/mail/t/click/{id}endpoint - Pixel loads →
MailTrackingEventrecorded withprovider=pixel,MailOpeneddispatched - Link clicked →
MailTrackingEventrecorded,MailClickeddispatched, 302 redirect to original URL
Key Classes
Services\PixelTracker— Injects pixel, rewrites links, generates/verifies HMAC-signed URLsHttp\Controllers\TrackingController— Serves GIF pixel, handles click redirectsListeners\InjectTrackingPixel— Listener onMessageSendingthat callsPixelTrackerServices\TrackingEventRecorder— Shared service for recording events (used by both webhooks and pixel)
Security
- HMAC-SHA256 signed URLs prevent forgery
- Click redirects validate URL scheme (blocks
javascript:,data:,vbscript:) mailto:,tel:,sms:,#anchorlinks are never rewritten- Pixel responses:
Cache-Control: no-storeprevents caching
Routes
| Endpoint | Purpose |
|---|---|
GET /mail/t/pixel/{id}?sig=... |
Serve 1x1 GIF, record open |
GET /mail/t/click/{id}?url=...&sig=... |
Record click, redirect |
Coexists with webhook tracking — both register in mail_tracking_events with different provider values.
Previewing Templates
use JeffersonGoncalves\LaravelMail\Actions\PreviewTemplateAction;
$action = new PreviewTemplateAction();
$preview = $action->execute($template, ['name' => 'Alice'], 'en');
// Returns: ['subject' => 'rendered subject', 'html' => 'rendered html (CSS inlined)', 'text' => null]
Using the Notification Channel
use JeffersonGoncalves\LaravelMail\Channels\TemplateMailChannel;
use Illuminate\Notifications\Notification;
class WelcomeNotification extends Notification
{
public function via($notifiable): array
{
return [TemplateMailChannel::class];
}
public function toTemplateMail($notifiable): array
{
return [
'template_key' => 'welcome',
'data' => ['name' => $notifiable->name],
'locale' => $notifiable->preferred_locale,
];
}
}
// Send it
$user->notify(new WelcomeNotification());
Retrying Failed Emails
use JeffersonGoncalves\LaravelMail\Actions\RetryFailedMailAction;
$action = new RetryFailedMailAction();
$success = $action->execute($failedMailLog);
// Checks retry.max_attempts, resends via ResendMailAction, increments metadata.retry_count
Polymorphic Association
use JeffersonGoncalves\LaravelMail\Traits\HasMailLogs;
class User extends Model
{
use HasMailLogs;
}
// Query
$user->mailLogs()->where('status', 'delivered')->latest()->get();
CLI Commands
| Command | Description |
|---|---|
mail:prune |
Delete old mail logs (--days, or uses per-status policies) |
mail:retry |
Retry failed/bounced emails (--status, --hours, --limit) |
mail:unsuppress {email} |
Remove address from suppression list |
mail:send-test {key} {email} |
Send test email using template (--locale, --data) |
mail:templates |
List all templates in a table |
mail:stats |
Show email statistics (--days, default 7) |
Send Test Email via CLI
# Uses example data from template variables
php artisan mail:send-test welcome user@example.com
# With specific locale
php artisan mail:send-test welcome user@example.com --locale=pt_BR
# With custom JSON data
php artisan mail:send-test welcome user@example.com --data='{"name":"Alice"}'
Configuration Reference
| Key | Default | Description |
|---|---|---|
logging.enabled |
true |
Enable email logging |
logging.store_html_body |
true |
Store HTML body |
logging.store_text_body |
true |
Store text body |
logging.store_attachments |
true |
Store attachment metadata |
logging.store_attachment_files |
false |
Store actual attachment files to disk |
logging.attachments_disk |
'local' |
Filesystem disk for attachments |
logging.attachments_path |
'mail-attachments' |
Path prefix for stored attachments |
prune.enabled |
true |
Enable pruning |
prune.older_than_days |
30 |
Default days to keep logs |
prune.policies |
null |
Per-status retention: ['delivered' => 30, 'bounced' => 90] |
tracking.enabled |
false |
Enable webhook tracking |
tracking.pixel.open_tracking |
false |
Inject tracking pixel for opens |
tracking.pixel.click_tracking |
false |
Rewrite links for click tracking |
tracking.pixel.route_prefix |
'mail/t' |
URL prefix for pixel routes |
tracking.pixel.signing_key |
null |
HMAC key (null = APP_KEY) |
suppression.enabled |
false |
Enable suppression list |
suppression.auto_suppress_hard_bounces |
true |
Auto-suppress hard bounces |
suppression.auto_suppress_complaints |
true |
Auto-suppress complaints |
retry.enabled |
false |
Enable retry |
retry.max_attempts |
3 |
Max retry attempts |
preview.enabled |
false |
Enable browser preview |
preview.signed_urls |
true |
Require signed URLs |
templates.enabled |
true |
Enable database templates |
templates.inline_css |
true |
Inline CSS in template HTML |
templates.unsubscribe.enabled |
false |
Add List-Unsubscribe headers |
templates.unsubscribe.url |
null |
Unsubscribe URL ({email} placeholder) |
templates.unsubscribe.mailto |
null |
Unsubscribe mailto address |
tenant.enabled |
false |
Enable multi-tenancy |
Troubleshooting
Emails not being logged
Cause: Logging disabled or listeners not registered.
Solution: Check LARAVEL_MAIL_LOGGING_ENABLED=true in .env. Ensure the service provider is auto-discovered.
mail_template_id not set on MailLog
Cause: Email was not sent via TemplateMailable.
Solution: Only emails sent through classes extending TemplateMailable get the template ID auto-linked via the X-LaravelMail-TemplateID header.
Webhook returning 503
Cause: Tracking globally disabled.
Solution: Set LARAVEL_MAIL_TRACKING_ENABLED=true and enable the specific provider in config.
Duplicate tracking events
Cause: Should not happen with idempotency. If it does, check that provider_event_id is being extracted.
Solution: Each handler extracts a unique ID from the payload. If the provider doesn't send one, fallback uses mail_log_id + type + provider + occurred_at.
Template not rendering variables
Cause: Template uses Blade syntax but data not passed.
Solution: Ensure templateData() returns all variables used in the template body. Check the template is is_active.
CSS not being inlined
Cause: Inline CSS disabled in config.
Solution: Set LARAVEL_MAIL_INLINE_CSS=true in .env or templates.inline_css => true in config.
Preview URL returns 404
Cause: Preview feature disabled.
Solution: Set LARAVEL_MAIL_PREVIEW_ENABLED=true in .env.
Suppression not blocking sends
Cause: Suppression disabled or listener not registered.
Solution: Set LARAVEL_MAIL_SUPPRESSION_ENABLED=true. The CheckSuppression listener is only registered when suppression is enabled at boot time.
Attachment files not stored
Cause: File storage disabled.
Solution: Set LARAVEL_MAIL_STORE_ATTACHMENT_FILES=true. Configure attachments_disk for your preferred storage.