Saltar al contenido
excepciones.php · devschool

Excepciones en PHP

Lección 20 de 26 · 12 min de lectura · Actualizado el

En esta lección
  1. Qué es una excepción
  2. try y catch
  3. finally: lo que se ejecuta siempre
  4. Lanzar excepciones con throw
  5. La jerarquía de errores
  6. Crear tus propias excepciones
  7. Convertir avisos en excepciones
  8. Errores en una web real
  9. Errores frecuentes
  10. Resumen

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étodoDevuelve
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 $porGrupo antes del echo. Si escribes echo '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 Error indica casi siempre un fallo de programación: pasar un array a strlen(), 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 Exception son 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 PDOException llegue 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. Un catch vacío que “se traga” el error es peor que no tenerlo: el programa sigue con datos incorrectos y nadie se entera.

Errores frecuentes

  • catch vacío: catch (Exception $e) {} oculta el problema. Como mínimo, regístralo con error_log().
  • Capturar Exception esperando atrapar un TypeError: los errores de PHP están en la rama Error, no en Exception. Si de verdad necesitas atrapar todo, usa Throwable.
  • Poner el catch general 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() devuelve null en silencio cuando el JSON está mal.
  • Perder la causa original al relanzar: pasa la excepción capturada como previous.

Resumen

ConceptoCódigo
Capturartry { ... } catch (JsonException $e) { ... }
Varios tiposcatch (TypeError | ValueError $e)
Sin variablecatch (JsonException) { ... }
Siempre se ejecutafinally { ... }
Lanzarthrow new InvalidArgumentException('mensaje');
Lanzar como expresión$x = $datos['x'] ?? throw new RuntimeException('...');
Capturarlo todocatch (Throwable $e)
Excepción propiaclass PlazasAgotadasException extends RuntimeException {}
Encadenarthrow new RuntimeException('...', previous: $e);
Avisos a excepcionesset_error_handler(fn(...) => throw new ErrorException(...))
Red de seguridadset_exception_handler(function (Throwable $e) { ... })
Registrar un errorerror_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

[PHP] ¿Qué muestra este código?
function p() {
    echo "open ";
    try {
        return "ok";
    } finally {
        echo "close ";
    }
}

echo p();

[PHP] ¿Qué ocurre al ejecutar este código?
try {
    echo intdiv(1, 0);
} catch (Exception $e) {
    echo "capturada";
}

[PHP] InvalidArgumentException extiende LogicException. ¿Qué imprime este código?
try {
    throw new InvalidArgumentException("x");
} catch (LogicException $e) {
    echo "logic";
} catch (InvalidArgumentException $e) {
    echo "inv";
}

[PHP] ¿Para qué sirve el argumento previous al lanzar una excepción?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.