Vulnerabilidades críticas en PHP 2026: versiones afectadas y cómo proteger tu servidor

PHP2Shell: conocé las vulnerabilidades críticas CVE-2026-109618 y CVE-2026-109619, las versiones corregidas de PHP y cómo actualizar tu servidor Linux.

Actualizado el 23 de octubre de 2026. El equipo de PHP publicó el 21 de octubre parches urgentes para dos vulnerabilidades críticas, CVE-2026-109618 y CVE-2026-109619, conocidas conjuntamente como PHP2Shell. Ambas tienen una calificación CVSS v4.0 de 9,2 y pueden permitir ejecución remota de código sin autenticación en determinadas condiciones.

Si administrás un servidor con PHP, Laravel, WordPress u otra aplicación PHP, esta guía te ayuda a identificar las versiones corregidas, revisar tu instalación y aplicar las actualizaciones con precaución.

¿Qué pasó con PHP en octubre de 2026?

Las vulnerabilidades afectan a componentes internos del intérprete y están relacionadas con errores de gestión de memoria. El problema puede ocurrir antes de que la aplicación procese normalmente una petición, por lo que validar correctamente los datos en Laravel o WordPress no reemplaza el parche del motor PHP. Comunicado oficial de PHP.

¿Qué son CVE-2026-109618 y CVE-2026-109619?

Las dos fallas, denominadas PHP2Shell en reportes de seguridad, incluyen situaciones de uso de memoria después de liberarla (use-after-free) relacionadas con gestión de errores y limpieza de peticiones. Bajo condiciones específicas pueden habilitar ejecución de código no autorizado. No significa que todos los sitios con PHP sean automáticamente explotables de la misma manera.

Versiones de PHP corregidas

Rama Versión corregida
PHP 8.2 8.2.35
PHP 8.3 8.3.36
PHP 8.4 8.4.27
PHP 8.5 8.5.12

Atención: PHP 8.1 y ramas anteriores están fuera del ciclo normal de soporte. Existen parches de código fuente y correcciones de determinados proveedores para algunas ramas antiguas, pero no conviene suponer que una instalación está protegida sin verificar el paquete exacto. En distribuciones Linux puede haber parches retroportados (backports) que mantienen el número de versión original.

Cómo comprobar qué PHP está usando tu servidor

Primero verificá PHP en la terminal:

php -v

Ese comando muestra la versión CLI, que puede diferir de la utilizada por Apache o PHP-FPM. Para conocer los procesos activos en Linux podés ejecutar:

ps aux | grep -E 'php-fpm|httpd|apache2'

Si usás PHP-FPM, verificá también la versión de su ejecutable o servicio según tu sistema. Identificá el origen de los paquetes instalados y comprobá sus avisos de seguridad antes de sacar conclusiones.

Actualizar PHP en Ubuntu o Debian

Revisá el repositorio de paquetes y las versiones candidatas antes de actualizar:

sudo apt update
apt list --upgradable
apt-cache policy php8.3-fpm

Aplicá las actualizaciones de seguridad disponibles siguiendo el procedimiento de mantenimiento del servidor. Si corresponde a tu configuración:

sudo apt upgrade

Después comprobá la versión y reiniciá, de forma controlada, el servicio PHP-FPM o Apache que realmente ejecuta la aplicación. Una actualización general no garantiza por sí sola que el repositorio ya tenga un paquete corregido.

Actualizar PHP en AlmaLinux, Rocky Linux o CentOS

En distribuciones modernas basadas en RHEL, revisá primero los paquetes y el repositorio desde el que se instaló PHP:

sudo dnf check-update
rpm -qa | grep -E '^php|^ea-php'

Si tu repositorio ofrece una versión corregida, realizá la actualización correspondiente mediante el gestor de paquetes:

sudo dnf upgrade

En instalaciones antiguas puede utilizarse yum. Si PHP proviene de un repositorio externo o panel de hosting, verificá específicamente sus anuncios y la versión de paquete con el parche. Probá compatibilidad de extensiones y aplicaciones antes de cambiar de rama mayor.

¿Laravel y WordPress están afectados?

Ambos funcionan sobre PHP. Actualizar Laravel, sus dependencias, WordPress o sus plugins es recomendable para la seguridad general, pero no sustituye la actualización del intérprete PHP. El nivel real de exposición depende de las versiones, la SAPI (por ejemplo, mod_php o PHP-FPM), la configuración y el entorno.

Cómo revisar errores y actividad sospechosa

Tras asegurar la actualización, es útil revisar registros para detectar comportamientos anómalos. En servidores que usan Apache sobre sistemas RHEL:

sudo tail -n 100 /var/log/httpd/error_log

También podés consultar errores del sistema desde el 20 de octubre:

sudo journalctl -p err --since "2026-10-20"

Un error 500, una caída de Apache o un mensaje de memoria no demuestran por sí solos una explotación. La ausencia de errores tampoco descarta que haya ocurrido. Si sospechás un compromiso, preservá los logs y seguí un procedimiento de respuesta a incidentes.

¿Alcanza con una mitigación temporal?

No. El proyecto PHP indica que no existe una mitigación completa que sustituya a instalar las correcciones. Mientras se planifica el parche, pueden evaluarse medidas de reducción de exposición, pero nunca deben presentarse como equivalentes a una actualización.

Preguntas frecuentes

¿Debo actualizar si mi sitio funciona normalmente? Sí, si utilizás una versión afectada y no tiene un parche retroportado confirmado. El funcionamiento normal no descarta vulnerabilidades.

¿Puedo verificar la seguridad solamente con php -v? No. Revisá también el PHP que atiende las solicitudes web y el estado de parches del proveedor.

¿Qué pasa con PHP 7.4? Está fuera del soporte estándar. Algunas fuentes ofrecen parches específicos, pero migrar a una rama vigente es la solución sostenible.

Conclusión

Estas vulnerabilidades muestran por qué la seguridad de una aplicación depende también del entorno donde se ejecuta. Identificá la versión real de PHP, consultá los avisos del proveedor, instalá los parches y verificá el servicio después del mantenimiento.

Fuentes y documentación

Seguí aprendiendo PHP

Encontrá más tutoriales, ejemplos y ejercicios en nuestra ruta de aprendizaje PHP desde cero.

¿Te sirvió esta guía? ☕

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

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Autenticación en una API REST con PHP: login, Bearer tokens, roles y endpoints protegidos

En la guía anterior construimos una API REST con PHP, JSON y PDO. Ahora vamos a dar el siguiente paso: agregar autenticación por tokens para proteger los endpoints y saber qué usuario está realizando cada petición.

Vamos a implementar un sistema sencillo pero correcto: el usuario inicia sesión con email y contraseña, la API genera un token aleatorio, guarda solamente su hash en MySQL y devuelve el token original al cliente. Después el cliente lo envía en el header Authorization: Bearer ....

¿Qué vamos a construir?

  • registro de usuarios;
  • login mediante JSON;
  • contraseñas con password_hash();
  • tokens aleatorios seguros;
  • almacenamiento del hash del token;
  • header Authorization: Bearer;
  • endpoints públicos y privados;
  • obtención del usuario autenticado;
  • expiración de tokens;
  • logout y revocación;
  • diferencia entre HTTP 401 y 403;
  • pruebas con Postman.

1. ¿Por qué una API necesita autenticación?

Sin autenticación, cualquier cliente que conozca una URL podría intentar acceder a operaciones sensibles. En una API necesitamos una forma de identificar al usuario en cada petición sin depender de una sesión HTML tradicional.

Un flujo habitual es:

  1. el usuario envía email y contraseña;
  2. la API verifica las credenciales;
  3. si son correctas, genera un token;
  4. el cliente guarda ese token;
  5. en cada petición protegida lo envía en Authorization;
  6. la API valida el token y recupera al usuario.

2. Estructura de las tablas

Vamos a utilizar una tabla de usuarios y otra tabla separada para los tokens.

CREATE TABLE usuarios (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    rol ENUM('admin', 'usuario')
        NOT NULL DEFAULT 'usuario',
    activo TINYINT(1) NOT NULL DEFAULT 1,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE api_tokens (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    usuario_id INT UNSIGNED NOT NULL,
    token_hash CHAR(64) NOT NULL UNIQUE,
    expira_en DATETIME NOT NULL,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (usuario_id)
        REFERENCES usuarios(id)
        ON DELETE CASCADE
);

3. Registrar usuarios de forma segura

La contraseña nunca debe guardarse en texto plano.

$passwordHash = password_hash(
    $password,
    PASSWORD_DEFAULT
);

$stmt = $pdo->prepare(
    'INSERT INTO usuarios
     (nombre, email, password)
     VALUES (:nombre, :email, :password)'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'password' => $passwordHash
]);

4. Endpoint de login

El cliente enviará una petición POST /api/login con JSON:

{
    "email": "ana@ejemplo.com",
    "password": "mi-clave-segura"
}

Primero buscamos el usuario por email.

$stmt = $pdo->prepare(
    'SELECT *
     FROM usuarios
     WHERE email = :email
     LIMIT 1'
);

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

$usuario = $stmt->fetch();

5. Verificar la contraseña

if (
    !$usuario ||
    !$usuario['activo'] ||
    !password_verify(
        $password,
        $usuario['password']
    )
) {
    responder([
        'success' => false,
        'message' => 'Credenciales inválidas'
    ], 401);
}

Usamos un mensaje genérico para no revelar si el email existe o no.

6. Generar un token seguro

Podemos generar 32 bytes aleatorios y convertirlos a hexadecimal:

$token = bin2hex(
    random_bytes(32)
);

El resultado tendrá 64 caracteres hexadecimales y será extremadamente difícil de adivinar.

7. No guardar el token original

La base debería almacenar solamente un hash del token. Así, si alguien accede a la tabla, no obtiene credenciales utilizables directamente.

$tokenHash = hash(
    'sha256',
    $token
);

Después guardamos el hash y una fecha de expiración.

$expiraEn = (
    new DateTimeImmutable(
        '+1 day'
    )
)->format('Y-m-d H:i:s');

$stmt = $pdo->prepare(
    'INSERT INTO api_tokens
     (usuario_id, token_hash, expira_en)
     VALUES (:usuario_id, :token_hash, :expira_en)'
);

$stmt->execute([
    'usuario_id' => $usuario['id'],
    'token_hash' => $tokenHash,
    'expira_en' => $expiraEn
]);

8. Respuesta del login

responder([
    'success' => true,
    'token' => $token,
    'token_type' => 'Bearer',
    'expires_at' => $expiraEn
]);

El token original se devuelve una sola vez al cliente. En la base queda únicamente su hash.

9. Enviar el Bearer token

Para acceder a un endpoint privado, el cliente agrega este header:

Authorization: Bearer 4fca9a...

En Postman se puede configurar desde Authorization → Bearer Token.

10. Leer el header Authorization en PHP

function obtenerBearerToken(): ?string
{
    $header = $_SERVER[
        'HTTP_AUTHORIZATION'
    ] ?? '';

    if (!preg_match(
        '/^Bearer\s+(.+)$/i',
        $header,
        $matches
    )) {
        return null;
    }

    return trim($matches[1]);
}

11. Validar el token

Recibimos el token, calculamos su hash y buscamos una coincidencia vigente.

$token = obtenerBearerToken();

if (!$token) {
    responder([
        'success' => false,
        'message' => 'Token requerido'
    ], 401);
}

$tokenHash = hash(
    'sha256',
    $token
);

$stmt = $pdo->prepare(
    'SELECT u.id, u.nombre, u.email, u.rol
     FROM api_tokens t
     INNER JOIN usuarios u
        ON u.id = t.usuario_id
     WHERE t.token_hash = :token_hash
       AND t.expira_en > NOW()
       AND u.activo = 1
     LIMIT 1'
);

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

$usuario = $stmt->fetch();

12. Crear un middleware de autenticación

function autenticar(PDO $pdo): array
{
    $token = obtenerBearerToken();

    if (!$token) {
        responder([
            'success' => false,
            'message' => 'No autenticado'
        ], 401);
    }

    $hash = hash('sha256', $token);

    $stmt = $pdo->prepare(
        'SELECT u.id, u.nombre, u.email, u.rol
         FROM api_tokens t
         INNER JOIN usuarios u
            ON u.id = t.usuario_id
         WHERE t.token_hash = :hash
           AND t.expira_en > NOW()
           AND u.activo = 1
         LIMIT 1'
    );

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

    $usuario = $stmt->fetch();

    if (!$usuario) {
        responder([
            'success' => false,
            'message' => 'Token inválido o vencido'
        ], 401);
    }

    return $usuario;
}

13. Proteger un endpoint

Por ejemplo, para GET /api/me:

$usuario = autenticar($pdo);

responder([
    'success' => true,
    'data' => $usuario
]);

Si el token es válido, el endpoint ya conoce la identidad del usuario sin pedir nuevamente email y contraseña.

14. Endpoint sólo para administradores

$usuario = autenticar($pdo);

if ($usuario['rol'] !== 'admin') {
    responder([
        'success' => false,
        'message' => 'No autorizado'
    ], 403);
}

15. Diferencia entre 401 y 403

CódigoCuándo usarlo
401No hay credenciales válidas: falta el token, es inválido o venció.
403El usuario está autenticado, pero no tiene permiso para realizar esa acción.

Esta diferencia es importante para que los clientes de la API sepan si deben volver a autenticarse o simplemente informar que el usuario no tiene permisos.

16. Logout: revocar el token actual

El logout de una API consiste en invalidar el token que estaba usando el cliente.

$token = obtenerBearerToken();

if ($token) {
    $hash = hash('sha256', $token);

    $stmt = $pdo->prepare(
        'DELETE FROM api_tokens
         WHERE token_hash = :hash'
    );

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

responder([
    'success' => true,
    'message' => 'Sesión cerrada'
]);

17. Eliminar tokens vencidos

Con el tiempo la tabla acumulará tokens expirados. Podemos limpiarlos periódicamente:

DELETE FROM api_tokens
WHERE expira_en < NOW();

En producción esto podría ejecutarse mediante una tarea programada del servidor.

18. Probar todo con Postman

  • POST /api/login → enviar email y contraseña.
  • copiar el token de la respuesta.
  • abrir un endpoint privado.
  • seleccionar Authorization → Bearer Token.
  • pegar el token.
  • repetir la petición.
  • probar sin token para comprobar el 401.
  • probar con un usuario sin rol adecuado para comprobar el 403.
  • ejecutar logout y volver a utilizar el mismo token: debería dejar de funcionar.

19. ¿Por qué no usamos JWT todavía?

JWT es muy popular, pero agrega conceptos adicionales: firma, claims, expiración embebida, claves, algoritmos y estrategias de revocación. Para aprender autenticación de APIs primero conviene entender el flujo con tokens opacos.

Los tokens almacenados en servidor tienen además una ventaja didáctica importante: podemos revocarlos inmediatamente eliminando una fila de la base.

20. Buenas prácticas de seguridad

  • usar siempre HTTPS en producción;
  • generar tokens con random_bytes();
  • guardar hashes y no tokens originales;
  • definir expiración;
  • permitir revocación;
  • no registrar tokens completos en logs;
  • no enviar tokens en parámetros GET;
  • validar que el usuario siga activo;
  • limitar intentos de login;
  • evitar mensajes que revelen si una cuenta existe;
  • rotar o revocar credenciales cuando sea necesario.

Flujo completo

  1. el cliente envía credenciales a /api/login;
  2. la API valida email y contraseña;
  3. genera un token aleatorio;
  4. guarda su hash con una fecha de expiración;
  5. devuelve el token original;
  6. el cliente lo envía como Bearer token;
  7. el middleware calcula su hash;
  8. busca un token vigente;
  9. recupera al usuario;
  10. el endpoint decide si además necesita un rol o permiso específico.

¿Qué aprendimos?

Agregamos autenticación real a nuestra API REST con PHP. Ahora podemos identificar usuarios, proteger rutas, manejar roles, expirar credenciales y revocar sesiones sin depender de cookies ni sesiones tradicionales del navegador.

Esta base sirve para entender cómo trabajan soluciones más completas de frameworks como Laravel Sanctum o Passport.

Siguiente paso

El próximo avance natural es organizar esta API en una arquitectura más completa y después trasladar los mismos conceptos a Laravel: rutas, controladores, validación, modelos, middleware y autenticación.

Seguí aprendiendo PHP

Aprender PHP desde cero: ruta completa →

¿Te sirvió esta guía? ☕

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

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

API REST con PHP desde cero: GET, POST, PUT, DELETE, JSON y PDO

En esta guía vamos a construir una API REST con PHP desde cero utilizando JSON, los métodos HTTP GET, POST, PUT y DELETE, códigos de estado y PDO para trabajar con MySQL.

La idea es entender cómo funciona una API por dentro antes de pasar a frameworks como Laravel. Al finalizar vas a tener una API capaz de listar, consultar, crear, modificar y eliminar registros desde clientes como Postman, JavaScript, aplicaciones móviles u otros sistemas.

¿Qué vamos a construir?

  • una API que devuelva respuestas JSON;
  • endpoint GET para listar usuarios;
  • endpoint GET para consultar un usuario por ID;
  • endpoint POST para crear usuarios;
  • endpoint PUT para modificarlos;
  • endpoint DELETE para eliminarlos;
  • validación de datos;
  • consultas preparadas con PDO;
  • códigos HTTP correctos;
  • manejo básico de errores;
  • pruebas desde Postman.

Antes de empezar

Conviene haber visto previamente estas guías:

1. ¿Qué es una API REST?

Una API permite que dos aplicaciones intercambien información. En una API REST los recursos se identifican mediante URLs y las acciones se expresan principalmente a través de métodos HTTP.

MétodoUso habitualEjemplo
GETConsultar datos/api/usuarios
POSTCrear un recurso/api/usuarios
PUTActualizar un recurso/api/usuarios/5
DELETEEliminar un recurso/api/usuarios/5

Una API no debería depender de páginas HTML. En este caso recibirá y devolverá información en formato JSON.

2. Estructura del proyecto

api-php/
├── config/
│   └── database.php
├── src/
│   ├── UsuarioRepository.php
│   └── response.php
└── public/
    └── index.php

Para mantener el ejemplo claro vamos a utilizar un único punto de entrada público y separar la conexión y el acceso a datos.

3. Crear la base de datos

CREATE DATABASE api_php
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE api_php;

CREATE TABLE usuarios (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    activo TINYINT(1) NOT NULL DEFAULT 1,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. Conexión con PDO

Archivo config/database.php:

<?php

return new PDO(
    'mysql:host=localhost;dbname=api_php;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

5. Responder siempre en JSON

Podemos crear una función común para todas las respuestas.

<?php

function responder(
    array $datos,
    int $status = 200
): never {
    http_response_code($status);

    header('Content-Type: application/json; charset=utf-8');

    echo json_encode(
        $datos,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    );

    exit;
}

Así evitamos repetir encabezados, código de estado y json_encode() en cada endpoint.

6. Leer el método HTTP y la URL

$metodo = $_SERVER['REQUEST_METHOD'];

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

$segmentos = array_values(
    array_filter(explode('/', $ruta))
);

Para una URL como /api/usuarios/5, los segmentos serán aproximadamente:

[
    'api',
    'usuarios',
    '5'
]

7. Repositorio de usuarios

<?php

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

    public function todos(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, nombre, email, activo, creado_en
             FROM usuarios
             ORDER BY id DESC'
        );

        return $stmt->fetchAll();
    }

    public function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nombre, email, activo, creado_en
             FROM usuarios
             WHERE id = :id'
        );

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

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }
}

8. GET: listar usuarios

Para GET /api/usuarios:

if (
    $metodo === 'GET'
    && count($segmentos) === 2
) {
    $usuarios = $repository->todos();

    responder([
        'success' => true,
        'data' => $usuarios
    ]);
}

Una respuesta posible sería:

{
    "success": true,
    "data": [
        {
            "id": 1,
            "nombre": "Ana",
            "email": "ana@ejemplo.com"
        }
    ]
}

9. GET: consultar un usuario por ID

if (
    $metodo === 'GET'
    && count($segmentos) === 3
) {
    $id = filter_var(
        $segmentos[2],
        FILTER_VALIDATE_INT
    );

    if (!$id) {
        responder([
            'success' => false,
            'message' => 'ID inválido'
        ], 400);
    }

    $usuario = $repository->buscar($id);

    if (!$usuario) {
        responder([
            'success' => false,
            'message' => 'Usuario no encontrado'
        ], 404);
    }

    responder([
        'success' => true,
        'data' => $usuario
    ]);
}

10. POST: crear un usuario

Cuando enviamos JSON, PHP no lo coloca automáticamente en $_POST. Debemos leer el cuerpo crudo de la petición.

$datos = json_decode(
    file_get_contents('php://input'),
    true
);

if (!is_array($datos)) {
    responder([
        'success' => false,
        'message' => 'JSON inválido'
    ], 400);
}

Después validamos los campos:

$nombre = trim($datos['nombre'] ?? '');
$email = trim($datos['email'] ?? '');

if ($nombre === '') {
    responder([
        'success' => false,
        'message' => 'El nombre es obligatorio'
    ], 422);
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    responder([
        'success' => false,
        'message' => 'El email no es válido'
    ], 422);
}

La inserción se realiza mediante una consulta preparada:

$stmt = $pdo->prepare(
    'INSERT INTO usuarios (nombre, email)
     VALUES (:nombre, :email)'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email
]);

$id = (int) $pdo->lastInsertId();

responder([
    'success' => true,
    'message' => 'Usuario creado',
    'id' => $id
], 201);

11. PUT: actualizar un usuario

$stmt = $pdo->prepare(
    'UPDATE usuarios
     SET nombre = :nombre,
         email = :email,
         activo = :activo
     WHERE id = :id'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'activo' => $activo,
    'id' => $id
]);

responder([
    'success' => true,
    'message' => 'Usuario actualizado'
]);

Antes de ejecutar el UPDATE conviene verificar que el recurso exista. De lo contrario una petición podría responder “actualizado” aunque el ID nunca haya existido.

12. DELETE: eliminar un usuario

$stmt = $pdo->prepare(
    'DELETE FROM usuarios WHERE id = :id'
);

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

responder([
    'success' => true,
    'message' => 'Usuario eliminado'
]);

En sistemas reales puede convenir una baja lógica utilizando un campo como activo o deleted_at, especialmente cuando se necesita conservar historial.

13. Códigos HTTP que conviene usar

CódigoSignificado
200Petición correcta
201Recurso creado
204Respuesta correcta sin contenido
400Petición inválida
401No autenticado
403No autorizado
404Recurso no encontrado
409Conflicto, por ejemplo email duplicado
422Datos válidos como JSON pero incorrectos para la operación
500Error interno del servidor

14. Manejar errores de base de datos

try {
    // ejecutar operación
} catch (PDOException $e) {
    responder([
        'success' => false,
        'message' => 'Error interno del servidor'
    ], 500);
}

En producción no conviene devolver al cliente el mensaje completo de la excepción porque podría revelar nombres de tablas, columnas o detalles de infraestructura. Ese detalle debería registrarse en logs.

15. Router completo simplificado

if ($segmentos[0] ?? null !== 'api') {
    responder([
        'success' => false,
        'message' => 'Ruta no encontrada'
    ], 404);
}

if (($segmentos[1] ?? null) !== 'usuarios') {
    responder([
        'success' => false,
        'message' => 'Recurso no encontrado'
    ], 404);
}

$id = isset($segmentos[2])
    ? (int) $segmentos[2]
    : null;

switch ($metodo) {
    case 'GET':
        // listar o buscar por ID
        break;

    case 'POST':
        // crear
        break;

    case 'PUT':
        // actualizar
        break;

    case 'DELETE':
        // eliminar
        break;

    default:
        responder([
            'success' => false,
            'message' => 'Método no permitido'
        ], 405);
}

16. Probar la API con Postman

Supongamos que el proyecto corre en http://localhost/api-php/public.

  • GET /api/usuarios → listar.
  • GET /api/usuarios/1 → consultar un usuario.
  • POST /api/usuarios → crear.
  • PUT /api/usuarios/1 → actualizar.
  • DELETE /api/usuarios/1 → eliminar.

Para POST y PUT seleccioná Body → raw → JSON y enviá algo como:

{
    "nombre": "Juan Pérez",
    "email": "juan@ejemplo.com"
}

17. ¿Qué pasa con CORS?

Si un frontend JavaScript se ejecuta desde otro origen, el navegador puede bloquear la petición por la política de mismo origen. Para desarrollo podríamos permitir un origen concreto:

header(
    'Access-Control-Allow-Origin: https://mi-frontend.com'
);

header(
    'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'
);

header(
    'Access-Control-Allow-Headers: Content-Type, Authorization'
);

No conviene usar * indiscriminadamente en producción cuando la API maneja credenciales o información sensible.

18. Buenas prácticas básicas

  • usar HTTPS en producción;
  • validar todos los datos recibidos;
  • usar consultas preparadas;
  • no devolver excepciones completas al cliente;
  • usar códigos HTTP coherentes;
  • mantener una estructura JSON consistente;
  • separar acceso a datos de la lógica HTTP;
  • registrar errores en logs;
  • versionar la API cuando empiece a crecer, por ejemplo /api/v1/usuarios;
  • agregar autenticación antes de exponer operaciones sensibles.

19. ¿Cómo quedaría una respuesta consistente?

Éxito:

{
    "success": true,
    "data": {
        "id": 1,
        "nombre": "Juan Pérez"
    }
}

Error:

{
    "success": false,
    "message": "Usuario no encontrado"
}

20. Siguiente paso: autenticación

Esta API todavía es pública. Cualquier cliente que conozca la URL podría intentar crear, modificar o eliminar datos. El siguiente paso natural es implementar autenticación por token y proteger determinados endpoints.

Eso nos permitirá trabajar conceptos como:

  • login mediante API;
  • generación de tokens;
  • header Authorization;
  • endpoints públicos y privados;
  • expiración o revocación de tokens;
  • permisos por usuario o rol.

¿Qué aprendimos?

Construimos la base de una API REST con PHP puro y MySQL. Vimos cómo interpretar métodos HTTP, leer JSON, devolver respuestas JSON, utilizar códigos de estado, validar datos y ejecutar operaciones CRUD mediante PDO.

Entender este flujo antes de utilizar un framework permite comprender mejor qué hacen Laravel, Symfony u otras herramientas cuando construimos APIs más grandes.

Seguí aprendiendo PHP

Podés recorrer toda la ruta desde:

Aprender PHP desde cero: ruta completa →

¿Te sirvió esta guía? ☕

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

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Proyecto final PHP: sistema de usuarios completo con login, roles, CRUD, PDO y MVC

En este proyecto final de PHP vamos a integrar muchos de los conceptos vistos a lo largo de la ruta de aprendizaje construyendo una aplicación real: un sistema de gestión de usuarios con registro, login, roles, CRUD, PDO, validaciones, seguridad y arquitectura MVC.

La idea no es solamente copiar código. El objetivo es entender cómo se conectan entre sí las distintas partes de una aplicación PHP moderna y cómo organizar el proyecto para que sea más fácil de mantener.

¿Qué vamos a construir?

El sistema tendrá las siguientes funcionalidades:

  • registro de usuarios;
  • inicio y cierre de sesión;
  • contraseñas almacenadas con password_hash();
  • autenticación con sesiones;
  • roles de usuario y administrador;
  • protección de páginas según permisos;
  • listado de usuarios;
  • alta, edición y eliminación;
  • búsqueda;
  • consultas preparadas con PDO;
  • validación de datos;
  • protección CSRF;
  • arquitectura MVC;
  • autoloading PSR-4 mediante Composer.

Antes de empezar

Este proyecto supone que ya manejás los conceptos principales de la ruta de PHP. Si algún tema todavía no está claro, podés repasarlo antes de continuar:

1. Estructura del proyecto

Vamos a separar responsabilidades utilizando una estructura MVC sencilla:

proyecto-usuarios/
├── app/
│   ├── Controllers/
│   │   ├── AuthController.php
│   │   └── UsuarioController.php
│   ├── Models/
│   │   └── Usuario.php
│   ├── Repositories/
│   │   └── UsuarioRepository.php
│   ├── Services/
│   │   └── AuthService.php
│   └── Views/
│       ├── auth/
│       │   ├── login.php
│       │   └── registro.php
│       └── usuarios/
│           ├── index.php
│           ├── crear.php
│           └── editar.php
├── config/
│   └── database.php
├── public/
│   └── index.php
├── routes/
│   └── web.php
├── vendor/
└── composer.json

La ventaja de esta estructura es que cada carpeta tiene una responsabilidad clara. El controlador coordina, el modelo representa los datos, el repositorio accede a la base, el servicio concentra lógica y la vista genera la interfaz.

2. Crear la base de datos

Vamos a utilizar una tabla usuarios con los datos básicos de autenticación y autorización.

CREATE DATABASE club_php
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE club_php;

CREATE TABLE usuarios (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    rol ENUM('admin', 'usuario') NOT NULL DEFAULT 'usuario',
    activo TINYINT(1) NOT NULL DEFAULT 1,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

La contraseña tendrá hasta 255 caracteres porque password_hash() genera hashes largos cuyo tamaño puede cambiar según el algoritmo utilizado.

3. Configurar Composer y PSR-4

El archivo composer.json puede ser:

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

Después ejecutamos:

composer dump-autoload

Y en el punto de entrada cargamos el autoloader:

<span style="color:#c4b5fd"><?php

require dirname(__DIR__) . '/vendor/autoload.php';

session_start();

4. Conexión a MySQL con PDO

Archivo config/database.php:

<span style="color:#c4b5fd"><?php

return new PDO(
    'mysql:host=localhost;dbname=club_php;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

Desactivar la emulación de consultas preparadas permite delegar el preparado al motor cuando el driver lo soporta.

5. Modelo Usuario

<span style="color:#c4b5fd"><?php

namespace App\Models;

class Usuario
{
    public function __construct(
        public ?int $id,
        public string $nombre,
        public string $email,
        public string $rol = 'usuario',
        public bool $activo = true
    ) {
    }
}

En un proyecto más grande podríamos agregar getters, setters, objetos de valor o métodos de dominio. Para este ejemplo nos interesa mantener la estructura simple.

6. Repositorio de usuarios

El repositorio concentra el acceso a la base de datos.

<span style="color:#c4b5fd"><?php

namespace App\Repositories;

use PDO;

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

    public function todos(string $buscar = ''): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nombre, email, rol, activo, creado_en
             FROM usuarios
             WHERE nombre LIKE :buscar
                OR email LIKE :buscar
             ORDER BY id DESC'
        );

        $stmt->execute([
            'buscar' => '%' . $buscar . '%'
        ]);

        return $stmt->fetchAll();
    }

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

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

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }

    public function buscarPorEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM usuarios WHERE email = :email'
        );

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

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }
}

7. Registrar usuarios

Para registrar un usuario necesitamos validar los datos y almacenar la contraseña de forma segura.

$nombre = trim($_POST['nombre'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

$errores = [];

if ($nombre === '') {
    $errores[] = 'El nombre es obligatorio.';
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errores[] = 'El email no es válido.';
}

if (strlen($password) < 8) {
    $errores[] = 'La contraseña debe tener al menos 8 caracteres.';
}

if (!$errores) {
    $hash = password_hash($password, PASSWORD_DEFAULT);

    $stmt = $pdo->prepare(
        'INSERT INTO usuarios (nombre, email, password)
         VALUES (:nombre, :email, :password)'
    );

    $stmt->execute([
        'nombre' => $nombre,
        'email' => $email,
        'password' => $hash
    ]);
}

8. Login seguro

El login debe buscar el usuario por email y después comprobar la contraseña con password_verify().

$usuario = $repository->buscarPorEmail($email);

if (
    !$usuario ||
    !$usuario['activo'] ||
    !password_verify($password, $usuario['password'])
) {
    $error = 'Credenciales inválidas.';
} else {
    session_regenerate_id(true);

    $_SESSION['usuario_id'] = $usuario['id'];
    $_SESSION['nombre'] = $usuario['nombre'];
    $_SESSION['rol'] = $usuario['rol'];

    header('Location: /usuarios');
    exit;
}

Es importante no indicar si falló el email o la contraseña por separado. Un mensaje genérico evita revelar información innecesaria sobre las cuentas existentes.

9. Cerrar sesión

session_start();

$_SESSION = [];

session_destroy();

header('Location: /login');
exit;

10. Middleware de autenticación

Podemos crear una función simple para evitar acceder a páginas privadas sin iniciar sesión.

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

11. Control de acceso por rol

Ahora agregamos una segunda comprobación para las funciones administrativas.

function requireAdmin(): void
{
    requireLogin();

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

Esto nos permite restringir, por ejemplo, la creación, edición y eliminación de usuarios.

12. Listado y búsqueda

En el controlador obtenemos el término enviado por GET:

$buscar = trim($_GET['buscar'] ?? '');

$usuarios = $repository->todos($buscar);

require dirname(__DIR__) . '/Views/usuarios/index.php';

Y en la vista:

<form method="get">
    <input
        type="text"
        name="buscar"
        value="<?= htmlspecialchars($buscar) ?>"
        placeholder="Buscar por nombre o email"
    >

    <button type="submit">Buscar</button>
</form>

13. Crear usuarios desde el panel

El administrador puede reutilizar gran parte de la validación del registro, pero además definir rol y estado.

$rol = $_POST['rol'] ?? 'usuario';

$rolesPermitidos = ['admin', 'usuario'];

if (!in_array($rol, $rolesPermitidos, true)) {
    $errores[] = 'Rol inválido.';
}

Nunca debemos confiar directamente en el valor recibido desde un select. El usuario puede modificar manualmente una petición HTTP.

14. Editar usuarios

Primero validamos el ID:

$id = filter_input(
    INPUT_GET,
    'id',
    FILTER_VALIDATE_INT
);

if (!$id) {
    http_response_code(400);
    exit('ID inválido.');
}

$usuario = $repository->buscarPorId($id);

if (!$usuario) {
    http_response_code(404);
    exit('Usuario no encontrado.');
}

Después actualizamos únicamente los campos permitidos.

$stmt = $pdo->prepare(
    'UPDATE usuarios
     SET nombre = :nombre,
         email = :email,
         rol = :rol,
         activo = :activo
     WHERE id = :id'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'rol' => $rol,
    'activo' => $activo,
    'id' => $id
]);

15. Eliminar usuarios

Las eliminaciones deberían enviarse mediante POST y no mediante un enlace GET.

<form method="post" action="/usuarios/eliminar">
    <input type="hidden" name="id" value="<?= $usuario['id'] ?>">

    <button type="submit">Eliminar</button>
</form>

En una aplicación real también podría ser preferible utilizar una baja lógica mediante un campo activo en lugar de eliminar definitivamente el registro.

16. Protección CSRF

Generamos un token por sesión:

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

Lo enviamos dentro de cada formulario sensible:

<input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
>

Y lo comprobamos al recibir la petición:

if (
    !isset($_POST['csrf_token']) ||
    !hash_equals(
        $_SESSION['csrf_token'],
        $_POST['csrf_token']
    )
) {
    http_response_code(403);
    exit('Token CSRF inválido.');
}

17. Escapar datos en las vistas

Los datos provenientes de usuarios o de la base deben escaparse al mostrarlos en HTML:

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

Esto ayuda a prevenir ataques XSS cuando mostramos contenido potencialmente manipulable.

18. Router básico

Para no depender de muchos archivos públicos podemos centralizar las rutas en public/index.php.

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

switch ($ruta) {
    case '/login':
        $authController->login();
        break;

    case '/logout':
        $authController->logout();
        break;

    case '/usuarios':
        $usuarioController->index();
        break;

    case '/usuarios/crear':
        $usuarioController->crear();
        break;

    default:
        http_response_code(404);
        echo 'Página no encontrada.';
}

19. Patrón Post/Redirect/Get

Después de guardar, editar o eliminar conviene redireccionar:

$repository->actualizar($id, $datos);

header('Location: /usuarios');
exit;

Así evitamos que el navegador vuelva a enviar el formulario cuando el usuario actualiza la página.

20. Flujo completo del sistema

El funcionamiento general queda así:

  1. el usuario abre una URL;
  2. el router determina qué controlador ejecutar;
  3. el controlador valida autenticación y permisos;
  4. el controlador recibe y valida los datos;
  5. el repositorio consulta o modifica MySQL mediante PDO;
  6. el controlador carga una vista;
  7. la vista muestra los datos escapados correctamente.

Arquitectura final

El proyecto combina varias capas:

  • Router: decide qué acción ejecutar.
  • Controller: coordina la petición.
  • Service: contiene lógica de negocio.
  • Repository: accede a MySQL.
  • Model: representa entidades.
  • View: genera la interfaz HTML.
  • Session: mantiene la autenticación.

Mejoras que podés agregar

  • paginación;
  • recuperación de contraseña;
  • confirmación de email;
  • auditoría de accesos;
  • fecha del último login;
  • permisos más específicos además de roles;
  • baja lógica;
  • logs de errores;
  • API REST;
  • tests automatizados;
  • variables de entorno para credenciales;
  • inyección de dependencias.

Desafío final

Agregá una tabla de permisos y construí un sistema RBAC donde un rol pueda tener múltiples permisos. Por ejemplo:

  • usuarios.ver
  • usuarios.crear
  • usuarios.editar
  • usuarios.eliminar

Después modificá el middleware para comprobar permisos en lugar de depender exclusivamente del nombre del rol.

¿Qué aprendimos?

Este proyecto reúne prácticamente toda la ruta inicial de PHP en una sola aplicación. Trabajamos con formularios, sesiones, autenticación, autorización, PDO, consultas preparadas, CRUD, POO, MVC, Composer y seguridad.

Entender cómo se conectan estas piezas es especialmente útil antes de pasar a frameworks como Laravel, porque muchas de estas ideas siguen existiendo allí, aunque el framework las resuelva mediante herramientas propias.

Seguí aprendiendo PHP

Podés recorrer todas las guías desde la página principal:

Aprender PHP desde cero: ruta completa →

También podés practicar con:

¿Te sirvió este proyecto? ☕

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

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Proyecto en Python: crear un juego Snake animado con Pygame paso a paso

En este proyecto vamos a crear un juego Snake animado en Python usando Pygame. A diferencia de la versión por turnos que podemos hacer en pseudocódigo, acá la serpiente se moverá continuamente y responderá al teclado en tiempo real.

El objetivo del tutorial es construir un juego completo y, al mismo tiempo, entender conceptos fundamentales del desarrollo de videojuegos: bucle principal, FPS, eventos, estados, colisiones, dibujo en pantalla y actualización continua.

¿Qué vamos a construir?

El juego tendrá:

  • movimiento continuo de la serpiente;
  • controles con flechas o WASD;
  • comida en posiciones aleatorias;
  • crecimiento al comer;
  • puntaje;
  • colisión contra paredes;
  • colisión contra el propio cuerpo;
  • aumento progresivo de velocidad;
  • pantalla de Game Over;
  • reinicio de partida sin cerrar el programa.

1. Instalar Pygame

Pygame es una biblioteca para crear juegos 2D en Python. Se instala desde la terminal o consola con:

pip install pygame

Si en tu sistema hay varias versiones de Python, también podés probar:

python -m pip install pygame

o:

py -m pip install pygame

2. Crear la ventana

Primero importamos las bibliotecas y configuramos el tamaño de la ventana.

import pygame
import random

pygame.init()

ANCHO = 600
ALTO = 600
TAMANIO = 20

pantalla = pygame.display.set_mode((ANCHO, ALTO))
pygame.display.set_caption("Snake - Club Programador")

El tamaño de cada bloque de la serpiente será de 20 píxeles. Como la ventana mide 600 x 600, tendremos una cuadrícula lógica de 30 x 30 posiciones.

3. El concepto más importante: el game loop

Un videojuego se ejecuta dentro de un ciclo que se repite constantemente. En cada vuelta:

  • leemos eventos del teclado;
  • actualizamos la posición de los objetos;
  • comprobamos colisiones;
  • dibujamos nuevamente la pantalla.

Ese ciclo se conoce como game loop o bucle principal del juego.

while jugando:
    procesar_eventos()
    actualizar_juego()
    dibujar()
    pygame.display.flip()

4. Representar la serpiente

Vamos a guardar la serpiente como una lista de coordenadas. La primera posición será siempre la cabeza.

serpiente = [
    (300, 300),
    (280, 300),
    (260, 300)
]

La cabeza está en serpiente[0]. Cada tupla representa una coordenada (x, y).

5. Código completo

import pygame
import random

pygame.init()

ANCHO = 600
ALTO = 600
TAMANIO = 20

FPS_INICIAL = 8
FPS_MAXIMO = 18

pantalla = pygame.display.set_mode((ANCHO, ALTO))
pygame.display.set_caption("Snake - Club Programador")

reloj = pygame.time.Clock()

fuente = pygame.font.SysFont("Arial", 26)
fuente_grande = pygame.font.SysFont("Arial", 48)


def nueva_comida(serpiente):
    while True:
        x = random.randrange(0, ANCHO, TAMANIO)
        y = random.randrange(0, ALTO, TAMANIO)

        posicion = (x, y)

        if posicion not in serpiente:
            return posicion


def crear_partida():
    serpiente = [
        (300, 300),
        (280, 300),
        (260, 300)
    ]

    direccion = (TAMANIO, 0)
    comida = nueva_comida(serpiente)

    return serpiente, direccion, comida


def direccion_opuesta(actual, nueva):
    return (
        actual[0] + nueva[0] == 0
        and
        actual[1] + nueva[1] == 0
    )


def cambiar_direccion(evento, direccion):
    nueva = direccion

    if evento.key in (pygame.K_UP, pygame.K_w):
        nueva = (0, -TAMANIO)

    elif evento.key in (pygame.K_DOWN, pygame.K_s):
        nueva = (0, TAMANIO)

    elif evento.key in (pygame.K_LEFT, pygame.K_a):
        nueva = (-TAMANIO, 0)

    elif evento.key in (pygame.K_RIGHT, pygame.K_d):
        nueva = (TAMANIO, 0)

    if direccion_opuesta(direccion, nueva):
        return direccion

    return nueva


def mover_serpiente(serpiente, direccion):
    cabeza_x, cabeza_y = serpiente[0]

    nueva_cabeza = (
        cabeza_x + direccion[0],
        cabeza_y + direccion[1]
    )

    serpiente.insert(0, nueva_cabeza)

    return nueva_cabeza


def hay_colision(serpiente):
    cabeza_x, cabeza_y = serpiente[0]

    choca_pared = (
        cabeza_x < 0
        or cabeza_x >= ANCHO
        or cabeza_y < 0
        or cabeza_y >= ALTO
    )

    choca_cuerpo = serpiente[0] in serpiente[1:]

    return choca_pared or choca_cuerpo


def calcular_velocidad(puntos):
    velocidad = FPS_INICIAL + puntos // 3

    return min(velocidad, FPS_MAXIMO)


def dibujar_juego(serpiente, comida, puntos):
    pantalla.fill((15, 23, 42))

    pygame.draw.rect(
        pantalla,
        (239, 68, 68),
        (*comida, TAMANIO, TAMANIO)
    )

    for indice, parte in enumerate(serpiente):
        if indice == 0:
            color = (34, 197, 94)
        else:
            color = (22, 163, 74)

        pygame.draw.rect(
            pantalla,
            color,
            (*parte, TAMANIO, TAMANIO)
        )

    texto = fuente.render(
        f"Puntaje: {puntos}",
        True,
        (255, 255, 255)
    )

    pantalla.blit(texto, (10, 10))


def dibujar_game_over(puntos):
    pantalla.fill((15, 23, 42))

    titulo = fuente_grande.render(
        "GAME OVER",
        True,
        (255, 255, 255)
    )

    resultado = fuente.render(
        f"Puntaje: {puntos}",
        True,
        (255, 255, 255)
    )

    ayuda = fuente.render(
        "R: reiniciar | ESC: salir",
        True,
        (200, 200, 200)
    )

    pantalla.blit(
        titulo,
        titulo.get_rect(center=(ANCHO // 2, ALTO // 2 - 70))
    )

    pantalla.blit(
        resultado,
        resultado.get_rect(center=(ANCHO // 2, ALTO // 2))
    )

    pantalla.blit(
        ayuda,
        ayuda.get_rect(center=(ANCHO // 2, ALTO // 2 + 60))
    )


serpiente, direccion, comida = crear_partida()

puntos = 0
jugando = True
game_over = False


while jugando:
    velocidad = calcular_velocidad(puntos)
    reloj.tick(velocidad)

    for evento in pygame.event.get():
        if evento.type == pygame.QUIT:
            jugando = False

        elif evento.type == pygame.KEYDOWN:
            if evento.key == pygame.K_ESCAPE:
                jugando = False

            elif game_over and evento.key == pygame.K_r:
                serpiente, direccion, comida = crear_partida()
                puntos = 0
                game_over = False

            elif not game_over:
                direccion = cambiar_direccion(evento, direccion)

    if not game_over:
        nueva_cabeza = mover_serpiente(serpiente, direccion)

        if nueva_cabeza == comida:
            puntos += 1
            comida = nueva_comida(serpiente)
        else:
            serpiente.pop()

        if hay_colision(serpiente):
            game_over = True

    if game_over:
        dibujar_game_over(puntos)
    else:
        dibujar_juego(serpiente, comida, puntos)

    pygame.display.flip()

pygame.quit()

6. Leer el teclado

Pygame genera eventos cada vez que ocurre algo: cerrar la ventana, mover el mouse o presionar una tecla.

Para Snake nos interesa especialmente:

if evento.type == pygame.KEYDOWN:
    if evento.key == pygame.K_UP:
        direccion = (0, -TAMANIO)

También agregamos WASD para que el jugador pueda elegir cualquiera de los dos controles.

7. Evitar el giro de 180 grados

Si la serpiente se mueve hacia la derecha, no debería poder pasar directamente a la izquierda. Eso provocaría una colisión instantánea con su propio cuerpo.

La función direccion_opuesta() comprueba que la nueva dirección no sea exactamente la contraria:

return (
    actual[0] + nueva[0] == 0
    and
    actual[1] + nueva[1] == 0
)

8. ¿Cómo se mueve la serpiente?

La nueva cabeza se calcula sumando la dirección actual:

nueva_cabeza = (
    cabeza_x + direccion[0],
    cabeza_y + direccion[1]
)

Después insertamos esa nueva posición al comienzo de la lista:

serpiente.insert(0, nueva_cabeza)

Si no comió, eliminamos la última parte:

serpiente.pop()

El resultado visual es que todo el cuerpo avanza una posición.

9. ¿Cómo crece?

Cuando la nueva cabeza coincide con la comida, simplemente no eliminamos la cola.

if nueva_cabeza == comida:
    puntos += 1
    comida = nueva_comida(serpiente)
else:
    serpiente.pop()

Como agregamos una nueva cabeza pero no quitamos el último segmento, la longitud aumenta automáticamente.

10. Generar comida sin colocarla sobre la serpiente

random.randrange() permite generar posiciones alineadas con nuestra cuadrícula de 20 píxeles.

x = random.randrange(0, ANCHO, TAMANIO)
y = random.randrange(0, ALTO, TAMANIO)

Después comprobamos que esa coordenada no esté ocupada:

if posicion not in serpiente:
    return posicion

11. Colisiones

Contra las paredes

La serpiente pierde cuando la cabeza sale de los límites de la ventana:

cabeza_x < 0
or cabeza_x >= ANCHO
or cabeza_y < 0
or cabeza_y >= ALTO

Contra el propio cuerpo

La cabeza no puede encontrarse dentro del resto de la serpiente:

serpiente[0] in serpiente[1:]

La expresión serpiente[1:] crea una vista lógica de todos los segmentos excepto la cabeza.

12. FPS y velocidad

Clock.tick() controla cuántas veces por segundo se ejecuta aproximadamente el ciclo.

reloj.tick(velocidad)

En nuestro juego la velocidad aumenta cada tres puntos:

velocidad = FPS_INICIAL + puntos // 3

Además usamos min() para evitar que el juego se vuelva excesivamente rápido.

13. Dibujar la pantalla

En cada frame borramos el contenido anterior y volvemos a dibujar comida, serpiente y puntaje.

pantalla.fill((15, 23, 42))

pygame.draw.rect(
    pantalla,
    (239, 68, 68),
    (*comida, TAMANIO, TAMANIO)
)

Al finalizar el frame llamamos:

pygame.display.flip()

Eso hace visible todo lo que acabamos de dibujar.

14. Game Over y reinicio

Cuando ocurre una colisión ponemos:

game_over = True

El ciclo general sigue funcionando, pero dejamos de mover la serpiente y mostramos una pantalla de Game Over.

Al pulsar R llamamos nuevamente a crear_partida(), reiniciando posiciones, comida, dirección y puntaje sin cerrar la ventana.

Ideas para seguir mejorando Snake

  • agregar sonidos;
  • usar sprites en lugar de rectángulos;
  • guardar el récord máximo;
  • agregar obstáculos;
  • crear niveles;
  • tener distintos tipos de comida;
  • crear comida temporal que otorgue puntos extra;
  • permitir atravesar un extremo y aparecer por el opuesto;
  • agregar un menú inicial;
  • agregar pausa con la tecla P.

Desafío

Agregá un récord máximo. Mientras el programa esté abierto, cada vez que termine una partida compará el puntaje actual contra el récord.

Después intentá guardarlo en un archivo para que el récord siga existiendo incluso después de cerrar el juego.

¿Qué aprendimos?

Este proyecto reúne varios conceptos importantes de Python, pero también introduce ideas propias del desarrollo de videojuegos:

  • game loop;
  • eventos;
  • frames por segundo;
  • coordenadas;
  • renderizado;
  • colisiones;
  • estado de juego.

Una vez que entendés esta estructura, podés reutilizarla para crear muchos otros juegos 2D sencillos.

¿Te sirvió este proyecto? ☕

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

Seguí aprendiendo Python

Encontrá todos los contenidos de Python en nuestra guía principal:

Aprender Python: guía completa paso a paso →

Introducción a PHP desde cero: qué es, cómo funciona un servidor web y cómo ejecutar tu primer archivo

PHP es uno de los lenguajes más utilizados para desarrollar aplicaciones web del lado del servidor. Antes de aprender variables, condicionales o formularios, conviene entender dónde se ejecuta PHP, qué papel cumple un servidor web y qué ocurre realmente cuando escribimos una URL en el navegador.

En esta guía vamos a construir esa base teórica desde cero y después vamos a ejecutar nuestro primer archivo PHP.

¿Qué es PHP?

PHP es un lenguaje de programación que normalmente se ejecuta en el servidor.

Eso significa que el navegador no interpreta directamente el código PHP. El servidor ejecuta ese código y devuelve como resultado HTML, JSON, texto u otro contenido.

Por ejemplo, este código:

<?php
echo 'Hola desde PHP';

no llega al navegador tal como está escrito. El servidor ejecuta PHP y el navegador recibe algo equivalente a:

Hola desde PHP

Cliente y servidor

En una aplicación web existen, como mínimo, dos partes importantes:

  • cliente: normalmente el navegador;
  • servidor: la máquina o software que procesa las solicitudes y devuelve respuestas.

Cuando escribimos una dirección como:

https://misitio.com/productos.php

el navegador envía una petición al servidor. El servidor busca el recurso, ejecuta PHP si corresponde y devuelve una respuesta.

Flujo básico de una aplicación PHP

Navegador
   ↓ HTTP
Servidor web (Apache / Nginx)
   ↓
PHP
   ↓
MySQL u otra base de datos
   ↓
PHP genera respuesta
   ↓
HTML / JSON
   ↓
Navegador

No todas las aplicaciones usan exactamente este flujo, pero es una buena representación inicial.

¿Qué es un servidor web?

Un servidor web es el software encargado de recibir solicitudes HTTP y devolver respuestas.

Dos de los más utilizados son:

  • Apache HTTP Server;
  • Nginx.

También PHP incluye un servidor de desarrollo integrado que resulta útil para proyectos simples y pruebas locales.

Apache

Apache es un servidor web muy utilizado en entornos PHP.

En instalaciones locales como XAMPP, Apache suele escuchar en el puerto 80. Por eso podemos acceder mediante:

http://localhost/

Si tenemos un archivo:

C:\xampp\htdocs\club\index.php

podemos acceder normalmente con:

http://localhost/club/index.php

Nginx

Nginx también es muy utilizado en servidores Linux y entornos de producción.

En este caso suele trabajar junto con PHP-FPM, que es quien ejecuta los archivos PHP.

El flujo simplificado es:

Navegador → Nginx → PHP-FPM → PHP → respuesta

Servidor integrado de PHP

PHP puede iniciar un servidor de desarrollo sin Apache ni Nginx.

Desde una terminal podemos ejecutar:

php -S 127.0.0.1:8000

Luego accedemos desde el navegador a:

http://127.0.0.1:8000/

o:

http://localhost:8000/

¿Qué significa localhost?

localhost representa la computadora en la que estamos trabajando.

Normalmente apunta a la dirección IP:

127.0.0.1

Por eso estas URLs suelen referirse a la misma máquina:

http://localhost/
http://127.0.0.1/

¿Qué es un puerto?

Una misma computadora puede ofrecer varios servicios al mismo tiempo. Los puertos permiten identificar cuál servicio debe recibir una conexión.

Algunos puertos frecuentes son:

  • 80: HTTP;
  • 443: HTTPS;
  • 8000: muy usado para servidores de desarrollo;
  • 3306: puerto habitual de MySQL.

Por ejemplo:

http://localhost:8000/

significa que queremos conectarnos al servicio web que escucha en el puerto 8000 de nuestra propia máquina.

HTTP y HTTPS

HTTP es el protocolo utilizado para intercambiar información entre navegador y servidor.

HTTPS es HTTP protegido mediante cifrado TLS.

Ejemplos:

http://localhost/
https://clubprogramador.com/

En producción deberíamos utilizar HTTPS siempre que sea posible.

¿Qué ocurre cuando escribimos una URL?

Supongamos que ingresamos:

http://localhost/club/index.php

De forma simplificada ocurre lo siguiente:

  1. el navegador interpreta la URL;
  2. se conecta al servidor web;
  3. solicita el recurso /club/index.php;
  4. Apache detecta que es un archivo PHP;
  5. PHP ejecuta el código;
  6. se genera una respuesta;
  7. Apache devuelve esa respuesta al navegador;
  8. el navegador muestra el resultado.

Una petición HTTP real

Conceptualmente, el navegador puede enviar algo parecido a:

GET /club/index.php HTTP/1.1
Host: localhost

Y el servidor podría responder:

HTTP/1.1 200 OK
Content-Type: text/html

<h1>Hola</h1>

¿Qué diferencia hay entre HTML y PHP?

HTML es un lenguaje de marcado. Describe la estructura de una página.

<h1>Hola mundo</h1>

PHP es un lenguaje de programación que puede generar HTML dinámicamente.

<?php
$nombre = 'Ana';
echo '<h1>Hola ' . $nombre . '</h1>';

¿Qué diferencia hay entre PHP y JavaScript?

Tradicionalmente:

  • PHP se ejecuta en el servidor;
  • JavaScript se ejecuta principalmente en el navegador.

JavaScript también puede ejecutarse en servidores mediante tecnologías como Node.js, pero en una arquitectura PHP tradicional cumple principalmente funciones del lado del cliente.

¿Qué papel cumple MySQL?

PHP puede comunicarse con MySQL para almacenar y recuperar información.

Por ejemplo:

PHP → consulta SQL → MySQL → resultado → PHP

PHP puede usar ese resultado para generar HTML o una respuesta JSON.

XAMPP

XAMPP es un paquete que instala varias herramientas útiles para desarrollo local, entre ellas normalmente:

  • Apache;
  • PHP;
  • MariaDB/MySQL;
  • phpMyAdmin.

En Windows, el directorio web suele ser:

C:\xampp\htdocs\

Si creamos:

C:\xampp\htdocs\miweb\index.php

podemos abrirlo mediante:

http://localhost/miweb/

Laragon

Laragon es otra herramienta popular para desarrollo PHP en Windows.

Su carpeta habitual de proyectos es:

C:\laragon\www\

Por ejemplo:

C:\laragon\www\club\index.php

puede abrirse normalmente mediante:

http://localhost/club/

Dependiendo de la configuración, Laragon también puede generar dominios locales automáticos, por ejemplo:

http://club.test/

DocumentRoot

El DocumentRoot es el directorio desde el cual el servidor web publica archivos.

En XAMPP suele ser:

C:\xampp\htdocs

En servidores Linux con Apache puede ser algo como:

/var/www/html

En Nginx también se configura un directorio raíz mediante su configuración.

Por qué no funciona abrir un PHP con doble clic

Si hacemos doble clic sobre:

C:\xampp\htdocs\club\index.php

Windows puede intentar abrir el archivo directamente.

Pero PHP necesita ser ejecutado por un intérprete del lado del servidor.

Por eso debemos acceder mediante una URL:

http://localhost/club/index.php

Nuestro primer archivo PHP

Creamos un archivo llamado:

index.php

y escribimos:

<?php

echo 'Hola mundo desde PHP';

Luego lo abrimos mediante la URL correspondiente.

La etiqueta <?php

Indica que comienza código PHP.

<?php

En archivos que contienen solamente PHP normalmente no es necesario escribir la etiqueta de cierre ?>.

echo

echo envía contenido a la salida.

echo 'Hola';

También podemos generar HTML:

echo '<h1>Hola mundo</h1>';

Mezclar PHP y HTML

PHP puede convivir dentro de un documento HTML.


    Mi primera página PHP

<h1>Mi sitio</h1>

<?php
echo 'Contenido generado con PHP';
?>

Archivos .php y .html

Un archivo .html normalmente es servido directamente.

Un archivo .php debe pasar por el intérprete PHP antes de ser enviado al navegador.

Por eso un archivo PHP puede generar contenido dinámico.

Códigos de estado HTTP

El servidor también devuelve un código que informa qué ocurrió.

  • 200: respuesta correcta;
  • 404: recurso no encontrado;
  • 403: acceso prohibido;
  • 500: error interno del servidor.

Errores comunes al comenzar

Apache no está iniciado

Si intentamos acceder a localhost y no existe ningún servidor escuchando, el navegador no podrá conectarse.

Puerto ocupado

Otro programa puede estar utilizando el puerto 80. En ese caso Apache puede no iniciar o puede configurarse otro puerto.

Por ejemplo:

http://localhost:8080/

Archivo fuera del DocumentRoot

Guardar:

C:\Users\Usuario\Desktop\index.php

no significa que Apache pueda acceder automáticamente a ese archivo.

Escribir una URL incorrecta

La ruta física:

C:\xampp\htdocs\club\index.php

no se escribe directamente en el navegador. Su URL es:

http://localhost/club/index.php

PHP no está instalado o configurado

Si usamos el servidor integrado y ejecutamos:

php -S localhost:8000

pero el comando php no existe, debemos instalar PHP o agregar su ejecutable al PATH del sistema.

Ejercicio práctico

Creá una carpeta llamada:

php-practica

dentro del directorio web de XAMPP o Laragon.

Después creá:

index.php

con el siguiente contenido:


    
    PHP

<h1>Mi primer proyecto PHP</h1>

<p>
<?php
    echo 'PHP está funcionando correctamente';
?>
</p>

Luego probalo desde:

http://localhost/php-practica/

Qué aprendimos

  • qué es PHP;
  • diferencia entre cliente y servidor;
  • qué hace un servidor web;
  • qué son Apache y Nginx;
  • qué significa localhost;
  • qué es un puerto;
  • diferencia entre PHP, HTML, JavaScript y MySQL;
  • cómo funcionan XAMPP y Laragon;
  • qué es DocumentRoot;
  • cómo ejecutar un archivo PHP;
  • cómo funciona echo.

¿Qué sigue?

En la próxima guía vamos a estudiar variables, tipos de datos y operadores en PHP, que son la base para empezar a escribir programas con lógica.

¿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

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

20 ejercicios intermedios de PHP resueltos: arrays, funciones, formularios y PDO

Si ya dominás variables, condicionales, bucles y funciones, estos 20 ejercicios intermedios de PHP resueltos te van a ayudar a combinar conceptos y acercarte a problemas reales.

1. Filtrar números pares de un array

<?php
$numeros = [1,2,3,4,5,6,7,8];

$pares = array_filter($numeros, fn($n) => $n % 2 === 0);

print_r($pares);
?>

2. Ordenar productos por precio

<?php
$productos = [
    ['nombre' => 'Mouse', 'precio' => 15000],
    ['nombre' => 'Monitor', 'precio' => 180000],
    ['nombre' => 'Teclado', 'precio' => 25000]
];

usort($productos, fn($a, $b) => $a['precio'] <=> $b['precio']);

print_r($productos);
?>

3. Buscar un usuario por email

<?php
$usuarios = [
    ['nombre' => 'Ana', 'email' => 'ana@mail.com'],
    ['nombre' => 'Juan', 'email' => 'juan@mail.com']
];

$emailBuscado = 'juan@mail.com';

$resultado = array_filter($usuarios, fn($u) => $u['email'] === $emailBuscado);

print_r($resultado);
?>

4. Eliminar duplicados

<?php
$valores = [1,2,2,3,4,4,5];

$unicos = array_unique($valores);

print_r($unicos);
?>

5. Contar palabras de un texto

<?php
$texto = 'PHP es simple de aprender y muy utilizado';

echo str_word_count($texto);
?>

6. Validar un email

<?php
$email = 'usuario@email.com';

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo 'Email válido';
} else {
    echo 'Email inválido';
}
?>

7. Validar un formulario POST

<?php
$nombre = trim($_POST['nombre'] ?? '');

if ($nombre === '') {
    echo 'El nombre es obligatorio';
}
?>

8. Mantener valores del formulario

<input
    type="text"
    name="nombre"
    value="<?= htmlspecialchars($_POST['nombre'] ?? '') ?>"
>

9. Generar una contraseña aleatoria

<?php
$caracteres = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$password = '';

for ($i = 0; $i < 12; $i++) {
    $password .= $caracteres[random_int(0, strlen($caracteres) - 1)];
}

echo $password;
?>

10. Guardar una contraseña con password_hash()

<?php
$password = 'ClaveSegura123';

$hash = password_hash($password, PASSWORD_DEFAULT);

echo $hash;
?>

11. Comprobar una contraseña

<?php
if (password_verify($password, $hash)) {
    echo 'Contraseña correcta';
}
?>

12. Crear una sesión de usuario

<?php
session_start();

$_SESSION['usuario_id'] = 10;
$_SESSION['nombre'] = 'Ana';
?>

13. Proteger una página con sesión

<?php
session_start();

if (!isset($_SESSION['usuario_id'])) {
    header('Location: login.php');
    exit;
}
?>

14. Leer un archivo de texto

<?php
$contenido = file_get_contents('datos.txt');

echo nl2br(htmlspecialchars($contenido));
?>

15. Guardar datos en un archivo

<?php
$texto = "Nueva línea
";

file_put_contents(
    'datos.txt',
    $texto,
    FILE_APPEND
);
?>

16. Conectarse a MySQL con PDO

<?php
$pdo = new PDO(
    'mysql:host=localhost;dbname=tienda;charset=utf8mb4',
    'root',
    ''
);

$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
?>

17. Buscar un registro con una consulta preparada

<?php
$stmt = $pdo->prepare(
    'SELECT * FROM productos WHERE id = :id'
);

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

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

18. Insertar un producto con PDO

<?php
$stmt = $pdo->prepare(
    'INSERT INTO productos (nombre, precio)
     VALUES (:nombre, :precio)'
);

$stmt->execute([
    'nombre' => 'Teclado',
    'precio' => 25000
]);
?>

19. Crear una paginación básica

<?php
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$porPagina = 10;
$offset = ($pagina - 1) * $porPagina;

$stmt = $pdo->prepare(
    'SELECT * FROM productos LIMIT :limite OFFSET :offset'
);

$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
?>

20. Crear un filtro de búsqueda

<?php
$buscar = trim($_GET['buscar'] ?? '');

$stmt = $pdo->prepare(
    'SELECT * FROM productos
     WHERE nombre LIKE :buscar'
);

$stmt->execute([
    'buscar' => '%' . $buscar . '%'
]);

$productos = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>

¿Qué sigue?

Si podés resolver estos ejercicios sin copiar las soluciones, ya estás listo para trabajar con desafíos avanzados: POO, seguridad, relaciones entre clases, arquitectura MVC y proyectos completos.

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

20 ejercicios de PHP para principiantes resueltos paso a paso

Si estás aprendiendo PHP desde cero, practicar es una de las mejores formas de fijar conceptos. En esta guía vas a encontrar 20 ejercicios de PHP para principiantes resueltos paso a paso.

Los ejercicios trabajan variables, operadores, condicionales, bucles, arrays y funciones.

1. Mostrar un mensaje

Ejercicio: mostrar el texto “Hola Mundo desde PHP”.

<?php
echo 'Hola Mundo desde PHP';
?>

2. Sumar dos números

<?php
$a = 10;
$b = 5;

$suma = $a + $b;

echo $suma;
?>

3. Calcular el promedio de tres números

<?php
$a = 8;
$b = 7;
$c = 9;

$promedio = ($a + $b + $c) / 3;

echo $promedio;
?>

4. Saber si un número es par o impar

<?php
$numero = 7;

if ($numero % 2 === 0) {
    echo 'Es par';
} else {
    echo 'Es impar';
}
?>

5. Saber si una persona es mayor de edad

<?php
$edad = 20;

if ($edad >= 18) {
    echo 'Es mayor de edad';
} else {
    echo 'Es menor de edad';
}
?>

6. Obtener el mayor de dos números

<?php
$a = 15;
$b = 22;

if ($a > $b) {
    echo $a;
} else {
    echo $b;
}
?>

7. Mostrar números del 1 al 10

<?php
for ($i = 1; $i <= 10; $i++) {
    echo $i . '<br>';
}
?>

8. Mostrar números pares del 1 al 20

<?php
for ($i = 2; $i <= 20; $i += 2) {
    echo $i . '<br>';
}
?>

9. Tabla de multiplicar

<?php
$numero = 5;

for ($i = 1; $i <= 10; $i++) {
    echo $numero . ' x ' . $i . ' = ' . ($numero * $i) . '<br>';
}
?>

10. Sumar números del 1 al 100

<?php
$suma = 0;

for ($i = 1; $i <= 100; $i++) {
    $suma += $i;
}

echo $suma;
?>

11. Contar caracteres de una palabra

<?php
$palabra = 'programacion';

echo strlen($palabra);
?>

12. Convertir texto a mayúsculas

<?php
$texto = 'hola php';

echo strtoupper($texto);
?>

13. Recorrer un array

<?php
$frutas = ['Manzana', 'Banana', 'Naranja'];

foreach ($frutas as $fruta) {
    echo $fruta . '<br>';
}
?>

14. Encontrar el mayor valor de un array

<?php
$numeros = [4, 9, 2, 15, 6];

echo max($numeros);
?>

15. Calcular el promedio de un array

<?php
$notas = [8, 7, 9, 10];

$promedio = array_sum($notas) / count($notas);

echo $promedio;
?>

16. Crear una función para saludar

<?php
function saludar(string $nombre): string
{
    return 'Hola ' . $nombre;
}

echo saludar('Ana');
?>

17. Función para calcular el área de un rectángulo

<?php
function areaRectangulo(float $base, float $altura): float
{
    return $base * $altura;
}

echo areaRectangulo(5, 3);
?>

18. Determinar si un número es positivo, negativo o cero

<?php
$numero = -3;

if ($numero > 0) {
    echo 'Positivo';
} elseif ($numero < 0) {
    echo 'Negativo';
} else {
    echo 'Cero';
}
?>

19. Contar cuántas veces aparece un valor

<?php
$numeros = [1, 2, 3, 2, 4, 2];

$resultado = array_count_values($numeros);

echo $resultado[2];
?>

20. Generar un número aleatorio

<?php
$numero = random_int(1, 100);

echo $numero;
?>

¿Qué practicar después?

Si estos ejercicios ya te resultan sencillos, el siguiente paso es trabajar con problemas intermedios que mezclen arrays, funciones, formularios, validación y acceso a bases de datos.

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

Composer en PHP: dependencias, composer.json, vendor y autoloading PSR-4

Después de aprender programación orientada a objetos y MVC, el siguiente paso para trabajar con PHP moderno es conocer Composer, el gestor de dependencias más utilizado en el ecosistema PHP.

Composer permite instalar librerías, administrar versiones y cargar clases automáticamente mediante autoloading.

Además, es una herramienta fundamental para trabajar con frameworks como Laravel.

¿Qué es Composer?

Composer es un gestor de dependencias para PHP.

Permite declarar qué librerías necesita nuestro proyecto y descargarlas automáticamente.

Por ejemplo, en lugar de copiar manualmente una librería dentro del proyecto, podemos instalarla con:

composer require monolog/monolog

Composer se encargará de descargar la librería y sus dependencias.

¿Qué problema resuelve?

Sin Composer, un proyecto podría requerir:

  • descargar librerías manualmente;
  • copiar archivos;
  • resolver dependencias;
  • verificar versiones compatibles;
  • hacer múltiples require.

Composer automatiza gran parte de ese trabajo.

Comprobar si Composer está instalado

Desde una terminal podemos ejecutar:

composer --version

Si está instalado correctamente, veremos la versión disponible.

Crear un proyecto con Composer

Dentro de una carpeta podemos ejecutar:

composer init

Composer nos hará algunas preguntas y generará un archivo llamado:

composer.json

¿Qué es composer.json?

composer.json contiene la configuración del proyecto y sus dependencias.

Un ejemplo mínimo:

{
    "name": "clubprogramador/ejemplo",
    "require": {
        "php": "^8.2"
    }
}

Instalar una dependencia

Podemos instalar una librería con:

composer require monolog/monolog

Composer modificará composer.json y descargará los paquetes necesarios.

La carpeta vendor

Composer almacena las dependencias dentro de:

vendor/

Por ejemplo:

proyecto/
│
├── app/
├── public/
├── vendor/
├── composer.json
└── composer.lock

¿Qué es composer.lock?

composer.lock registra las versiones exactas de las dependencias instaladas.

Esto permite que distintas personas o servidores instalen exactamente las mismas versiones.

Por eso, en proyectos de aplicaciones, normalmente composer.lock se incluye en el control de versiones.

composer install vs composer update

composer install

composer install

Instala las versiones indicadas en composer.lock.

composer update

composer update

Busca versiones nuevas compatibles con las reglas de composer.json y actualiza composer.lock.

Por eso no conviene ejecutar composer update indiscriminadamente en producción.

Autoloading

Composer genera automáticamente un archivo:

vendor/autoload.php

Podemos cargarlo una sola vez:

<?php

require __DIR__ . '/vendor/autoload.php';

?>

A partir de ahí, Composer puede cargar clases automáticamente.

El problema de múltiples require

Sin autoloading podríamos terminar con algo así:

require 'app/Models/Producto.php';
require 'app/Models/Usuario.php';
require 'app/Services/MailService.php';
require 'app/Controllers/ProductoController.php';

A medida que el proyecto crece, esto se vuelve difícil de mantener.

PSR-4

PSR-4 es un estándar utilizado para asociar namespaces con carpetas.

Podemos configurar Composer así:

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

Esto significa que las clases cuyo namespace comienza con App\ se buscarán dentro de la carpeta app/.

Ejemplo con namespace

Archivo:

app/Models/Producto.php

Contenido:

<?php

namespace App\Models;

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

?>

Regenerar el autoload

Después de modificar la configuración de autoload podemos ejecutar:

composer dump-autoload

Usar la clase

Ahora podemos escribir:

<?php

require __DIR__ . '/vendor/autoload.php';

use App\Models\Producto;

$producto = new Producto(
    'Teclado'
);

echo $producto->nombre;

?>

No necesitamos hacer un require específico para Producto.php.

Estructura de proyecto con PSR-4

proyecto/
│
├── app/
│   ├── Controllers/
│   │   └── ProductoController.php
│   │
│   ├── Models/
│   │   └── Producto.php
│   │
│   └── Services/
│       └── ProductoService.php
│
├── public/
│   └── index.php
│
├── vendor/
├── composer.json
└── composer.lock

Namespace para un controlador

<?php

namespace App\Controllers;

use App\Models\Producto;

class ProductoController
{
    public function index(): void
    {
        // lógica del controlador
    }
}

?>

Instalar paquetes de desarrollo

Algunas dependencias solo se necesitan durante el desarrollo.

Podemos instalarlas con:

composer require --dev paquete/nombre

Estas dependencias se guardan dentro de:

require-dev

Versiones en Composer

Composer permite indicar restricciones de versiones.

Por ejemplo:

"monolog/monolog": "^3.0"

El operador ^ permite instalar actualizaciones compatibles dentro de la misma versión mayor, según las reglas de versionado semántico aplicables al paquete.

Packagist

Packagist es el repositorio público principal de paquetes utilizados por Composer.

Cuando ejecutamos:

composer require proveedor/paquete

Composer normalmente busca ese paquete en Packagist.

No subir vendor al repositorio

En muchos proyectos PHP se agrega:

/vendor/

al archivo .gitignore.

Las dependencias pueden reconstruirse ejecutando:

composer install

Lo importante es conservar:

  • composer.json;
  • composer.lock.

Ejemplo de .gitignore

/vendor/
.env

También es habitual excluir archivos con configuraciones sensibles como .env.

Composer y MVC

Podemos mejorar el proyecto MVC de la guía anterior eliminando los require manuales.

Antes:

require 'Producto.php';
require 'ProductoController.php';

Después:

require __DIR__ . '/../vendor/autoload.php';

use App\Models\Producto;
use App\Controllers\ProductoController;

Composer no es solamente para frameworks

Aunque Laravel utiliza Composer intensivamente, también es muy útil en proyectos PHP propios.

Podemos utilizar paquetes para:

  • enviar emails;
  • generar PDFs;
  • trabajar con archivos Excel;
  • manejar logs;
  • consumir APIs;
  • crear tests;
  • procesar fechas;
  • trabajar con variables de entorno.

Buenas prácticas

  • versionar composer.json y composer.lock;
  • no modificar manualmente archivos dentro de vendor;
  • utilizar autoload PSR-4;
  • mantener namespaces consistentes con la estructura de carpetas;
  • revisar las dependencias antes de instalarlas;
  • actualizar paquetes de manera controlada;
  • no ejecutar composer update en producción sin comprender el impacto.

Errores comunes

  • olvidar ejecutar composer install después de clonar un proyecto;
  • eliminar composer.lock sin motivo;
  • editar archivos dentro de vendor;
  • configurar mal el namespace PSR-4;
  • olvidar ejecutar composer dump-autoload después de ciertos cambios;
  • confundir composer install con composer update;
  • subir credenciales o secretos al repositorio.

Ejercicio práctico

Tomá el proyecto MVC de la guía anterior y adaptalo para utilizar Composer.

El objetivo es:

  1. crear un archivo composer.json;
  2. configurar App\ con PSR-4;
  3. agregar namespaces a Modelos y Controladores;
  4. eliminar los require individuales;
  5. cargar solamente vendor/autoload.php;
  6. ejecutar composer dump-autoload;
  7. comprobar que el CRUD siga funcionando.

¿Qué sigue?

Con POO, MVC y Composer ya tenemos gran parte de la base conceptual necesaria para comprender un framework PHP moderno.

La próxima guía puede ser una introducción a Laravel desde cero:

  • qué es Laravel;
  • cómo instalarlo;
  • estructura del proyecto;
  • rutas;
  • controladores;
  • Blade;
  • modelos;
  • migraciones.

¿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