Skip to content

Application · Adapter

Mail

Send email through an application-owned port, then choose the transport at the boundary.

Symfony Mailer fulfills the application mail transport port without moving transport concerns into application code.

Mail lets an application describe a message and request delivery without choosing a provider, framework, or SMTP implementation in its use case. Start with the portable port; select a delivery adapter only in the composition root.

Ownership. Mail has no Mail-specific Domain model. The Application layer owns MailMessage and the MailTransport and MailFactory contracts; the Adapter layer implements delivery through Symfony Mailer, Laravel Mail, or another transport. Your application's composition root binds an adapter implementation to each Application contract. Your Domain owns the business facts and events; the Application layer coordinates the use case and decides when to send.

Dependencies. The portable path requires PHP 8.5+ and this package. It has no framework or provider dependency. Install optional packages only for the delivery path you select.

Install.

composer require johnnickell/fight-common

Start with application code.

Sending from a service.

Application code can build the message directly and depend only on MailTransport. The selected framework or composition root supplies the transport implementation.

use Fight\Common\Application\Mail\Message\MailMessage;
use Fight\Common\Application\Mail\Transport\MailTransport;

final readonly class OrderConfirmationService
{
    public function __construct(private MailTransport $mail) {}

    public function send(Order $order): void
    {
        $subject = sprintf('Order #%d confirmed', $order->id());

        $message = MailMessage::create()
            ->setSubject($subject)
            ->addTo($order->customerEmail(), $order->customerName())
            ->addFrom('orders@example.com', 'Example Store')
            ->addContent(sprintf('<h1>%s</h1>', $subject), MailMessage::CONTENT_TYPE_HTML)
            ->addContent($subject, MailMessage::CONTENT_TYPE_PLAIN);

        $this->mail->send($message);
    }
}

This use case is portable: it is unchanged whether the application binds Symfony, Laravel, a logging decorator, or a deliberate null transport.

Sending with attachments.

When an application chooses the MailService facade, the same dependency can create attachments, create messages, and send them:

final readonly class InvoiceService
{
    public function __construct(private MailService $mail) {}

    public function send(Invoice $invoice): void
    {
        $attachment = $this->mail->createAttachmentFromString(
            $this->generatePdf($invoice),
            sprintf('invoice-%d.pdf', $invoice->number()),
            'application/pdf',
        );

        $message = $this->mail->createMessage()
            ->setSubject('Your Invoice')
            ->addTo($invoice->customerEmail())
            ->addFrom('billing@example.com')
            ->addContent('Please find your invoice attached.', MailMessage::CONTENT_TYPE_PLAIN)
            ->addAttachment($attachment);

        $this->mail->send($message);
    }
}

Testing without delivery.

Bind NullMailTransport explicitly when a test should suppress delivery. It returns no delivery evidence, so tests that need to assert the message should provide a consumer-owned spy instead.

use Fight\Common\Adapter\Mail\Null\NullMailTransport;

$transport = new NullMailTransport();
$transport->send($message);

Configuration formats

Supported delivery paths

Select the adapter at the application boundary. The Application layer owns the two ports—MailTransport for delivery and MailFactory for messages and attachments. Framework integration binds its adapter implementations to those contracts. It does not bind MailService: create that facade in your application's composition root only when a single dependency that combines both ports is useful.

Application Delivery adapter Factory Composition boundary
Symfony SymfonyMailTransport over Symfony MailerInterface SymfonyMailFactory Your Symfony container definitions; the equivalent examples are below.
Laravel LaravelMailTransport over Laravel Illuminate\Contracts\Mail\Mailer LaravelMailFactory Register Fight\Common\Adapter\ServiceContainer\Laravel\MailServiceProvider.
Yii Proven Symfony fallback: SymfonyMailTransport SymfonyMailFactory Define the selected MailerInterface in the application and add the bounded Yii MailServiceProvider.
CodeIgniter Proven Symfony fallback: SymfonyMailTransport SymfonyMailFactory Delegate from the application's app/Config/Services.php to MailServices::mailFactory() and MailServices::mailTransport().
Slim or framework-free Explicit Symfony composition SymfonyMailFactory Construct the two ports in the application's PSR-11 container or bootstrap code.

Laravel's MailServiceProvider binds MailFactory to LaravelMailFactory and MailTransport to LaravelMailTransport; it deliberately leaves MailService application-owned. Yii's bounded provider similarly binds the two ports to the application-defined Symfony MailerInterface. CodeIgniter's MailServices delegate returns those same two Symfony fallbacks because its native email API has not proven the full Fight mail contract.

Before configuring a path, install its optional dependencies: Symfony uses symfony/mailer; Laravel uses laravel/framework; Yii's Symfony fallback uses yiisoft/di and symfony/mailer; and CodeIgniter's Symfony fallback uses codeigniter4/framework and symfony/mailer. These are Composer suggestions, not Fight Common production requirements; they match the selected capability seams in composer.json and the framework-support contract.

Laravel native adapter

Laravel applications use the native adapter rather than the Symfony container definitions below. Register Fight's provider in the application's provider list (bootstrap/providers.php on current Laravel releases, or config/app.php on older releases):

<?php

use Fight\Common\Adapter\ServiceContainer\Laravel\MailServiceProvider;

return [
    App\Providers\AppServiceProvider::class,
    MailServiceProvider::class,
];

The provider resolves Laravel's configured mailer, binds MailTransport to LaravelMailTransport, and binds MailFactory to LaravelMailFactory. The portable service above then receives the Laravel transport without changing its application code. Laravel delivery keeps the Fight message contract—including To, Cc, Bcc, content parts, priority, and attachments—through FightMailMailable.

Slim has no branded mail provider. A Slim or framework-free application chooses its Symfony MailerInterface, then composes the ports explicitly. The same composition also makes the facade choice visible:

<?php

use Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory;
use Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport;
use Fight\Common\Application\Mail\MailService;

$factory = new SymfonyMailFactory();
$transport = new SymfonyMailTransport($mailer);
$mail = new MailService($transport, $factory);

$mailer is the application-selected Symfony\Component\Mailer\MailerInterface. If a use case needs only delivery or message creation, inject the corresponding port instead of the facade.

A transport-abstraction layer for sending email. Messages are built via a fluent DTO (MailMessage) and sent through any MailTransport implementation. A MailService facade combines transport + factory into a single dependency.

Application\Mail
├── MailService                         — Facade: MailTransport + MailFactory
├── Message\
│   ├── MailMessage                     — Mutable message DTO (fluent builder)
│   ├── MailFactory (interface)         — createMessage(), createAttachment*(), generateEmbedId()
│   ├── Attachment (interface)          — getId(), getBody(), getFileName(), getContentType(),
│   │                                      getDisposition(), embed()
│   └── Priority (enum: int)            — HIGHEST..LOWEST
├── Transport\
│   └── MailTransport (interface)       — send(MailMessage): void
└── Exception\
    └── MailException                   — extends SystemException

Adapter\Mail
├── Symfony\
│   ├── SymfonyMailTransport            — MailTransport → Symfony MailerInterface
│   ├── SymfonyMailFactory              — MailFactory → SymfonyAttachment
│   └── SymfonyAttachment               — Attachment: fromString / fromPath, inline support
├── Laravel\
│   ├── LaravelMailTransport            — MailTransport → Laravel Mailer
│   ├── LaravelMailFactory              — MailFactory → SymfonyAttachment
│   └── FightMailMailable               — Laravel Mailable → Symfony Email
├── Logging\
│   └── LoggingMailTransport            — Decorator: logs metadata then delegates
└── Null\
    └── NullMailTransport               — No-op (tests / dev)

MailMessage

Fight\Common\Application\Mail\Message\MailMessage

A mutable, fluent DTO for building email messages. Use MailMessage::create() then chain setters.

use Fight\Common\Application\Mail\Message\MailMessage;
use Fight\Common\Application\Mail\Message\Priority;

$message = MailMessage::create()
    ->setSubject('Welcome!')
    ->addFrom('noreply@example.com', 'Example App')
    ->addTo('user@example.com', 'Alice')
    ->addContent('<h1>Hello</h1>', MailMessage::CONTENT_TYPE_HTML)
    ->addContent('Hello', MailMessage::CONTENT_TYPE_PLAIN)
    ->setPriority(Priority::HIGH);

Fields

Method Signature Description
setSubject (string $subject) Email subject line
addFrom (string $address, ?string $name) Sender address
addTo (string $address, ?string $name) Primary recipient
addReplyTo (string $address, ?string $name) Reply-To header
addCc (string $address, ?string $name) Carbon copy
addBcc (string $address, ?string $name) Blind carbon copy
addContent (string $body, string $contentType, ?string $charset) Body part (HTML or plain)
setSender (string $address, ?string $name) Sender header (overrides From for delivery)
setReturnPath (string $address) Bounce address
setCharset (string $charset) Character set (default utf-8)
setPriority (Priority $priority) Priority (default NORMAL)
setTimestamp (int $timestamp) UNIX timestamp for Date header
setMaxLineLength (int $maxLineLength) RFC 5322 line length (clamped to 998)
addAttachment (Attachment $attachment) File attachment

Every setter returns static for fluent chaining. Every field has a corresponding getter (getSubject(), getTo(), etc.).

Content Parts

Call addContent() multiple times to build a multipart message. The Symfony transport maps CONTENT_TYPE_HTML (text/html) to $email->html() and CONTENT_TYPE_PLAIN (text/plain) to $email->text().

$message
    ->addContent('<h1>Hello</h1>', MailMessage::CONTENT_TYPE_HTML)
    ->addContent('Hello', MailMessage::CONTENT_TYPE_PLAIN);

Each content part stores content, content_type, and charset (defaults to the message's charset if not specified).

Constants

Constant Value
MailMessage::DEFAULT_CHARSET 'utf-8'
MailMessage::CONTENT_TYPE_HTML 'text/html'
MailMessage::CONTENT_TYPE_PLAIN 'text/plain'

MailService (Facade)

Fight\Common\Application\Mail\MailService

Implements both MailTransport and MailFactory, delegating to injected implementations. This is the recommended way to depend on mail in application services — one dependency gives you send(), createMessage(), and attachment creation.

final readonly class MailService implements MailTransport, MailFactory
{
    public function __construct(
        private MailTransport $transport,
        private MailFactory $factory,
    ) {}
}
class WelcomeEmailService
{
    public function __construct(private MailService $mailer) {}

    public function send(User $user): void
    {
        $message = $this->mailer->createMessage()
            ->setSubject('Welcome!')
            ->addTo($user->email(), $user->name())
            ->addFrom('noreply@example.com')
            ->addContent('<h1>Welcome</h1>', MailMessage::CONTENT_TYPE_HTML);

        $this->mailer->send($message);
    }
}

MailTransport

Fight\Common\Application\Mail\Transport\MailTransport

interface MailTransport
{
    /** @throws MailException */
    public function send(MailMessage $message): void;
}

Implementations

Implementation Namespace Purpose
SymfonyMailTransport Adapter\Mail\Symfony Production — wraps Symfony MailerInterface
LaravelMailTransport Adapter\Mail\Laravel Production — wraps Laravel Mailer
LoggingMailTransport Adapter\Mail\Logging Dev — logs message metadata then delegates
NullMailTransport Adapter\Mail\Null Test — silent no-op

SymfonyMailTransport

Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport

Maps every MailMessage field to Symfony Mime Email. Supports address overrides for dev/staging:

$transport = new SymfonyMailTransport(
    $symfonyMailer,
    ['to' => ['dev@example.com'], 'cc' => [], 'bcc' => []]
);

When overrides are set, all To/Cc/Bcc from the message are replaced with the override addresses. Each override accepts a comma-separated string or an array of strings.

Delivery, failures, and safe operation

SymfonyMailTransport builds the Symfony Email and calls MailerInterface::send() inline. The Fight adapter owns no queue, retry, worker, or durable outbox. A selected Symfony Mailer configuration may dispatch the mail through Messenger and enqueue it before delivery, however. The application owns retry policy, delayed delivery, worker supervision, idempotency, and any durable outbox; queue a use case or message descriptor rather than assuming this transport supplies those concerns.

SymfonyMailTransport translates failures thrown while building an email or handing it to MailerInterface::send() into MailException. Attachment creation is also a failure boundary: SymfonyAttachment::fromPath() throws MailException when the file cannot be opened, and attachment conversion during send() is translated in the same way. If Messenger accepts the mail for later delivery, a worker delivery failure cannot surface to the original caller as MailException; the application owns how it observes and handles that outcome. Catch and classify MailException at the application boundary where the business outcome is known.

Recipient overrides are a consequential safety switch, not an additive routing rule. Any non-empty override map removes every original To, Cc, and Bcc recipient before applying the supplied values. Supplying only to therefore sends to the override To list and leaves Cc and Bcc empty; use explicit values for every recipient class required in the target environment.

LoggingMailTransport

Fight\Common\Adapter\Mail\Logging\LoggingMailTransport

Decorator that logs message metadata via PSR-3 before calling the inner transport:

$transport = new LoggingMailTransport(
    new SymfonyMailTransport($symfonyMailer),
    $logger,
    LogLevel::INFO   // default DEBUG
);

It logs subject, sender, recipient, reply-to, return-path, and other message metadata before delegation. Those fields can be personal or sensitive even though message bodies and attachments are not logged here. Choose a protected log sink, apply retention/redaction policy, and do not wrap a production transport with this decorator by default merely for delivery diagnostics.

NullMailTransport

Fight\Common\Adapter\Mail\Null\NullMailTransport

Silent no-op. send() does nothing. Useful in tests.

$transport = new NullMailTransport();

It is appropriate for tests and deliberate development suppression, but it provides no delivery evidence and must not be used to model a successful production mail path.


MailFactory

Fight\Common\Application\Mail\Message\MailFactory

interface MailFactory
{
    public function createMessage(): MailMessage;
    public function createAttachmentFromString(
        string $body,
        string $fileName,
        string $contentType,
        ?string $embedId = null
    ): Attachment;
    public function createAttachmentFromPath(
        string $path,
        string $fileName,
        string $contentType,
        ?string $embedId = null
    ): Attachment;
    public function generateEmbedId(): string;
}

The included adapter implementations are SymfonyMailFactory (Adapter\Mail\Symfony) and LaravelMailFactory (Adapter\Mail\Laravel).

$factory = new SymfonyMailFactory();

$message   = $factory->createMessage();
$attachment = $factory->createAttachmentFromString($pdf, 'invoice.pdf', 'application/pdf');
$embedId    = $factory->generateEmbedId();

Attachment

Fight\Common\Application\Mail\Message\Attachment

interface Attachment
{
    public function getId(): string;
    public function getBody(): mixed;       // string | resource
    public function getFileName(): string;
    public function getContentType(): string;
    public function getDisposition(): string;  // 'inline' | 'attachment'
    public function embed(): string;           // 'cid:<id>'
}

SymfonyAttachment (Adapter\Mail\Symfony) is the sole implementation.

Creating Attachments

use Fight\Common\Adapter\Mail\Symfony\SymfonyAttachment;

// From a content string
$attachment = SymfonyAttachment::fromString(
    $pdfBinary,
    'invoice.pdf',
    'application/pdf'
);

// From a file path
$attachment = SymfonyAttachment::fromPath(
    '/tmp/receipt.pdf',
    'receipt.pdf',
    'application/pdf'
);

Inline vs Regular

The disposition is determined by whether $embedId is provided:

  • $embedId is null — regular attachment (disposition: attachment). A random embed ID is generated internally but the attachment is not marked as inline.
  • $embedId is provided — inline attachment (disposition: inline). Use with embed() for CID references in HTML.
// Inline — for embedding in HTML
$image = SymfonyAttachment::fromString(
    $pngData,
    'logo.png',
    'image/png',
    $embedId  // provided → inline
);

// Use in HTML template: <img src="<?= $image->embed() ?>">
// Output: <img src="cid:abc123...">

Priority

Fight\Common\Application\Mail\Message\Priority

A backed integer enum matching RFC priorities:

enum Priority: int
{
    case HIGHEST = 1;
    case HIGH    = 2;
    case NORMAL  = 3;
    case LOW     = 4;
    case LOWEST  = 5;
}

Access the integer value via ->value (PHP backed-enum property):

$priority = Priority::HIGH;
$priority->value;  // 2

$message->setPriority(Priority::HIGHEST);

Symfony Configuration

These are Symfony-container definitions only. Choose the format already used by the application; all three wire the same factory, transport, application-owned facade, and port aliases. Laravel, Yii, CodeIgniter, Slim, and framework-free applications use the delivery paths above rather than translating these definitions into an unrelated container format.

YAML config/services.yaml
services:
    Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory: ~

    Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport:
        arguments:
            $mailer: '@mailer.mailer'
            $overrides: []

    Fight\Common\Application\Mail\MailService:
        arguments:
            $transport: '@Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport'
            $factory: '@Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory'

    Fight\Common\Application\Mail\Transport\MailTransport:
        alias: Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport

    Fight\Common\Application\Mail\Message\MailFactory:
        alias: Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory

Environment-specific overrides

Symfony applications can replace recipients in development or suppress delivery in tests without changing application code:

# config/packages/dev/common_mail.yaml
services:
    Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport:
        arguments:
            - '@mailer.mailer'
            - to: ['dev-team@example.com']

# config/packages/test/common_mail.yaml
services:
    Fight\Common\Application\Mail\Transport\MailTransport:
        alias: Fight\Common\Adapter\Mail\Null\NullMailTransport

Usage Examples

The primary service, attachment, and test examples are intentionally at the start of this guide. These additional variations cover inline images and development diagnostics.

Sending with an inline image

$embedId = $mail->generateEmbedId();
$logo = $mail->createAttachmentFromPath(
    '/assets/logo.png',
    'logo.png',
    'image/png',
    $embedId,
);

$message = $mail->createMessage()
    ->setSubject('Welcome')
    ->addTo($email)
    ->addFrom('noreply@example.com')
    ->addContent(sprintf('<img src="%s" alt="Logo">', $logo->embed()), MailMessage::CONTENT_TYPE_HTML)
    ->addAttachment($logo);

$mail->send($message);

Development diagnostics

$transport = new LoggingMailTransport(
    new NullMailTransport(),
    $logger,
    LogLevel::DEBUG,
);

LoggingMailTransport records message metadata before delegation; it is not delivery evidence.