gollumsf/mjml-binary
gollumsf/mjml-binary makes it easy to compile MJML into responsive HTML emails in Laravel/PHP projects. It bundles the MJML binary, so you can render templates without managing external installations or dependencies.
What you're actually installing. gollumsf/mjml-binary is a Composer package that ships pre-compiled MJML CLI executables (Linux/macOS/Windows builds produced by Node's pkg). It exists so that GollumSF/MjmlBundle (Symfony) can render MJML without Node.js installed. There is no PHP class, no service provider, no facade, and no config to publish — it's a binary distribution plus a thin path helper. In a Laravel app you are using exactly one thing from it: the executable file.
Minimal steps
composer require gollumsf/mjml-binary
Then find the binary and confirm it runs:
ls vendor/gollumsf/mjml-binary/
find vendor/gollumsf/mjml-binary -type f -name 'mjml*' -executable
./vendor/gollumsf/mjml-binary/<platform>/mjml --version
Wrap that discovery in a tiny helper rather than hardcoding the path (the folder layout is platform-specific and has changed across releases):
// app/Support/Mail/MjmlBinary.php
class MjmlBinary
{
public static function path(): string
{
$candidates = glob(base_path('vendor/gollumsf/mjml-binary/**/mjml*')) ?: [];
foreach ($candidates as $file) {
if (is_file($file) && is_executable($file) && ! str_ends_with($file, '.md')) {
return $file;
}
}
throw new RuntimeException('MJML binary not found; run composer install.');
}
}
First use case. Add a mail.mjml.* set of Blade views that emit MJML instead of HTML, and compile them in the mailable:
// config/mjml.php
return ['binary' => env('MJML_BINARY', null), 'timeout' => 30];
// app/Support/Mail/MjmlRenderer.php
class MjmlRenderer
{
public function __construct(private ?string $binary = null, private int $timeout = 30) {}
public function render(string $mjml): string
{
$process = new Symfony\Component\Process\Process(
[$this->binary ?? MjmlBinary::path(), '-s', '-r', '--config.validationLevel=soft']
);
$process->setInput($mjml);
$process->setTimeout($this->timeout);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException("MJML failed: ".$process->getErrorOutput());
}
return $process->getOutput();
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(MjmlRenderer::class, fn () => new MjmlRenderer(config('mjml.binary'), config('mjml.timeout')));
Where to look first: the package README (it lists the platform folders and the -s/-r stdin/stdout flags), then vendor/gollumsf/mjml-binary/ on disk. Ignore anything about MjmlBundle — that's Symfony wiring you won't use. Also read the MJML CLI docs upstream; the flags are MJML's, not the package's.
Pattern 1 — compile-at-render in a Mailable. Simplest and fine for low volume:
class InvoiceMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Invoice $invoice) {}
public function build(): static
{
// Render Blade -> MJML source (escape data yourself, see Gotchas)
$source = view('mail.mjml.invoice', [
'invoice' => $this->invoice,
'customerName' => e($this->invoice->customer->name),
'ctaUrl' => e(route('invoices.show', $this->invoice)),
])->render();
return $this->subject('Invoice #'.$this->invoice->number)
->html(app(MjmlRenderer::class)->render($source));
}
}
Mailable::build() is invoked with no arguments by Laravel, so type-hinted injection doesn't work here — resolve from the container with app(), or move the rendering into content()/an accessor.
Pattern 2 — compile the shell at build time, keep Blade at runtime. MJML is not a templating language, so the cheapest production setup is: compile the layout once in CI/deploy into an .blade.php file, then let Laravel interpolate per-recipient data. Put Blade tags directly in the MJML source and they survive compilation as literal text:
{{-- resources/mjml/invoice.mjml --}}
<mjml>
<mj-body>
<mj-section>
<mj-text>Hi {{ $customerName }},</mj-text>
<mj-button href="{{ $ctaUrl }}">View invoice</mj-button>
</mj-section>
</mj-body>
</mjml>
// app/Console/Commands/CompileMjml.php
protected $signature = 'mjml:compile';
public function handle(MjmlRenderer $mjml): int
{
foreach (File::glob(resource_path('mjml/*.mjml')) as $file) {
$html = $mjml->render(File::get($file));
File::put(resource_path('views/mail/html/'.basename($file, '.mjml').'.blade.php'), $html);
}
return self::SUCCESS;
}
Now the mailable is a plain ->view('mail.html.invoice', $data), the binary never runs in production, and you get zero runtime latency. This is the pattern I'd default to for anything beyond a handful of transactional emails.
Pattern 3 — cache by content hash. If you must compile at request time, cache the compiled shell (variables stripped) and only recompile when the MJML source changes:
$key = 'mjml:'.md5($layoutSource);
$shell = Cache::rememberForever($key, fn () => $mjml->render($layoutSource));
// then compile the small variable-bearing fragment, or splice at markers
For per-user data, compile the layout to @include-able HTML and use Blade inheritance rather than re-running the binary per email.
Pattern 4 — queue it. Compilation is 100–800 ms (cold start higher). If you send bulk mail, compile inside a job and persist the HTML to storage/app/mail/ or a rendered_html column; the actual send job just attaches/streams it. Keeps mail worker time flat and makes retries free.
Integration with Laravel Mail tests.
Mail::fake();
$this->post('/invoices/1/send');
Mail::assertSent(InvoiceMail::class, fn ($mail) => str_contains($mail->render(), 'View invoice'));
Bind a fake renderer in TestCase::setUp() so your test suite never shells out:
$this->app->instance(MjmlRenderer::class, new class extends MjmlRenderer {
public function render(string $mjml): string { return '<html>stub</html>'; }
});
Keep one or two real-binary tests (a "contract" test) asserting the output contains <table and the expected CTA URL, so you notice when the binary breaks on a CI image change.
Config/env conventions that pay off
| Concern | Where |
|---|---|
| Binary path override | MJML_BINARY env → config/mjml.php |
| Timeout | MJML_TIMEOUT, default 30s |
| Validation level | soft in prod (don't fail sends on warnings), strict in dev/CI |
| Build-time compilation | php artisan mjml:compile in your deploy script, before config:cache |
The license is the first thing to check. This package is GPL-3.0. MJML itself is MIT, but this distribution's license is not. Shipping a GPL binary inside a commercial Laravel app is usually "mere aggregation" (your PHP code stays proprietary), but many companies' OSS policies block GPL in vendor/ outright. Get a legal sign-off before it lands on main, or avoid the package and install mjml via npm in a build step instead — the output is identical.
It's stale. Last release January 2021. That pins you to an MJML from that era: newer components and bug fixes are missing, and the compiled pkg runtime is old enough that it may not start on modern distros. Treat the binary as frozen and version-pin it ("gollumsf/mjml-binary": "1.0.*"), or vendor the file yourself so a composer update can't swap it.
Architecture and libc are the top failure modes.
--platform=linux/amd64 in Docker or run under Rosetta; ARM GitHub runners (ubuntu-24.04-arm) will fail.pkg output is glibc-linked, so it does not run on Alpine/musl (Error loading shared library ld-linux-x86-64.so.2 or a bare "not found"). Use a Debian-slim base image, add gcompat, or compile MJML at build time on a builder stage and copy the HTML out.Process throws "Permission denied" — chmod +x in your deploy step, and never execute from a host-mounted vendor/ on macOS.Escaping is on you. MJML is XML-shaped. Any data injected into an mj-text, mj-button href, or attribute must be XML-escaped (e() / htmlspecialchars) before compilation, or you get broken markup or a compiler error. Conversely, when you intentionally inject HTML blocks ({!! $html !!}), MJML's own sanitization won't save you — sanitize at the source.
Use stdin, not argv. -s -r (stdin/stdout) avoids ARG_MAX limits, quoting bugs, and shell-injection risk from filenames. Build the argv array with Process, never a shell string.
Alpine and Vapor/Lambda. Both need special handling: Alpine via gcompat/Debian, Lambda via a custom layer or, far simpler, build-time compilation (Pattern 2) so no binary ships at all.
Windows dev machines. Even if a Windows build exists, path/line-ending differences bite. Guard MjmlBinary::path() to throw a clear message, and point MJML_BINARY at a local npx mjml shim if you want dev parity without the package.
Debugging checklist. Run the binary by hand with the exact same input: ./path/mjml -s -r < t.mjml. If it succeeds there and fails in PHP, the issue is Process env (missing PATH/LANG), timeout, or the exec bit. Log getErrorOutput() — MJML prints useful validation errors there but exits 0 with warnings under validationLevel=soft, so a "successful" render can still be visibly wrong.
Validation level. strict turns warnings into errors — great in CI, hostile in production. soft (or skip) is the right prod setting.
Minification and comments. MJML minifies by default (--config.minify). For email debugging you'll want --config.beautify --config.minify=false in a dev-only code path; remember comments (--config.keepComments) leak into client-facing HTML otherwise.
Extension points you actually have. There are none inside the package — swap behavior at your boundary: bind MjmlRenderer to a fake in tests, to a remote-API implementation (mjml.io / a Node sidecar) if the binary misbehaves, or to a "read precompiled Blade" implementation in production. Keep the interface small (render(string): string) and every escape hatch stays open.
Alternatives worth pricing in. If you're on Laravel, weigh: (a) npm i mjml in a CI build step + Pattern 2 — MIT, always current, no GPL, no runtime binary; (b) a Node sidecar or mjml HTTP service; (c) a hosted MJML API for low volume. This package's real value — "no Node.js needed" — matters mainly for Symfony shared hosting, which isn't a typical Laravel deployment target. With 3 stars and no dependents, you're the maintainer if it breaks.
How can I help you explore Laravel packages today?