bbs-lab / laravel-mail-mjml
Laravel package for building responsive emails with MJML and Mailables.
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/mail: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
- illuminate/view: ^11.0||^12.0||^13.0
- soundasleep/html2text: ^2.1
- spatie/laravel-package-tools: ^1.16
- symfony/process: ^7.0||^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^9.16||^10.9||^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-mutate: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
Build responsive transactional e-mails with MJML and Laravel Mailable classes — with Blade inside your templates and <mj-include> partials.
Maintained fork of asahasrabuddhe/laravel-mjml. No application-level RerenderMjml workaround required.
Contents
- Features
- Requirements
- Installation
- Quick start
- Recommended layout
- Usage
- How it works
- Configuration
- Compiler API
- Migrating from the original package
- Quality & testing
- Changelog
Features
BuildsMjmlMailtrait — add MJML to anyIlluminate\Mail\Mailable- Blade in root views and includes —
{{ }},@if,trans(),asset(),config(), nested<mj-include> - HTML + plain text — text part generated from compiled HTML
- Compile cache — artifacts stored under Laravel’s
view.compiledpath - Strict quality bar — Pest, PHPStan level 8, 100% line coverage on
src/
Requirements
| Tool | Version |
|---|---|
| PHP | ^8.3 (see CI matrix per Laravel version) |
| Laravel | ^11, ^12, or ^13 |
| Node.js | 20+ (CI E2E uses Node 24) |
| MJML CLI | via npm install mjml (or custom binary in config) |
Installation
composer require bbs-lab/laravel-mail-mjml npm install --save-dev mjml
Publish configuration (optional):
php artisan vendor:publish --tag="laravel-mail-mjml-config"
The package auto-registers LaravelMjmlServiceProvider. No manual setup beyond the trait on your mailables.
Quick start
1. Trait on your mailable
<?php namespace App\Mail; use BBSLab\LaravelMjml\Concerns\BuildsMjmlMail; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class WelcomeMail extends Mailable { use BuildsMjmlMail; use Queueable; use SerializesModels; public function __construct(public string $name) {} public function envelope(): Envelope { return new Envelope(subject: 'Welcome'); } public function content(): Content { return $this->mjmlContentDefinition('mail.welcome', [ 'name' => $this->name, ]); } }
2. MJML Blade view — resources/views/mail/welcome.blade.php
<mjml> <mj-body> <mj-section> <mj-column> <mj-text>Hello {{ $name }}</mj-text> </mj-column> </mj-section> <mj-include path="../mjml/footer.mjml" /> </mj-body> </mjml>
3. Send as usual
Mail::to($user)->send(new WelcomeMail($user->name));
Recommended layout
Structure that works well in production (one Blade shell per mail, reusable MJML partials):
resources/views/mail/
├── welcome.blade.php # <mjml> shell + <mj-include>
├── backend/
│ └── reset-password.blade.php
└── mjml/
├── head.mjml
├── header.mjml
└── footer.mjml # Blade: trans(), asset(), @if, etc.
Include paths are resolved relative to the view file that contains <mj-include>. From resources/views/mail/backend/reset-password.blade.php:
<mj-include path="../mjml/footer.mjml" />
Nested includes inside a partial (e.g. header.mjml including snippet.mjml) resolve relative to that partial’s directory.
Usage
Laravel 11+ Content API (recommended)
mjmlContentDefinition() compiles the view and returns a Content instance with html and text:
public function content(): Content { return $this->mjmlContentDefinition('mail.welcome', [ 'name' => $this->name, 'actionUrl' => $this->actionUrl, ]); }
Classic build() / buildView() flow
public function build(): self { return $this ->mjml('mail.welcome', ['name' => $this->name]) ->subject('Welcome'); }
The trait overrides buildView() when mjml() or mjmlContent() was called. Otherwise the default Laravel view behaviour applies.
Raw MJML string
Useful for tests or dynamically built markup:
return $this ->mjmlContent('<mjml><mj-body><mj-text>Hi {{ $name }}</mj-text></mj-body></mjml>') ->subject('Hi');
Pass data through the mailable’s viewData / constructor as with any Blade view.
Partial with Blade helpers
resources/views/mail/mjml/footer.mjml:
<mj-section> <mj-column> <mj-image src="{{ asset('images/email-footer.png') }}" alt="{{ config('app.name') }}" /> <mj-text>{{ trans('mail.footer.team') }}</mj-text> <mj-text> @if ($showSignature) {{ $signature }} @endif </mj-text> </mj-column> </mj-section>
With mjml.process_includes_with_blade enabled (default), these directives run before the MJML binary is invoked.
Preview in the browser (optional)
For local preview without sending mail, compile manually:
use BBSLab\LaravelMjml\Mjml\MjmlCompiler; $html = (new MjmlCompiler( view('mail.welcome', ['name' => 'Preview']), ['name' => 'Preview'], ))->renderHtml(); return response($html);
How it works
Default pipeline (one Blade pass before MJML):
Blade view on disk (mail/*.blade.php)
│
▼
MjmlIncludeResolver — inline <mj-include> partials (raw, recursive)
│
▼
Single Blade::render() on the assembled MJML
│
▼
MJML CLI → HTML (cached by content hash)
│
▼
Html2Text → plain-text part
This replaces the legacy pattern from the original package: Blade on the root view, MJML native includes (no Blade in partials), then a second Blade pass on the HTML (RerenderMjml). Here, partials are resolved in PHP and Blade runs once on the full document before the binary sees it.
Do not enable process_includes_with_blade and rerender_blade_after_compile together — you would run Blade multiple times without benefit.
Legacy escape hatch: set process_includes_with_blade to false and rerender_blade_after_compile to true to approximate the old HTML rerender workflow (not recommended for new projects).
Configuration
Environment variables map to config/mjml.php:
MJML_AUTO_DETECT_PATH=true MJML_PATH_TO_BINARY= MJML_NODE_PATH=node MJML_PROCESS_INCLUDES_WITH_BLADE=true MJML_RERENDER_BLADE_AFTER_COMPILE=false
| Key | Description | Default |
|---|---|---|
auto_detect_path |
Resolve base_path('node_modules/.bin/mjml') |
true |
path_to_binary |
Absolute path when auto-detect is off | '' |
node_path |
Node executable prepended to the CLI command | node |
process_includes_with_blade |
Inline includes, then one Blade pass on assembled MJML (recommended) | true |
rerender_blade_after_compile |
Legacy: Blade pass on HTML after MJML (keep false with the option above) |
false |
Production tip: set auto_detect_path to false and path_to_binary to a known binary in CI/Docker images where node_modules may not exist at runtime.
Compiler API
For advanced use (custom basePath, mocked process, container binding):
use BBSLab\LaravelMjml\Mjml\MjmlCompiler; $compiler = app(MjmlCompiler::class, [ 'mjml' => view('mail.welcome', $data), 'data' => $data, 'basePath' => resource_path('views/mail'), ]); $html = $compiler->renderHtml(); $text = $compiler->renderText();
Registered services: MjmlIncludeResolver (singleton), MjmlCompiler (bind).
Migrating from the original package
Before (asahasrabuddhe/laravel-mjml) |
After (bbs-lab/laravel-mail-mjml) |
|---|---|
extends Asahasrabuddhe\LaravelMJML\Mail\Mailable |
extends Illuminate\Mail\Mailable + use BuildsMjmlMail |
RerenderMjml trait in the app |
Remove — includes are Blade-rendered by default |
Same mjml() / mjmlContent() ergonomics |
Same method names on the trait |
// Before use Asahasrabuddhe\LaravelMJML\Mail\Mailable; // After use BBSLab\LaravelMjml\Concerns\BuildsMjmlMail; use Illuminate\Mail\Mailable; class WelcomeMail extends Mailable { use BuildsMjmlMail; }
Quality & testing
Current targets on main (local, PHP 8.3+):
| Check | Command | Target |
|---|---|---|
| Unit / integration tests | composer test |
65 tests (fake MJML CLI, no Node) |
| MJML E2E tests | composer test-mjml |
23 tests with the real mjml binary (npm install first) |
| Total | 88 tests | |
Line coverage (src/) |
composer test-coverage |
100% (enforced --min=100) |
| Mutation score | composer test-mutation |
≥ 65% on covered code (src/); requires Xdebug |
| Static analysis | composer analyse |
PHPStan level 8 |
| Code style | composer format |
Laravel Pint |
| Clean compile artifacts | composer clean |
Wipes tests/storage/, storage/, root *.html / *.mjml.php (also run automatically before test-mjml, test-coverage, and test-mutation) |
Mutation testing uses Pest Mutate. Each test file declares covers() for the classes it exercises. The test-mutation script runs composer clean first, then ensures Pest’s mutate temp directory exists (avoids false-low scores when .temp/mutations is missing).
Contributor workflow:
composer install composer test # fast suite (no Node) npm install # only for MJML E2E composer test-mjml # real mjml CLI → responsive HTML composer test-coverage composer test-mutation composer analyse composer format composer clean # manual wipe; test-mjml / test-coverage / test-mutation already call it
CI runs the Pest Package suite on Ubuntu for each supported Laravel version (11–13) against the PHP versions that Laravel supports (see exclude in run-tests.yml: e.g. no PHP 8.5 on Laravel 11), with prefer-lowest and prefer-stable. run-mjml-e2e-tests uses the same PHP×Laravel matrix (8 jobs, prefer-stable only) with Node 24 and npm ci. Mutation tests run on push/PR when src/ or tests/ change.
Changelog
See CHANGELOG.
Contributing
See CONTRIBUTING.
Security
See SECURITY.
Credits
- BBS
- Ajitem Sahasrabuddhe for the original
laravel-mjmlpackage
License
The MIT License (MIT). See LICENSE.