20 ejercicios avanzados de PHP resueltos: POO, MySQL, seguridad y MVC

Estos 20 ejercicios avanzados de PHP resueltos están pensados para quienes ya dominan sintaxis, formularios, PDO y bases de datos y quieren practicar conceptos de aplicaciones reales.

Vas a trabajar con POO, seguridad, autenticación, roles, consultas preparadas, MVC y Composer.

1. Crear una clase Producto

<?php
class Producto
{
    public function __construct(
        private string $nombre,
        private float $precio
    ) {
    }

    public function getNombre(): string
    {
        return $this->nombre;
    }

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

2. Encapsular el stock de un producto

public function descontarStock(int $cantidad): void
{
    if ($cantidad <= 0 || $cantidad > $this->stock) {
        throw new InvalidArgumentException('Stock inválido');
    }

    $this->stock -= $cantidad;
}

3. Crear una interfaz de notificaciones

interface Notificable
{
    public function enviar(string $mensaje): void;
}

4. Implementar notificaciones por email

class EmailNotificacion implements Notificable
{
    public function enviar(string $mensaje): void
    {
        echo 'Email: ' . $mensaje;
    }
}

5. Crear un repositorio con PDO

class ProductoRepository
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function todos(): array
    {
        return $this->pdo
            ->query('SELECT * FROM productos')
            ->fetchAll(PDO::FETCH_ASSOC);
    }
}

6. Buscar por ID con una consulta preparada

public function buscar(int $id): ?array
{
    $stmt = $this->pdo->prepare(
        'SELECT * FROM productos WHERE id = :id'
    );

    $stmt->execute(['id' => $id]);

    $resultado = $stmt->fetch(PDO::FETCH_ASSOC);

    return $resultado ?: null;
}

7. Implementar una transacción

$pdo->beginTransaction();

try {
    // operaciones relacionadas

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

8. Login seguro con password_verify()

if (
    $usuario &&
    password_verify($password, $usuario['password'])
) {
    session_regenerate_id(true);

    $_SESSION['usuario_id'] = $usuario['id'];
}

9. Middleware simple de autenticación

function requireLogin(): void
{
    if (!isset($_SESSION['usuario_id'])) {
        header('Location: /login.php');
        exit;
    }
}

10. Controlar acceso por rol

function requireAdmin(): void
{
    requireLogin();

    if ($_SESSION['rol'] !== 'admin') {
        http_response_code(403);
        exit('Acceso denegado');
    }
}

11. Generar un token CSRF

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

12. Validar el token CSRF

if (
    !isset($_POST['csrf_token']) ||
    !hash_equals(
        $_SESSION['csrf_token'],
        $_POST['csrf_token']
    )
) {
    http_response_code(403);
    exit;
}

13. Crear una capa de servicio

class ProductoService
{
    public function __construct(
        private ProductoRepository $repository
    ) {
    }

    public function listarActivos(): array
    {
        return array_filter(
            $this->repository->todos(),
            fn($producto) => $producto['activo']
        );
    }
}

14. Crear un controlador MVC

class ProductoController
{
    public function __construct(
        private ProductoService $service
    ) {
    }

    public function index(): void
    {
        $productos = $this->service->listarActivos();

        require '../Views/productos/index.php';
    }
}

15. Crear un router simple

$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

switch ($ruta) {
    case '/productos':
        $controller->index();
        break;

    default:
        http_response_code(404);
}

16. Usar namespaces

<?php

namespace App\Services;

class ProductoService
{
}
?>

17. Configurar autoload PSR-4

{
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}

Después:

composer dump-autoload

18. Crear una búsqueda paginada con PDO

$buscar = trim($_GET['buscar'] ?? '');
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$limite = 20;
$offset = ($pagina - 1) * $limite;

$stmt = $pdo->prepare(
    'SELECT *
     FROM productos
     WHERE nombre LIKE :buscar
     ORDER BY id DESC
     LIMIT :limite OFFSET :offset'
);

$stmt->bindValue(':buscar', '%' . $buscar . '%');
$stmt->bindValue(':limite', $limite, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

19. Registrar errores en un archivo

try {
    // operación
} catch (Throwable $e) {
    error_log(
        $e->getMessage() . PHP_EOL,
        3,
        __DIR__ . '/app.log'
    );
}

20. Diseñar un mini sistema MVC completo

Objetivo: construir una aplicación de gestión de productos con:

  • login;
  • roles;
  • CRUD;
  • PDO;
  • consultas preparadas;
  • CSRF;
  • arquitectura MVC;
  • autoload PSR-4;
  • validación;
  • paginación y búsqueda.

Una estructura posible:

app/
├── Controllers/
├── Models/
├── Repositories/
├── Services/
└── Views/

config/
public/
vendor/
composer.json

¿Qué deberías poder hacer después?

Si podés resolver estos desafíos entendiendo cada parte, ya tenés una base muy sólida de PHP moderno y estás en condiciones de comenzar a trabajar con frameworks como Laravel con mucha más claridad.

Serie de ejercicios de PHP

También podés ver todos los contenidos prácticos en la sección Ejercicios resueltos.

¿Te sirvió esta guía? ☕

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

MVC en PHP desde cero: Modelo, Vista y Controlador con un ejemplo completo

Hasta ahora construimos formularios, consultas con PDO, CRUD, autenticación, permisos, seguridad y programación orientada a objetos. El siguiente paso es aprender a organizar mejor una aplicación PHP.

Una de las arquitecturas más conocidas para separar responsabilidades es MVC: Modelo, Vista y Controlador.

En esta guía vamos a entender qué hace cada parte y construiremos un ejemplo sencillo con productos y MySQL.

¿Qué problema resuelve MVC?

En proyectos pequeños es común comenzar con archivos que mezclan:

  • HTML;
  • consultas SQL;
  • validaciones;
  • lógica de negocio;
  • redirecciones;
  • procesamiento de formularios.

Por ejemplo, un único archivo podría consultar productos, procesar un POST, validar datos y generar todo el HTML.

Eso puede funcionar al principio, pero a medida que la aplicación crece se vuelve difícil de mantener.

MVC intenta separar esas responsabilidades.

¿Qué significa MVC?

MVC significa:

  • Model: Modelo;
  • View: Vista;
  • Controller: Controlador.

Modelo

El Modelo se ocupa de trabajar con los datos y con la lógica relacionada con ellos.

Por ejemplo:

  • consultar productos;
  • buscar un producto por ID;
  • crear registros;
  • actualizarlos;
  • eliminarlos.

Vista

La Vista se ocupa de presentar la información al usuario.

Normalmente contiene:

  • HTML;
  • tablas;
  • formularios;
  • mensajes;
  • pequeñas expresiones PHP para mostrar datos.

La vista no debería contener consultas SQL.

Controlador

El Controlador coordina la aplicación.

Por ejemplo:

  1. recibe una petición;
  2. solicita información al modelo;
  3. procesa la lógica necesaria;
  4. envía datos a la vista.

Flujo básico de MVC

Usuario
   ↓
Controlador
   ↓
Modelo
   ↓
Base de datos
   ↓
Controlador
   ↓
Vista
   ↓
Usuario

No todas las aplicaciones MVC implementan exactamente el mismo flujo, pero esta idea ayuda a entender la separación de responsabilidades.

Estructura de carpetas

Podemos comenzar con una estructura simple:

proyecto/
│
├── app/
│   ├── Controllers/
│   │   └── ProductoController.php
│   │
│   ├── Models/
│   │   └── Producto.php
│   │
│   └── Views/
│       └── productos/
│           ├── index.php
│           └── create.php
│
├── config/
│   └── database.php
│
└── public/
    └── index.php

Base de datos de ejemplo

Vamos a utilizar una tabla productos:

CREATE TABLE productos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    precio DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL DEFAULT 0
);

Conexión a MySQL

En config/database.php podemos centralizar la conexión:

<?php

$host = 'localhost';
$db = 'tienda';
$user = 'root';
$password = '';

$dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";

$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);

?>

Crear el Modelo Producto

El modelo será responsable de consultar la tabla.

<?php

class Producto
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function todos(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, nombre, precio, stock
             FROM productos
             ORDER BY id DESC'
        );

        return $stmt->fetchAll();
    }
}

?>

El modelo conoce PDO y la base de datos. La vista no necesita saber cómo se obtuvieron los productos.

Crear el Controlador

En app/Controllers/ProductoController.php:

<?php

class ProductoController
{
    public function __construct(
        private Producto $producto
    ) {
    }

    public function index(): void
    {
        $productos = $this->producto->todos();

        require __DIR__
            . '/../Views/productos/index.php';
    }
}

?>

El controlador solicita los datos al modelo y carga la vista.

Crear la Vista

En app/Views/productos/index.php:

<h1>Productos</h1>

<table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Nombre</th>
            <th>Precio</th>
            <th>Stock</th>
        </tr>
    </thead>

    <tbody>

    <?php foreach ($productos as $producto): ?>

        <tr>
            <td>
                <?= $producto['id'] ?>
            </td>

            <td>
                <?= htmlspecialchars(
                    $producto['nombre'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </td>

            <td>
                $<?= number_format(
                    $producto['precio'],
                    2,
                    ',',
                    '.'
                ) ?>
            </td>

            <td>
                <?= $producto['stock'] ?>
            </td>
        </tr>

    <?php endforeach; ?>

    </tbody>
</table>

Crear un punto de entrada

Podemos utilizar public/index.php como punto de entrada:

<?php

require __DIR__ . '/../config/database.php';
require __DIR__ . '/../app/Models/Producto.php';
require __DIR__ . '/../app/Controllers/ProductoController.php';

$modelo = new Producto($pdo);

$controller = new ProductoController($modelo);

$controller->index();

?>

¿Qué logramos?

Ahora cada responsabilidad está separada:

  • Producto.php trabaja con los datos;
  • ProductoController.php coordina la petición;
  • index.php muestra el HTML.

Agregar un formulario de creación

Podemos agregar un método al controlador:

public function create(): void
{
    require __DIR__
        . '/../Views/productos/create.php';
}

Vista para crear un producto

<h1>Nuevo producto</h1>

<form method="POST" action="?accion=store">

    <label>Nombre</label>
    <input type="text" name="nombre" required>

    <label>Precio</label>
    <input type="number" step="0.01" name="precio" required>

    <label>Stock</label>
    <input type="number" name="stock" required>

    <button type="submit">
        Guardar
    </button>

</form>

Agregar el método crear() al Modelo

public function crear(
    string $nombre,
    float $precio,
    int $stock
): bool {
    $sql = 'INSERT INTO productos
            (nombre, precio, stock)
            VALUES (:nombre, :precio, :stock)';

    $stmt = $this->pdo->prepare($sql);

    return $stmt->execute([
        'nombre' => $nombre,
        'precio' => $precio,
        'stock' => $stock
    ]);
}

Agregar store() al Controlador

public function store(): void
{
    $nombre = trim($_POST['nombre'] ?? '');
    $precio = filter_input(
        INPUT_POST,
        'precio',
        FILTER_VALIDATE_FLOAT
    );

    $stock = filter_input(
        INPUT_POST,
        'stock',
        FILTER_VALIDATE_INT
    );

    if (
        $nombre === '' ||
        $precio === false ||
        $stock === false
    ) {
        exit('Datos inválidos');
    }

    $this->producto->crear(
        $nombre,
        $precio,
        $stock
    );

    header('Location: index.php');
    exit;
}

Agregar un router simple

Podemos decidir qué método ejecutar mediante un parámetro:

$accion = $_GET['accion'] ?? 'index';

switch ($accion) {

    case 'create':
        $controller->create();
        break;

    case 'store':
        $controller->store();
        break;

    default:
        $controller->index();
}

Esto todavía es un router muy básico, pero sirve para entender la idea.

Modelo no significa necesariamente tabla

Es común pensar que cada modelo debe representar una tabla exactamente, pero el concepto es más amplio.

El modelo representa los datos y reglas de negocio de la aplicación.

En proyectos simples puede coincidir bastante con una tabla, pero en sistemas mayores puede incluir lógica más compleja.

La Vista no debería consultar la base

Evitemos algo como esto dentro de una vista:

// No recomendado dentro de una vista

$stmt = $pdo->query(
    'SELECT * FROM productos'
);

La vista debería recibir los datos ya preparados.

El Controlador tampoco debería hacer todo

Otro error frecuente es mover todo el código del archivo original hacia el controlador.

Un controlador gigantesco sigue siendo difícil de mantener.

La idea es que coordine responsabilidades, no que concentre toda la aplicación.

Separar la lógica de negocio

A medida que el proyecto crece, podemos tener otras capas o servicios.

Por ejemplo:

app/
├── Controllers/
├── Models/
├── Services/
└── Views/

Un servicio podría encargarse de reglas específicas que no pertenecen directamente a una vista o a una consulta SQL.

MVC y seguridad

La arquitectura no reemplaza las buenas prácticas vistas anteriormente.

Seguimos necesitando:

  • consultas preparadas;
  • validación de datos;
  • protección CSRF;
  • escape de salida;
  • autenticación;
  • autorización.

MVC no es una regla rígida

Existen diferentes formas de implementar MVC.

Dos frameworks pueden utilizar el patrón y tener estructuras distintas.

Lo importante para comenzar es comprender la separación de responsabilidades.

¿Cómo se relaciona esto con Laravel?

Laravel utiliza muchos de estos conceptos.

Por ejemplo:

  • los Models representan y trabajan con datos;
  • los Controllers reciben peticiones y coordinan acciones;
  • las vistas se construyen normalmente con Blade;
  • las rutas determinan qué controlador debe ejecutarse.

Laravel agrega muchas herramientas alrededor de esta arquitectura, pero entender MVC primero hace que sea mucho más fácil comprender el framework.

Errores comunes al aprender MVC

  • poner consultas SQL dentro de las vistas;
  • crear controladores que hacen absolutamente todo;
  • mezclar HTML dentro de los modelos;
  • crear una clase por cada pequeño fragmento de código sin necesidad;
  • pensar que MVC obliga a usar una estructura exacta;
  • creer que usar carpetas llamadas Models, Views y Controllers ya convierte automáticamente un proyecto en MVC.

Ejercicio práctico

Extendé el ejemplo de productos para implementar un CRUD completo utilizando MVC.

Debería incluir:

  1. listar productos;
  2. crear productos;
  3. editar productos;
  4. eliminar productos;
  5. validar formularios;
  6. usar consultas preparadas;
  7. proteger la eliminación mediante POST;
  8. separar correctamente Modelo, Vista y Controlador.

¿Qué sigue?

En la próxima guía vamos a aprender Composer y autoloading en PHP.

Veremos:

  • qué es Composer;
  • qué es composer.json;
  • cómo instalar paquetes;
  • qué es vendor;
  • autoloading;
  • PSR-4;
  • cómo dejar de utilizar múltiples require manuales.

Ese paso nos acercará todavía más al funcionamiento interno de proyectos PHP modernos y frameworks como Laravel.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal