# pframe **Repository Path**: wfdaj/pframe ## Basic Information - **Project Name**: pframe - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-02-18 - **Last Updated**: 2026-08-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # PFrame Single-file PHP 8.4+ micro-framework. Zero runtime package dependencies, copy-paste deployment. One file. 20 classes. Single-file core in `src/PFrame.php` (~4300 LOC). Everything you need, nothing you don't. ## Quick Start ``` myproject/ ├── public/index.php ├── src/PFrame.php # from this repo ├── lib/PFrame.php # optional copied/renamed location ├── config/app.php ├── controllers/ ├── templates/ ├── logs/ └── tmp/cache/ ``` ```php loadConfig(dirname(__DIR__) . '/config/app.php'); $session = new \PFrame\Session($app->db(), advisory: true, lockTimeout: 5); $session->register(); $app->startSession(); $app->get('/', HomeController::class, 'index'); $app->get('/users/{id}', UserController::class, 'show', name: 'user.show'); $app->post('/login', AuthController::class, 'login'); $auth = \PFrame\Middleware::auth(); $csrf = \PFrame\Middleware::csrf(); $app->group('/admin', function (\PFrame\App $app) use ($csrf): void { $app->get('/users', AdminController::class, 'index', name: 'users'); $app->post('/users', AdminController::class, 'store', mw: [$csrf], name: 'users.store'); }, mw: [$auth], namePrefix: 'admin.'); $app->run(); ``` This is the classic one-request-per-process lifecycle. Call `Session::register()` before `App::startSession()` and before any output. The secure-cookie default assumes HTTPS; for a local plain-HTTP development server, explicitly pass `['secure' => false]` to `register()`. ```php 0, 'timezone' => 'Europe/Warsaw', 'view_path' => dirname(__DIR__) . '/templates', 'db' => [ 'host' => 'localhost', 'name' => 'mydb', 'user' => 'root', 'pass' => '', ], 'max_request_body_bytes' => 8_388_608, // 8 MiB; przekroczenie zwraca 413 przed dispatchem trasy 'performance' => [ 'server_timing' => false, // true lokalnie: metryki w DevTools/HTTP 'slow_ms' => 70, // 0 wyłącza log wolnych requestów ], ]; ``` The application limit complements, but does not replace, the web-server request-body limit and PHP's `post_max_size` / `upload_max_filesize`. Configure those limits explicitly, especially for form and multipart uploads that PHP parses before application code runs. ## Controllers ```php paginate(P1::var('SELECT COUNT(*) FROM users')); return $this->render('home.php', [ 'users' => $users, 'pagination' => $pag, ]); } public function create(): \PFrame\Response { $this->validateCsrf(); $data = $this->postData(['name', 'email']); $errors = \PFrame\Validator::validate([ 'name' => 'required', 'email' => ['required', 'email'], ], $data); if ($errors) { return $this->render('form.php', ['errors' => $errors]); } P1::exec('INSERT INTO users (name, email) VALUES (?, ?)', [$data['name'], $data['email']]); return $this->redirectRoute('user.show', ['id' => 1]); } } ``` ## Templates Plain PHP with automatic escaping via `h()`: ```php layout('layout.php', ['title' => 'Home']); ?>

Users

``` Layout: ```php <?= h($title) ?>
``` ## What's Included | Class | Purpose | |-------|---------| | `App` | Router, config, middleware pipeline, error handling | | `Request` | HTTP request with proxy support | | `Response` | HTTP response (html, json, redirect, send-and-exit helper) | | `SseResponse` | Streaming HTTP response for Server-Sent Events | | `Performance` | Bounded request profiler with wall, CPU/wait, memory and named spans | | `Db` | PDO wrapper with prepared statements, tx state and formatted query log | | `View` | Template engine with layouts and partials | | `Controller` | Base controller with auth, CSRF, pagination and view data bag helpers | | `Middleware` | Built-in middleware factories (`auth`, `csrf`) | | `Session` | Database-backed session handler with advisory locks, lazy-write and intended URL | | `Csrf` | CSRF token + per-action nonce generation | | `Flash` | Flash messages | | `Log` | File logger with level filtering | | `Validator` | Input validation (email, phone, postcode, length, slug) | | `Cache` | Single-backend cache: APCu when available, file otherwise. Rate limiting included | | `TickTask` | Task definition for periodic background work (interval, time window, callback/command) | | `Tick` | Scheduler that runs registered `TickTask` instances with global throttle and file-lock dedup | | `DebugBar` | Request/resource timing + SQL execution/fetch debug overlay renderer | | `Base` | Static facade for app/db/config access | | `HttpException` | HTTP error responses (401, 403, 404, 405) | `Cache` constructor: `new \PFrame\Cache(?string $dir = null)`. When APCu is available, `dir` is optional. Without APCu, provide an existing cache directory (constructor fails fast if missing). ## Performance diagnostics PFrame collects bounded, request-scoped metrics without storing SQL text by default. Built-in spans cover request parsing, dispatch, route matching, controller execution, response finalization, database connect/execute/fetch, template rendering, session start and session-lock acquisition. Database totals include fetching and hydrating results, not only `PDOStatement::execute()`. ```php $result = $app->measure('forum.prepare_posts', function () use ($posts) { return preparePosts($posts); }); $metrics = $app->performance()->snapshot(); // php_ms, app_ms, cpu_ms, wait_ms, mem_mb, peak_mb, spans ``` Set `performance.server_timing=true` in trusted development environments to expose the metrics in the standard `Server-Timing` response header. Set `performance.slow_ms` to a positive threshold in production to write one structured `WARN Slow request` entry with route, status, spans and aggregate database statistics. Both outputs are disabled by default. Aggregate query count, total time and fetched rows are always available through `Db::totalQueryCount()`, `Db::totalQueryTime()` and `Db::totalFetchedRows()`. SQL text, duplicate-query analysis and slowest-query details remain opt-in through `db.log_queries=true`; this avoids retaining parameters and growing a per-request SQL log in production. `cpu_ms` and derived `wait_ms` are reported only on non-thread-safe PHP builds. On ZTS runtimes such as FrankenPHP they are `null`, because PHP's `getrusage()` reports process-wide CPU and cannot safely attribute concurrent worker requests. Wall-clock timings and all named spans remain available. ### Global Helpers - `h($val)` -- HTML escape - `ha($array, $key)` -- escape array value by key - `*S()` functions -- null-safe wrappers: `trimS()`, `strlenS()`, `substrS()`, `countS()`, `explodeS()`, `strtotimeS()`, `strip_tagsS()`, `getS()` ## Database ```php // Via project facade (class P1 extends \PFrame\Base) $users = P1::results('SELECT * FROM users WHERE active = ?', [1]); $count = P1::var('SELECT COUNT(*) FROM users'); $user = P1::row('SELECT * FROM users WHERE id = ?', [$id]); $names = P1::col('SELECT name FROM users'); $id = P1::insertGetId('INSERT INTO users (name) VALUES (?)', [$name]); P1::exec('UPDATE users SET name = ? WHERE id = ?', [$name, $id]); // Transactions P1::db()->begin(); // ... P1::db()->commit(); // or ->rollback() // Compatibility helpers used by migration targets $inTx = P1::db()->trans(); // bool $count = P1::db()->count(); // last affected/returned row count $sqlLog = P1::db()->log(); // "(X.XXms) SQL" lines ``` DB sessions require the `sessions` table. Use `db/sessions.sql` for MySQL/MariaDB or `db/sessions.sqlite.sql` for SQLite. ### Session Database-backed handler with advisory locks (MySQL), lazy-write optimization, and intended URL support. ```php $session = new \PFrame\Session($db, advisory: true, lockTimeout: 5); $session->register(); $app->startSession(); ``` - **Lazy-write**: when session data is unchanged between `read()` and `write()`, only the timestamp is updated (lightweight `UPDATE` instead of full `INSERT OR REPLACE`) - **Strict IDs**: unknown client-supplied IDs are rejected through `SessionUpdateTimestampHandlerInterface::validateId()` and PHP generates a fresh ID - **Locking**: with the MySQL driver, advisory locking uses one `GET_LOCK` call; other drivers use `flock` file locks. The optional `lockDir` constructor argument selects the directory for file locks. - **Fail-closed lock failure**: if the lock cannot be acquired (timeout or lock error), `read()`, `write()` and `destroy()` return `false`; the caller must handle the failed operation instead of treating it as successful. - **Intended URL**: `Session::pullIntendedUrl(string $default = '/')` retrieves and clears the URL stored by `Middleware::auth()` ## Security Built-in: - CSRF tokens with `hash_equals()` and HMAC nonces - Prepared statements everywhere (no string concatenation in SQL) - XSS protection via `h()` helper (`htmlspecialchars` with `ENT_QUOTES|ENT_HTML5`) - Security headers middleware (CSP, HSTS, X-Frame-Options, etc.) - Session hardening (strict mode, httponly, samesite) - Path traversal protection in template rendering - Open redirect prevention (blocks `//`, `\`, scheme-without-authority, non-http schemes) - Trusted proxy resolution from exact IPs or resolvable hostnames (nearest untrusted IP from `X-Forwarded-For`) ```php $app->addSecurityHeaders(); // CSP, XFO, XCTO, Referrer-Policy, Permissions-Policy, HSTS ``` Default CSP: - `script-src 'self'` - `style-src 'self' 'unsafe-inline'` (allows built-in error pages and DebugBar styles) Built-in middleware: - `\PFrame\Middleware::auth()` -- guest -> stores intended URL in session (GET/HEAD only), flash warning + redirect to `login` route - `\PFrame\Middleware::csrf()` -- validates token from `csrf_token` field or `X-Csrf-Token` header After login, retrieve the intended URL with `\PFrame\Session::pullIntendedUrl()` (returns stored path or default `/`, clears session key). ### Error Handling Pipeline `App` has a built-in 4-stage error pipeline: 1. `3xx` `HttpException` passthrough (redirect-style responses are returned directly) 2. optional custom error handler 3. AJAX fallback (`text/plain`) 4. default inline HTML error page (`text/html; charset=UTF-8`) Register a custom handler: ```php $app->setErrorPageHandler(function ( \PFrame\HttpException $e, \PFrame\Request $request, \PFrame\App $app ): ?\PFrame\Response { // return Response to handle; return null to fallback to framework default return null; }); ``` Notes: - original exception headers (e.g. `Allow` for 405) are preserved in fallbacks - unhandled `\Throwable` is logged and routed through the same HTTP error pipeline as `HttpException(500)` ### Trusted Proxies `Request::fromGlobalsWithProxies()` trusts forwarded headers only for exact IPs or resolvable hostnames from `trusted_proxies`. ```php return [ 'trusted_proxies' => ['127.0.0.1', '172.20.0.5', 'infra_caddy'], ]; ``` CIDR ranges are not supported. Hostnames are resolved to their current IPv4 addresses. ### Worker Mode (FrankenPHP) Register the session handler once during worker bootstrap, then use `runWorkerRequest()` for each request. It resets request-scoped state, rolls back leaked DB transactions, resets DB debug counters/logs, starts the session per request, and closes it in `finally`. ```php $session = new \PFrame\Session($app->db(), advisory: true, lockTimeout: 5); $session->register(); $handler = static function () use ($app): void { $app->runWorkerRequest(startSession: true); }; if (function_exists('frankenphp_handle_request')) { $maxRequests = max(0, (int) ($_SERVER['MAX_REQUESTS'] ?? 500)); for ($handled = 0; $maxRequests === 0 || $handled < $maxRequests; $handled++) { $keepRunning = frankenphp_handle_request($handler); gc_collect_cycles(); if (!$keepRunning) { break; } } } else { $handler(); } ``` If your worker entrypoint does not use PHP sessions, call `$app->runWorkerRequest()` with the default `startSession: false`. `MAX_REQUESTS=0` keeps a worker alive without a request limit; the bounded default periodically recycles the process to contain leaks in application code. ### Rate Limiting Helper `Cache::rateCheck($scope, $id, $max, $window)` uses stable, bounded striped locks to keep file-backend updates atomic between concurrent requests. Internal `.pframe-cache-lock-*` files are deliberately kept by `clear()`; deleting a lock path while another process holds it would break mutual exclusion. Expired APCu entries are reclaimed by APCu. For the file backend, schedule bounded maintenance so expired entries that are no longer read do not accumulate: ```php $removed = $cache->pruneExpired(1000); // maximum removals in one run ``` ### Periodic Tasks (Tick) Register background tasks that run on a timer, optionally within a time window: ```php $tick = new \PFrame\Tick('/tmp/tick', throttleSeconds: 15, prefix: 'worker-a'); $tick->task('cleanup') ->every(3600) ->run(fn () => cleanOldRecords()); $tick->task('report') ->every(86400) ->between('23:00', '02:00') ->retries(5) ->command('php /app/bin/daily-report.php'); $tick->dispatch(); // call from a cron or worker loop ``` Tasks are deduplicated via file locks and globally throttled (`throttleSeconds`, default `30`). Time windows support crossing midnight (for example `23:00` → `02:00`). Failed tasks are retried on subsequent dispatches until `retries()` is exhausted, then they wait a full interval again. ## Testing Traits `src/PFrameTesting.php` provides PHPUnit traits for integration testing: The testing helpers intentionally are not part of Composer runtime autoload because they depend on PHPUnit. Copy `src/PFrameTesting.php` with the core (or use the file from the installed package) and require it explicitly after PHPUnit's autoloader in `tests/bootstrap.php`: ```php require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__) . '/lib/PFrameTesting.php'; ``` | Trait | Purpose | |-------|---------| | `DatabaseTransactions` | Wraps each test in a transaction, rolls back all levels (including savepoints) on teardown | | `RefreshDatabase` | Runs SQL migrations once per suite, wraps tests in transactions | | `HttpTesting` | `get()`, `post()`, `postJson()`, `put()`, `patch()`, `delete()` with automatic CSRF injection | | `ResponseAssertions` | `assertOk()`, `assertNotFound()`, `assertRedirectTo()`, `assertSee()`, `assertJsonContains()`, etc. | | `DatabaseAssertions` | `assertDatabaseHas()`, `assertDatabaseMissing()`, `assertDatabaseCount()` | | `FlashAssertions` | `assertFlash()`, `assertNoFlash()` | | `SessionAssertions` | `assertAuthenticated()`, `assertGuest()`, `assertSessionHas()` | | `ActingAs` | `actingAs($user)`, `actingAsGuest()` for auth simulation | JSON requests (`postJson`) send CSRF via `X-Csrf-Token` header (matching production JSON API behavior), form requests via `csrf_token` POST field. ## Migration Compatibility For F3-to-PFrame migration scenarios, the framework now includes: - `Db::trans()`, `Db::count()`, `Db::log()` - `Controller` view data bag (`set()` / `get()`) auto-merged in `render()` - `SseResponse` for SSE endpoints - `Response::sendAndExit()` for legacy flow compatibility ## Requirements - PHP 8.4+ - `ext-mbstring` - `ext-pdo` plus `pdo_mysql` or `pdo_sqlite` for the selected database - APCu is optional; without it `Cache` uses its file backend ## Tests ```bash composer install ./bin/test quick ``` Test standard v1 profiles: ```bash ./bin/test quick # syntax + unit + integration ./bin/test full # quick + contracts + phpstan ./bin/test ci # full + coverage report, minimum 85% line coverage ./bin/test coverage # coverage artifacts, minimum 85% line coverage ./bin/test contracts # governance/contracts suite ./bin/test e2e # unsupported in framework repo (exit 2) ./bin/test ui # unsupported in framework repo (exit 2) ``` Composer aliases: ```bash composer test composer test:unit composer test:integration composer test:contracts composer test:quick composer test:full composer test:ci composer test:coverage composer phpstan ``` Coverage artifacts are generated in `build/coverage/` (`clover.xml`, `html/`). The `coverage` and `ci` profiles fail if no coverage driver (`xdebug`, `pcov`, `phpdbg`) is available or line coverage falls below 85%. ## License MIT