josephscott / brilkic
A PHP web framework for quickly spinning up an HTTP site or service, runnable under the built-in server, PHP-FPM, Workerman, or FrankenPHP
Requires
- php: >=8.4
- laminas/laminas-escaper: ^2.18.0
- nikic/fast-route: ^1.3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95.2
- josephscott/phpcsfixer-config: ^0.0.6
- pestphp/pest: ^4.7.0
- phpstan/phpstan: ^2.2.0
- workerman/workerman: ^5.2
README
A PHP web framework for quickly spinning up a web site or HTTP service. The same app runs unchanged under the PHP built-in web server, PHP-FPM, FrankenPHP (classic and worker mode), and Workerman — only the entry point differs.
- Routes map to plain PHP files
- Each route file gets exactly one variable,
$here, for everything about the request and response - Output is just
echo, classic PHP style - Requires PHP 8.4+
Install
composer require josephscott/brilkic
A Minimal App
my-app/
├── composer.json
├── url-routes.php
├── routes/
│ ├── home.php
│ └── hello.php
└── public/
└── index.php
url-routes.php registers routes on the provided $router. Callback
file paths are relative to the routes file:
<?php $router->get( '/', 'routes/home.php' ); $router->get( '/hello/{name}', 'routes/hello.php' );
routes/home.php:
<?php echo 'Hello from brilkic';
routes/hello.php:
<?php echo 'Hello ' . $here->param( 'name' );
public/index.php:
<?php require __DIR__ . '/../vendor/autoload.php'; $server = new Brilkic\Server( __DIR__ . '/../url-routes.php' ); $server->run();
That is the whole app. A complete example using every feature lives in demo/, with make targets that spin it up under each runtime.
Running It
PHP Built-In Web Server
Good for development and trying things out. No configuration:
php -S 127.0.0.1:8080 public/index.php
PHP-FPM
Use the same public/index.php, pointed at by your web server. A minimal
nginx example:
server {
listen 8080;
root /path/to/my-app/public;
location / {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
include fastcgi_params;
}
}
Under PHP-FPM the routes are compiled on every request, so give fastroute
a cache file. Put it in an app-owned directory that no other user can
write to — never a world-writable directory like /tmp (see
The Route Cache File):
$server = new Brilkic\Server( __DIR__ . '/../url-routes.php' ); $server->cache_file( __DIR__ . '/../var/routes.cache' ); $server->run();
The cache file is not updated automatically when routes change, see The Route Cache File below.
A self-contained PHP-FPM + nginx config to copy from is in demo/config/.
FrankenPHP - Classic Mode
FrankenPHP behaves like a normal SAPI in classic mode, so the same
public/index.php works as is:
frankenphp php-server --root public/ --listen 127.0.0.1:8080
FrankenPHP - Worker Mode
In worker mode the app boots once and then handles requests in a loop,
skipping per-request startup cost. Create a worker.php in the app root:
<?php require __DIR__ . '/vendor/autoload.php'; $server = new Brilkic\Server( __DIR__ . '/url-routes.php' ); $server->run_frankenphp_worker();
Then point a Caddyfile at it:
{
frankenphp {
worker ./worker.php
}
}
localhost:8080 {
root * public/
php_server
}
frankenphp run
Workerman
Workerman is a pure PHP application server, no web server needed. Create
a workerman.php in the app root:
<?php require __DIR__ . '/vendor/autoload.php'; $server = new Brilkic\Server( __DIR__ . '/url-routes.php' ); $server->listen( 'http://0.0.0.0:8080' ); $server->workers( 4 ); $server->run_workerman();
composer require workerman/workerman
php workerman.php start
listen() sets the address to serve on (default http://0.0.0.0:8080),
workers() the number of worker processes (default 1).
Unlike PHP-FPM and FrankenPHP — which bound every request through php.ini
(post_max_size, memory_limit) — Workerman has no php.ini path. It caps
request headers at 16 KiB (431) and a whole request at 10 MiB (413) by
default; raise or lower that with
Workerman\Connection\TcpConnection::$defaultMaxPackageSize in this script.
Workerman also has no built-in per-worker connection limit or request
timeout, so for any worker deployment exposed to untrusted traffic, put a
reverse proxy (nginx, Caddy) in front for body-size limits, read timeouts,
and a connection cap.
The Route Cache File
The optional cache file set with $server->cache_file() is written
once, the first time it is missing, and trusted as is from then on —
fastroute never checks whether the routes have changed. Adding, changing,
or removing a route in url-routes.php has no effect until the cache file
is removed, so make deleting it part of every deploy or update:
rm -f var/routes.cache
Keep the cache file out of shared directories. The cache is PHP that
brilkic executes on every request, so a location any other user can write
to — /tmp and friends — lets them plant code that your app then runs.
Use an app-owned directory (var/, alongside your code) that is not group
or world writable. brilkic checks this at boot: if the cache directory is
writable by other users it refuses to run and logs the reason, rather than
trusting whatever is there.
The next request rebuilds it from the current routes. Two related notes:
- brilkic re-reads
url-routes.phpitself on every request, so the custom error page settings (not_found(),not_allowed(),error()) stay current even with a stale cache — it is the route patterns and their callback files that go stale - The worker runtimes (FrankenPHP worker mode, Workerman) do not use a cache file at all; they compile the routes once at boot, and restarting the workers picks up route changes
Route Files and $here
A route callback file is isolated from the rest of the environment: no
globals, no superglobals. The only variable in scope is $here, the
single gateway to everything about the request. This isolation is what
makes the same file work under every runtime above.
Reading the request:
$here->method(); // 'GET', 'POST', ... $here->path(); // '/hello/world' $here->param( 'name' ); // route parameter, e.g. {name} $here->query( 'page', '1' ); // query string argument $here->post( 'title', '' ); // form / POST body field $here->header( 'Accept', '' ); // request header, case insensitive $here->cookie( 'session', '' ); // cookie value $here->body(); // raw request body
Every read takes an optional default, returned when the value is missing.
Writing the response:
$here->set_status( 201 ); $here->set_header( 'Content-Type', 'application/json' ); $here->redirect( '/elsewhere' ); // 302 by default
The response body is whatever the file echoes:
<?php $here->set_header( 'Content-Type', 'application/json' ); echo json_encode( [ 'id' => $here->param( 'id' ) ] );
Templates
Declare a template directory on the server:
$server = new Brilkic\Server( __DIR__ . '/../url-routes.php' ); $server->templates( __DIR__ . '/../templates' ); $server->run();
Any route file can then pull in a template with template(), the path is
relative to the declared directory:
<?php // routes/home.php $here->set_header( 'Content-Type', 'text/html; charset=utf-8' ); template( 'header.php', [ 'title' => 'Home' ] ); echo '<p>Welcome</p>'; template( 'footer.php' );
The optional second argument is an array of key value pairs, available
inside the template file as $data:
<?php // templates/header.php ?> <!doctype html> <title><?php echo $data['title']; ?></title>
Template files are isolated the same way route files are: $data is the
only variable in scope. Templates can include other templates with
template(). A missing template file logs an error and renders nothing,
the rest of the page still goes out.
Escaping
Global esc_*() functions (backed by
laminas-escaper) are
available in route and template files. Escape anything that came from
the client before echoing it:
echo 'Nothing lives at ' . esc_html( $here->path() );
esc_html( $value )— HTML body textesc_attr( $value )— HTML attribute valuesesc_js( $value )— JavaScript contextsesc_css( $value )— CSS contextsesc_url( $value )— a URL component, e.g. one query string value (not a whole URL)
Escaping is UTF-8 by default. Serving another charset? Declare it at boot, next to the other server options:
$server->charset( 'iso-8859-1' );
Supported values are the encodings htmlspecialchars() handles (the
laminas-escaper list). An unsupported value is logged and ignored, the
previous charset stays active — output is always escaped, never raw.
Databases
MySQL and SQLite, through PDO. Register connections by name on the
server, then use them anywhere with db():
$server->database( 'main', 'sqlite:' . __DIR__ . '/../data/app.db' ); $server->database( 'stats', 'mysql:host=127.0.0.1;dbname=stats;charset=utf8mb4', 'user', 'pass' );
MySQL connections default to charset=utf8mb4 when the DSN does not name
one, and run with multi-statements disabled so a single call can only ever
execute a single statement.
Inside a route file, $here->db() is the first registered connection
and $here->db( 'stats' ) picks one by name:
<?php $db = $here->db(); $rows = $db->query( 'SELECT * FROM articles WHERE author = ?', [ $author ] ); $row = $db->row( 'SELECT * FROM articles WHERE id = ?', [ $id ] ); $count = $db->value( 'SELECT COUNT(*) FROM articles' ); $db->execute( 'INSERT INTO articles ( title ) VALUES ( ? )', [ $title ] ); $new_id = $db->insert_id(); $db->begin(); // ... several writes ... $db->commit();
Everything is a prepared statement, parameters never touch the SQL string. MySQL connections default to emulated prepares.
Errors are never exceptions. $db->error() is true when the most
recent call failed, and $db->error_message() has the text when you
want it:
$rows = $db->query( 'SELECT * FROM articles' ); if ( $db->error() ) { $here->set_status( 500 ); return; }
Failures are also logged, so an unchecked error still shows up.
The same wrapper instance comes back for the whole request, so one shot
chained reads work and error() is still reachable afterwards:
$hits = $here->db( 'stats' )->value( 'SELECT COUNT(*) FROM hits' ) ?? 0; if ( $here->db( 'stats' )->error() ) { // same instance, its state reflects the value() call above }
Large results can be fetched one row at a time instead of all at
once. statement() returns the underlying PDOStatement and accepts
prepare options such as cursors:
$stmt = $db->statement( 'SELECT * FROM big_table' ); while ( $row = $stmt->fetch() ) { // one row in memory at a time }
Rows come back as associative arrays (PDO::FETCH_ASSOC). Change it
with $db->fetch_mode( PDO::FETCH_NUM ) when you really need to, and
$db->pdo() hands you the raw PDO object for anything else.
Reliability across runtimes is handled for you:
- Connections are opened lazily on first use and closed after every request, in every runtime — so long-running workers (FrankenPHP worker mode, Workerman) never hit stale "server has gone away" connections, and nothing leaks between requests
- A transaction a route leaves open is rolled back at request end and logged
- SQLite connections automatically get
busy_timeout,journal_mode=WAL,synchronous=NORMAL,foreign_keys=ON, andtemp_store=MEMORY— the settings that make SQLite reliable and fast under concurrent web traffic
Routing
Patterns use fastroute syntax:
<?php $router->get( '/articles', 'routes/articles.php' ); $router->get( '/articles/{id:\d+}', 'routes/article.php' ); $router->post( '/articles', 'routes/article-create.php' ); $router->put( '/articles/{id}', 'routes/article-update.php' ); $router->delete( '/articles/{id}', 'routes/article-delete.php' ); $router->add( 'PURGE', '/cache', 'routes/cache-purge.php' );
get, post, put, patch, delete, head, and options are built
in, add() covers anything else. Note that nonstandard methods like
PURGE only reach the app under PHP-FPM and FrankenPHP — the PHP built-in
server and Workerman both reject them before PHP runs.
Error Pages
brilkic ships minimal built-in responses for 404 (not found), 405 (method
not allowed, with a correct Allow header), and 500 (a route file threw).
Each can be replaced with your own file in url-routes.php:
<?php $router->not_found( 'routes/errors/not-found.php' ); $router->not_allowed( 'routes/errors/not-allowed.php' ); $router->error( 'routes/errors/error.php' );
These files receive $here like any other route, with the status already
set. An error in a route file never kills a worker process: the partial
output is discarded, the error is logged, and the error response is sent.
License
MIT, see LICENSE.