Donde viven los datos de verdad
En la lección 4 la lista de productos estaba escrita dentro del archivo. Sirve para aprender y no sirve para nada más: no se puede buscar en ella, dos visitantes no pueden escribir a la vez y cada cambio obliga a editar código.
Para eso está la base de datos: un programa aparte, hecho para guardar, buscar y actualizar datos. La más común junto a PHP es MySQL -o MariaDB, que es equivalente para lo que vamos a hacer-.
CREATE TABLE servicios (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(120) NOT NULL,
descripcion TEXT,
precio INT NOT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;utf8mb4 no es un capricho: es el juego de caracteres que guarda
bien las tildes, la eñe y los emoji. Ponerlo desde el principio evita el clásico
«MartÃnez», que después hay que arreglar registro por registro.
Conectarse: PDO
PHP habla con la base de datos a través de PDO. La conexión se
hace una vez, en un archivo aparte, y todas las páginas la reciben con
require -lección 6-.
Y ese archivo, por lo que lleva dentro, es exactamente el que tiene que estar fuera de la carpeta pública:
<?php
$dsn = 'mysql:host=localhost;dbname=mi_sitio;charset=utf8mb4';
$bd = new PDO($dsn, 'usuario_bd', 'contraseña_bd', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);Las tres opciones, una por una
ERRMODE_EXCEPTION
Que un fallo se note: si algo sale mal, se lanza una excepción en vez de seguir como si nada.
Desde PHP 8.0 ya es el comportamiento por defecto -antes era fallar en silencio-, pero se deja escrito para que quien lea el archivo lo sepa sin consultar la versión.
FETCH_ASSOC
Que cada fila llegue como un arreglo asociativo:
$fila['nombre'], igual que en la lección 4.Este sí hay que pedirlo: por defecto PDO devuelve cada dato dos veces, por nombre y por número de columna.
EMULATE_PREPARES en false
Que las consultas preparadas las prepare de verdad el motor de la base de datos, y no una imitación hecha por el propio PDO.
La forma de consultar que NUNCA se usa
Este es el punto más importante de la lección, así que vamos a verlo roto primero. Buscar los servicios que coincidan con lo que escribió el visitante:
<?php
$buscado = $_GET['q'] ?? '';
// PELIGRO: el texto del visitante se pega dentro de la consulta.
$sql = "SELECT * FROM servicios WHERE nombre = '" . $buscado . "'";
$filas = $bd->query($sql)->fetchAll();Funciona perfectamente mientras la gente escriba nombres de servicios. Mira lo que pasa cuando alguien escribe otra cosa en el buscador:
-- Escribe en el buscador: ' OR '1'='1
SELECT * FROM servicios WHERE nombre = '' OR '1'='1'
-- Escribe: '; DROP TABLE servicios; --
SELECT * FROM servicios WHERE nombre = ''; DROP TABLE servicios; --'La primera devuelve toda la tabla, porque 1=1
siempre se cumple. Aplicado a una tabla de usuarios, es entrar sin contraseña.
La segunda borra la tabla. Y lo mismo sirve para leer los datos de otra tabla, la de clientes o la de contraseñas.
Se llama inyección SQL, se conoce desde hace más de veinte años y sigue siendo de los fallos más frecuentes que hay. La causa es siempre la misma: un dato de fuera pegado dentro de una instrucción.
La forma correcta: consultas preparadas
La solución no es «limpiar» el texto del visitante -eso es una carrera que se pierde-. Es separar la instrucción de los datos y no juntarlos nunca.
Primero se envía la consulta con marcadores en el sitio de los datos. El motor la analiza y decide qué va a hacer. Solo después se le entregan los valores, y para entonces ya no pueden cambiar la instrucción: son datos y nada más.
<?php
$buscado = trim($_GET['q'] ?? '');
$sql = 'SELECT id, nombre, precio
FROM servicios
WHERE activo = 1 AND nombre LIKE :buscado
ORDER BY nombre';
$consulta = $bd->prepare($sql);
$consulta->execute([':buscado' => '%' . $buscado . '%']);
$servicios = $consulta->fetchAll();
echo count($servicios) . ' resultados';Ahora el visitante puede escribir ' OR '1'='1 tranquilamente:
se buscará un servicio que se llame así, no lo habrá, y saldrán cero resultados. Que es
exactamente lo que debe pasar.
Regla sin excepciones: todo valor que no esté escrito por ti en el código va en un marcador. Da igual que «venga de un menú desplegable» o que «sea un número»: el visitante decide qué envía.
<?php
// Una fila: fetch() en vez de fetchAll(). Devuelve false si no hay ninguna.
function buscarUsuarioPorCorreo(PDO $bd, string $correo): ?array
{
$consulta = $bd->prepare('SELECT id, nombre, clave_hash FROM usuarios WHERE correo = :correo');
$consulta->execute([':correo' => $correo]);
$fila = $consulta->fetch();
return $fila === false ? null : $fila;
}
// Insertar: mismos marcadores.
$consulta = $bd->prepare(
'INSERT INTO servicios (nombre, descripcion, precio) VALUES (:nombre, :desc, :precio)'
);
$consulta->execute([
':nombre' => $nombre,
':desc' => $descripcion,
':precio' => $precio,
]);
echo 'Guardado con el id ' . $bd->lastInsertId();Ahí está, completa, la función que la lección 7 usaba como si existiera. Ya tienes el inicio de sesión de principio a fin.
Proyecto final: un directorio de servicios
Todo lo del curso cabe en un proyecto pequeño y publicable. No es un ejercicio de juguete: es la estructura de la mitad de los sitios que existen.
Qué construir
La estructura y la configuración
Carpeta pública aparte, y
conexion.phpfuera de ella. Compruébalo pidiéndolo concurl: tiene que responder 404 (lección 6).La página pública con buscador
Lista los servicios activos y filtra por lo que llegue por GET -es una búsqueda-, con consulta preparada y
htmlspecialchars()al pintar cada resultado.La cabecera y el pie compartidos
En
partes/, incluidos conrequiredesde todas las páginas. El menú, en un solo sitio.El acceso
entrar.phpconpassword_verify(),session_regenerate_id(true)y un solo mensaje de error;salir.phpque destruye la sesión.La zona privada
Alta y edición de servicios por POST, validando en el servidor -obligatorios, longitudes, precio mayor que cero-, y con
require partes/solo-usuarios.phpen la primera línea de cada página.La prueba que cierra el proyecto
Intenta abrir una página privada sin haber entrado, escribiendo la dirección: debe llevarte al login. Y manda un precio negativo con
curl, saltándote el formulario: debe rechazarlo.Mientras no hagas esas dos, el proyecto no está terminado: está sin comprobar.
Las ocho ideas del curso
Intenta responder antes de voltear cada tarjeta.
Compruébalo tú mismo
El identificador viene de un menú desplegable que tú mismo generaste. ¿Hace falta consulta preparada?
Alguien escribe ' OR '1'='1 en tu buscador, que usa
consulta preparada. ¿Qué pasa?
¿Por qué se pide FETCH_ASSOC al conectar?
FETCH_BOTH. Pedir
FETCH_ASSOC deja cada fila como un arreglo asociativo limpio.Hasta dónde llegaste
Empezaste sin saber qué pasaba entre el clic y la página. Ahora puedes recibir datos de un visitante sin fiarte de ellos, guardarlos en una base de datos sin abrir un agujero, cerrar una zona con contraseña y publicar el resultado.
Lo que sigue, cuando quieras seguir: organizar el código en clases, componer con librerías usando Composer, y de ahí a un framework como Laravel o Symfony. Todos ellos se apoyan en lo que acabas de aprender, no lo sustituyen.
Y una costumbre que vale más que cualquier atajo: cuando dudes de una función,
míralo en php.net, no en el primer resultado de la búsqueda. La
mitad de los errores de este curso son cosas que cambiaron y que los tutoriales viejos
siguen contando como antes.
Terminaste PHP básico. Presenta la evaluación y reclama tu certificado.
Quiero mi certificado ← Sesiones y una zona con contraseña