Search by

dophp / laravel-mail-builder

do-io

This package is abandoned and no longer maintained. The author suggests using the getodden/mail package instead.

Responsive, slot-based email builder and compiler for Laravel: modular blocks, MJML/HTML import, merge tags, AMP, plain-text fallback, and pre-send audits.

Package info

github.com/getodden/mail

Homepage

pkg:composer/dophp/laravel-mail-builder

Statistics

Installs: 216

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.5.3 2026-10-20 15:39 UTC

README

Latest version License: MIT PHP

A modular, Outlook-bulletproof email building, auditing, and compilation engine for Laravel applications.

Build an email from 29 responsive blocks (in code, in a Filament form, or in a drag-and-drop editor), audit it before it goes out, and compile it to inlined HTML, AMP for Email, MJML, or plain text. It is a plain Laravel package: the compiler is PHP, with no Node step at send time.

The drag-and-drop editor: adding a quote block, typing its text, moving it up the email and switching to the mobile preview

Try the editor in your browser: it is the one in this package, with nothing saved or sent.

Requires PHP 8.3+ and Laravel 12 or 13. Maintained by CaskStack, LLC.

Status: All 29 slot types are rendered by the compiler, described in the slot schema, and editable in both the Filament builder and the drag-and-drop editor.

Key Features

  • 29 Modular Responsive Slots: Pre-built responsive components that collapse smoothly to 100% width on mobile devices, with Microsoft Outlook MSO conditional table compatibility.
  • Universal Compilation Engine: Compile modular slots or structured EmailDocument instances into inlined responsive HTML (EmailSlotCompiler) or interactive AMP for Email (AmpEmailCompiler).
  • Liquid-Style Personalization: Fast token interpolation supporting uppercase, lowercase, capitalize, date formatting, currency, number, pluralize, default values, and inline conditionals ({% if condition %}...{% else %}...{% endif %}).
  • Deliverability & Spam Auditor: Automatic pre-flight auditing scoring unsubscribe links, image alt text, subject lines, mailing address, Gmail clipping, Outlook rendering and WCAG contrast, and live DNS deliverability checks (SPF, DMARC, BIMI).
  • Multi-Device Screenshot Simulation: Adapter interface for testing email rendering across Outlook Windows (120 DPI), Apple Mail iOS 17 Dark Mode, macOS Sonoma, and Gmail Android.
  • Theme & Typography Engine: Built-in design system presets (Corporate Slate, Midnight Indigo, Emerald SaaS, Warm Sunset, Monochrome Minimal) with automatic Google Fonts web font imports and Outlook fallback font stacks.
  • Bidirectional Ingestion: Export templates to HTML, Plain Text, MJML, or ZIP packages—and reverse-parse external raw HTML or standard MJML into native modular slots.
  • WCAG 2.1 AA Contrast Auditor: Exact mathematical relative luminance algorithm evaluating text and button contrast compliance.
  • Enterprise Transport: Send via TemplateMailable with RFC 8058 1-click unsubscribe headers, custom attachments, and automatic CID (Content-ID) inline image embedding.
  • Filament Visual Builder: Full Filament builder schema component with audience targeting and conditional visibility rules.
  • Drag-and-Drop Editor: A framework-free web component with a palette, a live preview rendered by the real compiler, undo and redo, and keyboard and screen reader support for every action.

Installation

composer require getodden/mail

Publish configuration and views (optional):

php artisan vendor:publish --tag=mail-builder-config
php artisan vendor:publish --tag=mail-builder-views

Configuration

config/mail-builder.php sets the default layout theme (width, fonts, colors) and the footer identity used when a slot doesn't provide one:

MAIL_BUILDER_COMPANY_NAME="Acme Inc."   # defaults to APP_NAME
MAIL_BUILDER_ADDRESS="123 Example St, Springfield"

CAN-SPAM and similar laws require a physical mailing address in marketing email, so set MAIL_BUILDER_ADDRESS or pass address to each footer slot.

Quick Start

1. Building and Compiling an Email

use Odden\MailBuilder\MailBuilder;
use Odden\MailBuilder\Enums\SlotType;

// Fluent EmailDocument API
$doc = MailBuilder::document(
    subject: 'Welcome to the Platform, {{ contact.first_name }}!',
    previewText: 'Get started with your new account in 3 easy steps.'
);

$doc->append(SlotType::Header, [
    'brand_name' => 'Acme Corp',
    'logo_url' => 'https://example.com/logo.png',
]);

$doc->append(SlotType::Hero, [
    'title' => 'Accelerate Your Workflow',
    'subtitle' => 'Everything you need to deliver world-class projects.',
    'button_text' => 'Get Started',
    'button_url' => 'https://example.com/onboarding',
]);

$doc->append(SlotType::TwoColumn, [
    'left_title' => 'Cloud Infrastructure',
    'left_body' => 'Deploy high-availability services across 30+ edge regions.',
    'right_title' => 'RevOps Analytics',
    'right_body' => 'Real-time pipeline tracking and automated revenue forecasting.',
    'reverse_stack_on_mobile' => true,
]);

$doc->append(SlotType::AppBadges, [
    'heading' => 'Download Our Mobile App',
    'app_store_url' => 'https://apps.apple.com/app/acme',
    'google_play_url' => 'https://play.google.com/store/apps/acme',
]);

$doc->append(SlotType::Footer, [
    'company_name' => 'Acme Inc.',
    'address' => '548 Market St, San Francisco, CA',
    'unsubscribe_url' => '{{ unsubscribe_url }}',
]);

// Compile to responsive inlined HTML
$html = MailBuilder::compile($doc);

// Extract plain text fallback
$plainText = MailBuilder::plainText($doc);

2. Personalization & Merge Tags

Personalization supports dot-notation object paths, Liquid filters, and inline conditionals:

$template = "Hello {{ contact.first_name | capitalize }}! Member since {{ contact.created_at | date: 'F Y' }}. Balance: {{ account.balance | currency: 'USD' }}. {% if contact.is_vip %}Your VIP bonus is ready.{% else %}Upgrade today.{% endif %}";

$interpolated = MailBuilder::interpolate($template, [
    'contact' => [
        'first_name' => 'alexandra',
        'created_at' => '2026-03-15 10:00:00',
        'is_vip' => true,
    ],
    'account' => [
        'balance' => 1250.00,
    ],
]);

Built-in tags are {{unsubscribe_url}}, {{current_year}}, and {{web_view_url}}. Register your application's own tags so they appear in the Filament builder's tag picker and in previews:

use Odden\MailBuilder\MergeTags\MergeTagRegistry;

// In a service provider's register() method
$this->callAfterResolving(MergeTagRegistry::class, function (MergeTagRegistry $registry): void {
    $registry->register('Customer', [
        '{{customer.first_name}}' => 'Customer first name',
        '{{customer.plan}}' => 'Subscription plan',
    ], [
        // Sample values used when previewing templates
        'customer' => ['first_name' => 'Alex', 'plan' => 'Pro'],
    ]);
});

Available Filters:

  • capitalize, title, lower, upper, trim
  • date: 'Format' (PHP date() formatting)
  • currency: 'USD'
  • number: 2 (decimal places)
  • pluralize: 'item', 'items'
  • truncate: 50
  • default: 'fallback value'
  • raw (see below)

Escaping. TemplateMailable (the transactional send) HTML-escapes every value that comes from the data you pass in, where it lands in the message's HTML: a name or a note a customer typed cannot add markup or a link to your email. Text you wrote in the template, including a default fallback, is left as written. If a value really is markup you trust (an order table you built), say so in the template: {{ order_table | raw }}. The plain-text part and the subject are text and are not escaped; the subject is always one line, and unsubscribe_url / listUnsubscribeUrl must be an http(s) or mailto address to become a List-Unsubscribe header.

MailBuilder::interpolate($content, $context) does not escape (callers that escape their own values rely on that); pass escape: true to escape.

3. Ingestion (HTML & MJML Import)

Import external templates into editable modular slots:

// Ingest raw HTML
$docFromHtml = MailBuilder::importHtml($rawHtml, 'Imported Newsletter');

// Ingest standard MJML
$docFromMjml = MailBuilder::importMjml($mjmlCode, 'Imported Campaign');

// Compile imported documents directly
$compiledHtml = MailBuilder::compile($docFromMjml);

4. Deliverability, Performance & Accessibility Audits

// 1. Run Pre-flight Deliverability & Spam Audit
$audit = MailBuilder::audit($html);
if ($audit->hasBlockingIssues()) {
    // Inspect $audit->checks (each with a status of pass, warning or fail) and $audit->score (0-100)
}

// 2. WCAG 2.1 AA/AAA Color Contrast Audit
$contrast = MailBuilder::wcagContrast('#2563eb', '#ffffff');
// Returns: ['ratio' => 4.56, 'aa_normal' => true, 'rating' => 'AA']

$themeAudit = MailBuilder::auditContrast($themeArray);

// 3. Render Performance Benchmark
$benchmark = MailBuilder::benchmarkRender($html);
// Returns: dom_nodes_count, nested_table_depth, 3G/4G/5G download times

// 4. Sender Domain DNS Validation
$dns = MailBuilder::validateDns('yourdomain.com');
// Checks: SPF, DMARC (p=reject/quarantine), and BIMI

5. Sending Emails via TemplateMailable

use Odden\MailBuilder\Mail\TemplateMailable;
use Illuminate\Support\Facades\Mail;

Mail::to('user@example.com')->send(
    new TemplateMailable(
        template: $doc,
        data: [
            'contact' => ['first_name' => 'Sarah'],
            'unsubscribe_url' => 'https://example.com/unsubscribe/token',
        ],
        subjectLine: 'Exclusive Offer for {{ contact.first_name }}',
        embedCidImages: true // Embeds local & base64 images as MIME CID parts
    )
);

6. Filament Visual Builder Integration

Requires filament/forms ^5.0. To integrate the modular slot builder into any Filament Resource:

use Odden\MailBuilder\Filament\Components\EmailSlotBuilder;

public static function form(Schema $schema): Schema
{
    return $schema->components([
        EmailSlotBuilder::make('slots'),
    ]);
}

7. Slot schema (for editors)

Each slot type can describe itself: which fields its data takes, how to label and edit them, and the default of each. The Filament form is generated from this description, and a drag-and-drop editor can read it as JSON, so the two always agree with the slot's view.

use Odden\MailBuilder\Enums\SlotType;
use Odden\MailBuilder\Schema\SlotSchemaRegistry;

$schema = SlotSchemaRegistry::for(SlotType::Hero);   // every slot type has a schema
$schema->defaults();                                  // the data of a new hero slot
$schema->basicFields();                               // the fields of the basic form

return response()->json(SlotSchemaRegistry::toArray()); // everything an editor needs: a "version", the described slots, and a "labels" map that names every slot type

A field has a key, a type (text, textarea, rich_text, url, image, color, select, toggle, number, tags, items), a label, required, a default, and, where they apply, placeholder, help, options and the item fields of a list. Fields marked advanced are read by the slot's view but kept out of the basic form: an editor shows them as style options.

All 29 slot types are described, so the editor and the Filament blocks can edit every one of them (see Supported Slot Types). Tests check that every described field is read by its slot's view, so the description cannot drift from what is rendered.

8. Drag-and-drop editor

An editor for the slots, built as a web component with no framework: <odden-mail-editor>. It shows the email as it will be sent, with a palette of blocks, a structure list, and a settings panel for the selected block. Drag a block from the palette onto the email, or drag a block to move it. Every action also has a button and a keyboard shortcut, and changes are announced to screen readers. Undo and redo (Ctrl or Cmd + Z, Shift + Z) cover everything.

The editor edits the slot document as data and never touches HTML. The server renders the preview with the same compiler that builds the email, so what you see is what is sent, and the preview cannot be broken by editing. Every slot type is editable (see the slot schema above). A slot of a type the editor does not know, such as one from a newer version, is kept as it is: you can move, duplicate and delete it, and its preview is rendered.

1. Turn on the routes. The editor needs two routes, a slot schema and a preview. They are off by default, because anyone who can reach them can render email HTML. Switch them on in config/mail-builder.php and set your own middleware:

'editor' => [
    'routes' => [
        'enabled' => true,
        'prefix' => 'admin/mail-editor',
        'middleware' => ['web', 'auth'],   // your authentication and authorization
    ],
],

2. Publish the editor files (plain JavaScript modules and one stylesheet, no build step):

php artisan vendor:publish --tag=mail-builder-assets

3. Use the element:

<script type="module" src="/vendor/mail-builder/editor/odden-mail-editor.js"></script>

<odden-mail-editor id="editor"
    schema-url="/admin/mail-editor/schema"
    preview-url="/admin/mail-editor/preview"></odden-mail-editor>

<script type="module">
    const editor = document.getElementById('editor');

    editor.value = { subject: 'News', slots: [{ type: 'hero', data: { title: 'Hello' } }] };
    editor.addEventListener('change', (event) => console.log(event.detail));   // the new document
</script>
  • value is the document, in the shape EmailDocument::fromArray() takes: {subject?, preview_text?, theme?, slots: [{type, data, visibility?}]}. Keys other than slots are kept untouched.
  • The change event carries the new document. With a name attribute the element also keeps a hidden input in sync with the JSON, for a plain form post.
  • The element's own change event is a CustomEvent; the browser's native change and input events from the fields inside the editor do not leave it, so a surrounding form never sees them.
  • The page needs <meta name="csrf-token"> for Laravel's CSRF check; the editor sends it as X-CSRF-TOKEN.
  • To talk to the server some other way, set editor.adapter = { loadSchema(), preview(document) }.

The preview is rendered into a sandboxed iframe with scripts disabled, so a slot that holds HTML cannot run code in your admin page.

To try it while developing the package: vendor/bin/testbench serve, then open the home page.

9. The editor as a Filament field

MailEditor puts the drag-and-drop editor in a Filament form. It is a drop-in for EmailSlotBuilder::make('slots'): the state is the same list of slots ({type, data}), so it saves to the same column.

use Odden\MailBuilder\Filament\Components\MailEditor;

MailEditor::make('slots')
    ->label('Email content')
    ->theme(['container_width' => 640]);   // optional: the theme of the preview (see `mail-builder.defaults`)

The theme can also be a closure, evaluated for every preview, so the preview follows the form. For example, with the theme's fields in the same form under theme.*:

MailEditor::make('slots')->theme(fn (Get $get): array => $get('theme') ?? []);

The field's theme wins over a theme sent by the browser.

  • No routes and no extra setup for the preview. The field renders the preview itself, through Livewire, so it runs under the panel's own authentication and authorization. (The routes in section 8 are for use outside Filament.)
  • Publish the assets with Filament's own command, the first time and after an update: php artisan filament:assets.
  • State from either shape. A column holding Filament's keyed Builder items or a plain list both load, and the field saves a plain list. Slots of a type the field does not know are kept as they are.
  • It follows the form. When the form changes the state itself, for example an action that applies a preset with $set('slots', ...), the editor shows the new content.
  • A disabled field is shown but cannot be edited.

Supported Slot Types (29 Total)

Slot Type Key Description
Header & Logo header Branding bar with logo, web-view link, and optional date
Hero Banner hero Headline, badge pill, subtext, and bulletproof CTA button
Text & Content body_text Editorial rich text with merge tags and alignments
CTA Button button Bulletproof Outlook VML action button
2-Column Grid two_column Side-by-side cards with reverse mobile stacking (rtl)
3-Column Grid three_column Three responsive feature cards
4-Column Wall four_column Partner logo trust wall or compact icon links
Asymmetric Split asymmetric_columns 1/3 + 2/3 editorial sidebar split
Feature List features Bulleted list with icons, bold titles, and descriptions
Testimonial testimonial Quote callout with star rating and author attribution
Stat Box stat_box Key metric numbers and percentage badges
Divider Line divider Horizontal rule or spacer with configurable height
Labeled Divider labeled_divider Horizontal line with centered text badge (e.g. "OR")
Social Links social_links Multi-channel social icon bar
App Badges app_badges Official Apple App Store and Google Play buttons
Data Table data_table Multi-column comparison table with zebra striping
Image Banner image_banner Responsive banner image with alt text and link
Video Card video_card Thumbnail with play button overlay linking to video
Pricing Grid pricing_grid Multi-tier pricing cards with featured plan highlight
Rating Scale rating_bar 1-Click NPS or CSAT survey buttons (1-10 or 1-5 scale)
Countdown Urgency countdown_timer Urgency banner with deadline and offer CTA
Accordion / FAQ accordion Stacked Q&A cards for policies and instructions
Dynamic Feed dynamic_feed Product recommendation grid with live JSON hydration
Order Receipt order_receipt Itemized invoice summary with taxes and subtotals
RSS Article Feed rss_feed Syndicated blog digest with publication dates
Product Catalog product_catalog Ecommerce cards with compare-at pricing and buy CTAs
Coupon Code coupon_code Promo voucher card with dashed border and expiry
Footer & Compliance footer CAN-SPAM / GDPR address and unsubscribe link
Custom Raw HTML html Bespoke HTML code component

Testing

composer install
composer test      # Pest, via Orchestra Testbench
composer analyse   # PHPStan level 8 with Larastan
npm run test:js    # the editor's model and history (Node's built-in test runner)

Feed blocks

The rss and dynamic_feed blocks fetch JSON from the feed_url stored in the email, when the email is compiled. Because that URL is content, the fetch is guarded: the host must resolve only to public addresses (private, loopback, link-local and cloud metadata addresses are refused), redirects are not followed, the request times out after a few seconds, and a response over mail-builder.feeds.max_bytes (1 MB) is ignored. A refused or failed feed falls back to the block's own items. To fetch a feed you serve from your own network, list its host in mail-builder.feeds.allowed_hosts.

Sending, lists and campaigns

This package builds and checks emails. It does not manage contacts, lists, consent or sending. Those come from Odden CRM (open source) and its paid add-ons, or from your own application: TemplateMailable sends through any Laravel mailer. See odden.io for Odden Cloud and the paid Marketing add-on.

License

The MIT License (MIT). See LICENSE.md.