druidvav/email-bundle
Symfony 4 bundle for transactional emails with Twig subject/text/HTML templates, Emogrifier CSS inlining and optional caching, embedded images, lifecycle events, locale switching, and multiple sender profiles via Symfony Mailer.
First: confirm this is a Symfony bundle, not a Laravel package. druidvav/email-bundle ships a DvEmailBundle (Symfony Bundle + DI Extension) that depends on symfony/framework-bundle, twig-bundle, event-dispatcher, translation, and symfony/mailer. You cannot composer require it into a Laravel app and expect it to boot — there is no service provider, no config/dv_email.php loader, and the API is built around Symfony's service container and event dispatcher. Treat it as either (a) the reference implementation you clone in Laravel, or (b) a Symfony sidecar/service you call over HTTP.
If you're evaluating it inside a Symfony component of a Laravel shop:
composer require druidvav/email-bundle
// config/bundles.php
Druidvav\DvEmailBundle\DvEmailBundle::class => ['all' => true],
Requirements to verify up front: PHP ≥ 7.4, Symfony 4.4 line, Mailer configured under framework.mailer, and at least one MailerInterface service (usually mailer).
Where to look first (in this order):
example.yml — the shape of the dv_email config: message, sender, optional locale.Message\Message — setTo(), setTemplate(), setLocale(), render(), send().Event\ directory — BeforeRenderHtmlEvent, AfterRenderHtmlEvent, BeforeSendEvent, AfterSendEvent.DvEmailAwareTrait — but note it expects a Psr\Container\ContainerInterface locator injected via setDvEmailLocator.First use case — one transactional email:
$message = $container->get('dv_email.message'); // alias for the 'default' message
$message->setTo('[email protected]');
$message->setTemplate('welcome'); // resolves under template_path
$message->setLocale('de');
$message->send(); // renders automatically
Use dv_email.locator when you have more than one message alias and want locator->get('subscribe')-style selection.
First Laravel equivalent you'd build: a Mailable + Blade view + Mail::to()->locale()->send(). The concepts map 1:1 — the value of reading this bundle is the composition pattern (template + subject + text + HTML as one unit, CSS inlined, images embedded, lifecycle events), not the code itself.
Template-per-message with a single Twig root. Configure template_path once, then every message names a template slug that yields subject, text, and html parts. In Laravel, mirror this with a convention: resources/views/mail/{slug}/subject.blade.php, text.blade.php, html.blade.php, resolved by a base Mailable.
CSS inlining as a build step, cached. The bundle inlines an external stylesheet through Pelago Emogrifier and optionally caches the preprocessed HTML in kernel.cache_dir. Day-to-day: edit the CSS, clear cache, re-render a preview. In Laravel, use tijsverkoyen/css-to-inline-styles in a Mailable::build() hook, or precompile at deploy time and cache the compiled HTML in Redis/S3 keyed by slug + locale + css_hash.
Image embedding via cid:. When embed_images is enabled, URLs are rewritten to cid: attachments. Pattern: keep images in a known asset directory, reference them by URL in the template, and let the renderer swap them. Verify your transport preserves multipart/related — some API transports (Mailgun/SES raw) behave differently than SMTP.
Lifecycle events as your extension seam. Four events, dispatched with a single argument, subscribed by FQCN (or the constants on DvEmailEvent):
$dispatcher->dispatch(new BeforeSendEvent($message));
Practical uses:
BeforeRenderHtmlEvent — inject per-recipient data (name, tracking pixel, UTM params) that shouldn't live in the template.AfterRenderHtmlEvent — snapshot the rendered HTML for tests/preview storage.BeforeSendEvent — abort sending (throw) or add headers; also the right place for a "do not send in staging" guard.AfterSendEvent — audit logging.In Laravel these become MailMessage/MessageSending/MessageSent event listeners; a MessageSending listener returning false gives you the same abort semantics as throwing in BeforeSendEvent.
Queue everything. The bundle renders synchronously inside send(). Symfony-side you'd wrap it in Messenger; Laravel-side you implement ShouldQueue on the Mailable. Keep rendering inside the job so the locale and DB state are correct at send time — which is exactly what the bundle's locale listener assumes.
Per-recipient locale. The locale: section installs a listener that swaps RequestContext, translator locale, and optionally Gedmo Translatable, then restores state. Pattern: always setLocale() before send(), never rely on the request's ambient locale. Laravel equivalent: ->locale($user->locale) on the mailable, which does the same swap-and-restore around rendering.
Multiple sender profiles. sender entries map an alias to a mailer service id — e.g. default → mailer, subscribe → a second MailerInterface. Do not look for a bundle-level fallback SMTP setting; failover belongs in MAILER_DSN (failover(smtp1 smtp2)). Laravel equivalent: multiple mailers in config/mail.php and Mail::mailer('subscribe')->....
Message registry. dv_email.locator is a ServiceLocator keyed by message aliases. Pattern: inject the locator, call get($alias), then configure. Don't inject all messages eagerly — the locator is lazy for a reason. Laravel equivalent: a small EmailRegistry class with bind()/make($alias) backed by the container.
The aware trait. DvEmailAwareTrait needs setDvEmailLocator($locator). If you use it, wire it explicitly:
services:
App\SomeService:
calls:
- [setDvEmailLocator, ['@dv_email.locator']]
The trait is fine, but constructor injection of the locator is more testable — prefer it in new code.
Testing pattern. Because rendering is a distinct step (render() produces the MIME message; send() renders-then-sends), you can assert on rendered output without a transport:
$message->setTo('[email protected]')->setTemplate('welcome')->setLocale('en');
$message->render();
$this->assertStringContainsString('cid:logo', $message->getHtml());
In Laravel, the equivalent is Mail::fake() plus a render() on the mailable to snapshot HTML.
No Laravel autoload integration. There's no EmailServiceProvider, no publishable config, no facade. Any "use" in a Laravel app is really a port. Budget for rewriting the DI Extension, the locator, and the event wiring — the message logic is the only part that transfers cheaply.
Version drift is real. PHP ≥ 7.4 / Symfony 4.4 with v3.3.1 (2026-09) as the latest tag. Laravel 11 on PHP 8.3 will not be able to consume this directly; the Symfony 4.4 constraint also means unsupported framework dependencies if you tried a sidecar and kept it modern. If you go the sidecar route, pin the Symfony version and isolate it in its own container.
send() renders for you. Calling render() and then send() renders twice — which means BeforeRenderHtmlEvent/AfterRenderHtmlEvent fire twice and any side effects in your listeners (counter increments, preview writes) double up. Pick one: either render explicitly to inspect, or just send().
Emogrifier is not a browser. Expect limited selector support, stripped media queries, no pseudo-elements, and surprises with @media and shorthand CSS. Build templates as if for Outlook 2007 (tables, inline-safe CSS), and keep the external stylesheet to a single file with a stable hash so the HTML cache invalidates correctly. Always clear the cache dir on CSS deploys — stale preprocessed HTML is the most likely "why didn't my style change" bug.
Events are dispatched with exactly one argument. Subscribers expecting a (event, eventName, dispatcher) signature will break. Write listeners against the event class only. Also, subscription is by FQCN — the DvEmailEvent::* constants are just strings equal to those class names, not a separate mechanism.
Locale state leaks in long-running processes. The locale listener swaps RequestContext, translator locale, and Gedmo state and restores after render. Under a queue worker (Messenger, or Laravel Horizon if you've ported) a thrown exception mid-render can leave the request/translator locale dirty for the next job. Wrap render/send in try/finally, or reset locale at job start. The same discipline applies to your Laravel equivalents.
sender.mailer must name a real MailerInterface service id. A typo produces a container error at first send, not at boot. Verify with debug:container | grep Mailer (Symfony). And remember the failover SMTP chain is a Mailer concern (failover(...) in the DSN) — the bundle has no mailer_fallback key despite what the feature list implies.
cid: embedding depends on the transport. Inline images work because the parts are attached as related MIME entities. API-based transports that accept pre-built MIME are fine; transports that re-serialize (or strip attachments) will break images with no error. Test with a real render against your actual transport, not just a fake.
Extension points, in order of safety: (1) event listeners, (2) overriding template_path and template naming, (3) decorating dv_email.message / building an alternate locator, (4) subclassing Message. Avoid (4) unless necessary — the @return void fix in v3.3.1 was specifically to keep the native DvEmailExtension::load() signature subclass-friendly, which tells you the maintainers expect downstream extension and are willing to patch for it.
Vendor risk is the headline. Zero stars, zero dependents, README-level maturity. Even for a Symfony shop this is "adopt with a fork and a maintenance plan." For a Laravel shop the calculus is clearer: reimplement the pattern, don't adopt the package. The 3–4 ideas worth copying — one template slug yielding subject/text/html, inline CSS with a cache, cid: image embedding, and before/after render/send events — cost perhaps a day to build natively on top of Illuminate\Mail + tijsverkoyen/css-to-inline-styles + MessageSending/MessageSent listeners, and give you PHP 8.x support, queues, and no cross-framework bridge. Also evaluate spatie/laravel-mailables before writing any of it.
How can I help you explore Laravel packages today?