Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Development Guides/Error Handling

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

  1. Overview
  2. Exception Types
  3. Custom Exceptions
  4. Exception Handler
  5. User-Friendly Errors
  6. API Error Responses
  7. 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

LayerResponsibility
ValidationForm Requests handle input validation
Business LogicServices throw domain exceptions
ControllerCatches exceptions, returns responses
Exception HandlerGlobal handling, logging, rendering

Exception Types

Built-in Laravel Exceptions

ExceptionHTTP CodeWhen to Use
ModelNotFoundException404Model not found (auto from route binding)
AuthenticationException401User not authenticated
AuthorizationException403User not authorized
ValidationException422Validation failed
NotFoundHttpException404Route not found
MethodNotAllowedHttpException405Wrong HTTP method
TooManyRequestsException429Rate 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

SituationLog LevelExample
User error (validation)Don't logInvalid form input
Expected business errorinfo/warningInvoice already paid
Unexpected errorerrorDatabase connection failed
Critical failurecritical/emergencyPayment 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