Enviar emails con PHP desde Gmail usando PHPMailer

mail() de PHP devuelve true y no envía nada en local. PHPMailer con Gmail envía de verdad: funciona en XAMPP, admite HTML, y cuando algo falla te dice exactamente qué

La función mail() de PHP tiene un comportamiento que desespera a quien la usa por primera vez en Windows: ejecutas el código, devuelve true, y el email no llega. No hay error, no hay mensaje, no hay nada. Simplemente no funciona porque en Windows no hay ningún servidor de correo escuchando — mail() le pasa el mensaje al sistema operativo y el sistema operativo no sabe qué hacer con él.

PHPMailer lo resuelve de raíz porque no depende del sistema operativo. Abre una conexión SMTP directa con Gmail, se autentica con tus credenciales, y entrega el email. Mismo comportamiento en Windows con XAMPP que en un servidor Linux en producción.

🎯 Lo que necesitas antes de empezar

XAMPP instalado con Apache corriendo — guía de instalación
Composer instalado — guía de Composer
Una cuenta de Gmail nueva solo para pruebas — no la personal, ahora explico por qué

Por qué una cuenta de Gmail aparte

PHPMailer necesita autenticarse con Gmail usando una contraseña de aplicación. Esa contraseña va en el .env de tu proyecto. El .env no sube a Git, pero en el día a día del desarrollo lo tienes abierto en el editor, a veces lo compartes para depurar algo, y hay una probabilidad no despreciable de que en algún momento lo subas por accidente.

Con una cuenta personal, si pasa eso tienes que revocar accesos y revisar qué se ha podido enviar en tu nombre. Con una cuenta de prueba tipo tunombre.dev@gmail.com, revocas la contraseña de aplicación en treinta segundos y a otra cosa.

Crear la contraseña de aplicación en Gmail

Google no deja que aplicaciones externas usen tu contraseña normal. Genera contraseñas de aplicación: tokens de 16 caracteres vinculados a una app concreta que puedes revocar cuando quieras sin tocar tu contraseña real.

Para crearlas necesitas la verificación en dos pasos activa en esa cuenta. Ve a Gestionar tu cuenta de Google → Seguridad → Verificación en dos pasos y actívala.

Con eso activo, busca "Contraseñas de aplicación" en el buscador de la configuración de Google. No intentes navegar por los menús — Google mueve esa opción dependiendo del tipo de cuenta y es más rápido buscarla directamente.

Escribe un nombre descriptivo — "PHP XAMPP" o el nombre del proyecto — y pulsa Crear. Google genera la contraseña y la muestra una sola vez. Si cierras la ventana sin copiarla, tendrás que generar una nueva porque no hay forma de recuperar la anterior.

Instalar PHPMailer y configurar credenciales

Desde la terminal, en la carpeta de tu proyecto:

composer require phpmailer/phpmailer

Añade las credenciales al .env. La contraseña de Google aparece con espacios en pantalla pero puedes pegarla con o sin ellos:

# .env
GMAIL_USER=tucuenta.dev@gmail.com
GMAIL_APP_PASSWORD=abcdefghijklmnop
EMAIL_DESTINO=donde.quieres.recibir@email.com

El código de envío

La función crearMailer() centraliza la configuración SMTP. Si en algún momento necesitas cambiar el puerto o el cifrado, lo cambias en un solo sitio:

<?php
// includes/email.php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config/database.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;

function crearMailer() {
    $mail = new PHPMailer(true);

    $mail->isSMTP();
    $mail->Host       = 'smtp.gmail.com';
    $mail->SMTPAuth   = true;
    $mail->Username   = getenv('GMAIL_USER');
    $mail->Password   = getenv('GMAIL_APP_PASSWORD');
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = 587;
    $mail->CharSet    = 'UTF-8';

    return $mail;
}

function enviarConfirmacion($nombre, $emailUsuario) {
    try {
        $mail = crearMailer();

        $mail->setFrom(getenv('GMAIL_USER'), 'Tu Web');
        $mail->addAddress($emailUsuario, $nombre);
        $mail->Subject = 'Hemos recibido tu mensaje';

        // Email en texto plano (fallback para clientes sin HTML)
        $mail->AltBody = "Hola {$nombre},\n\nGracias por escribirnos. Te contestamos en menos de 24 horas.\n\nUn saludo";

        // Email en HTML (lo que ve la mayoría)
        $mail->isHTML(true);
        $mail->Body = "
            <div style='font-family: sans-serif; max-width: 500px; margin: 0 auto;'>
                <h2 style='color: #333;'>Hola, {$nombre}</h2>
                <p>Gracias por escribirnos. Hemos recibido tu mensaje y
                te contestamos en menos de 24 horas.</p>
                <p style='color: #666;'>Un saludo</p>
            </div>
        ";

        $mail->send();
        return true;

    } catch (Exception $e) {
        error_log('PHPMailer confirmación: ' . $mail->ErrorInfo);
        return false;
    }
}

function notificarAdmin($nombre, $emailUsuario, $mensaje) {
    try {
        $mail = crearMailer();

        $mail->setFrom(getenv('GMAIL_USER'), 'Formulario Web');
        $mail->addAddress(getenv('EMAIL_DESTINO'));
        // Reply-To al usuario: cuando respondas desde tu cliente de correo
        // el destinatario es él, no tu cuenta de Gmail
        $mail->addReplyTo($emailUsuario, $nombre);
        $mail->Subject = "Nuevo mensaje de {$nombre}";
        $mail->Body    = "Nombre: {$nombre}\nEmail: {$emailUsuario}\n\nMensaje:\n{$mensaje}";

        $mail->send();
        return true;

    } catch (Exception $e) {
        error_log('PHPMailer admin: ' . $mail->ErrorInfo);
        return false;
    }
}

Tres cosas del código que no son obvias. Primero, el true en new PHPMailer(true): sin él PHPMailer captura los errores internamente y no los propaga — falla en silencio igual que mail(). Con true lanza excepciones que el catch puede atrapar y registrar.

Segundo, isHTML(true) con AltBody: cuando activas HTML tienes que proporcionar también una versión en texto plano en AltBody. Si no lo haces, los clientes de correo que no renderizan HTML — algunos corporativos, algunos móviles antiguos — muestran el email vacío. El AltBody es el fallback.

Tercero, addReplyTo en la notificación al admin: el From del email es técnicamente tu cuenta de Gmail, pero el Reply-To apunta al usuario que envió el formulario. Cuando abras ese email y pulses Responder, tu cliente de correo usará el Reply-To, así que el email irá al usuario directamente. Sin esto, responderías a tu propia cuenta de Gmail.

Puerto 587 vs 465: cuál usar y por qué

Gmail acepta conexiones SMTP en dos puertos con dos sistemas de cifrado distintos:

Puerto 587 con STARTTLS — la conexión empieza sin cifrar y luego negocia el cifrado. Es el estándar moderno para envío de correo. PHPMailer lo llama ENCRYPTION_STARTTLS.

Puerto 465 con SSL/TLS — la conexión empieza cifrada desde el primer momento. Era el estándar antes y todavía funciona. PHPMailer lo llama ENCRYPTION_SMTPS.

Usa el 587 con STARTTLS. Es el que recomienda Gmail en su documentación y el que mejor se lleva con los firewalls. Si por alguna razón el 587 está bloqueado en tu red y necesitas probar con el 465:

$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port       = 465;

Probar con un script independiente

Antes de conectar esto al formulario, comprueba que el envío funciona con un script directo. Si algo falla aquí, sabes exactamente dónde está el problema sin tener que descartar el resto del código:

<?php
// test-email.php — borra este archivo cuando termines
require_once 'config/database.php';
require_once 'includes/email.php';

$resultado = enviarConfirmacion('Prueba', 'tu@email.com');

echo $resultado
    ? 'Enviado — revisa la bandeja'
    : 'Error — revisa C:\xampp\php\logs\php_error_log';

Cuando algo falla: el modo debug de SMTP

Si el test devuelve error y el log de PHP no da suficiente información, activa el debug de SMTP en crearMailer(). Esto imprime en pantalla toda la conversación entre PHPMailer y el servidor de Gmail — cada comando enviado y cada respuesta recibida:

function crearMailer() {
    $mail = new PHPMailer(true);

    // Activa esto solo para depurar, nunca en producción
    $mail->SMTPDebug = SMTP::DEBUG_SERVER;

    $mail->isSMTP();
    // ... resto de la configuración
}

Con DEBUG_SERVER verás algo así al ejecutar el test:

2026-05-09 10:23:01 SERVER -> CLIENT: 220 smtp.gmail.com ESMTP
2026-05-09 10:23:01 CLIENT -> SERVER: EHLO localhost
2026-05-09 10:23:01 SERVER -> CLIENT: 250-smtp.gmail.com at your service
2026-05-09 10:23:01 CLIENT -> SERVER: AUTH LOGIN
2026-05-09 10:23:02 SERVER -> CLIENT: 535 5.7.8 Username and Password not accepted

Ese 535 Username and Password not accepted te dice exactamente qué falla: las credenciales. Con esa información ya sabes que el problema no es el código sino la contraseña de aplicación — o la generaste mal, o la pegaste con caracteres extra, o la verificación en dos pasos no está activa.

Los códigos de respuesta SMTP que más vas a ver: 235 es autenticación correcta, 250 es operación completada bien, 535 es credenciales incorrectas, 421 y 454 son problemas de conexión o límite de envíos alcanzado.

Cuando termines de depurar, quita la línea de SMTPDebug o ponla a cero: $mail->SMTPDebug = 0. En producción no quieres que esa salida aparezca en pantalla.

Los errores más habituales

"Could not authenticate" / código 535. La contraseña de aplicación está mal. Genera una nueva desde Google, cópiala sin espacios al .env, y vuelve a probar. Si sigues con el mismo error, verifica que la verificación en dos pasos está activa en esa cuenta concreta — a veces se desactiva sola si Google detecta actividad sospechosa.

Timeout al conectar. El puerto 587 está bloqueado en tu red. Prueba con el móvil como hotspot para confirmar que es la red y no tu código. Si en el hotspot funciona, el firewall de tu red o tu router está bloqueando conexiones SMTP salientes. Puedes probar cambiando al puerto 465.

El test dice éxito pero el email llega a spam. Normal cuando envías desde una IP residencial o una cuenta de Gmail nueva. Añade la dirección remitente a contactos y en los siguientes envíos llegará a la bandeja principal. En producción con tu propio dominio configura SPF y DKIM — eso resuelve el problema de raíz.

El formulario redirige a ?estado=ok pero no llega email. PHPMailer lanzó una excepción que el catch capturó sin romper el flujo del formulario. Abre C:\xampp\php\logs\php_error_log, busca las líneas que empiezan por "PHPMailer" y ahí tienes el error exacto.

⚠️ Borra test-email.php antes de subir a producción

En producción cualquiera podría acceder a tudominio.com/test-email.php y enviar emails desde tu cuenta. Bórralo o añádelo al .gitignore antes de desplegar.

Serie completa de formularios PHP