notfloran/mjml-bundle
A Symfony bundle that integrates MJML into PHP apps, compiling MJML markup into responsive HTML emails. It wraps the MJML renderer with configurable options and a simple service for generating templates, making it easy to build maintainable, mobile-friendly transactional emails in Symfony projects.
notfloran/mjml-bundle wraps the MJML compiler so you can turn .mjml markup into responsive email HTML from PHP instead of shelling out to the CLI yourself.
composer require notfloran/mjml-bundle
Two things must exist before the first render:
mjml binary — npm install -g mjml (or a local node_modules/.bin/mjml). Point the bundle at it with the MJML_BINARY_PATH env var or the mjml.binary_path config key; it defaults to looking up mjml on PATH.BinaryRenderer (local subprocess) and an ApiRenderer (Mailjet's hosted MJML API). Binary is the sane default; the API renderer is for hosts where you cannot install Node.Minimal usage once wired up:
use Mjml\Renderer\RendererInterface;
$html = $renderer->render(<<<'MJML'
<mjml>
<mj-body>
<mj-section><mj-column>
<mj-text>Hello</mj-text>
</mj-column></mj-section>
</mj-body>
</mjml>
MJML);
Where to look first: the bundle's DependencyInjection/Configuration.php (all config keys and defaults), the Renderer/ directory (which renderer is aliased to RendererInterface), and your framework's service registration for the bundle. Note this is a Symfony bundle at heart — it ships a *Bundle class and DI extension, not a Laravel service provider. In Laravel you either use it via a small adapter package, or instantiate BinaryRenderer directly and bind it in a service provider. Verify which path you are on before debugging config that "isn't being read" — that is almost always the cause.
First real use case: take an existing transactional email (password reset, order confirmation), port the HTML to MJML, render it in a console command, and diff the output against the current template before wiring it into the mailable/notification.
Template-first, then MJML. Rarely hand-write MJML strings in PHP. Render a Twig/Blade view containing the MJML, then pass the result to the renderer. This keeps loops, conditionals and translations in the templating layer where they belong:
$mjml = $twig->render('emails/order.mjml.twig', ['order' => $order]);
$html = $renderer->render($mjml, ['minify' => true]);
Options are per-render and global. Global defaults live in config (mjml.options); the second argument to render() merges over them for a single call. Use the global layer for house style (juicePreserveTags, minify, font settings) and per-call options for one-offs like keepComments in local debugging.
Cache the compiled output, not the input. MJML compilation spawns a Node process — tens to hundreds of milliseconds each. Cache the resulting HTML keyed on a hash of the MJML source plus the options array, with a TTL tied to your deploy. Because the source includes rendered variables, this naturally invalidates when data changes; for static shells, key on template mtime instead.
Wrap it in a mailer/notification layer. Don't inject RendererInterface into twenty classes. Introduce one MjmlMailRenderer that owns the render + cache + error handling, and inject that. It also gives you a single seam to swap in the API renderer or a stub in tests.
Stub it in tests. Bind a fake RendererInterface returning a fixed HTML string for anything asserting on mail content; keep one or two integration tests that hit the real binary so a broken mjml install fails CI rather than production. A test that asserts "the output contains <!doctype html> and no <mj- tags" catches a renderer that silently returned its input.
Install Node in your runtime image. The single most common integration failure is a PHP container with no Node. Add nodejs + mjml to the Dockerfile (or a sidecar) and set MJML_BINARY_PATH explicitly rather than relying on PATH resolution, which differs between CLI and FPM.
Pin the binary version alongside the bundle. MJML output changes between major versions. Record the mjml version in the image and, if you snapshot-test emails, expect churn on every MJML upgrade.
MJML 5 changes the runtime floor, not just the API. v3.11.0 adapts BinaryRenderer for the MJML 5 CLI. MJML 5 requires a much newer Node than the 4.x line did, so an image that happily ran mjml@4 may fail to install or execute mjml@5 — check your Node version before blaming the bundle. If you cannot move Node yet, pin mjml@4 in the image; the bundle's binary renderer talks to whichever binary you point it at.
The adapter is about invocation, not output. Treat v3.11.0 as a compatibility fix: expect it to make the subprocess call work against the 5.x CLI's arguments/entry point. It does not promise byte-identical HTML versus 4.x. If you have golden-file tests, regenerate them after upgrading the binary and skim the diff for dropped styles or changed inlining.
Silent-empty output is the classic failure mode. When the binary exits non-zero or writes to stderr, you can end up with an empty or truncated string rather than an exception, depending on renderer and version. Always assert the result is non-empty and contains </html> at your mail-render boundary. When debugging, run the exact same MJML through the CLI by hand — mjml --stdin --config.minify true — and read stderr; the bundle's exception message often surfaces only a generic exit code.
Option flattening is literal. Nested option arrays become dotted CLI flags (['config' => ['minify' => true]] → --config.minify true). Wrong nesting produces a flag the CLI rejects, which surfaces as an exit-code error with no useful message. When an option "doesn't work", echo the assembled command and compare against mjml --help for your installed version.
Paths and permissions. Under FPM the working directory and PATH differ from your shell. Use an absolute binary_path, and confirm the user running PHP has execute permission on both the mjml shim and the node it invokes. exec/proc_open disabled in php.ini produces a confusing "renderer unavailable" rather than a clear error.
The API renderer is a different risk profile. It ships your email HTML to Mailjet over the network: latency in the send path, rate limits under batch sends, an external dependency in your password-reset flow, and a data-egress question for anything containing customer PII. Use it for previews and tooling, prefer the binary for production mail.
Escape MJML-sensitive characters in interpolated data. User content containing <, &, or {{ }} inserted into <mj-text> can break parsing or be interpreted by the templating layer before MJML sees it. Escape at the templating layer and keep a raw block for the deliberately-HTML parts.
Extension point: implement RendererInterface and alias it over the bundle's renderer service to slot in a custom backend (a queue-backed renderer, a cached decorator, a mock). That alias is the supported seam — avoid subclassing BinaryRenderer internals, since v3.11.0 just demonstrated that those internals track the MJML CLI.
mj- tags leaking into the final email means MJML never ran — the string went to the transport directly. A one-line assertion in your mail test (assertStringNotContainsString('<mj-', $html)) catches this permanently.
How can I help you explore Laravel packages today?