Excepciones en PHP
En esta lección
Los programas fallan: un archivo que no existe, un dato de un formulario que no tiene sentido, una base de datos que no responde. Lo importante es que fallen de forma controlada: avisando al usuario con un mensaje claro, guardando los detalles para ti y sin dejar las cosas a medias. En esta lección aprenderás qué es una excepción, cómo capturarla con try/catch/finally, cómo lanzar las tuyas con throw, cómo está organizada la jerarquía de errores de PHP 8 y cómo preparar tu web para que ningún error se le escape.
Qué es una excepción
Una excepción es un objeto que representa un error y que interrumpe el flujo normal del programa. Cuando se lanza una excepción, PHP deja de ejecutar las líneas siguientes y la va pasando hacia arriba (a la función que llamó, y a la que llamó a esa…) hasta que alguien la captura. Si nadie la captura, el script se detiene con un error fatal.
En PHP 8 muchas operaciones incorrectas lanzan excepciones en vez de devolver valores raros. Por ejemplo, dividir entre cero:
<?php
$asistentes = 0;
echo 120 / $asistentes;
echo "Esta línea no se ejecuta\n";
PHP Fatal error: Uncaught DivisionByZeroError: Division by zero in /var/www/reparto.php:3
Uncaught significa “no capturada”. Vamos a capturarla.
try y catch
El código que puede fallar va dentro de un bloque try. Si dentro se lanza una excepción, PHP salta al bloque catch cuyo tipo coincida. Si no se lanza ninguna, el catch se ignora.
<?php
function precioPorPersona(float $total, int $personas): float
{
return $total / $personas;
}
try {
echo precioPorPersona(120, 4), "\n";
echo precioPorPersona(120, 0), "\n";
echo "Tampoco se ejecuta\n";
} catch (DivisionByZeroError $e) {
echo 'No se puede repartir: ', $e->getMessage(), "\n";
}
echo "El programa sigue\n";
// 30
// No se puede repartir: Division by zero
// El programa sigue
La variable $e es el objeto de la excepción. Sus métodos más útiles:
| Método | Devuelve |
|---|---|
getMessage() | El mensaje del error |
getCode() | Un código numérico (opcional, 0 por defecto) |
getFile() / getLine() | El archivo y la línea donde se lanzó |
getTraceAsString() | La lista de llamadas que llevaron hasta el error |
getPrevious() | La excepción original, si se encadenó (lo verás más abajo) |
Varios catch
Puedes poner varios bloques catch para tratar cada tipo de error de forma distinta. PHP prueba de arriba abajo y usa el primero que encaja. Si dos tipos se tratan igual, sepáralos con |:
<?php
function leerAforo(string $texto): int
{
$datos = json_decode($texto, true, flags: JSON_THROW_ON_ERROR);
return intdiv($datos['plazas'], $datos['grupos']);
}
foreach (['{"plazas": 30, "grupos": 4}', '{"plazas": 30, "grupos": 0}', '{plazas: 30}'] as $json) {
try {
$porGrupo = leerAforo($json);
echo "Plazas por grupo: $porGrupo\n";
} catch (DivisionByZeroError | TypeError $e) {
echo 'Datos imposibles: ', $e->getMessage(), "\n";
} catch (JsonException $e) {
echo 'JSON mal escrito: ', $e->getMessage(), "\n";
}
}
// Plazas por grupo: 7
// Datos imposibles: Division by zero
// JSON mal escrito: Syntax error
Cuidado: fíjate en que el resultado se guarda en
$porGrupoantes delecho. Si escribesecho 'Plazas por grupo: ', leerAforo($json);, PHP imprime el texto antes de llamar a la función, y cuando esta lanza la excepción ese texto ya ha salido.
Si no vas a usar el objeto, desde PHP 8 puedes omitir la variable: catch (JsonException) { ... }.
finally: lo que se ejecuta siempre
El bloque finally se ejecuta siempre, haya excepción o no, e incluso si dentro del try hay un return. Sirve para “recoger”: cerrar un archivo, liberar un bloqueo, escribir en un registro.
<?php
function procesar(int $pedido): string
{
echo "Abro el pedido $pedido\n";
try {
if ($pedido < 0) {
throw new InvalidArgumentException('Número de pedido no válido');
}
return "Pedido $pedido procesado";
} finally {
echo "Cierro el pedido $pedido\n";
}
}
echo procesar(7), "\n";
try {
procesar(-1);
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
// Abro el pedido 7
// Cierro el pedido 7
// Pedido 7 procesado
// Abro el pedido -1
// Cierro el pedido -1
// Número de pedido no válido
Fíjate en que un try puede llevar catch, finally o ambos. En la segunda llamada la excepción sale de la función (no hay catch dentro), pero antes se ejecuta el finally.
Lanzar excepciones con throw
Tus funciones también deben avisar cuando reciben algo que no pueden manejar. Para eso creas un objeto de excepción y lo lanzas con throw:
<?php
class Bono
{
private int $usos;
public function __construct(int $usos)
{
if ($usos <= 0) {
throw new InvalidArgumentException("Un bono necesita al menos 1 uso, no $usos");
}
$this->usos = $usos;
}
}
try {
$bono = new Bono(0);
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
// Un bono necesita al menos 1 uso, no 0
¿Por qué lanzar en vez de imprimir un aviso o devolver false? Porque la función que detecta el error no sabe qué hacer con él. En una web habrá que mostrar un mensaje en el formulario; en un script de consola, escribir en la terminal; en una API, devolver un código 400. Lanzando la excepción, cada parte del programa decide. Además, nadie puede “olvidarse” de comprobarla, como pasa con un false que se ignora.
Desde PHP 8, throw también se puede usar como expresión, por ejemplo junto a ?? para exigir un dato:
<?php
$config = ['host' => 'localhost'];
try {
$usuario = $config['usuario'] ?? throw new RuntimeException('Falta el usuario en la configuración');
} catch (RuntimeException $e) {
echo $e->getMessage(), "\n";
}
// Falta el usuario en la configuración
La jerarquía de errores
Todas las excepciones son objetos de clases que heredan unas de otras (lo viste en Herencia e interfaces). En lo más alto está la interfaz Throwable, de la que salen dos ramas:
Throwable
├── Error Fallos del programa: los provoca PHP
│ ├── TypeError Tipo de dato incorrecto
│ │ └── ArgumentCountError Faltan argumentos
│ ├── ValueError Tipo correcto, valor imposible
│ └── ArithmeticError
│ └── DivisionByZeroError
└── Exception Situaciones que tu código debe prever
├── LogicException
│ ├── InvalidArgumentException
│ └── DomainException
├── RuntimeException
│ └── UnexpectedValueException
├── JsonException
└── PDOException
- La rama
Errorindica casi siempre un fallo de programación: pasar un array astrlen(), llamar a un método que no existe, dividir entre cero. Lo normal no es capturarlos uno a uno, sino corregir el código. - La rama
Exceptionson situaciones que pueden ocurrir aunque el código esté bien: un JSON mal formado que llega de fuera, una base de datos caída, un dato de usuario fuera de rango. Esas sí se capturan.
Como catch funciona con la herencia, catch (Exception $e) captura cualquier excepción de esa rama, y catch (Throwable $e) captura todo. Por eso, en varios catch, el más concreto va primero:
<?php
function validarEdad(mixed $edad): int
{
if (!is_int($edad)) {
throw new InvalidArgumentException('La edad debe ser un número entero');
}
if ($edad < 14) {
throw new DomainException('La actividad es para mayores de 14 años');
}
return $edad;
}
foreach ([25, 9, 'veinte'] as $edad) {
try {
$valida = validarEdad($edad);
echo "Edad válida: $valida\n";
} catch (DomainException $e) {
echo 'No permitida: ', $e->getMessage(), "\n";
} catch (LogicException $e) {
echo 'Dato incorrecto: ', $e->getMessage(), "\n";
}
}
// Edad válida: 25
// No permitida: La actividad es para mayores de 14 años
// Dato incorrecto: La edad debe ser un número entero
InvalidArgumentException no tiene catch propio, pero lo atrapa el de LogicException, que es su padre. Si hubieras puesto el de LogicException primero, también se habría tragado los DomainException.
Crear tus propias excepciones
Para errores propios de tu aplicación, crea tus clases de excepción heredando de la más adecuada. Así puedes capturarlas por separado y añadirles los datos que necesites:
<?php
class PlazasAgotadasException extends RuntimeException
{
public function __construct(
public readonly string $actividad,
public readonly int $enEspera,
) {
parent::__construct("No quedan plazas en $actividad");
}
}
function reservar(string $actividad, int $libres): string
{
if ($libres === 0) {
throw new PlazasAgotadasException($actividad, enEspera: 3);
}
return "Reserva confirmada en $actividad";
}
try {
echo reservar('Pilates', 5), "\n";
echo reservar('Bloque nocturno', 0), "\n";
} catch (PlazasAgotadasException $e) {
echo $e->getMessage(), ". Hay {$e->enEspera} personas en lista de espera.\n";
}
// Reserva confirmada en Pilates
// No quedan plazas en Bloque nocturno. Hay 3 personas en lista de espera.
El nombre de la clase ya explica el problema, y por convención termina en Exception.
Encadenar excepciones
A veces capturas una excepción técnica y quieres lanzar otra con más sentido para tu aplicación, sin perder la original. El tercer parámetro del constructor, previous, guarda la excepción que la causó:
<?php
function cargarHorario(string $json): array
{
try {
return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException('El horario no se puede leer', previous: $e);
}
}
try {
cargarHorario('{"lunes": ');
} catch (RuntimeException $e) {
echo $e->getMessage(), "\n";
echo 'Causa: ', $e->getPrevious()->getMessage(), "\n";
}
// El horario no se puede leer
// Causa: Syntax error
Convertir avisos en excepciones
No todo en PHP lanza excepciones. Muchas funciones antiguas, como file_get_contents() o fopen(), emiten un aviso (warning) y devuelven false. Si prefieres tratarlo todo con try/catch, puedes registrar un manejador que convierta esos avisos en ErrorException:
<?php
set_error_handler(function (int $nivel, string $mensaje, string $archivo, int $linea): bool {
throw new ErrorException($mensaje, 0, $nivel, $archivo, $linea);
});
try {
$texto = file_get_contents(__DIR__ . '/no-existe.txt');
} catch (ErrorException $e) {
echo 'No se pudo leer el archivo', "\n";
}
// No se pudo leer el archivo
Muchos frameworks lo hacen así por defecto. Si lo usas, pon ese código al principio de tu aplicación, en un archivo que incluyan todas las páginas.
Errores en una web real
En una web hay dos públicos: el usuario, que solo debe ver un mensaje amable, y tú, que necesitas todos los detalles para arreglarlo. Nunca muestres getMessage() de un error interno al usuario: puede revelar rutas, nombres de tablas o contraseñas de conexión.
La configuración de PHP
En el php.ini del servidor de producción:
display_errors = Off ; no mostrar errores en la página
log_errors = On ; guardarlos en el registro
error_log = /var/log/php/errores.log
En tu servidor local, al contrario, conviene display_errors = On para verlos al momento (lo configuraste en Servidor local).
Un manejador global
Las excepciones que nadie captura acaban en el manejador que registres con set_exception_handler(). Es tu última red de seguridad:
<?php
set_exception_handler(function (Throwable $e): void {
error_log($e::class . ': ' . $e->getMessage() . ' en ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo '<h1>Algo ha fallado</h1><p>Inténtalo de nuevo en unos minutos.</p>';
});
error_log() escribe en el registro de errores del servidor. http_response_code(500) indica al navegador (y a Google) que la página ha fallado, en vez de devolver un 200 con un mensaje de error.
Qué capturar y dónde
Una regla práctica: captura una excepción solo donde puedas hacer algo útil con ella.
- Al validar un formulario, captura tus excepciones de validación y muestra el mensaje junto al campo.
- Al conectar con la base de datos, deja que la
PDOExceptionllegue al manejador global: no hay nada sensato que hacer en mitad de la página. Lo verás en MySQL con PDO. - No envuelvas cada línea en un
try. Uncatchvacío que “se traga” el error es peor que no tenerlo: el programa sigue con datos incorrectos y nadie se entera.
Errores frecuentes
catchvacío:catch (Exception $e) {}oculta el problema. Como mínimo, regístralo conerror_log().- Capturar
Exceptionesperando atrapar unTypeError: los errores de PHP están en la ramaError, no enException. Si de verdad necesitas atrapar todo, usaThrowable. - Poner el
catchgeneral antes que el concreto: el primero que encaja gana y el concreto nunca se ejecuta. - Mostrar
$e->getMessage()de errores internos al usuario: revela detalles del servidor. Muestra un mensaje genérico y registra el detalle. - Usar excepciones para el flujo normal: que un usuario no tenga reservas no es una excepción, es un array vacío. Las excepciones son para lo que no debería pasar.
- Olvidar
JSON_THROW_ON_ERROR: sin esa opción,json_decode()devuelvenullen silencio cuando el JSON está mal. - Perder la causa original al relanzar: pasa la excepción capturada como
previous.
Resumen
| Concepto | Código |
|---|---|
| Capturar | try { ... } catch (JsonException $e) { ... } |
| Varios tipos | catch (TypeError | ValueError $e) |
| Sin variable | catch (JsonException) { ... } |
| Siempre se ejecuta | finally { ... } |
| Lanzar | throw new InvalidArgumentException('mensaje'); |
| Lanzar como expresión | $x = $datos['x'] ?? throw new RuntimeException('...'); |
| Capturarlo todo | catch (Throwable $e) |
| Excepción propia | class PlazasAgotadasException extends RuntimeException {} |
| Encadenar | throw new RuntimeException('...', previous: $e); |
| Avisos a excepciones | set_error_handler(fn(...) => throw new ErrorException(...)) |
| Red de seguridad | set_exception_handler(function (Throwable $e) { ... }) |
| Registrar un error | error_log($e->getMessage()) |
En la siguiente lección conectarás PHP con una base de datos MySQL, donde las excepciones te ayudarán a detectar cualquier fallo: MySQL con PDO.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.