How Failing to Understand Exceptions Can Derail Your Application

· 6 minutes read
How Failing to Understand Exceptions Can Derail Your Application
Author avatar
James

Software Engineer

We’ve all been there—you’re deep into building a new feature, or your application is running smoothly, and then suddenly, something goes wrong.

Whether you’re working in a small team, flying solo, or part of a large commercial group, exceptions can throw your application off course pretty quickly if you’re not careful.

As developers, it’s crucial to fully understand exceptions and handle them correctly. Letting exceptions “fail fast” means that errors are immediately visible, allowing you to address them right away instead of letting them hide and cause bigger issues later.

1. Not using custom exceptions

Many developers lean on generic exceptions (like capturing everything Exception) which hides whether an error is minor or critical. This makes debugging a real headache.

Example in Action - Bad Practice

Imagine a controller method calling a service class to update an inspection record. The service throws an exception when the status is “pending”, and here’s how many developers handle it in the controller:

// app/Http/Controllers/InspectionController.php
public function update(UpdateInspectionRequest $request)
{
    $validated = $request->validated();
    $inspectionId = $validated['inspection_id'];
    $status = $validated['status'];

    try {
        $inspection = $this->inspectionService->update($inspectionId, $status);
        return response()->noContent();
    } catch (Exception $e) {
        // Status is `pending`
        return response()->json(['error' => 'Status is pending'], 500);
    }
}

What’s wrong?

The catch block actually captures every type of exception, ranging from a database connection error to a validation error. This makes it hard to determine the root cause of the error.

It also prevents fatal exceptions from propagating, meaning that monitoring tools like Sentry do not log these errors. Additionally, exception handling is scattered across your controllers rather than centralized.

**How to fix

We can start by using custom exceptions and also rather than handling exceptions in the controller, we can use Laravel’s global exception handler:

// app/Exceptions/PendingInspectionStatusException.php
class PendingInspectionStatusException extends Exception
{
    public function __construct(int $inspectionId)
    {
        parent::__construct("Inspection {$inspectionId} has a pending status and cannot be updated.");
    }
}
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->report(function (PendingInspectionStatusException $e) {
        return response()->json([
            'error' => $exception->getMessage()
        ], 409);
    });
})

Now, the service class can throw this specific exception:

// app/Services/InspectionService.php
public function update(int $inspectionId, string $status): Inspection
{
    $inspection = Inspection::findOrFail($inspectionId);
    
    if ($inspection->status === 'pending') {
        throw new PendingInspectionStatusException($inspectionId);
    }
    
    // Continue with the update logic...
}

And your controller becomes much cleaner:

// app/Http/Controllers/InspectionController.php
public function update(UpdateInspectionRequest $request)
{
    $validated = $request->validated();

    $this->inspectionService->update(
        $validated['inspection_id'], 
        $validated['status']
    );
    
    return response()->noContent();
}

This way, only that specific error is caught by the global handler, making debugging easier and allowing other types of exceptions to be processed by their appropriate handlers. Your API also returns more meaningful status codes and error details.

Working in a small team, we initially caught generic exceptions and when it came to debugging with tools like Sentry and production logs, it was a nightmare. We had to dig through the codebase to find the root cause of errors. After switching to custom exceptions with Laravel’s exception handler, we were able to quickly identify issues and fix them.

2. Gracefully handling exceptions

Some developers attempt to “fail gracefully” by catching fatal exceptions. While they might log the error or update a status, they continue execution as if nothing happened.

However, they often overlook that Laravel already handles fatal exceptions, logs them, returns a 500 response, and—if connected—sends them to Sentry.

Additionally, catching an exception doesn’t mean you have to suppress it; you can handle the failure and still propagate the error by re-throwing the exception.

What does this look like?

class InspectionService
{
    public function update(int $inspectionId, string $status): ?Inspection
    {
        $inspection = Inspection::find($inspectionId);

        if (!$inspection) {
            return null;
        }
        
        try {
            // Business logic here
            $inspection->status = $status;
            $inspection->save();
            
            // Additional operations that might fail
            $this->updateRelatedRecords($inspection);
            
            return $inspection;
        } catch (Exception $e) {
            // Mark as failed but don't silence the exception
            $inspection->status = 'failed';
            $inspection->save();

            return null;
        }
    }
}

By catching all exceptions and simply returning null, the code masks real issues. For instance, if there’s a database connection failure inside updateRelatedRecords(), you get a null without any clear indication that something went seriously wrong. This silent failure makes tracking down the root cause a real pain.

Correct Approach

class InspectionService
{
    public function update(int $inspectionId, string $status): Inspection
    {
        $inspection = Inspection::findOrFail($inspectionId);
        
        try {
            // Business logic here
            $inspection->status = $status;
            $inspection->save();
            
            // Additional operations that might fail
            $this->updateRelatedRecords($inspection);
            
            return $inspection;
        } catch (Exception $e) {
            // Mark as failed but don't silence the exception
            $inspection->status = 'failed';
            $inspection->save();
            
            throw $e; // Re-throw to let Laravel handle it properly
        }
    }
}

What’s the difference?

By re-throwing the exception, the error is properly passed to the next layer, allowing Laravel to:

  1. Log the error.
  2. Return a 500 response to the client.
  3. Send the error (with stack trace) to Sentry if configured.

Imagine a database connection failure inside updateRelatedRecords(). In the incorrect approach, the exception is caught, and null is returned. The calling code wouldn’t know an error occurred—it would only see a null response, leading to confusion and wasted debugging time.

By re-throwing the exception, the calling code can react appropriately, and Laravel can handle the failure as intended.

3. Not Using Transactions

Developers often forget to use transactions when performing multiple related database operations that need to be atomic.

Why it’s a problem

  • Partial data changes create inconsistent database states
  • Money transfers, inventory updates, or order processing can leave your system in a corrupted state
  • Data integrity violations that are extremely difficult to track down and fix

What it looks like?

Here’s a real-world example of processing a product order without transactions:

public function processOrder(array $orderData)
{
    // Create the order
    $order = Order::create([
        'user_id' => $orderData['user_id'],
        'total' => $orderData['total'],
        'status' => 'processing'
    ]);

    // Update product inventory for each item
    foreach ($orderData['items'] as $item) {
        $product = Product::find($item['product_id']);
        
        // Decrement inventory
        $product->inventory_count -= $item['quantity'];
        $product->save();
        
        // Create order item record
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item['product_id'],
            'quantity' => $item['quantity'],
            'price' => $item['price']
        ]);
    }
    
    // Process payment (Fails here)
    $payment = $this->paymentGateway->processPayment(
        $orderData['payment_method_id'],
        $orderData['total']
    );
    
    // Update order with payment info
    $order->payment_id = $payment->id;
    $order->status = 'paid';
    $order->save();
    
    return $order;
}

What could go wrong?

Imagine the payment gateway throws an exception due to insufficient funds or network issues. By this point:

  1. The order has been created
  2. Product inventory has been reduced
  3. Order items have been created
  4. BUT the payment failed

You now have inventory that appears sold (unavailable to other customers), an order in the system, but no actual payment. Using findOrFail() wouldn’t help here - all the records exist, but you have a partial, inconsistent transaction.

✅ Correct Approach

public function processOrder(array $orderData)
{
    return DB::transaction(function () use ($orderData) {
        // Create the order
        $order = Order::create([
            'user_id' => $orderData['user_id'],
            'total' => $orderData['total'],
            'status' => 'processing'
        ]);
    
        // Update product inventory for each item
        foreach ($orderData['items'] as $item) {
            $product = Product::findOrFail($item['product_id']);
            
            // Ensure sufficient inventory
            if ($product->inventory_count < $item['quantity']) {
                throw new InsufficientInventoryException($product);
            }
            
            // Decrement inventory
            $product->inventory_count -= $item['quantity'];
            $product->save();
            
            // Create order item record
            OrderItem::create([
                'order_id' => $order->id,
                'product_id' => $item['product_id'],
                'quantity' => $item['quantity'],
                'price' => $item['price']
            ]);
        }
        
        // Process payment
        $payment = $this->paymentGateway->processPayment(
            $orderData['payment_method_id'],
            $orderData['total']
        );
        
        // Update order with payment info
        $order->payment_id = $payment->id;
        $order->status = 'paid';
        $order->save();
        
        return $order;
    });
}

With transactions, if any part fails (product not found, insufficient inventory, payment declined):

  1. The entire operation is rolled back
  2. No inventory is reduced
  3. No order is created
  4. No payment is processed
  5. The database remains in a consistent state

This is especially important for financial operations, inventory management, or any scenario where multiple related changes must succeed or fail as a unit.

4. Standardising Exceptions & Distinguishing Fatal vs. Non-Fatal Exceptions

Credit to Sophist-UK for the core ideas and feedback that made this section.

What’s the problem

Many teams lump all exceptions into the same bucket—treating every issue as equally critical. In reality, there are two broad categories:

  • Fatal Exceptions indicate a bug or logic error in the code. They should not be suppressed or ignored. Instead, they should halt execution and surface details for immediate fixes.
  • Non-Fatal Exceptions often represent external or environmental errors (e.g., network timeouts, invalid data from an API). These are part of normal system operation and should be caught and handled gracefully.

When you fail to differentiate them, your code may inadvertently catch genuine code bugs—hiding serious flaws—or conversely treat typical network glitches as system-crashing failures.

Example in Action – Subclassing Exceptions

Suppose you’re building a microservice that relies on network calls. You might define a base exception and specialised subclasses for clarity:

namespace App\Exceptions;

use Exception;

class BaseAppException extends Exception {}

class NetworkDownException extends BaseAppException {}
class PacketChecksumException extends BaseAppException {}

Your service layer can then catch all “comms-related” issues without suppressing other fatal errors:

public function fetchData()
{
    try {
        // Remote call or data processing
    } catch (NetworkDownException $e) {
        // Handle it gracefully (retry, fallback, etc.)
    } catch (PacketChecksumException $e) {
        // Another specific recovery strategy
    }
}

By contrast, a genuine logic error (like a DivisionByZeroException) will bypass these handlers and let Laravel (and your monitoring tools) know there’s a fatal bug to address.

So, how do we adopt this:

  1. Create Non-fatal Custom Exception Classes: Group related issues under their own sub-classes (e.g., MyCommsException and its children). This ensures your catch blocks are narrowly focused on the exceptions you can actually handle.
  2. Limit Try Blocks: Wrap only the code that can specifically throw the exception. This prevents capturing unintended errors from deeper calls.
  3. Don’t Suppress Fatal Exceptions: If you catch an error that isn’t yours to handle, re-throw it so Laravel can log it, return a proper response, and notify your team.
  4. Return Meaningful Responses: In Laravel, you can convert certain custom exceptions into tailored HTTP responses (e.g., 409 Conflict for a pending inspection status, 503 Service Unavailable for a network failure).

Spatie-Style Technique

A useful pattern (popularised by Spatie) is to have a single exception class that centralises error messages for distinct internal non-fatal exceptions. Each static constructor method returns a new exception with a relevant message:

class UnvalidatedUserData extends Exception
{
    public static function invalidPostcode(string $addressType, string $postcode): self
    {
        return new static("User data validation failed: invalid postcode '{$postcode}' for address type: {$addressType}.");
    }

    public static function invalidTelephoneNumber(string $phoneNumber, string $country): self
    {
        return new static("User data validation failed: invalid phone number '{$phoneNumber}' for country: {$country}.");
    }
}

This technique keeps your codebase organised, standardises how you throw fatal exceptions, and makes each error scenario easy to identify and handle.

By classifying exceptions into fatal and non-fatal categories—and using custom subclasses for each—you maintain clarity in your code, ensure that genuine bugs are surfaced immediately, and handle external or environmental hiccups gracefully.

Final Thoughts

Effectively handling exceptions is a critical part of building a robust application.

By using custom exceptions, you clearly distinguish between different error types, which simplifies troubleshooting and ensures that serious errors aren’t inadvertently masked

Avoiding the suppression of fatal exceptions—by handling and re-throwing them—allows Laravel and tools like Sentry to capture and log these issues properly, enabling you to address underlying problems.

Incorporating transactions when executing multiple operations preserves data integrity, preventing partial updates and orphaned records.

Together, these strategies not only streamline debugging but also enhance overall system reliability and transparency.