Módulo 14: Errores y excepciones

Objetivos del módulo

  • Distinguir entre errores clásicos de PHP y excepciones
  • Conocer los niveles de error (E_ERROR, E_WARNING, E_NOTICE, E_DEPRECATED…)
  • Configurar error_reporting, display_errors y log_errors para desarrollo y producción
  • Entender la jerarquía Throwable → Error / Exception
  • Dominar try, catch y finally, la captura múltiple y throw como expresión
  • Usar las excepciones de la SPL y crear excepciones propias con contexto
  • Encadenar excepciones con $previous
  • Instalar manejadores globales con set_exception_handler() y set_error_handler()
  • Aprovechar los backtraces en errores fatales de PHP 8.5
  • Registrar errores con error_log() y con Monolog (PSR-3)
  • Aplicar buenas prácticas: no tragarse excepciones, fail fast y separar mensajes al usuario de los logs

1. ¿Qué es un error y qué es una excepción?

Cuando un programa se ejecuta, pueden pasar cosas inesperadas: un fichero que no existe, una división entre cero, una base de datos caída, un usuario que escribe letras donde esperabas un número… PHP tiene dos mecanismos para avisarte de que algo ha ido mal.

📘 Concepto: Un error (en el sentido clásico de PHP) es un aviso que el motor emite y que, según su gravedad, puede simplemente mostrarse en pantalla y dejar que el script continúe (un warning) o detenerlo por completo (un fatal error). Una excepción es un objeto que se “lanza” (throw) cuando ocurre un problema y que viaja hacia arriba por la pila de llamadas hasta que alguien lo “captura” (catch). Si nadie lo captura, el script termina.

Una analogía: imagina una cocina de restaurante.

  • Un warning es el cocinero diciendo en voz alta “¡se ha acabado el perejil!”. Avisa, pero el plato sale igual (quizá peor).
  • Una excepción es el cocinero que le pasa una nota al jefe de cocina: “No puedo hacer la paella: no hay arroz”. La nota sube por la jerarquía hasta que alguien decide qué hacer (ofrecer otro plato, avisar al cliente…). Si nadie la recoge, se cierra la cocina.

Por qué conviven los dos mecanismos

PHP nació en 1995 como un lenguaje de plantillas sencillo y su sistema de errores es anterior a la orientación a objetos. Las excepciones llegaron con PHP 5 y, desde PHP 7, la mayoría de los errores graves del motor se convirtieron en objetos Error que también se pueden capturar. En PHP 8 la tendencia continuó: muchas funciones internas que antes emitían un warning y devolvían false ahora lanzan TypeError o ValueError.

<?php
declare(strict_types=1);

// Error clásico (warning): el script sigue
$contenido = file_get_contents('no-existe.txt');
// Warning: file_get_contents(no-existe.txt): Failed to open stream: No such file or directory
var_dump($contenido); // bool(false)

// Excepción/Error moderno: el script se detiene si no lo capturas
echo intdiv(10, 0);
// Fatal error: Uncaught DivisionByZeroError: Division by zero
Característica Error clásico Excepción (Throwable)
Qué es Un mensaje emitido por el motor Un objeto con mensaje, código, fichero, línea y traza
Se puede capturar con try/catch No (salvo que lo conviertas con set_error_handler) Sí
¿Detiene el script? Depende del nivel (E_WARNING no, E_ERROR sí) Sí, si nadie la captura
Se configura con error_reporting, display_errors try/catch, set_exception_handler
Uso en código moderno Avisos del motor y de funciones antiguas Toda la gestión de errores de tu aplicación

💡 Consejo: En tu propio código, usa siempre excepciones para comunicar errores. Los niveles de error clásicos son algo que debes conocer para configurar PHP y para entender los mensajes del motor, pero no los emitas tú (salvo E_USER_DEPRECATED, como verás).


2. Niveles de error

Cada error clásico de PHP tiene un nivel, representado por una constante entera. El nivel indica la gravedad.

Constante Gravedad ¿Detiene el script? Ejemplo típico
E_ERROR Fatal en tiempo de ejecución Sí Memoria agotada, tiempo máximo excedido
E_PARSE Error de sintaxis al compilar Sí Falta un ; o una llave
E_CORE_ERROR / E_COMPILE_ERROR Fatal al arrancar o compilar Sí Extensión que no carga, require de un fichero inexistente
E_WARNING Aviso serio No Abrir un fichero que no existe, clave de array inexistente
E_NOTICE Aviso leve No Algunas situaciones dudosas (cada vez menos en PHP 8)
E_DEPRECATED Funcionalidad obsoleta No Usar algo que desaparecerá en la próxima versión
E_USER_WARNING / E_USER_NOTICE Avisos lanzados por tu código No trigger_error('...', E_USER_WARNING)
E_USER_DEPRECATED Obsolescencia lanzada por tu código No Avisar de que una función de tu librería va a desaparecer
E_RECOVERABLE_ERROR Fatal “capturable” con un manejador Sí, si no se maneja Casos raros heredados
E_ALL Todos los niveles juntos — Se usa para configurar

⚠️ Importante: En PHP 8 muchos avisos subieron de categoría. Por ejemplo, leer una variable no definida o una clave de array inexistente es ahora un E_WARNING (antes era E_NOTICE). Y la constante E_STRICT quedó obsoleta en PHP 8.4: ya no la uses en tu configuración.

Ejemplos que generan cada nivel

<?php
declare(strict_types=1);

// E_WARNING: variable no definida
echo $noExiste;
// Warning: Undefined variable $noExiste

// E_WARNING: clave inexistente
$datos = ['nombre' => 'Ana'];
echo $datos['edad'];
// Warning: Undefined array key "edad"

// E_DEPRECATED en PHP 8.5: los nombres de cast no canónicos están obsoletos
$n = (integer) '42';
// Deprecated: Non-canonical cast (integer) is deprecated, use the (int) cast instead

// E_USER_DEPRECATED: lo lanzas tú para avisar a quien usa tu código
function calcularIvaAntiguo(float $base): float
{
    trigger_error('Usa calcularIva() en su lugar', E_USER_DEPRECATED);
    return $base * 0.21;
}

💡 Consejo: Para marcar funciones obsoletas en tu propio código, desde PHP 8.4 es mejor el atributo #[\Deprecated]: PHP emite el aviso automáticamente y los IDE lo tachan.

<?php
#[\Deprecated(message: 'Usa calcularIva()', since: '2.0')]
function calcularIvaAntiguo(float $base): float
{
    return $base * 0.21;
}

calcularIvaAntiguo(100);
// Deprecated: Function calcularIvaAntiguo() is deprecated since 2.0, Usa calcularIva()

El operador de silencio @

Si antepones @ a una expresión, PHP no muestra los avisos que genere:

<?php
$contenido = @file_get_contents('no-existe.txt'); // no muestra el warning

⚠️ Importante: Evita @. Oculta el problema pero no lo resuelve, dificulta muchísimo la depuración y, desde PHP 8, ni siquiera silencia los errores fatales. Es mejor comprobar antes (is_file()) o capturar la excepción correspondiente.


3. Configurar los errores: desarrollo frente a producción

PHP decide qué errores se notifican y dónde se muestran mediante directivas del fichero php.ini.

Directiva Qué controla Desarrollo Producción
error_reporting Qué niveles se notifican E_ALL E_ALL (sí, también)
display_errors Si se muestran en la respuesta (HTML) On Off
display_startup_errors Errores al arrancar PHP On Off
log_errors Si se escriben en un fichero de log On On
error_log Ruta del fichero de log opcional ruta fuera de la carpeta pública
html_errors Formato HTML en los mensajes On Off
fatal_error_backtraces Traza en errores fatales (PHP 8.5) On On
zend.exception_ignore_args Ocultar argumentos en las trazas Off On

📘 Concepto: La regla de oro es: en desarrollo quieres verlo todo en pantalla; en producción no quieres que el usuario vea nada técnico, pero quieres que todo quede registrado en un log. Mostrar errores en producción es un fallo de seguridad: revela rutas, nombres de tablas, versiones y, a veces, contraseñas.

PHP trae dos plantillas listas en su carpeta de instalación: php.ini-development y php.ini-production. Copia la que corresponda como php.ini y ajusta lo necesario.

Configuración de desarrollo

; php.ini (desarrollo)
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
html_errors = On
fatal_error_backtraces = On
zend.exception_ignore_args = Off

Configuración de producción

; php.ini (producción)
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/app-error.log
html_errors = Off
fatal_error_backtraces = On
zend.exception_ignore_args = On

⚠️ Importante: error_reporting debe seguir siendo E_ALL en producción. Lo que cambias es dónde van los errores (display_errors = Off, log_errors = On), no cuáles se registran. Si bajas error_reporting, te perderás avisos que anuncian problemas reales.

Cambiar la configuración desde el código

Algunas directivas se pueden modificar en tiempo de ejecución con ini_set() o con funciones específicas:

<?php
declare(strict_types=1);

// Útil en un fichero de arranque (bootstrap) según el entorno
$esDesarrollo = ($_SERVER['APP_ENV'] ?? 'prod') === 'dev';

error_reporting(E_ALL);
ini_set('display_errors', $esDesarrollo ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../var/log/php-error.log');

// Consultar el valor actual
echo ini_get('display_errors'); // "1" o "0"

💡 Consejo: Para saber qué php.ini está usando PHP, ejecuta php --ini en la terminal o busca “Loaded Configuration File” en la salida de phpinfo(). Es muy habitual que la consola y Apache usen ficheros distintos.

php --ini
# Configuration File (php.ini) Path: C:\php
# Loaded Configuration File:         C:\php\php.ini

⚠️ Importante: Los errores de sintaxis (E_PARSE) ocurren antes de que tu código se ejecute, así que un ini_set('display_errors', '1') dentro de ese mismo fichero no sirve para verlos. Para eso necesitas configurarlo en php.ini.


4. La jerarquía Throwable

Desde PHP 7, todo lo que se puede lanzar con throw y capturar con catch implementa la interfaz Throwable. De ella cuelgan dos ramas:

  • Error: problemas internos del motor o errores de programación (tipos incorrectos, llamadas a métodos que no existen, división entre cero…). Normalmente indican un bug que hay que corregir.
  • Exception: situaciones excepcionales de la aplicación de las que, a menudo, se puede uno recuperar (un fichero que no se puede abrir, un dato inválido, un fallo de red…). Es la rama que tú extenderás.
classDiagram
    class Throwable {
        <<interface>>
        +getMessage()
        +getCode()
        +getFile()
        +getLine()
        +getTrace()
        +getPrevious()
    }
    Throwable <|.. Error
    Throwable <|.. Exception

    Error <|-- TypeError
    TypeError <|-- ArgumentCountError
    Error <|-- ValueError
    Error <|-- ArithmeticError
    ArithmeticError <|-- DivisionByZeroError
    Error <|-- CompileError
    CompileError <|-- ParseError
    Error <|-- UnhandledMatchError
    Error <|-- AssertionError

    Exception <|-- ErrorException
    Exception <|-- LogicException
    Exception <|-- RuntimeException
    Exception <|-- JsonException
    LogicException <|-- InvalidArgumentException
    LogicException <|-- DomainException
    LogicException <|-- LengthException
    LogicException <|-- OutOfRangeException
    RuntimeException <|-- OutOfBoundsException
    RuntimeException <|-- UnexpectedValueException
    RuntimeException <|-- OverflowException
    RuntimeException <|-- UnderflowException
    RuntimeException <|-- RangeException
    RuntimeException <|-- PDOException

Errores frecuentes de la rama Error

<?php
declare(strict_types=1);

function doble(int $n): int
{
    return $n * 2;
}

try {
    doble('5');                 // TypeError (con strict_types)
} catch (TypeError $e) {
    echo $e->getMessage(), PHP_EOL;
    // doble(): Argument #1 ($n) must be of type int, string given, called in ...
}

try {
    doble();                    // ArgumentCountError
} catch (ArgumentCountError $e) {
    echo $e->getMessage(), PHP_EOL;
    // Too few arguments to function doble(), 0 passed in ... and exactly 1 expected
}

try {
    echo 10 % 0;                // DivisionByZeroError
} catch (DivisionByZeroError $e) {
    echo $e->getMessage(), PHP_EOL; // Modulo by zero
}

try {
    echo match (7) { 1 => 'uno', 2 => 'dos' }; // UnhandledMatchError
} catch (UnhandledMatchError $e) {
    echo $e->getMessage(), PHP_EOL; // Unhandled match case 7
}

try {
    str_repeat('-', -1);        // ValueError
} catch (ValueError $e) {
    echo $e->getMessage(), PHP_EOL;
    // str_repeat(): Argument #2 ($times) must be greater than or equal to 0
}

⚠️ Importante: No puedes implementar Throwable directamente en tus clases. Si quieres crear tus propias excepciones, extiende Exception (o una de sus subclases). Y, en general, no captures Error para “seguir adelante”: un TypeError significa que tu código tiene un fallo que hay que arreglar.


5. try, catch y finally

La estructura básica para trabajar con excepciones tiene tres partes:

try {
    // Código que puede fallar
} catch (TipoDeExcepcion $e) {
    // Qué hacer si falla con ese tipo
} finally {
    // Se ejecuta SIEMPRE, haya fallado o no
}

💻 Ejemplo: Leer un fichero de configuración JSON y reaccionar si algo falla.

<?php
declare(strict_types=1);

function cargarConfig(string $ruta): array
{
    if (!is_file($ruta)) {
        throw new RuntimeException("No existe el fichero de configuración: $ruta");
    }

    $json = file_get_contents($ruta);

    // JSON_THROW_ON_ERROR hace que json_decode lance JsonException
    return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
}

try {
    $config = cargarConfig(__DIR__ . '/config.json');
    echo "Configuración cargada: ", count($config), " claves", PHP_EOL;
} catch (JsonException $e) {
    echo "El fichero no contiene JSON válido: ", $e->getMessage(), PHP_EOL;
} catch (RuntimeException $e) {
    echo "Error: ", $e->getMessage(), PHP_EOL;
} finally {
    echo "Fin del intento de carga", PHP_EOL;
}

echo "El programa continúa", PHP_EOL;

Salida si el fichero no existe:

Error: No existe el fichero de configuración: /ruta/config.json
Fin del intento de carga
El programa continúa

Cómo funciona el flujo

  1. PHP ejecuta el bloque try línea a línea.
  2. Si se lanza una excepción, abandona el try inmediatamente (las líneas siguientes no se ejecutan).
  3. Busca el primer catch cuyo tipo coincida con la excepción (o con una clase padre).
  4. Ejecuta ese catch y, después, el finally.
  5. Si ningún catch coincide, se ejecuta el finally y la excepción sigue subiendo a la función que llamó.
flowchart TD
    A[Entrar en try] --> B{¿Se lanza una excepción?}
    B -- No --> F[finally]
    B -- Sí --> C{¿Hay un catch compatible?}
    C -- Sí --> D[Ejecutar catch]
    D --> F
    C -- No --> G[finally]
    G --> H[La excepción sube a quien llamó]
    F --> I[Continúa después del try]

El orden de los catch importa

PHP prueba los catch de arriba abajo. Si pones primero una clase padre, las hijas nunca llegarán a su bloque:

<?php
try {
    throw new InvalidArgumentException('Edad negativa');
} catch (Exception $e) {
    echo "Exception genérica";          // ← Entra aquí
} catch (InvalidArgumentException $e) {
    echo "Argumento inválido";          // ← Nunca se ejecuta
}

💡 Consejo: Ordena los catch de lo más específico a lo más general.

finally para liberar recursos

finally es perfecto para cerrar ficheros, liberar bloqueos o restaurar estado, pase lo que pase:

<?php
declare(strict_types=1);

function procesarCsv(string $ruta): int
{
    $fichero = fopen($ruta, 'r');
    if ($fichero === false) {
        throw new RuntimeException("No se pudo abrir $ruta");
    }

    try {
        $lineas = 0;
        while (($fila = fgetcsv($fichero, escape: '')) !== false) {
            if (count($fila) !== 3) {
                throw new UnexpectedValueException("Línea " . ($lineas + 1) . " mal formada");
            }
            $lineas++;
        }
        return $lineas;
    } finally {
        fclose($fichero);          // Se cierra tanto si va bien como si falla
        echo "Fichero cerrado", PHP_EOL;
    }
}

⚠️ Importante: Si haces return dentro de finally, ese valor sustituye al return del try e incluso anula una excepción pendiente. Es una trampa clásica: no pongas return en finally.

Volver a lanzar una excepción

A veces quieres hacer algo (registrar, limpiar) y dejar que la excepción siga su camino:

<?php
try {
    $pedido->confirmar();
} catch (RuntimeException $e) {
    error_log('Fallo al confirmar pedido: ' . $e->getMessage());
    throw $e;  // La relanzamos tal cual
}

6. Captura múltiple, catch sin variable y throw como expresión

Capturar varios tipos en un mismo bloque

Desde PHP 7.1 puedes agrupar tipos con | cuando el tratamiento es el mismo:

<?php
declare(strict_types=1);

try {
    $datos = json_decode($entrada, true, flags: JSON_THROW_ON_ERROR);
    $usuario = crearUsuario($datos);
} catch (JsonException | InvalidArgumentException $e) {
    http_response_code(400);
    echo "Datos de entrada no válidos: ", $e->getMessage();
}

catch sin variable (PHP 8.0)

Si no necesitas el objeto de la excepción, puedes omitir la variable:

<?php
declare(strict_types=1);

function esJsonValido(string $texto): bool
{
    try {
        json_decode($texto, flags: JSON_THROW_ON_ERROR);
        return true;
    } catch (JsonException) {   // ← sin $e
        return false;
    }
}

var_dump(esJsonValido('{"a":1}')); // bool(true)
var_dump(esJsonValido('{a:1}'));   // bool(false)

💡 Consejo: Para este caso concreto, PHP 8.3 incluye json_validate($texto), que es más eficiente. El ejemplo sirve para ilustrar la sintaxis.

throw como expresión (PHP 8.0)

Antes throw era una sentencia; ahora es una expresión, así que puedes usarlo en sitios donde se espera un valor: con ??, ?:, en arrow functions o en match.

<?php
declare(strict_types=1);

// Con el operador null coalescing
$id = $_GET['id'] ?? throw new InvalidArgumentException('Falta el parámetro id');

// Con el ternario corto
$usuario = buscarUsuario($id) ?: throw new RuntimeException("Usuario $id no encontrado");

// En una arrow function
$validarPositivo = fn(int $n): int => $n > 0 ? $n : throw new DomainException('Debe ser positivo');

// En un match
function nombreMes(int $mes): string
{
    return match ($mes) {
        1 => 'enero',
        2 => 'febrero',
        3 => 'marzo',
        // ...
        default => throw new OutOfRangeException("Mes fuera de rango: $mes"),
    };
}

✏️ Ejercicio: Escribe una función leerEntero(array $datos, string $clave): int que devuelva $datos[$clave] convertido a entero si es numérico, y que lance InvalidArgumentException (usando ?? y throw como expresión) si la clave no existe.


7. Las excepciones de la SPL

La SPL (Standard PHP Library) incluye un catálogo de excepciones listas para usar. Elegir bien la clase comunica qué tipo de problema ha ocurrido sin necesidad de leer el mensaje.

Se dividen en dos familias:

  • LogicException: errores en la lógica del programa, que deberían detectarse y corregirse en el código (quien llama ha usado mal la función).
  • RuntimeException: errores que solo se pueden detectar en tiempo de ejecución y que no dependen de un fallo de programación (un fichero borrado, un servicio caído).
Excepción Familia Cuándo usarla Ejemplo
InvalidArgumentException Logic Un argumento no tiene un valor aceptable Email con formato incorrecto
DomainException Logic Un valor no pertenece al dominio válido del negocio Un descuento del 150 %
LengthException Logic Longitud no válida Contraseña de 3 caracteres
OutOfRangeException Logic Índice fuera de rango conocido en el código Mes 13
BadMethodCallException Logic Llamada a un método que no existe o no procede En __call()
RuntimeException Runtime Error genérico de ejecución No se pudo escribir el fichero
UnexpectedValueException Runtime Un valor recibido no es el esperado Una API devuelve un formato raro
OutOfBoundsException Runtime Clave o índice inexistente en datos dinámicos Producto no está en el carrito
OverflowException Runtime Se añade a un contenedor lleno Cola con capacidad máxima
UnderflowException Runtime Se extrae de un contenedor vacío Sacar de una pila vacía
RangeException Runtime Resultado fuera de rango en un cálculo Resultado que excede un límite

💻 Ejemplo: Una cuenta bancaria que usa varias excepciones SPL.

<?php
declare(strict_types=1);

final class CuentaBancaria
{
    private const float LIMITE_RETIRADA = 1000.0;

    public function __construct(
        public readonly string $titular,
        private float $saldo = 0.0,
    ) {
        if (mb_trim($titular) === '') {
            throw new InvalidArgumentException('El titular no puede estar vacío');
        }
    }

    public function ingresar(float $cantidad): void
    {
        if ($cantidad <= 0) {
            throw new InvalidArgumentException('La cantidad a ingresar debe ser positiva');
        }
        $this->saldo += $cantidad;
    }

    public function retirar(float $cantidad): void
    {
        if ($cantidad <= 0) {
            throw new InvalidArgumentException('La cantidad a retirar debe ser positiva');
        }
        if ($cantidad > self::LIMITE_RETIRADA) {
            throw new DomainException('No se pueden retirar más de ' . self::LIMITE_RETIRADA . ' € de una vez');
        }
        if ($cantidad > $this->saldo) {
            throw new UnderflowException("Saldo insuficiente: tienes {$this->saldo} €");
        }
        $this->saldo -= $cantidad;
    }

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

$cuenta = new CuentaBancaria('Lucía', 200);

foreach ([50, -5, 5000, 400] as $cantidad) {
    try {
        $cuenta->retirar($cantidad);
        echo "Retirados $cantidad €. Saldo: {$cuenta->saldo()} €", PHP_EOL;
    } catch (InvalidArgumentException | DomainException $e) {
        echo "Operación no permitida: ", $e->getMessage(), PHP_EOL;
    } catch (UnderflowException $e) {
        echo "Sin fondos: ", $e->getMessage(), PHP_EOL;
    }
}

Salida:

Retirados 50 €. Saldo: 150 €
Operación no permitida: La cantidad a retirar debe ser positiva
Operación no permitida: No se pueden retirar más de 1000 € de una vez
Sin fondos: Saldo insuficiente: tienes 150 €

8. Excepciones personalizadas

Las excepciones SPL son genéricas. En una aplicación real conviene crear tus propias excepciones para expresar los problemas de tu dominio: ProductoNoEncontrado, StockInsuficiente, EmailYaRegistrado…

Ventajas

  • Se pueden capturar de forma selectiva: un catch (StockInsuficiente $e) no atrapa por error otros problemas.
  • Llevan contexto: datos extra (el id del producto, el stock disponible) que ayudan a mostrar un mensaje útil o a depurar.
  • Documentan el código: leyendo las excepciones que lanza un método sabes qué puede salir mal.

Métodos que heredas de Exception

Método Devuelve
getMessage() El mensaje de error
getCode() Código numérico (opcional)
getFile() / getLine() Fichero y línea donde se creó
getTrace() La pila de llamadas como array
getTraceAsString() La pila de llamadas como texto
getPrevious() La excepción anterior encadenada (o null)
__toString() Todo lo anterior formateado

💻 Ejemplo: Una jerarquía de excepciones para una tienda.

<?php
declare(strict_types=1);

namespace App\Exception;

// 1. Una excepción base para TODA la aplicación
//    Permite capturar "cualquier error nuestro" con un solo catch
abstract class TiendaException extends \RuntimeException
{
}

// 2. Excepción con contexto: guarda el id que no se encontró
final class ProductoNoEncontrado extends TiendaException
{
    public function __construct(public readonly int $productoId, ?\Throwable $previous = null)
    {
        parent::__construct("No existe ningún producto con id $productoId", 404, $previous);
    }
}

// 3. Excepción con varios datos de contexto
final class StockInsuficiente extends TiendaException
{
    public function __construct(
        public readonly string $producto,
        public readonly int $solicitado,
        public readonly int $disponible,
    ) {
        parent::__construct(sprintf(
            'Stock insuficiente de "%s": pedidos %d, disponibles %d',
            $producto,
            $solicitado,
            $disponible,
        ));
    }

    public function faltan(): int
    {
        return $this->solicitado - $this->disponible;
    }
}

// 4. Constructores con nombre ("named constructors"): mensajes coherentes
final class PedidoInvalido extends TiendaException
{
    public static function sinLineas(): self
    {
        return new self('El pedido no tiene ningún producto');
    }

    public static function importeMinimo(float $importe, float $minimo): self
    {
        return new self(sprintf('El importe %.2f € no llega al mínimo de %.2f €', $importe, $minimo));
    }
}

Uso:

<?php
declare(strict_types=1);

use App\Exception\{ProductoNoEncontrado, StockInsuficiente, PedidoInvalido, TiendaException};

function reservar(string $producto, int $cantidad, int $stock): void
{
    if ($cantidad > $stock) {
        throw new StockInsuficiente($producto, $cantidad, $stock);
    }
    echo "Reservadas $cantidad unidades de $producto", PHP_EOL;
}

try {
    reservar('Teclado mecánico', 5, 2);
} catch (StockInsuficiente $e) {
    echo $e->getMessage(), PHP_EOL;
    echo "Te faltan {$e->faltan()} unidades. ¿Quieres reservar las {$e->disponible} disponibles?", PHP_EOL;
} catch (TiendaException $e) {
    // Cualquier otro error de la tienda
    echo "No se pudo completar la operación: ", $e->getMessage(), PHP_EOL;
}

// Salida:
// Stock insuficiente de "Teclado mecánico": pedidos 5, disponibles 2
// Te faltan 3 unidades. ¿Quieres reservar las 2 disponibles?

💡 Consejo: Nombra tus excepciones por lo que ha pasado (ProductoNoEncontrado, EmailYaRegistrado), no por dónde ha pasado. Y usa una excepción base propia de la aplicación: así puedes distinguir “errores de mi negocio” de “errores técnicos inesperados”.

⚠️ Importante: Nunca guardes datos sensibles (contraseñas, tokens, números de tarjeta) en el mensaje de una excepción: acabará en los logs. Si una función recibe una contraseña como parámetro, márcala con #[\SensitiveParameter] (PHP 8.2) para que no aparezca en las trazas.

<?php
function autenticar(string $usuario, #[\SensitiveParameter] string $clave): bool
{
    throw new RuntimeException('Servicio de autenticación no disponible');
}
// En la traza aparecerá: autenticar('ana', Object(SensitiveParameterValue))

9. Encadenar excepciones con $previous

Cuando capturas una excepción técnica de bajo nivel (por ejemplo, de la base de datos) y lanzas otra más significativa para tu aplicación, no pierdas la original: pásala como $previous.

📘 Concepto: El encadenamiento de excepciones conserva la causa raíz. Es como un informe de incidencias: “No se pudo registrar al usuario” porque “falló la consulta INSERT” porque “se perdió la conexión con MySQL”. Cada capa añade su significado sin borrar el de la anterior.

<?php
declare(strict_types=1);

final class RegistroFallido extends RuntimeException {}

function guardarEnBaseDeDatos(array $usuario): void
{
    // Simulamos un error técnico de PDO
    throw new PDOException('SQLSTATE[HY000] [2002] Connection refused');
}

function registrarUsuario(array $usuario): void
{
    try {
        guardarEnBaseDeDatos($usuario);
    } catch (PDOException $e) {
        // Traducimos el error técnico a uno de negocio, conservando la causa
        throw new RegistroFallido('No se pudo registrar al usuario', previous: $e);
    }
}

try {
    registrarUsuario(['email' => 'ana@example.com']);
} catch (RegistroFallido $e) {
    echo "Mensaje para el usuario: ", $e->getMessage(), PHP_EOL;

    // Recorremos la cadena de causas para el log
    $causa = $e;
    $nivel = 0;
    while ($causa !== null) {
        echo str_repeat('  ', $nivel), '- ', $causa::class, ': ', $causa->getMessage(), PHP_EOL;
        $causa = $causa->getPrevious();
        $nivel++;
    }
}

Salida:

Mensaje para el usuario: No se pudo registrar al usuario
- RegistroFallido: No se pudo registrar al usuario
  - PDOException: SQLSTATE[HY000] [2002] Connection refused

💡 Consejo: Si haces echo $e; (o lo escribes en el log), __toString() ya incluye toda la cadena con las secciones “Next …”. No hace falta recorrerla a mano salvo que quieras un formato propio.


10. Manejadores globales

Por muy bien que programes, siempre habrá alguna excepción que nadie capture. Para esos casos PHP permite registrar manejadores globales: la última red de seguridad.

set_exception_handler(): excepciones no capturadas

<?php
declare(strict_types=1);

set_exception_handler(function (Throwable $e): void {
    // 1. Registrar todos los detalles técnicos
    error_log(sprintf(
        "[%s] %s: %s en %s:%d\n%s",
        date('Y-m-d H:i:s'),
        $e::class,
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString(),
    ));

    // 2. Mostrar al usuario un mensaje neutro
    if (!headers_sent()) {
        http_response_code(500);
    }
    echo '<h1>Vaya, algo ha salido mal</h1><p>Ya hemos sido avisados. Inténtalo más tarde.</p>';
});

// Cualquier excepción no capturada termina en el manejador
throw new RuntimeException('Fallo inesperado');

⚠️ Importante: Después de ejecutar el manejador de excepciones, el script termina. No es un mecanismo para “continuar como si nada”, sino para fallar con elegancia.

set_error_handler() y ErrorException: convertir errores en excepciones

Los errores clásicos (E_WARNING, E_NOTICE…) no se pueden capturar con try/catch. Pero puedes registrar un manejador que los convierta en ErrorException, de modo que todo tu código trabaje con un único mecanismo:

<?php
declare(strict_types=1);

set_error_handler(function (int $nivel, string $mensaje, string $fichero, int $linea): bool {
    // Respetar error_reporting y el operador @
    if (!(error_reporting() & $nivel)) {
        return false; // Deja que PHP lo trate de forma normal
    }

    throw new ErrorException($mensaje, 0, $nivel, $fichero, $linea);
});

try {
    $contenido = file_get_contents('no-existe.txt');
} catch (ErrorException $e) {
    echo "Capturado: ", $e->getMessage(), PHP_EOL;
    echo "Nivel: ", $e->getSeverity() === E_WARNING ? 'E_WARNING' : $e->getSeverity(), PHP_EOL;
}

// Capturado: file_get_contents(no-existe.txt): Failed to open stream: No such file or directory
// Nivel: E_WARNING

📘 Concepto: ErrorException es una excepción que añade el método getSeverity() para saber el nivel de error original. Es la pieza que usan casi todos los frameworks (Laravel, Symfony) para tratar los warnings como excepciones en desarrollo.

⚠️ Importante: set_error_handler() no puede interceptar errores fatales (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR). Para esos solo queda register_shutdown_function().

register_shutdown_function() y errores fatales

<?php
declare(strict_types=1);

register_shutdown_function(function (): void {
    $error = error_get_last();
    $fatales = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;

    if ($error !== null && ($error['type'] & $fatales)) {
        error_log("FATAL: {$error['message']} en {$error['file']}:{$error['line']}");
        // Aquí podrías mostrar una página de error amigable
    }
});

Consultar los manejadores activos (PHP 8.5)

PHP 8.5 añade get_error_handler() y get_exception_handler(), que devuelven el manejador registrado actualmente (o null). Son útiles en librerías que quieren envolver el manejador existente sin perderlo:

<?php
declare(strict_types=1);

$anterior = get_exception_handler();

set_exception_handler(function (Throwable $e) use ($anterior): void {
    error_log('Registrado por mi librería: ' . $e->getMessage());
    if ($anterior !== null) {
        $anterior($e);  // Delegamos en el manejador que había antes
    }
});

Antes había que llamar a set_error_handler() dos veces (registrar y restaurar) solo para averiguar cuál estaba activo.

Un fichero de arranque completo

💻 Ejemplo: bootstrap.php que reúne todo lo anterior y que incluirías al principio de tu aplicación.

<?php
declare(strict_types=1);

// bootstrap.php
$entorno = getenv('APP_ENV') ?: 'prod';
$debug = $entorno === 'dev';

error_reporting(E_ALL);
ini_set('display_errors', $debug ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/var/log/php-error.log');

// 1. Warnings y notices → ErrorException
set_error_handler(function (int $nivel, string $mensaje, string $fichero, int $linea): bool {
    if (!(error_reporting() & $nivel)) {
        return false;
    }
    throw new ErrorException($mensaje, 0, $nivel, $fichero, $linea);
});

// 2. Excepciones no capturadas → log + página de error
set_exception_handler(function (Throwable $e) use ($debug): void {
    error_log((string) $e);

    if (!headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/html; charset=utf-8');
    }

    if ($debug) {
        echo '<h1>' . htmlspecialchars($e::class) . '</h1>';
        echo '<p>' . htmlspecialchars($e->getMessage()) . '</p>';
        echo '<pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>';
    } else {
        echo '<h1>Error interno</h1><p>Inténtalo de nuevo en unos minutos.</p>';
    }
});

11. Backtraces en errores fatales (PHP 8.5)

Hasta PHP 8.4, un error fatal como “tiempo máximo de ejecución excedido” o “memoria agotada” solo decía dónde había ocurrido, pero no cómo se había llegado hasta allí. Con una función llamada desde veinte sitios distintos, eso era casi inútil.

PHP 8.5 añade la directiva fatal_error_backtraces (activada por defecto) que incluye una traza de la pila en los errores fatales, igual que las excepciones.

<?php
declare(strict_types=1);

set_time_limit(1);

function calcularSinFin(): void
{
    while (true) {
        // Bucle infinito por un bug
    }
}

function generarInforme(): void
{
    calcularSinFin();
}

generarInforme();

Con PHP 8.5 verás algo parecido a:

PHP Fatal error:  Maximum execution time of 1 second exceeded in /app/informe.php on line 8
Stack trace:
#0 /app/informe.php(15): calcularSinFin()
#1 /app/informe.php(18): generarInforme()
#2 {main}

Además, error_get_last() incluye ahora esa traza en una clave trace, de modo que tu register_shutdown_function() puede registrarla en el log.

💡 Consejo: Deja fatal_error_backtraces = On también en producción (no se muestra al usuario si display_errors = Off, solo va al log). Si te preocupa que aparezcan argumentos con datos personales, combina con zend.exception_ignore_args = On y #[\SensitiveParameter].


12. Registrar errores: logging

Un log es el “diario de a bordo” de tu aplicación. En producción es la única forma de saber qué ha fallado, cuándo y a quién.

error_log(): lo mínimo

<?php
declare(strict_types=1);

// 1. Al log configurado en php.ini (error_log)
error_log('No se pudo enviar el correo de bienvenida');

// 2. A un fichero concreto (tipo 3): hay que añadir el salto de línea
error_log(date('[Y-m-d H:i:s] ') . "Pago rechazado pedido=1042\n", 3, __DIR__ . '/var/log/pagos.log');

error_log() funciona, pero se queda corto: no tiene niveles, no añade contexto estructurado y no permite enviar los errores graves a otro sitio (correo, Slack, un servicio de monitorización).

PSR-3 y Monolog

📘 Concepto: PSR-3 es un estándar de PHP-FIG que define la interfaz Psr\Log\LoggerInterface. Si tu código depende de esa interfaz, puedes cambiar de librería de log sin tocarlo. Monolog es la implementación más usada (la emplean Laravel y Symfony).

PSR-3 define ocho niveles, de menor a mayor gravedad:

Nivel Cuándo usarlo Ejemplo
debug Información detallada para depurar “Consulta ejecutada en 3 ms”
info Eventos normales interesantes “Usuario 42 ha iniciado sesión”
notice Eventos normales pero significativos “Se ha cambiado la configuración”
warning Situaciones anómalas que no son errores “API externa lenta: 4 s”
error Errores que no detienen la aplicación “No se pudo enviar el email”
critical Fallos graves de un componente “Pasarela de pago no disponible”
alert Hay que actuar ya “La base de datos no responde”
emergency El sistema está inutilizable “Disco lleno, no se puede escribir”

Instalación con Composer:

composer require monolog/monolog

💻 Ejemplo: Configurar Monolog con un fichero rotativo y un log aparte solo para errores.

<?php
declare(strict_types=1);

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

use Monolog\Logger;
use Monolog\Level;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\IntrospectionProcessor;

$logger = new Logger('tienda');

// Un fichero por día, conservando los últimos 14, desde nivel Info
$logger->pushHandler(new RotatingFileHandler(__DIR__ . '/var/log/app.log', 14, Level::Info));

// Además, los errores graves a un fichero propio
$logger->pushHandler(new StreamHandler(__DIR__ . '/var/log/errores.log', Level::Error));

// Añade automáticamente fichero, línea y clase desde donde se registró
$logger->pushProcessor(new IntrospectionProcessor());

// Uso: mensaje + contexto (array)
$logger->info('Usuario ha iniciado sesión', ['usuario_id' => 42, 'ip' => '203.0.113.7']);
$logger->warning('Stock bajo', ['producto' => 'SKU-881', 'quedan' => 2]);

try {
    throw new RuntimeException('Pasarela de pago caída');
} catch (RuntimeException $e) {
    // Convención PSR-3: la excepción va en la clave 'exception' del contexto
    $logger->critical('No se pudo cobrar el pedido', ['pedido' => 1042, 'exception' => $e]);
}

Contenido de var/log/app-2026-10-03.log:

[2026-10-03T10:15:02.123456+02:00] tienda.INFO: Usuario ha iniciado sesión {"usuario_id":42,"ip":"203.0.113.7"} {"file":"/app/log.php","line":21,...}
[2026-10-03T10:15:02.124001+02:00] tienda.WARNING: Stock bajo {"producto":"SKU-881","quedan":2} {...}
[2026-10-03T10:15:02.124820+02:00] tienda.CRITICAL: No se pudo cobrar el pedido {"pedido":1042,"exception":"[object] (RuntimeException(code: 0): Pasarela de pago caída at /app/log.php:27)"} {...}

Placeholders en el mensaje

PSR-3 permite poner {clave} en el mensaje; con el procesador PsrLogMessageProcessor se sustituye por el valor del contexto:

<?php
use Monolog\Processor\PsrLogMessageProcessor;

$logger->pushProcessor(new PsrLogMessageProcessor());
$logger->error('No se pudo enviar el correo a {email}', ['email' => 'ana@example.com']);
// ... tienda.ERROR: No se pudo enviar el correo a ana@example.com {"email":"ana@example.com"}

Depender de la interfaz, no de la librería

<?php
declare(strict_types=1);

use Psr\Log\LoggerInterface;

final class ServicioPedidos
{
    public function __construct(private LoggerInterface $logger) {}

    public function confirmar(int $pedidoId): void
    {
        $this->logger->info('Confirmando pedido {id}', ['id' => $pedidoId]);
        // ...
    }
}

// Hoy le pasas Monolog; mañana, cualquier otro logger PSR-3
$servicio = new ServicioPedidos($logger);

💡 Consejo: Registra en el contexto datos que ayuden a reproducir el problema (ids, parámetros, duración), pero nunca contraseñas, tokens ni datos de tarjetas. Los logs suelen tener menos protección que la base de datos.


13. Buenas prácticas y errores comunes

No te tragues las excepciones

El peor anti-patrón es el catch vacío: el error ocurre, nadie se entera y el programa sigue con datos corruptos.

<?php
// ❌ MAL: el error desaparece
try {
    $pago->cobrar();
} catch (Exception $e) {
    // nada
}

// ✅ BIEN: si no sabes qué hacer, no captures. Si capturas, haz algo útil
try {
    $pago->cobrar();
} catch (PagoRechazado $e) {
    $logger->warning('Pago rechazado', ['exception' => $e]);
    $flash->error('Tu tarjeta ha sido rechazada. Prueba con otra.');
}

⚠️ Importante: Captura una excepción solo si puedes hacer algo con ella: recuperarte, reintentar, traducirla a otra más significativa o mostrar un mensaje. Si no, deja que suba hasta el manejador global.

Fail fast: falla pronto y con claridad

Comprueba los datos al principio de la función y lanza una excepción en cuanto detectes algo incorrecto. Es mucho más fácil depurar un error en el punto donde entró el dato malo que tres funciones más abajo.

<?php
declare(strict_types=1);

// ❌ Devuelve un valor "mágico" que alguien olvidará comprobar
function dividir(float $a, float $b): float|false
{
    if ($b == 0) {
        return false;
    }
    return $a / $b;
}

// ✅ Falla de inmediato con un mensaje claro
function dividirSeguro(float $a, float $b): float
{
    if ($b === 0.0) {
        throw new InvalidArgumentException('El divisor no puede ser cero');
    }
    return $a / $b;
}

Mensaje al usuario frente a mensaje en el log

  Mensaje al usuario Mensaje en el log
Destinatario Cliente, visitante Desarrolladores, administradores
Contenido Qué ha pasado y qué puede hacer Causa técnica, traza, contexto
Ejemplo “No hemos podido procesar tu pago. No se ha realizado ningún cargo.” PDOException SQLSTATE[23000] Duplicate entry... pedido=1042 usuario=17
Datos técnicos Nunca Siempre (salvo datos sensibles)

Errores comunes

Error Consecuencia Solución
display_errors = On en producción Se filtran rutas, SQL y credenciales Off y log_errors = On
catch (Exception $e) {} vacío Errores silenciosos y datos corruptos Capturar solo lo que sabes tratar
Capturar Throwable en cualquier sitio Ocultas bugs (TypeError) Solo en el manejador global o en el front controller
Perder la causa al relanzar Imposible encontrar la raíz Pasar previous: $e
Usar @ para callar warnings Errores invisibles Comprobar antes o convertir a ErrorException
return dentro de finally Anula la excepción No devolver valores en finally
Mostrar $e->getMessage() de PDO al usuario Revelas la estructura de la BD Mensaje genérico al usuario, detalle al log
Lanzar Exception genérica Imposible capturar de forma selectiva Usar SPL o excepciones propias

💡 Consejo: Una buena estrategia en una aplicación web es: excepciones de dominio (TiendaException) capturadas en los controladores para mostrar mensajes concretos, y todo lo demás capturado en un único punto (el front controller o el manejador global) que registra en el log y muestra una página 500. Lo verás en el módulo de arquitectura MVC.


14. Ejercicios

Ejercicio 1: Configuración por entorno

Crea dos ficheros, php-dev.ini y php-prod.ini, con la configuración de errores adecuada para cada entorno. Después escribe un script diagnostico.php que muestre en una tabla HTML el valor actual de error_reporting (como texto: E_ALL si coincide), display_errors, log_errors, error_log y fatal_error_backtraces. Ejecútalo con php -c php-dev.ini -S localhost:8000 y con el otro fichero, y compara.

Ejercicio 2: Validador de edades

Escribe una función validarEdad(mixed $valor): int que:

  • Lance InvalidArgumentException si el valor no es numérico.
  • Lance DomainException si es menor que 0 o mayor que 130.
  • Devuelva el entero en caso contrario.

Pruébala con ['25', 'abc', -3, 200, 42] mostrando un mensaje distinto según el tipo de excepción, usando un único try/catch dentro de un foreach.

Ejercicio 3: Pila con capacidad

Implementa una clase Pila con capacidad máxima configurable y métodos apilar(mixed $x), desapilar(): mixed y cima(): mixed. Debe lanzar OverflowException al apilar en una pila llena y UnderflowException al desapilar o consultar la cima de una pila vacía. Escribe un script que provoque ambas situaciones y las capture.

Ejercicio 4: Excepciones de dominio para una biblioteca

Crea la jerarquía BibliotecaException (abstracta) → LibroNoEncontrado (con la propiedad isbn), LibroYaPrestado (con isbn y fechaDevolucion de tipo DateTimeImmutable) y LimitePrestamosAlcanzado (con socioId y limite). Usa named constructors donde tenga sentido. Escribe una clase Biblioteca con un método prestar(string $isbn, int $socioId) que lance cada una de ellas según el caso.

Ejercicio 5: Encadenamiento

Escribe una función leerPrecios(string $ruta): array que lea un JSON con precios. Si el fichero no existe, lanza RuntimeException. Si el JSON no es válido, captura la JsonException y lanza una PreciosInvalidos (excepción propia) con la original como previous. En el script principal, captura PreciosInvalidos y muestra la cadena completa de causas con un bucle sobre getPrevious().

Ejercicio 6: Bootstrap de errores

Crea un bootstrap.php que: convierta todos los errores en ErrorException, registre las excepciones no capturadas en var/log/app.log con fecha, clase, mensaje y traza, y muestre una página 500 distinta en desarrollo (con detalles) y en producción (genérica), según la variable de entorno APP_ENV. Comprueba que un echo $variableInexistente; acaba en la página de error.

Ejercicio 7: Logging con Monolog

Instala Monolog con Composer y crea un logger que escriba en var/log/app.log (desde Info) y en var/log/criticos.log (desde Critical). Simula una pequeña tienda que registre: inicio de sesión (info), stock bajo (warning), fallo de envío de email (error) y caída de la pasarela de pago (critical, con la excepción en el contexto). Comprueba qué líneas llegan a cada fichero.


Resumen

Concepto Sintaxis / Clave
Notificar todos los errores error_reporting = E_ALL
Producción display_errors = Off, log_errors = On
Raíz de todo lo lanzable Throwable (ramas Error y Exception)
Estructura básica try { } catch (Tipo $e) { } finally { }
Captura múltiple catch (JsonException \| ValueError $e)
Catch sin variable (8.0) catch (JsonException) { }
throw como expresión (8.0) $x = $y ?? throw new Exception('...');
Errores de lógica LogicException, InvalidArgumentException, DomainException
Errores de ejecución RuntimeException, UnexpectedValueException, OutOfBoundsException
Excepción propia final class X extends RuntimeException { }
Encadenar throw new X('...', previous: $e); / $e->getPrevious()
Excepciones no capturadas set_exception_handler(fn(Throwable $e) => ...)
Errores a excepciones set_error_handler(...) + ErrorException
Errores fatales register_shutdown_function() + error_get_last()
Consultar manejadores (8.5) get_error_handler(), get_exception_handler()
Traza en fatales (8.5) fatal_error_backtraces = On
Ocultar parámetros sensibles #[\SensitiveParameter]
Log básico error_log('mensaje')
Log profesional Monolog + Psr\Log\LoggerInterface