Version Migration
Migrating From v7 to v8
Breaking Change: Kirby Headless v8 validates the bearer token before the global catch-all route resolves anything, and brings routing, rendering and caching in line with Kirby's own resolver.
Most of the sections below describe the catch-all and only apply with headless.globalRoutes enabled. Five apply to every setup: Empty Tokens Are Rejected, Bearer Auth Without a Token Falls Back, Endpoint Caching Follows the Same Rules, Unknown Status Codes No Longer Throw and Middleware Return Values, which reaches every chain you build with the API builder.
Files Behind the Token
In v7, the catch-all route resolved files before validating the bearer token, so clean file URLs such as /about/hero.jpg responded without an Authorization header. In v8 the token is validated first.
Media URLs are unaffected. /media/pages/… is served by Kirby's own routes and never required a token, and since $file->url() returns the media URL by default, images in your frontend keep working unchanged.
Clean File URLs Are Opt-In
Clean file URLs now pass through Kirby's content.fileRedirects option, which is disabled by default. If you link to paths such as /about/hero.jpg, enable it:
return [
'content' => [
'fileRedirects' => true
]
];
The option also accepts a closure to decide per file.
Site Files Only at the Root Level
Site files now resolve only for paths without a slash, matching Kirby. In v7, any made-up path ending in the filename – for example /some/deep/path/logo.png – returned the site file logo.png as well.
Pages Win Over Files
Kirby strips the extension before it looks up a page, so a page claims the path whenever its ID matches the path without that extension – whatever the extension is. With a page blog/post, a file post.pdf next to it is no longer served at /blog/post.pdf: the path belongs to the page, which then answers according to the rules below. In v7 the file won.
Extensions Follow Kirby's Content Representations
The catch-all now answers the way Kirby's own resolver does:
- The extensionless path returns page JSON, rendered from the page's template.
.jsonrenders a*.json.phpcontent representation if you have one, and otherwise falls back to the page's template – so page JSON keeps working without any extra file.- Any other extension renders its matching representation (
.xml→*.xml.php) under the MIME type that extension implies. .htmlredirects to the canonical page URL with a301.- An extension with no representation responds with the error page.
In v7 every extension returned page JSON with a 200, and content representations were ignored entirely – so /about.xml served JSON under a URL that promised XML, even on a site that had an about.xml.php.
Non-Default Languages Resolve Again
On multi-language sites the catch-all is scoped into every language's router, and the default language's pattern matches any path. In v7 the route answered a prefixed URL such as /de/ueber-uns from the default language and ended Kirby's language cascade before the German route was ever tried, so every non-default language responded with the error page. Only sites that gave every language a URL prefix were unaffected.
The route now steps aside for a path carrying another language's prefix, unless the current language resolves the full path to a page of its own. No configuration change is needed.
Templates Shape the Response
The catch-all used to build its own response and discard whatever the template had configured, so a status code or a header set in a JSON template never left the server. The template now receives Kirby's response object and its settings win; Kirby Headless only fills in what is left. See Shaping the Response.
If one of your templates already sets a status code for its own reasons, that code now reaches the client. Nothing changes for templates that only echo JSON.
Drafts Render With a Preview Token
A draft used to answer with the error page under every circumstance. It now renders for a logged-in Panel user who may access the page, or for a request carrying a valid preview token – the same rule Kirby applies to its own rendering. Public requests still get the error page. See Previewing Drafts.
The Page Cache Mirrors Kirby's
Cached responses now store the response configuration next to the body, and the cache key gained the language, the content type and the rendered version. Two consequences:
$kirby->response()->cache(false)andexpires()are honored, where before every response was cached until the content changed.- A response that depended on the visitor's credentials is no longer replayed for anyone else.
Endpoint Caching Follows the Same Rules
/api/__sitemap__ and /api/__template__ cached every response, including those for requests carrying query or body data that their cache key could not tell apart. Both now render fresh for such requests, and both accept the X-Cacheable: false header that the KQL endpoint has always honored.
Unknown Status Codes No Longer Throw
Api::createResponse() threw an exception for any status code outside a list of eleven. It now takes the message from Kirby's own table and falls back to the class of the code – Client Error, Server Error – for anything unlisted, so a custom route can answer with 429 or 507.
Middleware Return Values
A middleware returning something other than a Response, a File or an array used to be ignored silently. Anything but null and arrays now ends the chain and becomes the answer, which lets a middleware return a Page or a plain string the way a Kirby route can. If you relied on a return value being dropped, return null instead.
The Catch-All Answers Every Request Method
The route is now registered for every HTTP method. In v7 it matched GET only, so POST, HEAD, PUT, PATCH, DELETE and OPTIONS fell through to Kirby's own catch-all and served pages and files without ever reaching the token check.
Empty Tokens Are Rejected
Leaving headless.token unset still disables authentication. A token that is set but blank – empty or whitespace only, the common result of an unresolved environment variable – now rejects every request with 401 instead of serving the site publicly.
Bearer Auth Without a Token Falls Back
Setting kql.auth to 'bearer' without a headless.token used to leave /api/kql open to everyone. It now falls back to Kirby's native API authentication. Use kql.auth => false if you want the endpoint public on purpose.
The Panel Redirect Only Catches Browsers
With headless.panel.redirect enabled, a request is only redirected to the Panel when it sends an Accept header that asks for something other than JSON – which is what a browser navigation does. A client that asks for JSON, or sends no Accept header at all, gets a JSON answer instead. In v7 every request without an Authorization header was redirected, which stranded any HTTP client that does not set Accept by default.
Migrating From v6 to v7
Breaking Change: Kirby Headless v7 requires Kirby 5 and corrects the status codes the endpoints answer with. A frontend that checks status codes needs updating; nothing in config.php does. Stay on the v6 line until the site itself is upgraded.
Endpoints Answer 200 Instead of 201
/api/__sitemap__ and /api/__template__ responded with 201 Created, which read as a write to every HTTP client that looks at the status. Both are read-only and now answer 200. Client code branching on 201 – or a response.status === 201 check in a test – has to accept 200.
Missing Pages Answer 404
A request for a page that does not exist served the error page under a 200. It now responds with 404, and the error page is the body. A frontend that only checked the status now sees the failure it was missing; one that checked the body for an error marker keeps working.
The Sitemap Payload Changed
Two entries answer differently, so a frontend that reads them needs a second look:
- A page with no resolvable modification date emitted
"modified": null. The key is now left out entirely – read it as optional rather than nullable. - Only a trailing
.utf-8was stripped from a language'sLC_ALLlocale before an hreflang code was derived. Every charset and modifier is now removed, sode_DE@euroyieldsde-dewhere it used to yieldde-de-euro.
Render Hooks Fire for API Requests
page.render:before and page.render:after now run when the catch-all renders a page, matching Kirby's own Page::render(). A hook you wrote for the Panel or for HTML rendering now also runs for every headless request – check that it does not assume a browser.
Resolved Fields Share the Bucket
With a resolvedKey configured and more than one field of a block resolved, only the last field survived. All resolved fields of a block now appear side by side, so a frontend reading a single key finds its siblings next to it.
Migrating From v5 to v6
Breaking Change: Kirby Headless v6 removes custom CORS handling in favor of Kirby's native CORS support, which needs Kirby 5.2.0 or higher.
CORS Configuration Changes
If you were using headless.cors configuration in v5, migrate to Kirby's native cors option:
return [
'headless' => [
'cors' => [
'allowOrigin' => '*',
'allowMethods' => 'GET, POST, OPTIONS',
'allowHeaders' => 'Accept, Content-Type, Authorization, X-Language, X-Cacheable',
'maxAge' => '86400'
]
]
];
return [
'cors' => [
'allowOrigin' => '*',
'allowMethods' => ['GET', 'POST'],
'allowHeaders' => ['Accept', 'Content-Type', 'Authorization', 'X-Language', 'X-Cacheable'],
'maxAge' => 86400
]
];
allowMethods and allowHeaders now accept arrays instead of comma-separated strings, and maxAge accepts an integer instead of a string. The OPTIONS method is handled automatically by Kirby.