We’ve all been there – a production server goes down due to an uncaught error in our application. Debugging becomes a nightmare as we sift through layers of abstraction, trying to pinpoint where things went wrong. Error handling in PHP can be a pain point for even the most seasoned developers.
You’ll build on the new features introduced in PHP 8.1 to streamline error handling and make your code more robust. By the end of this tutorial, you’ll have implemented custom error handling using attributes and will know how to leverage Laravel’s built-in error handling mechanisms, including logging errors through a PSR-3 logger.
Introduction to PHP 8.1 Error Handling Features
Modern PHP gives you several tools that improve error handling in PHP applications. One of the most useful is strict typing mode, which has been available since PHP 7.0 and lets developers opt in to having type mismatches reported as errors instead of being silently coerced.
<?php
declare(strict_types=1);
function addNumbers(int $a, int $b): int
{
return $a + $b;
}
try {
echo addNumbers('42', 8) . PHP_EOL;
} catch (TypeError $e) {
echo "Error: {$e->getMessage()}" . PHP_EOL;
}
In this example, passing the string '42' to a parameter declared as int triggers a TypeError. The strict mode is enabled at the top of the script using declare(strict_types=1);. Without it, PHP would silently convert the string to an integer; with it, the error message names the function, the argument and the specific types involved.
Another important building block is the built-in Error class. Since PHP 7, internal engine failures are thrown as Error objects (and subclasses such as TypeError, ValueError and DivisionByZeroError) instead of causing fatal errors, and PHP 8 converted many former warnings into thrown errors as well. When an error occurs, the Error object provides detailed information about the error, including its class, code, message, file and line.
<?php
try {
// ...
} catch (Error $e) {
echo "Error: {$e->getMessage()}" . PHP_EOL;
}
The introduction of these features enables developers to write more robust and maintainable code by providing detailed error messages that can be used for debugging purposes. With strict mode enabled, developers can identify type-related issues earlier in the development process.
Enabling Strict Mode for Enhanced Error Reporting
One of the most effective ways to tighten up error handling is to combine strict typing with full error reporting. Together they help catch potential issues before they cause problems in production.
To report every error, notice and deprecation, set the following in your php.ini file:
error_reporting = E_ALL
However, for a PHP 8.1+ project that will eventually be run via Laravel’s built-in web server or with PHP-FPM, you don’t need to change this yourself: during bootstrap Laravel calls error_reporting(-1), which reports every error level, and converts PHP errors into ErrorException instances that flow through its exception handler.
In your .env file, you can control how much detail you see:
APP_DEBUG=true
LOG_LEVEL=debug
For a local development environment where you want detailed error pages but don’t want them on production, keep APP_DEBUG=true only in your local .env and set APP_DEBUG=false on production. This value is read by the 'debug' key in config/app.php. Keep in mind that declare(strict_types=1); applies per file, so it must be added at the top of every PHP file you want checked strictly.
By enabling strict mode and tweaking error reporting levels, you can catch more issues before they reach production and avoid surprises during deployment.
Using Attributes for Custom Error Handling
Attributes, introduced in PHP 8.0, let you attach metadata to functions, methods and classes. They don’t change behavior on their own; your code reads them through the Reflection API. That makes them a good fit for declaring custom error handling behaviors at the function or method level. Let’s define an attribute class that doubles as the handler, plus a small runner that looks it up when something goes wrong:
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
final class CustomErrorHandler
{
public function __construct(
public readonly string $message = 'Custom error handler caught an error'
) {}
public function __invoke(Throwable $error): void
{
// Implement your custom error handling logic here
echo "{$this->message}: {$error->getMessage()}\n";
}
}
function runWithErrorHandler(Closure $callback): mixed
{
try {
return $callback();
} catch (Throwable $error) {
$attributes = (new ReflectionFunction($callback))->getAttributes(CustomErrorHandler::class);
if ($attributes === []) {
throw $error;
}
$attributes[0]->newInstance()($error);
return null;
}
}
The constructor uses a readonly promoted property, one of the features added in PHP 8.1. Next, we need to apply this attribute to a function or method. Let’s use the exampleFunction as follows, passing it to the runner with PHP 8.1’s first-class callable syntax:
#[CustomErrorHandler('Division failed')]
function exampleFunction(): void
{
$x = 1 / 0;
}
runWithErrorHandler(exampleFunction(...));
When we run this code, the division throws a DivisionByZeroError, runWithErrorHandler() finds the attribute on exampleFunction, and our custom error handler prints Division failed: Division by zero. You can also target classes with Attribute::TARGET_CLASS and read them with ReflectionClass; if you’re interested in catching errors globally across your application, combine this with PHP’s set_exception_handler().
Keep in mind that attributes are a powerful tool for customization, but it’s essential to balance their use with maintainability and readability concerns.
Implementing a Custom Exception with report() and render()
To further customize error handling in our application, we can create a custom exception class that defines its own report and render methods. Laravel detects these methods automatically, which lets us define how this error is logged and what the user sees.
First, let’s create a new class that extends PHP’s base Exception class. We’ll name it CustomException.
// app/Exceptions/CustomException.php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class CustomException extends Exception
{
public function report(): void
{
// Log the error here, or perform any other custom action
Log::error($this->getMessage(), ['exception' => $this]);
}
public function render(Request $request): Response
{
// Return a custom error response here
return response('Custom error message', 500);
}
}
In this example, we’ve defined a simple report method that logs the error using Laravel’s Log facade. We’ve also defined a render method to return a custom error message.
To use our custom exception, we simply throw it from anywhere in the application, for example from a controller action:
use App\Exceptions\CustomException;
public function store()
{
throw new CustomException('Payment gateway timed out');
}
With this in place, Laravel will automatically call our report and render methods whenever this exception is thrown and not caught. We can then customize the behavior to suit our application’s needs.
This approach provides a high degree of flexibility and control over how errors are handled in our application.
Handling Errors in Controllers and Routes
To handle errors effectively within your application’s controllers and routes, you’ll need to create a custom error handler that can be injected into these classes through Laravel’s service container.
Let’s first create an interface for our custom error handler. Create a new file app/Error/ErrorHandlerInterface.php with the following code:
namespace App\Error;
use Throwable;
interface ErrorHandlerInterface
{
public function handleError(Throwable $exception): void;
}
Next, we’ll implement this interface in a class that will handle errors. In app/Error/ErrorExceptionHandler.php, add the following code:
namespace App\Error;
use Illuminate\Support\Facades\Log;
use Throwable;
class ErrorExceptionHandler implements ErrorHandlerInterface
{
public function handleError(Throwable $exception): void
{
// Handle your error logic here, such as logging or sending an email.
Log::error($exception->getMessage(), ['exception' => $exception]);
}
}
Next, bind the interface to this implementation in the register method of app/Providers/AppServiceProvider.php, so Laravel’s service container knows which class to inject:
use App\Error\ErrorExceptionHandler;
use App\Error\ErrorHandlerInterface;
public function register(): void
{
$this->app->bind(ErrorHandlerInterface::class, ErrorExceptionHandler::class);
}
Finally, in your controller classes, you can type-hint the interface in the constructor and Laravel will inject the handler automatically. In a controller like app/Http/Controllers/ExampleController.php, add this:
namespace App\Http\Controllers;
use App\Error\ErrorHandlerInterface;
class ExampleController extends Controller
{
public function __construct(private ErrorHandlerInterface $errorHandler)
{
}
}
You can also use a middleware to pass exceptions that occurred while handling a request to your error handler. Laravel converts an exception into an error response before control returns to your middleware, so a try/catch around $next($request) would never fire; instead, the original exception is attached to the response, and the middleware can read it from there. Create a new file app/Http/Middleware/ErrorHandlingMiddleware.php with the following code:
namespace App\Http\Middleware;
use App\Error\ErrorHandlerInterface;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class ErrorHandlingMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Laravel has already rendered the exception into an error response
// and attached the original exception to it.
if (isset($response->exception) && $response->exception instanceof Throwable) {
app(ErrorHandlerInterface::class)->handleError($response->exception);
}
return $response;
}
}
You can then register this middleware globally in bootstrap/app.php (Laravel 11 and later):
use App\Http\Middleware\ErrorHandlingMiddleware;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(ErrorHandlingMiddleware::class);
})
Logging Errors with a PSR-3 Logger
With modern PHP and Laravel, it’s now easier than ever to log errors and exceptions. Laravel’s logger implements the PSR-3 Psr\Log\LoggerInterface, which allows you to log errors in a structured format by passing a context array alongside the message.
To use it, you’ll need to type-hint LoggerInterface in your error handler’s constructor, and Laravel’s service container will pass in the application logger. Here’s an example:
namespace App\Error;
use Psr\Log\LoggerInterface;
use Throwable;
class LoggingErrorHandler implements ErrorHandlerInterface
{
public function __construct(private LoggerInterface $logger)
{
}
public function handleError(Throwable $exception): void
{
$this->logger->error($exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
}
}
In this example, we’re injecting a LoggerInterface instance into our custom error handler. We can then use the error() method to log the exception along with structured context. Laravel writes these entries to storage/logs/laravel.log by default, and you can change the destination with the LOG_CHANNEL setting.
For errors that occur outside Laravel, such as during PHP startup, you’ll also want to configure PHP’s own error log. You can do this by adding the following code to your php.ini file:
error_log = /path/to/error/log
This will specify a path for the error log file.
With these changes in place, you should now be able to log errors and exceptions in a structured, searchable format. This can help you track down issues more easily and improve your application’s overall reliability.
Integrating with Laravel’s Built-in Error Handling
Now that we’ve explored modern PHP’s error handling features, let’s see how we can integrate these improvements into our existing Laravel application.
Laravel’s built-in error handling is configured in bootstrap/app.php through the withExceptions method (Laravel 11 and later). This is where we’ll plug in our own reporting and rendering logic.
First, we can create a new error reporter class that will be used in conjunction with Laravel’s built-in error handling mechanisms.
Let’s say we have created a new file app/Handlers/ErrorReporter.php containing the following code:
namespace App\Handlers;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Throwable;
class ErrorReporter
{
public function report(Throwable $e): void
{
// Custom error reporting logic here
Log::error("Error caught: {$e->getMessage()}", ['exception' => $e]);
}
public function render(Throwable $e, int $status): Response
{
return response()->view('errors.error', ['exception' => $e], $status);
}
}
Finally, we need to register our custom error reporter with Laravel. We can do this by updating the withExceptions call in our bootstrap/app.php file:
use App\Handlers\ErrorReporter;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (Throwable $e) {
app(ErrorReporter::class)->report($e);
});
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if (! $request->expectsJson()) {
return app(ErrorReporter::class)->render($e, 404);
}
});
})
The report callback runs for every reported exception, while the render callback only handles 404 errors for regular browser requests; returning nothing lets Laravel fall back to its default rendering.
With these changes, we’ve successfully integrated modern PHP error handling features with Laravel’s built-in error handling capabilities.
This concludes our tutorial on streamlining error handling in PHP 8.1 and integrating it with Laravel.
Frequently Asked Questions
How do I enable strict mode for my PHP project?
To enable strict mode, add declare(strict_types=1); as the first statement of each PHP file. To report every error level, set error_reporting = E_ALL in your php.ini file; Laravel applications already report all errors during bootstrap, and you control detailed error pages with APP_DEBUG in your .env file.
What is the difference between using attributes for custom error handling and leveraging Laravel’s built-in error handling mechanisms?
Attributes provide a more explicit way to handle errors, while Laravel’s built-in mechanisms offer a more streamlined approach. You can choose the method that best fits your project’s needs.
I’m getting an error message saying ‘TypeError: Argument 1 passed to … must be of type string’, but I’ve already defined my function parameters. What’s going on?
This error means a value of a different type was passed to a parameter declared as string, for example an integer or null. It is most common in files that start with declare(strict_types=1);, because strict mode disables automatic type conversion. Check the file and line named in the message and convert the value explicitly (for example with a (string) cast), or make the parameter nullable with ?string if null is a valid input.
Can I use error handling in PHP 8.1 without enabling strict mode?
Yes, you can still catch exceptions using try-catch blocks or leverage Laravel’s built-in error handling mechanisms without enabling strict mode. However, enabling strict mode surfaces type mismatches as errors instead of silently converting values, which makes bugs easier to find.
How does PHP’s Error class differ from the traditional Exception class?
Both Error and Exception implement the Throwable interface and provide the same details, including the message, code, file and line. Error (available since PHP 7) is thrown by the engine for internal problems such as type errors or division by zero, while Exception is meant for application-level errors you throw yourself. Catch Throwable when you need to handle both.
