Los 48 ejercicios de PHP de la web en una sola hoja: cada uno con su enunciado, los datos que necesitas (código de partida, ejemplos de entrada y salida o la base de datos) y, al final, la solución explicada.
La entrada es una petición HTTP tal y como llega al servidor: la línea de petición, las cabeceras, una línea en blanco y, a veces, un cuerpo en formato de formulario (application/x-www-form-urlencoded). Muestra «Método: …», «Ruta: …» (sin la query string), una línea «GET nombre = valor» por cada parámetro de la URL, «Host: …», «Cabeceras: N» y una línea «POST nombre = valor» por cada campo del cuerpo. Los valores deben aparecer decodificados (%C3%B1 es ñ y + es un espacio).
Código de partida (php)
<?php$texto = stream_get_contents(STDIN);$lineas = explode("\n", str_replace("\r", '', $texto));// TODO: línea de petición, cabeceras (hasta la primera línea vacía) y cuerpo
Ejemplo: Petición GET con parámetros
Entrada
GET /buscar?q=teclado+mec%C3%A1nico&pagina=2 HTTP/1.1
Host: tienda.es
Accept: text/html
Salida esperada
Método: GET
Ruta: /buscar
GET q = teclado mecánico
GET pagina = 2
Host: tienda.es
Cabeceras: 2
Ejemplo: Formulario con POST
Entrada
POST /registro HTTP/1.1
Host: academia.es
Content-Type: application/x-www-form-urlencoded
Content-Length: 45
nombre=Ana+Mu%C3%B1oz&email=ana%40correo.es&acepto=1
Salida esperada
Método: POST
Ruta: /registro
Host: academia.es
Cabeceras: 3
POST nombre = Ana Muñoz
POST email = ana@correo.es
POST acepto = 1
Cada línea de la entrada describe una respuesta con «código;tipo;cuerpo». Escribe la respuesta HTTP completa: la línea de estado «HTTP/1.1 código texto» (200 OK, 201 Created, 204 No Content, 301 Moved Permanently, 404 Not Found, 500 Internal Server Error), «Content-Type: tipo; charset=utf-8», «Content-Length: N» con la longitud del cuerpo en bytes, una línea en blanco y el cuerpo. Una respuesta 204 no lleva cuerpo, ni Content-Type ni Content-Length. Si el código no está en la lista, escribe «Código no soportado: N». Separa cada respuesta de la siguiente con una línea «---».
Código de partida (php)
<?php$textos = [200 => 'OK']; // TODO: completa los códigos$respuestas = [];foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$codigo, $tipo, $cuerpo] = explode(';', $linea, 3); // TODO: construye la respuesta y añádela a $respuestas}echo implode("\n---\n", $respuestas), "\n";
Así deciden los frameworks qué código ejecutar para cada URL. La entrada empieza con rutas «MÉTODO patrón acción» (el patrón puede tener parámetros como /productos/{id}), una línea «---» y después peticiones «MÉTODO url». Para cada petición escribe la acción y sus parámetros en orden («verProducto id=42»), «404 No encontrado» si ninguna ruta encaja con la ruta de la URL, o «405 Método no permitido (GET, PUT)» si la ruta existe pero no para ese método (lista los métodos permitidos en el orden en que se definieron). Ignora la query string y una barra final (/productos/ equivale a /productos). Si varias rutas encajan, gana la primera definida.
GET /productos listar
GET /productos/{id} verProducto
POST /productos crear
---
GET /productos
GET /productos/42
POST /productos
GET /clientes
Salida esperada
listar
verProducto id=42
crear
404 No encontrado
Ejemplo: Varios parámetros, 405 y barra final
Entrada
GET /usuarios/{usuario}/pedidos/{pedido} verPedido
GET /productos/{id} ver
PUT /productos/{id} actualizar
---
GET /usuarios/ana/pedidos/7?detalle=1
DELETE /productos/3
GET /productos/3/
PUT /productos/3
Salida esperada
verPedido usuario=ana pedido=7
405 Método no permitido (GET, PUT)
ver id=3
actualizar id=3
PHP genera HTML. Lee un número de la entrada y escribe su tabla de multiplicar del 1 al 10 como una tabla HTML, con una fila por línea y dos espacios de sangría: « <tr><td>7 x 1</td><td>7</td></tr>». Si la entrada no es un entero entre 1 y 100, escribe «<p>Número no válido</p>».
Código de partida (php)
<?php$texto = trim((string) fgets(STDIN));// TODO
Ejemplo: Tabla del 7
Entrada
7
Salida esperada
<table>
<tr><td>7 x 1</td><td>7</td></tr>
<tr><td>7 x 2</td><td>14</td></tr>
<tr><td>7 x 3</td><td>21</td></tr>
<tr><td>7 x 4</td><td>28</td></tr>
<tr><td>7 x 5</td><td>35</td></tr>
<tr><td>7 x 6</td><td>42</td></tr>
<tr><td>7 x 7</td><td>49</td></tr>
<tr><td>7 x 8</td><td>56</td></tr>
<tr><td>7 x 9</td><td>63</td></tr>
<tr><td>7 x 10</td><td>70</td></tr>
</table>
Ejemplo: Entrada no válida
Entrada
abc
Salida esperada
<p>Número no válido</p>
5. Encuentra el fallo: la frase que empieza por hola
El programa dice qué frases contienen la palabra «hola». Con «hola mundo» responde que no la contiene, aunque es lo primero que aparece. Corrige la comprobación.
Código de partida (php)
<?php$frases = ["hola mundo", "dice hola", "adiós"];foreach ($frases as $f) { if (strpos($f, "hola")) { echo "$f: contiene hola\n"; } else { echo "$f: no contiene hola\n"; }}
Lee un texto (puede tener varias líneas) y muestra las tres palabras más frecuentes con el formato «palabra: veces», en minúsculas. Una palabra es una secuencia de letras (incluidas las tildes y la ñ). Si dos palabras se repiten lo mismo, va primero la que sea menor alfabéticamente.
Código de partida (php)
<?php$texto = stream_get_contents(STDIN);// TODO
Ejemplo: Refrán
Entrada
El que mucho abarca poco aprieta, y el que poco abarca
mucho aprieta.
Cada línea de la entrada es «producto;precio;cantidad». Escribe una línea por producto con el formato «Teclado x 2 = 49,98 €» y al final la base, el IVA y el total: «Base: 62,48 €», «IVA (21%): 13,12 €», «Total: 75,60 €». Las cantidades usan coma decimal y punto de miles (1.234,50 €). Las líneas que no tengan tres campos, un precio numérico y una cantidad entera se ignoran mostrando «Línea ignorada: …».
Código de partida (php)
<?php$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);// TODO
Todo lo que llega de un formulario es texto, y convertirlo bien es la primera defensa. Cada línea de la entrada es «campo=valor». Valida y convierte según el campo: edad (entero de 0 a 120), email (dirección válida), nota (decimal de 0 a 10; admite coma o punto decimal) y web (URL que empiece por http o https). Para cada línea escribe «campo: valor convertido (tipo)» usando el tipo de PHP (int, float o string), o «campo: no válido». La nota se muestra con dos decimales. Los campos desconocidos se ignoran.
Código de partida (php)
<?phpforeach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$campo, $valor] = explode('=', $linea, 2) + [1 => '']; $valor = trim($valor); // TODO: convierte según el campo; false si no es válido $resultado = false; if ($resultado === false) { echo "$campo: no válido\n"; } else { echo "$campo: $resultado (" . get_debug_type($resultado) . ")\n"; }}
La entrada es un mes con el formato AAAA-MM. Genera su calendario como tabla HTML con la semana empezando en lunes: primero «<table>», una fila de cabecera « <tr><th>L</th><th>M</th><th>X</th><th>J</th><th>V</th><th>S</th><th>D</th></tr>», después una fila por semana con siete celdas (las celdas fuera del mes van vacías: «<td></td>»; los días, «<td>5</td>») y al final «</table>». Si la entrada no es un mes válido escribe «<p>Mes no válido</p>».
Twig o Blade separan el HTML del código con un lenguaje de plantillas. Construye uno pequeño. La primera línea de la entrada son los datos en JSON y el resto, la plantilla. Soporta: {{ nombre }} y {{ usuario.email }} (valor escapado con htmlspecialchars; si no existe, cadena vacía), {{{ html }}} (sin escapar), {% for x in lista %}…{% endfor %} (sin anidar otros for) y {% if var %}…{% endif %} (se muestra si el valor es «verdadero» en PHP; puede ir dentro de un for). Escribe la plantilla ya procesada.
{"nombre":"Ana <3","usuario":{"email":"ana@correo.es"},"firma":"<b>Equipo</b>"}
<p>Hola, {{ nombre }} ({{usuario.email}})</p>
{{{ firma }}} {{ firma }} [{{ falta }}]
Salida esperada
<p>Hola, Ana <3 (ana@correo.es)</p>
<b>Equipo</b> <b>Equipo</b> []
Ejemplo: Bucles y condiciones
Entrada
{"titulo":"Pedidos","pedidos":[{"id":1,"urgente":true},{"id":2,"urgente":false},{"id":3,"urgente":1}],"vacio":[]}
<h1>{{ titulo }}</h1>
<ul>{% for p in pedidos %}<li>#{{ p.id }}{% if p.urgente %} ¡urgente!{% endif %}</li>{% endfor %}</ul>
{% if vacio %}Hay datos{% endif %}{% for x in vacio %}nunca{% endfor %}fin
Salida esperada
<h1>Pedidos</h1>
<ul><li>#1 ¡urgente!</li><li>#2</li><li>#3 ¡urgente!</li></ul>
fin
Cada línea es un comentario «autor|texto» que han escrito los usuarios. El código ya los muestra como <li>, pero es vulnerable: un usuario podría colar HTML o JavaScript. Corrígelo para que los caracteres especiales se escapen (< > & " ').
Código de partida (php)
<?phpforeach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { if (!str_contains($linea, '|')) { continue; } [$autor, $texto] = explode('|', $linea, 2); // TODO: escapa los datos antes de mostrarlos echo "<li><strong>$autor</strong>: $texto</li>\n";}
Ejemplo: Comentarios normales
Entrada
Ana|Muy buen tema
Luis|Gracias
Salida esperada
<li><strong>Ana</strong>: Muy buen tema</li>
<li><strong>Luis</strong>: Gracias</li>
Ejemplo: Intento de ataque
Entrada
Hacker|<script>robar(document.cookie)</script>
Eva|Uso <b>negrita</b> & "comillas" y 'simples'
Salida esperada
<li><strong>Hacker</strong>: <script>robar(document.cookie)</script></li>
<li><strong>Eva</strong>: Uso <b>negrita</b> & "comillas" y 'simples'</li>
La entrada es el cuerpo de un formulario enviado por POST (nombre=Ana&email=…&edad=…&web=…). Valida y escribe un error por línea, en este orden: «nombre: obligatorio» o «nombre: máximo 50 caracteres»; «email: no válido»; «edad: debe estar entre 18 y 120» (entero); «web: no válida» (es opcional, solo se valida si no está vacía). Si no hay errores, escribe «Datos correctos».
Código de partida (php)
<?phpparse_str(trim((string) fgets(STDIN)), $datos);$errores = [];// TODO
Simulamos $_SESSION con un array. Cada línea es una acción del usuario: «anadir id cantidad», «quitar id», «vaciar» o «ver». El catálogo tiene precios y stock. Al añadir, la cantidad debe ser un entero positivo y el total de ese producto en el carrito no puede superar el stock; si no se cumple, escribe «Error: …» («cantidad no válida», «producto inexistente» o «stock insuficiente») y no cambies nada. «ver» muestra una línea por producto en el orden en que se añadieron («Teclado x 2 = 49,98 €») y «Total: …», o «Carrito vacío».
La función pinta un comentario de un formulario. Si alguien escribe una etiqueta <script>, se mete en la página tal cual (XSS). Corrige la función para que el autor y el texto se muestren escapados.
Las contraseñas se guardan cifradas con password_hash. Cada línea de la entrada es un intento «usuario contraseña» (la contraseña es lo que va después del primer espacio). Escribe «Bienvenido/a, usuario» si es correcto o «Usuario o contraseña incorrectos» si no (sin revelar si el usuario existe). Tras 3 fallos seguidos de un mismo usuario, su cuenta queda bloqueada: responde «Cuenta bloqueada» a cualquier intento posterior, aunque la contraseña sea correcta. Un acceso correcto reinicia sus fallos.
El usuario puede cambiar sus cookies, pero sí podemos detectar si lo ha hecho. Firmaremos cada valor con un HMAC: la cookie guarda «valor.firma», donde firma es hash_hmac('sha256', valor, clave secreta). Cada línea es «firmar texto» (escribe la cookie resultante) o «verificar cookie» (escribe «Válida: valor» o «Manipulada»). La clave secreta es la del código. Compara las firmas con hash_equals, que tarda lo mismo acierte o falle y no da pistas a un atacante. El valor puede contener puntos: la firma es lo que va tras el último punto.
La entrada tiene tres bloques separados por «---». Primero, los roles: «rol NOMBRE: permiso1, permiso2» o «rol NOMBRE hereda PADRE: permisos» (un rol tiene sus permisos y todos los de sus antepasados). Después, los usuarios: «NOMBRE: ROL». Por último, las peticiones: «USUARIO ACCION» o «USUARIO ACCION PROPIETARIO». Un usuario puede hacer ACCION si su rol tiene el permiso ACCION, o si tiene ACCION_propio y el recurso es suyo (PROPIETARIO es él). Escribe «USUARIO ACCION: permitido» o «… denegado». Un usuario desconocido siempre recibe denegado. Así se evitan los fallos de control de acceso, los primeros del OWASP Top 10.
rol lector: leer
rol autor hereda lector: crear, editar_propio, borrar_propio
rol editor hereda autor: editar, publicar
---
ana: autor
luis: editor
eva: lector
---
ana leer
ana editar luis
ana editar ana
ana publicar
luis editar ana
luis borrar ana
eva crear
pepe leer
Salida esperada
ana leer: permitido
ana editar: denegado
ana editar: permitido
ana publicar: denegado
luis editar: permitido
luis borrar: denegado
eva crear: denegado
pepe leer: denegado
Ejemplo: Varios niveles
Entrada
rol invitado: ver_catalogo
rol cliente hereda invitado: comprar, ver_pedido_propio, cancelar_pedido_propio
rol soporte hereda cliente: ver_pedido
rol admin hereda soporte: cancelar_pedido, gestionar_usuarios
---
marta: cliente
jorge: soporte
sara: admin
---
marta ver_catalogo
marta ver_pedido jorge
marta ver_pedido marta
jorge ver_pedido marta
jorge cancelar_pedido marta
jorge cancelar_pedido jorge
sara cancelar_pedido marta
sara gestionar_usuarios
Salida esperada
marta ver_catalogo: permitido
marta ver_pedido: denegado
marta ver_pedido: permitido
jorge ver_pedido: permitido
jorge cancelar_pedido: denegado
jorge cancelar_pedido: permitido
sara cancelar_pedido: permitido
sara gestionar_usuarios: permitido
Completa la clase Producto: el constructor usa promoción de propiedades (string $nombre, float $precio, int $stock = 0) y lanza InvalidArgumentException con «El nombre es obligatorio», «El precio no puede ser negativo» o «El stock no puede ser negativo». precioConIva() devuelve el precio con el 21 %. Al convertirlo en texto (__toString) debe dar «Teclado: 24,99 € (30,24 € con IVA) · stock 5». El programa ya lee líneas «nombre;precio;stock» y muestra el producto o «Error: mensaje».
Código de partida (php)
<?phpclass Producto{ // TODO: constructor con promoción de propiedades y validación, y precioConIva() public function __toString(): string { return ''; // TODO }}foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$nombre, $precio, $stock] = array_pad(explode(';', $linea), 3, '0'); try { echo new Producto(trim($nombre), (float) $precio, (int) $stock), "\n"; } catch (InvalidArgumentException $e) { echo 'Error: ', $e->getMessage(), "\n"; }}
Ejemplo: Productos válidos
Entrada
Teclado;24.99;5
Monitor;189;0
Salida esperada
Teclado: 24,99 € (30,24 € con IVA) · stock 5
Monitor: 189,00 € (228,69 € con IVA) · stock 0
Ejemplo: Errores de validación
Entrada
;10;1
Ratón;-3;2
Cable;2;-1
Salida esperada
Error: El nombre es obligatorio
Error: El precio no puede ser negativo
Error: El stock no puede ser negativo
Completa la jerarquía. Empleado es abstracta: guarda el nombre y obliga a implementar salario(). Fijo cobra su sueldo base más 50 € por cada trienio (3 años completos de antigüedad); PorHoras cobra horas × precio, y las que pasen de 160 a precio × 1,5. Comercial extiende Fijo y además cobra un 5 % de sus ventas. Los que implementan la interfaz Bonificable (solo Comercial) suman bonus() a su salario en la nómina. Cada línea de la entrada es «tipo;nombre;datos…». Escribe «nombre: salario €» para cada uno, y al final «Total: …».
Código de partida (php)
<?phpinterface Bonificable{ public function bonus(): float;}abstract class Empleado{ public function __construct(public readonly string $nombre) {} abstract public function salario(): float;}// TODO: completa Fijo, PorHoras y Comercial (que implementa Bonificable: bonus de 100 € si vende más de 20.000 €)class Fijo extends Empleado{ public function __construct(string $nombre, protected float $base, protected int $antiguedad) { parent::__construct($nombre); } public function salario(): float { return 0; // TODO }}class PorHoras extends Empleado{ public function __construct(string $nombre, private float $horas, private float $precio) { parent::__construct($nombre); } public function salario(): float { return 0; // TODO }}class Comercial extends Fijo // TODO: implements Bonificable{ public function __construct(string $nombre, float $base, int $antiguedad, private float $ventas) { parent::__construct($nombre, $base, $antiguedad); }}$euros = fn(float $n) => number_format($n, 2, ',', '.') . ' €';$total = 0;foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { $d = explode(';', $linea); $e = match ($d[0]) { 'fijo' => new Fijo($d[1], (float) $d[2], (int) $d[3]), 'horas' => new PorHoras($d[1], (float) $d[2], (float) $d[3]), 'comercial' => new Comercial($d[1], (float) $d[2], (int) $d[3], (float) $d[4]), }; $importe = $e->salario() + ($e instanceof Bonificable ? $e->bonus() : 0); $total += $importe; echo "{$e->nombre}: {$euros($importe)}\n";}echo "Total: {$euros($total)}\n";
CuentaAhorro hereda de Cuenta y añade intereses, pero tras ingresar 1000 € y aplicar un 5 % el saldo sigue en 1000 € y aparecen avisos. Corrígelo cambiando lo mínimo.
Código de partida (php)
<?phpclass Cuenta { private float $saldo = 0; public function ingresar(float $c): void { $this->saldo += $c; } public function getSaldo(): float { return $this->saldo; }}class CuentaAhorro extends Cuenta { public function aplicarIntereses(float $tipo): void { $this->saldo += $this->saldo * $tipo / 100; }}$c = new CuentaAhorro();$c->ingresar(1000);$c->aplicarIntereses(5);echo "Saldo: " . $c->getSaldo() . " €\n";
Separar la presentación de la lógica es la base de MVC. Completa render($plantilla, $datos): sustituye cada {{ clave }} por el valor de $datos escapado con htmlspecialchars, y {{ clave|raw }} por el valor sin escapar. Las claves pueden tener puntos para entrar en arrays anidados (usuario.nombre). Si una clave no existe, se sustituye por una cadena vacía. La primera línea de la entrada son los datos en JSON y el resto la plantilla.
Los frameworks procesan cada petición con una cadena de middlewares: cada uno puede hacer algo antes, pasar la petición al siguiente con $siguiente($peticion) y hacer algo después, o cortar la cadena devolviendo su propia respuesta. Completa ejecutar(), que envuelve el controlador con los middlewares en orden (el primero es el más externo). Los middlewares ya están escritos: registro (anota la entrada y la salida), autenticación (401 si no hay usuario) y administración (403 si la ruta empieza por /admin y el rol no es admin). Cada línea de la entrada es «ruta usuario rol» (o «ruta - -» sin usuario).
Laravel y Symfony crean los objetos por ti: miran el constructor de una clase, ven qué necesita y lo crean primero. Completa la clase Contenedor. get($clase) devuelve siempre la misma instancia de cada clase (singleton). Para crearla, usa ReflectionClass para leer los parámetros del constructor: si el tipo es una clase, la resuelve recursivamente con get; si es una interfaz, usa la implementación registrada con enlazar(); si es un tipo simple con valor por defecto, usa ese valor. Si detecta un ciclo (A necesita B y B necesita A) lanza LogicException «Dependencia circular: A → B → A». Si una interfaz no está enlazada, LogicException «Sin implementación para X». El programa ya muestra el orden en que se construyen los objetos.
Código de partida (php)
<?php$orden = [];interface Almacen {}final class AlmacenMemoria implements Almacen { public function __construct() { global $orden; $orden[] = 'AlmacenMemoria'; } }final class Reloj { public function __construct(public string $zona = 'Europe/Madrid') { global $orden; $orden[] = 'Reloj'; } }final class Registro { public function __construct(Reloj $reloj) { global $orden; $orden[] = 'Registro'; } }final class Repositorio { public function __construct(Almacen $almacen, Registro $registro) { global $orden; $orden[] = 'Repositorio'; } }final class Servicio { public function __construct(Repositorio $repo, Registro $registro, int $reintentos = 3) { global $orden; $orden[] = "Servicio($reintentos)"; } }final class Huevo { public function __construct(Gallina $g) {} }final class Gallina { public function __construct(Huevo $h) {} }interface Correo {}final class Avisos { public function __construct(Correo $c) {} }final class Contenedor{ private array $instancias = []; private array $enlaces = []; public function enlazar(string $interfaz, string $clase): void { $this->enlaces[$interfaz] = $clase; } public function get(string $clase): object { // TODO return new $clase(); }}$c = new Contenedor();$c->enlazar(Almacen::class, AlmacenMemoria::class);foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $pedido) { $orden = []; try { $a = $c->get($pedido); echo "$pedido: " . (implode(', ', $orden) ?: 'ya existía') . ($a === $c->get($pedido) ? ' (misma instancia)' : '') . "\n"; } catch (Throwable $e) { echo "$pedido: " . get_class($e) . ': ' . $e->getMessage() . "\n"; }}
Ejemplo: Árbol de dependencias
Entrada
Servicio
Repositorio
Almacen
Salida esperada
Servicio: AlmacenMemoria, Reloj, Registro, Repositorio, Servicio(3) (misma instancia)
Repositorio: ya existía (misma instancia)
Almacen: ya existía (misma instancia)
Ejemplo: Errores
Entrada
Huevo
Avisos
Reloj
Salida esperada
Huevo: LogicException: Dependencia circular: Huevo → Gallina → Huevo
Avisos: LogicException: Sin implementación para Correo
Reloj: Reloj (misma instancia)
26. Encuentra el fallo: el listado de un solo producto
El programa lee una categoría y lista sus productos ordenados por precio («Teclado - 24,99 €»), o «Sin resultados». Funciona, pero construye la consulta pegando el texto del usuario: prueba a buscar ' OR '1'='1 y verás todos los productos. Reescríbelo con una consulta preparada.
Código de partida (php)
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE productos (id INTEGER PRIMARY KEY, nombre TEXT, categoria TEXT, precio REAL)");$pdo->exec("INSERT INTO productos (nombre, categoria, precio) VALUES ('Teclado', 'perifericos', 24.99), ('Ratón', 'perifericos', 12.5), ('Monitor 27', 'pantallas', 189), ('Portátil', 'equipos', 749), ('Alfombrilla', 'perifericos', 6.9), ('Monitor 24', 'pantallas', 129)");$categoria = trim((string) fgets(STDIN));// TODO: esta consulta es vulnerable a inyección SQL$filas = $pdo->query("SELECT nombre, precio FROM productos WHERE categoria = '$categoria' ORDER BY precio")->fetchAll();if (!$filas) { echo "Sin resultados\n";}foreach ($filas as $f) { echo $f['nombre'], ' - ', number_format($f['precio'], 2, ',', '.'), " €\n";}
La tabla alumnos tiene 23 filas. La entrada es «página porPágina» (por ejemplo «2 5»). Muestra los nombres de esa página ordenados por id, uno por línea, y después «Página 2 de 5». Si la página o el tamaño no son enteros positivos, o la página no existe, muestra «Página fuera de rango».
Código de partida (php)
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE alumnos (id INTEGER PRIMARY KEY, nombre TEXT)");$insertar = $pdo->prepare('INSERT INTO alumnos (nombre) VALUES (?)');for ($i = 1; $i <= 23; $i++) { $insertar->execute(["Alumno $i"]);}[$pagina, $tamano] = array_pad(preg_split('/\s+/', trim((string) fgets(STDIN))), 2, '');// TODO
Cada línea de la entrada es un filtro opcional «clave=valor»: categoria, min (precio mínimo), max (precio máximo), texto (el nombre lo contiene) y orden (precio, precio_desc o nombre; cualquier otro valor se ignora y se ordena por nombre). Construye una única consulta preparada con solo los filtros recibidos y lista «nombre - precio €», o «Sin resultados». El texto se busca literalmente: los caracteres % y _ del usuario no deben funcionar como comodines de LIKE.
Cada línea es una transferencia «origen destino importe». Para cada una escribe «Cuenta inexistente», «Importe no válido» (menor o igual que 0 o no numérico), «Saldo insuficiente» o «Transferencia realizada», en ese orden de comprobación. Las dos actualizaciones de saldo deben hacerse dentro de una transacción. Al final, lista las cuentas ordenadas por id: «ES01 Ana: 400,00 €».
Código de partida (php)
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE cuentas (id TEXT PRIMARY KEY, titular TEXT, saldo REAL)");$pdo->exec("INSERT INTO cuentas VALUES ('ES01', 'Ana', 500), ('ES02', 'Luis', 120), ('ES03', 'Marta', 0)");foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$origen, $destino, $importe] = array_pad(preg_split('/\s+/', trim($linea)), 3, ''); // TODO}foreach ($pdo->query('SELECT * FROM cuentas ORDER BY id') as $c) { echo "{$c['id']} {$c['titular']}: ", number_format($c['saldo'], 2, ',', '.'), " €\n";}
El programa muestra, para un año dado, cuántos pedidos ha hecho cada cliente y cuánto ha gastado, ordenado de mayor a menor gasto (a igual gasto, por nombre). Deben salir todos los clientes, también los que no han comprado ese año («0 pedidos, 0,00 €»). El código actual funciona, pero hace una consulta por cliente: con 10.000 clientes serían 10.001 consultas. Reescríbelo con una sola consulta (LEFT JOIN y GROUP BY). El programa cuenta las consultas ejecutadas y lo muestra al final.
Código de partida (php)
<?phpfinal class SentenciaContada extends PDOStatement{ public static int $ejecutadas = 0; protected function __construct() {} public function execute(?array $params = null): bool { self::$ejecutadas++; return parent::execute($params); }}$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);$pdo->exec("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nombre TEXT)");$pdo->exec("CREATE TABLE pedidos (id INTEGER PRIMARY KEY, cliente_id INTEGER, fecha TEXT, total REAL)");$pdo->exec("INSERT INTO clientes (nombre) VALUES ('Ana'), ('Luis'), ('Marta'), ('Pablo'), ('Eva')");$pdo->exec("INSERT INTO pedidos (cliente_id, fecha, total) VALUES (1, '2025-03-10', 120), (1, '2025-11-02', 80.5), (1, '2026-01-15', 45), (2, '2025-06-01', 200.5), (3, '2026-02-20', 99.99), (3, '2026-05-05', 10), (4, '2025-12-31', 0.01), (5, '2025-01-01', 200.5)");$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [SentenciaContada::class]);$anio = trim((string) fgets(STDIN));$euros = fn(float $n) => number_format($n, 2, ',', '.') . ' €';// TODO: una sola consulta$clientes = $pdo->prepare('SELECT id, nombre FROM clientes');$clientes->execute();$informe = [];$porCliente = $pdo->prepare("SELECT COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS gasto FROM pedidos WHERE cliente_id = ? AND strftime('%Y', fecha) = ?");foreach ($clientes->fetchAll() as $c) { $porCliente->execute([$c['id'], $anio]); $informe[] = ['nombre' => $c['nombre']] + $porCliente->fetch();}usort($informe, fn($a, $b) => [$b['gasto'], $a['nombre']] <=> [$a['gasto'], $b['nombre']]);foreach ($informe as $fila) { echo "{$fila['nombre']}: {$fila['pedidos']} pedidos, {$euros($fila['gasto'])}\n";}echo 'Consultas: ', SentenciaContada::$ejecutadas, "\n";
La entrada es un CSV con cabecera «sku,nombre,precio,stock». Procesa las filas en orden dentro de una transacción: si el SKU ya existe en la tabla, actualiza nombre, precio y stock; si no, lo inserta. Valida cada fila antes de guardarla: 4 campos («número de campos incorrecto»), SKU con el formato AAA-000 («SKU no válido»), nombre no vacío («nombre vacío»), precio numérico ≥ 0 («precio no válido»), stock entero ≥ 0 («stock no válido») y que el SKU no se repita en el fichero («SKU repetido»). En cuanto una fila falle, escribe «Línea N: motivo» (la cabecera es la línea 1), deshaz la transacción y escribe «Importación cancelada». Si todo va bien, confirma y escribe «Nuevos: X, actualizados: Y». En ambos casos termina listando la tabla ordenada por SKU («TEC-001 Teclado 24.99 5»).
Código de partida (php)
<?php$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);$pdo->exec("CREATE TABLE productos (sku TEXT PRIMARY KEY, nombre TEXT NOT NULL, precio REAL NOT NULL, stock INTEGER NOT NULL)");$pdo->exec("INSERT INTO productos VALUES ('TEC-001', 'Teclado', 24.99, 5), ('RAT-001', 'Ratón', 12.5, 10)");$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);array_shift($lineas); // cabecera// TODO: validar e importar dentro de una transacciónforeach ($pdo->query('SELECT * FROM productos ORDER BY sku') as $p) { echo "{$p['sku']} {$p['nombre']} {$p['precio']} {$p['stock']}\n";}
La entrada es la respuesta JSON de una API de repositorios: un array de objetos con name, language (puede ser null) y stars. Muestra los tres con más estrellas («1. nombre (★ 1.234)», con punto de miles), después «Total de estrellas: N» y «Lenguajes: …» con los lenguajes distintos ordenados alfabéticamente y separados por comas, sin contar null. Si el JSON no es válido, muestra «JSON no válido».
Para no enviar una y otra vez los mismos datos, una API puede responder con una ETag (una huella del contenido). El cliente la guarda y, en la siguiente petición, la envía en la cabecera If-None-Match; si el recurso no ha cambiado, el servidor responde 304 sin cuerpo. Cada línea de la entrada es «id etag-recibida» (o «id -» si el cliente no envía ninguna). Para cada una: si el producto no existe, «404»; si no, calcula la ETag como "…" con el md5 del JSON del producto y escribe «304» si coincide con la recibida, o «200 ETag: "…" {json}» si no.
Cada línea de la entrada es una petición «MÉTODO RUTA [cuerpo JSON]». Escribe para cada una el código de estado y, si hay cuerpo, un espacio y la respuesta en JSON (json_encode con JSON_UNESCAPED_UNICODE). GET /api/libros → 200 con la lista; GET /api/libros/{id} → 200 con el libro o 404 {"error":"Libro no encontrado"}; POST /api/libros con titulo y autor → 201 con el libro creado (id = mayor id + 1) o 422 {"error":"Faltan campos"}; DELETE /api/libros/{id} → 204 sin cuerpo o 404. Cualquier otro método sobre esas rutas → 405 {"error":"Método no permitido"} y cualquier otra ruta → 404 {"error":"Ruta no encontrada"}.
Un JWT tiene tres partes en base64url separadas por puntos: cabecera, datos (payload) y firma HMAC-SHA256 de «cabecera.payload» con una clave secreta. Completa crear(array $datos): string y verificar(string $token, int $ahora): ?array. verificar devuelve los datos si el token tiene tres partes, la firma coincide (hash_equals) y no ha caducado (exp mayor que $ahora); si no, null. La entrada tiene órdenes «crear usuario rol exp» (escribe el token) y «verificar token ahora» (escribe «usuario (rol)» o «Token no válido»).
Toda API pública limita cuántas peticiones acepta de cada cliente. Implementa una ventana deslizante: un cliente puede hacer como máximo 3 peticiones en cualquier intervalo de 10 segundos. Cada línea de la entrada es «segundo cliente» (los segundos no bajan). Si la petición se admite, escribe «segundo cliente: 200 (quedan N)»; si no, «segundo cliente: 429 Retry-After: S», donde S es cuántos segundos faltan para que la petición más antigua de su ventana salga de ella. Las peticiones rechazadas no cuentan. Al final, escribe cuántas peticiones se admitieron por cliente, en orden alfabético.
Un campo de búsqueda de municipios pide sugerencias al servidor mientras el usuario escribe. Cada línea de la entrada es la query string que llega (q=…). Responde con un array JSON (una línea por petición, sin escapar las tildes) de como mucho 5 objetos {"nombre", "provincia"}: primero los municipios que empiezan por el texto y después los que lo contienen en otra posición, cada grupo en orden alfabético. La búsqueda ignora mayúsculas, tildes y espacios a los lados. Con menos de 2 caracteres, responde [].
[{"nombre":"Albacete","provincia":"Albacete"},{"nombre":"Alcalá de Henares","provincia":"Madrid"},{"nombre":"Alcobendas","provincia":"Madrid"},{"nombre":"Alfaro","provincia":"La Rioja"},{"nombre":"Alicante","provincia":"Alicante"}]
Cuando un pago se completa, la pasarela avisa a tu servidor con una petición (un webhook). Cualquiera podría enviar una petición falsa a esa URL, así que la pasarela la firma: la cabecera Firma es «t=segundos,v1=hmac», donde hmac es hash_hmac('sha256', "$t.$cuerpo", secreto). Puede haber varios v1 (mientras se cambia de secreto) y basta con que coincida uno. Cada línea de la entrada es «ahora|cabecera|cuerpo». Responde «Aceptado: tipo» (el campo tipo del JSON del cuerpo), «Rechazado: cabecera» si falta t o no hay ningún v1, «Rechazado: caducado» si la firma tiene más de 300 segundos (evita que alguien reenvíe un aviso antiguo) o «Rechazado: firma».
Si la API de envíos se cae, cada petición a tu tienda esperaría el tiempo máximo antes de fallar, y la tienda entera se volvería lenta. El patrón cortacircuitos (circuit breaker) lo evita. Empieza cerrado (las llamadas pasan). Tras 3 fallos seguidos se abre: durante 30 segundos las peticiones se rechazan al momento sin llamar. Pasados 30 segundos, la siguiente petición es una prueba: si sale bien se cierra (y se reinicia la cuenta de fallos); si falla, vuelve a abrirse otros 30 segundos. Un éxito con el circuito cerrado también reinicia la cuenta. Cada línea es «segundo resultado», donde resultado es lo que respondería el servicio si se le llamara (ok o fallo). Escribe, según el caso: «t: ok (cerrado)», «t: fallo (cerrado, N fallos)», «t: fallo → abierto», «t: rechazada (abierto)», «t: prueba ok → cerrado» o «t: prueba fallo → abierto». Al final, «Llamadas reales: N».
La entrada es un CSV con cabecera «fecha,comercial,importe» (los campos pueden ir entre comillas). Suma el importe por comercial y muestra una línea por comercial ordenada de mayor a menor importe («Ana: 3.200,50 €»), después «Total: …» y, si alguna fila no tenía 3 campos o un importe numérico, «Filas descartadas: N».
Código de partida (php)
<?php$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);// TODO
Une una API REST con PDO. La tabla tareas (id, titulo, hecha) empieza vacía. Cada línea es «MÉTODO RUTA [JSON]» y la respuesta, «código JSON». POST /tareas {"titulo": …} → 201 con la tarea ({"id":1,"titulo":…,"hecha":false}) o 422 {"error":"El título es obligatorio"}; GET /tareas → 200 con todas por id; GET /tareas?hecha=1 o ?hecha=0 → filtradas; PATCH /tareas/{id} {"hecha": true} → 200 con la tarea actualizada; DELETE /tareas/{id} → 204 sin cuerpo. Un id inexistente → 404 {"error":"Tarea no encontrada"}. Todas las consultas, preparadas; hecha se guarda como 0/1 y se devuelve como booleano.
Código de partida (php)
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec('CREATE TABLE tareas (id INTEGER PRIMARY KEY, titulo TEXT NOT NULL, hecha INTEGER NOT NULL DEFAULT 0)');function responder(int $codigo, mixed $datos = null): void{ echo $codigo, $datos === null ? '' : ' ' . json_encode($datos, JSON_UNESCAPED_UNICODE), "\n";}foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$metodo, $ruta, $cuerpo] = array_pad(explode(' ', $linea, 3), 3, ''); // TODO}
Ejemplo: Crear y listar
Entrada
POST /tareas {"titulo": "Estudiar PDO"}
POST /tareas {"titulo": "Hacer la API"}
POST /tareas {"titulo": " "}
GET /tareas
Salida esperada
201 {"id":1,"titulo":"Estudiar PDO","hecha":false}
201 {"id":2,"titulo":"Hacer la API","hecha":false}
422 {"error":"El título es obligatorio"}
200 [{"id":1,"titulo":"Estudiar PDO","hecha":false},{"id":2,"titulo":"Hacer la API","hecha":false}]
Ejemplo: Actualizar, filtrar y borrar
Entrada
POST /tareas {"titulo": "A"}
POST /tareas {"titulo": "B"}
PATCH /tareas/2 {"hecha": true}
GET /tareas?hecha=1
GET /tareas?hecha=0
DELETE /tareas/1
DELETE /tareas/1
GET /tareas
La entrada tiene líneas «producto;precio;cantidad» y, opcionalmente, líneas «CUPON código». El envío cuesta 4,95 € si el subtotal es menor de 50 €. Cupones: DESC10 (10 % del subtotal), MENOS5 (5 € si el subtotal es de al menos 30 €) y ENVIOGRATIS (sin gastos de envío). Solo se aplica un cupón: el que más ahorre de los introducidos (los códigos desconocidos o que no cumplan su condición se ignoran). Muestra «Subtotal: …», «Descuento: …» (con el código entre paréntesis si hay cupón, «Descuento (DESC10): 5,00 €»; si no, «Descuento: 0,00 €»), «Envío: …» y «Total: …».
Código de partida (php)
<?php$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);// TODO
Una tienda online pequeña guarda el carrito de cada cliente como texto, con una línea por producto: nombre, categoría, precio sin IVA y unidades. Antes de cobrar, necesita un programa que convierta ese carrito en una factura clara, con las promociones aplicadas y el IVA desglosado.
Para simplificar, la tienda agrupa sus productos en tres categorías y cada una tiene un único tipo de IVA (en la realidad hay alimentos al 4 % y otros al 10 %, pero aquí no hace falta afinar tanto). Además tiene dos promociones: un 3x2 en alimentación y un 5 % de descuento en los productos de la categoría general cuando la compra es grande.
El carrito lo rellena un formulario antiguo y a veces llegan líneas incorrectas. El programa no debe fallar por ellas: avisa de cada una y factura el resto.
Como en cualquier programa que maneje dinero, los importes se calculan en céntimos enteros. Sumar euros con decimales en coma flotante acumula pequeños errores (0.1 + 0.2 no es exactamente 0.3), y en una factura un céntimo de diferencia es un error.
Requisitos
Cada línea del carrito es nombre;categoria;precio;unidades. El precio no lleva IVA y usa punto decimal. Se ignoran las líneas vacías y los espacios al principio y al final de cada campo.
Una línea no es válida si no tiene exactamente 4 campos, si la categoría no es libros, alimentacion ni general, si el precio no es un número mayor o igual que 0 o si las unidades no son un número entero mayor que 0. Por cada una escribe Aviso: línea N ignorada, con N el número de línea de la entrada (las vacías también cuentan). Los avisos salen antes que la factura.
Si no queda ningún producto válido, escribe Carrito vacío y termina.
La tabla empieza con la cabecera y tiene una línea por producto, en el orden del carrito: el nombre en 22 caracteres alineado a la izquierda (contando letras, no bytes: «Lámpara» ocupa 7), y alineados a la derecha las unidades en 5 caracteres, el precio unitario en 10 y el importe (unidades × precio) en 11. La cabecera usa las mismas anchuras: Producto, Uds, Precio e Importe.
3x2 en alimentación: por cada 3 unidades de un mismo producto de alimentación, una es gratis (unidades gratis = unidades ÷ 3, división entera). Por cada producto con unidades gratis, escribe 3x2 en nombre: -X €.
Descuento por importe: si la base de todo el carrito, ya con el 3x2 aplicado, supera los 100 € (estrictamente), los productos de la categoría general tienen un 5 % de descuento sobre su base, redondeado al céntimo. Si ese descuento es mayor que 0, escribe Descuento del 5 % en general: -X €.
Si hay alguna promoción, se escriben bajo la línea Promociones: con dos espacios delante: primero los 3x2 en el orden del carrito y después el descuento.
El resumen tiene la Base imponible: (la suma de las bases de las tres categorías tras las promociones), una línea IVA T %: por cada categoría con base mayor que 0, en el orden 4 %, 10 % y 21 %, y el TOTAL: (base más cuotas). La cuota de cada tipo es su base × T ÷ 100, redondeada al céntimo. Cada línea lleva la etiqueta en 16 caracteres a la izquierda, el importe en 12 a la derecha, un espacio y €.
Todos los importes se escriben con dos decimales, coma decimal y punto de millar, por ejemplo 1.200,00. Los redondeos son al céntimo más cercano y las mitades suben (como hace round en PHP).
Formato de la entrada
Una línea por producto: nombre;categoria;precio;unidades. Puede haber líneas vacías o mal escritas.
Categorías de la tienda
Categoría
IVA
Promoción
libros
4 %
—
alimentacion
10 %
3x2 en cada producto
general
21 %
5 % de descuento si la base del carrito supera 100 €
Ejemplo: Un carrito con promociones
Entrada
Leche entera;alimentacion;0.95;6
Pan de molde;alimentacion;1.80;2
Auriculares;general;59.90;1
Cargador USB-C;general;19.95;2
El Quijote;libros;12.50;1
Salida esperada
Producto Uds Precio Importe
Leche entera 6 0,95 5,70
Pan de molde 2 1,80 3,60
Auriculares 1 59,90 59,90
Cargador USB-C 2 19,95 39,90
El Quijote 1 12,50 12,50
Promociones:
3x2 en Leche entera: -1,90 €
Descuento del 5 % en general: -4,99 €
Base imponible: 114,71 €
IVA 4 %: 0,50 €
IVA 10 %: 0,74 €
IVA 21 %: 19,91 €
TOTAL: 135,86 €
<?php// Tipos de IVA de cada categoríaconst IVA = ["libros" => 4, "alimentacion" => 10, "general" => 21];/** Céntimos → «1.234,50» (punto de millar y coma decimal). */function dinero(int $centimos): string{ return number_format($centimos / 100, 2, ",", ".");}$lineas = explode("\n", stream_get_contents(STDIN));$productos = [];foreach ($lineas as $i => $linea) { // TODO: valida la línea (o escribe el aviso) y guarda el producto con su precio en céntimos}// TODO: tabla de productos, promociones, base imponible, IVA de cada tipo y total
45. Préstamos de una biblioteca con PDO y transacciones
La biblioteca de un instituto quiere dejar la hoja de cálculo y gestionar los préstamos con una aplicación web en PHP. Antes de hacer las pantallas, se escribe y se prueba la capa de datos: funciones que reciben órdenes y trabajan contra la base de datos con PDO.
Para que el ejercicio funcione sin instalar nada, la base de datos es SQLite en memoria: el código inicial la crea con sus tres tablas y unos datos de partida, que tienes abajo. Con MySQL el código sería el mismo cambiando solo la cadena de conexión (el DSN): esa es la ventaja de PDO.
Las reglas del préstamo son las habituales: 15 días de plazo, como mucho 3 libros a la vez, nada nuevo si se tiene algo vencido y 0,20 € de multa por día de retraso. Comprobar las reglas y registrar el préstamo debe ser una operación atómica: si dos bibliotecarios prestan a la vez el último ejemplar, solo uno puede conseguirlo. Eso es una transacción.
Requisitos
Cada línea es una orden; antes de responder, escribe > y la línea tal como se ha leído (sin espacios en los extremos). Una orden desconocida o con argumentos que no tocan escribe Orden no válida.
BUSCAR texto: libros cuyo título o autor contienen el texto (sin distinguir mayúsculas), por orden de título, como código · título (autor) · disponibles D de E, donde D son los ejemplares menos los prestados sin devolver. Si no hay ninguno, Sin resultados para «texto».
PRESTAR código socio fecha: comprueba que existen el libro (No existe el libro X) y el socio (No existe el socio N) y, dentro de una transacción y en este orden, que queda algún ejemplar (No quedan ejemplares de «título»), que el socio no tiene préstamos activos vencidos a esa fecha (Nombre tiene préstamos vencidos) y que no tiene ya 3 activos (Nombre ya tiene 3 préstamos). Si todo va bien, registra el préstamo y escribe Préstamo N: «título» para Nombre (devolver antes del AAAA-MM-DD), con la fecha de salida más 15 días. Un préstamo vence cuando su fecha límite es anterior a la fecha de la operación.
DEVOLVER id fecha: No existe el préstamo N, El préstamo N ya se devolvió el AAAA-MM-DD, o registra la devolución y escribe Devuelto «título» a tiempo o Devuelto «título» con R días de retraso: multa X € (R = días transcurridos − 15; «día» si es 1).
MOROSOS fecha: Préstamos vencidos el fecha: y una línea por préstamo activo con retraso a esa fecha, por fecha de salida: Nombre: «título» (R días de retraso); o No hay préstamos vencidos el fecha.
RESUMEN: Préstamos activos: N y Socio más lector: Nombre (N préstamos) (contando también los devueltos; si empatan, el primero alfabéticamente).
Todas las consultas que llevan datos de la entrada deben ser consultas preparadas.
Formato de la entrada
Una orden por línea: BUSCAR, PRESTAR, DEVOLVER, MOROSOS o RESUMEN, con las fechas como AAAA-MM-DD.
> BUSCAR viento
L05 · El nombre del viento (Patrick Rothfuss) · disponibles 2 de 3
L04 · La sombra del viento (Carlos Ruiz Zafón) · disponibles 1 de 1
> BUSCAR orwell
L02 · 1984 (George Orwell) · disponibles 0 de 1
L03 · Rebelión en la granja (George Orwell) · disponibles 2 de 2
> BUSCAR O'Brien
L06 · El tercer policía (Flann O'Brien) · disponibles 1 de 1
> PRESTAR L02 3 2026-10-01
No quedan ejemplares de «1984»
> PRESTAR L04 2 2026-10-01
Luis Mora tiene préstamos vencidos
> PRESTAR L04 3 2026-10-01
Préstamo 4: «La sombra del viento» para Eva Ruiz (devolver antes del 2026-10-16)
> DEVOLVER 1 2026-10-01
Devuelto «1984» con 15 días de retraso: multa 3,00 €
> PRESTAR L02 3 2026-10-02
Préstamo 5: «1984» para Eva Ruiz (devolver antes del 2026-10-17)
> MOROSOS 2026-10-20
Préstamos vencidos el 2026-10-20:
Ana Gil: «El nombre del viento» (10 días de retraso)
Eva Ruiz: «La sombra del viento» (4 días de retraso)
Eva Ruiz: «1984» (3 días de retraso)
> RESUMEN
Préstamos activos: 3
Socio más lector: Ana Gil (2 préstamos)
> BUSCAR Quijote
Sin resultados para «Quijote»
> PRESTAR L99 1 2026-10-01
No existe el libro L99
> PRESTAR L01 7 2026-10-01
No existe el socio 7
> DEVOLVER 2 2026-10-01
El préstamo 2 ya se devolvió el 2026-09-28
> DEVOLVER 50 2026-10-01
No existe el préstamo 50
> PRESTAR L01
Orden no válida
> CATALOGO
Orden no válida
Código de partida (php)
<?php// Base de datos de la biblioteca (SQLite en memoria): tablas libros, socios y prestamos$pdo = new PDO("sqlite::memory:");$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);$pdo->exec(<<<SQL CREATE TABLE libros (codigo TEXT PRIMARY KEY, titulo TEXT NOT NULL, autor TEXT NOT NULL, ejemplares INTEGER NOT NULL); CREATE TABLE socios (id INTEGER PRIMARY KEY, nombre TEXT NOT NULL); CREATE TABLE prestamos ( id INTEGER PRIMARY KEY AUTOINCREMENT, codigo TEXT NOT NULL REFERENCES libros(codigo), socio INTEGER NOT NULL REFERENCES socios(id), salida TEXT NOT NULL, devolucion TEXT ); INSERT INTO libros VALUES ('L01', 'Cien años de soledad', 'Gabriel García Márquez', 2), ('L02', '1984', 'George Orwell', 1), ('L03', 'Rebelión en la granja', 'George Orwell', 2), ('L04', 'La sombra del viento', 'Carlos Ruiz Zafón', 1), ('L05', 'El nombre del viento', 'Patrick Rothfuss', 3), ('L06', 'El tercer policía', 'Flann O''Brien', 1); INSERT INTO socios VALUES (1, 'Ana Gil'), (2, 'Luis Mora'), (3, 'Eva Ruiz'); INSERT INTO prestamos (codigo, socio, salida, devolucion) VALUES ('L02', 2, '2026-09-01', NULL), ('L01', 1, '2026-09-20', '2026-09-28'), ('L05', 1, '2026-09-25', NULL); SQL);const PLAZO = 15; // días de préstamoconst MULTA = 20; // céntimos por día de retrasoconst MAXIMO = 3; // préstamos activos por sociofunction euros(int $c): string { return number_format($c / 100, 2, ",", ".") . " €"; }// TODO: buscar, prestar (en una transacción), devolver, morosos y resumen, siempre con consultas preparadasforeach (explode("\n", stream_get_contents(STDIN)) as $linea) { $linea = trim($linea); if ($linea === "") continue; // TODO: escribe «> línea» y ejecuta la orden}
Una aplicación móvil de tareas necesita un backend. La API sigue el estilo REST: la URL identifica el recurso (/api/tareas es la colección, /api/tareas/3 una tarea) y el método HTTP dice qué hacer con él. Las respuestas son JSON y el código de estado HTTP indica el resultado: 200 bien, 201 creado, 204 borrado sin contenido, 400 petición mal formada, 404 no existe, 405 método no permitido y 422 datos que no pasan la validación.
Los frameworks (Laravel, Symfony, Slim) traen un router que asocia cada ruta con su código. Aquí vas a escribir uno pequeño: cada ruta es un patrón como /api/tareas/{id} que se convierte en una expresión regular, y su acción es una closure.
Para probarlo sin servidor web, cada línea de la entrada es una petición HTTP simplificada (método, URL y cuerpo JSON), y el programa escribe la respuesta. Las tareas se guardan en memoria: una base de datos sería el siguiente paso.
Requisitos
Cada línea es MÉTODO URL [cuerpo JSON] (el cuerpo es todo lo que va tras el segundo espacio). Si la línea no tiene al menos método y URL, escribe Petición no válida. La respuesta se escribe como MÉTODO URL → estado cuerpo, con el método en mayúsculas, la URL tal como venía y el cuerpo en JSON compacto sin escapar tildes ni barras; las respuestas 204 no llevan cuerpo. Una barra al final de la ruta no cuenta (/api/tareas/ es /api/tareas).
Una tarea es {"id": n, "titulo": texto, "prioridad": alta|media|baja, "hecha": booleano}. Los id empiezan en 1 y no se reutilizan.
GET /api/tareas → 200 con el array de tareas por orden de id; admite los filtros ?hecha=true|false y ?q=texto (título que contiene el texto sin distinguir mayúsculas), combinables. GET /api/tareas/{id} → 200 con la tarea.
POST /api/tareas crea una tarea → 201 con la tarea. El título es obligatorio y, como los demás campos, se valida: título de 3 a 60 caracteres (tras quitar espacios de los extremos, que no se guardan), prioridad alta, media o baja (por defecto media), hecha true o false (por defecto false), y ningún otro campo (campo desconocido). Los errores devuelven 422 con {"errores": {campo: mensaje}}: obligatorio, entre 3 y 60 caracteres, debe ser alta, media o baja, debe ser true o false.
PATCH /api/tareas/{id} cambia solo los campos que vienen (con las mismas reglas, sin título obligatorio) → 200 con la tarea completa. DELETE /api/tareas/{id} → 204.
Si el cuerpo de un POST o PATCH no es un objeto JSON → 400 {"error": "JSON no válido"}. Si la tarea no existe → 404 {"error": "No existe la tarea N"}. Si la ruta no existe → 404 {"error": "Ruta no encontrada"}; si existe pero no con ese método → 405 {"error": "Método no permitido"}.
Formato de la entrada
Una petición por línea: MÉTODO URL y, en POST y PATCH, el cuerpo JSON.
Rutas de la API
Método
Ruta
Respuesta correcta
GET
/api/tareas?hecha=&q=
200 + lista
POST
/api/tareas
201 + tarea creada
GET
/api/tareas/{id}
200 + tarea
PATCH
/api/tareas/{id}
200 + tarea modificada
DELETE
/api/tareas/{id}
204 sin cuerpo
Ejemplo: Crear, consultar, modificar y borrar
Entrada
POST /api/tareas {"titulo": "Preparar el examen de DWES", "prioridad": "alta"}
POST /api/tareas {"titulo": " Comprar café "}
GET /api/tareas
PATCH /api/tareas/2 {"hecha": true}
GET /api/tareas?hecha=true
GET /api/tareas?q=EXAMEN
DELETE /api/tareas/1
GET /api/tareas/1
GET /api/tareas/
Salida esperada
POST /api/tareas → 201 {"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}
POST /api/tareas → 201 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}
GET /api/tareas → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false},{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}]
PATCH /api/tareas/2 → 200 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}
GET /api/tareas?hecha=true → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]
GET /api/tareas?q=EXAMEN → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}]
DELETE /api/tareas/1 → 204
GET /api/tareas/1 → 404 {"error":"No existe la tarea 1"}
GET /api/tareas/ → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]
Ejemplo: Errores de la API
Entrada
POST /api/tareas {"titulo": "ab", "prioridad": "urgente", "color": "rojo"}
POST /api/tareas {titulo: mal}
POST /api/tareas [1, 2]
POST /api/tareas {"prioridad": "baja"}
PUT /api/tareas
GET /api/usuarios
PATCH /api/tareas/9 {"hecha": true}
POST
Salida esperada
POST /api/tareas → 422 {"errores":{"titulo":"entre 3 y 60 caracteres","prioridad":"debe ser alta, media o baja","color":"campo desconocido"}}
POST /api/tareas → 400 {"error":"JSON no válido"}
POST /api/tareas → 400 {"error":"JSON no válido"}
POST /api/tareas → 422 {"errores":{"titulo":"obligatorio"}}
PUT /api/tareas → 405 {"error":"Método no permitido"}
GET /api/usuarios → 404 {"error":"Ruta no encontrada"}
PATCH /api/tareas/9 → 404 {"error":"No existe la tarea 9"}
Petición no válida
Código de partida (php)
<?php/** Una ruta: método, patrón con {parámetros} y la función que la atiende. */final class Ruta { public function __construct(public string $metodo, public string $patron, public Closure $accion) {} /** Los parámetros de la ruta si la URL encaja con el patrón, o null. */ public function encaja(string $ruta): ?array { // TODO: convierte el patrón en una expresión regular con grupos con nombre return null; }}final class Respuesta { public function __construct(public int $estado, public mixed $cuerpo = null) {}}final class Router { private array $rutas = []; public function add(string $metodo, string $patron, Closure $accion): void { $this->rutas[] = new Ruta($metodo, $patron, $accion); } public function despachar(string $metodo, string $ruta, array $consulta, ?string $cuerpo): Respuesta { // TODO: 404 si ninguna ruta encaja, 405 si encaja pero con otro método return new Respuesta(404, ["error" => "Ruta no encontrada"]); }}$tareas = [];$siguiente = 1;// TODO: registra las rutas GET, POST, PATCH y DELETE de /api/tareas y atiende cada línea de la entrada
47. Libro de visitas seguro: validación, spam y escape de HTML
La web de una casa rural tiene un libro de visitas: los huéspedes dejan su nombre, su email, su web (opcional) y un mensaje, y los mensajes se publican en la página. Es el ejemplo perfecto de por qué no hay que fiarse de nada de lo que llega en un formulario.
Un visitante malintencionado puede escribir <script> en el mensaje o poner javascript:alert(1) como web: si se publica tal cual, el código se ejecuta en el navegador de todos los que vean la página (un ataque XSS). La defensa es doble: validar al recibir y escapar al mostrar con htmlspecialchars.
Además, los robots de spam rellenan todos los campos que encuentran. Un truco eficaz es el campo trampa (honeypot): un campo telefono2 que se oculta con CSS, que ninguna persona ve ni rellena. Si llega con algo, el envío es de un robot.
Requisitos
Cada línea no vacía es un envío del formulario codificado como lo envía el navegador (nombre=Ana&email=ana%40correo.es&mensaje=Hola), con los campos nombre, email, web, mensaje y telefono2. Los envíos se numeran desde 1. A todos los campos se les quitan los espacios de los extremos.
Si telefono2 no está vacío, escribe Mensaje N descartado: spam y no se valida nada más.
Reglas, en este orden: nombre obligatorio y de 2 a 50 caracteres; email obligatorio y válido según FILTER_VALIDATE_EMAIL; web opcional, pero si se escribe tiene que ser una URL válida con esquema http o https (solo direcciones http o https); mensaje obligatorio y de 200 caracteres como mucho. Los mensajes de error son obligatorio, entre 2 y 50 caracteres, no es un email válido, solo direcciones http o https y como mucho 200 caracteres.
Un email (sin distinguir mayúsculas) solo puede publicar 2 mensajes: si el envío es correcto pero ese email ya tiene 2 publicados, el error es email: ya ha enviado 2 mensajes.
Por cada error, Mensaje N rechazado: campo: motivo. Al final, la lista HTML de los mensajes publicados en orden: <ul class="visitas">, una línea por mensaje <li><strong>nombre</strong> (<a href="web" rel="nofollow ugc">web</a>): mensaje</li> (sin el paréntesis si no hay web) y </ul>, con el nombre, la web y el mensaje escapados con htmlspecialchars (ENT_QUOTES | ENT_HTML5). Después, Publicados: P · Rechazados: R · Spam: S.
Formato de la entrada
Un envío del formulario por línea, como query string (campo=valor&campo=valor).
<?phpconst MAX_POR_EMAIL = 2;/** Errores de un mensaje (campo → motivo), en el orden de los campos. */function validar(array $f): array { $e = []; // TODO: nombre, email, web y mensaje return $e;}/** Escapa para HTML: <, >, &, comillas dobles y simples. */function h(string $texto): string { // TODO return $texto;}// TODO: lee cada envío con parse_str, descarta el spam, valida y escribe la lista HTML y el resumen
48. Horas trabajadas con nocturnidad usando DateTime
Un hospital paga a su personal de guardias por horas, con un plus del 25 % por cada hora trabajada de noche (de 22:00 a 06:00). Los fichajes se exportan del control de presencia con la entrada y la salida de cada turno, y a fin de mes hay que calcular cuánto cobra cada persona.
Las fechas son la parte delicada: un turno de 22:00 a 06:30 cruza la medianoche, y uno de 20:00 a 08:00 tiene unas horas nocturnas y otras no. Calcularlo con horas sueltas lleva a errores; con objetos de fecha y la intersección de intervalos sale solo.
El control de presencia comete errores: fechas que no existen, salidas anteriores a la entrada, turnos que se olvidaron de cerrar (de más de 12 horas) o fichajes duplicados que se solapan. Esos turnos se rechazan con un mensaje y no se pagan.
Requisitos
La primera línea es TARIFA euros (euros por hora, con hasta dos decimales); si no lo es, escribe Falta la tarifa y nada más. Cada línea siguiente es un turno: nombre;AAAA-MM-DD HH:MM;AAAA-MM-DD HH:MM (entrada y salida).
Un turno se rechaza, escribiendo Línea N: motivo (N es el número de línea de la entrada), si: no tiene ese formato o alguna fecha no existe (formato no válido); la salida no es posterior a la entrada (la salida no es posterior a la entrada); dura más de 12 horas (un turno no puede pasar de 12 horas); o se solapa con un turno ya aceptado de la misma persona (se solapa con otro turno de nombre). Dos turnos que solo se tocan (uno termina cuando empieza el otro) no se solapan.
Horas nocturnas: los minutos del turno que caen entre las 22:00 y las 06:00 de cualquier noche.
Importe de cada persona: todos sus minutos a la tarifa más un 25 % de la tarifa por cada minuto nocturno, redondeado al céntimo.
Escribe una línea por persona, por orden alfabético: nombre: T turnos, D (nocturnas N) · importe, con «1 turno» en singular y las duraciones como 38 h 30 min o 8 h. Al final, Total a pagar: X. Los importes con punto de millar, coma decimal y €.
Formato de la entrada
Línea 1: TARIFA euros. Resto: nombre;entrada;salida con fechas AAAA-MM-DD HH:MM.
Ejemplo: Guardias de una semana
Entrada
TARIFA 14.50
Ana Gil;2026-09-28 22:00;2026-09-29 06:30
Luis Mora;2026-09-28 08:00;2026-09-28 15:00
Ana Gil;2026-09-30 20:00;2026-10-01 08:00
Luis Mora;2026-09-29 15:00;2026-09-29 23:15
Salida esperada
Ana Gil: 2 turnos, 20 h 30 min (nocturnas 16 h) · 355,25 €
Luis Mora: 2 turnos, 15 h 15 min (nocturnas 1 h 15 min) · 225,66 €
Total a pagar: 580,91 €
Línea 2: formato no válido
Línea 3: la salida no es posterior a la entrada
Línea 4: un turno no puede pasar de 12 horas
Línea 6: se solapa con otro turno de Eva
Línea 8: formato no válido
Eva: 2 turnos, 10 h (nocturnas 0 h) · 120,00 €
Total a pagar: 120,00 €
Código de partida (php)
<?php/** Minutos de [$inicio, $fin) que caen entre las 22:00 y las 06:00. */function minutosNocturnos(DateTimeImmutable $inicio, DateTimeImmutable $fin): int { // TODO return 0;}function euros(int $c): string { return number_format($c / 100, 2, ",", ".") . " €";}$lineas = explode("\n", stream_get_contents(STDIN));// TODO: lee la tarifa y los turnos, valídalos y escribe las horas y el importe de cada persona
<?php$texto = stream_get_contents(STDIN);$lineas = explode("\n", str_replace("\r", '', $texto));// Línea de petición: MÉTODO URL VERSIÓN[$metodo, $url] = explode(' ', $lineas[0]);echo "Método: $metodo\n";echo 'Ruta: ', parse_url($url, PHP_URL_PATH), "\n";parse_str((string) parse_url($url, PHP_URL_QUERY), $get);foreach ($get as $nombre => $valor) { echo "GET $nombre = $valor\n";}// Cabeceras: hasta la primera línea vacía$cabeceras = [];$i = 1;while ($i < count($lineas) && $lineas[$i] !== '') { [$nombre, $valor] = explode(':', $lineas[$i], 2); $cabeceras[trim($nombre)] = trim($valor); $i++;}echo 'Host: ', $cabeceras['Host'] ?? '', "\n";echo 'Cabeceras: ', count($cabeceras), "\n";// Cuerpo: lo que queda tras la línea vacíaparse_str(trim(implode("\n", array_slice($lineas, $i + 1))), $post);foreach ($post as $nombre => $valor) { echo "POST $nombre = $valor\n";}
Una petición HTTP es texto con una estructura fija: la primera línea (método, URL y versión), las cabeceras «Nombre: valor» hasta una línea vacía y, después, el cuerpo. El código la recorre en ese orden, con un índice que avanza hasta la línea vacía.
parse_url separa la ruta de la query string y parse_str convierte una query string (o un cuerpo de formulario, que tiene el mismo formato) en un array, decodificando ya los %C3%B1 y los +. Es lo que hace PHP por ti al rellenar $_GET y $_POST.
La respuesta se construye en el orden en que viaja: línea de estado, cabeceras, una línea en blanco y el cuerpo. El texto de cada código sale de un array asociativo, y isset detecta los códigos que no están.
Content-Length cuenta bytes, no caracteres, y por eso se usa strlen (que cuenta bytes) y no mb_strlen: en UTF-8, la «ñ» ocupa dos. El explode con límite 3 deja el cuerpo entero aunque contenga punto y coma.
<?php$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);$separador = array_search('---', $lineas);$rutas = array_slice($lineas, 0, $separador);$peticiones = array_slice($lineas, $separador + 1);// Sin barra final, salvo la raíz$normalizar = fn(string $ruta) => rtrim($ruta, '/') === '' ? '/' : rtrim($ruta, '/');// Cada patrón se convierte en una expresión regular: {id} pasa a ser un grupo con nombre$definidas = [];foreach ($rutas as $r) { [$m, $patron, $accion] = explode(' ', $r); $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $normalizar($patron)) . '$#'; $definidas[] = [$m, $regex, $accion];}foreach ($peticiones as $peticion) { [$metodo, $url] = explode(' ', $peticion); $ruta = $normalizar((string) parse_url($url, PHP_URL_PATH)); $encontrada = null; $permitidos = []; foreach ($definidas as [$m, $regex, $accion]) { if (!preg_match($regex, $ruta, $grupos)) { continue; } $permitidos[] = $m; if ($m === $metodo && $encontrada === null) { // gana la primera que encaja $params = []; foreach ($grupos as $nombre => $valor) { if (is_string($nombre)) { $params[] = "$nombre=$valor"; } } $encontrada = trim("$accion " . implode(' ', $params)); } } if ($encontrada !== null) { echo $encontrada, "\n"; } elseif ($permitidos) { echo '405 Método no permitido (', implode(', ', array_unique($permitidos)), ")\n"; } else { echo "404 No encontrado\n"; }}
Cada patrón se traduce a una expresión regular: {id} se sustituye por (?P<id>[^/]+), un grupo con nombre que captura un trozo de ruta sin barras. Así preg_match comprueba si la URL encaja y, a la vez, extrae los parámetros con su nombre.
Para distinguir el 404 del 405 se recorren todas las rutas: las que encajan con la URL aportan su método a la lista de permitidos, y la primera que además coincide en método es la que se ejecuta. Si ninguna encaja con la URL, la ruta no existe; si encaja alguna pero con otro método, el problema es el método.
preg_match devuelve cada grupo con nombre dos veces (por nombre y por número), por eso al montar los parámetros solo se toman las claves de tipo texto.
filter_var con FILTER_VALIDATE_INT comprueba a la vez que el texto sea un entero y que esté en el rango, y devuelve false si no lo es; se compara con === false porque un 0 válido también sería «falso» con ==.
El HTML se genera escribiendo texto: PHP sustituye $n y $i dentro de las comillas dobles, y el producto se pasa a echo como otro argumento porque las operaciones no se pueden escribir dentro de la cadena.
5. Encuentra el fallo: la frase que empieza por hola
<?php$frases = ["hola mundo", "dice hola", "adiós"];foreach ($frases as $f) { if (strpos($f, "hola") !== false) { echo "$f: contiene hola\n"; } else { echo "$f: no contiene hola\n"; }}
strpos devuelve la posición donde aparece el texto, y en «hola mundo» esa posición es 0. Como el if convierte el 0 en falso, la frase parecía no contenerlo. strpos devuelve false (no 0) cuando no encuentra nada, así que hay que comparar con !== false, que distingue el tipo.
En PHP 8 es más claro str_contains($f, "hola"), que devuelve directamente un booleano.
En PHP el operador para unir cadenas es el punto (.), no el +. Con +, PHP intenta sumar números, y en PHP 8 sumar un texto que no es numérico como «Hola, » lanza un TypeError.
Otra forma es la interpolación: "Hola, $nombre. Tienes $mensajes mensajes.\n".
<?php$texto = stream_get_contents(STDIN);// Palabras = secuencias de letras (la u hace que \p{L} entienda UTF-8: tildes y ñ)preg_match_all('/\p{L}+/u', mb_strtolower($texto), $m);$cuenta = array_count_values($m[0]);$palabras = array_keys($cuenta);usort($palabras, fn($a, $b) => $cuenta[$b] <=> $cuenta[$a] ?: strcmp($a, $b));foreach (array_slice($palabras, 0, 3) as $p) { echo "$p: {$cuenta[$p]}\n";}
preg_match_all con \p{L}+ y el modificador u extrae todas las palabras, incluidas las que llevan tilde o ñ; antes se pasa el texto a minúsculas con mb_strtolower, que, a diferencia de strtolower, entiende UTF-8. array_count_values cuenta las apariciones de cada una de una vez.
Para ordenar por dos criterios se usa el operador nave espacial: $cuenta[$b] <=> $cuenta[$a] ordena de más a menos y, si empatan (da 0), ?: pasa al segundo criterio, el orden alfabético.
Cada línea se valida antes de usarla: tres campos, un precio numérico (is_numeric) y una cantidad entera (FILTER_VALIDATE_INT). Las que no cumplen se informan y se saltan con continue, sin romper el resto del ticket.
number_format($n, 2, ',', '.') da el formato español: dos decimales, coma decimal y punto de miles. Se guarda en una función flecha ($euros) para no repetirlo, y el IVA se redondea a céntimos antes de sumarlo, como en una factura real.
Cada campo tiene su filtro de filter_var: entero con rango, email, decimal con rango y URL. Los filtros no solo comprueban: también convierten, así que la edad sale como int y la nota como float, que es lo que muestra get_debug_type.
Dos detalles: la nota admite coma decimal, así que se cambia por un punto antes de validarla, y FILTER_VALIDATE_URL acepta también ftp://, por lo que la web se comprueba además con una expresión que exige http o https. El resultado se compara con === false, porque una edad 0 es válida.
10. Encuentra el fallo: la subida de precios que no sube
foreach trabaja con una copia de cada valor: cambiar $p dentro del bucle no cambia el array. Para modificarlo hay que escribir en el propio array usando la clave ($precios[$producto] = …).
La alternativa es recorrerlo por referencia (foreach ($precios as &$p)), pero entonces hay que hacer unset($p) al terminar: si no, $p sigue apuntando al último elemento y el siguiente foreach lo sobrescribe. Por eso la versión con la clave es más segura.
La validación tiene dos partes: la expresión regular comprueba el formato AAAA-MM y checkdate que el mes exista. Después, DateTimeImmutable da los dos datos que hacen falta: cuántos días tiene el mes (t) y en qué día de la semana cae el 1 (N, que ya empieza en lunes).
El calendario se construye como una lista plana de celdas: tantos huecos vacíos como días de la semana hay antes del 1, los días del mes y huecos al final hasta completar múltiplos de 7. array_chunk la parte en semanas, y cada semana es una fila.
<?php$datos = json_decode(fgets(STDIN), true);$plantilla = stream_get_contents(STDIN);// Valor de una ruta con puntos (usuario.email) dentro del contexto; null si no existefunction valor(string $ruta, array $ctx): mixed{ $v = $ctx; foreach (explode('.', $ruta) as $clave) { if (!is_array($v) || !array_key_exists($clave, $v)) { return null; } $v = $v[$clave]; } return $v;}function renderizar(string $tpl, array $ctx): string{ // 1. Bucles: el cuerpo se procesa una vez por elemento, con la variable del bucle en el contexto $tpl = preg_replace_callback('/\{%\s*for\s+(\w+)\s+in\s+([\w.]+)\s*%\}(.*?)\{%\s*endfor\s*%\}/s', function ($m) use ($ctx) { $salida = ''; foreach ((array) (valor($m[2], $ctx) ?? []) as $elemento) { $salida .= renderizar($m[3], [$m[1] => $elemento] + $ctx); } return $salida; }, $tpl); // 2. Condiciones $tpl = preg_replace_callback('/\{%\s*if\s+([\w.]+)\s*%\}(.*?)\{%\s*endif\s*%\}/s', fn($m) => valor($m[1], $ctx) ? renderizar($m[2], $ctx) : '', $tpl); // 3. Variables: primero las de tres llaves (sin escapar), después las de dos (escapadas) $tpl = preg_replace_callback('/\{\{\{\s*([\w.]+)\s*\}\}\}/', fn($m) => (string) valor($m[1], $ctx), $tpl); return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn($m) => htmlspecialchars((string) valor($m[1], $ctx)), $tpl);}echo renderizar($plantilla, $datos);
El motor procesa la plantilla por capas con preg_replace_callback, que sustituye cada coincidencia de una expresión regular por lo que devuelva una función. El orden importa: primero los bucles (porque su cuerpo puede tener condiciones y variables que dependen del elemento), luego las condiciones y por último las variables.
El cuerpo de un bucle se procesa llamando otra vez a renderizar con un contexto ampliado: [$m[1] => $elemento] + $ctx añade la variable del bucle, y el + de arrays da prioridad a la de la izquierda. Las expresiones usan .*? (no codicioso) y el modificador s para que el cuerpo pueda ocupar varias líneas.
Las variables de tres llaves se sustituyen antes que las de dos; si no, la expresión de dos llaves encontraría {{ firma }} dentro de {{{ firma }}}. Escapar por defecto y exigir una sintaxis especial para no hacerlo es justo lo que hacen Twig y Blade para evitar XSS.
htmlspecialchars convierte los caracteres con significado en HTML (<, >, & y las comillas) en entidades, así que el navegador los muestra como texto en lugar de interpretarlos como etiquetas: un <script> escrito por un usuario se ve, pero no se ejecuta.
Se escapan los dos datos, también el autor, porque cualquier cosa que venga del usuario es sospechosa. ENT_QUOTES hace que se escapen también las comillas simples, necesarias si el dato acaba dentro de un atributo.
Los datos de un formulario pueden no llegar, así que cada uno se lee con ?? ''. El nombre se recorta antes de comprobarlo (un nombre de espacios no es un nombre) y se mide con mb_strlen, que cuenta caracteres y no bytes.
Los errores se acumulan en un array en lugar de parar en el primero: así el usuario ve todo lo que tiene que corregir de una vez. Un campo opcional, como la web, solo se valida si trae algo.
El carrito guarda solo id → cantidad; los nombres y precios se toman siempre del catálogo, que es la fuente fiable. Guardar el precio en la sesión permitiría que un cambio de precio no se viera, y nunca hay que fiarse de un precio que llegue del cliente.
Antes de añadir se comprueba todo y solo si pasa se modifica la sesión, así un error deja el carrito como estaba. El stock se compara con lo que ya hay en el carrito más lo que se quiere añadir, no solo con lo nuevo. Los arrays de PHP conservan el orden de inserción, por eso «ver» lista en el orden en que se añadieron.
16. Encuentra el fallo: el comentario que ejecuta código
El fallo es meter en el HTML texto que viene de un usuario sin escaparlo. htmlspecialchars convierte <, >, & y las comillas en entidades (<…) que el navegador muestra como texto, así el <script> se ve pero no se ejecuta.
Con ENT_QUOTES también se escapan las comillas simples ('), lo que protege además cuando el dato acaba dentro de un atributo HTML.
Las contraseñas nunca se comparan directamente: password_verify calcula el hash de lo que se ha escrito con la misma sal y el mismo coste que el guardado y los compara. El bloqueo se comprueba antes que la contraseña, para que ni siquiera una contraseña correcta entre en una cuenta bloqueada.
El mensaje de error es el mismo exista o no el usuario: si fueran distintos, un atacante podría averiguar qué usuarios existen probando nombres.
<?phpconst CLAVE = 'b7f1c0a9e2d34f5a8c6b1e0d9f2a4c7e';foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$orden, $dato] = explode(' ', $linea, 2) + [1 => '']; if ($orden === 'firmar') { echo $dato, '.', hash_hmac('sha256', $dato, CLAVE), "\n"; } elseif ($orden === 'verificar') { $punto = strrpos($dato, '.'); // la firma va tras el ÚLTIMO punto if ($punto === false) { echo "Manipulada\n"; continue; } $valor = substr($dato, 0, $punto); $firma = substr($dato, $punto + 1); echo hash_equals(hash_hmac('sha256', $valor, CLAVE), $firma) ? "Válida: $valor\n" : "Manipulada\n"; }}
Firmar es calcular un HMAC del valor con una clave que solo conoce el servidor. Para verificar se vuelve a calcular con el valor recibido: si alguien cambió el valor (o la firma), no coinciden, y sin la clave no puede fabricar una firma buena.
Como el valor puede contener puntos, la firma se separa con strrpos, que busca el último punto. La comparación se hace con hash_equals, que tarda lo mismo acierte o falle, para no dar pistas a quien intente adivinar la firma midiendo tiempos.
Los roles forman un árbol de herencia, y los permisos de un rol se calculan recursivamente: los suyos más los de su padre, que a su vez incluyen los del suyo. La lista $vistos corta la recursión si dos roles se heredan mutuamente, que de otro modo sería un bucle infinito.
La comprobación tiene dos caminos: tener el permiso general (editar cualquier artículo) o tener la versión _propio y ser el dueño del recurso. Ese segundo caso es el que más se olvida en las aplicaciones reales: comprobar que el usuario puede editar, pero no que lo que edita es suyo.
Todo lo que no se puede demostrar que está permitido se deniega: usuario desconocido, rol inexistente o permiso que no aparece.
<?phpclass Producto{ public function __construct( public string $nombre, public float $precio, public int $stock = 0, ) { if (trim($nombre) === '') { throw new InvalidArgumentException('El nombre es obligatorio'); } if ($precio < 0) { throw new InvalidArgumentException('El precio no puede ser negativo'); } if ($stock < 0) { throw new InvalidArgumentException('El stock no puede ser negativo'); } } public function precioConIva(): float { return $this->precio * 1.21; } public function __toString(): string { $euros = fn(float $n) => number_format($n, 2, ',', '.') . ' €'; return "{$this->nombre}: {$euros($this->precio)} ({$euros($this->precioConIva())} con IVA) · stock {$this->stock}"; }}foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$nombre, $precio, $stock] = array_pad(explode(';', $linea), 3, '0'); try { echo new Producto(trim($nombre), (float) $precio, (int) $stock), "\n"; } catch (InvalidArgumentException $e) { echo 'Error: ', $e->getMessage(), "\n"; }}
La promoción de propiedades declara y asigna los atributos directamente en los parámetros del constructor (public string $nombre), y el cuerpo del constructor solo tiene que validar. Si algo no es válido se lanza una excepción, así que nunca llega a existir un Producto con datos incorrectos: la entidad se protege a sí misma.
__toString se llama automáticamente cuando el objeto se usa como texto, como en el echo del programa. Dentro de una cadena con comillas dobles, {$euros(...)} llama a la función flecha y mete el resultado en el texto.
<?phpinterface Bonificable{ public function bonus(): float;}abstract class Empleado{ public function __construct(public readonly string $nombre) {} abstract public function salario(): float;}class Fijo extends Empleado{ public function __construct(string $nombre, protected float $base, protected int $antiguedad) { parent::__construct($nombre); } public function salario(): float { return $this->base + 50 * intdiv($this->antiguedad, 3); // 50 € por trienio completo }}class PorHoras extends Empleado{ public function __construct(string $nombre, private float $horas, private float $precio) { parent::__construct($nombre); } public function salario(): float { $normales = min($this->horas, 160); $extra = max($this->horas - 160, 0); return $normales * $this->precio + $extra * $this->precio * 1.5; }}class Comercial extends Fijo implements Bonificable{ public function __construct(string $nombre, float $base, int $antiguedad, private float $ventas) { parent::__construct($nombre, $base, $antiguedad); } public function salario(): float { return parent::salario() + $this->ventas * 0.05; // lo de un fijo más la comisión } public function bonus(): float { return $this->ventas > 20000 ? 100 : 0; }}$euros = fn(float $n) => number_format($n, 2, ',', '.') . ' €';$total = 0;foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { $d = explode(';', $linea); $e = match ($d[0]) { 'fijo' => new Fijo($d[1], (float) $d[2], (int) $d[3]), 'horas' => new PorHoras($d[1], (float) $d[2], (float) $d[3]), 'comercial' => new Comercial($d[1], (float) $d[2], (int) $d[3], (float) $d[4]), }; $importe = $e->salario() + ($e instanceof Bonificable ? $e->bonus() : 0); $total += $importe; echo "{$e->nombre}: {$euros($importe)}\n";}echo "Total: {$euros($total)}\n";
Cada clase solo escribe lo que la diferencia. Comercial hereda de Fijo, así que su salario es parent::salario() (base y trienios) más la comisión: no repite el cálculo de los trienios. intdiv da los trienios completos con una división entera.
La interfaz Bonificable expresa una capacidad que no depende de la jerarquía: el código de la nómina pregunta instanceof Bonificable y suma el bonus solo a quien lo tiene, sin saber qué clase concreta es. Si mañana otro tipo de empleado tuviera bonus, bastaría con que implementara la interfaz.
22. Encuentra el fallo: los intereses que no llegan
<?phpclass Cuenta { protected float $saldo = 0; public function ingresar(float $c): void { $this->saldo += $c; } public function getSaldo(): float { return $this->saldo; }}class CuentaAhorro extends Cuenta { public function aplicarIntereses(float $tipo): void { $this->saldo += $this->saldo * $tipo / 100; }}$c = new CuentaAhorro();$c->ingresar(1000);$c->aplicarIntereses(5);echo "Saldo: " . $c->getSaldo() . " €\n";
Un atributo private solo es accesible desde la clase que lo declara, ni siquiera desde sus hijas. Dentro de CuentaAhorro, $this->saldo no era el saldo de Cuenta: PHP creaba una propiedad nueva y dinámica (de ahí los avisos), y el saldo real no cambiaba.
Con protected el atributo sigue oculto para el código de fuera, pero las subclases pueden usarlo. Es el cambio mínimo; otra opción sería añadir en Cuenta un método protegido para modificar el saldo.
preg_replace_callback busca cada {{ … }} con una expresión regular y lo sustituye por lo que devuelve la función. El grupo (\|raw)? es opcional: si aparece, el valor se devuelve tal cual; si no, se escapa con htmlspecialchars. Escapar por defecto es la decisión segura.
Las claves con puntos se resuelven bajando nivel a nivel por el array; en cuanto una clave no existe, se devuelve la cadena vacía. use ($datos) hace que la función anónima pueda leer los datos de fuera.
La cadena se construye como unas muñecas rusas: se empieza por el controlador y, recorriendo los middlewares al revés, cada uno se envuelve en una función que lo llama pasándole el «siguiente» que ya había. Al final, $siguiente es el primer middleware, que contiene a todos los demás.
La clave es que la función flecha captura $middleware y $siguiente por valor en el momento de crearse, así cada capa recuerda la suya aunque las variables cambien en la siguiente vuelta del bucle. Un middleware que devuelve su propia respuesta sin llamar a $siguiente corta la cadena, y el registro sigue viendo la salida porque es la capa más externa.
<?php$orden = [];interface Almacen {}final class AlmacenMemoria implements Almacen { public function __construct() { global $orden; $orden[] = 'AlmacenMemoria'; } }final class Reloj { public function __construct(public string $zona = 'Europe/Madrid') { global $orden; $orden[] = 'Reloj'; } }final class Registro { public function __construct(Reloj $reloj) { global $orden; $orden[] = 'Registro'; } }final class Repositorio { public function __construct(Almacen $almacen, Registro $registro) { global $orden; $orden[] = 'Repositorio'; } }final class Servicio { public function __construct(Repositorio $repo, Registro $registro, int $reintentos = 3) { global $orden; $orden[] = "Servicio($reintentos)"; } }final class Huevo { public function __construct(Gallina $g) {} }final class Gallina { public function __construct(Huevo $h) {} }interface Correo {}final class Avisos { public function __construct(Correo $c) {} }final class Contenedor{ private array $instancias = []; private array $enlaces = []; private array $enConstruccion = []; // pila de clases que se están creando (para detectar ciclos) public function enlazar(string $interfaz, string $clase): void { $this->enlaces[$interfaz] = $clase; } public function get(string $clase): object { $real = $this->enlaces[$clase] ?? $clase; if (isset($this->instancias[$real])) { return $this->instancias[$real]; } $reflexion = new ReflectionClass($real); if ($reflexion->isInterface()) { throw new LogicException("Sin implementación para $clase"); } $pos = array_search($real, $this->enConstruccion, true); if ($pos !== false) { $ciclo = [...array_slice($this->enConstruccion, $pos), $real]; throw new LogicException('Dependencia circular: ' . implode(' → ', $ciclo)); } $this->enConstruccion[] = $real; try { $argumentos = []; foreach ($reflexion->getConstructor()?->getParameters() ?? [] as $param) { $tipo = $param->getType(); if ($tipo instanceof ReflectionNamedType && !$tipo->isBuiltin()) { $argumentos[] = $this->get($tipo->getName()); // otra clase: se resuelve antes } elseif ($param->isDefaultValueAvailable()) { $argumentos[] = $param->getDefaultValue(); } else { throw new LogicException("No se puede resolver \${$param->getName()} de $real"); } } return $this->instancias[$real] = $reflexion->newInstanceArgs($argumentos); } finally { array_pop($this->enConstruccion); } }}$c = new Contenedor();$c->enlazar(Almacen::class, AlmacenMemoria::class);foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $pedido) { $orden = []; try { $a = $c->get($pedido); echo "$pedido: " . (implode(', ', $orden) ?: 'ya existía') . ($a === $c->get($pedido) ? ' (misma instancia)' : '') . "\n"; } catch (Throwable $e) { echo "$pedido: " . get_class($e) . ': ' . $e->getMessage() . "\n"; }}
ReflectionClass permite leer el constructor de una clase en tiempo de ejecución: getParameters() da cada parámetro y getType() su tipo. Si el tipo es una clase, el contenedor la pide recursivamente con get; si es un tipo simple con valor por defecto, usa el valor. Así se construye todo el árbol de dependencias sin escribir un solo new.
Las instancias ya creadas se guardan en un array y se devuelven directamente (singleton), y las interfaces se traducen a su clase con los enlaces. Para detectar ciclos se lleva una pila de las clases que se están construyendo: si una vuelve a aparecer, hay un ciclo, y el trozo de pila desde su primera aparición es justo el mensaje.
El finally saca la clase de la pila tanto si se construye bien como si salta una excepción; si no, un error dejaría la pila sucia para las peticiones siguientes.
26. Encuentra el fallo: el listado de un solo producto
<?php$pdo = new PDO("sqlite::memory:");$pdo->exec("CREATE TABLE productos (nombre TEXT, precio INTEGER)");$pdo->exec("INSERT INTO productos VALUES ('Teclado', 25), ('Ratón', 12), ('Monitor', 150)");$st = $pdo->query("SELECT nombre, precio FROM productos ORDER BY precio");while ($fila = $st->fetch(PDO::FETCH_ASSOC)) { echo "{$fila['nombre']}: {$fila['precio']} €\n";}
fetch devuelve una sola fila cada vez que se llama (y false cuando ya no quedan). Con if se llama una vez y solo se muestra la primera; con while se repite hasta que se acaban las filas.
Si se quieren todas de golpe, fetchAll() devuelve un array con todas las filas para recorrerlo con foreach.
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE productos (id INTEGER PRIMARY KEY, nombre TEXT, categoria TEXT, precio REAL)");$pdo->exec("INSERT INTO productos (nombre, categoria, precio) VALUES ('Teclado', 'perifericos', 24.99), ('Ratón', 'perifericos', 12.5), ('Monitor 27', 'pantallas', 189), ('Portátil', 'equipos', 749), ('Alfombrilla', 'perifericos', 6.9), ('Monitor 24', 'pantallas', 129)");$categoria = trim((string) fgets(STDIN));$consulta = $pdo->prepare('SELECT nombre, precio FROM productos WHERE categoria = ? ORDER BY precio');$consulta->execute([$categoria]);$filas = $consulta->fetchAll();if (!$filas) { echo "Sin resultados\n";}foreach ($filas as $f) { echo $f['nombre'], ' - ', number_format($f['precio'], 2, ',', '.'), " €\n";}
Al pegar el texto del usuario en la consulta, sus comillas forman parte del SQL: con ' OR '1'='1 la condición pasa a ser siempre verdadera y salen todos los productos. Una consulta preparada envía la sentencia con un ? y, aparte, el dato; la base de datos lo trata siempre como un valor, nunca como código.
Por eso la búsqueda maliciosa simplemente busca una categoría que se llama literalmente «' OR '1'='1» y no encuentra nada. prepare + execute([...]) es la forma de pasar cualquier dato que venga del usuario.
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE alumnos (id INTEGER PRIMARY KEY, nombre TEXT)");$insertar = $pdo->prepare('INSERT INTO alumnos (nombre) VALUES (?)');for ($i = 1; $i <= 23; $i++) { $insertar->execute(["Alumno $i"]);}[$pagina, $tamano] = array_pad(preg_split('/\s+/', trim((string) fgets(STDIN))), 2, '');$pagina = filter_var($pagina, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);$tamano = filter_var($tamano, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);$total = (int) $pdo->query('SELECT COUNT(*) FROM alumnos')->fetchColumn();$paginas = $tamano ? (int) ceil($total / $tamano) : 0;if ($pagina === false || $tamano === false || $pagina > $paginas) { echo "Página fuera de rango\n"; exit;}$consulta = $pdo->prepare('SELECT nombre FROM alumnos ORDER BY id LIMIT ? OFFSET ?');$consulta->bindValue(1, $tamano, PDO::PARAM_INT);$consulta->bindValue(2, ($pagina - 1) * $tamano, PDO::PARAM_INT);$consulta->execute();foreach ($consulta->fetchAll(PDO::FETCH_COLUMN) as $nombre) { echo $nombre, "\n";}echo "Página $pagina de $paginas\n";
LIMIT n OFFSET m pide a la base de datos solo n filas a partir de la m: la página p empieza en (p - 1) * tamaño. Así nunca se traen a PHP las 10.000 filas de una tabla grande para mostrar 10.
El número de páginas es el total dividido entre el tamaño, redondeando hacia arriba con ceil. Los dos números se validan como enteros positivos antes de usarlos, y se pasan con bindValue(..., PDO::PARAM_INT) para que lleguen a la consulta como números y no como texto.
<?php$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);$pdo->exec("CREATE TABLE productos (id INTEGER PRIMARY KEY, nombre TEXT, categoria TEXT, precio REAL)");$pdo->exec("INSERT INTO productos (nombre, categoria, precio) VALUES ('Teclado mecánico', 'perifericos', 59.9), ('Ratón', 'perifericos', 12.5), ('Monitor 27', 'pantallas', 189), ('Monitor 24', 'pantallas', 129), ('Cable USB_C', 'cables', 9.95), ('Cable USBxC', 'cables', 7.5), ('Pack descuento 100%', 'ofertas', 99), ('Pack 1000', 'ofertas', 45), ('Alfombrilla', 'perifericos', 6.9)");$filtros = [];foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$clave, $valor] = explode('=', $linea, 2) + [1 => '']; $filtros[$clave] = $valor;}// Cada filtro añade una condición con su marcador y su valor$condiciones = [];$valores = [];if (isset($filtros['categoria'])) { $condiciones[] = 'categoria = ?'; $valores[] = $filtros['categoria'];}if (isset($filtros['min'])) { $condiciones[] = 'precio >= ?'; $valores[] = (float) $filtros['min'];}if (isset($filtros['max'])) { $condiciones[] = 'precio <= ?'; $valores[] = (float) $filtros['max'];}if (isset($filtros['texto'])) { $condiciones[] = "nombre LIKE ? ESCAPE '\\'"; $valores[] = '%' . addcslashes($filtros['texto'], '%_\\') . '%'; // % y _ del usuario, literales}// El orden no puede ir en un marcador: se elige de una lista cerrada$ordenes = ['precio' => 'precio', 'precio_desc' => 'precio DESC', 'nombre' => 'nombre'];$orden = $ordenes[$filtros['orden'] ?? ''] ?? 'nombre';$sql = 'SELECT nombre, precio FROM productos' . ($condiciones ? ' WHERE ' . implode(' AND ', $condiciones) : '') . " ORDER BY $orden";$consulta = $pdo->prepare($sql);$consulta->execute($valores);$filas = $consulta->fetchAll();echo $filas ? '' : "Sin resultados\n";foreach ($filas as $f) { echo $f['nombre'], ' - ', number_format($f['precio'], 2, ',', '.'), " €\n";}
La consulta se construye a trozos, pero los datos nunca se pegan en ella: cada filtro añade una condición con un ? y, en paralelo, su valor a un array que se pasa a execute. Así se puede tener una consulta con cero, uno o cuatro filtros y seguir siendo inmune a la inyección.
Lo que no puede ir en un marcador, como el nombre de la columna del ORDER BY, se elige de una lista blanca: el usuario solo escoge una clave, y el texto que llega a la consulta lo escribe el programa. Cualquier otro valor se ignora.
En LIKE, % y _ son comodines. Para buscarlos literalmente se escapan con una barra (addcslashes) y se indica con ESCAPE cuál es el carácter de escape; así «USB_» no encuentra «USBxC».
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec("CREATE TABLE cuentas (id TEXT PRIMARY KEY, titular TEXT, saldo REAL)");$pdo->exec("INSERT INTO cuentas VALUES ('ES01', 'Ana', 500), ('ES02', 'Luis', 120), ('ES03', 'Marta', 0)");$saldoDe = $pdo->prepare('SELECT saldo FROM cuentas WHERE id = ?');$mover = $pdo->prepare('UPDATE cuentas SET saldo = saldo + ? WHERE id = ?');foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$origen, $destino, $importe] = array_pad(preg_split('/\s+/', trim($linea)), 3, ''); $saldoDe->execute([$origen]); $saldoOrigen = $saldoDe->fetchColumn(); $saldoDe->execute([$destino]); $existeDestino = $saldoDe->fetchColumn() !== false; if ($saldoOrigen === false || !$existeDestino) { echo "Cuenta inexistente\n"; continue; } if (!is_numeric($importe) || (float) $importe <= 0) { echo "Importe no válido\n"; continue; } $importe = (float) $importe; if ($saldoOrigen < $importe) { echo "Saldo insuficiente\n"; continue; } $pdo->beginTransaction(); try { $mover->execute([-$importe, $origen]); $mover->execute([$importe, $destino]); $pdo->commit(); // las dos o ninguna echo "Transferencia realizada\n"; } catch (PDOException $e) { $pdo->rollBack(); echo "Error: la transferencia se ha deshecho\n"; }}foreach ($pdo->query('SELECT * FROM cuentas ORDER BY id') as $c) { echo "{$c['id']} {$c['titular']}: ", number_format($c['saldo'], 2, ',', '.'), " €\n";}
Una transferencia son dos actualizaciones que tienen que ocurrir juntas: si el programa fallara entre la primera y la segunda, el dinero desaparecería. beginTransaction agrupa las dos, commit las confirma a la vez y, si algo lanza una excepción, rollBack deshace lo que se hubiera hecho.
Las comprobaciones van antes y en el orden pedido. fetchColumn devuelve false si no hay ninguna fila, lo que sirve para saber si una cuenta existe; las sentencias se preparan una sola vez fuera del bucle y se ejecutan con datos distintos en cada vuelta. El saldo se modifica con saldo = saldo + ?, en la propia base de datos.
<?phpfinal class SentenciaContada extends PDOStatement{ public static int $ejecutadas = 0; protected function __construct() {} public function execute(?array $params = null): bool { self::$ejecutadas++; return parent::execute($params); }}$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);$pdo->exec("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nombre TEXT)");$pdo->exec("CREATE TABLE pedidos (id INTEGER PRIMARY KEY, cliente_id INTEGER, fecha TEXT, total REAL)");$pdo->exec("INSERT INTO clientes (nombre) VALUES ('Ana'), ('Luis'), ('Marta'), ('Pablo'), ('Eva')");$pdo->exec("INSERT INTO pedidos (cliente_id, fecha, total) VALUES (1, '2025-03-10', 120), (1, '2025-11-02', 80.5), (1, '2026-01-15', 45), (2, '2025-06-01', 200.5), (3, '2026-02-20', 99.99), (3, '2026-05-05', 10), (4, '2025-12-31', 0.01), (5, '2025-01-01', 200.5)");$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [SentenciaContada::class]);$anio = trim((string) fgets(STDIN));$euros = fn(float $n) => number_format($n, 2, ',', '.') . ' €';// Una sola consulta: el filtro del año va en el ON para no perder a los clientes sin pedidos$consulta = $pdo->prepare(" SELECT c.nombre, COUNT(p.id) AS pedidos, COALESCE(SUM(p.total), 0) AS gasto FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id AND strftime('%Y', p.fecha) = ? GROUP BY c.id, c.nombre ORDER BY gasto DESC, c.nombre");$consulta->execute([$anio]);$informe = $consulta->fetchAll();foreach ($informe as $fila) { echo "{$fila['nombre']}: {$fila['pedidos']} pedidos, {$euros($fila['gasto'])}\n";}echo 'Consultas: ', SentenciaContada::$ejecutadas, "\n";
El problema N+1 aparece al hacer una consulta para la lista y otra por cada elemento. Se resuelve dejando que la base de datos haga el trabajo en una sola consulta: LEFT JOIN une cada cliente con sus pedidos, GROUP BY los agrupa por cliente, y COUNT y SUM calculan cada fila del informe. El orden también se hace en SQL.
El detalle importante es dónde va el filtro del año. Si estuviera en el WHERE, eliminaría las filas de los clientes sin pedidos ese año (en ellas p.fecha es NULL) y esos clientes desaparecerían. En la condición del ON solo decide qué pedidos se unen, y el LEFT JOIN conserva a todos los clientes.
COUNT(p.id) cuenta solo los pedidos que existen (no cuenta los NULL), y COALESCE(SUM(...), 0) convierte en 0 la suma vacía, que sería NULL.
<?php$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);$pdo->exec("CREATE TABLE productos (sku TEXT PRIMARY KEY, nombre TEXT NOT NULL, precio REAL NOT NULL, stock INTEGER NOT NULL)");$pdo->exec("INSERT INTO productos VALUES ('TEC-001', 'Teclado', 24.99, 5), ('RAT-001', 'Ratón', 12.5, 10)");$lineas = file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);array_shift($lineas); // cabecera// Devuelve el motivo del error de una fila, o null si es válidafunction validar(array $c, array $vistos): ?string{ if (count($c) !== 4) return 'número de campos incorrecto'; [$sku, $nombre, $precio, $stock] = $c; if (!preg_match('/^[A-Z]{3}-\d{3}$/', $sku)) return 'SKU no válido'; if (trim($nombre) === '') return 'nombre vacío'; if (!is_numeric($precio) || (float) $precio < 0) return 'precio no válido'; if (filter_var($stock, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false) return 'stock no válido'; if (isset($vistos[$sku])) return 'SKU repetido'; return null;}$existe = $pdo->prepare('SELECT 1 FROM productos WHERE sku = ?');$actualizar = $pdo->prepare('UPDATE productos SET nombre = ?, precio = ?, stock = ? WHERE sku = ?');$insertar = $pdo->prepare('INSERT INTO productos (nombre, precio, stock, sku) VALUES (?, ?, ?, ?)');$nuevos = $actualizados = 0;$vistos = [];$error = null;$pdo->beginTransaction();foreach ($lineas as $i => $linea) { $c = str_getcsv($linea, ',', '"', ''); $error = validar($c, $vistos); if ($error !== null) { echo 'Línea ', $i + 2, ": $error\n"; // la cabecera es la línea 1 break; } [$sku, $nombre, $precio, $stock] = $c; $vistos[$sku] = true; $existe->execute([$sku]); $datos = [trim($nombre), (float) $precio, (int) $stock, $sku]; if ($existe->fetchColumn()) { $actualizar->execute($datos); $actualizados++; } else { $insertar->execute($datos); $nuevos++; }}if ($error !== null) { $pdo->rollBack(); // nada de lo importado se queda echo "Importación cancelada\n";} else { $pdo->commit(); echo "Nuevos: $nuevos, actualizados: $actualizados\n";}foreach ($pdo->query('SELECT * FROM productos ORDER BY sku') as $p) { echo "{$p['sku']} {$p['nombre']} {$p['precio']} {$p['stock']}\n";}
«Todo o nada» es exactamente lo que da una transacción: se abre antes de la primera fila, cada fila válida se guarda dentro de ella, y en cuanto una falla se hace rollBack, que deshace también las filas anteriores. Solo si todas pasan se confirma con commit.
str_getcsv interpreta el CSV de verdad: respeta las comillas (una coma dentro de «Cable, 2 m» no separa campos) y convierte "" en una comilla. La validación está en su propia función, que devuelve el primer motivo de error o null, y el mismo array de datos sirve para el UPDATE y el INSERT porque los marcadores van en el mismo orden.
json_decode($texto, true) convierte el JSON en arrays de PHP y devuelve null si el texto no es JSON válido, que es como se detecta el error. A partir de ahí, las funciones de arrays hacen casi todo: usort ordena por estrellas, array_slice toma los tres primeros y array_column extrae una columna de todos los objetos.
Los lenguajes se obtienen encadenando funciones: la columna language, sin los nulos (array_filter), sin repetidos (array_unique) y ordenada. number_format con 0 decimales pone el punto de miles.
La ETag es una huella del contenido: el md5 del JSON que se enviaría. Si el producto no cambia, la huella tampoco, así que basta con compararla con la que manda el cliente en If-None-Match: si coincide, se responde 304 sin cuerpo y el cliente usa lo que ya tiene.
La huella se calcula sobre exactamente los mismos bytes que se envían (con JSON_UNESCAPED_UNICODE), porque cualquier diferencia en la representación cambiaría el md5 aunque los datos fueran los mismos.
Una sola expresión regular reconoce las dos rutas: /api/libros y /api/libros/{id}, con el id en un grupo opcional. Si no encaja, la ruta no existe (404); si encaja, el método decide la operación, y uno que no tiene sentido para esa ruta da 405.
La lista se devuelve con array_values para que json_encode produzca un array JSON ([...]) y no un objeto con claves numéricas. El nuevo id es el mayor existente más uno, y un 204 se responde sin cuerpo.
Un JWT no cifra los datos (cualquiera puede decodificar el payload), pero los firma. La firma es un HMAC-SHA256 de «cabecera.payload» con la clave del servidor, en binario (true en hash_hmac) y codificado en base64url. Se extrae a la función firmar porque crear y verificar la calculan igual.
Al verificar, el orden es importante: primero la firma y solo después se leen los datos para mirar la caducidad, porque hasta que la firma no se comprueba no hay motivo para creer lo que dicen. La firma se compara con hash_equals, en tiempo constante.
La ventana deslizante guarda, por cliente, los instantes de sus peticiones admitidas. En cada petición se descartan las que ya tienen más de 10 segundos y se cuentan las que quedan: si son menos de 3, se admite y se apunta; si no, se rechaza con 429.
Retry-After dice cuánto falta para que la petición más antigua de la ventana caduque, que es cuando habrá sitio: su instante más 10 menos el actual. Las rechazadas no se apuntan, para que un cliente que insiste no se bloquee a sí mismo para siempre.
Para que «LOGRO» encuentre «Logroño» y «ávila» encuentre «Ávila», el texto buscado y cada municipio se normalizan igual: sin espacios a los lados, en minúsculas con mb_strtolower (que entiende UTF-8) y sin tildes con strtr. La comparación se hace siempre entre versiones normalizadas, pero se devuelve el nombre original.
La ñ también se convierte en n al normalizar: strcmp compara bytes, y en UTF-8 la ñ va detrás de todas las letras sin tilde, así que «Logroño» saldría después de «Logrosán».
mb_strpos dice dónde aparece el texto: en la posición 0, el municipio empieza por él; en otra, lo contiene; y false si no está. Cada grupo se ordena por separado y se unen con los que empiezan primero, porque son las sugerencias más útiles. El límite de 5 y el mínimo de 2 caracteres evitan respuestas enormes con cada pulsación.
La firma se calcula sobre el instante y el cuerpo juntos («t.cuerpo»): así no se puede cambiar ni el contenido del aviso ni su fecha sin invalidarla. El servidor la recalcula con su secreto y la compara con cada v1 recibido con hash_equals; basta con que coincida una, para poder cambiar de secreto sin cortar el servicio.
Comprobar la antigüedad evita los ataques de repetición: alguien que capture un aviso válido no puede reenviarlo días después. Como t está dentro de lo firmado, tampoco puede cambiar la fecha para que parezca nuevo.
<?phpconst UMBRAL = 3;const ESPERA = 30;$estado = 'cerrado';$fallos = 0;$abiertoDesde = 0;$llamadas = 0;foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$t, $resultado] = explode(' ', $linea); $t = (int) $t; if ($estado === 'abierto' && $t - $abiertoDesde < ESPERA) { echo "$t: rechazada (abierto)\n"; // ni siquiera se llama al servicio continue; } $llamadas++; $ok = $resultado === 'ok'; if ($estado === 'abierto') { // ya pasó la espera: esta llamada es la prueba if ($ok) { $estado = 'cerrado'; $fallos = 0; echo "$t: prueba ok → cerrado\n"; } else { $abiertoDesde = $t; echo "$t: prueba fallo → abierto\n"; } continue; } if ($ok) { $fallos = 0; echo "$t: ok (cerrado)\n"; } elseif (++$fallos >= UMBRAL) { $estado = 'abierto'; $abiertoDesde = $t; $fallos = 0; echo "$t: fallo → abierto\n"; } else { echo "$t: fallo (cerrado, $fallos fallos)\n"; }}echo "Llamadas reales: $llamadas\n";
El cortacircuitos es una máquina de estados. Abierto, rechaza al momento las peticiones hasta que pasan 30 segundos; la primera después de esa espera se deja pasar como prueba, y su resultado decide si se cierra o se vuelve a abrir.
Cerrado, cuenta los fallos seguidos (un éxito la reinicia) y se abre al llegar a 3. Lo importante es que las peticiones rechazadas no llegan a llamar al servicio: la tienda responde rápido aunque la API esté caída, y la API no recibe más carga mientras intenta recuperarse.
str_getcsv interpreta cada línea como CSV, así que «"Pérez, Juan"» llega como un solo campo aunque tenga una coma dentro; con explode(',') se partiría en dos. Las filas con un número de campos distinto o un importe no numérico se cuentan y se saltan.
Las ventas se acumulan en un array asociativo comercial → importe y se ordenan con arsort, que ordena por valor de mayor a menor conservando las claves. number_format redondea al mostrar, por eso 0,1 + 0,2 sale como 0,30 aunque en coma flotante sea 0,30000000000000004.
<?php$pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,]);$pdo->exec('CREATE TABLE tareas (id INTEGER PRIMARY KEY, titulo TEXT NOT NULL, hecha INTEGER NOT NULL DEFAULT 0)');function responder(int $codigo, mixed $datos = null): void{ echo $codigo, $datos === null ? '' : ' ' . json_encode($datos, JSON_UNESCAPED_UNICODE), "\n";}// Fila de la base de datos → JSON (hecha como booleano)$aJson = fn(array $f) => ['id' => (int) $f['id'], 'titulo' => $f['titulo'], 'hecha' => (bool) $f['hecha']];$buscar = $pdo->prepare('SELECT * FROM tareas WHERE id = ?');foreach (file('php://stdin', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) { [$metodo, $ruta, $cuerpo] = array_pad(explode(' ', $linea, 3), 3, ''); $camino = (string) parse_url($ruta, PHP_URL_PATH); parse_str((string) parse_url($ruta, PHP_URL_QUERY), $query); $datos = json_decode($cuerpo, true) ?? []; if ($camino === '/tareas') { if ($metodo === 'GET') { if (isset($query['hecha'])) { $consulta = $pdo->prepare('SELECT * FROM tareas WHERE hecha = ? ORDER BY id'); $consulta->execute([$query['hecha'] ? 1 : 0]); } else { $consulta = $pdo->prepare('SELECT * FROM tareas ORDER BY id'); $consulta->execute(); } responder(200, array_map($aJson, $consulta->fetchAll())); } elseif ($metodo === 'POST') { $titulo = trim((string) ($datos['titulo'] ?? '')); if ($titulo === '') { responder(422, ['error' => 'El título es obligatorio']); continue; } $pdo->prepare('INSERT INTO tareas (titulo) VALUES (?)')->execute([$titulo]); responder(201, ['id' => (int) $pdo->lastInsertId(), 'titulo' => $titulo, 'hecha' => false]); } else { responder(405, ['error' => 'Método no permitido']); } continue; } if (preg_match('#^/tareas/(\d+)$#', $camino, $m)) { $buscar->execute([$m[1]]); $tarea = $buscar->fetch(); if (!$tarea) { responder(404, ['error' => 'Tarea no encontrada']); } elseif ($metodo === 'GET') { responder(200, $aJson($tarea)); } elseif ($metodo === 'PATCH') { if (array_key_exists('hecha', $datos)) { $pdo->prepare('UPDATE tareas SET hecha = ? WHERE id = ?')->execute([$datos['hecha'] ? 1 : 0, $m[1]]); } if (isset($datos['titulo']) && trim($datos['titulo']) !== '') { $pdo->prepare('UPDATE tareas SET titulo = ? WHERE id = ?')->execute([trim($datos['titulo']), $m[1]]); } $buscar->execute([$m[1]]); responder(200, $aJson($buscar->fetch())); } elseif ($metodo === 'DELETE') { $pdo->prepare('DELETE FROM tareas WHERE id = ?')->execute([$m[1]]); responder(204); } else { responder(405, ['error' => 'Método no permitido']); } continue; } responder(404, ['error' => 'Ruta no encontrada']);}
La API tiene dos tipos de rutas: la colección (/tareas), donde se lista y se crea, y cada elemento (/tareas/{id}), donde se consulta, modifica o borra. Para los elementos, lo primero es buscar la tarea: si no existe, la respuesta es 404 sea cual sea el método.
La base de datos guarda hecha como 0/1 y la API la devuelve como booleano: la función $aJson hace esa traducción en un solo sitio, y se usa para todas las respuestas. PATCH solo cambia los campos que llegan en el cuerpo y responde con la tarea leída otra vez de la base de datos, que es su estado real.
Todas las consultas que llevan datos van preparadas, incluido el filtro ?hecha=, que se lee de la query string con parse_url y parse_str.
Para elegir el mejor cupón hay que poder comparar cupones muy distintos (un porcentaje, una cantidad fija y el envío). La función $ahorro los reduce todos a lo mismo, los euros que ahorran, y devuelve 0 si el código no existe o no cumple su condición. Después basta con quedarse con el que más ahorra.
El envío se calcula antes que los cupones porque ENVIOGRATIS ahorra justo lo que cuesta el envío (0 si ya era gratis). match compara con === y obliga a contemplar todos los casos, con default para los códigos desconocidos.
La lectura y la validación van primero y por separado: cada línea se comprueba campo a campo y, si algo falla, se avisa y se pasa a la siguiente con continue. Como los avisos se escriben mientras se lee, aparecen antes que la factura sin necesidad de guardarlos.
Todos los importes son céntimos enteros. Solo hay redondeo donde aparece un porcentaje (el descuento y cada cuota de IVA), y se redondea una vez por categoría, igual que en una factura real, donde el IVA se calcula sobre la base de cada tipo y no producto a producto.
El array asociativo $base es el corazón del programa: acumula la base de cada categoría ya con el 3x2, el descuento modifica solo la de general y el bucle del IVA lo recorre en el orden de la constante IVA, que define a la vez los tipos y el orden de las líneas del resumen.
La presentación se apoya en sprintf para las columnas y en una única función dinero para el formato de los importes. mb_str_pad cuenta caracteres en lugar de bytes, así que los nombres con tildes también quedan alineados.
45. Préstamos de una biblioteca con PDO y transacciones
<?php// Base de datos de la biblioteca (SQLite en memoria): tablas libros, socios y prestamos$pdo = new PDO("sqlite::memory:");$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);$pdo->exec(<<<SQL CREATE TABLE libros (codigo TEXT PRIMARY KEY, titulo TEXT NOT NULL, autor TEXT NOT NULL, ejemplares INTEGER NOT NULL); CREATE TABLE socios (id INTEGER PRIMARY KEY, nombre TEXT NOT NULL); CREATE TABLE prestamos ( id INTEGER PRIMARY KEY AUTOINCREMENT, codigo TEXT NOT NULL REFERENCES libros(codigo), socio INTEGER NOT NULL REFERENCES socios(id), salida TEXT NOT NULL, devolucion TEXT ); INSERT INTO libros VALUES ('L01', 'Cien años de soledad', 'Gabriel García Márquez', 2), ('L02', '1984', 'George Orwell', 1), ('L03', 'Rebelión en la granja', 'George Orwell', 2), ('L04', 'La sombra del viento', 'Carlos Ruiz Zafón', 1), ('L05', 'El nombre del viento', 'Patrick Rothfuss', 3), ('L06', 'El tercer policía', 'Flann O''Brien', 1); INSERT INTO socios VALUES (1, 'Ana Gil'), (2, 'Luis Mora'), (3, 'Eva Ruiz'); INSERT INTO prestamos (codigo, socio, salida, devolucion) VALUES ('L02', 2, '2026-09-01', NULL), ('L01', 1, '2026-09-20', '2026-09-28'), ('L05', 1, '2026-09-25', NULL); SQL);const PLAZO = 15; // días de préstamoconst MULTA = 20; // céntimos por día de retrasoconst MAXIMO = 3; // préstamos activos por sociofunction euros(int $c): string { return number_format($c / 100, 2, ",", ".") . " €"; }function dias(string $desde, string $hasta): int { return (int) (new DateTimeImmutable($desde))->diff(new DateTimeImmutable($hasta))->format("%r%a");}function vence(string $salida): string { return (new DateTimeImmutable($salida))->modify("+" . PLAZO . " days")->format("Y-m-d");}/** Ejemplares que quedan en la estantería: los totales menos los prestados sin devolver. */function disponibles(PDO $pdo, string $codigo): int { $st = $pdo->prepare("SELECT l.ejemplares - COUNT(p.id) FROM libros l LEFT JOIN prestamos p ON p.codigo = l.codigo AND p.devolucion IS NULL WHERE l.codigo = ? GROUP BY l.codigo"); $st->execute([$codigo]); return (int) $st->fetchColumn();}function buscar(PDO $pdo, string $texto): void { // El texto va como parámetro: un apóstrofo (O'Brien) no rompe la consulta ni permite inyectar SQL $st = $pdo->prepare("SELECT codigo, titulo, autor, ejemplares FROM libros WHERE titulo LIKE :t OR autor LIKE :t ORDER BY titulo"); $st->execute([":t" => "%$texto%"]); $libros = $st->fetchAll(); if (!$libros) { echo "Sin resultados para «{$texto}»\n"; return; } foreach ($libros as $l) { echo "{$l['codigo']} · {$l['titulo']} ({$l['autor']}) · disponibles " . disponibles($pdo, $l['codigo']) . " de {$l['ejemplares']}\n"; }}function prestar(PDO $pdo, string $codigo, int $socio, string $fecha): void { $libro = $pdo->prepare("SELECT titulo FROM libros WHERE codigo = ?"); $libro->execute([$codigo]); $titulo = $libro->fetchColumn(); $persona = $pdo->prepare("SELECT nombre FROM socios WHERE id = ?"); $persona->execute([$socio]); $nombre = $persona->fetchColumn(); if ($titulo === false) { echo "No existe el libro $codigo\n"; return; } if ($nombre === false) { echo "No existe el socio $socio\n"; return; } // Las comprobaciones y el INSERT van en una transacción: nadie puede llevarse el último ejemplar entre medias $pdo->beginTransaction(); try { if (disponibles($pdo, $codigo) <= 0) throw new RuntimeException("No quedan ejemplares de «{$titulo}»"); $activos = $pdo->prepare("SELECT salida FROM prestamos WHERE socio = ? AND devolucion IS NULL"); $activos->execute([$socio]); $salidas = $activos->fetchAll(PDO::FETCH_COLUMN); foreach ($salidas as $s) { if (vence($s) < $fecha) throw new RuntimeException("$nombre tiene préstamos vencidos"); } if (count($salidas) >= MAXIMO) throw new RuntimeException("$nombre ya tiene " . MAXIMO . " préstamos"); $pdo->prepare("INSERT INTO prestamos (codigo, socio, salida) VALUES (?, ?, ?)")->execute([$codigo, $socio, $fecha]); $id = $pdo->lastInsertId(); $pdo->commit(); echo "Préstamo $id: «{$titulo}» para $nombre (devolver antes del " . vence($fecha) . ")\n"; } catch (RuntimeException $e) { $pdo->rollBack(); echo $e->getMessage() . "\n"; }}function devolver(PDO $pdo, int $id, string $fecha): void { $st = $pdo->prepare("SELECT p.salida, p.devolucion, l.titulo FROM prestamos p JOIN libros l USING (codigo) WHERE p.id = ?"); $st->execute([$id]); $p = $st->fetch(); if (!$p) { echo "No existe el préstamo $id\n"; return; } if ($p['devolucion'] !== null) { echo "El préstamo $id ya se devolvió el {$p['devolucion']}\n"; return; } $pdo->prepare("UPDATE prestamos SET devolucion = ? WHERE id = ?")->execute([$fecha, $id]); $retraso = max(0, dias($p['salida'], $fecha) - PLAZO); if ($retraso === 0) { echo "Devuelto «{$p['titulo']}» a tiempo\n"; } else { echo "Devuelto «{$p['titulo']}» con $retraso " . ($retraso === 1 ? "día" : "días") . " de retraso: multa " . euros($retraso * MULTA) . "\n"; }}function morosos(PDO $pdo, string $fecha): void { $st = $pdo->query("SELECT s.nombre, l.titulo, p.salida FROM prestamos p JOIN socios s ON s.id = p.socio JOIN libros l ON l.codigo = p.codigo WHERE p.devolucion IS NULL ORDER BY p.salida, p.id"); $lista = []; foreach ($st as $fila) { $retraso = dias($fila['salida'], $fecha) - PLAZO; if ($retraso > 0) $lista[] = " {$fila['nombre']}: «{$fila['titulo']}» ($retraso " . ($retraso === 1 ? "día" : "días") . " de retraso)"; } echo $lista ? "Préstamos vencidos el $fecha:\n" . implode("\n", $lista) . "\n" : "No hay préstamos vencidos el $fecha\n";}function resumen(PDO $pdo): void { $activos = $pdo->query("SELECT COUNT(*) FROM prestamos WHERE devolucion IS NULL")->fetchColumn(); echo "Préstamos activos: $activos\n"; $top = $pdo->query("SELECT s.nombre, COUNT(*) AS n FROM prestamos p JOIN socios s ON s.id = p.socio GROUP BY s.id ORDER BY n DESC, s.nombre LIMIT 1")->fetch(); echo $top ? "Socio más lector: {$top['nombre']} ({$top['n']} préstamos)\n" : "Socio más lector: ninguno\n";}foreach (explode("\n", stream_get_contents(STDIN)) as $linea) { $linea = trim($linea); if ($linea === "") continue; $p = preg_split('/\s+/', $linea, 2); $orden = strtoupper($p[0]); $args = isset($p[1]) ? preg_split('/\s+/', $p[1]) : []; echo "> $linea\n"; match (true) { $orden === "BUSCAR" && isset($p[1]) => buscar($pdo, $p[1]), $orden === "PRESTAR" && count($args) === 3 => prestar($pdo, $args[0], (int) $args[1], $args[2]), $orden === "DEVOLVER" && count($args) === 2 => devolver($pdo, (int) $args[0], $args[1]), $orden === "MOROSOS" && count($args) === 1 => morosos($pdo, $args[0]), $orden === "RESUMEN" => resumen($pdo), default => print("Orden no válida\n"), };}
Toda consulta con datos del usuario es preparada. Además de proteger frente a la inyección de SQL, evita los errores de comillas (O'Brien) y permite reutilizar la consulta con distintos valores.
El préstamo es el caso de libro de una transacción: varias lecturas (disponibles, préstamos activos) y una escritura que solo tiene sentido si las lecturas siguen siendo ciertas. Con beginTransaction/commit/rollBack todo ocurre o nada ocurre; en MySQL se añadiría SELECT … FOR UPDATE para bloquear las filas leídas.
Los ejemplares disponibles no se guardan en ninguna columna: se calculan con un LEFT JOIN a partir de los préstamos. Así nunca pueden quedar desincronizados, que es lo que pasaría si cada préstamo tuviera que acordarse de restar uno.
Las reglas se expresan como excepciones con el mensaje para el usuario, de modo que la transacción tiene un único camino de éxito y un único catch que deshace. DateTimeImmutable hace las cuentas de fechas sin errores de meses o años bisiestos.
<?php/** Una ruta: método, patrón con {parámetros} y la función que la atiende. */final class Ruta { public function __construct(public string $metodo, public string $patron, public Closure $accion) {} /** Los parámetros de la ruta si la URL encaja con el patrón, o null. */ public function encaja(string $ruta): ?array { $regex = "#^" . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $this->patron) . "$#"; if (!preg_match($regex, $ruta, $m)) return null; return array_filter($m, "is_string", ARRAY_FILTER_USE_KEY); }}final class Respuesta { public function __construct(public int $estado, public mixed $cuerpo = null) {}}final class Router { /** @var Ruta[] */ private array $rutas = []; public function add(string $metodo, string $patron, Closure $accion): void { $this->rutas[] = new Ruta($metodo, $patron, $accion); } public function despachar(string $metodo, string $ruta, array $consulta, ?string $cuerpo): Respuesta { $rutaExiste = false; foreach ($this->rutas as $r) { $params = $r->encaja($ruta); if ($params === null) continue; $rutaExiste = true; if ($r->metodo === $metodo) return ($r->accion)($params, $consulta, $cuerpo); } // Si la ruta existe pero no con ese método, es 405, no 404 return $rutaExiste ? new Respuesta(405, ["error" => "Método no permitido"]) : new Respuesta(404, ["error" => "Ruta no encontrada"]); }}$tareas = [];$siguiente = 1;const PRIORIDADES = ["alta", "media", "baja"];/** Valida los campos que vienen en $datos; con $nueva, el título es obligatorio. Devuelve los errores. */function validar(array $datos, bool $nueva): array { $errores = []; if ($nueva && !array_key_exists("titulo", $datos)) { $errores["titulo"] = "obligatorio"; } elseif (array_key_exists("titulo", $datos) && (!is_string($datos["titulo"]) || mb_strlen(trim($datos["titulo"])) < 3 || mb_strlen(trim($datos["titulo"])) > 60)) { $errores["titulo"] = "entre 3 y 60 caracteres"; } if (array_key_exists("prioridad", $datos) && !in_array($datos["prioridad"], PRIORIDADES, true)) { $errores["prioridad"] = "debe ser alta, media o baja"; } if (array_key_exists("hecha", $datos) && !is_bool($datos["hecha"])) { $errores["hecha"] = "debe ser true o false"; } $permitidos = ["titulo", "prioridad", "hecha"]; foreach (array_diff(array_keys($datos), $permitidos) as $campo) $errores[$campo] = "campo desconocido"; return $errores;}/** El cuerpo JSON como array asociativo, o null si no es un objeto JSON. */function json(?string $cuerpo): ?array { $datos = json_decode($cuerpo ?? "", true); return is_array($datos) && !array_is_list($datos) ? $datos : (($cuerpo ?? "") === "{}" ? [] : null);}$router = new Router();$router->add("GET", "/api/tareas", function (array $p, array $q) use (&$tareas): Respuesta { $lista = array_values($tareas); if (isset($q["hecha"])) $lista = array_values(array_filter($lista, fn($t) => $t["hecha"] === ($q["hecha"] === "true"))); if (isset($q["q"])) $lista = array_values(array_filter($lista, fn($t) => mb_stripos($t["titulo"], $q["q"]) !== false)); return new Respuesta(200, $lista);});$router->add("POST", "/api/tareas", function (array $p, array $q, ?string $cuerpo) use (&$tareas, &$siguiente): Respuesta { $datos = json($cuerpo); if ($datos === null) return new Respuesta(400, ["error" => "JSON no válido"]); if ($errores = validar($datos, true)) return new Respuesta(422, ["errores" => $errores]); $tarea = ["id" => $siguiente++, "titulo" => trim($datos["titulo"]), "prioridad" => $datos["prioridad"] ?? "media", "hecha" => $datos["hecha"] ?? false]; $tareas[$tarea["id"]] = $tarea; return new Respuesta(201, $tarea);});$router->add("GET", "/api/tareas/{id}", function (array $p) use (&$tareas): Respuesta { return isset($tareas[$p["id"]]) ? new Respuesta(200, $tareas[$p["id"]]) : new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]);});$router->add("PATCH", "/api/tareas/{id}", function (array $p, array $q, ?string $cuerpo) use (&$tareas): Respuesta { if (!isset($tareas[$p["id"]])) return new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]); $datos = json($cuerpo); if ($datos === null) return new Respuesta(400, ["error" => "JSON no válido"]); if ($errores = validar($datos, false)) return new Respuesta(422, ["errores" => $errores]); if (isset($datos["titulo"])) $datos["titulo"] = trim($datos["titulo"]); $tareas[$p["id"]] = array_merge($tareas[$p["id"]], $datos); return new Respuesta(200, $tareas[$p["id"]]);});$router->add("DELETE", "/api/tareas/{id}", function (array $p) use (&$tareas): Respuesta { if (!isset($tareas[$p["id"]])) return new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]); unset($tareas[$p["id"]]); return new Respuesta(204);});foreach (explode("\n", stream_get_contents(STDIN)) as $linea) { $linea = trim($linea); if ($linea === "") continue; $partes = explode(" ", $linea, 3); if (count($partes) < 2) { echo "Petición no válida\n"; continue; } [$metodo, $url] = $partes; $ruta = parse_url($url, PHP_URL_PATH) ?? ""; parse_str(parse_url($url, PHP_URL_QUERY) ?? "", $consulta); $r = $router->despachar(strtoupper($metodo), rtrim($ruta, "/") ?: "/", $consulta, $partes[2] ?? null); $json = $r->cuerpo === null ? "" : " " . json_encode($r->cuerpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); echo strtoupper($metodo) . " $url → {$r->estado}$json\n";}
El router separa el «a dónde va cada petición» del «qué hace cada una»: registrar una ruta nueva no toca nada más. Convertir los patrones en expresiones regulares con grupos con nombre es exactamente lo que hacen los routers de los frameworks.
Los códigos de estado son parte de la API: un cliente decide qué hacer según el código antes de leer el cuerpo. Distinguir 400 (no entiendo la petición), 404 (no existe), 405 (existe, pero no así) y 422 (entiendo la petición, pero los datos no valen) es lo que hace una API predecible.
Las closures con use (&$tareas) comparten el almacén en memoria; en una aplicación real ese estado viviría en una base de datos y se inyectaría un repositorio, pero la estructura de las acciones sería la misma.
La validación es una función que acumula errores por campo en lugar de parar en el primero: el cliente puede mostrar todos los problemas del formulario de una vez. PATCH reutiliza las mismas reglas sin exigir el título.
47. Libro de visitas seguro: validación, spam y escape de HTML
<?phpconst MAX_POR_EMAIL = 2;/** Errores de un mensaje (campo → motivo), en el orden de los campos. */function validar(array $f): array { $e = []; $nombre = trim($f["nombre"] ?? ""); if ($nombre === "") $e["nombre"] = "obligatorio"; elseif (mb_strlen($nombre) < 2 || mb_strlen($nombre) > 50) $e["nombre"] = "entre 2 y 50 caracteres"; $email = trim($f["email"] ?? ""); if ($email === "") $e["email"] = "obligatorio"; elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) $e["email"] = "no es un email válido"; // La web es opcional, pero solo http o https: «javascript:» en un enlace es una puerta a XSS $web = trim($f["web"] ?? ""); if ($web !== "" && (filter_var($web, FILTER_VALIDATE_URL) === false || !in_array(strtolower(parse_url($web, PHP_URL_SCHEME)), ["http", "https"], true))) { $e["web"] = "solo direcciones http o https"; } $mensaje = trim($f["mensaje"] ?? ""); if ($mensaje === "") $e["mensaje"] = "obligatorio"; elseif (mb_strlen($mensaje) > 200) $e["mensaje"] = "como mucho 200 caracteres"; return $e;}/** Escapa para HTML: <, >, &, comillas dobles y simples. */function h(string $texto): string { return htmlspecialchars($texto, ENT_QUOTES | ENT_HTML5, "UTF-8");}$publicados = [];$porEmail = [];$rechazados = $spam = 0;$n = 0;foreach (explode("\n", stream_get_contents(STDIN)) as $linea) { if (trim($linea) === "") continue; $n++; parse_str(trim($linea), $f); // Campo trampa: está oculto con CSS, así que solo lo rellenan los robots if (($f["telefono2"] ?? "") !== "") { $spam++; echo "Mensaje $n descartado: spam\n"; continue; } $errores = validar($f); $email = strtolower(trim($f["email"] ?? "")); if (!$errores && ($porEmail[$email] ?? 0) >= MAX_POR_EMAIL) $errores["email"] = "ya ha enviado " . MAX_POR_EMAIL . " mensajes"; if ($errores) { $rechazados++; foreach ($errores as $campo => $motivo) echo "Mensaje $n rechazado: $campo: $motivo\n"; continue; } $porEmail[$email] = ($porEmail[$email] ?? 0) + 1; $publicados[] = $f;}echo "<ul class=\"visitas\">\n";foreach ($publicados as $f) { $autor = "<strong>" . h(trim($f["nombre"])) . "</strong>"; $web = trim($f["web"] ?? ""); if ($web !== "") $autor .= " (<a href=\"" . h($web) . "\" rel=\"nofollow ugc\">web</a>)"; echo " <li>$autor: " . h(trim($f["mensaje"])) . "</li>\n";}echo "</ul>\n";echo "Publicados: " . count($publicados) . " · Rechazados: $rechazados · Spam: $spam\n";
La seguridad tiene dos momentos: al recibir (validar que cada dato es lo que dice ser) y al mostrar (escapar para el contexto donde se escribe). Validar no sustituye a escapar: un nombre como O'Brien <3 es válido y aun así hay que escaparlo.
htmlspecialchars con ENT_QUOTES escapa también las comillas simples, necesarias cuando el valor va dentro de un atributo como href. Por eso el enlace se escribe con el valor escapado y, además, solo se aceptan esquemas http y https.
El campo trampa detecta robots sin molestar a las personas (a diferencia de un CAPTCHA). No es infalible, pero combinado con el límite por email frena la mayor parte del spam automático.
rel="nofollow ugc" le dice a los buscadores que el enlace lo ha puesto un usuario: así los spammers no ganan posicionamiento enlazando desde tu web.
48. Horas trabajadas con nocturnidad usando DateTime
<?php/** Minutos de [$inicio, $fin) que caen entre las 22:00 y las 06:00. */function minutosNocturnos(DateTimeImmutable $inicio, DateTimeImmutable $fin): int { $total = 0; // Cada noche va de las 22:00 de un día a las 06:00 del siguiente; se empieza por la noche anterior al inicio $noche = $inicio->modify("-1 day")->setTime(22, 0); while ($noche < $fin) { $desde = max($inicio, $noche); $hasta = min($fin, $noche->modify("+8 hours")); if ($hasta > $desde) $total += intdiv($hasta->getTimestamp() - $desde->getTimestamp(), 60); $noche = $noche->modify("+1 day"); } return $total;}function duracion(int $minutos): string { $h = intdiv($minutos, 60); $m = $minutos % 60; return $m === 0 ? "$h h" : "$h h $m min";}function euros(int $c): string { return number_format($c / 100, 2, ",", ".") . " €";}$lineas = explode("\n", stream_get_contents(STDIN));if (!preg_match('/^TARIFA (\d+(\.\d{1,2})?)$/', trim($lineas[0]), $m)) { echo "Falta la tarifa\n"; exit;}$tarifa = (int) round((float) $m[1] * 100); // céntimos por hora$turnos = []; // nombre → lista de [inicio, fin]foreach (array_slice($lineas, 1) as $i => $linea) { $linea = trim($linea); if ($linea === "") continue; $num = $i + 2; $campos = array_map("trim", explode(";", $linea)); $inicio = count($campos) === 3 ? DateTimeImmutable::createFromFormat("!Y-m-d H:i", $campos[1]) : false; $fin = count($campos) === 3 ? DateTimeImmutable::createFromFormat("!Y-m-d H:i", $campos[2]) : false; // createFromFormat acepta el 31 de septiembre (lo pasa al 1 de octubre): se comprueba que la fecha no cambie if (!$inicio || !$fin || $inicio->format("Y-m-d H:i") !== $campos[1] || $fin->format("Y-m-d H:i") !== $campos[2] || $campos[0] === "") { echo "Línea $num: formato no válido\n"; continue; } $nombre = $campos[0]; $minutos = intdiv($fin->getTimestamp() - $inicio->getTimestamp(), 60); if ($minutos <= 0) { echo "Línea $num: la salida no es posterior a la entrada\n"; continue; } if ($minutos > 12 * 60) { echo "Línea $num: un turno no puede pasar de 12 horas\n"; continue; } foreach ($turnos[$nombre] ?? [] as [$a, $b]) { if ($inicio < $b && $a < $fin) { echo "Línea $num: se solapa con otro turno de $nombre\n"; continue 2; } } $turnos[$nombre][] = [$inicio, $fin];}ksort($turnos, SORT_STRING);$total = 0;foreach ($turnos as $nombre => $lista) { $minutos = $nocturnos = 0; foreach ($lista as [$a, $b]) { $minutos += intdiv($b->getTimestamp() - $a->getTimestamp(), 60); $nocturnos += minutosNocturnos($a, $b); } // Todas las horas a la tarifa, y las nocturnas con un 25 % más $importe = (int) round(($minutos * $tarifa + $nocturnos * $tarifa * 0.25) / 60); $total += $importe; $turnosTexto = count($lista) === 1 ? "1 turno" : count($lista) . " turnos"; echo "$nombre: $turnosTexto, " . duracion($minutos) . " (nocturnas " . duracion($nocturnos) . ") · " . euros($importe) . "\n";}echo "Total a pagar: " . euros($total) . "\n";
La clave del ejercicio es tratar los turnos y las noches como intervalos de tiempo: la parte nocturna de un turno es su intersección con cada noche. El mismo razonamiento sirve para tarifas eléctricas por franjas, aparcamientos o reservas de salas.
DateTimeImmutable hace las cuentas de calendario (cambio de día, de mes o de año) y su inmutabilidad evita los errores de modificar sin querer una fecha que se sigue usando. Comparar objetos de fecha con < y usar max/min funciona directamente.
La validación de fechas con la vuelta al texto original detecta lo que createFromFormat corrige en silencio. Es el mismo problema que con Date en JavaScript: las librerías de fechas prefieren «arreglar» antes que fallar.
El importe se calcula en céntimos con un único redondeo por persona, para que la suma de las nóminas sea exactamente el total que se escribe.