Error Handling
Introductionâ
There is no global exception handler. Each layer signals failure differently, and knowing which is which is the difference between a caught error and a fatal one:
| Layer | On failure |
|---|---|
| Validation | Sends a 422 and stops the request |
| Middleware | Returns a Response, which is sent instead of the controller's |
| Query builder | Returns false, does not throw |
| Schema builder | Throws RuntimeException |
| Your services | Throw, or return WP_Error |
Returning errorsâ
The normal path is an error response:
public function destroy(Request $request)
{
$tag = Tag::query()->findOne(['id' => $request->id]);
if (!$tag) {
return Response::error('Tag not found')->httpStatus(404);
}
$tag->delete();
return Response::success('Tag deleted successfully');
}
Add a code when the frontend must branch on the reason rather than the message:
return Response::error('Connection expired')
->code('CONNECTION_EXPIRED')
->httpStatus(409);
Validation errorsâ
validate() never returns on failure â it emits a 422 and ends the request, so code after it always runs on valid input:
{
"status": "error",
"code": "VALIDATION",
"data": { "title": ["The title field is required."] }
}
The data field maps each field to its list of messages, which is what a form needs to render errors inline.
Database failures are silentâ
The query builder returns false on a database error rather than throwing. An unchecked write looks identical to a successful one:
$tag = Tag::query()->insert($validated);
if (!$tag) {
error_log(Connection::prop('last_error'));
return Response::error('Failed to save tag');
}
if (!$result) is the only thing standing between a failed write and a success response. Check every insert, update and delete â the builder will not raise for you.
Exceptionsâ
Throw for conditions a caller cannot reasonably continue past. Give them names that read at the catch site, and put them in app/src/Exception:
namespace YourPlugin\src\Exception;
if (!defined('ABSPATH')) {
exit;
}
use Exception;
class ConnectionIdNotFoundException extends Exception
{
public function __construct($connectionId, $code = 0, ?Exception $previous = null)
{
parent::__construct("Connection ID '{$connectionId}' not found.", $code, $previous);
}
}
Catch at the controller boundary and convert to a response â an uncaught exception in an AJAX handler produces a fatal error page where the client expects JSON:
public function show(Request $request)
{
try {
return Response::success($this->service->find($request->id));
} catch (ConnectionIdNotFoundException $e) {
return Response::error($e->getMessage())->httpStatus(404);
}
}
WP_Errorâ
A returned WP_Error is unwrapped into the error envelope automatically, mapping its code, message and data. Use it when the value comes from a WordPress API that already produced one:
$response = wp_remote_post($url, $args);
if (is_wp_error($response)) {
return $response; // becomes {"status":"error","code":"http_request_failed",...}
}
Transactionsâ
A failed write mid-sequence leaves partial data unless the whole sequence is wrapped:
Connection::startTransaction();
try {
$flow = Flow::query()->insert($flowData);
FlowNode::query()->insert($nodeData);
Connection::commit();
} catch (\Throwable $e) {
Connection::rollback();
return Response::error('Failed to create flow');
}
Remember the builder returns false instead of throwing, so check results inside the try and throw yourself if you want the rollback to fire. See Transactions.