🐘 Curso PHP · Nivel N3 Intermedio

Bloque 3: POO y base de datos

El bloque que te convierte en desarrollador/a backend de verdad: organizarás el código en clases y objetos con POO (herencia, interfaces y traits), conectarás de forma segura a MySQL con PDO haciendo CRUD completos, y aprenderás a proteger tu web contra inyección SQL, XSS y CSRF con contraseñas con hash. Terminarás creando un CRUD seguro completo con Composer.

⏱ ≈ 16 horas 📚 6 lecciones ✍️ 16 ejercicios guiados 📝 Examen final superable (≥ 70 %)
Lección 1 · Teoría

Clases y objetos

La Programación Orientada a Objetos organiza el código en torno a objetos: estructuras que combinan datos (propiedades) y comportamiento (métodos). Una clase es el «plano»; cada objeto es una «instancia» concreta construida a partir de ese plano.

1.1 · Tu primera clase

definir y usar una clase
class Usuario {
    // propiedades con nivel de acceso
    public $nombre;
    private $clave;

    // constructor: se ejecuta al crear el objeto
    public function __construct($nombre, $clave) {
        $this->nombre = $nombre;
        $this->clave  = $clave;
    }

    // método público
    public function saludar() {
        return "Hola, soy " . $this->nombre;
    }
}

// crear una instancia (objeto)
$u = new Usuario("Raúl", "secreto");
echo $u->saludar();   // Hola, soy Raúl
echo $u->nombre;      // público: accesible
// echo $u->clave;  ❌ privado: fuera de la clase no se puede

1.2 · Modificadores de acceso y getters

public (accesible desde fuera), private (solo dentro de la clase) y protected (dentro de la clase y sus herederas). Para leer propiedades privadas sin exponerlas directamente, se usan getters/setters:

encapsulación con getter/setter
class CuentaBancaria {
    private $saldo = 0;

    public function getSaldo() {
        return $this->saldo;
    }

    public function ingresar($cantidad) {
        if ($cantidad <= 0) {
            throw new InvalidArgumentException("Cantidad inválida");
        }
        $this->saldo += $cantidad;
    }
}
💡 propiedades private + métodos public = encapsulación: el objeto controla cómo cambia su propio estado y evitas que se corrompa desde fuera.
Lección 2 · Teoría

Herencia, interfaces y traits

2.1 · Herencia con extends

Una clase puede heredar de otra con extends, reutilizando sus métodos y añadiendo o sobrescribiendo (override) lo suyo:

clase hija sobrescribe un método
class Animal {
    protected $nombre;

    public function sonido() {
        return "...";
    }
}

class Perro extends Animal {
    public function sonido() {
        return "Guau";
    }
}

$p = new Perro();
echo $p->sonido();  // Guau

2.2 · Interfaces: contratos

Una interfaz declara métodos que la clase debe implementar. PHP no tiene herencia múltiple, pero una clase sí puede implementar varias interfaces:

obligar a implementar métodos
interface Loggeable {
    public function loguear($mensaje): void;
}

class Pedido implements Loggeable {
    public function loguear($mensaje): void {
        error_log($mensaje);
    }
}

2.3 · Traits: reutilizar sin herencia

Un trait es un bloque de métodos reutilizable que se «inyecta» en varias clases, ideal para compartir lógica común que no encaja en una jerarquía de herencia:

compartir métodos con use
trait Formateable {
    public function precioMostrable($precio) {
        return number_format($precio, 2) . " €";
    }
}

class Producto {
    use Formateable;
    public $precio = 19.9;
}

$prod = new Producto();
echo $prod->precioMostrable($prod->precio); // "19.90 €"
⚠️ Regla práctica: herencia para relaciones «es un» (un Perro es un Animal), interfaces para «contratos» entre clases, y traits para compartir comportamiento sin forzar herencia.
Lección 3 · Teoría

PDO y conexión segura a MySQL

PDO (PHP Data Objects) es la capa moderna para hablar con bases de datos. A diferencia de la vieja mysqli, PDO usa sentencias preparadas que te protegen de la inyección SQL de forma natural. Para este bloque activa MySQL en XAMPP.

3.1 · Conectar y crear la base de datos

conexión PDO con try/catch
$dsn = "mysql:host=localhost;dbname=tienda;charset=utf8mb4";
$usuario = "root";
$clave  = "";      // XAMPP por defecto no tiene contraseña

try {
    $pdo = new PDO($dsn, $usuario, $clave, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    echo "Conectado\n";
} catch (PDOException $e) {
    echo "Error de conexión: " . $e->getMessage() . "\n";
    exit;
}
💡 ATTR_ERRMODE => ERRMODE_EXCEPTION hace que los errores de SQL lancen excepciones que capturas con try/catch, en lugar de fallos silenciosos. FETCH_ASSOC devuelve cada fila como un array asociativo.

3.2 · Sentencias preparadas (preventions de inyección)

Con un placeholder ? o :nombre y execute(), PDO separa la consulta de los datos: el valor jamás se interpreta como SQL.

placeholders con nombre
$sql = "SELECT * FROM productos WHERE precio > :minimo";
$stmt = $pdo->prepare($sql);
$stmt->execute(["minimo" => 20]);
$productos = $stmt->fetchAll();
Lección 4 · Teoría

CRUD completo con PDO

CRUD = Create, Read, Update, Delete: las cuatro operaciones básicas sobre cualquier tabla.

4.1 · Create (INSERT)

insertar con prepared statement
$stmt = $pdo->prepare(
    "INSERT INTO productos (nombre, precio) VALUES (:nombre, :precio)"
);
$stmt->execute(["nombre" => "Ratón", "precio" => 9.99]);

// id del último insertado
$id = $pdo->lastInsertId();

4.2 · Read (SELECT)

conseguir filas
// todas las filas
$filas = $pdo->query("SELECT * FROM productos")->fetchAll();

// una fila por id (con placeholder)
$stmt = $pdo->prepare("SELECT * FROM productos WHERE id = ?");
$stmt->execute([1]);
$uno = $stmt->fetch();

foreach ($filas as $f) {
    echo $f["nombre"] . ": " . $f["precio"] . "\n";
}

4.3 · Update y Delete

modificar y borrar
// UPDATE
$stmt = $pdo->prepare(
    "UPDATE productos SET precio = :precio WHERE id = :id"
);
$stmt->execute(["precio" => 11.50, "id" => 1]);

// DELETE (siempre con WHERE: ¡cuidado con borrarlo todo!)
$stmt = $pdo->prepare("DELETE FROM productos WHERE id = :id");
$stmt->execute(["id" => 4]);

echo $stmt->rowCount() . " fila(s) afectadas\n";
⚠️ En UPDATE y DELETE valida que el usuario tenga permiso y usa siempre condición WHERE. Nunca hagas DELETE FROM tabla sin filtro (borrarías todo).
Lección 5 · Teoría

Seguridad web

Esta es una de las lecciones más importantes de todo el curso. Las cuatro amenazas que debes conocer y bloquear:

5.1 · Inyección SQL

Ocurre cuando un dato del usuario se mete literalmente en una consulta. La solución es siempre usar sentencias preparadas (ya lo viste en la lección 3).

❌ vulnerable: concatenas SQL
// $id viene del usuario: ' OR '1'='1  ¡peligro!
$pdo->query("SELECT * FROM usuarios WHERE id = " . $_GET["id"]);
✅ seguro: prepared statement
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE id = :id");
$stmt->execute(["id" => $_GET["id"]]);

5.2 · XSS (Cross-Site Scripting)

Si imprimes datos del usuario sin escapar, un atacante puede inyectar <script> que se ejecute en el navegador de otras víctimas. Escapa SIEMPRE la salida con htmlspecialchars():

escapar antes de imprimir
// $nombre viene del usuario y se va a pintar en HTML
echo htmlspecialchars($nombre, ENT_QUOTES, "UTF-8");

5.3 · CSRF (Cross-Site Request Forgery)

Un atacante puede hacer que un usuario ya logueado envíe formularios sin saberlo. La defensa clásica es un token CSRF único por sesión que va en cada formulario:

token CSRF de ida y vuelta
session_start();
if (empty($_SESSION["csrf"])) {
    $_SESSION["csrf"] = bin2hex(random_bytes(32));
}

// en el formulario
echo '<input type="hidden" name="csrf" value="' . htmlspecialchars($_SESSION["csrf"]) . '">';

// al recibir el POST, validar
if (hash_equals($_SESSION["csrf"], $_POST["csrf"] ?? "")) {
    echo "Petición válida\n";
} else {
    echo "Token CSRF inválido\n";
}

5.4 · Hashing de contraseñas

NUNCA guardes contraseñas en claro. Usa password_hash() para almacenar y password_verify() para comprobar. El hash incluye un salt y es resistente a fuerza bruta:

hash y verificación correctas
// al registrar
$hash = password_hash("secreto123", PASSWORD_DEFAULT);
// guarda $hash en la base de datos

// al iniciar sesión
if (password_verify("secreto123", $hash)) {
    echo "Contraseña correcta\n";
} else {
    echo "Contraseña incorrecta\n";
}
💡 Recuerda todo el patrón seguro: prepared statements (SQL) + htmlspecialchars (XSS) + token CSRF (forgery) + password_hash/verify (credenciales). Con estas cuatro prácticas cubres la gran mayoría de los ataques reales.
Lección 6 · Teoría

Composer y carga automática

Composer es el gestor de dependencias de PHP (el equivalente a npm en Node). Con él instalas librerías y generas una carga automática de tus propias clases.

6.1 · composer.json y PSR-4

composer.json
{
  "require": {
    "php": ">=8.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Con PSR-4, la clase App\Producto se carga desde src/Producto.php automáticamente. Tras crear el archivo ejecutas composer dump-autoload y ya puedes usarla sin require manual:

6.2 · Usar el autoload

cargar todas tus clases sin require
// único require que necesitas
require __DIR__ . "/vendor/autoload.php";

// ahora las clases App\... se cargan solas
$p = new App\Producto("Ratón", 9.99);
⚠️ Para instalar Composer, descárgalo y ejecuta composer install dentro de tu carpeta del proyecto (donde está composer.json). No lo confundas con el servidor: Composer no es obligatorio para una web simple, pero sí para proyectos reales y para usar el ecosistema de librerías.
Práctica guiada

✍️ Los 16 ejercicios del bloque

Crea la carpeta php-b3/ dentro de htdocs. Los ejercicios de POO se pueden probar por línea de comandos (php archivo.php) o desde el navegador; los de base de datos necesitan MySQL activado y una base tienda creada (p. ej. desde phpMyAdmin).

Sección A · POO

1 Clase Producto

Crea la clase Producto con constructor y propiedades públicas $nombre y $precio. Crea dos instancias, llama a un método formatear() que devuelva "nombre — X,XX €" (con number_format) y muéstralos.

Ver solución
class Producto {
    public $nombre;
    public $precio;

    public function __construct($nombre, $precio) {
        $this->nombre = $nombre;
        $this->precio = $precio;
    }

    public function formatear() {
        return $this->nombre . " — " .
            number_format($this->precio, 2) . " €";
    }
}

$p1 = new Producto("Ratón", 9.99);
$p2 = new Producto("Monitor", 129);

echo $p1->formatear() . "\n";
echo $p2->formatear() . "\n";

2 Encapsulación con CuentaBancaria

Crea CuentaBancaria con private $saldo, un constructor que acepte el saldo inicial, y métodos getSaldo(), ingresar($n) y retirar($n). En retirar, lanza una excepción si $n > $saldo o es negativo. Prueba el flujo y muestra el saldo final.

Ver solución
class CuentaBancaria {
    private $saldo;

    public function __construct($saldoInicial = 0) {
        $this->saldo = $saldoInicial;
    }

    public function getSaldo() {
        return $this->saldo;
    }

    public function ingresar($n) {
        if ($n <= 0) {
            throw new InvalidArgumentException("Cantidad inválida");
        }
        $this->saldo += $n;
    }

    public function retirar($n) {
        if ($n <= 0 || $n > $this->saldo) {
            throw new InvalidArgumentException("Fondos insuficientes");
        }
        $this->saldo -= $n;
    }
}

$c = new CuentaBancaria(100);
$c->ingresar(50);
$c->retirar(30);
echo "Saldo: " . $c->getSaldo() . "\n"; // 120

3 Herencia Animal

Crea la clase base Animal con un método sonido() que devuelva "...", y las clases Perro y Gato que lo sobrescriban con «Guau» y «Miau». Instancia cada una y muestra sus sonidos.

Ver solución
class Animal {
    public function sonido() {
        return "...";
    }
}

class Perro extends Animal {
    public function sonido() {
        return "Guau";
    }
}

class Gato extends Animal {
    public function sonido() {
        return "Miau";
    }
}

echo (new Perro())->sonido() . "\n";
echo (new Gato())->sonido() . "\n";

4 Interfaz Pagable

Define la interfaz Pagable con un método importe(): float. Crea las clases LineaDePedido (con precio y cantidad) y TarifaPlana que la implementen devolviendo su importe. Instancia ambas, invoca importe() en cada una y muéstrala.

Ver solución
interface Pagable {
    public function importe(): float;
}

class LineaDePedido implements Pagable {
    public function __construct(private float $precio, private int $cantidad) {}

    public function importe(): float {
        return $this->precio * $this->cantidad;
    }
}

class TarifaPlana implements Pagable {
    public function __construct(private float $precio) {}

    public function importe(): float {
        return $this->precio;
    }
}

echo (new LineaDePedido(9.99, 2))->importe() . "\n"; // 19.98
echo (new TarifaPlana(30))->importe() . "\n"; // 30

5 Trait Formateable

Crea un trait Formateable con un método precioMostrable() y úsalo en dos clases distintas (Producto y Servicio) que tengan una propiedad $precio. Muestra el precio formateado de ambas.

Ver solución
trait Formateable {
    public function precioMostrable() {
        return number_format($this->precio, 2) . " €";
    }
}

class Producto {
    use Formateable;
    public $precio = 19.9;
}

class Servicio {
    use Formateable;
    public $precio = 50;
}

echo (new Producto())->precioMostrable() . "\n";
echo (new Servicio())->precioMostrable() . "\n";

Sección B · PDO y MySQL

6 Conectar con PDO

Escribe un conexion.php que conecte a mysql:host=localhost;dbname=tienda con try/catch, mostrando «Conectado» en éxito y el mensaje de error en fallo. Crea antes la base tienda desde phpMyAdmin o con CREATE DATABASE tienda.

Ver solución
$dsn = "mysql:host=localhost;dbname=tienda;charset=utf8mb4";

try {
    $pdo = new PDO($dsn, "root", "", [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
    echo "Conectado\n";
} catch (PDOException $e) {
    echo "Error: " . $e->getMessage() . "\n";
}

7 Crear la tabla productos

Mediante CREATE TABLE IF NOT EXISTS (ejecutado con $pdo->exec()) crea una tabla productos con id (INT AUTO_INCREMENT PRIMARY KEY), nombre (VARCHAR) y precio (DECIMAL). Muestra «Tabla creada».

Ver solución
$pdo->exec("CREATE TABLE IF NOT EXISTS productos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    precio DECIMAL(10,2) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

echo "Tabla creada\n";

8 INSERT y lastInsertId

Inserta tres productos con prepare() + execute(). Al insertar cada uno, muestra su id con lastInsertId(). Usa placeholders con nombre (:nombre, :precio).

Ver solución
$stmt = $pdo->prepare(
    "INSERT INTO productos (nombre, precio) VALUES (:nombre, :precio)"
);

foreach ([
    ["Ratón", 9.99], ["Teclado", 24.5], ["Monitor", 129],
] as $p) {
    $stmt->execute(["nombre" => $p[0], "precio" => $p[1]]);
    echo "Insertado id " . $pdo->lastInsertId() . "\n";
}

9 SELECT y fetchAll

Consulta SELECT * FROM productos y recorre las filas mostrando «id: nombre — precio €». Después haz una consulta con placeholder que traiga solo los productos con precio > :min (p. ej. 20) y muéstralos.

Ver solución
$filas = $pdo->query("SELECT * FROM productos")->fetchAll();

foreach ($filas as $f) {
    echo $f["id"] . ": " . $f["nombre"] . " — " . $f["precio"] . " €\n";
}

$stmt = $pdo->prepare("SELECT * FROM productos WHERE precio > :min");
$stmt->execute(["min" => 20]);
$caros = $stmt->fetchAll();

echo "Caros: " . count($caros) . "\n";

10 UPDATE y DELETE

Actualiza el precio del producto con id 1 a 11.50 con UPDATE ... WHERE id = :id y muestra rowCount(). Después borra el producto con id 3 con DELETE ... WHERE id = :id y muestra las filas afectadas.

Ver solución
$stmt = $pdo->prepare("UPDATE productos SET precio = :precio WHERE id = :id");
$stmt->execute(["precio" => 11.5, "id" => 1]);
echo "Update: " . $stmt->rowCount() . " fila(s)\n";

$stmt = $pdo->prepare("DELETE FROM productos WHERE id = :id");
$stmt->execute(["id" => 3]);
echo "Delete: " . $stmt->rowCount() . " fila(s)\n";

Sección C · Seguridad

11 Demonstrar inyección y arreglarla

Escribe una versión VULNERABLE que concatene $_GET["id"] en una consulta y una comentada que demuestre que ' OR '1'='1 rompería la lógica. Después escribe la versión segura con prepared statement. Comenta la diferencia.

Ver solución
// ❌ VULNERABLE: el usuario podría enviar: id=1 OR 1=1
// $pdo->query("SELECT * FROM productos WHERE id = " . $_GET["id"]);

// ✅ SEGURO: prepared statement con placeholder
$stmt = $pdo->prepare("SELECT * FROM productos WHERE id = :id");
$stmt->execute(["id" => $_GET["id"]]);
$fila = $stmt->fetch();

12 Escapar con htmlspecialchars

Ten un $nombre con un valor aparentemente inofensivo pero que contenga <script>alert('xss')</script>. Muestra la versión «cruda» y la escapada con htmlspecialchars($nombre, ENT_QUOTES, "UTF-8") para ver la diferencia. Comenta cuál se mostraría de forma segura en el navegador.

Ver solución
$nombre = "<script>alert('xss')</script>";

echo "Crudo: " . $nombre . "\n";
echo "Escapado: " . htmlspecialchars($nombre, ENT_QUOTES, "UTF-8") . "\n";

// El escapado convierte < > y ' en entidades, por lo que el navegador
// lo muestra como texto y NO lo ejecuta. Siempre escapa la salida.

13 Token CSRF

Crea un script con session_start(), genera (si no existe) un token en $_SESSION["csrf"] con random_bytes() + bin2hex(), muéstralo en un campo oculto de un formulario, y al recibir el POST valida con hash_equals() mostrando «Válido» o «Inválido».

Ver solución
session_start();

if (empty($_SESSION["csrf"])) {
    $_SESSION["csrf"] = bin2hex(random_bytes(32));
}

$enviado = $_SERVER["REQUEST_METHOD"] === "POST";
$valido = hash_equals($_SESSION["csrf"], $_POST["csrf"] ?? "");

?>
<form method="post">
    <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION["csrf"]) ?>">
    <button>Enviar</button>
</form>
<p><?= $enviado ? ($valido ? "Válido" : "Inválido") : "" ?></p>

14 Hash de contraseña

Usa password_hash("secreto123", PASSWORD_DEFAULT), muestra el hash, y comprueba con password_verify() la contraseña correcta y una incorrecta. Comenta por qué NO debería guardarse nunca en claro.

Ver solución
$clave = "secreto123";
$hash = password_hash($clave, PASSWORD_DEFAULT);

echo "Hash: $hash\n";

var_dump(password_verify("secreto123", $hash)); // true
var_dump(password_verify("incorrecta", $hash)); // false

// El hash con salt hace cada resultado único y difícil de revertir:
// aunque la BD se filtre, la contraseña en claro no queda expuesta.

Sección D · Composer

15 composer.json con PSR-4

Crea un composer.json con php >= 8.0 y autoload "App\\": "src/" (PSR-4). Crea src/Producto.php con la clase App\Producto y un uso.php que cargue vendor/autoload.php e instancie la clase. Pista: recuerda el namespace App; en la clase.

Ver solución
// composer.json
{
  "require": { "php": ">=8.0" },
  "autoload": {
    "psr-4": { "App\\": "src/" }
  }
}

// src/Producto.php
<?php
namespace App;

class Producto {
    public function __construct(public string $nombre) {}
}
?>

// uso.php — ejecutar: composer dump-autoload y luego php uso.php
<?php
require __DIR__ . "/vendor/autoload.php";

$p = new App\Producto("Ratón");
echo $p->nombre . "\n";

16 🏆 Proyecto integrador: «crud-seguro.php»

Une TODO el bloque en una aplicación que genere/lea productos en la tabla productos de forma segura:

  • Conecta con PDO (con try/catch y ERRMODE_EXCEPTION).
  • Define al menos una clase con constructor y algún método (p. ej. Producto).
  • Usa sentencias preparadas (prepare() + execute()) con placeholders.
  • Implementa al menos INSERT y SELECT (CRUD básico).
  • Recorre los resultados con foreach.
  • Escapa la salida con htmlspecialchars().
  • Usa password_hash() o password_verify().
  • NO concatenes variables directamente en SQL (nada de query("... " . $dato)).

Este ejercicio es el reto del laboratorio del examen: la validación automática comprobará estos criterios sobre el contenido de tus etiquetas <?php ... ?>.

Evaluación del bloque

📝 Examen del Bloque 3

Dos partes: un test teórico autoevaluable (mínimo 70 %) y un reto práctico de autocorrección con checklist.

Parte A · Test teórico (aprueba con 11/15)

1 · ¿Qué se crea con new Clase()?
2 · ¿Qué método se ejecuta automáticamente al crear un objeto?
3 · Un método private…
4 · ¿Con qué palabra clave una clase hereda de otra?
5 · ¿Qué define una interfaz?
6 · ¿Para qué sirve un trait?
7 · ¿Qué extensión es la forma segura de conectar PHP con MySQL?
8 · ¿Por qué protegen las sentencias preparadas de la inyección SQL?
9 · ¿Qué opción de PDO hace que los errores lancen excepciones?
10 · Las siglas CRUD significan…
11 · ¿Cómo escapas la salida para evitar XSS?
12 · ¿Qué protege un token CSRF?
13 · ¿Cuál es la forma correcta de guardar contraseñas?
14 · ¿Qué es Composer?
15 · Con autoload PSR-4 "App\\": "src/", ¿dónde vive App\Producto?

Parte B · Reto práctico final

Construye el ejercicio 16 («crud-seguro.php») sin mirar soluciones y valida tú mismo/a estos criterios. Mínimo para aprobar: 6 de 7 casillas.

Cumplidos: 0 / 7

🎉 ¿Superaste las dos partes? Enhorabuena: has aprobado el Bloque 3 de PHP. Ya escribes código orientado a objetos, te conectas de forma segura a MySQL con PDO y proteges tus aplicaciones. En el Bloque 4 construirás un proyecto real con arquitectura MVC, APIs REST y despliegue.