Errors, Logging & Debugging
Exception handling, custom error pages, the Log facade and debugging tools.
Things go wrong in every application: a record doesn't exist, input is invalid, a user isn't allowed to do something, a third-party API is down. Laravel turns each of these into an exception, then decides two things about it: whether to report it (write it to a log or error tracker) and how to render it (the HTML page or JSON the user receives). This lesson covers both, plus logging and the debugging tools you'll use daily.
Try it: the error gallery
Each button calls GET /api/demo/errors/{case} on this site. The controller throws one kind of exception on purpose, and Laravel's handler turns it into a response. Compare the status codes, headers and JSON bodies.
None of these requests are logged: they're expected errors, the kind a client causes, not bugs. A genuine bug (an uncaught TypeError, say) would render as a 500 with the message Server Error in production, and be written to the log with a full stack trace.
Where it's configured
In Laravel 11 and later, exception handling lives in bootstrap/app.php. This app adds one rule: anything under api/ is always rendered as JSON, even if the client forgot the Accept: application/json header.
bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
// API routes always answer with JSON (422 errors, 404s), even without an Accept header
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
})->create();
Without that rule, a browser opening /api/demo/projects/nope would get Laravel's HTML 404 page instead of a JSON body.
Built-in exceptions and their status codes
You rarely need to write return response(..., 404) by hand. Throw (or let Laravel throw) the right exception and the handler picks the status:
| Thrown by | Exception | Status | Reported? |
|---|---|---|---|
firstOrFail(), findOrFail(), route model binding |
ModelNotFoundException |
404 | No |
abort(404), unknown route |
NotFoundHttpException |
404 | No |
$this->authorize(), Gate::authorize() |
AuthorizationException |
403 | No |
auth middleware with no user |
AuthenticationException |
401 / redirect to login | No |
$request->validate(), Form Requests |
ValidationException |
422 / redirect back | No |
throttle middleware |
ThrottleRequestsException |
429 | No |
| Expired CSRF token | TokenMismatchException |
419 | No |
| Anything else | e.g. RuntimeException, QueryException |
500 | Yes |
The real controller behind the gallery triggers five of them:
app/Http/Controllers/Api/ErrorDemoController.php
/**
* Throw one kind of exception on purpose so you can see how Laravel renders it.
* Nothing here writes to the database or the log.
*/
public function __invoke(Request $request, string $case): JsonResponse
{
match ($case) {
// ModelNotFoundException → 404
'not-found' => Project::where('slug', 'no-such-project')->firstOrFail(),
// ValidationException → 422 with an errors bag
'validation' => $request->validate([
'email' => ['required', 'email'],
'age' => ['required', 'integer', 'min:18'],
]),
// HttpException via abort() → 403
'forbidden' => abort(403, 'Only the project owner can delete tasks.'),
// Our own exception with a render() method → 409
'conflict' => $this->completeTask(Task::query()->completed()->firstOrFail()),
// ThrottleRequestsException → 429, normally thrown by the throttle middleware
'throttled' => throw new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => 60]),
};
return response()->json(['message' => 'No exception was thrown.']);
}
abort() and friends
abort() throws an HttpException with the status and message you give it. The conditional versions keep guard clauses on one line:
abort(403, 'Only the project owner can delete tasks.');
abort_if($task->isComplete(), 409, 'Task already completed.');
abort_unless($request->user()?->is($project->owner), 403);
Custom exceptions
When a business rule breaks, give it its own exception class. The code that detects the problem just throws it; the class knows how to render itself. This app has one for completing a task that's already done:
app/Exceptions/TaskAlreadyCompletedException.php
/**
* A domain rule broken by the caller: completing a task that is already done.
* It renders itself as a 409 and is expected, so it is not written to the log.
*/
class TaskAlreadyCompletedException extends RuntimeException implements ShouldntReport
{
public function __construct(public readonly Task $task)
{
parent::__construct("Task #{$task->id} was already completed.");
}
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => $this->getMessage(),
'task_id' => $this->task->id,
'completed_at' => $this->task->completed_at?->toIso8601String(),
], 409);
}
}
And the code that throws it:
private function completeTask(Task $task): void
{
if ($task->isComplete()) {
throw new TaskAlreadyCompletedException($task);
}
}
Three things to notice:
render()on the exception class is picked up automatically: no handler registration needed. It returns a409 Conflictwith useful context (task_id,completed_at).ShouldntReportmarks it as expected, so it never fills the log. Leave that interface off for exceptions that signal a bug you'd want to hear about.- The constructor takes the model, so the message and the response both have the details. Exceptions are objects; give them data.
You can also register rendering and reporting centrally instead of on the class, which is handy for exceptions from packages you don't own:
// Illustrative — not in this app
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (PaymentDeclined $e, Request $request) {
return response()->json(['message' => $e->getMessage()], 402);
});
$exceptions->report(function (PaymentDeclined $e) {
Log::channel('payments')->warning('Payment declined', ['order' => $e->orderId]);
})->stop(); // don't also write it to the default log
$exceptions->dontReport([InvalidCoupon::class]);
$exceptions->throttle(fn (Throwable $e) => Limit::perMinute(100)); // cap noisy errors
})
Debug mode and error pages
APP_DEBUG decides how much a user sees when something breaks:
APP_DEBUG=true(local only): JSON errors includeexception,file,lineand the fulltrace; browsers get the detailed error page with the code that failed.APP_DEBUG=false(production, and this site): a 500 says onlyServer Error. Expected HTTP errors (403, 404, 409…) still show their message, because you wrote those messages for users.
APP_DEBUG=true. Stack traces reveal file paths, SQL, and sometimes environment values such as passwords.
For browsers, customise the HTML error pages by creating a view named after the status code. Laravel uses it automatically:
{{-- resources/views/errors/404.blade.php — illustrative, not in this app --}}
@extends('layouts.app')
@section('content')
<h1>Page not found</h1>
<p>{{ $exception->getMessage() ?: 'That page has moved or never existed.' }}</p>
<a href="{{ url('/') }}">Back to the home page</a>
@endsection
errors/4xx.blade.php and errors/5xx.blade.php act as fallbacks for any status in that range. Run php artisan vendor:publish --tag=laravel-errors to copy Laravel's own templates as a starting point.
Logging
The Log facade writes messages at the eight standard levels, from debug up to emergency. Always pass context as an array instead of building strings: log tools can then filter and search on each field.
This app's queued listener logs every completed task:
app/Listeners/LogTaskCompletion.php
public function handle(TaskCompleted $event): void
{
$task = $event->task->loadMissing('project');
Log::info('Task completed', [
'task_id' => $task->id,
'title' => $task->title,
'project' => $task->project->slug,
]);
}
That produces a line like:
[2026-10-19 09:14:02] production.INFO: Task completed {"task_id":65,"title":"Design new homepage hero","project":"website-relaunch"}
Other everyday patterns:
Log::warning('Stripe webhook signature mismatch', ['ip' => $request->ip()]);
Log::error('Import failed', ['file' => $path, 'exception' => $e]);
// Add context to every log line for the rest of the request
Log::withContext(['request_id' => (string) Str::uuid()]);
// Send to a specific channel defined in config/logging.php
Log::channel('slack')->critical('Payments are failing');
// Report an exception you caught, without stopping the request
try {
$rates = Http::get($url)->throw()->json();
} catch (RequestException $e) {
report($e);
$rates = $this->cachedRates();
}
Channels are configured in config/logging.php. The default stack channel fans out to the channels listed in LOG_STACK: single writes to storage/logs/laravel.log; daily rotates a file per day and deletes old ones; stderr (what the local Docker setup for this app uses) suits containers; slack and papertrail send to external services. Set LOG_LEVEL to hide debug noise in production.
Debugging tools
| Tool | Use it for |
|---|---|
dump($value) / dd($value) |
Print a value (and stop, for dd). Also chainable: $query->dd(), collect(...)->dump(). |
php artisan pail |
Tail the application log live in the terminal, filtered by level or text (--filter="Task completed"). Installed in this app for development. |
php artisan tinker |
Run code against the real app: try a query, dispatch a job, inspect config. |
| Laravel Telescope | A dashboard of every request, query, job, mail and exception (illustrative: not installed here). |
| Laravel Debugbar | A toolbar showing queries and timing on each page (illustrative: not installed here). |
->toSql(), DB::listen() |
See the SQL a query produces; see the Query Builder lesson. |
dd() behind: it ends the request with a 500 in production. A quick grep -rn "dd(" app/ before committing, or Pint plus code review, catches them.
Key takeaways
- Every uncaught exception goes through one handler that reports it (log) and renders it (response). Configure both in
bootstrap/app.phpwithwithExceptions(). - Use the built-in exceptions:
findOrFail,abort,authorizeandvalidateproduce the right 404, 403 and 422 responses without extra code. They aren't logged. - Give business rules their own exception classes with a
render()method. Mark expected onesShouldntReport. - Keep
APP_DEBUG=falsein production. Customise browser error pages withresources/views/errors/{status}.blade.php. - Log with the
Logfacade and an array of context. Choose channels and levels inconfig/logging.php, and usereport($e)for exceptions you catch and recover from. dd,dump,pailandtinkercover most day-to-day debugging.