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.
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
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.
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'); // 500The 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 becomes500; - 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 |
{
"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.
<?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:
#[AdviceException(NotFoundException::class, GoneException::class)]And with no arguments it becomes the fallback — it takes everything nothing else claimed:
#[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
- Handlers listing classes — the exception is tested with
instanceof. - The argument-less handler, if there is one.
- 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
ValidationExceptionand its422come from - Middleware — rejecting from
before()withMiddlewareException - Responses — format negotiation, shared with errors
- Logging — where messages land, and at which level