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

Inky Extra Laravel Package

twig/inky-extra

Twig extension that provides the inky_to_html filter, converting Zurb Inky email templates into responsive, table-based HTML. It brings Inky's component-driven markup to Twig-based apps, letting you write cleaner email layouts that render reliably across mail clients.

View on GitHub
Deep Wiki
Context7

Getting Started

twig/inky-extra is a thin wrapper: it registers a single Twig filter, inky_to_html, backed by ZURB Inky (via the PHP port lorenzo/pinky). There is no config file, no service provider, and no facade — it is a class you hand to a Twig\Environment.

Install

composer require twig/inky-extra:^3.26

Pulling ^3.26 matters: that release fixes CVE-2026-46637 (XSS / pre-escaping on the HTML-emitting extras). Anything earlier is a liability in an email path that interpolates user data.

Smallest possible wire-up

use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\Extra\Inky\InkyExtension;

$twig = new Environment(
    new FilesystemLoader(resource_path('views/emails')),
    ['cache' => storage_path('framework/views/twig')]
);

$twig->addExtension(new InkyExtension());

echo $twig->render('welcome.inky.twig', ['user' => $user]);

First template — wrap the whole document in {% apply %}

{# resources/views/emails/welcome.inky.twig #}
{% apply inky_to_html %}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>{{ email_css }}</style>
  </head>
  <body>
    <container>
      <row>
        <columns small="12" large="8">
          <h1>Hello {{ user.name }}</h1>
          <p>Thanks for your order #{{ order.number }}.</p>
          <button href="{{ receiptUrl }}">View receipt</button>
        </columns>
        <columns small="12" large="4">
          <callout class="secondary">Total: {{ order.total }}</callout>
        </columns>
      </row>
    </container>
  </body>
</html>
{% endapply %}

Where to look first

  1. vendor/twig/inky-extra/src/InkyExtension.php — the whole package, ~20 lines. Confirms the filter name, the is_safe declaration, and the Pinky version it pins.
  2. The Twig docs page for inky_to_html (twig.symfony.com) — the only official documentation.
  3. ZURB Inky component docs (container / row / columns / button / callout / menu / spacer / wrapper). The component vocabulary is Inky's, not this package's.

Best first use case: a transactional email with a table-ish layout — order confirmation, receipt, password reset. These are the ones Inky's 12-column grid actually earns its keep on, and they're low-variance, so the migration risk is small.


Implementation Patterns

A dedicated renderer, not a global Twig switch

Laravel is Blade-first. Do not try to convert the app to Twig to get Inky. Build one service that owns a Twig environment scoped to email views:

namespace App\Mail;

use Twig\Environment;
use Twig\Extra\Inky\InkyExtension;
use Twig\Loader\FilesystemLoader;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;

class InkyRenderer
{
    private Environment $twig;

    public function __construct()
    {
        $this->twig = new Environment(
            new FilesystemLoader([
                resource_path('views/emails'),
                resource_path('views/emails/partials'),
            ]),
            [
                'cache'         => storage_path('framework/views/twig'),
                'auto_reload'   => app()->isLocal(),
                'strict_variables' => ! app()->isProduction(),
            ]
        );

        $this->twig->addExtension(new InkyExtension());
    }

    public function render(string $template, array $data = [], ?string $css = null): string
    {
        $html = $this->twig->render($template, $data);

        if ($css !== null) {
            $html = (new CssToInlineStyles())->convert($html, $css);
        }

        return $html;
    }
}

Bind it as a singleton in AppServiceProvider, and pull the CSS from a real file (resources/css/emails.css) so you can edit it without touching PHP.

The ordering rule: Inky first, inliner second

Inky emits table markup — it does not inline styles. Inline the CSS after inky_to_html runs, because Inky creates new <table>/<td> elements that your stylesheet needs to target. Reversing the order leaves the generated grid unstyled in Gmail.

Path 1 — send the rendered string (simplest)

Mail::html(
    app(InkyRenderer::class)->render('welcome.inky.twig', $data, $emailCss),
    function ($message) use ($user) {
        $message->to($user->email)->subject('Welcome to Acme');
    }
);

Because Mail::html() is synchronous, wrap it in a job for anything volume-facing:

class SendWelcomeEmail implements ShouldQueue
{
    public function __construct(public User $user) {}

    public function handle(InkyRenderer $renderer): void
    {
        Mail::html(
            $renderer->render('welcome.inky.twig', [
                'user' => $this->user,
                'receiptUrl' => URL::signedRoute('dashboard'),
            ], file_get_contents(resource_path('css/emails.css'))),
            fn ($m) => $m->to($this->user->email)->subject('Welcome to Acme')
        );
    }
}

Path 2 — keep Mailable features via a passthrough view

If you need ->to(), ->locale(), attachments, or ->assertSent(), keep a Mailable and hand the pre-rendered HTML to a one-line Blade view.

resources/views/emails/raw.blade.php:

{!! $html !!}
class WelcomeMail extends Mailable implements ShouldQueue
{
    public function __construct(public User $user) {}

    public function build()
    {
        return $this
            ->view('emails.raw', [
                'html' => app(InkyRenderer::class)->render(
                    'welcome.inky.twig',
                    ['user' => $this->user, 'receiptUrl' => URL::signedRoute('dashboard')],
                    file_get_contents(resource_path('css/emails.css'))
                ),
            ])
            ->subject('Welcome to Acme');
    }
}

Use the signed URL generated in PHP, never {{ route(...) }} inside the template — keeps Twig dumb and the URLs testable.

Compose with includes, not with string concatenation

Twig partials work normally inside the apply block:

{% apply inky_to_html %}
<!DOCTYPE html>
<html>
  <head><meta charset="utf-8"><style>{{ email_css }}</style></head>
  <body>
    <wrapper>
      {% include 'header.inky.twig' with { user: user } %}
      <container>
        {% block content %}{% endblock %}
      </container>
      {% include 'footer.inky.twig' %}
    </wrapper>
  </body>
</html>
{% endapply %}

A base.inky.twig plus per-email {% extends %} gives you the "unified email design system" outcome without a second templating layer. Keep partials one level deep; Inky's grid gets fragile when a <row> opens in one file and closes in another.

Preview route + snapshot tests

Add a dev-only route that renders a template with fixture data and dumps it to the browser — this is the fastest inner loop you'll get short of Litmus:

// routes/web.php, local env only
Route::get('/dev/emails/{name}', fn (string $name) => response(
    app(InkyRenderer::class)->render("{$name}.inky.twig", EmailFixtures::for($name), $emailCss)
));

Then lock the output with a snapshot assertion:

it('renders the order confirmation', function () {
    $html = app(InkyRenderer::class)->render('order-confirmation.inky.twig', [
        'user'  => User::factory()->make(['name' => 'Ada']),
        'order' => Order::factory()->make(['number' => 'A-1001']),
        'receiptUrl' => 'https://example.test/receipt',
    ]);

    expect($html)
        ->toContain('Hello Ada')
        ->toContain('<table')          // Inky actually ran
        ->toContain('<!--[if mso]>')   // Outlook scaffolding survived
        ->toMatchSnapshot();
});

That single test catches the two failure modes that actually reach production: the filter not running, and MSO conditional comments being stripped by a bad regex or a CSS inliner.

Validate every template in CI

A small artisan command that walks resources/views/emails/*.inky.twig, renders each with fixture data, and fails on Twig parse errors or on output missing <table. Run it before deploy — a broken Inky template fails at send time otherwise, which is the worst possible moment.


Gotchas and Tips

Tag names are bare — <row>, not <inky:row>. Inky is parsed from ordinary HTML tags. If you write <inky:columns large="6"> you get literal unknown elements passed through untouched, and a silently broken email. Pinky only rewrites the tags it recognises.

inky_to_html is a filter, not a tag. The ergonomic form is {% apply inky_to_html %} … {% endapply %} around the whole document. Using {{ whole_template_string|inky_to_html }} from PHP means you're passing untrusted-ish input into the filter and fighting the v3.26 escaping change.

The 3.26.0 escaping change is a real behavioural break. CVE-2026-46637 made the HTML-emitting extras pre-escape their input. If you upgrade from 3.20–3.24, diff your rendered output before shipping — particularly templates where a variable was fed directly into the filter. Do not reflexively add |raw to "fix" double-escaped entities; fix the interpolation instead. Keep variables inside {{ }} (autoescaped) and let the filter handle literal markup only.

Inky does not inline CSS, and Inky's CSS isn't bundled. Pinky outputs structure. You still need a stylesheet (Foundation for Emails, or your own) plus tijsverkoyen/css-to-inline-styles — already a Laravel dependency via the mailer, so new CssToInlineStyles() is free. Emails that "work in the preview but look broken in Gmail" are almost always the missing inlining step.

Column widths must sum to 12 per row. <columns small="12" large="6"> twice per <row> is fine; large="7" + large="6" produces a wrapped, mangled table. Inky won't warn.

<wrapper> wraps <container>, not the other way round. <wrapper> is the full-width background band; <container> is the centred content box. Nesting them backwards is the most common reason a background colour only paints a 600px strip.

Buttons need href and short labels. <button href="..."> generates a bulletproof VML table for Outlook; without href you get an <a> with no target, and long labels overflow the generated <td>.

Twig's cache directory is not Blade's. php artisan view:clear will not touch storage/framework/views/twig. Add rm -rf storage/framework/views/twig/* to your deploy script, or set auto_reload => false in production and accept that you must clear it manually. Sharing the directory with Blade causes confusing "view not found" collisions.

Turn on strict_variables outside production. Twig's default is to render missing variables as empty strings — which means a typo'd {{ user.nmae }} ships an email that says "Hello ," and nobody notices until a customer tweets about it. Enable strict mode in tests and local; keep it off in prod so a data gap doesn't 500 your queue.

Pinky lags ZURB Inky (the Node original). New components or attributes added upstream may not have landed in the PHP port. If a documented Inky feature does nothing, check vendor/lorenzo/pinky before assuming your template is wrong. When in doubt, compile once with npx inky and diff against Pinky's output for the same source.

Malformed nesting fails silently. Unclosed <row>/<columns> typically produces output rather than an exception — just subtly wrong output. The CI render command and the toContain('<table') assertions are your safety net; don't rely on Inky to complain.

Dark mode and CSS custom properties are a dead end. prefers-color-scheme inside <style> is stripped by Gmail and Outlook.com. Inky gives you responsive structure, not theming — if dark mode is a requirement, plan for it explicitly (or look at MJML/<style>-tolerant clients only) rather than assuming Inky handles it.

Testing hooks. Mailable::assertSent() works with the passthrough-view pattern; with Mail::html() use Mail::fake() and assert the closure — or just unit-test InkyRenderer directly, which is where the real logic lives. Keep the renderer the unit under test and treat the send path as plumbing.

Extension point. Since the whole package is one filter registration, if you need an inky_to_text companion or a custom filter set, subclass InkyExtension or add your own TwigFilter to the same Environment — there is no plugin surface beyond addExtension().

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.
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator