Skip to content

Send Email with a Third-Party Library in Winter Boot

Winter Boot ships no mailer — email goes through a third-party library wired as ordinary beans. This guide uses Symfony Mailer (symfony/mailer): DSN-configured transports, a Mailer bean, a small EmailService for templated messages, and #[Async] delivery so SMTP latency never blocks a request.

Require the package and point a DSN at your SMTP server. For local development use MailHog (smtp://127.0.0.1:1025, web UI on port 8025); for staging, Mailtrap’s sandbox SMTP credentials:

Terminal window
composer require symfony/mailer

Custom keys live straight in application.yml and are injected with #[Value]:

config/application.yml — additions
mail:
dsn: "$env.MAIL_DSN"
from: "no-reply@example.com"
templates: "templates/emails"

($env.MAIL_DSN needs the EnvPropertySource registered under propertySources — see Configuration. Set MAIL_DSN=smtp://127.0.0.1:1025 locally.)

The DSN also carries the SMTP username and password for servers that need authentication:

Terminal window
MAIL_DSN="smtp://USERNAME:PASSWORD@smtp.mailtrap.io:2525"

URL-encode both values (%40 for @, %3A for :) when they contain reserved characters. Because the whole DSN — credentials included — lives in the environment variable, no secret is ever committed to application.yml.

Three files: a configuration exposing the transport and mailer, a service that builds and sends messages, and a plain-PHP template (no engine needed — output buffering plus htmlspecialchars is enough for transactional mail). Switch between them:

The transport comes from the DSN, so switching providers (MailHog → Mailtrap → SES) is a config change, not a code change.

<?php
declare(strict_types=1);
namespace dev\example\config;
use dev\winterframework\stereotype\Bean;
use dev\winterframework\stereotype\Configuration;
use dev\winterframework\stereotype\Value;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\TransportInterface;
#[Configuration]
class MailConfig
{
#[Value('${mail.dsn}')]
protected string $dsn;
#[Bean]
public function mailTransport(): TransportInterface
{
return Transport::fromDsn($this->dsn);
}
#[Bean]
public function mailer(TransportInterface $transport): Mailer
{
return new Mailer($transport);
}
}
  • send() blocks until the SMTP exchange finishes — fine for a reply-to contact form, wrong for a post-registration mail inside a request handler.
  • sendAsync() returns immediately and runs in a worker process. It needs #[EnableAsync] on the application class (and Swoole, like all workers):
MyApplication.php — additions
use dev\winterframework\stereotype\task\EnableAsync;
#[WinterBootApplication(configDirectory: ['config'])]
#[EnableAsync]
class MyApplication {
}

Tune the pool in application.yml (winter.task.async: { poolSize: 4, queueCapacity: 1000 } — see Async Tasks). Fire-and-forget means the caller never sees the result, which is exactly why the service logs every failure: an exception in a worker with no logging vanishes silently.

  • Attachments are one call: $email->attachFromPath('/invoices/42.pdf') before send().
  • Bulk mail is not a loop. A thousand sendAsync() calls flood the pool and the SMTP server. For newsletters or digests, enqueue the recipient list and drain it with a scheduled job or a DTCE worker at the provider’s rate limit.
  • Test without SMTP. Symfony Mailer’s InMemoryTransport collects sent messages for assertions — swap it in a test #[Bean] and assert on subject/recipients instead of mocking the service.
  • Never log bodies or addresses together. Recipient + content in one log line is a privacy incident waiting for a log aggregator. Log the recipient on failure (for support triage), never the rendered body.
  • Validate the sender domain. SPF/DKIM/DMARC belong to your DNS, not your code — without them, everything above lands in spam.
  1. Start MailHog and set MAIL_DSN=smtp://127.0.0.1:1025.
  2. Trigger sendWelcome() (a temporary controller endpoint or a unit test).
  3. Open http://127.0.0.1:8025 — the message is there with subject and HTML body.
  4. Stop MailHog, trigger again — the call returns false, the app stays up, and the failure is in the logs with the recipient but no body.
  • Async Tasks — pool tuning behind sendAsync().
  • Scheduling — digest and newsletter drains.
  • Logging — log levels and handlers for the failure lines above.