Sending email
Building messages, sending over SMTP, and the header-injection and TLS rules that are not optional.
<?php
use PhpOnWheels\Mail\Mailer;
use PhpOnWheels\Mail\Message;
final class WelcomeController implements Handler
{
public function __construct(private readonly Mailer $mailer)
{
}
public function handle(ServerRequest $request): Response
{
$this->mailer->send(
Message::to('ada@example.test')
->subject('Welcome')
->text('Thanks for signing up.'),
);
return Response::redirect('/');
}
}That is the whole common case. The sender comes from configuration, so a message rarely needs one.
Configuration
# array | smtp
# "array" keeps messages in memory instead of sending them.
MAIL_DRIVER=array
MAIL_QUEUE=false
MAIL_HOST=smtp.example.test
# tls (STARTTLS, port 587) | ssl (implicit TLS, port 465) | none
MAIL_ENCRYPTION=tls
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=no-reply@example.test
MAIL_FROM_NAME=phponwheels
MAIL_TIMEOUT=10array collects messages in memory. A development machine that quietly starts delivering real mail to real people is a worse failure than one that sends none, so choosing smtp is something you write down.
Set MAIL_QUEUE=true to persist each message in mail_log as pending instead of contacting the configured driver during the request. The default is false, preserving direct delivery. Run pow mail:send-pending from a scheduler or by hand to deliver queued messages; it updates each row to sent or failed.
Building a message
<?php
Message::to('Ada Lovelace <ada@example.test>')
->addCc('team@example.test')
->addBcc('audit@example.test')
->replyTo('support@example.test')
->subject('Your invoice')
->html('<p>Attached.</p>', 'Attached.') // HTML with a plain-text alternative
->attach(Attachment::fromPath('/srv/app/storage/invoice.pdf'))
->header('X-Campaign', 'invoices');Every call returns a copy, so a half-built message is safe to keep as a template and finish differently per recipient.
| Message | Structure sent |
|---|---|
text() only | A single text/plain part |
html() only | A single text/html part |
| Both | multipart/alternative |
| With attachments | multipart/mixed, nesting the above |
Pass a plain-text alternative to html() where you can: some clients refuse HTML, and a message offering only an HTML part scores worse with spam filters. Date and Message-ID headers are added for you, for the same reason.
Two rules that are not configurable
Header injection is refused, not stripped
<?php
new Address('ada@example.test', "Ada\r\nBcc: victim@example.test");
// InvalidArgumentException: A display name may not contain CR, LF or NUL —
// that is how header injection works.
Message::to('ada@example.test')->subject("Hi\r\nBcc: victim@example.test");
// InvalidArgumentException: Header values may not contain CR, LF or NUL.Everything after a newline in a header becomes further headers of the sender's choosing — extra recipients, a forged From, a second body. Subjects and display names are frequently user-supplied, which is what makes this the defect to design against rather than document.
The same rule holds inside the transport: a line consisting of a single . ends the message, so a leading dot on any body line is doubled before it goes out.
Credentials require an encrypted connection
<?php
new SmtpSettings('mail.example.test', username: 'ada', password: 'hunter2', encryption: SmtpEncryption::None);
// InvalidArgumentException: Refusing to send credentials over an unencrypted
// connection. SMTP AUTH base64-encodes the password, which is not encryption
// — anyone on the path can read it.Set MAIL_ALLOW_INSECURE_AUTH=true if the server really is on localhost. TLS certificate verification, separately, has no setting at all: an unverified connection cannot detect the interceptor it exists to stop, and a mail server's certificate is exactly what one would forge to collect the credentials sent moments later.
Testing
ArrayMailer is the mailer in tests. It validates messages exactly as the real one does, so a test still catches a missing sender or a malformed address.
<?php
$mailer = new ArrayMailer();
$application = Application::boot(static function (Blueprint $app) use ($mailer): void {
$app->container->singleton(Mailer::class, static fn (): Mailer => $mailer);
// ...
});
$application->handle(Requests::post('/register', 'email=ada@example.test'));
self::assertSame(1, $mailer->count());
self::assertTrue($mailer->sentTo('ada@example.test'));
self::assertStringContainsString('Welcome', (string) $mailer->last()?->subjectLine);Register it as scoped() rather than singleton() if the application reads back what it collected. As a singleton under a worker it accumulates every message the process has ever sent, and shows one request's mail to the next.
How the connection is managed
SmtpMailer opens and closes its connection inside send(). Keeping one open across requests would be faster, and would also mean a worker carrying a stateful socket — possibly mid-transaction after a failure — into the next user's request. Reconnecting costs a round trip and removes a class of bug; that is the trade this framework makes everywhere.
Reads are bounded by MAIL_TIMEOUT. A server that accepts a connection and then says nothing would otherwise hold the process for as long as it liked.
Every send is persisted
Both scaffolds wrap the driver-selected mailer in PersistingMailer, so Mailer::class in the container is never the bare driver. Normally, send() writes one row to mail_log with the full message and outcome after the attempt resolves, then still throws MailFailed on the same failures. With MAIL_QUEUE=true, it validates and stores the full message as pending without calling the driver.
<?php
// app/bootstrap.php
$mailer = new PersistingMailer(
MailerFactory::fromEnvironment($env),
new MailLogRepository($database),
$env->bool('MAIL_QUEUE', false),
);
$app->container->singleton(Mailer::class, static fn (): Mailer => $mailer);
// Bound under its concrete type too: CLI mail commands need methods the
// Mailer interface does not declare.
$app->container->singleton(PersistingMailer::class, static fn (): PersistingMailer => $mailer);| Column | Holds |
|---|---|
to_addresses, cc_addresses, bcc_addresses | JSON arrays of header-value strings — the same form Address::parse() reads back |
attachments | JSON: filename, media type and base64-encoded contents — a resend attaches the identical bytes |
status | pending (deferred, not attempted), sent, or failed |
error | The server's reply, on failure — the same string a caller would see in MailFailed::getMessage() |
attempts | Pending messages start at 0; each actual delivery attempt increments it, including a resend |
A validation failure — no recipients, no body, the things assertSendable() catches — is the caller's bug, not a delivery failure, and is deliberately not logged. Only what the Mailer interface's @throws MailFailed actually promises gets recorded.
Resending
pow mail:list, pow mail:send-pending and pow mail:resend — see The phponwheels CLI for every flag.
$ ./pow mail:list --status=failed
4 failed 1 bob@example.test Reminder 2026-01-01T09:14:02+00:00
$ ./pow mail:list --status=pending
5 pending 0 ada@example.test Welcome 2026-01-01T09:15:02+00:00
$ ./pow mail:send-pending
Sent #5
1 sent, 0 failed.
$ ./pow mail:resend 4
Resent #4 to bob@example.test.
$ ./pow mail:resend --failed
2 resent, 0 still failing.Resending is refused for anything that is not currently failed — resending a message already marked sent would deliver it twice with no record that it happened, which defeats the reason the row exists. A resend that fails again updates the same row: the status and error move to the latest attempt, and attempts grows.
mail:send-pending delivers each selected message synchronously in the CLI process. A slow server slows that command; schedule it externally if desired. It continues after a message fails, records the error on that row, and exits non-zero if any send failed. This is deferred storage and manual delivery, not a background worker or automatic retry loop.
Not built
- Automatic retries. A failed send stays
faileduntilpow mail:resendis run by hand or from a cron entry you write — the framework does not retry on its own. - DKIM signing. Sign at the relay — most providers do it for you, and doing it here would mean managing private keys in the application.
- Inline images (
cid:references) andmultipart/related. - Bounce handling. A
MailFailedmeans the server refused the message; a delivery failure after that arrives by email or webhook, out of band.