Repository navigation
A Routes and Controllers
In this appendix you look under the hood: you write routes that answer with a function, a controller that reads the models itself and renders a page (an author and their books), and a classic JSON route. Then you compare them with capabilities, to see what the other experiences (REST, the JS client, AI agents) do not get.
Before you start: this appendix is optional. Do it any time after lesson 06 (the checkpoint appendix-a is built
on lesson-13, but nothing here needs loans). The containers run (docker compose ps).
Time: 45 minutes.
Route. A route connects an HTTP method and an address (a path) to the code that answers. Every route is one line
of application/config/routing/web.php:
Route::get('/about', function () { echo '...'; });
// ▲ ▲ ▲
// │ │ └─ what answers: a function (a "closure"), a controller action, or a capability
// │ └─ the path
// └─ the HTTP method: get, post, put or deleteYou already wrote several kinds of routes: Route::page('/books/{id}', 'Bookshelf/book', ['data' => ...]) (a page
with data from capabilities, lesson 06), Route::put('/books/{id}', 'Bookshelf\\BookController@update') (a controller,
lesson 06), Route::capability(...) (REST, lesson 07) and Route::mcp('/mcp') (agents). This appendix writes
closures and controllers by hand, the classic way, without capabilities: useful to read older projects. In Bookshelf
you keep Route::page for pages and the capabilities for the data.
Closure. function () { ... } without a name: a function written right where it is used. Fine for a tiny page.
Route variable. A part of the path in braces, {name} in /hello/{name}, matches any value in that position.
The code reads it with request()->fetch('name'); inside a controller, $this->request->fetch('name').
The three rules of routing:
-
Route::run();is the last line inside thetry { }ofweb.php. TheRoute::...lines only declare routes;Route::run()picks the one that matches and runs it. A route declared after it is never used. -
The first route that matches wins. A route matches when the method is the same and the path has the same
number of segments (
/authors/2has two:authorsand2). So/authors/newmust come before/authors/{id}, or{id}would catch the wordnew(Appendix B). -
An address that matches no route shows the first route (
/): Caligrafy has no automatic "404" for unknown addresses. That is why/stays the first route.
Controller and action. A controller is a class that extends Caligrafy\Controller; each public method that
answers a route is an action. The route names both: 'Bookshelf\\AuthorController@show' (namespace, class,
@, action). BookController (lesson 06) is one: its only action saves a form by calling a capability.
AuthorController reads the models itself, the classic way. Never name an action like a method of Controller (find, save, delete, all,
search, check, validate, filter, view, api, associate, table).
Namespace. Caligrafy loads every PHP file of application/, so two classes with the same name would crash every
page. namespace Bookshelf; makes the full name Bookshelf\AuthorController. In a single-quoted PHP string the
backslash is written twice: 'Bookshelf\\AuthorController'.
The controller's database helpers. $this->associate('Author', 'authors') says which model and table the next
calls work on. Then $this->find() returns the row whose id is the {id} of the route (or nothing),
$this->all(array('order' => 'ORDER BY name')) returns every row. A model's relationships work as in lesson 03:
$author->books().
view() and api(). view('Bookshelf/author', $data) renders a Pug page. api($data) sends $data as JSON:
the classic JSON output, without the {"data": ...} envelope of capabilities.
Controller routes and API calls. A request that asks for JSON (Accept: application/json) is an API call.
For a controller route, Caligrafy then requires a token with the scope * (or the legacy key, turned off in
lesson 12): scopes such as books:read mean nothing to a controller. A browser (no such header) gets the page or
the JSON without a token. Since Caligrafy Quill 2.7.0, a page of your own site may also make that JSON call with the
visitor's session: it sends the session's CSRF token in an X-CSRF-Token header ({ headers: apiHeaders() }, from
public/js/services/app.js). Never the legacy key in a page: it is public, and it is off online.
In application/config/routing/web.php, add these lines above // MUST NOT BE REMOVED and Route::run(); (the
full file is in the checkpoint):
// APPENDIX A: routes and controllers written by hand
Route::get('/about', function () {
echo 'Bookshelf: a small catalog of the books we love.';
});
Route::get('/hello/{name}', function () {
$name = request()->fetch('name'); // the {name} part of the address
echo 'Hello ' . sanitize($name) . '!'; // sanitize: never print what a visitor typed as is
});sanitize() turns <, >, & and quotes into HTML entities, so an address that contains a <script> shows text
instead of running it. Always escape what comes from the address or a form before you print it (Pug views do it for
you).
Check, from the bookshelf folder:
curl http://localhost:8080/about
curl http://localhost:8080/hello/UrsulaExpected: Bookshelf: a small catalog of the books we love. and Hello Ursula!. Add -i to see the status:
the first line is HTTP/1.1 200 OK.
File application/controllers/Bookshelf/AuthorController.php:
<?php
namespace Bookshelf;
use Caligrafy\Controller;
// The classic way: a controller reads the models itself and renders a view. No capability is involved,
// so only this page has these features: REST clients and AI agents do not see them
class AuthorController extends Controller
{
// GET /authors/{id}: an author and their books
public function show()
{
$this->associate('Author', 'authors'); // the model and the table this controller works on
$author = $this->find(); // finds the row whose id is {id} of the route
if (!$author) {
http_response_code(404);
return view('Bookshelf/notfound', array('message' => 'There is no such author.', 'authorized' => authorized()));
}
return view('Bookshelf/author', array('author' => $author, 'books' => $author->books(), 'authorized' => authorized()));
}
// GET /v1/authors: JSON for programs (Accept: application/json and a token with the scope *)
public function index()
{
$this->associate('Author', 'authors');
return api(array('authors' => $this->all(array('order' => 'ORDER BY name'))));
}
}-
namespace Bookshelf;is the first statement after<?php; the foldercontrollers/Bookshelfmatches it. -
show():associate(), thenfind()reads{id}from the route. No author: status 404 (set before the view) and the "Not found" page of lesson 06. Otherwise the author page, with the books of the author. -
index(): every author, sorted by name, as JSON for programs.
File application/views/Bookshelf/author.pug (the parts of lesson 06; $author and $books are models, so their columns are read with ->):
extends ../default/master/site
//- An author and their books, rendered by Bookshelf\AuthorController (the classic way)
block title
= $author->name . ' · Bookshelf'
block content
section.cq-hero.cq-hero--page
h1.cq-display= $author->name
p.cq-lead= $author->bio
section.cq-band.cq-band--tight
.cq-inner.cq-inner--narrow
ul.cq-rows
each $book in $books
li.cq-row
.cq-row-main
p.cq-row-title
a(href="{$home}books/{$book->id}")= $book->title
p.cq-row-meta= $book->published_year
p.cq-auth-note
a(href="{$home}books") All booksAdd the two routes below the lines of step 1:
Route::get('/authors/{id}', 'Bookshelf\\AuthorController@show');
Route::get('/v1/authors', 'Bookshelf\\AuthorController@index');Open http://localhost:8080/authors/2: the title "Octavia E. Butler", her bio, and her books (Kindred, Parable of the Sower, and the ones you added) with links to their pages, and "All books" at the bottom. Open http://localhost:8080/authors/99: the "Not found" page with "There is no such author.". Check the status:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/authors/99Expected: 404.
Open http://localhost:8080/v1/authors in your browser: the JSON of the authors (200). A browser does not ask for JSON, so this is not an API call and no token is needed. Now as a program:
curl -s -H "Accept: application/json" -w " [%{http_code}]\n" http://localhost:8080/v1/authorsExpected: {"error":{"code":1004,"message":"Access Denied: send the page's CSRF token in an X-CSRF-Token header (pages, with the session cookie) or Authorization: Bearer <personal access token> (programs and agents)"}} [401]:
since 2.7.0 the message says why. Try the "Claude Code" token of lesson 11
(books:read,books:write): put it in a variable, BOOKS_TOKEN=$WRITER in the same terminal as lesson 11 (otherwise
paste the "Claude Code" token of lesson 11, BOOKS_TOKEN=cq_...), then
curl -s -H "Accept: application/json" -H "Authorization: Bearer $BOOKS_TOKEN" -w " [%{http_code}]\n" http://localhost:8080/v1/authorsExpected: 401 again, and the message says the token does not grant everything (*) as controller routes need:
controller routes accept only tokens with the scope *. Create one:
docker compose exec dev-box php caligrafer.php token create "old style client" --scopes "*"Put the printed token in STAR_TOKEN=cq_... and call again with -H "Authorization: Bearer $STAR_TOKEN". Expected:
{"authors":[{"table":"authors","id":3,"name":"Italo Calvino",... (sorted by name). Compare with
GET /api/authors (lesson 07): there is no data envelope, and each author has a table field. That is the
classic api() output of a model.
A * token may call everything, capabilities included. Revoke it now: token list to find its id, then
docker compose exec dev-box php caligrafer.php token revoke <id>.
If you do this appendix before lesson 11: there is no "Claude Code" token yet, and the "my agent" token of lesson 02 has the scope
*, so it already gets the JSON.
- Open http://localhost:8080/does/not/exist: the catalog. No route matched, so the first route (
/) answered (rule 3). - Move the line
Route::run();above the// APPENDIX Ablock, save, wait 2 seconds and open http://localhost:8080/about: the catalog again, because/aboutwas declared afterRoute::run()(rule 1). MoveRoute::run();back just above the closing}of thetry, and check that/aboutworks again.
If it doesn't work
What you see Cause Fix The catalog instead of your page The route did not match: a typo in the path, another number of segments, or the route is below Route::run();Compare with steps 1 and 2 [ERROR 1001] Invalid Controller or Action(404)The route names a class or action that does not exist: missing Bookshelf\\, a typo, wrong case, or thenamespaceline is missingCompare the route and the file with step 2 Cannot redeclare class Bookshelf\AuthorControllerA copy of the file is somewhere in application/(every PHP file there is loaded)Delete the copy or move it out of application/[ERROR 500] syntax error, ...A PHP syntax error docker compose exec dev-box php -l application/controllers/Bookshelf/AuthorController.phpnames the line/v1/authorswith the*token still answers 401The token was not copied whole, or it was revoked token list; create a new oneYour change does not show PHP caches compiled files and checks for changes every 2 seconds Wait 2 seconds and reload
Start Claude Code in the bookshelf folder (claude) and ask:
Without capabilities, the classic way: in application/config/routing/web.php, in a block
"// APPENDIX A: routes and controllers written by hand" above "// MUST NOT BE REMOVED", add two closure routes:
GET /about that echoes "Bookshelf: a small catalog of the books we love." and GET /hello/{name} that echoes
"Hello <name>!" with the name read by request()->fetch('name') and escaped with sanitize(). Then create
application/controllers/Bookshelf/AuthorController.php (namespace Bookshelf, extends Caligrafy\Controller) with
show(): associate('Author', 'authors'), find() the {id} of the route, 404 with the view Bookshelf/notfound and the
message "There is no such author." when missing, else the view Bookshelf/author with the author and $author->books();
and index(): api(['authors' => all authors ordered by name]). Write application/views/Bookshelf/author.pug in
Caligrafy's look (extends ../default/master/site; a title band with the name and the bio, then a band with the books
as ul.cq-rows, links to /books/<id> with their year, and a p.cq-auth-note link to all books). Pass
'authorized' => authorized() to the views. Routes: GET /authors/{id} show, GET /v1/authors index.
What Claude should do: add the four routes in one block, create the controller and the view. The plugin's
caligrafy skill knows the routing rules and the reserved action names.
Check before you accept:
-
Route::get('/', ...)is still the first route andRoute::run();the last line insidetry. - The name of
/hello/{name}goes throughsanitize(), never printed raw. - The file starts with
namespace Bookshelf;and the routes say'Bookshelf\\AuthorController@...'. - No action is named
find,all,delete,save,vieworapi. -
http_response_code(404)comes before the view of a missing author. - The checks of steps 1 to 3 give the same answers (including the 401 on
/v1/authorsfor a token without the scope*).
If Claude's result differs: if Claude offers to write a get_author capability instead, say no for this
appendix (that is the point of the comparison below). If it reads {name} with $_GET, ask for
request()->fetch('name'): $_GET does not contain route variables.
AuthorController (this appendix) |
A capability (lessons 04 to 13) | |
|---|---|---|
| The author page | yes | yes, through a controller that calls it ($this->call()) |
| REST | only /v1/authors, with a * token, no data envelope |
any route, scoped tokens, {"data"} / {"error"} everywhere |
| The JS client (Vue apps, SPA) | no: there is no function for it | yes, after client
|
| AI agents (MCP) | no: no tool | yes, a tool with its description and schema |
| Rules, permissions, audit | written again in each controller | written once; refusals and changes audited |
An agent connected to /mcp has no tool for the author page or /v1/authors: everything it knows about authors comes
from list_authors. Write an action by hand when it belongs to one page only; write a capability when anyone else
may need it.
git add -A
git commit -m "Appendix A: routes and controllers by hand"
git tag appendix-aReference: checkpoints/appendix-a.
- You add
Route::get('/authors/top', ...)below/authors/{id}. What does/authors/topshow, and why? - Why does
/v1/authorsanswer 401 to the "Claude Code" token, while/api/authorsanswers 200 to anyone? -
/hello/{name}prints the name withsanitize(). What could happen without it?
Show the solution
- The "Not found" page of an author (404):
/authors/{id}comes first and matches (two segments), with{id}=top, and the first route that matches wins. Declare/authors/topabove/authors/{id}. -
/v1/authorsis a controller route: an API call to it needs a token with the scope*./api/authorsis the capabilitylist_authors, which ispublic: true, so anyone may call it. - A visitor could put HTML or a
<script>in the address and send the link to someone: the page would run it in their browser (an XSS attack).sanitize()turns it into harmless text.
Previous: Lesson 14 - Put it online · Next: Appendix B - Classic forms and controller APIs
Start
Describe it
Approve it
Ship it
Your turn
By hand
- 00 Your tools · 01 · 02
- 03 Your data · 04 · 05
- 06 Web pages · 07 · 08 · 09 · 10
- 11 People and permissions
- 12 Tests · 13 Loans · 14 Online · 15 Reminders
- A Routes · B Forms