SOP: A Working Contact Form with PHPMailer and Gmail
A contact form that silently stopped sending, what actually broke, and the setup that survives a rebuild.
The shape
require __DIR__ . '/vendor/autoload.php';
$envDirs = [dirname(__DIR__), __DIR__]; // above the docroot first
foreach ($envDirs as $i => $dir) {
if (file_exists($dir . '/.env')) {
Dotenv\Dotenv::createImmutable($dir)->load();
if ($i > 0) { error_log('contact: .env still in the docroot'); }
break;
}
}
$_ENV['GMAIL_USER'], $_ENV['GMAIL_PASS'], smtp.gmail.com, port 587, TLS.
Two faults, stacked, and the second was invisible
vendor/ was gone. Untracked, no composer.lock, so it only ever existed because someone had run composer install on the box. A cleanup removed it, the require on line 6 fatalled, and nothing after it ran. That is why a plain GET returned 500 with an empty body instead of the form's own 403.
Fixed by re-running composer install — and committing composer.lock, so a rebuild reproduces the versions that work.
.env was gone too. Only discoverable once the first fault was cleared: a file that will not load cannot report a credential problem. Without it, $_ENV['GMAIL_USER'] ?? 'your_gmail@gmail.com' fell through to the placeholder and Gmail answered SMTP Error: Could not authenticate.
A single 500 read like a single problem for as long as anyone looked at it from outside.
The Gmail App Password trap
Gmail needs an App Password with 2-Step Verification on — your account password will not work. Google displays it in four groups of four, like abcd efgh ijkl mnop.
Enter it with no spaces. Pasted as displayed, phpdotenv refuses the entire file with Failed to parse dotenv file due to unexpected whitespace. That is an uncaught throw, so it fatals exactly like a missing vendor/ and looks identical from outside.
That exception prints the offending line. A malformed .env does not merely fail — it echoes the value into stdout and into the Apache error log. A diagnostic written to print only strlen() was defeated by the library's own error message. Treat any credential that has triggered this as disclosed, and rotate it.
Diagnose it in one request
curl -i https://example.com/contact.php
A GET should hit the script's own else branch — for this one, **403 with "There was a problem with your submission"**. That response is proof the file loaded past its require and its Dotenv call. A 500 with an empty body means it fatalled at the top. Everything else, read the Apache error log.
Worth wiring into whatever checks your deployment: this failure is silent on the form itself, so every submission is lost with no signal until somebody thinks to make this exact request by hand.
If a client posts to it
- Form-encode the body. PHP only fills
$_POSTfor form encodings. A JSONbody arrives intact, parses to an empty
$_POST, and sends a blank message under a 200. - Forward the honeypot field if that is the spam guard.
- Do not assume a failure body came from your script. An Apache-level error
returns a full HTML document; showing it to the user puts a whole error page in their message box. Prefer the status code, and always offer a
mailto:fallback carrying what they typed.