{"id":418,"date":"2026-09-18T03:00:32","date_gmt":"2026-09-18T08:30:32","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=418"},"modified":"2026-09-18T09:30:33","modified_gmt":"2026-09-18T15:00:33","slug":"why-php-mail-fails-in-production-and-what-to-use-instead","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/why-php-mail-fails-in-production-and-what-to-use-instead\/","title":{"rendered":"Why PHP mail() Fails in Production and What to Use Instead"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Your contact form works on localhost. You push to the production server and the messages stop arriving. <code>mail()<\/code> still returns <code>true<\/code>, PHP throws no error, and the logs show nothing unusual.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The function is behaving exactly as documented. The problem is that <code>mail()<\/code> was designed in an era when servers ran their own mail transfer agents and receiving servers accepted almost anything. Neither is true now, and the function has no way to meet modern authentication requirements.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide explains what <code>mail()<\/code> actually does when you call it, why that fails today, the security issue most developers never hear about, and how to replace it without rewriting every call in your codebase.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: Why Is PHP mail() Not Working?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>mail()<\/code> hands your message to a local mail program on the web server. It cannot authenticate, cannot use TLS, and gives no delivery feedback. Most hosts either disable it or route it through an IP with no sending reputation, so receiving servers filter or reject the message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to send through authenticated SMTP using PHPMailer or Symfony Mailer instead:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>use PHPMailer\\PHPMailer\\PHPMailer;\n\n$mail = new PHPMailer(true);\n$mail-&gt;isSMTP();\n$mail-&gt;Host       = 'smtp.photonrelay.com';\n$mail-&gt;Port       = 587;\n$mail-&gt;SMTPAuth   = true;\n$mail-&gt;Username   = getenv('SMTP_USER');\n$mail-&gt;Password   = getenv('SMTP_PASS');\n$mail-&gt;SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;\n\n$mail-&gt;setFrom('noreply@yourdomain.com', 'Your App');\n$mail-&gt;addAddress('user@example.com');\n$mail-&gt;Subject = 'Your verification code';\n$mail-&gt;Body    = 'Your code is 492019.';\n\n$mail-&gt;send();<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An authenticated relay such as <a href=\"https:\/\/www.photonconsole.com\/\">PhotonConsole<\/a> supplies the credentials and handles the reputation side that <code>mail()<\/code> cannot.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What mail() Actually Does<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.php.net\/manual\/en\/function.mail.php\" target=\"_blank\" rel=\"noopener\">PHP manual<\/a> describes <code>mail()<\/code> as sending mail, but the function itself sends nothing. On Linux it writes your message to the <code>sendmail<\/code> binary configured in <code>php.ini<\/code>. On Windows it opens a direct SMTP connection to the host in the <code>SMTP<\/code> directive, usually without authentication.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In both cases the function hands the message off and stops caring. Its return value tells you whether the handoff succeeded \u2014 not whether anything was delivered.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>What <code>mail()<\/code> returns<\/th><th>What it actually means<\/th><\/tr><\/thead><tbody><tr><td><code>true<\/code><\/td><td>The local mail program accepted the message for processing<\/td><\/tr><tr><td><code>false<\/code><\/td><td>The handoff failed, usually because no mail program exists<\/td><\/tr><tr><td>Nothing about delivery<\/td><td>There is no return value for delivered, bounced or filtered<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This is why <code>mail()<\/code> returning <code>true<\/code> tells you almost nothing. The message can be discarded a millisecond later and your code will never know.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Five Reasons It Fails<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"577\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM-1024x577.png\" alt=\"Diagram showing what happens when PHP mail is called: handed to a local mail program with no authentication, no TLS and no delivery feedback\" class=\"wp-image-423\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM-1024x577.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM-767x432.png 767w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM-1536x865.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_53_19-PM.png 1671w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">mail() reports success at the handoff, long before delivery is decided.<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">1. It Cannot Authenticate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">SMTP authentication proves a sender is permitted to use a mail server. <code>mail()<\/code> has no parameter for a username or password because it was never designed to connect to an authenticated server. Receiving providers increasingly treat unauthenticated mail as suspect by default.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. The Envelope Sender Does Not Match Your Domain<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">SPF checks the envelope sender, not the <code>From:<\/code> header you set in your headers array. With <code>mail()<\/code> the envelope sender is usually the web server&#8217;s system user, something like <code>www-data@srv-1234.hostingcompany.net<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your message claims to be from your domain while the envelope says otherwise, so SPF alignment fails. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a> explains why alignment matters, and the free <a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">email deliverability checker<\/a> shows what your domain currently publishes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Hosts Disable or Throttle It<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Compromised PHP applications are a common spam source, so many hosts disable <code>mail()<\/code> entirely, cap it per hour, or silently route it to a null destination. Cloud providers including AWS also restrict outbound port 25 by default, documented in the <a href=\"https:\/\/repost.aws\/knowledge-center\/ec2-port-25-throttle\" target=\"_blank\" rel=\"noopener\">AWS port 25 throttle removal process<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. Shared Server Reputation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On shared hosting your mail leaves from the same IP as every other site on that server. One spamming neighbour is enough to get the IP blocklisted, and your transactional mail goes down with it through no fault of your own.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. No TLS in Transit<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Messages handed to a local agent without TLS can traverse the network unencrypted. Beyond the privacy problem, <a href=\"https:\/\/support.google.com\/mail\/answer\/81126\" target=\"_blank\" rel=\"noopener\">Google&#8217;s sender guidelines<\/a> expect encrypted transmission, and unencrypted mail is weighed against you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Checking Whether mail() Works At All<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Run <code>php -i | grep sendmail_path<\/code> to see whether a mail program is configured<\/li>\n\n\n\n<li>Check <code>disable_functions<\/code> in <code>php.ini<\/code> for <code>mail<\/code><\/li>\n\n\n\n<li>Test the return value: <code>var_dump(mail(...))<\/code> \u2014 <code>false<\/code> means no local agent<\/li>\n\n\n\n<li>Check your host&#8217;s mail logs, usually under <code>\/var\/log\/mail.log<\/code><\/li>\n\n\n\n<li>If it returns <code>true<\/code> and nothing arrives, the problem is authentication, not PHP<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">The Security Problem Nobody Mentions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Beyond deliverability, <code>mail()<\/code> carries a well-known injection risk when any part of the headers comes from user input.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Vulnerable \u2014 never do this\n$headers = \"From: \" . $_POST&#91;'email'] . \"\\r\\n\";\nmail($to, $subject, $message, $headers);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Because headers are separated by newlines, an attacker who submits an email field containing a newline followed by <code>Bcc:<\/code> can add recipients of their own. The result is a contact form that quietly sends spam on someone else&#8217;s behalf, from your domain and your server.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Building header strings by concatenating anything a user submitted. Even with validation added later, string-built headers remain fragile because the safety depends on every call site getting it right. PHPMailer and Symfony Mailer construct headers programmatically and reject values containing newlines, which removes the entire class of vulnerability rather than patching one instance of it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What to Use Instead<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Option 1: PHPMailer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The most widely used library for this, and the closest replacement if your codebase already calls <code>mail()<\/code> directly.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>composer require phpmailer\/phpmailer<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\nrequire 'vendor\/autoload.php';\n\nuse PHPMailer\\PHPMailer\\PHPMailer;\nuse PHPMailer\\PHPMailer\\Exception;\n\nfunction send_email(string $to, string $subject, string $html, string $replyTo = ''): bool\n{\n    $mail = new PHPMailer(true);\n\n    try {\n        $mail-&gt;isSMTP();\n        $mail-&gt;Host       = getenv('SMTP_HOST');\n        $mail-&gt;Port       = (int) getenv('SMTP_PORT');\n        $mail-&gt;SMTPAuth   = true;\n        $mail-&gt;Username   = getenv('SMTP_USER');\n        $mail-&gt;Password   = getenv('SMTP_PASS');\n        $mail-&gt;SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;\n        $mail-&gt;Timeout    = 10;\n        $mail-&gt;CharSet    = 'UTF-8';\n\n        $mail-&gt;setFrom(getenv('MAIL_FROM'), getenv('MAIL_FROM_NAME'));\n        $mail-&gt;addAddress($to);\n\n        if ($replyTo !== '') {\n            $mail-&gt;addReplyTo($replyTo);\n        }\n\n        $mail-&gt;isHTML(true);\n        $mail-&gt;Subject = $subject;\n        $mail-&gt;Body    = $html;\n        $mail-&gt;AltBody = strip_tags($html);\n\n        $mail-&gt;send();\n        return true;\n    } catch (Exception $e) {\n        error_log('Mail failed: ' . $mail-&gt;ErrorInfo);\n        return false;\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/github.com\/PHPMailer\/PHPMailer\" target=\"_blank\" rel=\"noopener\">PHPMailer repository<\/a> documents the full API. Note <code>AltBody<\/code> \u2014 sending a plain text alternative alongside HTML improves spam filter scores.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Option 2: Symfony Mailer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A better fit for modern applications, and the transport layer Laravel uses internally.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>composer require symfony\/mailer<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\nrequire 'vendor\/autoload.php';\n\nuse Symfony\\Component\\Mailer\\Mailer;\nuse Symfony\\Component\\Mailer\\Transport;\nuse Symfony\\Component\\Mime\\Email;\n\n$dsn = sprintf(\n    'smtp:\/\/%s:%s@%s:%d',\n    urlencode(getenv('SMTP_USER')),\n    urlencode(getenv('SMTP_PASS')),\n    getenv('SMTP_HOST'),\n    (int) getenv('SMTP_PORT')\n);\n\n$mailer = new Mailer(Transport::fromDsn($dsn));\n\n$email = (new Email())\n    -&gt;from('noreply@yourdomain.com')\n    -&gt;to('user@example.com')\n    -&gt;subject('Your verification code')\n    -&gt;text('Your code is 492019.')\n    -&gt;html('&lt;p&gt;Your code is &lt;strong&gt;492019&lt;\/strong&gt;.&lt;\/p&gt;');\n\n$mailer-&gt;send($email);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/symfony.com\/doc\/current\/mailer.html\" target=\"_blank\" rel=\"noopener\">Symfony Mailer documentation<\/a> covers transports and DSN configuration in depth. Note the <code>urlencode()<\/code> calls \u2014 passwords containing special characters break a DSN otherwise, which is a common source of authentication failures.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Migrating an Existing Codebase<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"577\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM-1024x577.png\" alt=\"Migration diagram showing scattered PHP mail calls replaced by a single wrapper function that sends through authenticated SMTP\" class=\"wp-image-421\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM-1024x577.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM-767x432.png 767w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM-1536x865.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-18-2026-02_51_55-PM.png 1671w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">One wrapper replaces every call site without rewriting the application.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">You rarely need to rewrite every call site. Define a wrapper with the same shape as <code>mail()<\/code>, then change the calls to point at it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\/**\n * Drop-in replacement for mail() using authenticated SMTP.\n * Change mail(...) to smtp_mail(...) at each call site.\n *\/\n\nuse PHPMailer\\PHPMailer\\PHPMailer;\nuse PHPMailer\\PHPMailer\\Exception;\n\nfunction smtp_mail(string $to, string $subject, string $message, array $options = &#91;]): bool\n{\n    static $mail = null;\n\n    if ($mail === null) {\n        $mail = new PHPMailer(true);\n        $mail-&gt;isSMTP();\n        $mail-&gt;Host       = getenv('SMTP_HOST');\n        $mail-&gt;Port       = (int) getenv('SMTP_PORT');\n        $mail-&gt;SMTPAuth   = true;\n        $mail-&gt;Username   = getenv('SMTP_USER');\n        $mail-&gt;Password   = getenv('SMTP_PASS');\n        $mail-&gt;SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;\n        $mail-&gt;SMTPKeepAlive = true;   \/\/ reuse the connection\n    }\n\n    try {\n        $mail-&gt;clearAllRecipients();\n        $mail-&gt;clearReplyTos();\n\n        $mail-&gt;setFrom(getenv('MAIL_FROM'), getenv('MAIL_FROM_NAME'));\n        $mail-&gt;addAddress($to);\n\n        if (!empty($options&#91;'reply_to'])) {\n            $mail-&gt;addReplyTo($options&#91;'reply_to']);\n        }\n\n        $mail-&gt;Subject = $subject;\n        $mail-&gt;Body    = $message;\n        $mail-&gt;isHTML(!empty($options&#91;'html']));\n\n        return $mail-&gt;send();\n    } catch (Exception $e) {\n        error_log('smtp_mail failed for ' . $to . ': ' . $mail-&gt;ErrorInfo);\n        return false;\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details make this work well in production. <code>SMTPKeepAlive<\/code> reuses one connection across multiple sends in the same request instead of reconnecting each time. And <code>clearAllRecipients()<\/code> on each call prevents recipients accumulating across sends, which is the most common bug when reusing a PHPMailer instance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">PHPMailer SMTP Connection Errors<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>SMTP connect() failed<\/code> \u2014 wrong host or port, or the host blocks outbound SMTP<\/li>\n\n\n\n<li><code>SMTP Error: Could not authenticate<\/code> \u2014 wrong credentials, or whitespace copied into them<\/li>\n\n\n\n<li>Connection hangs \u2014 port 587 needs STARTTLS, port 465 needs SMTPS. Do not mix them<\/li>\n\n\n\n<li>Enable <code>$mail->SMTPDebug = 2<\/code> temporarily to see the full SMTP conversation<\/li>\n\n\n\n<li>Try port 2525 if your host blocks both 587 and 465<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Port Reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Port<\/th><th>PHPMailer setting<\/th><th>When to use<\/th><\/tr><\/thead><tbody><tr><td>587<\/td><td><code>ENCRYPTION_STARTTLS<\/code><\/td><td>Recommended default<\/td><\/tr><tr><td>465<\/td><td><code>ENCRYPTION_SMTPS<\/code><\/td><td>When the provider requires implicit SSL<\/td><\/tr><tr><td>2525<\/td><td><code>ENCRYPTION_STARTTLS<\/code><\/td><td>When the host blocks 587 and 465<\/td><\/tr><tr><td>25<\/td><td>None<\/td><td>Avoid \u2014 blocked by nearly all hosts<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring PHP With PhotonConsole<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two steps: authenticate your domain in DNS, then set the environment variables the code above reads.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>TXT    @                    v=spf1 include:relay.photonconsole.com ~all\nCNAME  photon._domainkey    dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">DNS changes are not instant. SPF and DKIM records can take from a few minutes to 24-48 hours to propagate depending on your registrar and TTL settings. Check you do not already publish a second SPF record \u2014 two separate TXT records starting with <code>v=spf1<\/code> break authentication entirely.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SMTP_HOST=smtp.photonrelay.com\nSMTP_PORT=587          # 465 for implicit SSL, 2525 if 587 is blocked\nSMTP_USER=your_project_api_user\nSMTP_PASS=your_secret_api_key\nMAIL_FROM=noreply@yourdomain.com\nMAIL_FROM_NAME=\"Your App\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Port 2525 exists for hosts that block the standard SMTP ports, which resolves the shared hosting and cloud provider problems described above. Every PhotonConsole account includes 5,000 free emails per month, enough to migrate and validate the full path before any spend. Details are on the <a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay page<\/a>, and <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">pricing<\/a> has no monthly minimum.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you are comparing options first, our analysis of <a href=\"https:\/\/photonconsole.com\/blog\/free-smtp-servers\/\">free SMTP servers<\/a> covers where each stops being practical.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Framework-Specific Notes<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">WordPress<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>wp_mail()<\/code> wraps PHPMailer but defaults to the <code>mail()<\/code> transport, so it inherits every problem above. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/wordpress-not-sending-email-fix\/\">fixing WordPress not sending email<\/a> covers the plugin and code approaches.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Laravel<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Laravel uses Symfony Mailer internally and never touches <code>mail()<\/code> when the SMTP mailer is configured. See our guide to <a href=\"https:\/\/photonconsole.com\/blog\/laravel-mail-configuration-production\/\">Laravel mail configuration<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">CodeIgniter and Legacy Frameworks<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Older framework email classes often default to the <code>mail<\/code> protocol. Switch the protocol setting to <code>smtp<\/code> and supply host, port and credentials rather than replacing the framework&#8217;s mail class.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Plain PHP Scripts and Cron Jobs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The wrapper function above works unchanged in CLI scripts. Remember that CLI PHP often reads a different <code>php.ini<\/code>, so environment variables set for the web server may not be present for cron.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Never send from the visitor&#8217;s address.<\/strong> Put their address in Reply-To and send from your own domain, or SPF and DMARC will fail.<\/li>\n\n\n\n<li><strong>Keep credentials out of source control.<\/strong> Read them from environment variables, not from a config file committed to the repository.<\/li>\n\n\n\n<li><strong>Turn SMTPDebug off before deploying.<\/strong> Debug output can appear in responses and expose your host and username.<\/li>\n\n\n\n<li><strong>Log failures with the SMTP response.<\/strong> <code>$mail->ErrorInfo<\/code> contains the actual server reply, which usually names the cause.<\/li>\n\n\n\n<li><strong>Verify DNS after any change.<\/strong> <a href=\"https:\/\/mxtoolbox.com\/\" target=\"_blank\" rel=\"noopener\">MXToolbox<\/a> checks SPF, DKIM and blocklist status in a single lookup.<\/li>\n\n\n\n<li><strong>Score a real send before launch.<\/strong> <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> flags authentication problems before users encounter them.<\/li>\n\n\n\n<li><strong>Add rate limiting to public forms.<\/strong> An open contact endpoint becomes a spam relay within days of going live.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Issues You May Hit Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-authentication-error\/\">SMTP authentication errors<\/a> when credentials are rejected despite looking correct<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP connection timeouts<\/a> when the connection hangs<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-not-working\/\">SMTP not working<\/a> across the ten most common failure modes<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/why-emails-go-to-spam-in-gmail\/\">Emails landing in Gmail spam<\/a> despite a successful send<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/emails-sent-but-not-delivered\/\">Emails sent but not delivered<\/a> when the server reports success<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Why does mail() return true but no email arrives?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The return value only confirms the local mail program accepted the message. It says nothing about delivery. The message can be discarded, rejected or filtered afterwards with no feedback to your code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is PHP mail() deprecated?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No, it remains part of PHP. But it cannot authenticate or use TLS, so it fails modern deliverability requirements regardless of its supported status.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need Composer to use PHPMailer?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Composer is the recommended route. PHPMailer can be included manually by requiring its class files, which is useful on legacy hosting without Composer access.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Which is better, PHPMailer or Symfony Mailer?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">PHPMailer is the easier migration from existing <code>mail()<\/code> code and works on older PHP versions. Symfony Mailer has a cleaner API and suits modern applications. Both send authenticated SMTP correctly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I keep using mail() for low volume?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You can, but volume is not the issue. A single password reset that lands in spam is as damaging as a hundred, and the header injection risk applies at any volume.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does authentication fail when the password is correct?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two usual causes: whitespace copied into the credential, or special characters in a password inside a DSN string without URL encoding. Check both before assuming the credentials are wrong.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I still need SPF and DKIM with a relay?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. The relay delivers the message, but the DNS records prove your domain authorised it. Without them, delivery is far less reliable regardless of which relay you use.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PHP&#8217;s <code>mail()<\/code> function is not broken. It does what it was built to do, which is hand a message to a local mail program and report whether that handoff worked. Everything modern email delivery requires \u2014 authentication, TLS, alignment between envelope and header, delivery feedback \u2014 sits outside what the function can offer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Replacing it is usually a short job. A wrapper with the same signature lets you change call sites incrementally rather than rewriting the application, and it closes the header injection risk at the same time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What remains after the code change is infrastructure: whether your host permits outbound SMTP, and whether receiving providers trust your sending domain. A dedicated <a href=\"https:\/\/www.photonconsole.com\/relay.php\">transactional email solution<\/a> handles authentication, routing and reputation using the configuration shown above. Developers working across stacks may also want our guides to <a href=\"https:\/\/photonconsole.com\/blog\/laravel-mail-configuration-production\/\">Laravel mail configuration<\/a> and <a href=\"https:\/\/photonconsole.com\/blog\/sending-email-in-python-smtplib-vs-an-email-api-with-working-code\/\">sending email in Python<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">Free Email Deliverability Checker<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/wordpress-not-sending-email-fix\/\">Fixing WordPress Not Sending Email<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/laravel-mail-configuration-production\/\">Laravel Mail Configuration for Production<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP Configuration: Complete Setup Reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Your contact form works on localhost. You push to the production server and the messages stop arriving. mail() still returns true, PHP throws no error, and the logs show nothing unusual. The function is behaving exactly as documented. The problem is that mail() was designed in an era when servers ran their own mail transfer [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":419,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[312],"tags":[535,533,534],"class_list":["post-418","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-email-engineering-guide","tag-and-how-to-switch-to-smtp-without-a-rewrite","tag-php-mail-returns-true-but-nothing-arrives-here-is-what-the-function-actually-does","tag-why-hosts-block-it"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/418","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=418"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/418\/revisions"}],"predecessor-version":[{"id":424,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/418\/revisions\/424"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/419"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=418"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=418"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=418"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}