Once more than one script in a codebase sends email, the same fifteen lines of SMTP configuration start appearing in several places — and drift apart. Subclassing PHPMailer puts that configuration in one file, so a changed provider is a one-line edit instead of a search across the project.
Examples target PHPMailer 6.x.
The basic pattern
<?php
namespace App\Mail;
use PHPMailer\PHPMailer\PHPMailer;
class AppMailer extends PHPMailer
{
public function __construct(?bool $exceptions = true)
{
parent::__construct($exceptions);
$this->isSMTP();
$this->Host = getenv('SMTP_HOST');
$this->Port = (int) getenv('SMTP_PORT') ?: 587;
$this->SMTPAuth = true;
$this->Username = getenv('SMTP_USER');
$this->Password = getenv('SMTP_PASS');
$this->SMTPSecure = self::ENCRYPTION_STARTTLS;
$this->CharSet = self::CHARSET_UTF8;
$this->Timeout = 30;
$this->XMailer = ' ';
$this->setFrom(getenv('MAIL_FROM'), getenv('MAIL_FROM_NAME'));
$this->Sender = getenv('MAIL_BOUNCE') ?: getenv('MAIL_FROM');
}
}
Every send now starts with new AppMailer() and is already configured. Two details worth copying: default $exceptions to true rather than null, so failures throw unless someone deliberately opts out; and use self:: for the constants, since the subclass inherits them.
$mail = new AppMailer();
$mail->addAddress('user@example.com');
$mail->Subject = 'Welcome';
$mail->Body = 'Thanks for signing up.';
$mail->send();
Adding message helpers
The next repetition to remove is message construction itself. A method per message type keeps templating out of your controllers:
class AppMailer extends PHPMailer
{
// ... constructor as above ...
public function sendTemplate(string $to, string $subject, string $template, array $data = []): bool
{
$this->addAddress($to);
$this->Subject = $subject;
$html = $this->render($template, $data);
$this->msgHTML($html, __DIR__ . '/templates');
return $this->send();
}
private function render(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
require __DIR__ . "/templates/{$template}.php";
return ob_get_clean();
}
}
msgHTML() is doing real work here: it sets Body, generates AltBody from the markup, and embeds any local images the template references. That is three things you no longer have to remember per message.
Making it safe to reuse
The classic bug with a reused instance is accumulating recipients — message five goes to all five people. Wrap it so that cannot happen:
public function sendTo(string $to, string $subject, string $body): bool
{
try {
$this->addAddress($to);
$this->Subject = $subject;
$this->Body = $body;
return $this->send();
} finally {
$this->clearAddresses();
$this->clearAttachments();
}
}
The finally block runs whether the send succeeded or threw, so state is always clean for the next call. Combine it with SMTPKeepAlive for batches:
$mail = new AppMailer();
$mail->SMTPKeepAlive = true;
foreach ($users as $user) {
try {
$mail->sendTo($user['email'], 'Your digest', renderDigest($user));
} catch (Exception $e) {
error_log("Failed for {$user['email']}: {$mail->ErrorInfo}");
}
}
$mail->smtpClose();
Logging every send
Override send() to record outcomes without touching call sites:
public function send(): bool
{
$recipients = array_column($this->getToAddresses(), 0);
try {
$result = parent::send();
error_log(sprintf('mail ok to=%s subject=%s', implode(',', $recipients), $this->Subject));
return $result;
} catch (\Throwable $e) {
error_log(sprintf('mail FAIL to=%s subject=%s error=%s',
implode(',', $recipients), $this->Subject, $this->ErrorInfo));
throw $e;
}
}
Log $this->ErrorInfo, not the exception message. The exception says SMTP Error: Could not authenticate.; ErrorInfo carries the server’s own reply, which is what identifies the cause.
Rethrow after logging. Swallowing the exception here would give every caller a silent failure — the exact problem exceptions were enabled to prevent.
A safety net for non-production environments
The costly mistake is a staging deployment mailing real customers. Enforce it in the class rather than trusting configuration:
public function send(): bool
{
if (getenv('APP_ENV') !== 'production') {
$intended = array_column($this->getToAddresses(), 0);
$this->clearAllRecipients();
$this->addAddress(getenv('MAIL_CATCHALL'));
$this->Subject = '[' . implode(',', $intended) . '] ' . $this->Subject;
}
return parent::send();
}
Outside production every message is rerouted to one mailbox, with the intended recipients preserved in the subject so testing stays meaningful.
Preset DKIM signing
protected function configureDkim(): void
{
$key = getenv('DKIM_PRIVATE_KEY_PATH');
if ($key && is_readable($key)) {
$this->DKIM_domain = getenv('DKIM_DOMAIN');
$this->DKIM_selector = getenv('DKIM_SELECTOR');
$this->DKIM_private = $key;
$this->DKIM_identity = $this->From;
}
}
Call it from the constructor after setFrom(), since DKIM_identity depends on $From. Guarding on is_readable() means a missing key degrades to unsigned mail instead of throwing — signing is valuable, but not worth taking the send down for.
Testing without sending
preSend() builds the full message without delivering it, which makes assertions straightforward:
public function buildRawMessage(): string
{
$this->preSend();
return $this->getSentMIMEMessage();
}
$mail = new AppMailer();
$mail->addAddress('test@example.com');
$mail->Subject = 'Test';
$mail->Body = 'Body';
$raw = $mail->buildRawMessage();
$this->assertStringContainsString('To: test@example.com', $raw);
$this->assertStringContainsString('Subject: Test', $raw);
For unit tests that must not touch the network at all, inject a mock SMTP instance with setSMTPInstance().
When not to subclass
If you are inside a framework, use its mailer instead. Symfony Mailer and Laravel’s Mail facade are already wired into the container, the queue and the template engine, and reimplementing that on top of PHPMailer costs more than it returns.
Subclassing also stops being the right answer when the class starts accumulating unrelated responsibilities — template rendering, queueing, retry logic, analytics. At that point compose instead: a small service that uses a PHPMailer instance, rather than inherits from it. Inheritance is the cheap win for configuration; composition is the right structure for behaviour.
See also
- Methods Reference — what is available to override.
- Properties Reference — everything the constructor can preset.
- SMTP Example — the configuration being centralised here.
- Examples — complete scripts per transport.
