Architecture
Extensions
Package pages, blocks, API endpoints, and chrome outside the core app. Core always resolves first; pages and API fall back across extensions, while blocks and chrome reach an extension only when named explicitly.
What Extensions Are
An extension is a self-contained package of pages, blocks, API endpoints, and chrome that lives outside the core app, under extensions/<slug>/. It lets a Bolt site grow with substantial, separable features — an admin area, a billing module, a custom auth system — without threading that code through the core directories.
The rule is core first, and core always wins — an extension only ever fills a gap, never silently overriding a core file. What happens when core has no match depends on the surface. A page or an API route falls back across the installed extensions. A block or a piece of chrome — a header, layout, or footer — does not: a bare name resolves in core only, and an extension's block or chrome is reached just by naming it explicitly.
Discovery is implicit. Any directory under extensions/ whose name is a valid slug is active — there is no registry to edit and nothing to enable. Drop a folder in, and its pages, blocks, and endpoints are reachable.
Anatomy of an Extension
An extension mirrors the core layout. Each sub-directory is the fallback target for the matching core area, so the path inside an extension is the same path you would use in core:
extensions/
billing/ the folder name is the slug
pages/
invoices/index.php → /invoices
blocks/
invoice.php → block('billing:invoice')
headers/ layouts/ footers/ chrome the extension can supply or name
api/
billing/charge/POST.php → POST /api/billing/charge
README.md
Add only the directories you need — an extension that ships just an API needs only an api/ folder.
Slug Rules
The folder name is the extension's slug. It must be lowercase and start with a letter or digit, then contain only lowercase letters, digits, and dashes (^[a-z0-9][a-z0-9-]*$) — no underscores, so a slug equals a sanitized URL path segment exactly (the basis for the URL targeting shown below). Names that don't match — anything with uppercase, an underscore, or a leading dot — are skipped, so the slug is always identical to the on-disk folder name on every filesystem. The core area names are reserved and can never be slugs:
api pages includes blocks headers layouts footers config
Resolution Order
Bolt always checks core first, so core takes precedence everywhere. What happens when core has no match depends on the surface:
- Blocks and chrome resolve in core only. A bare name never falls back to an extension — so two extensions can ship the same block path without either silently winning. To render an extension's block or chrome, name it explicitly with a
slug:reference (below). If core has no match and no slug was given, the lookup fails gracefully: a block renders nothing (an HTML comment) and a missing header, layout, or footer is simply skipped. - Pages and API routes fall back across extensions, since a URL can't carry a
slug:prefix: a first path segment that names an extension targets it directly, and a bare route is steered to the explicit form — a page that exactly one extension provides redirects to its/<slug>/route, and a route two or more provide resolves to a disambiguation page rather than a silent winner. Both are covered below.
| Reference | Searched in order |
|---|---|
block('login') |
core blocks/login.php only — a bare name never falls back to an extension |
GET /reports |
core pages/reports…, then extensions — one match redirects to /<slug>/reports, 2+ show a disambiguation page |
POST /api/billing/charge |
core api/billing/charge/POST.php, then each extension's api/ tree |
header 'default' |
core headers/default.php only — chrome never falls back to an extension |
block('billing:invoice') |
extensions/billing/blocks/invoice.php only (explicit — see below) |
The home route (/) is the one exception — it always loads core's pages/index.php and cannot be claimed by an extension.
Targeting One Extension
Two mechanisms pick a single extension explicitly, skipping core and every other extension.
Server-side: slug:
Prefix a block() name or a chrome value with slug::
echo block('invoice'); // core only
echo block('billing:invoice'); // billing extension only
$page['config']['header'] = 'billing:portal'; // that extension's header
This is a server-side affordance for block() names and the header/layout/footer values a page sets. Because blocks and chrome never fall back to an extension on their own, the slug: prefix is the only way to reach an extension's copy. It can't be typed into a URL — the routers strip : while sanitizing.
From the URL: /slug/route
Because slugs are hyphenated, a first path segment that matches a slug is the explicit form for pages and API routes — the URL twin of slug::
| URL | Resolves to |
|---|---|
/event-registration/dashboard |
extensions/event-registration/pages/dashboard.php |
GET /api/event-registration/report |
extensions/event-registration/api/report/GET.php |
This is authoritative: once the first segment names an extension, the rest of the path resolves inside that extension only — /<slug>/* is its namespace, and a miss there is a 404 (no fall-through). A core page at the same literal path still wins, because core is checked first, and /<slug> on its own loads that extension's pages/index.php.
When Two Extensions Collide
If a bare route — one with no slug prefix — is provided by more than one extension, Bolt does not pick an alphabetical winner. A page request returns a disambiguation page: an HTTP 404 whose body lists the explicit /<slug>/route link for each extension that has it. The API returns the same case as a 404 with a JSON candidates array.
A bare route that exactly one extension provides is not served at the bare URL either: a page request redirects to that extension's canonical /<slug>/route — a 302, so the bare path stays free to resolve elsewhere later — and loads there authoritatively. (An API request, with no address bar to canonicalize, simply serves that one endpoint.) A route no extension provides is an ordinary 404. So a bare URL never displays an extension's page outright; it only redirects to the explicit form, or — on a real collision — asks which you meant.
Per-Extension API Bootstrap
An extension's api/ tree gets its own _bootstrap.php chain. When an endpoint resolves into an extension, the bootstraps that run are the ones inside that extension, walked from its api/ root down to the resource directory — core's bootstraps are not in scope. So an extension can load its own database handle, configuration, or authentication for its endpoints without touching core, and without inheriting core's.
Serving and Security
- Source is include-only. An
extensions/.htaccessdenies direct web access to.phpfiles — they run only when a front controller includes them. Static assets (CSS, JS, images, fonts) under an extension stay web-servable, so blocks and pages can link to them. - Every lookup is confined. A resolved path is
realpath-checked against its root and rejected if it escapes — a name containing.., or a symlink pointing outside the root, never resolves. This is the same guarantee the block engine always made, now applied across pages, API, and chrome. - Reserved and malformed slugs are ignored. A folder named for a core area, or one with illegal characters, is simply never discovered.
A Worked Example
The shipped extensions/example/ exercises every surface. None of it exists in core, yet each route works:
| Surface | File | Reach it at |
|---|---|---|
| Page | pages/index.php |
GET /example (the slug is the route) |
| Block | blocks/example-card.php |
rendered by the page via block('example:example-card') |
| API + bootstrap | api/ping/GET.php, api/_bootstrap.php |
GET /api/example/ping |
Visiting /example renders a page that lives only in the extension (reached because example is its slug), and that page renders the extension's block through an explicit example:example-card reference — a bare block('example-card') would look in core only, where no such block exists. GET /api/example/ping targets the extension's api/ tree and returns JSON proving the route resolved and the extension's own bootstrap ran.
Adding an Extension
There is nothing to register. Create a folder under extensions/, give it a valid slug, and add the directories you need:
extensions/
reports/
pages/
reports/index.php → /reports
blocks/
chart.php → block('reports:chart')
api/
reports/export/GET.php → GET /api/reports/export
Reload the site and the routes are live. Because resolution is core-first, you can later move a feature into core — or into another extension — without changing how it is referenced.