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_errorsylog_errorspara desarrollo y producción - Entender la jerarquía
Throwable→Error/Exception - Dominar
try,catchyfinally, la captura múltiple ythrowcomo 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()yset_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 eraE_NOTICE). Y la constanteE_STRICTquedó 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_reportingdebe seguir siendoE_ALLen producción. Lo que cambias es dónde van los errores (display_errors = Off,log_errors = On), no cuáles se registran. Si bajaserror_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.iniestá usando PHP, ejecutaphp --inien la terminal o busca “Loaded Configuration File” en la salida dephpinfo(). 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 unini_set('display_errors', '1')dentro de ese mismo fichero no sirve para verlos. Para eso necesitas configurarlo enphp.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
Throwabledirectamente en tus clases. Si quieres crear tus propias excepciones, extiendeException(o una de sus subclases). Y, en general, no capturesErrorpara “seguir adelante”: unTypeErrorsignifica 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
- PHP ejecuta el bloque
trylínea a línea. - Si se lanza una excepción, abandona el
tryinmediatamente (las líneas siguientes no se ejecutan). - Busca el primer
catchcuyo tipo coincida con la excepción (o con una clase padre). - Ejecuta ese
catchy, después, elfinally. - Si ningún
catchcoincide, se ejecuta elfinallyy 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
catchde 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
returndentro definally, ese valor sustituye alreturndeltrye incluso anula una excepción pendiente. Es una trampa clásica: no pongasreturnenfinally.
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): intque devuelva$datos[$clave]convertido a entero si es numérico, y que lanceInvalidArgumentException(usando??ythrowcomo 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:
ErrorExceptiones una excepción que añade el métodogetSeverity()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 quedaregister_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.phpque 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 = Ontambién en producción (no se muestra al usuario sidisplay_errors = Off, solo va al log). Si te preocupa que aparezcan argumentos con datos personales, combina conzend.exception_ignore_args = Ony#[\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
InvalidArgumentExceptionsi el valor no es numérico. - Lance
DomainExceptionsi 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 |