A filamentPHP plugin to manage email templates for Mailables

Maintainers

Package info

github.com/Blackpig/epitre

Homepage

Issues

pkg:composer/blackpig-creatif/epitre

Fund package maintenance!

BlackpigCreatif

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.1 2026-03-17 13:39 UTC

This package is auto-updated.

Last update: 2026-03-17 13:39:29 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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-translatable
  • lara-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.php
  • resources/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.