thakurcoder

May 23, 2025

· 18 min read

Effective Use of Try-Catch in Laravel: Best Practices and Layers

Discover how to use try-catch blocks effectively in Laravel for clean architecture and robust error handling.

Effective Use of Try-Catch in Laravel: Best Practices and Layers

Introduction

Error handling is crucial in any application to ensure that failures are logged, user-friendly responses are shown, and resources are cleaned up properly. In PHP, uncaught exceptions will halt execution and generate fatal errors, so it's essential to handle them appropriately. Laravel comes with a global exception handler (App\Exceptions\Handler) that automatically logs uncaught exceptions and converts them into HTTP responses (e.g. showing a 404 or 500 error page). However, there are many cases where you want finer control over specific operations—this is where manual try-catch blocks come in. In this post, we'll explore common scenarios for using try-catch in Laravel, discuss which layers of the application should handle exceptions, and provide examples of proper and improper usage. We'll also compare Laravel's built-in exception handling with explicit try-catch, and cover best practices like logging, rethrowing, and creating custom exception classes.

Exception Flow in Laravel

Here's a visual representation of how exceptions flow through a Laravel application:

This diagram shows how exceptions can bubble up through the layers of your application, with each layer having the opportunity to catch and handle them. The global exception handler acts as a safety net for any uncaught exceptions.

Common Use Cases for try-catch in Laravel

Not all code paths need a try-catch block. In general, use try-catch around operations that are risky – i.e. calls that you don't fully control or that are likely to throw exceptions. Common examples include:

  • Database and Query Operations: When interacting with the database, certain operations can fail: for example, inserting or updating records might violate constraints, and findOrFail() will throw an ModelNotFoundException if a record isn't found. You can catch these to return a custom response. For instance:

    try {
        $user = User::findOrFail($id);
    } catch (ModelNotFoundException $e) {
        \Log::warning("User {$id} not found");
        return response()->json(['error' => 'User not found'], 404);
    }

    Here we catch the specific exception and return a 404 JSON response. Laravel's default handler would also produce a 404, but catching it lets us customize the message and log context. Other database exceptions like Illuminate\Database\QueryException (for SQL errors) can also be caught if you want to handle constraint violations or deadlocks at runtime.

  • External Service/API Calls: HTTP requests or SDK calls to external APIs can fail due to network issues, timeouts, or invalid responses. When using Laravel's HTTP client (Http::) or any Guzzle-based client, you might catch exceptions like Illuminate\Http\Client\RequestException or Guzzle's ConnectException. For example:

    try {
        $response = Http::timeout(5)->get('https://api.example.com/data');
        $data = $response->json();
    } catch (\Illuminate\Http\Client\RequestException $e) {
        \Log::error('API request failed: ' . $e->getMessage());
        // Fallback or error response
        return response()->json(['error' => 'External service unavailable'], 503);
    }

    In this snippet, if the API call fails (e.g. timeout or 500 error), we log the error and return a 503 Service Unavailable to the client. This provides a graceful fallback instead of a generic error page.

  • File and Storage Operations: Reading from or writing to the file system can raise exceptions like Illuminate\Contracts\Filesystem\FileNotFoundException or League\Flysystem\FilesystemException. For example:

    try {
        $contents = Storage::disk('local')->get($path);
    } catch (\Illuminate\Contracts\Filesystem\FileNotFoundException $e) {
        \Log::notice("File {$path} not found");
        // Perhaps return default content or a 404
        return response()->view('errors.file_not_found', [], 404);
    }

    Catching these lets you provide a fallback or user-friendly message if a file is missing or unreadable.

  • Validation and Authorization: While Laravel's form requests and validation automatically handle ValidationException (returning redirect or JSON errors), there are cases where you might catch them manually. Similarly, AuthorizationException or AuthenticationException (from policies or Auth::user()) can be caught to customize access-denied responses:

    try {
        $this->authorize('update', $post);
    } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
        abort(403, 'You are not authorized to update this post.');
    }

    If you don't catch these, Laravel's default exception handler will typically convert them into a 403 response or redirect. But catching them lets you tailor the message or logging.

  • Custom Business Logic: Sometimes your domain logic might throw custom exceptions (e.g. PaymentFailedException, InvalidOrderException, etc.). Catching those where you can respond appropriately is common. For example, a service layer might throw a PaymentFailedException, which the controller catches to display a failure notice.

  • JSON or Data Parsing: If you use functions that throw exceptions (like json_decode() with JSON_THROW_ON_ERROR), catch JsonException to handle invalid input:

    try {
        $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR);
    } catch (\JsonException $e) {
        return response()->json(['error' => 'Invalid JSON'], 400);
    }

    This pattern (from Laravel core) treats unexpected data as a handled error.

In summary, use try-catch around code that might fail in a recoverable way: database queries, external API calls, file operations, JSON parsing, or any risky library function. These are cases where you have a plan for the failure (fallback, retry, user message, etc.). Don't wrap every method blindly; only catch exceptions you can handle meaningfully.

Where to Place try-catch: Controllers, Services, Repositories, etc.

In a Laravel application with multiple layers (Controller → Service → Repository → Model), deciding where to catch exceptions depends on who can best handle them. A common guideline is:

  • Controllers: Catch exceptions in controllers if you need to convert them into HTTP responses. Since controllers form the transport layer, it's appropriate to catch an exception from a service and return a response (JSON or view) to the user. For example:

    public function show($id)
    {
        try {
            $user = $this->userService->findUser($id);
            return view('users.show', compact('user'));
        } catch (UserNotFoundException $e) {
            return redirect()->route('users.index')
                             ->with('error', 'User not found');
        }
    }

    Here, the controller catches a domain exception from the service and shows a friendly error message. If we didn't catch it, Laravel's Handler might turn it into a 500 error. Controllers are also a good place to catch validation or authorization exceptions and redirect accordingly.

  • Service Layer: If you have a service or business logic layer (often for complex operations), you might use try-catch internally to manage retries or fallbacks. For example, a service that calls two APIs might catch the first API's exception to try an alternate service. However, services should usually throw domain exceptions rather than handle HTTP concerns. For instance:

    public function chargePayment($data)
    {
        try {
            return $this->paymentGateway->charge($data);
        } catch (PaymentGatewayException $e) {
            \Log::error('Payment failed', ['error' => $e->getMessage()]);
            throw new \App\Exceptions\PaymentFailedException('Payment could not be processed.');
        }
    }

    In this snippet, the service catches a low-level exception, logs context, and throws a custom PaymentFailedException. The controller (or Handler) can then catch that custom exception to show an error. Services can also catch exceptions if they can automatically fix a problem (e.g. rollback a transaction on failure).

  • Repository/Data Access: Repositories (or model classes) typically deal directly with the database. You may catch database-specific exceptions (like QueryException) here to convert them into domain exceptions. For example:

    public function create(array $data)
    {
        try {
            return User::create($data);
        } catch (QueryException $e) {
            // Duplicate entry or SQL error
            throw new \App\Exceptions\DatabaseException('Failed to create user');
        }
    }

    This way, controllers or services don't need to know about SQL details. If you don't use repositories, catching in Eloquent queries can still make sense in service/controller if you expect a possible failure.

  • Middleware/Global Handler: Custom exception handling that applies to all controllers should go in the global exception handler (App\Exceptions\Handler). Laravel's Handler already catches everything not handled elsewhere and logs it. You typically use Handler to configure reporting or rendering (e.g. sending errors to Sentry, or customizing JSON error formats). You should not litter Handler with try/catch for specific business logic; it's for global concerns.

  • Artisan Commands, Jobs, Listeners: In console commands, queue jobs, or event listeners, uncaught exceptions will also go to the Handler or the queue's failure mechanism. If you want a job to retry on failure, you can let exceptions bubble. If you want to log differently or perform cleanup, you can catch in the handle() method of the job.

In short, catch exceptions at the boundary where you can best handle them. If only the controller can decide the HTTP response, catch there. If a service can retry or transform an error, catch there and rethrow if needed. Avoid mixing HTTP details into services or repositories; instead throw exceptions and catch them up the chain. As one expert notes, returning false from every layer leads to many if checks ("if service returned false, if repository returned false, etc."). Using exceptions avoids that boilerplate and centralizes error flow.

[[NEWSLETTER]]

Proper vs. Improper Usage (Code Examples)

It's helpful to see code examples of good and bad try-catch usage.

Proper Usage

  1. Catching Specific Exceptions: Always catch only those exception types you expect and can handle. For example:

    try {
        $post = Post::findOrFail($id);
    } catch (ModelNotFoundException $e) {
        return response()->json(['error' => 'Post not found'], 404);
    }

    This way, other exceptions (e.g. a database connection issue) will still bubble up to be handled elsewhere. After catching, you can log or return a custom response.

  2. Using Transactions: Wrap multiple DB operations in a transaction and use try-catch to rollback on failure:

    DB::beginTransaction();
    try {
        Order::create([...]);
        Payment::create([...]);
        DB::commit();
        return redirect()->route('order.success');
    } catch (\Throwable $e) {
        DB::rollBack();
        \Log::error('Order creation failed', ['error' => $e->getMessage()]);
        return redirect()->back()->with('error', 'Order could not be completed.');
    }

    Here we catch any Throwable to ensure the transaction is rolled back and to return an error message. This is a recommended use of try/catch for atomic operations.

  3. API Calls with Fallback: If you call an external API, catch its exception and handle gracefully:

    try {
        $data = Http::get('https://third-party.com/api')->json();
    } catch (\Illuminate\Http\Client\RequestException $e) {
        \Log::warning('Third-party API down', ['message' => $e->getMessage()]);
        $data = []; // fallback to empty data
    }
    // Continue using $data safely
  4. Service or Repository Converting Errors: Inside a repository or service, catch low-level errors and throw domain exceptions:

    public function updateProfile(array $data)
    {
        try {
            $this->user->update($data);
        } catch (\Exception $e) {
            throw new \App\Exceptions\ProfileUpdateException('Unable to update profile.');
        }
    }

    This ensures controllers need only catch ProfileUpdateException and remain unaware of the exact cause.

Improper Usage

  1. Overly Broad catch Without Action: Avoid catching Exception or Throwable and doing nothing (or almost nothing). For instance:

    try {
        // some code
    } catch (\Exception $e) {
        // nothing or just generic
        return back()->with('error', 'Something went wrong.');
    }

    This hides the real error and offers no useful response. It also prevents the global handler from logging the exception. If you catch, at least log it: \Log::error($e).

  2. Try/Catch Around Everything: Don't wrap every line of code in its own try/catch. For example, don't do:

    public function store(Request $request)
    {
        try {
            $user = User::create($request->all());
        } catch (\Exception $e) {
            // ...
        }
     
        try {
            $this->sendWelcomeEmail($user);
        } catch (\Exception $e) {
            // ...
        }
     
        return redirect()->route('home');
    }

    This is verbose and hard to maintain. It's better to combine related operations in one block or let some exceptions bubble.

  3. Handling HTTP in Lower Layers: Avoid catching an exception deep in a repository and returning an HTTP response there. Business/data layers should not be aware of HTTP or views. For example, this is improper:

    public function deleteUser($id)
    {
        try {
            return User::destroy($id);
        } catch (\Exception $e) {
            // ❌ Returning an HTTP redirect inside a repository is a bad separation of concerns.
            return redirect()->route('error.page');
        }
    }

    Instead, throw an exception or return a status, and let the controller decide how to respond.

By following proper usage (catch specific cases, handle or log them, and let other errors bubble) and avoiding bad patterns (empty catches, catching everything everywhere), your code will be more maintainable and you won't inadvertently hide bugs.

Laravel's Exception Handler vs. Manual try-catch

Laravel's default exception handling is powerful: any uncaught exception in your code will automatically go to App\Exceptions\Handler, where it's reported (logged or sent to an error service) and rendered into an HTTP response. For example, a ModelNotFoundException thrown in a controller without a catch will be converted into a 404 response by the handler. A ValidationException triggers a redirect back with error messages by default. Because of this, you often don't need try-catch for every situation. Letting exceptions bubble can actually simplify code.

However, relying solely on the global handler means you get generic behavior. By using try-catch in specific places, you can override or augment that behavior. For instance, you might want to:

  • Return a JSON error instead of HTML for certain routes. You could catch exceptions in the controller and return JSON, instead of letting the handler render an HTML error page.
  • Perform cleanup or alternative logic immediately after an operation fails (e.g. retrying a query, sending a notification).
  • Prevent some exceptions from being logged or reported until after certain processing.

In Laravel, you can also customize the Handler using the reportable and renderable methods. For example, you can say "for this specific exception type, run this callback" when reporting or rendering. This often replaces the need to write a manual try-catch in the application code itself.

In summary: Laravel's built-in handler is your safety net for uncaught exceptions. Use try-catch when you need to intervene in the flow of a specific block of code. Otherwise, let exceptions bubble and be handled by the framework. As one best practice guide advises, "Not every piece of code needs a try-catch block; use it only for code that is prone to failure, such as database queries, file operations, or external API calls". Overusing try-catch can clutter your code; underusing it can lead to unhandled errors or poor user experience.

Best Practices: Logging, Rethrowing, and Custom Exceptions

To effectively use try-catch in Laravel, follow these best practices:

  • Catch Specific Exceptions First: In a try with multiple catch blocks, handle the most specific exceptions first (like ModelNotFoundException), then fall back to a generic \Exception or \Throwable if needed. This ensures you handle each type appropriately. Example:

    try {
        // some code
    } catch (AuthorizationException $e) {
        abort(403, 'Unauthorized');
    } catch (Exception $e) {
        report($e);
        abort(500, 'Server error');
    }

    This pattern lets you customize the response per exception. In fact, always prefer catching specific exception classes rather than generic ones.

  • Log with Context: When catching an exception to handle it, log it so that you don't lose the error details. Use Laravel's Log facade or report($exception) helper. Include relevant context (IDs, input data) in the log. Example:

    try {
        $order = $this->orderService->place($data);
    } catch (\Exception $e) {
        \Log::error('Order placement failed', [
            'orderData' => $data,
            'error'     => $e->getMessage(),
        ]);
        throw $e; // or rethrow a custom exception
    }

    Logging helps you diagnose issues later. Laravel's handler automatically logs uncaught exceptions, but if you catch and don't rethrow, make sure to log manually. The dev.to guide emphasizes including contextual data when logging caught exceptions.

  • Rethrow or Transform Exceptions: Often a try-catch block will either recover from an error or rethrow it (maybe as a different exception). Don't silently swallow exceptions. If you can't fully handle it, rethrow so that the error can continue to propagate. In many cases, you catch an exception to log or add context, then do throw $e; (or throw new SomethingException). This ensures Laravel's global handler (and error tracking) still sees the exception unless you explicitly handled it. For example:

    try {
        $item->save();
    } catch (\Exception $e) {
        \Log::error('Save failed', ['error' => $e->getMessage()]);
        throw $e; // let the handler deal with it (500 response, etc.)
    }
  • Use Custom Exception Classes: Define your own exception types for your application's domain. For example, InsufficientFundsException, InvalidOrderException, etc. This makes it easier to catch and handle specific situations. You can even give them methods (e.g. statusCode()) or have them implement Illuminate\Contracts\Support\Renderable to define a custom HTTP response. Then in a controller:

    try {
        $paymentService->charge($paymentData);
    } catch (InsufficientFundsException $e) {
        return response()->view('errors.payment', ['msg' => $e->getMessage()], 400);
    }

    Using custom exceptions keeps your controller logic clean and intention-revealing.

  • Mind the Layers: As mentioned above, don't mix responsibilities. If you catch an exception, do not perform unrelated tasks. For instance, a repository should throw or return failure, not redirect or echo output. Let the layer above decide (usually the controller). An exception might bubble up many layers; design your exceptions accordingly.

  • Global Exception Handling: For truly global concerns (logging to external services, ignoring certain exceptions), use the Handler (reportable, renderable, dontReport, etc.) rather than peppering try-catch everywhere.

Following these best practices will make your error handling robust. In essence: "catch only where you can handle it; otherwise let it bubble", but if you do catch, always do something useful—log, transform, or cleanup, never silently ignore.

Testing Exception Handling

Testing exception handling is crucial to ensure that your application handles errors correctly. Here are some strategies:

  • Unit Tests: Use PHPUnit to create tests that simulate exceptions and verify that your code handles them correctly.
  • Integration Tests: Create tests that simulate a full request cycle, including exception handling, and verify that the expected response is returned.
  • Mocking: Use PHPUnit's mocking capabilities to mock external dependencies and test exception handling in isolation.

Real-World Scenarios

Let's consider a real-world scenario where try-catch blocks are used effectively:

Scenario: User Registration

  1. User Registration Process:

    • Controller: Handles user input and calls a service to register the user.
    • Service: Contains business logic for user registration.
    • Repository: Handles database operations.
  2. Exception Handling:

    • Controller: Catches exceptions and returns appropriate HTTP responses.
    • Service: Catches exceptions and throws domain-specific exceptions.
    • Repository: Catches database-specific exceptions and throws domain-specific exceptions.
  3. Testing:

    • Unit Tests: Test that the service and repository handle exceptions correctly.
    • Integration Tests: Test that the entire registration process handles exceptions gracefully.

Code Playground: Exception Handling in Action

Here's an interactive example that demonstrates exception handling in a Laravel application. You can try different scenarios to see how exceptions are handled:

<?php
 
namespace App\Http\Controllers;
 
use App\Services\UserService;
use App\Exceptions\UserRegistrationException;
use Illuminate\Http\Request;
 
class UserController extends Controller
{
    protected $userService;
 
    public function __construct(UserService $userService)
    {
        $this->userService = $userService;
    }
 
    public function register(Request $request)
    {
        try {
            // Validate the request
            $validated = $request->validate([
                'name' => 'required|string|max:255',
                'email' => 'required|email|unique:users',
                'password' => 'required|min:8',
            ]);
 
            // Attempt to register the user
            $user = $this->userService->register($validated);
 
            return response()->json([
                'message' => 'User registered successfully',
                'user' => $user
            ], 201);
 
        } catch (UserRegistrationException $e) {
            // Handle specific registration exceptions
            return response()->json([
                'error' => $e->getMessage()
            ], 400);
 
        } catch (\Exception $e) {
            // Log unexpected errors
            \Log::error('User registration failed', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
 
            return response()->json([
                'error' => 'An unexpected error occurred'
            ], 500);
        }
    }
}

Try these scenarios:

  1. Valid registration data
  2. Duplicate email
  3. Invalid password
  4. Database connection error

Each scenario demonstrates different exception handling patterns and appropriate responses.

Conclusion

In Laravel, the built-in exception handler ensures that uncaught errors are logged and converted to HTTP responses by default. Manual try-catch blocks are not needed for routine error display, but they are invaluable when dealing with risky operations or when you want to customize recovery. Use try-catch around database calls, external API requests, file operations, or any code where you have a plan B (fallback data, retries, or custom messages). Place catches where it makes sense: controllers (for HTTP response control), services (for business logic or retries), and repositories (for data layer exceptions). Catch specific exception classes, log them with context, and either handle or rethrow. Avoid catching everything everywhere or returning HTTP details deep in your models. Instead, let exceptions propagate to the layer that can do something sensible with them. By following these practices, your Laravel app will handle errors gracefully, providing clear feedback to users while preserving full diagnostics in logs.

References