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

Maintainers

Package info

github.com/josephscott/brilkic

pkg:composer/josephscott/brilkic

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-06-11 17:20 UTC

This package is auto-updated.

Last update: 2026-06-12 20:13:22 UTC


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.php itself 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 text
  • esc_attr( $value ) — HTML attribute values
  • esc_js( $value ) — JavaScript contexts
  • esc_css( $value ) — CSS contexts
  • esc_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, and temp_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.