pelago/emogrifier
Emogrifier converts CSS styles into inline style attributes in your HTML, ensuring reliable display in email clients and mobile readers that lack stylesheet support. Useful for Outlook 2007 and Gmail quirks. Install via Composer: pelago/emogrifier.
Installation (v8.3.0 or later):
composer require pelago/emogrifier:^8.3
Check your platform:
sabberworm/php-css-parser >= 9.5.0. If any other package pins an older version, run composer why sabberworm/php-css-parser before upgrading.thecodingmachine/safe is no longer a runtime dependency — if your code (or another package) relied on it being installed transitively, add it explicitly.Basic inlining:
use Pelago\Emogrifier\CssInliner;
$html = '<html><body><p class="email-text">Hello</p></body></html>';
$css = '.email-text { color: red; }';
$inlinedHtml = CssInliner::fromHtml($html)->inlineCss($css)->render();
// <p style="color: red;">Hello</p>
No external CSS? Omit the argument to parse <style> blocks in the document:
$inlinedHtml = CssInliner::fromHtml($html)->inlineCss()->render();
First use case: transactional email templates — convert a styled Blade/HTML template into inline CSS so Outlook and Gmail render it consistently.
Preprocess / normalize (adds missing <head>/<body>, fixes nesting):
use Pelago\Emogrifier\HtmlProcessor\HtmlNormalizer;
$normalizedHtml = HtmlNormalizer::fromHtml($rawHtml)->render();
Inline — pass external CSS as a string, or omit to consume <style> blocks. Chain options before inlineCss():
$inliner = CssInliner::fromHtml($html)
->setDebug(false) // see Gotchas: exception vs. trigger_error
->addExcludedCssSelector('.ad-banner')
->addAllowedMediaType('handheld')
->inlineCss($css);
Post-process — prune hidden nodes and redundant classes, then convert visual CSS to HTML attributes:
use Pelago\Emogrifier\HtmlProcessor\HtmlPruner;
use Pelago\Emogrifier\HtmlProcessor\CssToAttributeConverter;
$pruned = HtmlPruner::fromDomDocument($inliner->getDomDocument())
->removeElementsWithDisplayNone()
->removeRedundantClassesAfterCssInlined($inliner)
->render();
$visual = CssToAttributeConverter::fromDomDocument($inliner->getDomDocument())
->convertCssToVisualAttributes()
->render();
CSS variables — resolve var(--x) into concrete values:
$evaluated = CssVariableEvaluator::fromDomDocument($dom)->evaluateVariables()->render();
Wrap it in your own service, not a singleton of CssInliner. CssInliner is constructed via the static fromHtml()/fromDomDocument() factories and carries per-document state — binding it as a singleton in a container is a footgun (shared DOM across requests, stale option flags):
// app/Services/EmailInliner.php
namespace App\Services;
use Pelago\Emogrifier\CssInliner;
class EmailInliner
{
public function inline(string $html, ?string $css = null): string
{
$inliner = CssInliner::fromHtml($html)
->addExcludedCssSelector('.ad-banner')
->addAllowedMediaType('handheld');
foreach (config('emogrifier.excluded_selectors', []) as $selector) {
$inliner->addExcludedCssSelector($selector);
}
return $inliner->inlineCss($css)->render();
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(EmailInliner::class);
Use it from a Mailable, a Blade directive, or a queued job:
public function handle(EmailInliner $inliner): void
{
$html = $inliner->inline($this->template, $this->styles);
Mail::html($html, fn ($m) => $m->to($this->recipient)->subject($this->subject));
}
Use renderBodyContent() to assert on the body without HTML document noise:
$body = CssInliner::fromHtml($html)->inlineCss()->renderBodyContent();
$this->assertStringContainsString('style="color: red;"', $body);
render() now throws on internal DOMDocument errors instead of returning an empty string. That is a good change — the old behaviour could silently mail a blank body — but it means every render path needs a guard:
try {
$html = $inliner->inline($template, $css);
} catch (\Throwable $e) {
report($e);
$html = $template; // ship unstyled rather than blank
}
render() no longer fails silently (8.3.0) — any code that treated an empty string as "something went wrong" is now dead code, and any unguarded render() call can throw mid-request or mid-job. Audit mail-sending paths before upgrading; a try/catch with a raw-HTML fallback is the minimum.
Regex blowout behaviour changed (8.3.0) — on an unexpected PCRE backtrack limit:
trigger_error() is called and the original string is returned unmodified (graceful degradation, restoring 8.1.0 behaviour).set_error_handler or your handler config) so you actually learn about it, and set setDebug(true) in local/staging so tests fail loudly.PHP version drift: PHP 8.6 is supported, PHP 7.3 is deprecated. Don't upgrade composer on a 7.3 host without checking CI.
Selector specificity: addExcludedCssSelector('.example') does not match p .example. Broaden with .example, * .example. Pseudo-classes (:hover, :active) are not inlined at all.
Media queries: allowed types default to all, screen, print — add more with addAllowedMediaType(). Outlook ignores @media entirely, so exclude critical rules explicitly.
HTML structure: HtmlNormalizer will insert <head>/<body> and can restructure malformed markup. Deeply nested HTML is fixed in 8.3.0, but still test your worst template.
<style> blocks are removed after inlining by default — disableStyleBlocksParsing() if you need them preserved (e.g. for clients that do support them).
CSS variables: must be defined on :root or an ancestor; unresolved var(--x) stays literal in the output.
Dump intermediate DOM:
file_put_contents('debug.html', $cssInliner->getDomDocument()->saveHTML());
Test selectors in isolation with a two-line fixture before blaming the template.
Catch the new exceptions by type and log the offending HTML — a DOMDocument internal error is almost always malformed markup or invalid UTF-8 (run mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') if needed).
Batch memory: 8.3.0 clears all previous caches on each invocation, which fixes the memory build-up reported in earlier 8.x releases — long-running queue workers can now loop without the old ceiling. Still reuse a single normalizer per batch where practical, and restart workers on a schedule as hygiene.
Custom processor — extend AbstractHtmlProcessor for extra steps (sanitization, tracking-pixel injection), returning $this for chaining.
Config file — centralize options and feed them into your own service (see above):
// config/emogrifier.php
return [
'allowed_media' => ['all', 'screen', 'print', 'handheld'],
'excluded_selectors' => ['.ad-banner', '.ad-banner *'],
];
Post-inlining DOM hooks — getDomDocument() plus DOMXPath for rewrites that CSS can't express:
foreach ((new DOMXPath($cssInliner->getDomDocument()))->query('//img') as $img) {
$img->setAttribute('alt', 'Fallback');
}
Caching — cache inlined output for static templates:
$html = Cache::remember('email:'.md5($template.$css), now()->addHour(), fn () => $inliner->inline($template, $css));
Note the cache key must include the package version if you rely on output stability across upgrades.
!important on critical declarations still wins over email-client defaults; Emogrifier does not strip !important from inline output.How can I help you explore Laravel packages today?