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.
Architecture fit. This package is a renderer adapter around the MJML compiler — a Node.js tool that transpiles declarative MJML markup into responsive, email-client-safe HTML. v3.11.0's single change ("Adapt BinaryRenderer for MJML 5") tells you the integration seam: the package shells out to a locally installed mjml binary and parses its output. That is a Node runtime dependency bolted onto a PHP stack, and it is the central architectural decision. Two viable shapes: (a) build-time rendering — compile MJML in CI into Blade partials, zero Node in production; (b) runtime rendering — Node + mjml present in every PHP container or behind an HTTP render service. For a Laravel product with mostly static transactional templates, (a) is strictly better; runtime rendering only earns its cost when templates are user-authored or parameterized per request.
Note also that notFloran/mjml-bundle is a Symfony bundle, not a Laravel package. It is usable from Laravel only by autoloading the renderer classes directly and binding them in a service provider, bypassing the bundle's Symfony DI/config layer.
Integration feasibility. Straightforward with caveats. Composer install, Node 18/20 LTS in the image, npm i -g mjml (pinned). The renderer API surface is small — a render call taking MJML source and returning HTML. Risks are: (1) Symfony component version constraints can collide with the Symfony packages Laravel already requires; audit composer why-not before committing; (2) the composer package version and the globally installed mjml binary version are independent — nothing in the release note suggests the adapter detects the binary version, so the adaptation is likely a hard-coded assumption about MJML 5's CLI invocation/output shape; (3) MJML 5 is not broadly GA, so "adapting for MJML 5" may mean tracking a moving target.
Technical risk. Medium-high, concentrated in operations rather than code. Specific risks: silent version skew (package adapted for MJML 5 CLI, image ships MJML 4, or vice versa → wrong output or non-zero exit swallowed); no documented fallback path for MJML 4 in this release; process-spawn cost per render (typically hundreds of ms, CPU-bound); command-injection surface if template content or user-supplied parameters reach the binary without escaping via argument arrays; and mj-include/mj-raw giving template authors filesystem-read and arbitrary-HTML primitives. Add maintainer bus-factor risk — this is a small single-maintainer project, and the release is a one-line changelog with no migration notes.
Key questions.
BinaryRenderer public API, or is this internal only? Nothing in the changelog says.Stack fit. Laravel 10/11 on PHP 8.2+, Composer, Node 20 LTS, mjml CLI pinned via a package-lock.json baked into the image. Treat the package as a thin library, not a framework integration: composer require notFloran/mjml-bundle, then in a service provider bind an MjmlRenderer interface to the package's renderer, configured with the binary path from config/mail.php or a dedicated config file. Do not attempt to register the Symfony bundle itself.
Migration path. Default to build-time. A CI job renders resources/mjml/*.mjml into resources/views/emails/*.blade.php, committed or built into the release artifact; Mailables then reference plain Blade views. This deletes the Node dependency from production entirely and makes renders deterministic and diffable in code review. Only if a requirement appears for per-request or per-tenant template content should you stand up runtime rendering — and then behind the same MjmlRenderer interface, so the switch is a config change.
Compatibility. Pin both sides: notFloran/mjml-bundle to ^3.11 and the mjml binary to the exact major it targets. Before merging, run composer why-not symfony/* against our Laravel version. Build a golden-template corpus (10–15 representative templates covering Outlook-conditional and dark-mode cases) and diff MJML 4 output against MJML 5 output; email HTML diffs are noisy but layout regressions in Outlook are the ones that matter.
Sequencing.
MjmlRenderer; implement both adapters (binary, HTTP service) even if only one is live.Maintenance. Two dependency graphs to patch on two cadences — Composer (PHP/CVE) and npm/Node (CVE, plus MJML's own release churn). MJML 5 pre-GA status means upstream instability propagating into your build; a minor MJML bump can change rendered markup. Budget for pinning and periodic, deliberate upgrades rather than continuous tracking.
Support. The real support load is not PHP, it is email client rendering — Outlook's Word engine, Gmail's CSS stripping, dark-mode inversions. Expect template-author questions and "the email looks broken in X" tickets that are not application bugs. Mitigate with a preview route (render template → open in browser and in a Litmus/Email on Acid run) and a documented component library.
Scaling. Process-per-render is CPU-bound and does not share well. At meaningful volume, push renders onto a Laravel queue, cache rendered HTML keyed by hash(template_source + params), and prefer a shared render service over installing Node in every worker. Build-time rendering sidesteps all of this: renders happen once per deploy, scaling cost is zero.
Failure modes.
mj-include enabling arbitrary file reads; mj-raw enabling arbitrary HTML. Constrain who can author templates and validate/whitelist include paths.Ramp-up. Low for template authors — MJML is declarative and readable, a day to productivity. Moderate for engineers: Node in Docker/CI, binary version pinning, and the build-time vs runtime decision. Realistic budget: 1–2 days spike, ~1 week to productionize with pinning, golden tests, and either a CI render step or a queued render path.
How can I help you explore Laravel packages today?