How to stop your PHP site's emails from landing in spam
Password resets, order emails and contact forms from WordPress or Laravel often end up in spam, or never arrive. Here is why, and how to fix it on your own server.
Why your emails go to spam
Out of the box, WordPress and many PHP apps hand email to the server itself (PHP's mail() function). The server then tries to deliver it to Gmail, Outlook or Yahoo on its own. That usually goes wrong for a few reasons:
- Nothing proves the email is really from you. Anyone can write any From address on an email. Big mailbox providers check DNS records (SPF, DKIM, DMARC) to see if a server may send for your domain. A new server has none of them.
- The server's IP address has no good name. Cheap VPS addresses are often reused, and some were used for spam before. Mailbox providers trust them less.
- Port 25 is often blocked. Servers deliver email to each other on port 25. Many cloud and VPS providers block it on new servers to stop spam, so the email never leaves.
- The From address doesn't match. WordPress sends from
wordpress@yourdomainby default, and many apps use an address on a domain they can't prove they own.
Since 2024, Google and Yahoo check this for every email sent to their users. Everyone needs at least SPF or DKIM, and bulk senders need all three. Email that fails goes to spam or gets refused.
Before you start
- You need to be able to change the DNS records of your domain (at your domain registrar or DNS host).
- The examples use the domain
example.com, a WordPress site in/var/www/shopand a Laravel app in/var/www/app. Use your own names. - To test, send email only to mailboxes you own, such as your own Gmail or Outlook address.
1. Pick an email service
The easy, reliable fix is to let a company that only does email deliver it for you. These are often called transactional email services. They have good IP addresses, handle bounces, and give you the DNS records you need. Your server sends to them on port 587, which providers almost never block.
Some well-known ones are Amazon SES, Brevo, Mailgun, Postmark, Resend and SendGrid. Many have a free tier for small sites. Your domain's email host (for example Google Workspace or Microsoft 365) can also send for you, but they have daily limits and are made for people, not apps. We don't get anything for naming these. Compare prices, limits and where they store data, and pick one you like.
When you sign up, the service asks you to add your domain. It then shows you a few DNS records to add, and gives you either SMTP details (host, port, username, password) or an API key. Keep this page open, you need it in the next steps.
Want to see if your own server can reach port 25 at all? This only opens a connection, it sends nothing:
timeout 5 bash -c '</dev/tcp/gmail-smtp-in.l.google.com/25' && echo open || echo blocked
Even if it says open, an email service is still the better choice.
2. SPF, DKIM and DMARC in plain words
These are three small text records in your domain's DNS. Think of a letter from your company:
- SPF is a public list of the post offices allowed to send letters for you. A mailbox provider checks if the email came from a server on that list.
- DKIM is a seal on the envelope. The email service signs each email with a secret key. You publish the matching public key in DNS, so anyone can check that the seal is yours and the email wasn't changed on the way.
- DMARC tells mailbox providers what to do if an email says it's from you but fails these checks: nothing, spam folder or refuse it. It also asks them to send you reports.
One more rule ties them together. DMARC only passes if SPF or DKIM passes for the same domain as the From address. So an email from shop@example.com needs a DKIM seal from example.com, not from the email service's own domain. That's why you add your domain at the service.
3. Add the SPF record
SPF is a TXT record on the domain itself. The email service tells you what to put in it. It looks like this:
example.com. TXT "v=spf1 include:_spf.your-email-service.example ~all"
include:adds the service's servers to your list. Copy it exactly from the service.~allmeans "anything else is suspicious".-allis stricter: "anything else is not us".- A domain may have only one SPF record. If you already have one, for example for Google Workspace, add the new
include:to it. Two separatev=spf1records make SPF fail. - SPF allows at most 10 DNS lookups. Each
include:counts, so don't add services you no longer use.
Some services ask you to put SPF on a subdomain instead, such as send.example.com or bounces.example.com. Follow their instructions: that is where they send bounces back to.
4. Add the DKIM record
The email service creates the key and shows you the record. You can't make it up yourself. It's a TXT or CNAME record with a name that ends in ._domainkey, for example:
s1._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...long key...IDAQAB"
s2._domainkey.example.com. CNAME s2.domainkey.your-email-service.example.
The first part (s1, s2) is called the selector. It lets one domain have several keys, one per service. Copy names and values exactly. Some DNS hosts add your domain to the name for you, so s1._domainkey may be all you need to type.
Then click "verify" at the email service. DNS changes can take from a few minutes to a few hours to show up.
5. Add the DMARC record, starting gently
DMARC is a TXT record on _dmarc of your domain. Start with p=none: it changes nothing about delivery, it only asks for reports. That way you find senders you forgot about (a newsletter tool, an invoice system, your office mail) before anything gets blocked.
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"
rua is where the daily reports go. They are XML files. A free DMARC report reader (many email services include one) turns them into a readable list. Use a mailbox you actually check, or a separate one, because big sites send a lot of them.
After a few weeks, when the reports show that all your real email passes, make it stricter in two steps:
"v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
"v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.com"
quarantine sends failing email to spam, reject refuses it. With reject, nobody can send convincing fake emails "from" your domain.
6. Check your records with dig
dig shows what the world sees in your DNS. On Ubuntu and Debian it's in the dnsutils (or bind9-dnsutils) package. Here is what two big providers publish, as we saw it in November 2026:
$ dig +short TXT yahoo.com | grep spf
"v=spf1 redirect=_spf.mail.yahoo.com"
$ dig +short TXT _dmarc.yahoo.com
"v=DMARC1; p=reject; pct=100; rua=mailto:d@rua.agari.com; ruf=mailto:d@ruf.agari.com;"
$ dig +short TXT _dmarc.gmail.com
"v=DMARC1; p=none; sp=quarantine; rua=mailto:mailauth-reports@google.com"
Yahoo refuses fake mail from yahoo.com (p=reject). Gmail uses p=none for gmail.com itself, but sp=quarantine for its subdomains. Now check your own domain:
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT s1._domainkey.example.com
Use the selector your service gave you in the last line. For a CNAME DKIM record, dig first prints the name it points to, then the key. Look for exactly one line starting with v=spf1, one starting with v=DMARC1, and a DKIM line with a long p= value.
7. Laravel: send through the service
Put the SMTP details in the .env file on the server. These are the settings for current Laravel versions (12 and later), tested on Laravel 13:
MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=smtp.your-email-service.example
MAIL_PORT=587
MAIL_USERNAME=your-smtp-username
MAIL_PASSWORD="your-smtp-password"
MAIL_FROM_ADDRESS="shop@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_SCHEME=smtpwith port587sends over an encrypted connection (STARTTLS). If your service uses port465, setMAIL_SCHEME=smtps. Older Laravel versions useMAIL_ENCRYPTION=tlsinstead.MAIL_FROM_ADDRESSmust be on the domain you set up at the service.- Laravel also has built-in drivers that use some services' web APIs instead of SMTP. See the Laravel mail docs.
Rebuild the config cache after you change .env. If you ran config:cache when you deployed, Laravel doesn't read .env again. In our test, a changed password was simply ignored until we rebuilt it:
cd /var/www/app
php artisan config:cache
Send a test email to your own address:
php artisan tinker --execute='Mail::raw("This is a test.", fn ($m) => $m->to("you@your-own-mailbox.example")->subject("Test from Laravel"));'
No output means it was handed to the service. A wrong password stops with an error like Failed to authenticate on SMTP server ... 535 5.7.8 Authentication credentials invalid.
Sending takes a second or two, so on a real site, send emails from a queue. Then a slow email service doesn't slow down your pages. See our guide to Laravel queue workers and the scheduler, and keep .env out of reach of the web, as shown in how to deploy Laravel on your own server.
8. WordPress: send through the service
You have two good options.
Option A: an SMTP plugin. Several well-known plugins on WordPress.org do this, with a settings page and a "send test email" button. By default, most save the SMTP password in the database, where any admin and every database backup can see it. Many let you put it in wp-config.php instead. Check the plugin's docs for that and use it.
Option B: a small must-use plugin of your own. It's about 30 lines, has no settings page, and reads the password from wp-config.php. Must-use plugins are always on and can't be turned off from the dashboard. WordPress already uses PHPMailer for email, and its phpmailer_init hook lets you point it at your service.
First add the details to wp-config.php, as the site's user, with WP-CLI:
cd /var/www/shop
sudo -u shop -- wp config set SMTP_HOST smtp.your-email-service.example
sudo -u shop -- wp config set SMTP_PORT 587 --raw
sudo -u shop -- wp config set SMTP_USER your-smtp-username
sudo -u shop -- wp config set SMTP_PASS 'your-smtp-password'
sudo -u shop -- wp config set SMTP_FROM shop@example.com
sudo -u shop -- wp config set SMTP_FROM_NAME 'Shop'
Make sure only the site's user can read wp-config.php (chmod 600), as shown in our guide to harden WordPress on Ubuntu or Debian. Then create wp-content/mu-plugins/smtp-mail.php (make the mu-plugins folder if it doesn't exist):
<?php
/**
* Plugin Name: Send email over SMTP
* Description: Sends all WordPress email through the SMTP server set in wp-config.php.
*/
add_action( 'phpmailer_init', function ( $phpmailer ) {
if ( ! defined( 'SMTP_HOST' ) ) {
return; // Not set up: WordPress keeps its default.
}
$phpmailer->isSMTP();
$phpmailer->Host = SMTP_HOST;
$phpmailer->Port = SMTP_PORT;
$phpmailer->SMTPSecure = 'tls'; // STARTTLS on port 587
$phpmailer->SMTPAuth = true;
$phpmailer->Username = SMTP_USER;
$phpmailer->Password = SMTP_PASS;
} );
add_filter( 'wp_mail_from', function ( $from ) {
return defined( 'SMTP_FROM' ) ? SMTP_FROM : $from;
} );
add_filter( 'wp_mail_from_name', function ( $name ) {
return defined( 'SMTP_FROM_NAME' ) ? SMTP_FROM_NAME : $name;
} );
add_action( 'wp_mail_failed', function ( $error ) {
error_log( 'wp_mail failed: ' . $error->get_error_message() );
} );
The wp_mail_from filter replaces the default wordpress@ address with your own. The last part writes failures to the PHP error log, because WordPress hides them otherwise. If your service uses port 465, set SMTPSecure to 'ssl'. Check that WordPress sees the plugin, then send a test to your own address:
sudo -u shop -- wp plugin list --status=must-use
sudo -u shop -- wp eval 'var_dump( wp_mail( "you@your-own-mailbox.example", "Test from WordPress", "This is a test." ) );'
bool(true) means the service accepted it. With a wrong password you get bool(false), and the log shows wp_mail failed: SMTP Error: Could not authenticate.
Use only one way of sending. If you add this file, turn off any other SMTP plugin.
9. Check that SPF, DKIM and DMARC pass
"Sent" doesn't mean "landed in the inbox". Open the test email in your own mailbox and look at its headers:
- Gmail: the three dots next to the reply button, then "Show original". The top shows SPF, DKIM and DMARC with
PASSorFAIL. - Outlook and others: look for "View message source" or "Show headers".
In the headers, find the line that starts with Authentication-Results. It looks something like this, and you want all three to pass for your own domain:
Authentication-Results: mx.google.com;
dkim=pass header.i=@example.com ...;
spf=pass (google.com: domain of bounces@send.example.com designates ... as permitted sender) ...;
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=example.com
dkim=passshould name your domain (@example.com), not only the service's.spf=passmay name the service's bounce domain or your subdomain. That's normal.dmarc=passandheader.from=your domain is the goal.
Send the test to two or three providers you have accounts with, for example Gmail and Outlook. They don't all judge the same way.
10. Handle bounces and keep your list clean
Mailbox providers watch how people react to your email. Sending to many addresses that don't exist, or getting many "this is spam" clicks, hurts every email you send after that, including password resets.
- Stop sending to addresses that bounce. A "hard bounce" means the address doesn't exist. Good services stop sending to it for you (a "suppression list"). Many can also tell your app through a webhook, so you can mark the user.
- Watch spam complaints. Google asks senders to stay under 0.3 percent, and much lower is better. Your email service shows this number.
- Only email people who asked for it. Never buy or copy lists. For newsletters, confirm sign-ups with a link, and add an easy unsubscribe link. Gmail and Yahoo require one-click unsubscribe for bulk mail.
- Keep newsletters apart from account email. Send marketing from a subdomain such as
news.example.com, or a separate stream at your service. Then a bad campaign doesn't push password resets into spam. - Protect your forms. Contact and sign-up forms that bots can fill turn your site into a spam sender. Add rate limits or a captcha, and don't send a copy of the message to the address the visitor typed in.
If you really want to send straight from your server
It can work, but it's a lot more upkeep, and one bad week can ruin it. If you still want to, you need all of these:
- Port 25 open for outgoing connections. Ask your provider, many unblock it only on request.
- Reverse DNS (PTR): the server's IP address must point back to a name, such as
mail.example.com, and that name must point to the same IP. You set the PTR record at your hosting provider, not at your DNS host. Google requires it. - SPF that lists the server's IP, for example
v=spf1 ip4:203.0.113.10 ~all. - DKIM signing on the server, with a mail server such as Postfix and a signing tool such as OpenDKIM, plus the DMARC record from step 5.
- An IP that isn't on blocklists, and a slow start: a few emails a day at first, more over weeks.
For most sites this isn't worth it. An email service costs little or nothing at small volumes.
If something goes wrong
- Laravel says "Connection could not be established" or times out: the host or port is wrong, or a firewall blocks outgoing port 587. Check the details at your service, and your firewall's outgoing rules.
- "535 Authentication credentials invalid" or "Could not authenticate": wrong username or password. Many services use an API key as the password, and a separate SMTP username.
- Laravel ignores your new settings: run
php artisan config:cacheagain (step 7), and restart queue workers withphp artisan queue:restart. - The service refuses the From address: the domain isn't verified there yet, or
MAIL_FROM_ADDRESS/SMTP_FROMis on another domain. spf=failorpermerror: you have two SPF records, or more than 10 lookups. Merge them into one.dkim=passbutdmarc=fail: the DKIM seal is from the service's domain, not yours. Finish the domain setup at the service (step 4).- Everything passes but email still lands in spam: the domain is new, the content looks like spam (only a link, ALL CAPS, a link shortener), or people have marked your email as spam. Give it time, and check step 10.
What we tested, and what we couldn't
We tested the Laravel settings (Laravel 13 on PHP 8.3) and the WordPress must-use plugin with WP-CLI, on Ubuntu 24.04. Both sent to a local test mail server that required STARTTLS and a password on port 587. The From addresses, the wrong-password errors and the Laravel config cache problem above come from those tests. We also ran the dig commands against the real public records shown in step 6.
We could not test real delivery to Gmail or Outlook, or DKIM signing by an email service, because that needs a real domain and account at a service. Follow your service's own setup steps for those, and check the headers as shown in step 9.
Official sources
- Email sender guidelines (Google) and Sender best practices (Yahoo)
- RFC 7208: SPF, RFC 6376: DKIM and RFC 7489: DMARC
- DMARC overview (dmarc.org)
- Mail (Laravel docs) and configuration caching
- phpmailer_init, wp_mail_from and wp_mail() (WordPress developer docs)
- Must-use plugins (WordPress developer docs)
Next steps: if you run WordPress, follow our guide to running WordPress on your own server. For Laravel, keep emails in a queue with queue workers that restart on their own.
ServerNest is in development. Email setup isn't part of it today. Join early access to follow along and tell us what you'd like it to check.