Skip to main content
Error Handling

Error Handling

Error handling decides what users see and what the team learns. Laravel reports exceptions to logs and renders a response based on the request type: an error page for browsers, JSON for API clients.

Throwing errors

abort(404);
abort(403, 'You cannot view this invoice.');
abort_if(! $user->active, 403);
abort_unless($user->can('view', $invoice), 403);

For domain failures, throw a dedicated exception. It documents intent and can be rendered consistently.

namespace App\Exceptions;

use RuntimeException;

class PaymentDeclined extends RuntimeException
{
    public function __construct(public readonly string $reason)
    {
        parent::__construct('Payment declined: '.$reason);
    }
}

Rendering by exception type

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (PaymentDeclined $e, Request $request) {
        return $request->expectsJson()
            ? response()->json(['message' => $e->reason], 402)
            : back()->withErrors(['payment' => $e->reason]);
    });
})

Custom error views

Place 403.blade.php, 404.blade.php, or 500.blade.php in resources/views/errors. Laravel uses them for HTTP exceptions and, when debug mode is off, for uncaught errors. Keep these pages simple and free of sensitive details.

Logging versus displaying

Users need a short message and a way forward. Operators need a request identifier, stack trace, and context. Do not put exception messages or SQL fragments into a production error page. Log the details instead and show a generic explanation.

Recoverable failures

Not every failure is an exception. A failed third-party sync can be reported and retried. A malformed CSV row can be collected and shown in an import report. Reserve exceptions for conditions that break the current operation.

try {
    $crm->push($customer);
} catch (ConnectionException $e) {
    report($e);
    $customer->update(['sync_pending' => true]);
}

Testing failure paths

$this->get('/invoices/999')
    ->assertNotFound();

$this->actingAs($user)
    ->get('/admin')
    ->assertForbidden();

$this->post('/payments', $payload)
    ->assertStatus(402)
    ->assertJson(['message' => 'Card expired']);

Production checklist

  • APP_DEBUG=false and a custom 500 page.
  • Log level set so warnings survive but debug noise does not fill the disk.
  • Error reporting to a service that groups duplicate exceptions.
  • A request identifier echoed to the user and stored with each log entry.
  • Alerts on new exception types, not only on volume spikes.