2lenet/hermes-bundle
HermesBundle is a simple mail manager for the Crudit Bundle: create and send templated emails through Symfony Mailer. Per-template statistics track link clicks, and a statistics flag enables consent management links so recipients can manage consent. Install with composer require 2lenet/hermes-bundle...
Reality check first. 2lenet/hermes-bundle is a Symfony bundle built for CruditBundle admin panels, wired on top of Symfony Mailer. It is not a Laravel package: there is no service provider, no facade, no config/hermes.php, and no Blade support. So the honest starting position for a Laravel developer is a decision: are you bolting this on for its persistence model + templated-send service, or are you better served by Mail + Notifications + a mail_templates table of your own? Adopt only if (a) you need admin-managed, DB-stored email templates with recipient tracking/subscriptions, and (b) you accept running Symfony Mailer alongside — or instead of — Laravel's mailer.
Minimal install path.
composer require 2lenet/hermes-bundle
Then:
docs/index.md in vendor/2lenet/hermes-bundle/ first — the README is a stub that just points there. That file plus docs/img/dashboard.png is the entire onboarding surface.src/ before writing code. The public surface you actually care about is small: the Mail entity (subject, body/template, status, attachments), the Recipient entity (address, status, error message), and the mailer/manager service that renders and sends. Class names and constructor signatures are undocumented — read them, don't guess.Mail / Recipient / subscription tables. In an Eloquent app you either (a) run Doctrine alongside Eloquent for these three tables, or (b) copy the migration schema into Laravel migrations and write a thin Eloquent model over it, losing the bundle's repositories and admin CRUD. Option (b) is usually the pragmatic one.spatie/laravel-symfony-mailer, or configure symfony/mailer directly with a DSN from config/mail.php values so you don't end up with two divergent mail configs.app(MailDispatcher::class)->send($templateKey, $recipients, $vars) — and hide everything Hermes-specific behind it. Given 2 stars and 0 dependents, you want one file to change if you swap it out later.First use case to prove out: a transactional notification (order confirmation / password reset) where the subject and HTML body live in a Mail row rather than a Blade file, and where you can see per-recipient delivery status. If that works end-to-end, the admin-driven campaign flow (the bundle's actual reason to exist) is a short hop; if it doesn't, you've spent an hour instead of a sprint.
Template-per-mail-type. The core value is that a Mail record is the template: subject + body stored in the DB, rendered at send time with variables. Model one row per logical email (order.confirmation, user.welcome), key senders by a stable slug rather than by primary key, and treat the row as content that marketing/support can edit without a deploy. Keep variables explicit — a documented array of expected keys per template — because there is no schema on the body and a missing Twig variable is a runtime error, not a compile-time one.
// app/Mail/TemplateMailer.php — the one file that knows about Hermes
public function send(string $slug, array $to, array $vars = [], array $attachments = []): void
{
$mail = $this->templates->findBySlug($slug); // your lookup, not the bundle's
// delegate to the bundle's mailer/manager service here
}
Transactional sends from application code. Resolve the bundle's mailer service out of the container once, keep it out of controllers, and call it from actions/listeners. Because it renders from stored content, template changes ship without releases — which is the actual day-to-day win versus hand-editing Blade files.
Queue everything. Symfony Mailer sends synchronously and the bundle's send() is annotated to throw (release 1.8.2 explicitly documented the exceptions). Wrap the call in a Laravel job with retries and failed() logging:
final class SendTemplatedMail implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function handle(TemplateMailer $mailer): void
{
$mailer->send($this->slug, $this->to, $this->vars);
}
}
Admin-driven campaigns. This is the bundle's home turf: a Crudit-backed dashboard where an editor creates a mail, picks a recipient sublist, previews, schedules/sends, and can cancel or resend. If you have the Crudit panel, use it as-is. If you're Laravel-only, this flow is the part you'd be reimplementing in Filament/Nova — factor that reimplementation cost into the adoption decision, because it's where most of the bundle's ~130 releases of features live (CC sublists in 1.10.6, resend-recipient in 1.8.2, cancel sending in 1.10.0, attachment deletion in 1.9.3, CC-aware mail status in 1.14.5).
Recipient sublists and CC. Since 1.10.6 a mail can carry a CC sublist independent of the primary recipients. Model this deliberately: one recipient row per address per send — CC addresses included — so status and error text are per-address. Don't fan out a single row to multiple addresses — you lose the per-recipient error tracking added in 1.7.16, which is the best debugging affordance the bundle has. Since 1.14.5 the mail status aggregates CC recipients too (Crudit #668), so a mail is only fully "sent" once CC deliveries resolve; any status computation you write yourself must mirror that, or your UI and the bundle's will disagree.
Attachments. Supported since 1.9.2, deletable since 1.9.3. Store files via Laravel's filesystem and only hand the bundle paths/contents — don't let two abstractions both decide where bytes live, or attachment cleanup becomes orphan-row archaeology.
Localization. 1.11.0 added translatable emails. If you're multilingual, decide now whether that means one Mail row per locale (the safer read of "translatable email") or a translated body field. Mixed conventions mid-project are painful to unwind, and the unsubscribe-link translations (fixed in 1.11.1) live in the same area.
Unsubscribe flow. Treat subscription state as a first-class part of the send path, not an afterthought: generate absolute URLs from a configured base (the 1.8.6 fix was exactly this), route them through a signed URL, and verify they work behind a proxy before any real campaign goes out.
Mail::fake() interception, no Blade. You will write the container binding, the config mapping, and the testing strategy yourself. Budget for that; it's the single biggest hidden cost.recipients and users. Prefer copying the schema into Laravel migrations and writing Eloquent models over those tables — you keep the data, lose the bundle's repositories and admin, and gain a codebase your team can actually read.config/mail.php (Laravel) and Symfony Mailer's DSN are separate sources of truth. If mail silently vanishes, check the Symfony transport/spool first — MAIL_MAILER=log in Laravel does nothing for Symfony Mailer, and vice versa. Configure one from the other at boot.recipients early — one query saves hours. Remember CC addresses are rows too as of 1.14.5's status change.composer require 2lenet/hermes-bundle:1.14.* with a committed lockfile, and be ready to fork.templates/bundles/... — this now includes the status partial introduced in 1.14.5; (3) custom Crudit field/brick if you're in the admin; (4) fork. Anything below (3) is fork territory, so keep the adapter thin enough that you never need it.test transport, not Mail::fake(). Point the Symfony DSN at null:// or the test transport in CI, and assert on the resulting Mail/Recipient rows — that's the contract the bundle actually honours.How can I help you explore Laravel packages today?