docs / Routes & ports
Routes & ports
Every route gets a decision before the build finishes: a compiled handler, a snapshot, a static file, a port — or a declared gap. Silence is a failed build.
What --strict enforces
The route list comes from Laravel itself (route:list, dumped at build time). For each route the compiler asks: can this handler be compiled? If yes, it is. If not, the route must be declared — a static file, a port, or an explicit ignore. An undeclared route fails the build; a declared one ships and is named in the verdict.
That is the whole guarantee. “Runs on the edge” never means “every route was compiled”: it means every route has a decision, and the ignored ones are printed where a reviewer can see them.
registry.json
One file at the app root, optional. init scaffolds it; the build reads it if present.
{
"autoSnapshot": true,
"static": { "GET /health": "public/health.html" },
"ports": { "POST /hooks/stripe": "stripe-webhook" },
"ignore": { "GET /admin/legacy": "served by a separate Worker" },
"views": {},
"guards": { "seal": "seal-guard" },
"config": ["needs"]
}| key | meaning |
|---|---|
| autoSnapshot | render clean GET routes at build time into static snapshots (the 0-invocation tier) unless a route opts out |
| static | route → file in public/: served as an asset, never invokes the worker |
| ports | route → port name: the module in ports/<name>.mjs answers that route |
| ignore | route → reason: declared away. It answers 404 at the edge and is listed in the READINESS block |
| guards | auth driver → port name, for a driver the app registers itself (Auth::extend) |
| config | config groups the runtime may read. Without a declaration, a config('group.key') the build cannot answer fails loud |
A port
A port is an ES module with a fetch(ctx) method — ordinary JavaScript at the edge, with access to the app's compiled bindings. The build imports it statically; dynamic module loading is forbidden in the Workers runtime.
// ports/stripe-webhook.mjs
export default {
async fetch(ctx) {
const body = await ctx.request.text();
if (!validSignature(body, ctx.app.env.STRIPE_SECRET)) {
return new Response('bad signature', { status: 400 });
}
await ctx.app.db.prepare('insert into events (body) values (?)').bind(body).run();
return new Response('ok');
},
};build --scaffold writes a 501 stub for every route that needs a port, so the gap becomes a file you can see instead of a surprise in production. A guard port is written by hand: it implements user(ctx), not fetch — seeauth & sessions.
Guard ports
A non-session guard driver the app registers itself (Auth::extend('seal', …)) is PHP the compiled rung cannot run — and answering “no user” would let a guarded route through. The refusal is therefore config-level, before routes are compiled: declaring the driver (guards → "seal": "seal-guard") and providing ports/seal-guard.mjswith async user(ctx) turns it green. The middleware, auth()->user(), the 401 and the redirect stay compiled; only the driver is yours.
When a route changes shape
If two routes would collide at the edge (same method and pattern after compilation), the build names both and suggests declaring one in registry.json. It does not pick a winner: the order a framework resolves routes in is part of its contract, and guessing it is exactly the class of silent difference this toolchain refuses.
Source: README.md §Compiles today, §A guard port; tools/init.mjs; tools/ports.mjs — repository ba1e9a5