$value) { $this->setOption($key, $value); } if ($this->_varExporter === null) { $this->_varExporter = fn(mixed $value): string|bool => print_r($value, true); } } public function __invoke(): void { if (count($this->outputBuffer) === 0) { return; } // Send the headers if possible and necessary if (isset($_SERVER['REQUEST_URI'])) { // Can't figure out a way to test coverage here // @codeCoverageIgnoreStart if (!headers_sent()) { header_remove('location'); header(sprintf('Content-type: %s; charset=%s', static::CONTENT_TYPE, $this->_charset)); } http_response_code($this->_httpCode); // @codeCoverageIgnoreEnd } $this->invokeCallback(); $this->outputBuffer = []; } public function getOption(string $name): mixed { $class = get_class($this); if (!property_exists($class, "_$name")) { trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING); return null; } $name = "_$name"; return $this->$name; } public function handle(ThrowableController $controller): array { $output = $this->buildOutputArray($controller); if ($this->_outputBacktrace) { $output['frames'] = $controller->getFrames(argFrameLimit: $this->_backtraceArgFrameLimit); } if ($this->_outputTime && $this->_timeFormat !== '') { $output['time'] = new \DateTimeImmutable(); } $code = 0; if ($this->_bubbles) { $code = self::BUBBLES; } if ($this->_forceExit) { $code |= self::EXIT; } if ($this->_logger !== null && (!$this->_silent || ($this->_silent && $this->_logWhenSilent))) { $code |= self::LOG; } if ($this->_forceOutputNow) { $code |= self::NOW; } if (!$this->_silent) { $code |= self::OUTPUT; } $output['code'] = $code; $output = $this->handleCallback($output); $this->outputBuffer[] = $output; return $output; } /** * If an error is triggered while logging the error would be output by PHP's * handler. This is used to attempt as best as possible to have the error be * output by Catcher. * * @internal */ public function handleError(int $code, string $message, ?string $file = null, ?int $line = null): bool { if ($code && $code & error_reporting()) { // PHP's method for getting the current exception handler is stupid, // but that's how it is... $exceptionHandler = set_exception_handler(null); set_exception_handler($exceptionHandler); // If the current exception handler happens to not be Catcher use PHP's handler // instead; this shouldn't happen but is here just in case if (!is_array($exceptionHandler) || !$exceptionHandler[0] instanceof Catcher) { return false; } $catcher = $exceptionHandler[0]; $handlers = $catcher->getHandlers(); $silent = false; foreach ($handlers as $h) { $h->setOption('logger', null); $h->setOption('varExporter', null); $silent = (!$silent) ? $h->getOption('silent') : $silent; } // If all of the handlers are silent then use PHP's handler instead; this is // because a valid use for Catcher is to have it be silent but instead have the // logger print the errors to stderr/stdout; if there is an error in the logger // then it wouldn't print. if ($silent) { return false; } $catcher->handleThrowable(new Error($message, $code, $file, $line)); } return true; } public function setOption(string $name, mixed $value): void { $class = get_class($this); if (!property_exists($class, "_$name")) { trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING); return; } if ( $name === 'httpCode' && is_int($value) && $value !== 200 && max(400, min($value, 418)) !== $value && max(421, min($value, 429)) !== $value && $value !== 431 && $value !== 451 && max(500, min($value, 511)) !== $value && // Cloudflare extensions max(520, min($value, 527)) !== $value && $value !== 530 ) { throw new RangeException('Option "httpCode" can only be a valid HTTP 200, 4XX, or 5XX code'); } $name = "_$name"; $this->$name = $value; } protected function buildOutputArray(ThrowableController $controller): array { $throwable = $controller->getThrowable(); $output = [ 'controller' => $controller, 'class' => $throwable::class, 'code' => $throwable->getCode(), 'file' => $throwable->getFile() ?: '[UNKNOWN]', 'line' => $throwable->getLine(), 'message' => $throwable->getMessage() ]; if ($throwable instanceof Error) { $output['errorType'] = $controller->getErrorType(); } if ($this->_outputPrevious) { $prevController = $controller->getPrevious(); if ($prevController) { $output['previous'] = $this->buildOutputArray($prevController); } } return $output; } protected function cleanOutputThrowable(array $outputThrowable): array { unset($outputThrowable['controller']); unset($outputThrowable['code']); if (isset($outputThrowable['previous'])) { $outputThrowable['previous'] = $this->cleanOutputThrowable($outputThrowable['previous']); } if (isset($outputThrowable['time'])) { $outputThrowable['time'] = $outputThrowable['time']->format($this->_timeFormat); } return $outputThrowable; } abstract protected function handleCallback(array $output): array; abstract protected function invokeCallback(): void; protected function log(\Throwable $throwable, string $message): void { if ($this->_logger === null) { return; } $context = [ 'exception' => $throwable ]; set_error_handler([ $this, 'handleError' ]); if ($throwable instanceof \Error) { switch ($throwable->getCode()) { case \E_NOTICE: case \E_USER_NOTICE: case \E_STRICT: $this->_logger->notice($message, $context); break; case \E_WARNING: case \E_COMPILE_WARNING: case \E_USER_WARNING: case \E_DEPRECATED: case \E_USER_DEPRECATED: $this->_logger->warning($message, $context); break; case \E_PARSE: case \E_CORE_ERROR: case \E_COMPILE_ERROR: $this->_logger->critical($message, $context); break; default: $this->_logger->error($message, $context); } } elseif ($throwable instanceof \PharException || $throwable instanceof \RuntimeException) { $this->_logger->alert($message, $context); } else { $this->_logger->critical($message, $context); } restore_error_handler(); } protected function print(string $string): void { if (strtolower(\PHP_SAPI) === 'cli' && $this->_outputToStderr) { // Can't test this in code coverage without printing errors to STDERR fwrite(\STDERR, $string); // @codeCoverageIgnore return; // @codeCoverageIgnore } echo $string; } protected function varExporter(mixed $value): string|bool { $exporter = $this->_varExporter; if ($exporter !== null) { set_error_handler([ $this, 'handleError' ]); $value = $exporter($value); restore_error_handler(); } return $value; } }