Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Fin Mail Laravel Package

finity-labs/fin-mail

FinMail for Filament is an email template manager and composer. Build, manage, and send emails from your admin panel with token replacement, multilingual templates, themes, versioning, email logging, and a reusable Send Email action.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up (≈10 minutes).

composer require finity-labs/fin-mail
php artisan fin-mail:install

The install command is interactive and does a lot: publishes config + migrations, detects locales from lang/, optionally runs migrations, seeds default templates, registers the plugin in a panel provider, patches your custom theme CSS, sets up Shield permissions, and offers to schedule cleanup of old sent emails. For CI/scripted setups, skip prompts:

php artisan fin-mail:install --locales=en,hu,de --seed

If Composer fails on phpdocumentor/type-resolver, it's the known conflict between phpdocumentor/reflection-docblock 6.x and spatie/laravel-settings. Resolve with:

composer require finity-labs/fin-mail phpdocumentor/reflection-docblock:^5.6 -W

Register the plugin if the installer didn't:

FinMailPlugin::make()
    ->navigationGroup('Communications')
    ->navigationSort(10),

Where to look first, in this order:

  1. config/fin-mail.php — queue connection/name, locales, defaults.
  2. The plugin's Settings page in the admin — sender defaults, branding, logging toggle, attachment rules. These are persisted via spatie/laravel-settings, not the config file.
  3. TemplateMail — the single class you'll use for every send.

First use case: create a template named welcome-email in the admin (Tokens tab + RichEditor body), then send it from anywhere:

Mail::to($user->email)->send(
    TemplateMail::make('welcome-email')->models(['user' => $user])
);

No Mailable class, no Blade file, no deploy. That's the whole value proposition in one snippet — and it's where I'd demo the package to a team.

Implementation Patterns

Treat the template slug as a public API contract. TemplateMail::make('invoice-sent') is a string lookup in the database. Renaming a template in the admin UI silently breaks every call site. Keep slugs stable, and put them in a PHP enum or constants class (EmailTemplate::InvoiceSent) rather than scattering literals. Template versioning protects content, not identity.

Design tokens around your models() map. Tokens resolve as model.attribute paths against the array you pass, so the key names in models() are effectively schema. Keep them aligned with real relations so {{ order.customer.name }} just works. Define every token in the Tokens tab — that's what populates the RichEditor merge-tag picker, which is what lets non-developers edit templates safely.

Drop the action into resources instead of building email UI. This is the highest-leverage pattern:

SendEmailAction::make()
    ->template('invoice-sent')
    ->recipient(fn (Invoice $record) => $record->customer->email)
    ->models(fn (Invoice $record) => ['invoice' => $record, 'customer' => $record->customer])
    ->attachments(fn (Invoice $record) => [
        ['path' => $record->getPdfPath(), 'name' => "Invoice-{$record->number}.pdf"],
    ])
    ->onSent(fn (Invoice $record) => $record->update(['emailed_at' => now()]))

Everything is closure-driven off $record, so it composes with table row actions, header actions, and bulk actions. Use onSent to keep your own emailed_at/status columns in sync rather than polling the log table.

Pair it with HasEmailTemplates for per-record history. Trait on the model, SentEmailsRelationManager in getRelations(), and you get a full audit panel on any resource. Then use the helpers as business logic guards:

if (! $invoice->hasBeenEmailed('invoice-sent')) {
    // send
}

That's a cheap, DB-backed idempotency check for retry-prone jobs — far more reliable than a queue-level ShouldBeUnique alone.

Distinguish tokens from view data. models() feeds token replacement. with() / extraData() feeds the Blade view directly — use it for loops and rich objects you don't want to express as {{ }} paths:

TemplateMail::make('order-confirmation')
    ->models(['user' => $user])
    ->extraData(['orderItems' => $order->items, 'currency' => 'EUR']);

Then @foreach ($orderItems as $item) in the published view. Note that body, preheader, theme, branding are reserved keys.

Use branding overrides instead of duplicating templates. ->withoutLogo() and ->overrideBranding([...]) mean a partner co-brand or an internal plain notice doesn't need its own template. Same for overrideView('emails.custom-layout') when you need a radically different shell.

Be deliberate about logging and body storage. Logging is on when enabled in settings and fires automatically. Override per-send:

  • ->withoutLogging() for high-volume transactional noise.
  • ->withLogging() to force a record when globally disabled.
  • ->withoutStoringRenderedBody() for anything with signed URLs, magic links, or OTPs — the package already does this for its password-reset and verification overrides. Copy that instinct.

Locale at send time, not template duplication. Translatable templates store all locales in one record. Pass locale: 'hu' to make() based on the recipient's preference. This is much cleaner than the foo_en / foo_hu template sprawl teams usually end up with.

Gotchas and Tips

Unresolved tokens are the #1 failure mode. If you omit a model or a path doesn't exist, the leftover {{ user.name }} reaches Blade, which tries to compile it as a PHP echo of an undefined variable. Symptom: a broken or failed send that only appears in production for edge-case records. Mitigations: always use fallbacks ({{ user.name | 'Customer' }}), make token names defensive, and validate in tests with deliberately sparse models.

Conditionals are minimal. The documented syntax is {% if token %}…{% else %}…{% endif %} — truthiness only. There's no evidence of comparison operators or and/or in the README. If you need {% if order.total > 100 %}, don't assume it works: precompute a boolean into models() (e.g. 'is_large_order' => $order->total > 100) and branch on that. Verify against your installed version before designing around it.

Template bodies are admin-authored Blade-adjacent content. Token output is injected into a rendered view, and extraData() variables are live in the template's scope. That's fine when only trusted admins author templates — it is not fine if you ever expose template editing to tenants or end users. Gate the template resource with policies (policyNamespace() exists for Shield) and don't hand-roll a customer-facing template editor on top of this.

Queued status is a liveness probe, not an error. Mail is queued at dispatch and flips to Sent/Failed when a worker processes it. A growing pile of Queued rows means your queue isn't running — a faster diagnosis than digging through failed_jobs. Confirm the queue connection/name in config/fin-mail.php matches your infra, because it's separate from your app default.

Rendered-body storage grows monotonically. Turn on the scheduled cleanup the installer offers, or prune sent_emails yourself. Combine with withoutStoringRenderedBody() on your highest-volume templates.

Use the Sent Emails detail view as your primary debugging tool. It stores the rendered body — you see exactly what the recipient got, tokens and theme applied. That beats re-sending and checking your inbox, and it's the loop I'd teach first: preview in admin → test send → inspect log.

Extending resources: keep the slugs. Subclass and register via ->emailTemplateResource(MyEmailTemplateResource::class); the plugin replaces the built-in one. If you change the slug, override getPages() too, otherwise internal links (and any bookmarks) break.

Custom theme is a two-part setup. The @source '../../../../vendor/finity-labs/fin-mail/resources/views/**/*'; line in your panel theme CSS is mandatory — without it Tailwind won't emit the plugin's classes and the UI looks broken in ways that read like a bug in the package.

Settings live outside the config cache. Because they're spatie/laravel-settings, changing values in the UI won't be picked up if settings caching is stale, and a config:cache in deploy won't touch them. If branding changes "don't apply," clear caches before filing a bug.

Maturity signal: 34 stars, 0 dependents. It supports Filament 4 and 5 and Laravel 11–13, which is a broad matrix for a small package — pin the version in composer.json, read CHANGELOG before upgrades, and wrap your own sends in feature tests. The extension points you actually need are documented (EditorContract for swapping RichEditor → Tiptap/TinyMCE, resource overriding, branding overrides), so you're not likely to be forced into a fork. But treat this as a dependency you'd be comfortable replacing: keep template slugs and token maps in your own code, not just in the database, so a migration off it is a weekend rather than a quarter.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log