diff --git a/.github/workflows/test-phpcpd.yml b/.github/workflows/test-phpcpd.yml
index 6d586b40e29b..a8a48e235733 100644
--- a/.github/workflows/test-phpcpd.yml
+++ b/.github/workflows/test-phpcpd.yml
@@ -43,4 +43,4 @@ jobs:
extensions: dom, mbstring
- name: Detect code duplication
- run: phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php --exclude system/Database/SQLSRV/Forge.php --exclude system/Database/MySQLi/Builder.php --exclude system/Database/OCI8/Builder.php -- app/ public/ system/
+ run: phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php --exclude system/Database/SQLSRV/Forge.php --exclude system/Database/MySQLi/Builder.php --exclude system/Database/OCI8/Builder.php --exclude system/Debug/Exceptions.php -- app/ public/ system/
diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php
index ca0713a33711..1c35bb8b997b 100644
--- a/app/Config/Exceptions.php
+++ b/app/Config/Exceptions.php
@@ -3,7 +3,10 @@
namespace Config;
use CodeIgniter\Config\BaseConfig;
+use CodeIgniter\Debug\BaseExceptionHandler;
+use CodeIgniter\Debug\ExceptionHandler;
use Psr\Log\LogLevel;
+use Throwable;
/**
* Setup how the exception handler works.
@@ -74,4 +77,28 @@ class Exceptions extends BaseConfig
* to capture logging the deprecations.
*/
public string $deprecationLogLevel = LogLevel::WARNING;
+
+ /*
+ * DEFINE THE HANDLERS USED
+ * --------------------------------------------------------------------------
+ * Given the HTTP status code, returns exception handler that
+ * should be used to deal with this error. By default, it will run CodeIgniter's
+ * default handler and display the error information in the expected format
+ * for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected
+ * response format.
+ *
+ * Custom handlers can be returned if you want to handle one or more specific
+ * error codes yourself like:
+ *
+ * if (in_array($statusCode, [400, 404, 500])) {
+ * return new \App\Libraries\MyExceptionHandler();
+ * }
+ * if ($exception instanceOf PageNotFoundException) {
+ * return new \App\Libraries\MyExceptionHandler();
+ * }
+ */
+ public function handler(int $statusCode, Throwable $exception): BaseExceptionHandler
+ {
+ return new ExceptionHandler($this);
+ }
}
diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php
new file mode 100644
index 000000000000..a4a4c72946b1
--- /dev/null
+++ b/system/Debug/BaseExceptionHandler.php
@@ -0,0 +1,233 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Debug;
+
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use Config\Exceptions as ExceptionsConfig;
+use Throwable;
+
+/**
+ * Provides common functions for exception handlers,
+ * especially around displaying the output.
+ */
+abstract class BaseExceptionHandler
+{
+ /**
+ * Config for debug exceptions.
+ */
+ protected ExceptionsConfig $config;
+
+ /**
+ * Nesting level of the output buffering mechanism
+ */
+ protected int $obLevel;
+
+ /**
+ * The path to the directory containing the
+ * cli and html error view directories.
+ */
+ protected ?string $viewPath = null;
+
+ public function __construct(ExceptionsConfig $config)
+ {
+ $this->config = $config;
+
+ $this->obLevel = ob_get_level();
+
+ if ($this->viewPath === null) {
+ $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR;
+ }
+ }
+
+ /**
+ * The main entry point into the handler.
+ *
+ * @return void
+ */
+ abstract public function handle(
+ Throwable $exception,
+ RequestInterface $request,
+ ResponseInterface $response,
+ int $statusCode,
+ int $exitCode
+ );
+
+ /**
+ * Gathers the variables that will be made available to the view.
+ */
+ protected function collectVars(Throwable $exception, int $statusCode): array
+ {
+ $trace = $exception->getTrace();
+
+ if ($this->config->sensitiveDataInTrace !== []) {
+ $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace);
+ }
+
+ return [
+ 'title' => get_class($exception),
+ 'type' => get_class($exception),
+ 'code' => $statusCode,
+ 'message' => $exception->getMessage(),
+ 'file' => $exception->getFile(),
+ 'line' => $exception->getLine(),
+ 'trace' => $trace,
+ ];
+ }
+
+ /**
+ * Mask sensitive data in the trace.
+ *
+ * @param array|object $trace
+ */
+ protected function maskSensitiveData(&$trace, array $keysToMask, string $path = '')
+ {
+ foreach ($keysToMask as $keyToMask) {
+ $explode = explode('/', $keyToMask);
+ $index = end($explode);
+
+ if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) {
+ if (is_array($trace) && array_key_exists($index, $trace)) {
+ $trace[$index] = '******************';
+ } elseif (is_object($trace) && property_exists($trace, $index) && isset($trace->{$index})) {
+ $trace->{$index} = '******************';
+ }
+ }
+ }
+
+ if (is_object($trace)) {
+ $trace = get_object_vars($trace);
+ }
+
+ if (is_array($trace)) {
+ foreach ($trace as $pathKey => $subarray) {
+ $this->maskSensitiveData($subarray, $keysToMask, $path . '/' . $pathKey);
+ }
+ }
+ }
+
+ /**
+ * Describes memory usage in real-world units. Intended for use
+ * with memory_get_usage, etc.
+ *
+ * @used-by app/Views/errors/html/error_exception.php
+ */
+ protected static function describeMemory(int $bytes): string
+ {
+ helper('number');
+
+ return number_to_size($bytes, 2);
+ }
+
+ /**
+ * Creates a syntax-highlighted version of a PHP file.
+ *
+ * @used-by app/Views/errors/html/error_exception.php
+ *
+ * @return bool|string
+ */
+ protected static function highlightFile(string $file, int $lineNumber, int $lines = 15)
+ {
+ if (empty($file) || ! is_readable($file)) {
+ return false;
+ }
+
+ // Set our highlight colors:
+ if (function_exists('ini_set')) {
+ ini_set('highlight.comment', '#767a7e; font-style: italic');
+ ini_set('highlight.default', '#c7c7c7');
+ ini_set('highlight.html', '#06B');
+ ini_set('highlight.keyword', '#f1ce61;');
+ ini_set('highlight.string', '#869d6a');
+ }
+
+ try {
+ $source = file_get_contents($file);
+ } catch (Throwable $e) {
+ return false;
+ }
+
+ $source = str_replace(["\r\n", "\r"], "\n", $source);
+ $source = explode("\n", highlight_string($source, true));
+ $source = str_replace('
', "\n", $source[1]);
+ $source = explode("\n", str_replace("\r\n", "\n", $source));
+
+ // Get just the part to show
+ $start = max($lineNumber - (int) round($lines / 2), 0);
+
+ // Get just the lines we need to display, while keeping line numbers...
+ $source = array_splice($source, $start, $lines, true);
+
+ // Used to format the line number in the source
+ $format = '% ' . strlen((string) ($start + $lines)) . 'd';
+
+ $out = '';
+ // Because the highlighting may have an uneven number
+ // of open and close span tags on one line, we need
+ // to ensure we can close them all to get the lines
+ // showing correctly.
+ $spans = 1;
+
+ foreach ($source as $n => $row) {
+ $spans += substr_count($row, ']+>#', $row, $tags);
+
+ $out .= sprintf(
+ "{$format} %s\n%s",
+ $n + $start + 1,
+ strip_tags($row),
+ implode('', $tags[0])
+ );
+ } else {
+ $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n";
+ }
+ }
+
+ if ($spans > 0) {
+ $out .= str_repeat('', $spans);
+ }
+
+ return '' . $out . '
';
+ }
+
+ /**
+ * Given an exception and status code will display the error to the client.
+ *
+ * @param string|null $viewFile
+ */
+ protected function render(Throwable $exception, int $statusCode, $viewFile = null): void
+ {
+ if (empty($viewFile) || ! is_file($viewFile)) {
+ echo 'The error view files were not found. Cannot render exception trace.';
+
+ exit(1);
+ }
+
+ if (ob_get_level() > $this->obLevel + 1) {
+ ob_end_clean();
+ }
+
+ echo(function () use ($exception, $statusCode, $viewFile): string {
+ $vars = $this->collectVars($exception, $statusCode);
+ extract($vars, EXTR_SKIP);
+
+ // CLI error views output to STDERR/STDOUT, so ob_start() does not work.
+ ob_start();
+ include $viewFile;
+
+ return ob_get_clean();
+ })();
+ }
+}
diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php
new file mode 100644
index 000000000000..5428f38994e6
--- /dev/null
+++ b/system/Debug/ExceptionHandler.php
@@ -0,0 +1,149 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Debug;
+
+use CodeIgniter\API\ResponseTrait;
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\Exceptions\HTTPException;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use Config\Paths;
+use Throwable;
+
+final class ExceptionHandler extends BaseExceptionHandler
+{
+ use ResponseTrait;
+
+ /**
+ * ResponseTrait needs this.
+ */
+ private ?RequestInterface $request = null;
+
+ /**
+ * ResponseTrait needs this.
+ */
+ private ?ResponseInterface $response = null;
+
+ /**
+ * Determines the correct way to display the error.
+ *
+ * @return void
+ */
+ public function handle(
+ Throwable $exception,
+ RequestInterface $request,
+ ResponseInterface $response,
+ int $statusCode,
+ int $exitCode
+ ) {
+ // ResponseTrait needs these properties.
+ $this->request = $request;
+ $this->response = $response;
+
+ if ($request instanceof IncomingRequest) {
+ try {
+ $response->setStatusCode($statusCode);
+ } catch (HTTPException $e) {
+ // Workaround for invalid HTTP status code.
+ $statusCode = 500;
+ $response->setStatusCode($statusCode);
+ }
+
+ if (! headers_sent()) {
+ header(
+ sprintf(
+ 'HTTP/%s %s %s',
+ $request->getProtocolVersion(),
+ $response->getStatusCode(),
+ $response->getReasonPhrase()
+ ),
+ true,
+ $statusCode
+ );
+ }
+
+ if (strpos($request->getHeaderLine('accept'), 'text/html') === false) {
+ $data = (ENVIRONMENT === 'development' || ENVIRONMENT === 'testing')
+ ? $this->collectVars($exception, $statusCode)
+ : '';
+
+ $this->respond($data, $statusCode)->send();
+
+ if (ENVIRONMENT !== 'testing') {
+ // @codeCoverageIgnoreStart
+ exit($exitCode);
+ // @codeCoverageIgnoreEnd
+ }
+
+ return;
+ }
+ }
+
+ // Determine possible directories of error views
+ $addPath = ($request instanceof IncomingRequest ? 'html' : 'cli') . DIRECTORY_SEPARATOR;
+ $path = $this->viewPath . $addPath;
+ $altPath = rtrim((new Paths())->viewDirectory, '\\/ ')
+ . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR . $addPath;
+
+ // Determine the views
+ $view = $this->determineView($exception, $path);
+ $altView = $this->determineView($exception, $altPath);
+
+ // Check if the view exists
+ $viewFile = null;
+ if (is_file($path . $view)) {
+ $viewFile = $path . $view;
+ } elseif (is_file($altPath . $altView)) {
+ $viewFile = $altPath . $altView;
+ }
+
+ // Displays the HTML or CLI error code.
+ $this->render($exception, $statusCode, $viewFile);
+
+ if (ENVIRONMENT !== 'testing') {
+ // @codeCoverageIgnoreStart
+ exit($exitCode);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Determines the view to display based on the exception thrown,
+ * whether an HTTP or CLI request, etc.
+ *
+ * @return string The filename of the view file to use
+ */
+ protected function determineView(Throwable $exception, string $templatePath): string
+ {
+ // Production environments should have a custom exception file.
+ $view = 'production.php';
+
+ if (str_ireplace(['off', 'none', 'no', 'false', 'null'], '', ini_get('display_errors'))) {
+ $view = 'error_exception.php';
+ }
+
+ // 404 Errors
+ if ($exception instanceof PageNotFoundException) {
+ return 'error_404.php';
+ }
+
+ $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR;
+
+ // Allow for custom views based upon the status code
+ if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) {
+ return 'error_' . $exception->getCode() . '.php';
+ }
+
+ return $view;
+ }
+}
diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php
index a4671a86e8d5..0479e6415436 100644
--- a/system/Debug/Exceptions.php
+++ b/system/Debug/Exceptions.php
@@ -15,9 +15,7 @@
use CodeIgniter\Exceptions\HasExitCodeInterface;
use CodeIgniter\Exceptions\HTTPExceptionInterface;
use CodeIgniter\Exceptions\PageNotFoundException;
-use CodeIgniter\HTTP\CLIRequest;
-use CodeIgniter\HTTP\Exceptions\HTTPException;
-use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Exceptions as ExceptionsConfig;
use Config\Paths;
@@ -36,6 +34,8 @@ class Exceptions
* Nesting level of the output buffering mechanism
*
* @var int
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
public $ob_level;
@@ -44,6 +44,8 @@ class Exceptions
* cli and html error view directories.
*
* @var string
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
protected $viewPath;
@@ -57,7 +59,7 @@ class Exceptions
/**
* The request.
*
- * @var CLIRequest|IncomingRequest
+ * @var RequestInterface
*/
protected $request;
@@ -69,12 +71,14 @@ class Exceptions
protected $response;
/**
- * @param CLIRequest|IncomingRequest $request
+ * @param RequestInterface $request
*/
public function __construct(ExceptionsConfig $config, $request, ResponseInterface $response)
{
+ // For backward compatibility
$this->ob_level = ob_get_level();
$this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR;
+
$this->config = $config;
$this->request = $request;
$this->response = $response;
@@ -106,8 +110,6 @@ public function initialize()
* Catches any uncaught errors and exceptions, including most Fatal errors
* (Yay PHP7!). Will log the error, display it if display_errors is on,
* and fire an event that allows custom actions to be taken at this point.
- *
- * @codeCoverageIgnore
*/
public function exceptionHandler(Throwable $exception)
{
@@ -122,29 +124,14 @@ public function exceptionHandler(Throwable $exception)
]);
}
- if (! is_cli()) {
- try {
- $this->response->setStatusCode($statusCode);
- } catch (HTTPException $e) {
- // Workaround for invalid HTTP status code.
- $statusCode = 500;
- $this->response->setStatusCode($statusCode);
- }
-
- if (! headers_sent()) {
- header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode);
- }
-
- if (strpos($this->request->getHeaderLine('accept'), 'text/html') === false) {
- $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send();
-
- exit($exitCode);
- }
+ // For upgraded users who did not update the config file.
+ if (! method_exists($this->config, 'handler')) {
+ $handler = new ExceptionHandler($this->config);
+ } else {
+ $handler = $this->config->handler($statusCode, $exception);
}
- $this->render($exception, $statusCode);
-
- exit($exitCode);
+ $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode);
}
/**
@@ -195,6 +182,8 @@ public function shutdownHandler()
* whether an HTTP or CLI request, etc.
*
* @return string The path and filename of the view file to use
+ *
+ * @deprecated No longer used. Moved to ExceptionHandler.
*/
protected function determineView(Throwable $exception, string $templatePath): string
{
@@ -221,6 +210,8 @@ protected function determineView(Throwable $exception, string $templatePath): st
/**
* Given an exception and status code will display the error to the client.
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
protected function render(Throwable $exception, int $statusCode)
{
@@ -265,6 +256,8 @@ protected function render(Throwable $exception, int $statusCode)
/**
* Gathers the variables that will be made available to the view.
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
protected function collectVars(Throwable $exception, int $statusCode): array
{
@@ -289,6 +282,8 @@ protected function collectVars(Throwable $exception, int $statusCode): array
* Mask sensitive data in the trace.
*
* @param array|object $trace
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
protected function maskSensitiveData(&$trace, array $keysToMask, string $path = '')
{
@@ -401,6 +396,8 @@ public static function cleanPath(string $file): string
/**
* Describes memory usage in real-world units. Intended for use
* with memory_get_usage, etc.
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
public static function describeMemory(int $bytes): string
{
@@ -419,6 +416,8 @@ public static function describeMemory(int $bytes): string
* Creates a syntax-highlighted version of a PHP file.
*
* @return bool|string
+ *
+ * @deprecated No longer used. Moved to BaseExceptionHandler.
*/
public static function highlightFile(string $file, int $lineNumber, int $lines = 15)
{
diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php
new file mode 100644
index 000000000000..fd7efdd5f686
--- /dev/null
+++ b/tests/system/Debug/ExceptionHandlerTest.php
@@ -0,0 +1,141 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Debug;
+
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\Test\CIUnitTestCase;
+use CodeIgniter\Test\StreamFilterTrait;
+use Config\Exceptions as ExceptionsConfig;
+use Config\Services;
+use RuntimeException;
+
+/**
+ * @internal
+ */
+final class ExceptionHandlerTest extends CIUnitTestCase
+{
+ use StreamFilterTrait;
+
+ private ExceptionHandler $handler;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->handler = new ExceptionHandler(new ExceptionsConfig());
+ }
+
+ public function testDetermineViewsPageNotFoundException(): void
+ {
+ $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView');
+
+ $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar');
+ $templatePath = APPPATH . 'Views/errors/html';
+ $viewFile = $determineView($exception, $templatePath);
+
+ $this->assertSame('error_404.php', $viewFile);
+ }
+
+ public function testDetermineViewsRuntimeException(): void
+ {
+ $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView');
+
+ $exception = new RuntimeException('Exception');
+ $templatePath = APPPATH . 'Views/errors/html';
+ $viewFile = $determineView($exception, $templatePath);
+
+ $this->assertSame('error_exception.php', $viewFile);
+ }
+
+ public function testDetermineViewsRuntimeExceptionCode404(): void
+ {
+ $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView');
+
+ $exception = new RuntimeException('foo', 404);
+ $templatePath = APPPATH . 'Views/errors/html';
+ $viewFile = $determineView($exception, $templatePath);
+
+ $this->assertSame('error_404.php', $viewFile);
+ }
+
+ public function testCollectVars(): void
+ {
+ $collectVars = $this->getPrivateMethodInvoker($this->handler, 'collectVars');
+
+ $vars = $collectVars(new RuntimeException('This.'), 404);
+
+ $this->assertIsArray($vars);
+ $this->assertCount(7, $vars);
+
+ foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) {
+ $this->assertArrayHasKey($key, $vars);
+ }
+ }
+
+ public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void
+ {
+ $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar');
+
+ $request = Services::incomingrequest(null, false);
+ $response = Services::response(null, false);
+ $response->pretend();
+
+ ob_start();
+ $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR);
+ $output = ob_get_clean();
+
+ $json = json_decode($output);
+ $this->assertSame(PageNotFoundException::class, $json->title);
+ $this->assertSame(PageNotFoundException::class, $json->type);
+ $this->assertSame(404, $json->code);
+ $this->assertSame('Controller or its method is not found: Foo::bar', $json->message);
+ }
+
+ public function testHandleWebPageNotFoundExceptionAcceptHTML(): void
+ {
+ $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar');
+
+ $request = Services::incomingrequest(null, false);
+ $request->setHeader('accept', 'text/html');
+ $response = Services::response(null, false);
+ $response->pretend();
+
+ ob_start();
+ $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR);
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString('404 - Page Not Found', $output);
+ }
+
+ public function testHandleCLIPageNotFoundException(): void
+ {
+ $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar');
+
+ $request = Services::clirequest(null, false);
+ $request->setHeader('accept', 'text/html');
+ $response = Services::response(null, false);
+ $response->pretend();
+
+ $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR);
+
+ $this->assertStringContainsString(
+ 'ERROR: 404',
+ $this->getStreamFilterBuffer()
+ );
+ $this->assertStringContainsString(
+ 'Controller or its method is not found: Foo::bar',
+ $this->getStreamFilterBuffer()
+ );
+
+ $this->resetStreamFilterBuffer();
+ }
+}
diff --git a/user_guide_src/source/changelogs/v4.3.0.rst b/user_guide_src/source/changelogs/v4.3.0.rst
index 683dff2c8c90..d5022186c9f5 100644
--- a/user_guide_src/source/changelogs/v4.3.0.rst
+++ b/user_guide_src/source/changelogs/v4.3.0.rst
@@ -204,6 +204,7 @@ Helpers and Functions
Error Handling
==============
+- Now you can use :ref:`custom-exception-handlers`.
- You can now log deprecation errors instead of throwing them. See :ref:`logging_deprecation_errors` for details.
Others
@@ -248,6 +249,7 @@ Deprecations
- ``CodeIgniter::$path`` and ``CodeIgniter::setPath()`` are deprecated. No longer used.
- The public property ``IncomingRequest::$uri`` is deprecated. It will be protected. Use ``IncomingRequest::getUri()`` instead.
- The public property ``IncomingRequest::$config`` is deprecated. It will be protected.
+- Many methods and properties in ``CodeIgniter\Debug\Exceptions`` are deprecated. Because these methods have been moved to ``BaseExceptionHandler`` or ``ExceptionHandler``.
Bugs Fixed
**********
diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst
index 152d6f74a7bc..da74a6214ea2 100644
--- a/user_guide_src/source/general/errors.rst
+++ b/user_guide_src/source/general/errors.rst
@@ -161,3 +161,39 @@ After that, subsequent deprecations will be logged instead of thrown.
This feature also works with user deprecations:
.. literalinclude:: errors/014.php
+
+.. _custom-exception-handlers:
+
+Custom Exception Handlers
+=========================
+
+.. versionadded:: 4.3.0
+
+If you need more control over how exceptions are displayed you can now define your own handlers and
+specify when they apply.
+
+Defining the New Handler
+------------------------
+
+The first step is to create a new class which must extend ``CodeIgniter\Debug\BaseExceptionHandler``.
+This class includes a number of utility methods that are used by the default exception handler.
+The new handler must implement a single method: ``handle()``:
+
+.. literalinclude:: errors/015.php
+
+This example defines the minimum amount of code typically needed - display a view and exit with the proper
+exit code. However, the ``BaseExceptionHandler`` provides a number of other helper functions and objects.
+
+Configuring the New Handler
+---------------------------
+
+Telling CodeIgniter to use your new exception handler class is done in the **app/Config/Exceptions.php**
+configuration file's ``handler()`` method:
+
+.. literalinclude:: errors/016.php
+
+You can use any logic your application needs to determine whether it should handle the exception, but the
+two most common are checking on the HTTP status code or the type of exception. If your class should handle
+it then return a new instance of that class:
+
+.. literalinclude:: errors/017.php
diff --git a/user_guide_src/source/general/errors/015.php b/user_guide_src/source/general/errors/015.php
new file mode 100644
index 000000000000..633e78f4bde4
--- /dev/null
+++ b/user_guide_src/source/general/errors/015.php
@@ -0,0 +1,26 @@
+render($exception, $statusCode, $this->viewPath . "error_{$statusCode}.php");
+
+ exit($exitCode);
+ }
+}
diff --git a/user_guide_src/source/general/errors/016.php b/user_guide_src/source/general/errors/016.php
new file mode 100644
index 000000000000..85dc8f99d627
--- /dev/null
+++ b/user_guide_src/source/general/errors/016.php
@@ -0,0 +1,17 @@
+