generalmedia/twigemailrenderbundle
Generalmedia TwigEmailRenderBundle is a Symfony bundle for rendering email templates with Twig. It helps streamline building HTML email content in Symfony apps by integrating Twig-based email template rendering.
This is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config publish tag. Before anything else, clone the repo and read, in this order:
src/ (or Service/) — the renderer class itself. It is almost certainly a thin wrapper: Twig\Environment::render($template, $context) plus, at most, CSS inlining and a plain-text pass.DependencyInjection/*Extension.php + Resources/config/services.yaml — this is where the Symfony container wiring lives and where the real behaviour (template path, cache dir, inliner settings) is configured.Tests/ — fastest way to see a working call with realistic arguments.Do not composer require generalmedia/twigemailrenderbundle into a Laravel app. It declares symfony/framework-bundle and will not boot. The practical first use case for a Laravel developer is one of two things:
A. You are sharing email templates with a Symfony service. Then port the renderer (it's ~50 lines) and point both apps at the same Twig template directory.
B. You just want Twig-rendered HTML mail in Laravel. Then use twig/twig directly:
composer require twig/twig
// app/Services/TwigEmailRenderer.php
namespace App\Services;
use Twig\Environment;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
class TwigEmailRenderer
{
public function __construct(private Environment $twig) {}
public function render(string $template, array $data = []): string
{
$html = $this->twig->render($template, $data);
return (new CssToInlineStyles())->convert($html);
}
}
// app/Providers/TwigServiceProvider.php
public function register(): void
{
$this->app->singleton(Environment::class, function () {
$twig = new Environment(
new FilesystemLoader([
resource_path('views/emails/twig'),
base_path('shared-templates/emails'),
]),
[
'cache' => storage_path('framework/twig'),
'autoescape' => 'html',
'strict_variables' => ! $this->app->isProduction(),
]
);
$twig->addExtension(new \App\Twig\LaravelHelpersExtension());
return $twig;
});
}
tijsverkoyen/css-to-inline-styles is already a transitive dependency of laravel/framework, so the inliner is free.
Sending from a Mailable. Keep mail transport in Laravel, only the body in Twig. Note build() is not container-resolved, so use app():
class OrderShipped extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Order $order) {}
public function build(): static
{
$html = app(TwigEmailRenderer::class)->render('emails/order-shipped.twig', [
'order' => $this->order->only(['id', 'total', 'shipped_at']),
'cta_url' => route('orders.show', $this->order),
'support' => config('mail.from.address'),
]);
return $this->subject("Order #{$this->order->id} shipped")
->html($html);
}
}
Pass scalars and arrays, not Eloquent models — keeps Twig ignorant of your ORM and makes templates reusable from the Symfony side.
Bridge Laravel helpers into Twig. This is the pattern that makes the port actually ergonomic:
class LaravelHelpersExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('route', fn (string $n, array $p = []) => route($n, $p)),
new TwigFunction('asset_url', fn (string $p) => asset($p)),
new TwigFunction('trans', fn (string $k, array $r = []) => __($k, $r)),
];
}
}
Only expose what email templates need. Avoid exposing config() and env() — templates become debugging nightmares.
Layout inheritance for email. Mirror your Blade mail::message layout as a Twig base:
{# emails/layout.twig #}
<!doctype html>
<html><body style="margin:0;padding:0;background:#f4f4f4">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<table role="presentation" width="600" style="background:#fff">
<tr><td>{% block content %}{% endblock %}</td></tr>
</table>
</td></tr>
</table>
</body></html>
{% extends "emails/layout.twig" %}
{% block content %}
<h1>Order #{{ order.id }} shipped</h1>
<a href="{{ cta_url }}">Track it</a>
{% endblock %}
Local preview route. Indispensable when the designer is writing Twig and you're writing PHP:
Route::get('/dev/email/{template}', function (string $template) {
abort_unless(app()->isLocal(), 404);
return app(TwigEmailRenderer::class)
->render("emails/{$template}.twig", ['order' => (object) ['id' => 1234, 'total' => '42.00']]);
})->where('template', '[a-z0-9\-/]+');
Snapshot-test the output. Since the template is the product, assert on rendered HTML rather than on mocked mail calls:
it('renders the shipped email', function () {
$html = app(TwigEmailRenderer::class)->render('emails/order-shipped.twig', [
'order' => ['id' => 1, 'total' => '10.00'],
'cta_url' => 'https://example.test/o/1',
]);
expect($html)->toContain('Order #1 shipped')
->toContain('https://example.test/o/1');
});
Extension class registers definitions with Symfony's ContainerBuilder. Laravel's container has no idea those exist. You are copying the class body, not the package.strict_variables vs Blade's forgiveness. Blade silently renders null for undefined variables; Twig with strict_variables: true throws Twig\Error\RuntimeError. Enable strict only outside production, and use {{ foo|default('') }} for optional fields.{{ var }} auto-escapes (like {{ }} in Blade); {{ var|raw }} is {!! !!}. Email HTML is full of & in tracked URLs — do not reach for |raw to fix it, build the URL with http_build_query.url() inside a queued job resolves against the worker's APP_URL. Set APP_URL correctly on queue hosts, or call URL::forceRootUrl(config('app.url')) in a service provider.@vite does not exist in Twig. There is no asset pipeline for email anyway. Inline CSS into the template or let the inliner do it — but Gmail strips <style> blocks in some clients, so rely on the CssToInlineStyles pass, not <link> tags.APP_URL, run php artisan config:clear and restart workers (queue:restart). Twig's own compiled cache in storage/framework/twig must be cleared on deploy if templates moved.trans function (emails are the first thing that gets localized), and a {% apply inline_css %} filter if you want inlining inside the template rather than in PHP. The Symfony bundle likely wires one or both via twig.extension tags — replicate that rather than editing the renderer.@component/<x-button> must be rewritten as {% include 'emails/partials/button.twig' with {...} only %}. Use only to avoid leaking the whole context into partials.app/Services with your own test. If you genuinely need the Symfony bundle's config surface, run git log --oneline and read the last three commits — with this repo age, the HEAD is the documentation.dd(). Register new TwigFunction('dump', fn ($v) => logger()->debug(print_r($v, true))), or use {% verbatim %}{{ dump(var) }}{% endverbatim %} with the Twig\Extension\DebugExtension enabled locally. The most common failure is a LoaderError from a path that worked in Symfony but isn't in your FilesystemLoader array — check the loader paths first, not the template syntax.How can I help you explore Laravel packages today?