josephscott / brilkic
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.7
- pestphp/pest: 4.7.0
- phpstan/phpstan: 2.2.0
README
A tiny PHP micro-framework. File-based routing on top of FastRoute, isolated-scope template rendering, and built-in escaping, CSRF, and session helpers. No controllers, no classes to extend — routes and templates are plain PHP files.
Requires PHP >= 8.4.
Quick start
Install:
composer require josephscott/brilkic
Fastest path: scaffold a starter site
Generate a runnable site (entry point, config, a home page, header/footer templates, and 404/500 error routes) into the current directory:
vendor/bin/brilkic init # or: vendor/bin/brilkic init path/to/dir
php -S localhost:8080 -t public
The scaffold never overwrites existing files — it aborts if any are already present. It writes a single init.php that defines your Config class, HTTPS-ready out of the box (the session cookie is Secure). For plain-HTTP local work, uncomment the SESSION_OPTIONS line in init.php to turn Secure off.
By hand
Create a Config class, register routes, and call run_app(). A minimal app is four files:
// public/index.php — the single entry point (point your web server here) <?php require __DIR__ . '/../vendor/autoload.php'; final class Config { const string ROUTE_PATH = __DIR__ . '/../routes/'; const string TEMPLATE_PATH = __DIR__ . '/../templates/'; } Router::get( '/', 'home.php' ); Router::get( '/hello/{name}', 'hello.php' ); run_app();
// routes/home.php — a route is just a PHP file in ROUTE_PATH <?php template( 'page.php', [ 'title' => 'Home', 'body' => 'Hello, world' ] );
// routes/hello.php — matched URL params arrive in $vars <?php template( 'page.php', [ 'title' => 'Hi', 'body' => 'Hello, ' . esc_html( $vars['name'] ) ] );
// templates/page.php — a template is a PHP file in TEMPLATE_PATH; gets $data <?php ?> <!DOCTYPE html> <title><?= esc_html( $data['title'] ) ?></title> <p><?= $data['body'] ?></p>
Run it locally with PHP's built-in server:
php -S localhost:8080 -t public
A complete working example lives in demo/ (php -S localhost:8080 -t demo/public).
Deploying with nginx + php-fpm
Point the document root at your public/ directory and route every request that
isn't a real file to index.php — brilkic's single entry point handles the rest.
server { listen 80; server_name example.com; root /var/www/app/public; index index.php; # Serve real files directly; send everything else to the front controller. location / { try_files $uri /index.php$is_args$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.4-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root/index.php; fastcgi_param DOCUMENT_ROOT $document_root; } }
Only public/ is web-served; keep routes/, templates/, vendor/, and your
Config outside the document root.
Core API
| Call | Purpose |
|---|---|
Router::get/head/post/put/patch/delete/options( $path, $file ) |
Register a route. $path is a FastRoute pattern (e.g. /hello/{name}); $file is a path relative to ROUTE_PATH. |
Router::add( $method, $path, $file ) |
Register a route for any method. |
Router::error( $status, $file ) |
Render $file for an HTTP error (e.g. 404, 405, 500). Without one, the bare status is sent and nothing is rendered. |
run_app() |
Match the request, run the route, send the response. Call once from your entry point. |
template( $file, $data = [] ) |
Render a template file (relative to TEMPLATE_PATH) in an isolated scope; it sees only $data. |
esc_html / esc_attr / esc_js / esc_url / esc_css( $string ) |
Context-aware escaping (laminas-escaper). |
csrf_token( $ttl = null ) |
Mint a fresh single-use token. |
csrf_field( $ttl = null ) |
Return a ready-to-embed hidden <input> carrying a fresh token. |
csrf_verify() |
Validate and consume the token from $_POST (the one-liner for a form handler). |
csrf_validate( $token ) |
Validate and consume an arbitrary token value. |
session_start_safe() |
Start a session with fixation protection forced on (no-op if one is active or headers are sent). |
session_destroy_safe() |
Log out: clear $_SESSION, expire the session cookie, and discard the server-side record. |
log_error( $data ) |
Write to the PHP error log (arrays/objects are print_r'd). |
Inside a route file, matched URL parameters are available as $vars. Error routes receive context in $vars too (404: method, uri; 405: allowed). Templates receive $data. The Config class is available everywhere (it's global); nothing else leaks into route or template scope.
Behavior worth knowing
- The whole response is buffered, so CSRF and session helpers can start a session lazily mid-render — only on pages that need one. Visitors without a session never touch the session subsystem.
- Sessions are started with
session.use_strict_modeandHttpOnlyforced on (fixation protection plus keeping the id away from JavaScript) — these can't be overridden. The cookie isSameSite=Lax+Secureby default; the wholesession.*surface (lifetime, GC, path, domain, …) is tunable viaConfig::SESSION_OPTIONS, e.g.[ 'cookie_secure' => false ]for plain-HTTP local dev. An existing session is auto-resumed when the client presents its cookie. - Route and template paths are canonicalized and confined to their configured roots, neutralizing
../traversal and symlink escapes. - A route that throws is logged, the half-rendered output is discarded, and the
500handler runs. X-Content-Type-Options: nosniffis always sent;Content-Type(with charset) is sent before dispatch unless opted out.
Configuration options
All configuration lives as constants on your Config class. Only ROUTE_PATH and TEMPLATE_PATH are required; every other constant is read by name and falls back to a default when omitted.
| Constant | Type | Default | Description |
|---|---|---|---|
ROUTE_PATH |
string |
— | Directory route files are resolved against. Routes must live inside it. |
TEMPLATE_PATH |
string |
— | Directory template files are resolved against. Templates must live inside it. |
CHAR_SET |
string |
utf-8 |
Charset for the escaper and the Content-Type header (pinned together so they can't drift). |
DEFAULT_CONTENT_TYPE |
string |
text/html |
Content-Type sent before dispatch (charset appended). Set to '' to send none — e.g. an API that sets its own type per route. |
CSRF_TOKEN_FIELD |
string |
csrf_token |
Hidden form field / $_POST key the token travels in. |
CSRF_SESSION_KEY |
string |
csrf_token |
$_SESSION key the token pool is stored under. |
CSRF_TOKEN_TTL |
int |
1800 |
Seconds a minted token stays valid. A per-call $ttl overrides it. |
SESSION_AUTO_RESUME |
bool |
true |
Resume an existing session when the client presents its cookie, before routes run. Set false for a stateless API. |
SESSION_OPTIONS |
array |
[] |
Per-app overrides passed straight to session_start() — any session.* directive without the prefix (cookie_lifetime, gc_maxlifetime, cookie_path, cookie_domain, sid_length, …). Cookie is named SID (not PHP's platform-advertising PHPSESSID) and defaults to Secure + SameSite=Lax; set [ 'name' => 'myapp' ] for a custom name, [ 'cookie_secure' => false ] for plain-HTTP local dev, or [ 'cookie_samesite' => 'None' ] for a cross-site embed. use_strict_mode and cookie_httponly are forced on and can't be overridden. cookie_lifetime (browser) and gc_maxlifetime (server) are independent — a persistent login wants both. |
TRAILING_SLASH_REDIRECT |
bool |
true |
When a path doesn't match, try its trailing-slash variant and redirect to it if registered (rather than 404). Set false to disable. |
TRAILING_SLASH_ADD |
bool |
false |
Redirect direction. false strips a trailing slash (/csrf/ → /csrf); true adds one (/csrf → /csrf/). |
TRAILING_SLASH_REDIRECT_CODE |
int |
302 |
Status for the trailing-slash redirect. 302 (temporary) or 301 (permanent, cacheable). |
A ready-to-copy config stub with these documented inline is in stubs/config.stub.
Development
make all # style, lint, static analysis, tests make tests # Pest make analyze # PHPStan make style # php-cs-fixer
License
MIT — see LICENSE.