Application · Adapter
Send email through an application-owned port, then choose the transport at the boundary.
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.
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.
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:
$embedIdis null — regular attachment (disposition:attachment). A random embed ID is generated internally but the attachment is not marked as inline.$embedIdis provided — inline attachment (disposition:inline). Use withembed()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):
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.
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
config/services.xml
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory" />
<service id="Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport">
<argument type="service" id="mailer.mailer" />
<argument type="collection" />
</service>
<service id="Fight\Common\Application\Mail\MailService">
<argument type="service" id="Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport" />
<argument type="service" id="Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory" />
</service>
<service id="Fight\Common\Application\Mail\Transport\MailTransport" alias="Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport" />
<service id="Fight\Common\Application\Mail\Message\MailFactory" alias="Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory" />
</services>
</container>
config/services.php
<?php
use Fight\Common\Adapter\Mail\Symfony\SymfonyMailFactory;
use Fight\Common\Adapter\Mail\Symfony\SymfonyMailTransport;
use Fight\Common\Application\Mail\MailService;
use Fight\Common\Application\Mail\Message\MailFactory;
use Fight\Common\Application\Mail\Transport\MailTransport;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$services->set(SymfonyMailFactory::class);
$services->set(SymfonyMailTransport::class)
->arg('$mailer', service('mailer.mailer'))
->arg('$overrides', []);
$services->set(MailService::class)
->arg('$transport', service(SymfonyMailTransport::class))
->arg('$factory', service(SymfonyMailFactory::class));
$services->alias(MailTransport::class, SymfonyMailTransport::class);
$services->alias(MailFactory::class, SymfonyMailFactory::class);
};
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¶
LoggingMailTransport records message metadata before delegation; it is not delivery evidence.