Skip to main content
Create your own
Lesson illustration

Custom Exception Handling

Hello! Welcome back.

In our last lesson, we saw how Form Requests neatly handle authorization and validation, automatically throwing exceptions and generating the appropriate response when rules fail. This is a great example of Laravel taking care of common error scenarios for us.

But what about errors specific to your application's business logic? For instance, what if a user tries to perform an action that is valid on the surface but disallowed by a specific business rule? Relying on generic error pages for these cases can lead to a confusing user experience.

This lesson addresses the final outcome in our Laravel Fundamentals module: Implement custom exception handling for application-specific errors. You will learn how to create, throw, and handle your own exceptions to provide meaningful feedback to users and make your application more robust and predictable.

1. The Default Behavior and Its Limitations

First, a crucial security reminder. Your .env file contains an APP_DEBUG setting. In development, this is true, giving you detailed stack traces. In production, this must always be false.

When APP_DEBUG is false and an unhandled error occurs, Laravel protects your application by showing a generic "500 Server Error" page, hiding potentially sensitive information.

Exceptions in Laravel: Why/How to Use and Create Your Own

This short clip from Laravel Daily demonstrates the problem with unhandled exceptions and the importance of setting APP_DEBUG to false in production.

Watch from 00:40 to 01:35. Notice how a simple error results in a detailed debug screen, and how setting APP_DEBUG=false changes this to a generic 500 error page.

While secure, a generic 500 page isn't helpful. If a user tries to purchase an out-of-stock item, they shouldn't see a vague "Server Error"; they should be told the item is unavailable. This is where custom exception handling comes in.

2. Creating and Throwing Custom Exceptions

The first step towards better error handling is creating your own exception classes. This makes your code more readable and self-documenting. Instead of a generic Exception, you can have a PaymentFailedException or an InsufficientStockException.

You can easily generate a new exception class using Artisan:

php artisan make:exception InsufficientStockException

This command creates a new file at app/Exceptions/InsufficientStockException.php.

Now, imagine you have a Product model with a method to handle purchases. You can throw your new exception when a business rule is violated.

// In some service class or controller
use App\Models\Product;
use App\Exceptions\InsufficientStockException;

public function purchase(Product $product, int $quantity)
{
    if ($product->stock < $quantity) {
        // Instead of returning false or a generic error,
        // we throw a specific, meaningful exception.
        throw new InsufficientStockException(
            "Sorry, we only have {$product->stock} of {$product->name} left in stock."
        );
    }

    // ... proceed with purchase logic
}
Generic vs. Custom Exception Handling in Laravel
This image highlights the clarity gained by using a descriptive, custom exception (`TestException::oopsieDaisy()`) over a generic one (`new Exception()`).

Throwing the exception is only half the story. Now we need to decide how to handle it.

3. Handling Exceptions: Local vs. Global

There are two primary strategies for handling exceptions in Laravel:

  1. Local Handling (try-catch): You can wrap specific, risky operations in a try-catch block. This is useful when you need to perform a very specific action immediately where the error occurs.
  2. Global Handling (Exception Handler): You can register application-wide "listeners" for your custom exceptions. This is the more common and powerful Laravel approach, as it centralizes your error-handling logic and keeps your controllers clean.

Local Handling with try-catch

You can catch your custom exception within a controller method.

// In your ProductController.php
public function processPurchase(Request $request, Product $product)
{
    try {
        $this->productService->purchase($product, $request->input('quantity'));
        
        return redirect()->route('purchase.success');

    } catch (InsufficientStockException $e) {
        // The specific exception was caught.
        // We can now redirect back with a user-friendly error message.
        return back()->with('error', $e->getMessage());
    }
}

This works, but it can lead to repetitive try-catch blocks across many controllers. For consistent error responses, global handling is preferred.

4. Global Exception Handling

Laravel provides a centralized place to manage how all exceptions are reported (logged) and rendered (converted to an HTTP response). As of Laravel 11+, this is configured in the bootstrap/app.php file using the withExceptions method.

There are two excellent ways to define global handling logic:

  • On the Exception Class Itself: Add report() and render() methods directly to your custom exception class. This encapsulates the handling logic with the exception.
  • In bootstrap/app.php: Register report and render callbacks for specific exception types. This centralizes all handling logic in one configuration file.

Both approaches are valid. We'll focus on the first, as it promotes better encapsulation.

Reportable & Renderable Exceptions

This is the most object-oriented approach. You add the handling logic directly inside your custom exception class.

  • report(): This method is for logging the exception or sending it to an external service like Sentry.
  • render(): This method is responsible for creating the HTTP response that gets sent back to the user.

Let's modify our InsufficientStockException.php:

// app/Exceptions/InsufficientStockException.php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;

class InsufficientStockException extends Exception
{
    /**
     * Report the exception.
     * You could log this, send a notification to a Slack channel, etc.
     */
    public function report(): void
    {
        // Example: \Log::warning('Stock issue encountered: ' . $this->getMessage());
    }

    /**
     * Render the exception into an HTTP response.
     */
    public function render(Request $request)
    {
        // If the request is an API call (expects JSON)...
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Insufficient stock.',
                'message' => $this->getMessage()
            ], 422); // 422 Unprocessable Entity is a good choice here.
        }

        // For standard web requests...
        return redirect()
            ->back()
            ->withInput() // Send back the user's original input
            ->with('error', $this->getMessage());
    }
}

With this in place, you no longer need the try-catch block in your controller!

// In your ProductController.php
public function processPurchase(Request $request, Product $product)
{
    // No try-catch needed!
    $this->productService->purchase($product, $request->input('quantity'));
    
    return redirect()->route('purchase.success');
}

If purchase() throws an InsufficientStockException, Laravel will automatically catch it, call the render() method you defined, and generate the correct response. Your controller remains clean and focused on the "happy path."

To understand this mechanism and the alternative of using bootstrap/app.php, the official documentation is your best resource.

Error Handling - Laravel 12.x

The official Laravel documentation explains the modern approach to error handling. Focus on the two main strategies for global handling.

Please read the following two sections: 'Rendering Exceptions': This shows how to register a rendering callback in bootstrap/app.php. Pay attention to the example that checks if the request is for an API ($request->is('api/*')). 'Reportable and Renderable Exceptions': This covers the approach we just implemented, where the report and render methods are defined directly on the exception class. \nUnderstanding both will give you a complete picture of Laravel's flexible exception handling system.

This article from Vishal Garg is also a great resource that explicitly discusses the modern approach and summarizes the key concepts well.

How to Customize Default Exceptions in Laravel 12

This article provides a concise guide to the latest exception handling features in Laravel, reinforcing the concepts from the official docs.

Quickly scan this article to solidify your understanding. Notice how it contrasts the new withExceptions API with older versions of Laravel and provides clear examples for both rendering callbacks and renderable exception classes.

Conclusion

You now have the tools to handle application-specific errors gracefully. By moving beyond generic error pages and implementing custom exceptions, you create a more predictable, user-friendly, and debuggable application.

Key Takeaways:

  • Always set APP_DEBUG=false in production.
  • Create custom exception classes with php artisan make:exception for clear, domain-specific error handling.
  • You can handle exceptions locally with try-catch blocks for fine-grained control.
  • For consistent, application-wide error responses, use global handling by adding report() and render() methods to your custom exception classes.
  • The render() method can return different responses based on the request type (e.g., JSON for APIs, redirects for web).

Next Up:

This lesson concludes our module on Laravel's fundamentals and service container. We've covered the request lifecycle, routing, controllers, and error handling. Now, we're ready to build the foundation of our application's data structure. In the next module, "Database Schema Design & Migrations," we'll start with a crucial theoretical topic: explaining the principles of database normalization (1NF, 2NF, 3NF).

Can't find a good explanation? Sign up and we'll make it for you

Sign up