Tienes el formulario de contacto con su diseño CSS bonito, las validaciones JavaScript funcionando, y cuando le das a "Enviar"... no pasa nada. El mensaje se pierde en el vacío.
Aquí es donde la mayoría se atasca porque de repente hay que combinar tres cosas a la vez: PHP, MySQL y el envío de emails. Por separado los entiendes, juntos se convierten en un lío.
Este tutorial coge exactamente ese problema y lo resuelve paso a paso. Al terminar tendrás un formulario que guarda cada mensaje en base de datos y manda un email de confirmación automático al que escribe.
📋 Serie completa de formularios
- 1. Formulario básico HTML/CSS
- 2. Validaciones JavaScript
- 3. 6 estilos CSS únicos
- 4. Backend PHP + MySQL (este tutorial)
Lo que vas a construir
Un sistema completo que hace tres cosas cuando alguien envía el formulario:
- Valida los datos en el servidor (no solo en el navegador)
- Guarda el mensaje en una tabla MySQL con fecha y hora
- Envía un email de confirmación al usuario y una notificación a tu correo
🎯 Requisitos previos
Estructura del proyecto
Antes de escribir código, organiza los archivos así. Cada archivo tiene una responsabilidad clara y esto te va a ahorrar muchos dolores de cabeza después:
mi-formulario/
├── index.html # El formulario (HTML)
├── procesar.php # Recibe y procesa los datos
├── config/
│ └── database.php # Conexión a MySQL
├── includes/
│ ├── guardar.php # Guarda en base de datos
│ └── email.php # Envía los emails
└── .env # Credenciales (NUNCA subir a Git)
⚠️ Antes de empezar: el archivo .env
Las credenciales de base de datos y email van en un archivo .env, nunca escritas directamente en el código. Y ese archivo nunca va a Git. Añádelo a tu .gitignore ahora, antes de escribir nada más.
Paso 1: Crear la base de datos
Abre phpMyAdmin (si usas XAMPP, es localhost/phpmyadmin) y ejecuta este SQL. Crea la base de datos y la tabla donde se van a guardar los mensajes:
-- Crear base de datos
CREATE DATABASE mi_web CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mi_web;
-- Tabla para guardar los mensajes del formulario
CREATE TABLE contactos (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL,
mensaje TEXT NOT NULL,
fecha DATETIME DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(45)
);
El campo ip es opcional pero útil: si alguien te manda spam desde el formulario, puedes bloquearlo por IP directamente desde la base de datos.
Paso 2: Archivo de configuración
Primero el archivo .env con tus credenciales reales (este archivo nunca sale de tu máquina):
# .env — NUNCA subir a Git
DB_HOST=localhost
DB_USER=tu_usuario_aqui
DB_PASS=tu_password_aqui
DB_NAME=mi_web
EMAIL_DESTINO=tu@email.com
EMAIL_FROM=noreply@tudominio.com
Ahora el archivo config/database.php que lee esas credenciales y crea la conexión:
<?php
// config/database.php
// Carga el archivo .env manualmente
function cargarEnv($archivo) {
if (!file_exists($archivo)) return;
foreach (file($archivo, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) {
if (strpos($linea, '#') === 0) continue;
list($clave, $valor) = explode('=', $linea, 2);
putenv(trim($clave) . '=' . trim($valor));
}
}
cargarEnv(__DIR__ . '/../.env');
function conectarDB() {
$conexion = new mysqli(
getenv('DB_HOST'),
getenv('DB_USER'),
getenv('DB_PASS'),
getenv('DB_NAME')
);
if ($conexion->connect_error) {
// Log el error pero no lo muestres al usuario
error_log('Error DB: ' . $conexion->connect_error);
return null;
}
$conexion->set_charset('utf8mb4');
return $conexion;
}
Fíjate en el error_log: nunca muestres los errores de base de datos al usuario. Aparte de feo, le estás dando información sobre tu infraestructura a cualquiera que intente atacar tu web.
Paso 3: Guardar en MySQL
El archivo includes/guardar.php recibe los datos ya validados y los inserta en la tabla. Lo importante aquí son las sentencias preparadas: sin ellas, cualquiera puede hacer SQL injection en tu formulario.
<?php
// includes/guardar.php
require_once __DIR__ . '/../config/database.php';
function guardarMensaje($nombre, $email, $mensaje) {
$db = conectarDB();
if ($db === null) return false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
// ✅ Sentencia preparada: protege contra SQL injection
$stmt = $db->prepare(
'INSERT INTO contactos (nombre, email, mensaje, ip) VALUES (?, ?, ?, ?)'
);
if ($stmt === false) return false;
// 's' = string, hay cuatro parámetros string
$stmt->bind_param('ssss', $nombre, $email, $mensaje, $ip);
$resultado = $stmt->execute();
$stmt->close();
$db->close();
return $resultado;
}
Por qué sentencias preparadas y no concatenar strings
Si construyes la query así: "INSERT INTO contactos VALUES ('" . $nombre . "'...", un usuario puede escribir en el campo nombre algo como '; DROP TABLE contactos; -- y borrar toda tu tabla. Con prepare() y bind_param(), PHP trata los valores como datos puros, nunca como parte de la query.
Paso 4: Enviar emails con PHP mail()
PHP tiene la función mail() integrada. En local con XAMPP puede que no funcione sin configuración extra, pero en cualquier hosting real funciona sin instalar nada.
Vamos a enviar dos emails: uno al usuario confirmándole que recibiste su mensaje, y otro a tu correo con el contenido del mensaje.
<?php
// includes/email.php
require_once __DIR__ . '/../config/database.php';
function enviarConfirmacion($nombre, $emailUsuario) {
$emailFrom = getenv('EMAIL_FROM');
$asunto = 'Hemos recibido tu mensaje';
$cuerpo = "Hola {$nombre},\n\n"
. "Gracias por ponerte en contacto. Hemos recibido tu mensaje\n"
. "y te responderemos en menos de 24 horas.\n\n"
. "Un saludo,\nEl equipo";
$cabeceras = "From: {$emailFrom}\r\n"
. "Reply-To: {$emailFrom}\r\n"
. "X-Mailer: PHP/" . PHP_VERSION;
return mail($emailUsuario, $asunto, $cuerpo, $cabeceras);
}
function notificarAdmin($nombre, $emailUsuario, $mensaje) {
$emailAdmin = getenv('EMAIL_DESTINO');
$emailFrom = getenv('EMAIL_FROM');
$asunto = "Nuevo mensaje de contacto de {$nombre}";
$cuerpo = "Nuevo mensaje recibido:\n\n"
. "Nombre: {$nombre}\n"
. "Email: {$emailUsuario}\n"
. "Mensaje:\n{$mensaje}\n";
$cabeceras = "From: {$emailFrom}\r\n"
. "Reply-To: {$emailUsuario}\r\n"
. "X-Mailer: PHP/" . PHP_VERSION;
return mail($emailAdmin, $asunto, $cuerpo, $cabeceras);
}
El Reply-To en notificarAdmin apunta al email del usuario, no al from. Así cuando le des a responder desde tu correo, el email va directamente a quien te escribió.
Paso 5: El procesador principal
Este es el archivo que hace de pegamento. Recibe el POST del formulario, valida los datos en el servidor, y llama a las funciones de guardado y email:
<?php
// procesar.php
require_once 'includes/guardar.php';
require_once 'includes/email.php';
// Solo acepta peticiones POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: index.html');
exit;
}
// Limpiar y validar datos
$nombre = trim(strip_tags($_POST['nombre'] ?? ''));
$email = trim(filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL));
$mensaje = trim(strip_tags($_POST['mensaje'] ?? ''));
$errores = [];
if (empty($nombre) || strlen($nombre) > 100) {
$errores[] = 'El nombre es obligatorio (máximo 100 caracteres)';
}
if ($email === false || empty($email)) {
$errores[] = 'El email no es válido';
}
if (empty($mensaje) || strlen($mensaje) < 10) {
$errores[] = 'El mensaje debe tener al menos 10 caracteres';
}
if (count($errores) > 0) {
// Vuelve al formulario con los errores en la URL
$queryString = http_build_query([
'estado' => 'error',
'errores' => implode('|', $errores)
]);
header("Location: index.html?{$queryString}");
exit;
}
// Guardar en base de datos
$guardado = guardarMensaje($nombre, $email, $mensaje);
if ($guardado) {
// Enviar emails (el fallo de email no interrumpe el flujo)
enviarConfirmacion($nombre, $email);
notificarAdmin($nombre, $email, $mensaje);
header('Location: index.html?estado=ok');
} else {
header('Location: index.html?estado=error-db');
}
exit;
Las validaciones en JavaScript del tutorial anterior están bien, pero siempre hay que validar también en el servidor. Cualquiera puede desactivar JavaScript o enviar una petición directamente a procesar.php saltándose el formulario.
Paso 6: El formulario HTML actualizado
El HTML necesita un ajuste: el atributo action apunta a procesar.php y el método es POST. También añadimos un bloque para mostrar el resultado:
<!-- index.html -->
<!-- Mostrar mensaje de resultado -->
<div id="mensaje-resultado"></div>
<form action="procesar.php"
method="POST"
class="contact-form">
<div class="form-group">
<label for="nombre">Nombre</label>
<input type="text"
id="nombre"
name="nombre"
placeholder="Tu nombre completo"
required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email"
id="email"
name="email"
placeholder="tu@email.com"
required>
</div>
<div class="form-group">
<label for="mensaje">Mensaje</label>
<textarea id="mensaje"
name="mensaje"
rows="4"
placeholder="Tu mensaje aquí..."
required></textarea>
</div>
<button type="submit">Enviar Mensaje</button>
</form>
<script>
// Leer el parámetro ?estado= de la URL y mostrar feedback
const params = new URLSearchParams(window.location.search);
const estado = params.get('estado');
const contenedor = document.getElementById('mensaje-resultado');
if (estado === 'ok') {
const aviso = document.createElement('div');
aviso.className = 'aviso-exito';
aviso.textContent = 'Mensaje enviado. Te responderemos pronto.';
contenedor.appendChild(aviso);
} else if (estado) {
const aviso = document.createElement('div');
aviso.className = 'aviso-error';
aviso.textContent = 'Hubo un problema al enviar. Inténtalo de nuevo.';
contenedor.appendChild(aviso);
}
</script>
Probar que funciona
Antes de subir a producción, prueba en local con XAMPP. Copia la carpeta al directorio htdocs de XAMPP y ábrela en el navegador como localhost/mi-formulario/.
Rellena el formulario y comprueba tres cosas:
- En phpMyAdmin, dentro de la tabla
contactos, aparece una nueva fila con el mensaje - El navegador redirige a
index.html?estado=ok - Si tienes el servidor de email configurado, recibes la notificación en tu correo
⚠️ php mail() no funciona en local con XAMPP
En Windows con XAMPP, mail() no envía emails porque no hay servidor SMTP en tu ordenador. Para probar el envío real en local, sustituye este archivo por PHPMailer con Gmail: el email llega de verdad a una bandeja de entrada real. Tienes la guía completa en Enviar emails con PHPMailer y Gmail.
Subir a producción: qué cambia
Cuando subas el proyecto a tu hosting hay tres cosas que debes verificar:
1. El archivo .env. Súbelo por FTP pero asegúrate de que no está accesible desde el navegador. En Apache, añade esto al .htaccess de la raíz:
# .htaccess
<Files .env>
Order allow,deny
Deny from all
</Files>
2. Las credenciales de producción. Cambia los valores del .env: el usuario y contraseña de la base de datos serán distintos en el hosting. La mayoría de hostings tienen un panel (cPanel, Plesk) donde creas el usuario MySQL.
3. Verificar que mail() funciona. Envía un formulario de prueba y revisa la carpeta de spam. Si el email llega a spam, tienes que configurar SPF y DKIM en el DNS de tu dominio, pero eso ya es otro tema.
Errores que seguro vas a encontrar
La base de datos no conecta. Lo más habitual es un error de credenciales. Verifica que el nombre de usuario, contraseña y base de datos en el .env coinciden exactamente con lo que creaste en phpMyAdmin. Las mayúsculas importan.
El formulario redirige pero la tabla está vacía. Revisa que el nombre de los campos en el HTML (name="nombre") coincide con lo que lees en PHP ($_POST['nombre']). Si no coinciden, PHP recibe un string vacío y la validación lo rechaza.
Error "Call to undefined function mysqli". La extensión mysqli no está activa en PHP. En XAMPP abre el archivo php.ini, busca ;extension=mysqli y elimina el punto y coma del principio. Reinicia Apache.
🔗 Lo que sigue
Con esto el formulario es funcional. Si quieres añadir estilos al formulario antes de conectar el backend, revisa los 6 estilos CSS que puedes aplicar directamente. Y si necesitas también validaciones del lado del cliente, tienes la guía de validaciones JavaScript.