Skip to content

Public routes for apps

10 min read

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 true when the route was registered, and false when 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

PatternMatches
/shopexactly /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%A9 gives café.
  • 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

OptionMeaning
nameA unique name, for $this->router()->url('shop.item', ['slug' => $slug]). The URL includes the install's base path.
whereParameter constraints, as above.
authA 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').
canCapabilities, comma separated, for example 'can' => 'edit_posts'. Implies auth. A missing capability gets 403.
csrfFor 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.
middlewareExtra middleware classes, run after the checks above.
replaceTake 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. Prefer site() 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, and layout => false for your HTML alone, without the theme.
  • Which theme template: the theme's templates/app.php if it has one (it receives $content and $title), otherwise its page.php. Every theme has page.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:

  1. An app route registered with 'replace' => true for GET / or GET /search. Only one app can do so, and it is logged.
  2. Core's routes with a fixed first segment: /admin/…, /api/v1/…, /ajax, /search, /feed, /posts/{slug}, /category/{slug}, /uploads/… and so on.
  3. App routes, admin and public, most specific first: more fixed segments, then fewer wildcards, then fewer parameters, then registration order. So /shop/cart is tried before /shop/{slug}.
  4. 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):

RefusedBecause
A public route under /adminIt 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 /storageReserved 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 firstThe 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_DEBUG is 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">&larr; 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment