Skip to content

Refusing a Call ServiceError

Dory Azar edited this page Nov 15, 2026 · 1 revision

A capability refuses a call by throwing a Caligrafy\ServiceError. It never prints an error, redirects or picks an HTTP status itself: each channel turns the same refusal into its own form, a status and a JSON error for REST, a tool error for an agent, the errors of a form for a page. Since 2.1.0.


With Claude

Say when the capability must refuse:

/caligrafy-quill:new-capability lend_book loans:write: lend a book to a reader; refuse when the book is already out

Claude throws ServiceError::notFound() for a book that does not exist and ServiceError::conflict() when it is lent.

Check before you say OK: php caligrafer.php call <name> ... with the case that must be refused prints {"error": {"code": ...}} with the code you expect, and exits with 1.


By hand

use Caligrafy\ServiceError;

public function handle(array $input, Context $context)
{
    $book = Book::find($input['book_id']);
    if (!$book) {
        throw ServiceError::notFound('There is no book #' . $input['book_id']);
    }
    if (Loan::where('book_id', $input['book_id'])->whereNull('returned_at')->exists()) {
        throw ServiceError::conflict('This book is already lent');
    }
    // ...
}

The invalid inputs of $rules are refused before handle() runs, with a message per field: you throw invalid only for what the rules cannot say.

throw ServiceError::invalid(['due_at' => 'The due date must be after today']);

The five kinds

Throw Code Status Default message When
ServiceError::invalid(['field' => 'message'], $message) invalid 422 Some of the inputs are invalid an input is wrong
ServiceError::unauthenticated($message) unauthenticated 401 Sign in or send a personal access token nobody is signed in
ServiceError::forbidden($message) forbidden 403 You are not allowed to do this this caller may not do it
ServiceError::notFound($message) not_found 404 Not found the thing does not exist
ServiceError::conflict($message) conflict 409 (a message is required) it clashes with the current state

new ServiceError($kind, $message = '', $fields = []) does the same; any other kind throws InvalidArgumentException. On the error: $e->kind, $e->fields, $e->getMessage(), $e->status() and $e->toArray(), which is {"error": {"code": "invalid", "message": "...", "fields": {...}}} (fields only when there are some).

The framework throws the same errors itself: not_found for an unknown capability, unauthenticated and forbidden ("This needs the scope books:write") from the access checks, invalid from the input rules.


What each channel makes of it

Channel What the caller gets
REST (Route::capability, Route::capabilities) the status of the kind and $e->toArray() as JSON; 401 also sends WWW-Authenticate: Bearer
MCP a tool result with "isError": true and $e->toArray() as its text, so the agent can read the reason and correct itself
php caligrafer.php call $e->toArray() as JSON, exit code 1
The JS client a CapabilityError with status, code, message and fields
Route::form, Route::action (a browser) invalid or conflict: back, with errors(), old(), flash('error'); unauthenticated: the sign-in page; forbidden: 403 [ERROR 1006]; not_found or anything else: 404 [ERROR 1005]
Route::form, Route::action (a JSON request) the status of the kind and the REST error
Route::page data a visitor goes to sign in (with the authentication module), otherwise 403 or 404; with 'notFound' => 'Folder/view' that view, its $message the refusal
Snap an Ask card for invalid (the fields to fix), a notice for the others

Every refusal is written to the audit log, its outcome the code and its detail the message. Any other exception is written with the outcome error and the exception's class, then thrown on: it is a bug, not a refusal.

Reference: ServiceError, Capabilities::call().


Related pages

Caligrafy: Describe it. Approve it. Ship it. Your idea turned into a complete app: every feature built once, for web pages, Vue apps, a REST API and AI agents, and online.

Start here

Why Caligrafy: the benefits, the measurements, and what it is not yet

  1. Quickstart with Claude: describe it, approve it, ship it

    or the Quickstart: the same three steps by hand, in 5 minutes

  2. How Caligrafy works: the ideas in 5 minutes
  3. Your first capability: build a feature, page included

Capabilities

Experiences

Data and people

  1. Models
  2. Relationships
  3. Queries: filters, paging, related rows
  4. Sign-in, users and tokens

Built-in features

Put it online

Build with AI

Reference

Clone this wiki locally