backstage/filament-mails
Filament Mails adds a searchable mail log to your Filament admin panel, letting you browse, preview and inspect emails sent by your Laravel app, review recipients, subjects and content, and debug delivery issues without leaving the dashboard.
Install and publish everything before you migrate — and verify the package name first.
composer require backstage/mails
php artisan vendor:publish --tag="mails-config"
php artisan vendor:publish --tag="mails-migrations"
php artisan migrate
Check the actual composer name and publish tags with composer show backstage/mails and php artisan vendor:publish --list before copying commands from a blog post. The vendor namespace is Backstage\Mails\... and the release notes refer to the package as backstage/mails, while older docs and the Filament plugin class (Backstage\FilamentMails\FilamentMailsPlugin) suggest backstage/filament-mails. Run composer show | grep -i backstage once and write the correct string in your README — you will otherwise waste an afternoon on a composer require that resolves to nothing.
Confirm your Filament major before anything else. As of v3.0.15 this package tracks filament/filament: ^5.0 (the fix targets v5.7.0+ behaviour). If you are still on Filament v3 or v4, this release line is not for you — pin an older package tag rather than mixing majors.
Then register the plugin on the panel you want it on — not on every panel:
// app/Providers/Filament/AdminPanelProvider.php
use Backstage\FilamentMails\FilamentMailsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentMailsPlugin::make());
}
Where to look first (in this order):
composer.json of the package — check the filament/filament constraint and the exact tag you resolved. With a package this small, the constraint is the compatibility contract.config/filament-mails.php (or mails.php, per the publish tag you actually got) — enable/disable, model swapping, table and pruning options live here. Read it fully before changing anything.text vs longtext vs json) and whether attachments are stored inline. This determines your retention strategy later.Mail model and the listener that writes rows — that's your integration seam for anything custom.First use case: stop relying on MAIL_MAILER=log + storage/logs/laravel.log grep sessions. Keep your real mailer (SMTP/Postmark/SES), let the package persist each outgoing message via Laravel's MessageSent event, and browse recipients, subject, headers, and rendered HTML in the panel. This is most valuable in staging and in a gated production panel, where "did the customer actually get the invoice mail?" is otherwise unanswerable without asking them.
Gate the panel, then gate the resource. The plugin exposes mail bodies — password reset links, magic links, invoices, PII. Two layers:
->plugin(
FilamentMailsPlugin::make()
->navigationGroup('System')
->navigationLabel('Outbox')
)
and a policy (or the plugin's canAccess()/visible() hook, depending on version) restricting it to a super-admin role. Do this on day one, not after the first security review.
Make it observable, not just browsable. Turn the resource into an operations tool:
Resending is your code, not theirs. To re-send you need to reconstruct the Mailable, not just replay the stored body. If resend matters, log a discriminator alongside the package's row (a mailable_class + serialized payload column via a MessageSending listener) and build a Filament action that rehydrates it. Don't try to fake it from stored HTML — you'll lose headers, attachments, and tracking.
Attachments: store on disk, not in the row. If the package (or your config) inlines base64 attachments, a single 10 MB PDF becomes a 13 MB row and your mails table becomes the largest thing in the database. Prefer a custom model that writes attachments to a private disk and stores paths.
Multi-tenancy needs a scope. Out of the box the resource is global. In a multi-tenant panel, override the resource's query to constrain on tenant_id (or filter by the authenticated user's team), otherwise tenant A reads tenant B's mail. Same for a customer-facing panel: never register this plugin there.
Prune on a schedule. Mail bodies have no long-term value beyond debugging. Add $this->artisan('model:prune') to the scheduler with a 30/90-day window, or a Prunable trait on your custom model. This is the single most common cause of "why is the DB 40 GB?" after adopting a mail logger.
Subclass pages defensively. If you extend ListMails or any vendor page to add actions, match the widest signature the parent accepts, and mirror the pattern v3.0.15 adopted:
public function unmountAction(bool|string|null $cancelParentActions = null): void
Forward only the arguments you actually received; do not re-supply parent defaults. Filament changes these signatures in minor releases, and a narrower override in your subclass will fatal at discovery time exactly like the bug this release fixed.
Testing. Mail::fake() swaps the transport, so MessageSent never fires and nothing is logged — your feature tests will silently skip the integration. Either assert against Mail::assertSent() (correct for most cases) or unit-test the listener directly with a synthetic MessageSent event.
unmountAction(bool $canCancelParentActions = true) to bool|string|null $cancelParentActions = null and the package's ListMails override no longer matched, killing Laravel at package discovery — before any of your code runs. Symptom: php artisan dies with a "Declaration of ... must be compatible with ..." fatal. Fix on the package side is composer update backstage/mails; fix on your side is to audit your own overrides the same way. When you see this class of error, it is never your application logic — it is a signature mismatch between a vendor page and a newer Filament.composer update, and keep filament/filament pinned rather than floating ^5.0, or a routine composer update on Filament will pull the package into a state you did not test.MessageSent fires after a successful send. A mail that throws in the transport, or dies in a failed queue job, leaves no row. If you need failure visibility, hook MessageSending (write a pending row) and mark it sent/failed from the job's failed() callback — or just pair this with filament-failed-jobs-style monitoring.<iframe srcdoc> or through an HTML sanitizer (e.g. mews/purifier). Never drop stored mail into {!! !!} in a Livewire component.^) for this package, read the diff of the page classes before upgrading, and assume you may need to fork — MIT makes that painless.migrate leaves you with a mismatched schema and a confusing "table not found."mysql_logs connection so mail bloat never competes with your app tables for backups or disk alarms.How can I help you explore Laravel packages today?