blackpig-creatif / epitre
A filamentPHP plugin to manage email templates for Mailables
Fund package maintenance!
Requires
- php: ^8.2|^8.3|^8.4
- filament/filament: ^5.0
- lara-zeus/spatie-translatable: ^2.0
- spatie/laravel-package-tools: ^1.15.0
Requires (Dev)
- filament/blueprint: ^2.1
- larastan/larastan: ^3.0
- laravel/boost: ^2.3
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
- rector/rector: ^2.0
- spatie/laravel-ray: ^1.26
This package is auto-updated.
Last update: 2026-03-17 13:39:29 UTC
README
An editable email copy layer for Laravel Mailables, managed through a Filament v5 panel.
Epitre sits between your Mailable classes and their output. Each template has a Blade view as its default. Editors can override the subject and body per locale through the Filament panel without touching code. If no DB record exists, the Blade view is used as-is.
Requirements
- PHP 8.2+
- Laravel 11+
- Filament 5.0+
spatie/laravel-translatablelara-zeus/spatie-translatable(Filament translatable plugin)
Installation
composer require blackpig-creatif/epitre
Run the migration:
php artisan migrate
Register the plugin in your PanelProvider:
use BlackpigCreatif\Epitre\EpitrePlugin; public function panel(Panel $panel): Panel { return $panel ->plugins([ EpitrePlugin::make(), ]); }
Epitre will register SpatieTranslatablePlugin automatically if it is not already present. It reads your locale list from config('app.locales') if that key exists (expected format: ['en' => 'English', 'fr' => 'Francais']), falling back to app()->getLocale().
Quick Start
1. Generate a template class
php artisan epitre:make-template ContactConfirmation
This creates:
app/BlackpigCreatif/Epitre/Templates/ContactConfirmationTemplate.phpresources/views/mail/epitre/contact-confirmation.blade.php
2. Define tokens and resolution logic
Open the generated class and fill in the tokens your template uses:
namespace App\BlackpigCreatif\Epitre\Templates; use BlackpigCreatif\Epitre\Support\EpitreTemplate; class ContactConfirmationTemplate extends EpitreTemplate { protected string $key = 'contact-confirmation'; protected string $label = 'Contact Confirmation'; protected string $view = 'mail.epitre.contact-confirmation'; /** @var array<string, string> */ protected array $tokens = [ '{name}' => 'The recipient\'s name', '{message}' => 'The message they submitted', ]; /** @return array<string, string> */ public function resolve(array $data): array { return [ '{name}' => $data['name'], '{message}' => $data['message'], ]; } }
3. Register the template in your service provider
use BlackpigCreatif\Epitre\Facades\Epitre; use App\BlackpigCreatif\Epitre\Templates\ContactConfirmationTemplate; public function boot(): void { Epitre::register(ContactConfirmationTemplate::class); }
4. Wire up your Mailable
Add the HasEpitreTemplate trait and implement the two required members:
use BlackpigCreatif\Epitre\Concerns\HasEpitreTemplate; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Attachment; class ContactConfirmation extends Mailable { use HasEpitreTemplate; protected string $epitreKey = 'contact-confirmation'; public function __construct( public string $name, public string $message, ) {} public function epitreData(): array { return [ 'name' => $this->name, 'message' => $this->message, ]; } public function attachments(): array { return []; } }
The trait provides envelope() and content(). Do not implement those methods yourself.
Converting an Existing Mailable
If you have a Mailable already in production, the migration is straightforward.
Before:
class OrderShipped extends Mailable { public function __construct(public Order $order) {} public function envelope(): Envelope { return new Envelope(subject: 'Your order has shipped'); } public function content(): Content { return new Content(view: 'mail.order-shipped'); } public function attachments(): array { return []; } }
Steps:
1. Generate the template class, pointing at your existing Blade view:
php artisan epitre:make-template OrderShipped
Open the generated class and set $view to your existing view, define any tokens, and implement resolve():
class OrderShippedTemplate extends EpitreTemplate { protected string $key = 'order-shipped'; protected string $label = 'Order Shipped'; protected string $view = 'mail.order-shipped'; // your existing view, unchanged protected array $tokens = [ '{order_number}' => 'The order reference number', '{customer_name}' => 'The recipient\'s name', ]; public function resolve(array $data): array { return [ '{order_number}' => $data['order_number'], '{customer_name}' => $data['customer_name'], ]; } }
2. Register the template in your service provider:
Epitre::register(OrderShippedTemplate::class);
3. Update the Mailable. Remove envelope() and content(), add the trait and the two required members:
use BlackpigCreatif\Epitre\Concerns\HasEpitreTemplate; class OrderShipped extends Mailable { use HasEpitreTemplate; protected string $epitreKey = 'order-shipped'; public function __construct(public Order $order) {} public function epitreData(): array { return [ 'order' => $this->order, // still available in the Blade view 'order_number' => $this->order->reference, 'customer_name' => $this->order->customer->name, ]; } public function attachments(): array { return []; } }
Your existing Blade view continues to work unchanged since no DB record exists yet. Editors can customise the copy through the panel at any point, and the Blade view remains the fallback if they haven't.
How Resolution Works
When a Mailable using HasEpitreTemplate is sent, Epitre resolves the content in this order:
Subject: If a DB record exists with a subject for the current locale, it is used with tokens replaced. Otherwise, the template $label is used as the subject.
Body: If a DB record exists with a body for the current locale, it is rendered as an HTML string with tokens replaced. If a $layout is set on the template, the resolved HTML is passed to the layout view as $body instead. Otherwise the body is returned as a raw htmlString with no wrapping. If no DB record exists, the Blade view is rendered via Content(view: ...) with epitreData() passed as view data.
This means your Blade view is always the working default. Editors only override when they want to.
Template Classes
All template classes extend EpitreTemplate:
abstract class EpitreTemplate { protected string $key; // unique dot-notation or kebab identifier protected string $label; // displayed in the Filament panel protected string $view; // Blade view path for the default content protected ?string $layout; // optional layout view for DB-stored content protected array $tokens; // '{token}' => 'Description for editors' abstract public function resolve(array $data): array; }
The $tokens array is informational only. It is displayed in the Filament editor sidebar so editors know what substitutions are available. The resolve() method maps token strings to their runtime values given the data array from epitreData().
Layouts
When editors save content through the Filament panel, Epitre renders it as HTML with tokens replaced. Without a layout, this is returned as a raw htmlString — no wrapping, no styling.
If your emails use a mail layout (Laravel's <x-mail::message>, a custom component, or any Blade view), set $layout on the template to a Blade view path. Epitre will render that view with two variables available: $body (the resolved HTML) and everything from epitreData().
class OrderShippedTemplate extends EpitreTemplate { protected string $view = 'mail.order-shipped'; protected ?string $layout = 'mail.layouts.epitre'; // ... }
Create the layout view:
{{-- resources/views/mail/layouts/epitre.blade.php --}} <x-mail::message> {!! $body !!} </x-mail::message>
Epitre renders layout views through Laravel's mail rendering pipeline, which is the only way <x-mail::message> and related components are available. This pipeline also inlines CSS from your mail theme into the output, which is necessary for email client compatibility. Your stored HTML body is output raw via {!! $body !!} — nothing is converted to or processed as Markdown. The pipeline name is a Laravel implementation detail you do not need to think about.
The $layout only applies when a DB record exists. The $view default continues to use its own layout as normal.
Filament Resource
Registering EpitrePlugin adds an Email Templates resource to your panel.
List view shows all registered templates with their current status:
| Status | Meaning |
|---|---|
| Using default | No DB record exists, Blade view is active |
| Customised | A DB record overrides the subject and/or body |
Edit view lets editors set the subject and body per locale. The sidebar shows the available tokens for that template. Saving creates or updates the DB record. Leaving a field empty falls back to the Blade view default.
Reset to default (visible only when a DB record exists) deletes the record and restores the Blade view default. Requires confirmation.
Translation
Subject and body are stored as translatable JSON columns via spatie/laravel-translatable. The Filament editor uses the locale switcher from lara-zeus/spatie-translatable to manage per-locale content.
Configure your available locales in config/app.php:
'locales' => [ 'en' => 'English', 'fr' => 'Francais', ],
Epitre reads this at boot time to configure the translatable plugin. If you are already registering SpatieTranslatablePlugin yourself with locales set, Epitre will not overwrite them.
Testing
composer test
Changelog
Please see CHANGELOG for recent changes.
Credits
License
MIT. See LICENSE for details.