josephscott / taalkic
A PHP web framework built on Workerman.
Requires
- php: >=8.4
- laminas/laminas-escaper: ^2.13
- nikic/fast-route: ^1.3
- workerman/workerman: ^5.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- josephscott/phpcsfixer-config: ^0.0.6
- pestphp/pest: ^4.7
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-05-27 02:42:35 UTC
README
A small PHP 8.4+ web framework built on Workerman and FastRoute. Designed to keep the application-facing layer flat and obvious while staying fast on the wire.
Status: pre-1.0, in active development. The API isn't promising stability yet.
What's in the box
- Workerman-only runtime. No PHP-FPM. The framework freely uses Workerman classes —
Request,Response,Worker. - FastRoute for URL dispatch. Routes are added as a flat list, optionally in a separate file.
- Three handler shapes: closure /
[Class::class, 'method']/ class-string with__invoke/ a path to a.phpfile (old-school page). - Middleware: per-route, per-group, and global. Onion ordering, no reflection on the hot path.
- Custom 404 / 405 / 500 responses via
Router::on_not_found(),Router::on_method_not_allowed(),Router::on_error(). - HEAD requests are auto-served by GET handlers (body stripped, per RFC 9110).
- Templates — one-line
requireof shared header/footer/partial files, scoped naturally. - Services injection — register any shared object (escaper, db, redis, mailer) on
$app->services; arrives in every handler as the 3rd arg, extracted into local scope inside file-based pages. - Dev-mode file watching: edits to
src/,pages/, or your routes file gracefully reload Workerman workers. - No DI container, no per-request reflection, no per-request filesystem scans.
Requirements
- PHP 8.4 or later
posixandpcntlextensions (for Workerman)ohafor benchmarking (optional):brew install ohaorcargo install oha
Install
composer require josephscott/taalkic
Hello world
<?php declare( strict_types = 1 ); require __DIR__ . '/vendor/autoload.php'; use Taalkic\App; use Taalkic\Router; $router = new Router(); $router->add( 'GET', '/', static fn(): string => 'hello, world' ); ( new App( $router ) )->run( 'http://0.0.0.0:5678' );
php hello.php start # foreground (debug mode) php hello.php start -d # daemonize php hello.php stop # graceful stop
Routing
$router->add( 'GET', '/users/{id}', static function ( Request $req, array $params ): string { return 'user ' . $params['id']; } ); // [class, method] — taalkic instantiates `new $class()` per request so state // can't leak across requests in a long-lived worker. $router->add( 'GET', '/users/{id}', [ Users_Controller::class, 'show' ] ); // class-string with __invoke — same fresh-instance guarantee. $router->add( 'GET', '/users/{id}', User_Show::class ); // File callback — `$request` and `$params` are in scope. echo (captured) or // `return new Response(...)`. $router->add( 'GET', '/about', __DIR__ . '/pages/about.php' ); // Multiple verbs share a handler. $router->add( [ 'GET', 'HEAD' ], '/ping', static fn(): string => 'pong' );
Routes can live in a separate file — taalkic just requires it with $router in scope:
// bin/server.php $router = new Router(); require __DIR__ . '/routes.php'; ( new App( $router ) )->run( 'http://0.0.0.0:5678' );
// bin/routes.php $router->add( 'GET', '/ping', static fn(): string => 'pong' ); $router->add( 'GET', '/about', __DIR__ . '/../pages/about.php' );
Middleware
Middleware signature: function( Request $req, callable $next, array $params ): Response. Call $next( $req, $params ) to continue the chain, or return your own Response to short-circuit. $params is the route's URL parameters (e.g. {id} matches) — middleware that doesn't care about them just passes them through to $next.
$auth = static function ( Request $req, callable $next, array $params ): Response { if ( $req->header( 'Authorization' ) === null ) { return new Response( 401, [], 'unauthorized' ); } return $next( $req, $params ); }; $router->add_middleware( $log_requests ); // global, every route $router->group( [ $auth ], static function ( Router $r ): void { $r->add( 'GET', '/me', $me_handler ); // group middleware applies $r->add( 'GET', '/account', $account_handler, [ $rate_limit ] ); // group + per-route } );
Execution order: global → group → per-route → handler → per-route → group → global.
The middleware chain is pre-composed once per route at Router build time, not rebuilt per request. With or without middleware, the per-request cost is the same — a single nested-closure call. No reflection, no allocations on the hot path.
Services (dependency injection without ceremony)
Shared things you want every handler to reach — escaper, database handle, redis client, mailer, whatever — go on $app->services, set once at boot:
use Laminas\Escaper\Escaper; use Taalkic\App; use Taalkic\Router; $router = new Router(); $app = new App( $router ); $app->services->escaper = new Escaper( 'utf-8' ); $app->services->db = new PDO( '...' ); $app->services->redis = new Redis( ... ); $app->run( 'http://0.0.0.0:5678' );
The same Services instance arrives as the 3rd argument of every closure / class / [Class::class, 'method'] handler:
use Taalkic\Services; $router->add( 'GET', '/users/{id}', static function ( Request $req, array $params, Services $svc ): Response { $user = $svc->db->query( 'SELECT * FROM users WHERE id = ?', [ $params['id'] ] )->fetch(); return new Response( 200, [], $svc->escaper->escapeHtml( $user['name'] ) ); } );
Handlers that don't need it just don't declare the 3rd parameter — PHP silently drops the extra positional arg. Existing 2-arg handlers keep working unchanged.
For file-based pages, the framework extracts each Services key into local scope before require-ing the page (EXTR_SKIP keeps $request/$params safe from any registered key of the same name):
// pages/show-user.php ?> <h1><?= $escaper->escapeHtml( $title ) ?></h1> <p>Posts: <?= $db->query( 'SELECT COUNT(*) FROM posts' )->fetchColumn() ?></p>
How it's not a DI container. No autowiring, no constructor injection, no reflection, no graph resolution. A user assigns to a property, the framework hands the property bag to handlers. That's it.
Performance. Services is declared with #[AllowDynamicProperties], so $svc->escaper is a direct property read (one FETCH_OBJ_R opcode), not a magic __get call. In a benchmark calling the escaper 1000 times per request, the framework-injected $svc->escaper->escapeHtml(...) matches a manual use ($escaper) closure capture within 0.5% — the dynamic-properties machinery is free at this layer.
Typed escape hatch. Apps that want PHPStan-verified property types subclass Services and declare the fields:
final class App_Services extends Taalkic\Services { public PDO $db; public Laminas\Escaper\Escaper $escaper; } $app->services = new App_Services(); $app->services->db = new PDO( '...' ); $app->services->escaper = new Escaper( 'utf-8' ); // In handlers: static function ( Request $req, array $params, App_Services $svc ): Response { // $svc->db is PDO, $svc->escaper is Escaper — fully typed. };
The base Taalkic\Services stays generic for users who don't want to write a class.
Templates
File-callback handlers naturally read as PHP pages. For shared chrome (headers, footers, partials), taalkic ships a tiny one-root path resolver — Taalkic\Templates. No templating engine, no opinions about syntax.
Templates is an instance class; create one at boot and put it on $app->services (alongside the escaper or anything else) so every handler and page can reach it:
use Laminas\Escaper\Escaper; use Taalkic\App; use Taalkic\Templates; $app = new App( $router ); $app->services->templates = new Templates( __DIR__ . '/templates' ); $app->services->escaper = new Escaper( 'utf-8' ); $app->run( '...' );
Then in a page ($templates and $escaper arrive as locals via the Services extract):
<?php // pages/about.php $title = 'About'; require $templates->path( 'header' ); // → /…/templates/header.php ?> <h1>About</h1> <p>Built with taalkic <?= $escaper->escapeHtml( Taalkic\Taalkic::VERSION ) ?>.</p> <?php require $templates->path( 'footer' );
<?php // templates/header.php /** @var string $title */ /** @var \Laminas\Escaper\Escaper $escaper */ ?> <!DOCTYPE html> <html><head><title><?= $escaper->escapeHtml( $title ) ?></title></head> <body>
$templates->path( 'partials/menu' ) works the same — subdirectories supported. .php is appended if missing. There is no built-in render( $name, $data ) helper because PHP's require already passes the caller's scope through; if you need explicit data-passing, extract( $data ); require $templates->path( $name ); is one line and one explicit choice.
For output escaping, taalkic intentionally doesn't ship its own escaper wrapper — register a Laminas\Escaper\Escaper (or whatever escaper your app uses) on $app->services and call it directly. One mechanism (Services), one shared instance per worker, no parallel API. See the laminas/laminas-escaper docs for the available methods (escapeHtml, escapeHtmlAttr, escapeJs, escapeCss, escapeUrl).
Error responses
$router->on_not_found( static fn ( Request $r ): Response => new Response( 404, [ 'Content-Type' => 'application/json' ], '{"error":"not_found"}' ) ); $router->on_method_not_allowed( static fn ( Request $r, array $allowed ): Response => new Response( 405, [], 'method not allowed' ) // Allow header auto-added ); $router->on_error( static fn ( Throwable $e, Request $r ): Response => new Response( 500, [ 'Content-Type' => 'application/json' ], '{"error":"server"}' ) );
If on_error isn't registered, uncaught throwables get logged (with stack trace) via PHP's error_log and a plain Response(500, [], '') is sent. Custom handlers that themselves throw or return non-Response values fall back to the default.
Dev mode
make dev # boots the server with file watching on src/, pages/, bin/
A separate watcher worker polls every 500 ms and, on any change, sends SIGUSR1 to the master so HTTP workers reload gracefully — no dropped requests.
Configurable via App::run( $listen, $watch_paths, $workers, $port ):
( new App( $router ) )->run( 'http://0.0.0.0:5678', [ __DIR__ . '/src', __DIR__ . '/pages' ], // dev mode if non-empty workers: null, // null = half the CPU cores port: null, // shortcut: only the port );
Just changing the port? Use the $port shortcut:
( new App( $router ) )->run( port: 8080 );
When $port is set, the scheme and host from $listen (default http://0.0.0.0:…) are preserved and only the port is swapped. Use the full $listen form for non-HTTP schemes or non-0.0.0.0 hosts.
Production
make serve # foreground make serve WORKERS=8 # explicit worker count make serve PORT=8080 # explicit port make serve WORKERS=8 PORT=8080 # both php bin/server.php start -d # daemonize directly php bin/server.php stop
The worker count defaults to max(1, intdiv(Cpu::count(), 2)) — half of the visible CPU cores. Override with the $workers argument to App::run() or TAALKIC_WORKERS=N in the environment.
The listen port defaults to 5678. Override with the $port argument to App::run(), TAALKIC_PORT=N in the environment, or make serve PORT=N. For full control over scheme + host, use TAALKIC_LISTEN=http://… instead.
Make targets
make help # this list
make all # classmap → style → lint → analyze → tests (boots server for the duration)
make install # composer install
make classmap # composer dump-autoload
make style # php-cs-fixer fix
make lint # php -l on src/ tests/ bin/ pages/
make analyze # PHPStan, level 9
make tests # pest
make serve [WORKERS=N] # foreground server
make dev [WORKERS=N] # foreground server with file watching
make server-start # daemonize on 5678
make server-stop # graceful stop
make bench [BENCH_*] # oha against /ping, server lifecycle managed
Benchmarking
make bench # 60s default make bench BENCH_DURATION=10s # short run make bench BENCH_URL=http://127.0.0.1:5678/hello/jane
Sample numbers on a 16-core macOS host (no tuning, single trivial route, keep-alive on): ~160k RPS, p99 < 1 ms. Your mileage will vary; the only way to know is to run it on your hardware.
License
MIT — see LICENSE.