Public routes for apps
Since Basehim 1.2.72 an app registers its public pages and endpoints through one small API, $this->router()->site(). The routes go into core's own router. They sit beside the admin, your pages and your permalinks without taking any of them over, and a faulty app costs only itself.
A complete, working example ships with Basehim in docs/examples/public-routes. Copy the folder to content/apps/public-routes, activate it, and open /example.
Registering routes
Register routes in boot(), which runs on every request while the app is active:
use App\Core\Http\SitePage;
use App\Core\Request;
use App\Core\Response;
public function boot(): void
{
$site = $this->router()->site();
$site->get('/shop', [$this, 'index'], ['name' => 'shop.index']);
$site->get('/shop/{slug}', [$this, 'show'], ['where' => ['slug' => '[a-z0-9-]+']]);
$site->post('/api/shop/cart', [$this, 'add'], ['rate' => [30, 60]]);
$site->put('/api/shop/cart/{id}', [$this, 'update'], ['where' => ['id' => '\d+']]);
$site->patch('/api/shop/cart/{id}', [$this, 'patch']);
$site->delete('/api/shop/cart/{id}', [$this, 'remove']);
$site->match(['GET', 'POST'], '/shop/search', [$this, 'search']);
$site->group(['prefix' => '/account', 'auth' => true], function ($r) {
$r->get('', [$this, 'account']); // /account
$r->get('/orders', [$this, 'orders']); // /account/orders
});
}
- Methods: GET, POST, PUT, PATCH and DELETE. A GET route also answers HEAD.
- Return value: each call returns
truewhen the route was registered, andfalsewhen it was refused. The reason goes to the core log, to your app's log (Apps → Logs) and to the app's report (Apps → Report → Routes). - Handlers: a closure,
[$this, 'method'], or[ControllerClass::class, 'method']. A controller class given by name is only loaded when its route is used, so your other code isn't loaded on every request. - Repeated registration: registering the same route twice (boot code that runs twice, say) is one route, not a conflict.
Paths and parameters
| Pattern | Matches |
|---|---|
/shop | exactly /shop (a trailing slash is ignored) |
/shop/{slug} | one segment: /shop/blue-mug |
/shop/{slug?} | optional, last segment only: /shop and /shop/x |
/files/{path:*} | the rest of the path, slashes included; last segment only |
- Order: parameters are passed to the handler after the request, in the order they appear:
function show(Request $request, string $slug). - Decoding: parameters arrive URL-decoded, so
/tag/caf%C3%A9givescafé. - Refused with 400: a parameter containing an encoded
/(except in a{path:*}wildcard), a NUL byte or invalid UTF-8, and any path with a.or..segment. where: narrows a segment with a regular expression, for example['where' => ['id' => '\d+']]. A path that doesn't match is simply not this route. The pattern may not contain/,#, or named or look-around groups.
Options
| Option | Meaning |
|---|---|
name | A unique name, for $this->router()->url('shop.item', ['slug' => $slug]). The URL includes the install's base path. |
where | Parameter constraints, as above. |
auth | A signed-in account is required. Without one: 401 for JSON requests, or the sign-in page. The handler gets the account as $app->make('auth.user'). |
can | Capabilities, comma separated, for example 'can' => 'edit_posts'. Implies auth. A missing capability gets 403. |
csrf | For POST, PUT, PATCH and DELETE. On by default. See below. |
rate | [requests, seconds] per visitor; [10, 60] is ten a minute. Over the limit: 429 with Retry-After. |
middleware | Extra middleware classes, run after the checks above. |
replace | Take over GET / or GET /search from core (see Precedence). |
An unknown option is refused, so a typo such as midleware can't silently switch a check off. Options given to group() apply to every route in the group, and middleware lists are joined.
CSRF, sessions and stateless calls
A request that changes something (POST, PUT, PATCH or DELETE) and carries this site's session cookie must also carry the session's CSRF token. That means an X-CSRF-Token header or a _csrf form field; without it the request gets 403. Your page gets the token from $this->session()->csrfToken().
<form method="post" action="<?= $e($base) ?>/api/shop/cart">
<input type="hidden" name="_csrf" value="<?= $e($csrf) ?>">
…
</form>
A request with no session cookie has no signed-in session for another site to abuse. It passes the CSRF check, and it is anonymous: a server calling your public JSON endpoint, or a cookie-less script. Validate its input like any public form, and add rate.
'csrf' => false is for endpoints that other servers call, such as webhooks; verify their signature yourself. It can't be combined with auth or can.
Note: Routes added with the older helpers,
$this->get(),$this->post()and$this->route(), keep their old behaviour, with no automatic CSRF check, so existing apps keep working. Prefersite()for new code.
Answering
A handler returns a Response, or something core turns into one. A string is sent as HTML and an array as JSON:
return SitePage::view($this, 'item', ['item' => $item], ['title' => $item['name']]); // page in the theme
return Response::json(['items' => $items]); // JSON
return Response::json(['errors' => $errors], 422); // JSON with a status
return Response::redirect($this->router()->url('shop.index'), 303); // redirect
return Response::make($csv, 200, ['Content-Type' => 'text/csv; charset=utf-8']); // anything else
return SitePage::notFound(); // the theme's 404
return SitePage::error(409, 'Conflict', 'That item was changed meanwhile.'); // a short error page
Pages in the active theme
SitePage::view($this, $template, $data, $page) renders views/{template}.php from your app and puts it inside the theme's layout, as for a core page. That includes the header, footer, menus, the Customizer's styles and the SEO tags.
- Page options:
title,description,status,robots, andlayout => falsefor your HTML alone, without the theme. - Which theme template: the theme's
templates/app.phpif it has one (it receives$contentand$title), otherwise itspage.php. Every theme haspage.php, and it prints the title above your content, so don't repeat it as an<h1>. - Theme overrides: a theme can restyle your page by shipping
templates/apps/{your-slug}/{template}.php. - Escaping: templates escape their output, as core's do. Every template receives
$e()as a shortcut, as in<?= $e($item['name']) ?>, and$base, the install's base path. - Styling: don't rely on the theme having particular CSS classes. Inline or bundled styles (
$this->asset()) are safer. - A missing template is logged in your app's log, and the visitor gets a plain 500 page.
SitePage::html($title, $html, $page) does the same with HTML you built yourself.
Precedence and conflicts
Routes are tried in this order, whatever order apps boot in:
- An app route registered with
'replace' => trueforGET /orGET /search. Only one app can do so, and it is logged. - Core's routes with a fixed first segment:
/admin/…,/api/v1/…,/ajax,/search,/feed,/posts/{slug},/category/{slug},/uploads/…and so on. - App routes, admin and public, most specific first: more fixed segments, then fewer wildcards, then fewer parameters, then registration order. So
/shop/cartis tried before/shop/{slug}. - Core's content routes,
/{slug}and/{category}/{slug}: your pages and posts, under every permalink setting.
What is refused, and why (logged once a day and shown in the app's report):
| Refused | Because |
|---|---|
A public route under /admin | It would skip sign-in and the admin's permission checks. Use adminGet()/adminPost(). |
A site() route under /ajax, /api/v1, /mcp, /oauth, /.well-known, /uploads, /content or /storage | Reserved for core. Your own API can live at /api/your-app/…. |
A route whose first segment is a parameter (/{slug}) | It would answer every page of the site. Use fallback() for URLs nothing else serves. |
| A route core already serves (same method and path shape) | Core routes always win. |
| A route another app registered first | The first is kept, and the report names the app that has it. |
A route you do get can hide content. If your app serves /shop and a page has the slug shop, that page can't be reached at /shop. The app's report lists such cases. New pages and posts never get a slug an app route uses.
When nothing matches, the answer is 405 with an Allow header if the path exists for other methods, and 404 otherwise.
Failures
- The app fails while loading or in
boot(): its routes are removed with its other registrations and the app is switched off. Its public URLs answer 503 until it is fixed. See When an app fails. - A handler throws: the visitor gets core's error page, HTML or JSON, with status 500 and no details (unless
APP_DEBUGis on). Your app's log gets the route, the exception and the file and line. A failing page doesn't switch the app off. - A bad route definition is refused when it is registered; the app's other routes are unaffected.
- Deactivating the app removes its routes on the next request, because an inactive app doesn't boot.
A complete example
This is the example app's entry class, slightly shortened. It serves GET /example (a page in the theme), GET /example/{slug}, GET /api/example (JSON) and POST /api/example (validated, rate-limited and CSRF-checked for browser sessions).
namespace Basehim\PublicRoutesExample;
use App\Core\Http\SitePage;
use App\Core\Request;
use App\Core\Response;
class App extends \App\Core\App
{
private const ITEMS = [
'blue-mug' => ['name' => 'Blue mug', 'price' => 12.5, 'summary' => 'Stoneware, 350 ml.'],
'green-plate' => ['name' => 'Green plate', 'price' => 18.0, 'summary' => 'Hand glazed, 26 cm.'],
];
public function boot(): void
{
$site = $this->router()->site();
$site->get('/example', [$this, 'index'], ['name' => 'example.index']);
$site->get('/example/{slug}', [$this, 'show'], [
'name' => 'example.item', 'where' => ['slug' => '[a-z0-9-]+'],
]);
$site->group(['prefix' => '/api/example'], function ($api) {
$api->get('', [$this, 'list']);
$api->post('', [$this, 'addNote'], ['rate' => [10, 60]]);
});
}
public function index(Request $request): Response
{
return SitePage::view($this, 'index', [
'items' => self::ITEMS,
'csrf' => $this->session()->csrfToken(),
], ['title' => 'Example shop']);
}
public function show(Request $request, string $slug): Response
{
$item = self::ITEMS[$slug] ?? null;
if ($item === null) return SitePage::notFound();
return SitePage::view($this, 'item', ['item' => $item], ['title' => $item['name']]);
}
public function list(Request $request): Response
{
return Response::json(['items' => self::ITEMS]);
}
public function addNote(Request $request): Response
{
$name = trim((string) $request->input('name', ''));
$note = trim((string) $request->input('note', ''));
if ($name === '' || $note === '' || mb_strlen($note) > 500) {
return Response::json(['ok' => false, 'error' => 'Give a name and a note.'], 422);
}
$notes = (array) $this->getSetting('notes', []);
$notes[] = ['name' => $name, 'note' => $note, 'at' => gmdate('c')];
$this->setSetting('notes', array_slice($notes, -50));
return Response::json(['ok' => true], 201);
}
}
And its views/item.php. The theme prints the title, so the view starts below it:
<article>
<p><a href="<?= $e($base) ?>/example">← All items</a></p>
<p><strong><?= $e(number_format($item['price'], 2)) ?></strong></p>
<p><?= $e($item['summary']) ?></p>
</article>
Caching
Routes aren't cached. Active apps register them on every request, which takes well under a millisecond. Enabling, disabling, installing or updating an app takes effect on the next request, with nothing to clear.
Read next
- Admin screens, routes and UI components, for pages inside the admin.
- AJAX actions, for browser features that save without a page load.
- Security for developers
Leave a comment