PHP básico

Base de datos con PDO y proyecto final

Lección 8 de 8 · 18 min

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-.

La tabla con la que vamos a trabajar
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:

conexion.php, fuera de public_html
<?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

  1. 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.

  2. 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.

  3. 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:

Mal. Nunca escribas esto
<?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:

Lo que el visitante escribe, y la consulta que resulta
-- 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.

Bien. Así se consulta siempre
<?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.

Una sola fila, y guardar una nueva
<?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

  1. La estructura y la configuración

    Carpeta pública aparte, y conexion.php fuera de ella. Compruébalo pidiéndolo con curl: tiene que responder 404 (lección 6).

  2. 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.

  3. La cabecera y el pie compartidos

    En partes/, incluidos con require desde todas las páginas. El menú, en un solo sitio.

  4. El acceso

    entrar.php con password_verify(), session_regenerate_id(true) y un solo mensaje de error; salir.php que destruye la sesión.

  5. 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.php en la primera línea de cada página.

  6. 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?

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