Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mjml Php Laravel Package

spatie/mjml-php

spatie/mjml-php converts MJML email markup into responsive, email-client-ready HTML from PHP. Install with Composer plus the mjml npm package (Node 16+), then use the simple Mjml::new()->toHtml($mjml) API to render MJML without hand-writing HTML.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a thin, well-behaved wrapper: it shells out to the Node mjml CLI and hands you back HTML. That single sentence explains almost every design decision and every gotcha below.

Minimum viable setup — two installs, in this order:

composer require spatie/mjml-php
npm install mjml        # Node 16+ required, ideally pinned in package.json

The PHP package does not bundle MJML. If node or the mjml binary is missing on a given host, every render throws Spatie\Mjml\Exceptions\CouldNotRenderMjml — usually at 2am in a queued mail. Treat the Node dependency as a first-class deployment concern, not an afterthought.

First use case: a Blade partial of MJML, compiled in a mailable.

use Spatie\Mjml\Mjml;

$html = Mjml::new()->minify()->toHtml(
    view('mail.mjml.newsletter', ['user' => $user])->render()
);

Two things to notice. First, view()->render() is the natural bridge — you author MJML in resources/views/mail/*.mjml.blade.php or *.mjml, and Blade gives you loops and conditionals before MJML ever sees the markup. Second, Mjml::new() is a fluent builder: options are set on the instance, and the instance is immutable enough that ->minify() returns a configured copy you can hold in a container singleton.

Where to look first. The README is short and complete; the two files worth reading in the source are Mjml.php (the fluent API and the process invocation) and MjmlResult.php / MjmlError.php (the useful metadata surface). The changelog is unusually informative here: 1.2.1/1.2.4/1.2.5 are all transport fixes (base64, then stdin, then a decode type bug), which tells you the package has had real-world rough edges around argument passing. 1.3.0 adds official MJML v5 support.

Start with convert(), not toHtml(). toHtml() is the convenient path, but convert() returns an MjmlResult exposing html(), array(), hasErrors(), and errors() — where each MjmlError gives you line(), message(), tagName(), and formattedMessage(). You want that during development on day one, even if production ends up using toHtml().

Implementation Patterns

A dedicated renderer service, bound once. Don't scatter Mjml::new() through controllers and mailables; you'll want one place to change options and one place to catch exceptions.

namespace App\Support;

use Spatie\Mjml\Mjml;
use Spatie\Mjml\Exceptions\CouldNotRenderMjml;

class MjmlRenderer
{
    public function render(string $mjml, array $options = []): string
    {
        return Mjml::new()
            ->minify()
            ->hideComments()
            ->validationLevel(config('mail.mjml.validation', \Spatie\Mjml\Enums\ValidationLevel::Soft))
            ->toHtml($mjml, $options);
    }
}

Wrap the call in try/catch (CouldNotRenderMjml $e) and decide your fallback policy explicitly: log + send a plain-text or previously cached HTML version, or let the job fail loudly. Silently sending an empty body is the worst outcome.

Compile-time rendering (the pattern that actually scales). Because each conversion spawns a Node process, doing it inside Mail::send() is a performance and reliability tax. For templates that are static or only parameterised by a handful of variables, split rendering from conversion:

  • Build step: an Artisan command walks resources/views/mail/**/*.mjml, renders each with sample data, converts once, and writes resources/views/mail/html/*.blade.php (or a JSON map). Deploy then needs Node only in CI, not on every web/queue node.
  • Runtime: view('mail.html.newsletter', $data) — pure PHP, no subprocess.

This also gives you a lint gate: the same command fails the build when a template stops converting.

Caching for dynamic templates. When the MJML genuinely varies per user, cache on the rendered source plus an options hash, so an option change invalidates correctly:

$key = 'mjml:'.hash('xxh128', $mjml.'|'.json_encode($options));

$html = Cache::remember($key, now()->addWeek(), fn () => $this->renderer->render($mjml, $options));

Invalidate on template edit by including the file's mtime or a content hash of the Blade source in the key.

Validation as a test, not a runtime check. canConvert() tells you the MJML is structurally acceptable; canConvertWithoutErrors() additionally requires zero non-fatal errors (bad attributes, deprecated tags). That second one belongs in CI:

it('renders valid mjml', function (string $path) {
    expect(Mjml::new()->canConvertWithoutErrors(file_get_contents($path)))->toBeTrue();
})->with(glob(resource_path('views/mail/mjml/*.mjml')));

canConvert() returning true does not mean clean output — non-fatal errors still produce HTML, with attributes silently dropped. A newsletter can ship looking subtly wrong for months because an attribute was misspelled.

Options: two equivalent doors. The fluent methods (minify(), beautify(), hideComments(), validationLevel()) and the raw options array (second argument to toHtml()/convert(), mirroring the Node MJML options) both work. Prefer fluent for the four things the package names, and reserve the array for engine options it doesn't wrap. Note that 0.0.6 was literally "fix an issue where settings weren't kept" — if you mix both, assert the merged result in a test rather than assuming precedence.

Laravel Mail integration. The cleanest fit is a base mailable that accepts pre-rendered HTML, keeping conversion out of the Mailable lifecycle:

class NewsletterMail extends Mailable
{
    public function __construct(public User $user, public string $mjml) {}

    public function build(): static
    {
        return $this->subject('July update')
            ->html(app(MjmlRenderer::class)->render($this->mjml));
    }
}

If you need to inspect the structured tree, $result->array() gives you the JSON-shaped representation — handy for debugging nested mj-section/mj-column output or for building a preview UI that renders both MJML and HTML side by side.

Sidecar for Node-free production. If your infrastructure can't run Node (shared hosting, some serverless setups), install spatie/mjml-sidecar, register Spatie\MjmlSidecar\MjmlFunction in config/sidecar.php, run php artisan sidecar:deploy --activate, and call ->sidecar() on the builder. This is the escape hatch that makes the package viable on Forge/Vapor-style stacks where you'd rather not manage a Node runtime — at the cost of a Lambda round-trip per render, which makes caching even more essential.

Gotchas and Tips

Node is a runtime dependency, and its absence is a hard failure. Every host that renders MJML needs Node 16+ and the mjml package installed where the process is spawned. Docker images that only run composer install will pass CI and fail in production. The MJML_NODE_PATH env var exists precisely for nvm-style installs where the binary isn't on the default PATH:

MJML_NODE_PATH=/home/user/.nvm/versions/node/v20.11.0/bin

Note the variable points at a directory, not the binary. In Docker, install Node in the same layer as your app or set the path explicitly — don't rely on the package's auto-detection surviving a multi-stage build.

Version drift between PHP and Node. The package passes options to whatever mjml CLI it finds. Pin mjml in package.json and commit the lockfile. MJML v5 is officially supported as of 1.3.0; on older package versions v5 was alpha-only. If output changes after a npm update, that's your cause — not the PHP package.

hideComments() and Outlook. Outlook conditional comments (<!--[if mso]>...<![endif]-->) are HTML comments. Hiding comments or aggressively minifying can break the hacks many templates rely on for Outlook rendering. If you use hideComments(), visually check the result in Outlook (or Litmus/Email on Acid) before trusting it; better, keep conditional-comment-dependent templates on minify() alone.

Non-fatal errors are invisible by default. toHtml() returns HTML regardless of warnings. In development, use convert() and log formattedMessage() for each error. A validationLevel of strict turns problems into failures — great for CI, risky in production where a malformed template would mean a dead send. A reasonable split: strict in tests, soft in production.

Subprocess cost adds up. Expect tens to low hundreds of milliseconds per conversion, plus process spawn overhead. Converting inside a loop over 5,000 recipients is a self-inflicted outage. Convert once, cache the HTML, personalise with placeholders or a templating pass afterwards — or precompile at build time.

Escape user data before it reaches MJML. mj-text accepts HTML, and MJML is not a sanitiser. If a user's name is interpolated into an mj-text node, escape it at the Blade layer, or you've reintroduced the injection problem in a channel people forget to audit.

Test the failure path. Deliberately feed malformed MJML in a test and assert your fallback behaves (logged, alert raised, plain-text alternative sent). The exception class is Spatie\Mjml\Exceptions\CouldNotRenderMjml; catching \Throwable around it will mask configuration errors you actually want to see.

Extension points are limited by design — extend by composition. There's no config file to publish and no service provider to lean on; Mjml is a final-ish builder around a process call. Your extension surface is your own wrapper: a MjmlRenderer in the container, a Blade directive (@mjml ... @endmjml) that compiles inline blocks, or a base mailable. Keep the Spatie\Mjml\Mjml type out of your public APIs so you can swap the engine later.

isMjml() exists but isn't documented. The changelog shows it landing in 0.0.3. It's useful for a cheap guard before you attempt a conversion (e.g. accepting user-supplied template bodies in an admin panel), but since it's absent from the README, don't build critical logic on undocumented behaviour without a test pinning it.

Debugging checklist, in order: does node -v work as the web/queue user? Does npx mjml --version in the project root? Is MJML_NODE_PATH set in the actual runtime environment (not just your shell)? Does the same MJML convert via the CLI directly? Each question isolates one layer, and the third one catches most production-only failures.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console