Web basics

Error handling

You do not catch an error and turn it into a response by hand. You throw an exception from anywhere — a controller, a service, middleware — and the framework picks the HTTP status, the response format and the log level itself.

Base ResponseExceptionOwn handlers #[AdviceException]Format by Accept

What error handling is and why

Error handling is turning a failure — not found, not allowed, the database went away — into a correct HTTP response.

The problem. Returning an error as a value is awkward: the result has to be threaded through every layer, and every intermediate function has to check it and pass it up. Usually that does not happen — calls get wrapped in try/catch and the response is assembled on the spot. The result is inconsistency: one endpoint answers 404, another answers the same situation with 200 and {"error": ...}; somewhere a stack trace leaks into the body; in the logs an expected 404 sits next to a database outage at the same level.

The solution. Throw an exception where you discover the problem. The framework catches it at the top, chooses the status, negotiates the format with the client and writes a log entry at the right level. Intermediate layers need not know about the failure, and responses come out the same across the whole application.

Throwing

php
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\Kernel\Http\Response\ResponseException;

throw new ResponseException('User not found', HttpCode::NOT_FOUND);

// the same as an expression — handy inside a ternary or ?:
ResponseException::throw('Forbidden', HttpCode::FORBIDDEN);

// with an extra header
throw new ResponseException('Rate limit exceeded', HttpCode::TOO_MANY_REQUESTS)
  ->withHeader('Retry-After', '60');

There is nowhere you need to catch this: the exception rises from any depth — from a service, from a repository, from a middleware’s before() — and becomes the response.

Throwing beats returning

return ResponseEntity::notFound() from deep inside a method means the caller is obliged to recognise that response and pass it on. An exception travels through the layers by itself, so the “did we find the record” check is one line, written where it belongs.

Which exception to choose

The type sets the default status and the log level. The status can be overridden by the second argument; the level cannot.

Exception Status Log level When to throw
ResponseException 400 by status Any expected HTTP error
ClientError 409 warning The client asked for something the domain rules forbid
ServerError 500 error A failure on our side: an external service, disk, network
Error 520 by status When it is unclear on the spot whose fault it is
KernelError 500 emergency An invariant of the core itself was violated
MiddlewareException 401 by status A rejection from middleware
ValidationException 422 by status A validation failure — thrown for you

“By status” means 5xx is written as error and 4xx as warning. That keeps a failing gateway and an ordinary 404 out of the same stream.

php
use Flytachi\Winter\Kernel\Exception\ClientError;
use Flytachi\Winter\Kernel\Exception\ServerError;

throw new ClientError('Email already taken');                    // 409
ClientError::throw('Slot is booked', HttpCode::UNPROCESSABLE_ENTITY);

throw new ServerError('Payment gateway timeout');                // 500

The difference between ResponseException and ClientError is intent. The first says “answer with this status” and lives close to HTTP; the second describes a broken domain rule and need not know about status codes at all. In services and repositories the second fits better.

Ordinary PHP exceptions

You may throw anything — RuntimeException, LogicException, an error from a third-party library. It will become a response too, but:

  • the status comes from getCode(), and if that does not look like an HTTP status it becomes 500;
  • it is logged as error, because only framework exceptions declare a level.

So an unhandled TypeError from the depths turns into an honest 500 rather than a blank page.

What the client receives

The body is assembled from the Accept header, exactly as for ordinary responses:

Accept What is sent
application/json, */* or no header JSON
application/xml XML
text/html A page with the status and the message
json
{
"code": 404,
"message": "User not found"
}

A ValidationException additionally carries an errors map in the body — see Validation.

Debug mode

With DEBUG=true debug data is added to the body, and for Accept: text/html a detailed page with a stack trace. With DEBUG=false only code and message remain.

The exception message is always visible to the client

What is hidden is the stack trace, not the text. throw new ServerError("Could not connect to db-prod-01: wrong password for user app_rw") reaches the client verbatim, in production too.

Put in the message only what you are willing to show outside, and send the details to the log separately or attach the original exception through previous.

Your own handlers — #[AdviceException]

When the default format does not fit — you need your own error code, extra fields, a particular shape for a partner API — write a handler class. It needs no registration; the scan finds it.

main/Exception/DomainErrorResponse.php
<?php

namespace Main\Exception;

use Flytachi\Winter\Kernel\Http\Response\AdviceException;
use Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase;

#[AdviceException(DomainException::class)]
class DomainErrorResponse extends ExceptionResponseBase
{
  protected function contentData(): array
  {
      return [
          'error'  => 'domain_error',
          'code'   => $this->throwable->getCode(),
          'detail' => $this->throwable->getMessage(),
      ] + $this->debugData();
  }
}

One handler can cover several types:

php
#[AdviceException(NotFoundException::class, GoneException::class)]

And with no arguments it becomes the fallback — it takes everything nothing else claimed:

php
#[AdviceException]
class FallbackErrorResponse extends ExceptionResponseBase { /* ... */ }

What you can override

Method What it is responsible for
contentData(): array The body for JSON and XML
contentHtml(): string The body for Accept: text/html

Available inside:

What What for
$this->throwable The exception itself
$this->httpCode The status that will be sent
debugData() The debug block — empty when DEBUG=false
validationRequests() The validation error map; [] for other exceptions
addHeader() Add a header to the error response

Add debugData() to your body, or in debug mode you will lose the stack trace on exactly the errors you debug most often.

Selection order

  1. Handlers listing classes — the exception is tested with instanceof.
  2. The argument-less handler, if there is one.
  3. The default behaviour.

Do not write overlapping handlers

If an exception matches two handlers with explicit classes — say one declared on RuntimeException and another on its subclass — the one the scanner met first wins. File traversal order is undefined, so it cannot be relied on: keep handler scopes disjoint.

Errors outside a request

Everything above concerns errors inside an HTTP request. A failure in a background process, a daemon or a console command has no HTTP response to become — it goes to the log at the same levels. Where the log goes and how to configure channels is on the Logging page.

Next

  • Validation — where ValidationException and its 422 come from
  • Middleware — rejecting from before() with MiddlewareException
  • Responses — format negotiation, shared with errors
  • Logging — where messages land, and at which level