josephscott/taalkic

A PHP web framework built on Workerman.

Maintainers

Package info

github.com/josephscott/taalkic

pkg:composer/josephscott/taalkic

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

dev-trunk 2026-05-27 02:11 UTC

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 .php file (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 require of 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
  • posix and pcntl extensions (for Workerman)
  • oha for benchmarking (optional): brew install oha or cargo 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.