Skip to main content

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:

LayerOn failure
ValidationSends a 422 and stops the request
MiddlewareReturns a Response, which is sent instead of the controller's
Query builderReturns false, does not throw
Schema builderThrows RuntimeException
Your servicesThrow, 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');
}
warning

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.