# pframe
**Repository Path**: wfdaj/pframe
## Basic Information
- **Project Name**: pframe
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-02-18
- **Last Updated**: 2026-04-08
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# PFrame
Single-file PHP 8.4+ micro-framework. Zero dependencies, copy-paste deployment.
One file. 19 classes. Single-file core in `src/PFrame.php` (~2800 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');
$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();
```
```php
0,
'timezone' => 'Europe/Warsaw',
'view_path' => dirname(__DIR__) . '/templates',
'db' => [
'host' => 'localhost',
'name' => 'mydb',
'user' => 'root',
'pass' => '',
],
];
```
## 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
= h($user['name']) ?>
```
Layout:
```php
= h($title) ?>
= h($msg['text']) ?>
= $content ?>
```
## 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 |
| `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 timing + SQL query 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).
### 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 -- see `db/sessions.sql`.
### 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_set_save_handler($session);
```
- **Lazy-write**: when session data is unchanged between `read()` and `write()`, only the timestamp is updated (lightweight `UPDATE` instead of full `INSERT OR REPLACE`)
- **Advisory locks** (MySQL only): single `GET_LOCK` with configurable timeout prevents concurrent writes
- **Graceful degradation on lock timeout**: if the advisory lock cannot be acquired within `lockTimeout` seconds (default 5), the session degrades to read-only — reads succeed, writes are silently skipped. This prevents 500 cascades under contention while preserving page rendering.
- **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 IP resolution (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 from `trusted_proxies`.
```php
return [
'trusted_proxies' => ['127.0.0.1', '172.20.0.5'],
];
```
CIDR ranges are not supported. Use exact addresses.
### Worker Mode (FrankenPHP)
Use `runWorkerRequest()` when running long-lived workers. It resets request-scoped state,
rolls back leaked DB transactions, resets DB debug counters/logs, and closes the session in `finally`.
```php
$handler = static function () use ($app): void {
$app->runWorkerRequest(startSession: true);
};
```
If your worker entrypoint does not use PHP sessions, call `$app->runWorkerRequest()` with
the default `startSession: false`.
### Rate Limiting Helper
`Cache::rateCheck($scope, $id, $max, $window)` is protected by a lock file to keep updates atomic between concurrent requests.
### 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:
| 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()`, `assertJson()`, 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+
- PDO (MySQL or SQLite)
## 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
./bin/test coverage # coverage artifacts only
./bin/test contracts # governance/contracts suite
./bin/test e2e # not applicable in framework repo (success)
./bin/test ui # not applicable in framework repo (success)
```
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/`). If no coverage
driver is available (`xdebug`, `pcov`, `phpdbg`), `coverage`/`ci` print a clear fallback message
and continue successfully.
## License
MIT