wotz/filament-mail-templates
Filament Mail Templates is a Filament plugin for managing Laravel email templates directly from your admin panel. Create, edit, and organize reusable mail templates with dynamic variables, making transactional email updates faster and easier for your team.
1. Install and publish assets
composer require wotz/filament-mail-templates
php artisan vendor:publish --tag="filament-mail-templates-config"
php artisan vendor:publish --tag="filament-mail-templates-migrations"
php artisan migrate
Publish tags vary by package; if a tag is wrong, run php artisan vendor:publish interactively and pick the provider — that's the fastest way to see exactly what the package ships (config, migrations, views, translations).
2. Register the plugin on your panel
Filament plugins are registered in the panel provider, not the service provider:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(\Wotz\FilamentMailTemplates\FilamentMailTemplatesPlugin::make());
}
If the class name differs, check the package README or composer show wotz/filament-mail-templates and look at the auto-discovered service provider to find the plugin/resource namespace.
3. Where to look first
config/filament-mail-templates.php) — usually holds the model class, table name, editor driver, available placeholders, and mail-sending behaviour. This is your primary extension point.4. First use case: a welcome email
The canonical flow is:
welcome, a subject like Welcome, {{ user.name }}, and a body.MailTemplate::query()
->where('key', 'welcome')
->firstOrFail()
->send(['user' => $user]); // method name depends on the package API
Wire this into a Registered event listener first — it's the smallest end-to-end loop and immediately exposes any placeholder/rendering mismatches.
The core value of a package like this is decoupling mail copy from code. Treat the template key as a contract:
App\Mail\TemplateKey::Welcome).// database/seeders/MailTemplateSeeder.php
MailTemplate::updateOrCreate(
['key' => 'welcome'],
[
'subject' => 'Welcome, {{ user.name }}',
'body' => '<p>Hi {{ user.name }}, thanks for joining.</p>',
],
);
Use updateOrCreate keyed on the slug so re-seeding doesn't duplicate and so production edits survive deploys (see Gotchas).
Wrap the package's send call in one app-level service. That gives you a single place to handle logging, fallbacks, locale selection, and queueing — and a single place to patch when the package API changes on upgrade.
class TemplateMailer
{
public function send(TemplateKey $key, array $data, string|array $to): void
{
$template = MailTemplate::where('key', $key->value)->first();
if (! $template) {
report(new \RuntimeException("Missing mail template [{$key->value}]"));
return;
}
$template->send($data, $to);
}
}
Then call TemplateMailer from listeners, jobs, and Filament actions rather than touching the model directly.
Passing raw Eloquent models into the renderer works until a column is renamed. Prefer flattening to an explicit array:
$mailer->send(TemplateKey::Welcome, [
'user' => [
'name' => $user->name,
'email' => $user->email,
],
'app' => ['name' => config('app.name')],
], $user->email);
Dot-notation placeholders ({{ user.name }}) usually resolve against nested arrays — this shape keeps them working.
Most of these packages ship a preview action on the resource. If yours does, wire its sample-data source to the same DTO shape you use in production, otherwise previews render blank {{ }} and editors lose confidence in the feature. If there's no preview action, add a custom Filament Action that renders the body with a fixture array and dumps it into a modal.
key column to the table and make it searchable; without it, editors can't find templates.RichEditor with a limited toolbar set if the package lets you swap the editor; full toolbars invite inline styles that break dark-mode and Outlook clients.is_active/draft toggle so copy can be staged without going live.If the resource exposes a locale column, drive it from app()->getLocale() at send time and fall back to your default locale when the row is missing — otherwise a missing translation becomes a silent no-mail.
Prefer queueing at the boundary rather than relying on the package:
dispatch(new SendTemplateMail($key, $data, $to));
This keeps web requests fast and gives you retries, which matters because template rendering failures (bad placeholder, missing relation) would otherwise throw during the user's request.
Very small install base (4 stars, no dependents) — pin it. Add an explicit constraint ("wotz/filament-mail-templates": "^1.0") and commit composer.lock. Expect API churn between minor versions; read the diff before composer update. Also verify the Filament major version it supports (v3 vs v4 panel providers differ meaningfully) before installing into an existing panel.
Seeder overwrites vs. production edits. If you seed templates on every deploy with updateOrCreate, you'll silently discard the copy your marketing team edited in the admin. Either seed only when the record is absent (firstOrCreate), or version the seed data and gate it behind a manual command.
Rendering and escaping. Two failure modes are common:
{{ user.name }} containing & renders as & — usually fine, but confusing when the raw HTML editor shows the entity.Missing or extra placeholders fail silently. A {{ user.nmae }} typo typically renders as an empty string, not an exception. Defensive steps:
{{ remains in the rendered subject/body.Config cache. After changing the config file or registering the plugin, run php artisan config:clear (and optimize:clear in CI/deploy). Filament resource/plugin registration also caches route and component discovery — php artisan filament:clear-cached-components (or optimize:clear) fixes "resource not showing up after install".
Migrations on existing databases. Check the published migration for the table name before running — collisions with an existing mail_templates table are a real risk if you've hand-rolled one. If you already have one, either map the package to your table via config or write a data-migration rather than letting the package create a second table.
Panel route/permission leakage. If the plugin auto-registers on all panels (or you forget ->plugin() in a non-admin panel but the routes still resolve), the template editor may be reachable by users who shouldn't see it. Verify with php artisan route:list | grep -i mail-template and add a policy.
Extension points to prefer over forking:
tenant_id, locale, or is_active, then point the config at yours.form()/table() to add fields — cleaner than editing vendor files.Testing. Use Mail::fake() and assert a Mail/Mailable was sent with the expected subject after rendering; also assert the rendered body for one template end-to-end. Since the package is young, an integration test that actually renders and sends each template is your best upgrade safety net.
Low-activity maintenance risk. With 4 stars and no dependents, treat this as a small internal dependency: budget for reading the source (it's likely a few classes), and keep your TemplateMailer wrapper thin enough that replacing the package with a hand-rolled model + Blade renderer is a half-day job, not a rewrite.
How can I help you explore Laravel packages today?