jeffersongoncalves/laravel-mail
Complete email management for Laravel: logging, translated database templates with versioning, webhook delivery tracking for five providers, pixel open/click tracking, suppression lists, inline CSS, statistics, and retries.
Installation:
composer require jeffersongoncalves/laravel-mail
php artisan vendor:publish --tag="laravel-mail-migrations"
php artisan migrate
Upgrading to 1.4.0: re-publish the migrations tag so the new
add_tags_to_mail_logs_tablemigration is copied into your app, thenphp artisan migrate. Previously published migrations are not updated automatically.
First Use Case: Send a logged email
Mail::to('[email protected]')->send(new WelcomeMail($user));
mail_logs table.php artisan vendor:publish --tag="laravel-mail-config"
.env:
LARAVEL_MAIL_TRACKING_ENABLED=true
LARAVEL_MAIL_PIXEL_OPEN_TRACKING=true
config/laravel-mail.php:
'campaigns' => [
'enabled' => true,
],
Without this flag, tags are not persisted on mail_logs and campaign reports will be empty.// Create template via Tinker or migration
MailTemplate::create([
'key' => 'welcome',
'subject' => ['en' => 'Welcome!'],
'html_body' => ['en' => '<h1>Hello!</h1>'],
]);
php artisan mail:campaign welcome
Requires that sent mail was tagged (welcome in this example) and that campaigns are enabled.
Workflow:
mail_templates table (supports spatie/laravel-translatable).TemplateMailable:
class WelcomeEmail extends TemplateMailable {
public function templateKey(): string { return 'welcome'; }
public function templateData(): array { return ['name' => $this->user->name]; }
}
Mail::to()->send(new WelcomeEmail($user)).Integration Tip:
PreviewTemplateAction for admin previews:
$preview = app(PreviewTemplateAction::class)->execute($template, ['name' => 'Alice']);
1.4.0 change:
text() as a view name. Plain-text parts defined in mail_templates.text_body render as-is — remove any workaround view files named after template keys.Workflow:
config/laravel-mail.php (e.g., sendgrid.signing_secret).Event::listen(MailBounced::class, function ($event) {
$event->mailLog->suppress(); // Auto-suppress bounces
});
https://app.com/webhooks/mail/sendgrid).Integration Tip:
MailLog::find($id)->trackingEvents to query historical events.Workflow:
'pixel' => [
'open_tracking' => true,
'click_tracking' => true,
],
RouteServiceProvider):
Route::mailTracking(); // Mounts /mail/t/pixel and /mail/t/click
opened/clicked events.Integration Tip:
'pixel' => [
'route_prefix' => 'track',
'route_middleware' => ['throttle:60'],
],
Workflow:
failed (status: failed).php artisan mail:retry --attempts=3
$log = MailLog::where('status', 'failed')->first();
$log->retry();
Integration Tip:
MailLog::failed()->withRetryAttemptsLessThan(3) to query retryable emails.Workflow:
TemplateMailable and use with Notification:
$user->notify(new WelcomeNotification($user));
config/notifications.php:
'channels' => [
'mail' => [
'transport' => 'laravel-mail',
],
],
Integration Tip:
via() in your notification:
public function via($notifiable) {
return ['laravel-mail'];
}
Workflow:
// On the mailable
$mailable = (new WelcomeEmail($user))->tag('welcome');
// Or via the Envelope
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome!',
tags: ['welcome', 'onboarding'],
);
}
mail_logs row.php artisan mail:campaign welcome
Output covers sent, delivered, bounced, complained, distinct opens and clicks, open / click / click-to-open rates, and the most clicked links for that campaign.Integration Tip:
CampaignReport for admin dashboards so you get the same numbers as the Artisan command instead of re-aggregating trackingEvents yourself.welcome + onboarding email shows up in both reports; distinct-open counts keep the numbers honest.Missing tags migration after upgrade (1.4.0):
mail_logs has no tags column.--tag="laravel-mail-migrations", confirm the new add_tags_to_mail_logs_table file landed, then php artisan migrate.Tags silently dropped when campaigns are disabled:
tag() / Envelope tags are only persisted when laravel-mail.campaigns.enabled is true.Webhook Validation Failures:
LARAVEL_MAIL_[PROVIDER]_SIGNING_SECRET in .env (e.g., LARAVEL_MAIL_SENDGRID_SIGNING_SECRET).mail_webhook_attempts table for failed attempts.Pixel Tracking Blocked:
LARAVEL_MAIL_TRACKING_ENABLED=true) as a fallback.Template Versioning Overhead:
mail_template_versions.$template->update(['versioned' => false]);
CSS Inlining Performance:
LARAVEL_MAIL_INLINE_CSS=false
Suppression List Conflicts:
MailSuppression::with('mailLog')->get();
Plain-text body regression check (1.4.0):
text() view-name change alters how text_body renders; templates relying on the old behaviour may now output raw Blade.text_body and inspect the plain-text part.Campaign Numbers Look Wrong:
MailLog::where('tags', 'like', '%welcome%')->count().trackingEvents for the same logs — a mismatch means webhooks/pixels aren't landing.opened event counts.Webhook Debugging:
LARAVEL_MAIL_TRACKING_LOG_WEBHOOKS=true
mail_webhook_attempts table for payloads.Pixel Tracking:
src="/mail/t/pixel/).curl:
curl -v "https://app.com/mail/t/pixel/123?sig=abc"
Template Rendering:
dd(app(PreviewTemplateAction::class)->execute($template, $data));
mail_template_versions.Attachment Storage:
LARAVEL_MAIL_ATTACHMENT_DISK is configured if storing attachments:
LARAVEL_MAIL_ATTACHMENT_DISK=s3
Campaign Reporting:
CampaignReport in your own dashboard/controller and cache the output — aggregation over large mail_logs tables is not free.php artisan mail:campaign [tag] is a good fit for scheduled snapshots; pipe it into Slack/email for weekly campaign digests.Custom Tracking Events:
MailTrackingEvent model or create custom events by listening to MailLogUpdated:
Event::listen(MailLogUpdated::class, function ($event) {
if ($event->mailLog->status === 'custom_status') {
// Dispatch custom event
}
});
Override Models:
php artisan vendor:publish --tag="laravel-mail-models"
MailLog fields:
class CustomMailLog extends MailLog {
protected $casts = [
'custom_field' => 'string',
];
}
MailLog, keep the new tags attribute fillable/castable or campaign reports break.Custom Webhook Handlers:
WebhookHandler service provider:
class CustomWebhookServiceProvider extends WebhookServiceProvider {
protected function registerHandlers() {
$this->app->bind(WebhookHandler::class, CustomWebhookHandler::class);
}
}
Multi-Tenant Scoping:
'multi_tenant' => [
'enabled' => true,
'tenant_model' => \App\Models\Tenant::class,
'tenant_key' => 'id',
],
MailLog::forTenant($tenant) for scoped queries.Attachment Storage:
LARAVEL_MAIL_ATTACHMENT_DISK is set, attachments are stored in mail_attachments table with files on the specified disk.Pixel Signing Key:
APP_KEY if LARAVEL_MAIL_PIXEL_SIGNING_KEY is empty.APP_KEY.Template Layouts:
config/laravel-mail.php:
'templates' => [
'default_layout' => '<html><body>{!! $slot !!}</body></html>',
],
$template->update(['layout' => '<div>{!! $slot !!}</div>']);
Unsubscribe Headers:
{email}) are replaced at send time.Campaign Config Scope:
laravel-mail.campaigns.enabled gates tag persistence only; tracking (webhooks/pixels) still needs its own flags. Enabling campaigns does not turn on open/click collection, so reports can show sends with zero engagement if tracking is off.How can I help you explore Laravel packages today?