Error Handling Guide
Last Updated: 2026-01-08 Status: Active Audience: Developers
This guide documents error handling patterns and best practices for the Client Portal application.
Table of Contents
- Overview
- Exception Types
- Custom Exceptions
- Exception Handler
- User-Friendly Errors
- API Error Responses
- Logging Errors
Overview
Proper error handling ensures:
- Users see helpful messages, not stack traces
- Developers get detailed information for debugging
- Security-sensitive details are not exposed
- Errors are logged appropriately
Error Handling Layers
| Layer | Responsibility |
|---|---|
| Validation | Form Requests handle input validation |
| Business Logic | Services throw domain exceptions |
| Controller | Catches exceptions, returns responses |
| Exception Handler | Global handling, logging, rendering |
Exception Types
Built-in Laravel Exceptions
| Exception | HTTP Code | When to Use |
|---|---|---|
ModelNotFoundException | 404 | Model not found (auto from route binding) |
AuthenticationException | 401 | User not authenticated |
AuthorizationException | 403 | User not authorized |
ValidationException | 422 | Validation failed |
NotFoundHttpException | 404 | Route not found |
MethodNotAllowedHttpException | 405 | Wrong HTTP method |
TooManyRequestsException | 429 | Rate limit exceeded |
Handling Built-in Exceptions
use Illuminate\Database\Eloquent\ModelNotFoundException;
// In controller - usually not needed (auto-handled)
public function show(string $id): View
{
try {
$client = Client::findOrFail($id);
} catch (ModelNotFoundException $e) {
abort(404, 'Client not found');
}
return view('clients.show', compact('client'));
}
// Better - use route model binding (auto 404)
public function show(Client $client): View
{
return view('clients.show', compact('client'));
}
Custom Exceptions
Creating Domain Exceptions
<?php
namespace App\Exceptions;
use Exception;
class InvoiceException extends Exception
{
/**
* Invoice has no line items.
*/
public static function emptyInvoice(): self
{
return new self('Invoice must have at least one line item.', 422);
}
/**
* Invoice is not in correct status.
*/
public static function invalidStatus(string $current, string $required): self
{
return new self(
"Cannot perform this action. Invoice is '{$current}', must be '{$required}'.",
422
);
}
/**
* Invoice already paid.
*/
public static function alreadyPaid(): self
{
return new self('This invoice has already been paid.', 422);
}
/**
* Payment failed.
*/
public static function paymentFailed(string $reason): self
{
return new self("Payment failed: {$reason}", 402);
}
}
Creating HTTP Exceptions
<?php
namespace App\Exceptions;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ClientAccessDeniedException extends HttpException
{
public function __construct(string $message = 'You do not have access to this client.')
{
parent::__construct(403, $message);
}
}
Using Custom Exceptions
// In service
class InvoiceService
{
public function sendInvoice(Invoice $invoice): Invoice
{
if ($invoice->items->isEmpty()) {
throw InvoiceException::emptyInvoice();
}
if ($invoice->status !== InvoiceStatus::Draft) {
throw InvoiceException::invalidStatus(
$invoice->status->value,
InvoiceStatus::Draft->value
);
}
// Send logic...
}
}
// In controller
public function send(Invoice $invoice, InvoiceService $service): RedirectResponse
{
try {
$service->sendInvoice($invoice);
return redirect()->back()->with('success', 'Invoice sent.');
} catch (InvoiceException $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
Exception Handler
Customizing bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use App\Exceptions\InvoiceException;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
// Custom rendering for specific exceptions
$exceptions->render(function (InvoiceException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], $e->getCode() ?: 422);
}
return back()->with('error', $e->getMessage());
});
// Don't report certain exceptions
$exceptions->dontReport([
InvoiceException::class,
]);
// Add context to all exceptions
$exceptions->context(function () {
return [
'user_id' => auth()->id(),
'url' => request()->fullUrl(),
];
});
})->create();
Reportable vs Renderable
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PaymentException extends Exception
{
/**
* Report the exception (logging).
*/
public function report(): bool
{
// Custom logging
Log::channel('payments')->error($this->getMessage(), [
'code' => $this->getCode(),
]);
return true; // Return true to stop default reporting
}
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): Response
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Payment processing failed.',
'error_code' => $this->getCode(),
], 402);
}
return response()->view('errors.payment', [
'message' => $this->getMessage(),
], 402);
}
}
User-Friendly Errors
Flash Messages Pattern
// In controller
public function destroy(Client $client): RedirectResponse
{
if ($client->projects()->exists()) {
return redirect()
->back()
->with('error', 'Cannot delete client with active projects. Archive projects first.');
}
$client->delete();
return redirect()
->route('admin.clients.index')
->with('success', 'Client deleted successfully.');
}
Error Views
Create custom error pages in resources/views/errors/:
{{-- resources/views/errors/404.blade.php --}}
<x-guest-layout>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-300">404</h1>
<p class="text-xl text-gray-600 mt-4">Page not found</p>
<p class="text-gray-500 mt-2">The page you're looking for doesn't exist.</p>
<a href="{{ route('dashboard') }}" class="mt-6 inline-block text-blue-600 hover:underline">
Return to Dashboard
</a>
</div>
</div>
</x-guest-layout>
{{-- resources/views/errors/403.blade.php --}}
<x-guest-layout>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-300">403</h1>
<p class="text-xl text-gray-600 mt-4">Access Denied</p>
<p class="text-gray-500 mt-2">{{ $exception->getMessage() ?: 'You do not have permission to access this page.' }}</p>
<a href="{{ url()->previous() }}" class="mt-6 inline-block text-blue-600 hover:underline">
Go Back
</a>
</div>
</div>
</x-guest-layout>
Validation Error Display
{{-- Show all validation errors --}}
@if ($errors->any())
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<h3 class="text-red-800 font-medium">Please fix the following errors:</h3>
<ul class="mt-2 text-red-700 text-sm list-disc list-inside">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- Show error for specific field --}}
<div>
<label for="email">Email</label>
<input type="email" name="email" id="email"
class="@error('email') border-red-500 @enderror"
value="{{ old('email') }}">
@error('email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
API Error Responses
Consistent Error Format
// Standard error response structure
{
"success": false,
"message": "Human-readable error message",
"errors": {
"field_name": ["Error for this field"]
},
"code": "ERROR_CODE" // Optional machine-readable code
}
API Controller Error Handling
class ApiController extends Controller
{
/**
* Return a success response.
*/
protected function success($data = null, string $message = null, int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
/**
* Return an error response.
*/
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors,
], $code);
}
}
// Usage
class ClientController extends ApiController
{
public function store(StoreClientRequest $request): JsonResponse
{
$client = Client::create($request->validated());
return $this->success(
new ClientResource($client),
'Client created successfully.',
201
);
}
public function destroy(Client $client): JsonResponse
{
if ($client->projects()->exists()) {
return $this->error(
'Cannot delete client with active projects.',
422,
['client' => ['Client has active projects']]
);
}
$client->delete();
return $this->success(null, 'Client deleted successfully.');
}
}
API Validation Errors
Form Requests automatically return JSON for API requests:
{
"message": "The given data was invalid.",
"errors": {
"email": [
"The email field is required.",
"The email must be a valid email address."
],
"company_name": [
"The company name must be at least 2 characters."
]
}
}
Logging Errors
When to Log
| Situation | Log Level | Example |
|---|---|---|
| User error (validation) | Don't log | Invalid form input |
| Expected business error | info/warning | Invoice already paid |
| Unexpected error | error | Database connection failed |
| Critical failure | critical/emergency | Payment gateway down |
Logging with Context
use Illuminate\Support\Facades\Log;
try {
$this->processPayment($invoice);
} catch (PaymentException $e) {
Log::error('Payment processing failed', [
'invoice_id' => $invoice->id,
'client_id' => $invoice->client_id,
'amount' => $invoice->total,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
Don't Log Sensitive Data
// Bad - logs password
Log::info('Login attempt', ['email' => $email, 'password' => $password]);
// Good - omit sensitive data
Log::info('Login attempt', ['email' => $email]);
// Good - mask if needed
Log::info('API call', ['api_key' => '***' . substr($apiKey, -4)]);
Try-Catch Patterns
When to Use Try-Catch
// DO use for external services
try {
$response = Http::post('https://api.payment.com/charge', $data);
} catch (ConnectionException $e) {
Log::error('Payment API unreachable', ['error' => $e->getMessage()]);
return back()->with('error', 'Payment service temporarily unavailable.');
}
// DO use when you can handle the error meaningfully
try {
$service->processInvoice($invoice);
} catch (InvoiceException $e) {
return back()->with('error', $e->getMessage());
}
// DON'T catch just to rethrow
try {
$client = Client::findOrFail($id);
} catch (ModelNotFoundException $e) {
throw $e; // Pointless!
}
// DON'T catch Exception broadly
try {
$this->doSomething();
} catch (Exception $e) {
// Too broad - catches everything
}
Multiple Exception Types
try {
$service->processPayment($invoice);
} catch (InvoiceException $e) {
return back()->with('error', $e->getMessage());
} catch (PaymentException $e) {
Log::error('Payment failed', ['error' => $e->getMessage()]);
return back()->with('error', 'Payment could not be processed. Please try again.');
} catch (\Exception $e) {
Log::critical('Unexpected error during payment', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return back()->with('error', 'An unexpected error occurred.');
}
Best Practices
Do
- Create domain-specific exceptions
- Provide user-friendly error messages
- Log unexpected errors with context
- Use appropriate HTTP status codes
- Let Laravel handle common exceptions
- Create custom error pages
Don't
- Expose stack traces to users
- Log sensitive information
- Catch exceptions without handling them
- Use generic error messages for all errors
- Swallow exceptions silently
- Return 200 for error responses