Lección 52 de 75 12 min de lectura

Manejo de Errores en Producción

En producción, los errores no deben mostrarse al usuario pero sí registrarse para poder diagnosticar problemas. Aprende a configurar handlers globales, logging y páginas de error amigables.

Configuración para producción

La regla principal: nunca mostrar errores al usuario. Los mensajes de error pueden revelar información sensible sobre tu aplicación.

PHP
<?php

declare(strict_types=1);

// bootstrap.php o config.php

$esProduccion = getenv('APP_ENV') === 'production';

if ($esProduccion) {
    // PRODUCCIÓN: ocultar errores, registrar en log
    error_reporting(E_ALL);
    ini_set('display_errors', '0');
    ini_set('display_startup_errors', '0');
    ini_set('log_errors', '1');
    ini_set('error_log', '/var/log/php/app-errors.log');
} else {
    // DESARROLLO: mostrar todo
    error_reporting(E_ALL);
    ini_set('display_errors', '1');
    ini_set('display_startup_errors', '1');
}

Handler global de excepciones

set_exception_handler captura cualquier excepción no manejada antes de que PHP muestre el error al usuario:

PHP
<?php

declare(strict_types=1);

set_exception_handler(function (Throwable $e): void {
    // 1. Registrar el error completo en el log
    $mensaje = sprintf(
        "[%s] %s: %s en %s:%d\nStack trace:\n%s",
        date('Y-m-d H:i:s'),
        get_class($e),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString()
    );
    error_log($mensaje);

    // 2. Mostrar página de error genérica al usuario
    http_response_code(500);

    if (getenv('APP_ENV') === 'production') {
        // Página amigable sin detalles técnicos
        include __DIR__ . '/templates/error-500.html';
    } else {
        // En desarrollo, mostrar el error completo
        echo "<h1>Error: " . htmlspecialchars($e->getMessage()) . "</h1>";
        echo "<pre>" . htmlspecialchars($e->getTraceAsString()) . "</pre>";
    }

    exit(1);
});

// Ahora cualquier excepción no capturada pasará por este handler
throw new RuntimeException('Error de ejemplo');

Handler global de errores

set_error_handler captura errores tradicionales (warnings, notices) y los convierte en excepciones para manejarlos uniformemente:

PHP
<?php

declare(strict_types=1);

set_error_handler(function (
    int $nivel,
    string $mensaje,
    string $archivo,
    int $linea
): bool {
    // Respetar el nivel de error_reporting
    if (!(error_reporting() & $nivel)) {
        return false;
    }

    // Convertir el error en excepción
    throw new ErrorException($mensaje, 0, $nivel, $archivo, $linea);
});

// Ahora los warnings también se capturan como excepciones
try {
    $resultado = 1 / 0; // Antes era warning, ahora es excepción
} catch (ErrorException $e) {
    echo 'Capturado: ' . $e->getMessage();
}

Función de shutdown

register_shutdown_function se ejecuta cuando el script termina, incluyendo errores fatales que no pueden capturarse de otra forma:

PHP
<?php

declare(strict_types=1);

register_shutdown_function(function (): void {
    $error = error_get_last();

    if ($error === null) {
        return; // No hubo error
    }

    // Solo nos interesan errores fatales
    $fatales = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];

    if (!in_array($error['type'], $fatales, true)) {
        return;
    }

    // Registrar el error fatal
    $mensaje = sprintf(
        "[FATAL] %s en %s:%d",
        $error['message'],
        $error['file'],
        $error['line']
    );
    error_log($mensaje);

    // Si no se han enviado headers, mostrar página de error
    if (!headers_sent()) {
        http_response_code(500);
        include __DIR__ . '/templates/error-500.html';
    }
});

Clase ErrorHandler completa

Combinar todo en una clase reutilizable:

PHP
<?php

declare(strict_types=1);

class ErrorHandler
{
    private bool $debug;
    private string $logPath;

    public function __construct(bool $debug = false, string $logPath = '')
    {
        $this->debug = $debug;
        $this->logPath = $logPath ?: sys_get_temp_dir() . '/app.log';
    }

    public function register(): void
    {
        set_error_handler([$this, 'handleError']);
        set_exception_handler([$this, 'handleException']);
        register_shutdown_function([$this, 'handleShutdown']);
    }

    public function handleError(
        int $nivel,
        string $mensaje,
        string $archivo,
        int $linea
    ): bool {
        if (!(error_reporting() & $nivel)) {
            return false;
        }

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

    public function handleException(Throwable $e): void
    {
        $this->log($e);
        $this->render($e);
        exit(1);
    }

    public function handleShutdown(): void
    {
        $error = error_get_last();

        if ($error === null) {
            return;
        }

        $fatales = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];

        if (in_array($error['type'], $fatales, true)) {
            $e = new ErrorException(
                $error['message'],
                0,
                $error['type'],
                $error['file'],
                $error['line']
            );
            $this->handleException($e);
        }
    }

    private function log(Throwable $e): void
    {
        $mensaje = sprintf(
            "[%s] %s: %s en %s:%d\n%s\n\n",
            date('Y-m-d H:i:s'),
            get_class($e),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine(),
            $e->getTraceAsString()
        );

        file_put_contents($this->logPath, $mensaje, FILE_APPEND | LOCK_EX);
    }

    private function render(Throwable $e): void
    {
        if (!headers_sent()) {
            http_response_code(500);
            header('Content-Type: text/html; charset=utf-8');
        }

        if ($this->debug) {
            $this->renderDebug($e);
        } else {
            $this->renderProduction();
        }
    }

    private function renderDebug(Throwable $e): void
    {
        echo '<!DOCTYPE html><html><head><title>Error</title></head><body>';
        echo '<h1>' . htmlspecialchars(get_class($e)) . '</h1>';
        echo '<p>' . htmlspecialchars($e->getMessage()) . '</p>';
        echo '<p><strong>Archivo:</strong> ' . $e->getFile() . ':' . $e->getLine() . '</p>';
        echo '<pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>';
        echo '</body></html>';
    }

    private function renderProduction(): void
    {
        echo '<!DOCTYPE html><html><head><title>Error</title></head><body>';
        echo '<h1>Ha ocurrido un error</h1>';
        echo '<p>Lo sentimos, algo salió mal. Por favor, inténtalo de nuevo más tarde.</p>';
        echo '</body></html>';
    }
}

// Uso
$handler = new ErrorHandler(
    debug: getenv('APP_ENV') !== 'production',
    logPath: '/var/log/app/errors.log'
);
$handler->register();

Logging con error_log

PHP tiene funciones nativas para registrar errores:

PHP
<?php

declare(strict_types=1);

// Escribir al log configurado en php.ini
error_log('Mensaje de error');

// Escribir a un archivo específico
error_log('Error en pago', 3, '/var/log/pagos.log');

// Enviar por email (tipo 1) - poco usado
error_log('Error crítico', 1, 'admin@example.com');

// Función helper para logging estructurado
function logError(string $nivel, string $mensaje, array $contexto = []): void
{
    $linea = sprintf(
        "[%s] [%s] %s %s\n",
        date('Y-m-d H:i:s'),
        strtoupper($nivel),
        $mensaje,
        $contexto !== [] ? json_encode($contexto) : ''
    );

    error_log($linea, 3, '/var/log/app/app.log');
}

// Uso
logError('error', 'Pago fallido', [
    'usuario_id' => 123,
    'monto' => 99.99,
    'codigo' => 'CARD_DECLINED'
]);

// Resultado en el log:
// [2024-01-15 10:30:45] [ERROR] Pago fallido {"usuario_id":123,"monto":99.99,"codigo":"CARD_DECLINED"}

Manejador para APIs

Para APIs, los errores deben retornarse como JSON, no HTML:

PHP
<?php

declare(strict_types=1);

class ApiErrorHandler
{
    private bool $debug;

    public function __construct(bool $debug = false)
    {
        $this->debug = $debug;
    }

    public function handle(Throwable $e): void
    {
        // Determinar código HTTP según el tipo de excepción
        $statusCode = $this->getStatusCode($e);

        // Log del error
        error_log(sprintf(
            "[API Error] %s: %s en %s:%d",
            get_class($e),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        ));

        // Respuesta JSON
        http_response_code($statusCode);
        header('Content-Type: application/json');

        $respuesta = [
            'error' => true,
            'message' => $this->getMensajePublico($e),
        ];

        if ($this->debug) {
            $respuesta['debug'] = [
                'exception' => get_class($e),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => explode("\n", $e->getTraceAsString())
            ];
        }

        echo json_encode($respuesta, JSON_PRETTY_PRINT);
        exit(1);
    }

    private function getStatusCode(Throwable $e): int
    {
        return match (true) {
            $e instanceof InvalidArgumentException => 400,
            $e instanceof RuntimeException => 500,
            method_exists($e, 'getStatusCode') => $e->getStatusCode(),
            default => 500
        };
    }

    private function getMensajePublico(Throwable $e): string
    {
        // En producción, solo mostrar mensajes "seguros"
        if (!$this->debug && $this->getStatusCode($e) === 500) {
            return 'Ha ocurrido un error interno';
        }

        return $e->getMessage();
    }
}

// Registrar el handler
$apiHandler = new ApiErrorHandler(debug: getenv('APP_ENV') !== 'production');
set_exception_handler([$apiHandler, 'handle']);
Seguridad en producción

Nunca expongas stack traces, rutas de archivos ni detalles internos en producción. Esta información puede ayudar a atacantes a explotar vulnerabilidades.

Ejercicios

Ejercicio 1: Handler con niveles de log

Crea un ErrorHandler que registre errores en diferentes archivos según su gravedad: critical.log para errores fatales, error.log para excepciones, warning.log para warnings.

Ver solución
<?php

declare(strict_types=1);

class MultiLogErrorHandler
{
    private string $logDir;

    public function __construct(string $logDir)
    {
        $this->logDir = rtrim($logDir, '/');
    }

    public function register(): void
    {
        set_error_handler([$this, 'handleError']);
        set_exception_handler([$this, 'handleException']);
        register_shutdown_function([$this, 'handleShutdown']);
    }

    public function handleError(int $nivel, string $mensaje, string $archivo, int $linea): bool
    {
        if (!(error_reporting() & $nivel)) {
            return false;
        }

        $esWarning = in_array($nivel, [E_WARNING, E_USER_WARNING, E_NOTICE, E_USER_NOTICE]);
        $logFile = $esWarning ? 'warning.log' : 'error.log';

        $this->log($logFile, $mensaje, $archivo, $linea);
        return true;
    }

    public function handleException(Throwable $e): void
    {
        $this->log('error.log', $e->getMessage(), $e->getFile(), $e->getLine());
    }

    public function handleShutdown(): void
    {
        $error = error_get_last();
        if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR])) {
            $this->log('critical.log', $error['message'], $error['file'], $error['line']);
        }
    }

    private function log(string $archivo, string $mensaje, string $file, int $linea): void
    {
        $entrada = sprintf(
            "[%s] %s en %s:%d\n",
            date('Y-m-d H:i:s'),
            $mensaje,
            $file,
            $linea
        );
        file_put_contents($this->logDir . '/' . $archivo, $entrada, FILE_APPEND);
    }
}

// Uso
$handler = new MultiLogErrorHandler('/var/log/app');
$handler->register();

Ejercicio 2: Notificación de errores críticos

Extiende el ErrorHandler para que cuando ocurra un error fatal, además de registrarlo, envíe una notificación (simula el envío guardando en un archivo notifications.log).

Ver solución
<?php

declare(strict_types=1);

class NotifyingErrorHandler
{
    private string $logPath;
    private string $notifyPath;

    public function __construct(string $logPath, string $notifyPath)
    {
        $this->logPath = $logPath;
        $this->notifyPath = $notifyPath;
    }

    public function register(): void
    {
        set_exception_handler([$this, 'handleException']);
        register_shutdown_function([$this, 'handleShutdown']);
    }

    public function handleException(Throwable $e): void
    {
        $this->log($e->getMessage(), $e->getFile(), $e->getLine());

        // Notificar errores críticos
        if ($e instanceof Error) {
            $this->notificar($e);
        }
    }

    public function handleShutdown(): void
    {
        $error = error_get_last();
        if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR])) {
            $this->log($error['message'], $error['file'], $error['line']);
            $this->notificar(new ErrorException(
                $error['message'], 0, $error['type'], $error['file'], $error['line']
            ));
        }
    }

    private function log(string $mensaje, string $archivo, int $linea): void
    {
        $entrada = sprintf("[%s] %s en %s:%d\n", date('Y-m-d H:i:s'), $mensaje, $archivo, $linea);
        file_put_contents($this->logPath, $entrada, FILE_APPEND);
    }

    private function notificar(Throwable $e): void
    {
        $notificacion = sprintf(
            "[%s] ERROR CRÍTICO\nMensaje: %s\nArchivo: %s:%d\n---\n",
            date('Y-m-d H:i:s'),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        );
        file_put_contents($this->notifyPath, $notificacion, FILE_APPEND);
    }
}

// Uso
$handler = new NotifyingErrorHandler('/var/log/app.log', '/var/log/notifications.log');
$handler->register();

Ejercicio 3: Rate limiting de logs

Implementa un sistema que evite registrar el mismo error más de N veces por minuto para evitar llenar los logs. Usa un archivo temporal para trackear los errores recientes.

Ver solución
<?php

declare(strict_types=1);

class RateLimitedLogger
{
    private string $logPath;
    private string $trackPath;
    private int $maxPorMinuto;

    public function __construct(string $logPath, int $maxPorMinuto = 5)
    {
        $this->logPath = $logPath;
        $this->trackPath = sys_get_temp_dir() . '/error_tracker.json';
        $this->maxPorMinuto = $maxPorMinuto;
    }

    public function log(Throwable $e): void
    {
        $hash = md5($e->getMessage() . $e->getFile() . $e->getLine());

        if (!$this->puedeRegistrar($hash)) {
            return; // Rate limited, ignorar
        }

        $entrada = sprintf(
            "[%s] %s: %s en %s:%d\n",
            date('Y-m-d H:i:s'),
            get_class($e),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        );
        file_put_contents($this->logPath, $entrada, FILE_APPEND);
    }

    private function puedeRegistrar(string $hash): bool
    {
        $tracker = $this->cargarTracker();
        $ahora = time();
        $hace1min = $ahora - 60;

        // Limpiar entradas antiguas
        foreach ($tracker as $key => $datos) {
            $tracker[$key] = array_filter($datos, fn($t) => $t > $hace1min);
            if (empty($tracker[$key])) {
                unset($tracker[$key]);
            }
        }

        // Verificar límite
        $ocurrencias = $tracker[$hash] ?? [];
        if (count($ocurrencias) >= $this->maxPorMinuto) {
            $this->guardarTracker($tracker);
            return false;
        }

        // Registrar ocurrencia
        $tracker[$hash][] = $ahora;
        $this->guardarTracker($tracker);
        return true;
    }

    private function cargarTracker(): array
    {
        if (!file_exists($this->trackPath)) {
            return [];
        }
        return json_decode(file_get_contents($this->trackPath), true) ?? [];
    }

    private function guardarTracker(array $tracker): void
    {
        file_put_contents($this->trackPath, json_encode($tracker));
    }
}

// Uso
$logger = new RateLimitedLogger('/var/log/app.log', 3);

set_exception_handler(function (Throwable $e) use ($logger): void {
    $logger->log($e);
    echo "Error registrado (si no excede el límite)";
});

¿Te está gustando el curso?

Tenemos cursos premium con proyectos reales y soporte.

Descubrir cursos premium