joydeep-bhowmik / quire
File-based page router for plain PHP: every .php or .blade.php file is a route, with middleware and named routes.
Requires
- php: ^8.1
Requires (Dev)
- illuminate/events: ^13.34
- illuminate/view: ^13.34
Suggests
- illuminate/events: Required by illuminate/view when using the Blade adapter.
- illuminate/view: Render *.blade.php pages with Quire\Blade\BladeRenderer.
Provides
None
Conflicts
None
Replaces
None
README
A page-based router for plain PHP, inspired by Laravel Folio. No framework needed.
Drop .php files in a folder and each one becomes a route. Middleware and route names are declared at the top of the page.
A quire is a stack of folio sheets bound together.
pages/
├── index.php → /
├── about.php → /about
├── login.php → /login
├── users/
│ ├── index.php → /users
│ ├── [id].php → /users/42 $id = '42'
│ └── [id]/
│ └── edit.php → /users/42/edit $id = '42'
├── docs/
│ └── [...slug].php → /docs/a/b/c $slug = ['a', 'b', 'c'] (1+ segments)
├── blog/
│ └── [[...slug]].php → /blog, /blog/x/y $slug = [] or ['x', 'y'] (0+ segments)
├── admin/
│ ├── _middleware.php → (middleware for everything in admin/)
│ └── index.php → /admin
├── _partials/ → (ignored: files/folders starting with _ or . aren't routes)
└── _errors/
├── 404.php → 404 page
└── error.php → any other error page
Installation
composer require joydeep-bhowmik/quire
Requires PHP 8.1+. No other dependencies. For Blade pages, also install Blade (see Blade).
Usage
// public/index.php require __DIR__ . '/../vendor/autoload.php'; $quire = new Quire\Quire(); $quire->path(__DIR__ . '/../pages'); $quire->run();
Send every request that isn't a real file to index.php. With Apache, use
example/public/.htaccess. With PHP's built-in server, run
php -S localhost:8000 -t public public/index.php.
No Composer? Download the repo and require 'path/to/quire/bootstrap.php'; instead of the autoloader.
Example app
example/ is a ready-made starter project: a small Blade site styled with Tailwind CSS v4,
with its own composer.json that pulls in Quire and Blade. Copy the folder anywhere and run:
composer install # installs joydeep-bhowmik/quire, illuminate/view, illuminate/events composer serve # http://localhost:8000
example/
├── composer.json requires joydeep-bhowmik/quire + Blade, autoloads helpers.php
├── app.css Tailwind entry (@import "tailwindcss")
├── postcss.config.mjs @tailwindcss/postcss, like Next.js
├── package.json npm run dev / npm run build
├── public/
│ ├── index.php front controller (Quire + BladeRenderer)
│ └── css/app.css built CSS
├── helpers.php global helpers: asset(), users(), user(), initials(), is_current()
├── data/users.php fake data, outside pages/
└── pages/
├── _layouts/app.blade.php layout with nav
├── _components/ <x-avatar>, <x-error-page>, <x-error-action>
├── _errors/ 400, 401, 403, 404, 405, 419, 429, 500, 503 + error.blade.php
├── index.blade.php /
├── about.blade.php /about
└── users/
├── index.blade.php /users
└── [id].blade.php /users/{id}
The built CSS is checked in, so the demo is styled without Node. To change styles:
cd example npm install npm run dev # rebuilds public/css/app.css when pages change npm run build # minified build
tests/fixtures/ has a bigger app covering middleware, catch-alls, plain PHP pages and more.
Coding agents
Quire ships an Agent Skill that teaches coding agents (Claude Code and others that read
SKILL.md) its routing rules, metadata block, middleware, error pages, Blade setup and common mistakes.
Copy it into your project:
mkdir -p .claude/skills cp -r vendor/joydeep-bhowmik/quire/skills/quire .claude/skills/
Development
git clone https://github.com/joydeep-bhowmik/quire && cd quire composer install # dev dependencies include Blade composer test # run the test suite
Pages
A page is a normal PHP file. Route params are local variables, and $request is always there.
<?php // pages/users/[id].php use function Quire\{name, middleware, abort, e}; name('users.show'); middleware(['auth']); $user = find_user($id) ?? abort(404); ?> <h1>Hello <?= e($user->name) ?></h1>
What the page returns decides the response:
| Page does | Response |
|---|---|
outputs HTML (echo, ?>...) |
200 text/html |
return ['ok' => true]; |
JSON |
return redirect('/somewhere'); |
that Response |
abort(403, 'Nope'); |
error page with 403 |
respond($response); |
that response, from anywhere (even templates) |
Page metadata
Put these at the top of the page, before any other code:
use function Quire\{name, middleware, methods}; name('users.edit'); // name the route for route() middleware('auth', 'role:admin'); // page middleware methods('GET', 'POST'); // default is GET only (HEAD is automatic)
Quire reads only the run of use, name(), middleware() and methods() statements at the top of the file,
so it can find a page's middleware and name without running the page. When the page actually renders, these calls do nothing.
Anything after the first other statement isn't read as metadata.
POST to a GET-only page gives 405 with an Allow header. HTML forms can fake PUT/PATCH/DELETE with a hidden _method field.
Blade
Pages can be Blade templates. Install Blade (no Laravel app needed) and register the adapter:
composer require illuminate/view illuminate/events
use Quire\Blade\BladeRenderer; $blade = new BladeRenderer( viewPaths: __DIR__ . '/pages', // where @extends / @include look cachePath: __DIR__ . '/storage/views', // compiled templates ); $blade->components(__DIR__ . '/pages/_components'); // <x-card> → pages/_components/card.blade.php $quire->extension('.blade.php', $blade);
Now pages/team/[member].blade.php is served at /team/{member}. Route params, $request, middleware, names,
_middleware.php, and error pages (_errors/404.blade.php) all work as they do for plain PHP pages. Layouts and partials go in _-folders,
so they never become routes: @extends('_layouts.app') loads pages/_layouts/app.blade.php.
Put metadata at the top in an @php block (or a plain <?php ?> block):
@php use function Quire\{name, middleware}; name('team.manage'); middleware('auth'); @endphp @use('function Quire\route') @extends('_layouts.app') @section('content') <h1>Hi {{ $request->attributes['user'] }}</h1> <a href="{{ route('team.index') }}">Back</a> @endsection
A Blade template can't return a response. Call respond() instead, which works from any page:
<?php use function Quire\{name, respond, redirect, route, abort}; name('team.show'); if ($member === 'old-name') { respond(redirect(route('team.show', ['member' => 'new-name']), 301)); } $person = find($member) ?? abort(404); ?> @extends('_layouts.app') ...
Also on the adapter: ->directive('money', fn ($e) => "<?php echo number_format($e, 2); ?>"), ->share('appName', 'Acme'),
plus ->factory() and ->compiler() for anything else Blade can do.
Until a .blade.php renderer is registered, *.blade.php files are skipped. Their raw source is never served as PHP.
Other template engines
extension() takes any callable fn (string $file, array $vars): string|array|Response, so Twig, Latte or Plates can be plugged in the same way.
If an engine wraps exceptions, Quire still finds abort() and respond() inside them.
Middleware
A middleware is any callable fn (Request $request, Closure $next, ...$args) that returns a response,
usually $next($request). It can also be a class with a handle() method, or an invokable class.
use Quire\{Request, Response}; $quire->alias('auth', function (Request $request, Closure $next) { if (!$request->cookie('user')) { return Quire\redirect(Quire\url('/login')); // stop here } $request->attributes['user'] = $request->cookie('user'); // pass data to the page return $next($request); }); $quire->alias('role', function (Request $request, Closure $next, string ...$roles) { in_array($request->attributes['user'], $roles, true) || Quire\abort(403); return $next($request); }); $quire->alias('timing', function (Request $request, Closure $next): Response { $response = $next($request); // run the page first... return $response->header('X-Powered-By', 'Quire'); // ...then change the response }); $quire->alias('csrf', App\Middleware\VerifyCsrf::class); // class with handle()
'role:admin,editor' passes 'admin' and 'editor' as extra arguments.
There are four places to attach middleware. They run in this order (outermost first):
- Global:
$quire->middleware('timing') - By path pattern on a mount (relative to its URI,
*is a wildcard):$quire->path(__DIR__ . '/pages')->middleware([ '*' => ['timing'], 'account/*' => ['auth'], ]);
- By folder: a
_middleware.phpthat returns an array. It covers that folder and every subfolder, parent folders first.<?php // pages/admin/_middleware.php return ['auth', 'role:admin'];
- On the page:
middleware(...)at the top of the file.
Using a DI container? $quire->resolveUsing(fn (string $class) => $container->get($class));
Named routes & URLs
use function Quire\{route, url}; route('users.show', ['id' => 5]); // /users/5 route('users.show', ['id' => 5, 'tab' => 'x']); // /users/5?tab=x (extra params → query string) route('blog', ['slug' => ['2026', 'hello']]); // /blog/2026/hello route('blog'); // /blog (optional catch-all can be left out) url('/login'); // /login, with the base path added
Mounts, prefixes, base path
$quire->path(__DIR__ . '/pages'); // served at / $quire->path(__DIR__ . '/admin-pages')->uri('/admin')->middleware(['auth']); // served at /admin/... $quire->base('/my-app'); // app lives at example.com/my-app/
Route priority
More specific routes win, segment by segment: static > [param] > [...catchall] > [[...optional]].
So users/create.php beats users/[id].php. If two pages map to the same route (users.php and users/index.php,
or [id].php and [slug].php in the same folder), Quire throws an error.
Errors
abort($status, $message) anywhere (page or middleware) renders _errors/{status}.php (such as _errors/404.php),
then _errors/error.php, from the mount's root folder. Either can be .blade.php. If neither exists, you get plain text.
pages/_errors/
├── 404.blade.php ← not found
├── 403.blade.php ← forbidden
└── error.php ← everything else (405, 500...)
Error pages get $status, $message, $exception and $request.
Uncaught exceptions become 500. Turn on $quire->debug() to see the real message (never in production).
Request & Response
$request->method; $request->path; $request->query; $request->body; $request->cookies; $request->files; $request->input('name', 'default'); // body, then query $request->param('id'); // route param $request->header('Accept'); $request->attributes['user']; // set by middleware $request->isMethod('POST'); $request->wantsJson(); Response::html($html, 200); Response::json($data, 201); Response::redirect('/x', 303)->cookie('flash', 'Saved')->header('X-Foo', 'bar');
Inside pages and middleware, Quire\request() returns the current request.
Testing
$quire->handle(Request::create('POST', '/login', ['user' => 'bob'], cookies: [...])) returns a Response without sending it.
See tests/run.php.
License
MIT. See LICENSE.