freshheads/mailer-bundle
Freshheads MailerBundle is a Symfony bundle that adds a configurable mailer service to your app, making it easier to compose and send transactional emails. It centralizes mailer configuration so application code can dispatch messages cleanly.
Treat the bundle as "Laravel Mailables for Symfony." You get a mailable abstraction — a PHP class that owns recipient, subject, Twig template, context variables, and attachments — plus a mailer service that renders and hands the finished Email to Symfony Mailer's transport. If you've written app/Mail/OrderConfirmation.php in Laravel, you've already used the mental model; only Blade becomes Twig and Mail::send() becomes a service call.
Minimal path:
composer require freshheads/mailer-bundle — then verify it resolves against your symfony/mailer and framework version before writing any code (see Gotchas).README.md first, then src/ for two things: the base Mailable/AbstractMailable class and the mailer service it registers. Those two files define the entire public API surface.config/bundles.php if Flex doesn't do it, and check DependencyInjection/Configuration.php for the root config key (likely fh_mailer) and what it defaults — particularly the default from address.config/packages/mailer.yaml via MAILER_DSN. Start with null://null locally so nothing accidentally leaves your machine.First use case: password reset or order confirmation — the classic single-recipient transactional mail with one Twig template. Create src/Mail/PasswordResetMail.php, put the template in templates/emails/password_reset.html.twig, inject the mailer service into the controller or a handler, call send(). This replaces the hand-rolled new Email() → setTo() → renderView() → send() block you'd otherwise repeat in five places.
One mailable per email type, constructor takes domain objects. new OrderConfirmationMail($order) beats new Mailable()->setTo(...)->setSubject(...) at the call site. The class owns the template name and the variable mapping; controllers stay thin. This is exactly the discipline Laravel Mailables enforce, and it's the main reason to adopt the bundle at all.
Separate construction from rendering. Keep the template path and context-building logic in the mailable, keep the Twig file dumb. Namespace templates as emails/<domain>/<name>.html.twig so a designer can find them without grepping PHP.
Inject, don't instantiate. The mailer service should come through the container (autowiring or services.yaml), so tests can swap it and so a decorator can add logging later.
Send async via Messenger. The bundle composes; it almost certainly does not queue. Wrap the send in a SendEmailMessage handler or dispatch a domain event and let a listener build the mailable. If you queue the mailable object itself, keep it plain-serializable — no closures, no attached Doctrine entities with lazy proxies.
Test at two levels. Unit: build the mailable, render it to a string, assert on subject and a couple of body strings — fast, no transport. Functional: use Symfony's MailerAssertionsTrait (assertEmailIsSent, assertEmailTextBodyContains) against the profiler. Don't test the bundle's rendering; test that your template got the variables it expected.
Attachments and embedded images. Inline images are the usual reason people reach for a wrapper — if the bundle exposes an attachFromPath/embed style API, use it; if not, drop to the underlying Email object from inside the mailable and set them there. Don't fight the abstraction.
Multiple recipients / cc / bcc / reply-to. These belong on the mailable's construction, not sprinkled at call sites across the app. If you find yourself calling setCc() in three controllers, add a constructor parameter.
Compatibility is the real risk, not the API. Last release January 2023, zero stars, zero dependents. symfony/mailer has moved since — run composer why-not symfony/mailer and read the bundle's composer.json constraints before committing. If it only allows ^5.x/^6.0 and you're on 7.x, you're adopting a fork, not a package.
"Nothing sends" is almost never the bundle. It's MAILER_DSN unset, or an envelope/from address that the transport rejects. Check the default from precedence: bundle config versus per-mailable setFrom(). Set it once in config and stop thinking about it.
Escape-by-default is your friend, but context is raw. Twig autoescaping handles the body. The trap is passing pre-rendered HTML into a context variable and then |raw-ing it — you've now built an injection vector into an email. Keep context values as data.
Config key mismatch. Small bundles rename root keys between minors. If fh_mailer: throws "unrecognized option," open Configuration.php and read the actual tree rather than guessing from the README.
Extension points, in order of usefulness: (1) override templates at the app level if the bundle exposes a template path config; (2) decorate the mailer service to add logging, tenant-specific from rewriting, or a dev-mode "redirect all mail to me" interceptor — this is the highest-value ten lines you'll write on top of any mailer wrapper; (3) listen on Symfony Mailer's MessageEvent for global auditing. Don't fork the bundle for anything short of a hard incompatibility.
A Laravel developer's honest calculus: you cannot install this in a Laravel app — it's a Symfony bundle, wired to the DI container and bundle config, and Laravel already ships a strictly better version of this pattern. Its value to you is (a) when you're dropped into a Symfony codebase and want familiar ergonomics, or (b) as a reference implementation. The abstraction is roughly 100–150 lines: a base class with to/subject/template/context/attach, a render call, and a send(). Given the maintenance signal here, read src/, copy the design, and own it — unless you're inheriting a codebase already using it, in which case keep it and pin your versions.
How can I help you explore Laravel packages today?