starstruck0207/swiftmailerdbbundle
Symfony bundle that integrates SwiftMailer with Doctrine so outgoing emails can be spooled to the database instead of memory or files. Helps queue and track mail for reliable delivery in apps already using Doctrine.
Before anything else, decide whether you should adopt this at all. The package is a fork of whiteoctober/swiftmailer-db-bundle, which has been unmaintained since the Symfony 2.x/3.x era. It depends on swiftmailer/swiftmailer, which is itself end-of-life. If you are on Symfony 4.4+, or can move to symfony/mailer with the Doctrine transport, do that instead — Symfony's symfony/doctrine-messenger transport solves the same problem (durable, DB-backed mail queue) and is maintained. Adopt this bundle only if you are pinned to a legacy Symfony app and need DB spooling with minimal churn.
Minimum steps to a working spool:
Require the package and confirm what you actually got:
composer require starstruck0207/swiftmailerdbbundle
Then check the installed namespace and service IDs, because forks routinely rename them:
composer show starstruck0207/swiftmailerdbbundle
bin/console debug:container | grep -i spool
Expect the namespace to still be WhiteOctober\SwiftMailerDBBundle, not something matching the Composer name.
Register the bundle. On Symfony 2/3, add it to app/AppKernel.php:
new WhiteOctober\SwiftMailerDBBundle\WhiteOctoberSwiftMailerDBBundle(),
On Symfony 4+, config/bundles.php — but verify the bundle class actually supports Flex registration; many old bundles do not.
Point SwiftMailer's spool at the bundle's DB spool service in config.yml:
swiftmailer:
spool:
type: service
id: white_october_swift_mailer_db.spool
Confirm the ID from debug:container above. The transport (swiftmailer.transport) stays whatever you already use — SMTP, sendmail, etc. The spool only changes buffering, not delivery.
Create the spool table. The bundle ships a Doctrine entity but no automatic schema management. Run:
bin/console doctrine:schema:update --dump-sql
Inspect the output for the spool table (typically swiftmailer_spool), then apply it. In production, write an explicit Doctrine migration rather than using schema:update.
Smoke test end-to-end:
$message = (new \Swift_Message('test'))->setFrom('[email protected]')->setTo('[email protected]')->setBody('hi');
$mailer->send($message);
Then verify the row landed:
SELECT id, created_at, LENGTH(message) FROM swiftmailer_spool;
And flush it:
bin/console swiftmailer:spool:send --env=prod
First real use case: the canonical one — you have multiple web front-ends behind a load balancer and a single worker/cron host that is allowed to talk to your SMTP relay. The file spool cannot do this (each web node has its own queue on its own disk). The DB spool gives you one shared queue and one place to drain it.
Cron-driven flush (the standard pattern). The web request only writes to the spool table; nothing is sent inline. A single cron entry or supervisor process drains it:
*/2 * * * * cd /var/www/app && bin/console swiftmailer:spool:send --env=prod --message-limit=200
Keep the interval shorter than your alerting tolerance, and the message limit low enough that one flush run does not exceed your SMTP provider's rate limit or the cron overlap window.
Dedicated worker command with failure handling. The stock swiftmailer:spool:send command is fire-and-forget: if your SMTP relay rejects a message, the spool row may already be gone. For anything important, wrap it in a custom command that reads rows, attempts delivery, and only deletes on success — or moves failures to a swiftmailer_spool_failed table:
protected function execute(InputInterface $input, OutputInterface $output)
{
$conn = $this->getDoctrine()->getConnection();
$rows = $conn->fetchAllAssociative(
'SELECT id, message FROM swiftmailer_spool ORDER BY id LIMIT 100'
);
foreach ($rows as $row) {
try {
$this->transport->send(unserialize($row['message']));
$conn->delete('swiftmailer_spool', ['id' => $row['id']]);
} catch (\Throwable $e) {
$this->logger->error('spool send failed', ['id' => $row['id'], 'e' => $e]);
$conn->update('swiftmailer_spool', ['attempts' => ...], ['id' => $row['id']]);
}
}
}
This is the most common reason people end up forking the bundle in the first place, so check the fork's spool class before assuming the stock behavior is safe.
Transactional mail inside a unit of work. Because the spool is a DB row, you can — and should — enqueue mail inside the same Doctrine transaction as the business write:
$em->transactional(function ($em) use ($mailer, $order) {
$em->persist($order);
$mailer->send($confirmationEmail); // writes spool row in the same tx
});
If the transaction rolls back, the email is never queued. This is the single biggest argument for DB spooling over file spooling, and it works only if the spool's connection is the same entity manager — verify it is, and that white_october_swift_mailer_db.spool is wired to your doctrine.orm.default_entity_manager (not a separate DBAL connection to the same database).
Separate databases for app and queue. A refinement many teams use: configure the spool against a read/write connection to a different database than the business data. That way a mail storm cannot contend with OLTP queries for locks. This requires overriding the spool service definition (see extension points below).
Observability. Treat the spool table as a queue and instrument it:
SELECT COUNT(*) FROM swiftmailer_spool on a scrape interval, exported as a gauge.SELECT MIN(created_at) FROM swiftmailer_spool — alert if it exceeds your SLO.Bulk mail and notification fan-out. For newsletters, this bundle is a poor fit — you will be serializing thousands of full MIME messages (attachments included) into a table with no built-in batch semantics. Use it for transactional/one-off mail; use a proper bulk provider (SES, Mailgun batches) for marketing blasts.
Extension points worth knowing:
services.yml to swap in your own Swift_Spool implementation (adds locking, retries, priority).attempts, priority, last_error, tenant_id columns. The bundle's entity is typically not final and has a configurable table name via parameters.You are adopting an abandoned package with zero stars and zero dependents. There is no community to catch regressions, no security review, and no upstream fixes. Fork it into your own VCS, pin a commit hash, and treat it as your code. Do not expect upstream PRs to land.
Name and namespace will not match the Composer package. starstruck0207/swiftmailerdbbundle almost certainly still declares WhiteOctober\SwiftMailerDBBundle in its namespace and class names. If the fork changed the bundle class name but not the PSR-4 path (or vice versa), the bundle will fail to autoload with a confusing Class not found. Check composer.json autoload.psr-4 and the bundle class file before debugging anything else.
swiftmailer:spool:send deletes on read. Many spool implementations fetch a batch and delete it before/while sending. A mid-batch SMTP failure silently loses messages. If you need delivery guarantees, replace the flush command with your own (see Implementation Patterns) and make deletion conditional on transport success. This is a data-loss bug waiting to happen on the stock implementation.
Concurrency will double-send. Nothing in the default spool prevents two workers (two cron runs overlapping, or a manually run command alongside cron) from picking up the same rows. Mitigations, in order of preference:
flock) or Symfony's Lock component.FOR UPDATE SKIP LOCKED (MySQL 8+, Postgres) to your custom fetch query.Table growth is unbounded. Serialized MIME bodies with attachments can be hundreds of KB each. Add a retention policy: purge rows older than N days (a created_at index helps), and alert on table size. Consider storing only a pointer to a blob store if attachments are common.
The schema is your responsibility. There is no migration shipped with this bundle and no doctrine:migrations:diff integration out of the box. On a fresh environment your first $mailer->send() fails with a table-not-found error that looks like a configuration problem but is not. Make the table part of your deploy migrations.
Spool type vs. transport confusion. Setting swiftmailer.spool.type: service changes where messages are buffered, not how they are delivered. If mail never arrives, check in this order: (1) is the row being inserted? (2) does the flush command run? (3) does the transport config (swiftmailer.transport) reach the relay? All three must be true; a failure at any one is symptomatically "no mail."
--message-limit has an undocumented interaction with your relay's rate limits. Run the spool flush with a limit tuned to your SMTP provider's throughput (e.g. --message-limit=50 for a 1 msg/sec relay), or you will get throttled and then, per the delete-on-read issue, lose mail.
Disabling SwiftMailer's mailer in test. In config_test.yml, set the spool to memory so unit tests don't hit the DB at all:
swiftmailer:
spool: { type: memory }
Otherwise tests silently write to a real queue and your CI suite sends real email when a flush runs.
Symfony 4+/5 incompatibility is likely. If the bundle's DependencyInjection extension uses ContainerBuilder APIs removed after Symfony 3.x, or the services.yml uses old-style service definitions without autowire/public support, you will get container compilation errors. Budget a half-day for a compatibility patch, and consider whether that effort is better spent migrating to symfony/mailer.
unserialize() on spool rows is a trust boundary. Anyone who can write to the spool table can put a PHP object payload in message. Restrict DB access accordingly, and if you rewrite the spool, prefer a serialization format that does not instantiate arbitrary classes.
Check whether the fork actually differs from upstream. With 0 stars and 0 dependents, this may be an unmodified mirror (someone's accidental publish) or a one-line patch. Diff it against whiteoctober/swiftmailer-db-bundle before assuming added value; if it is identical, you are better off depending on the original by name for at least some chance of finding documentation and Stack Overflow answers.
How can I help you explore Laravel packages today?