New Features in Pramnos Framework v1.2¶
This document is the authoritative reference for all new features introduced in v1.2. It is updated alongside each implementation — not at release time.
Contents:
1. Read/Write Replicas
2. Connection Health & Auto-reconnect
3. DatabaseCapabilities — Runtime Detection
4. DML Query Builder
5. Grammar / Adapter Pattern
6. DDL Schema Builder
7. timeBucket() — Dialect-Transparent Time Bucketing
8. Characterization Coverage Expansion
9. Phase 4: Migration System Overhaul
10. Phase 4: MigrationLoader and CLI Commands
16. Phase 4: Framework System Migrations
17. Phase 4: Middleware Pipeline
18. Phase 4: Formal Response Object
19. Phase 4: Centralized Error / Exception Handler
20. Phase 4: PHP 8.1 Minimum Version
21. Phase 4: Security — CSRF Hardening
22. Phase 4: Security — Session Cookie Hardening
23. Phase 4: Security — View Escaping Helpers
24. Phase 6: PSR Compliance Layer
25. Phase 20: HTTP Testing Infrastructure
62. View & Template System — Complete Guide
63. MakeCommandBase Service Decomposition
64. Router::group() + #[RouteGroup]
65. JsonResponseMiddleware + ApiAuthMiddleware
66. REST API Scaffolding — pramnos init --rest-api
67. Database-driven CORS (PF-43) + Phase 15 Convergence Test
69. Auth Feature Wiring in init app
70. AuthServer + Logs Wiring in init app
71. Scaffold — Rich Settings Page, Full Application Edit, User Token Management
74. QR code local generation, DataTable JS fix, "View" link
1. Read/Write Replicas¶
Problem: Applications that scale horizontally typically run one primary database for writes and one or more read replicas for SELECT queries. Previously, Database supported only a single connection, so routing queries to the right server had to be done manually.
Solution: The Database class now maintains separate read and write connections. It automatically routes every query to the correct server based on whether the SQL is a write operation. No change is required in application code.
Getting started¶
Add read and write blocks to your settings.php:
// settings.php
'database' => [
'type' => 'mysql',
'write' => [
'hostname' => 'db-primary.example.com',
'user' => 'app_rw',
'password' => 'secret',
'database' => 'myapp',
],
'read' => [
'hostname' => 'db-replica.example.com',
'user' => 'app_ro',
'password' => 'secret',
'database' => 'myapp',
],
'port' => 3306,
'prefix' => 'pramnos_',
'collation' => 'utf8mb4_unicode_ci',
]
PostgreSQL / TimescaleDB works identically:
'database' => [
'type' => 'postgresql',
'write' => ['hostname' => 'pg-primary', 'user' => 'app', 'password' => '...', 'database' => 'myapp'],
'read' => ['hostname' => 'pg-replica', 'user' => 'app', 'password' => '...', 'database' => 'myapp'],
'schema' => 'public',
]
From this point on, all queries are routed automatically:
$db = \Pramnos\Database\Database::getInstance();
// Automatically uses the READ connection
$result = $db->query("SELECT * FROM #PREFIX#users WHERE active = 1");
// Automatically uses the WRITE connection
$db->query("UPDATE #PREFIX#users SET last_login = NOW() WHERE userid = %i", 42);
How routing works¶
Database::isWriteQuery(string $sql): bool checks the first keyword of the SQL. Queries that begin with SELECT, SHOW, EXPLAIN, DESC, or DESCRIBE are treated as reads; everything else as a write.
API reference¶
| Method | Description |
|---|---|
getConnection(bool $isWrite = false) |
Returns the appropriate live connection, reconnecting if needed. |
isConnectionAlive(mixed $connection): bool |
Checks if a connection handle is still open (PGSQL_CONNECTION_OK / SELECT 1). |
isWriteQuery(string $sql): bool |
Returns true if the query's first keyword implies a write operation. |
BC notes¶
No existing code changes are required. If read/write config keys are absent, the database behaves exactly as before: a single connection is used for all queries. Existing $db->query() / $db->prepareQuery() / $db->execute() calls are unaffected.
2. Connection Health & Auto-reconnect¶
Problem: Long-running workers and daemon processes lose their database connections when the server closes idle sockets (e.g., MySQL's wait_timeout). Previously this caused silent failures or fatal errors on the next query.
Solution: Database::query() now detects a lost connection before executing and transparently reconnects once. The application never sees the drop.
How it works¶
On each query, if the connection handle is dead (isConnectionAlive() returns false), the framework calls tryReconnect() before executing the SQL. If the reconnect succeeds the query runs normally. If it fails the original database exception propagates — no silent swallowing.
API reference¶
| Method | Description |
|---|---|
tryReconnect(): bool |
Non-fatal reconnect attempt. Returns true on success, false on failure (does not throw). |
refresh(bool $throwOnFailure = true): bool |
Full reconnect. Throws RuntimeException if $throwOnFailure is true and reconnect fails. |
isConnectionAlive(mixed $connection): bool |
Low-level check used internally before each query. |
Usage examples¶
For most applications, reconnect is fully automatic — no code changes needed:
// Normal query — transparently reconnects on a dropped connection
$result = $db->query('SELECT * FROM users WHERE active = 1');
For long-running daemons that want to pro-actively verify the connection before a critical operation:
// Non-fatal check (returns bool, never throws)
if (!$db->tryReconnect()) {
// connection could not be re-established — log and skip this cycle
$logger->warning('Database unavailable, skipping job cycle');
sleep(5);
continue;
}
For workers that should abort on connection failure:
BC notes¶
Fully transparent. No API changes. Existing code benefits automatically.
3. DatabaseCapabilities — Runtime Detection¶
Problem: Features like JSONB, TimescaleDB hypertables, and spatial indexes are not available on every database backend. Code that calls createHypertable() on a plain PostgreSQL instance, or uses JSONB on MySQL, crashes. Scattered if ($db->type == 'postgresql') checks accumulate and become unmaintainable.
Solution: DatabaseCapabilities detects the actual capabilities of the connected server at runtime and provides a clean API to branch on them — including a callback-based ifCapable() pattern for conditional DDL.
Class: Pramnos\Database\DatabaseCapabilities
Getting started¶
$db = \Pramnos\Database\Database::getInstance();
$caps = new \Pramnos\Database\DatabaseCapabilities($db);
if ($caps->hasTimescaleDB()) {
// use time_bucket(), hypertable APIs
} elseif ($caps->isPostgreSQL()) {
// plain PostgreSQL fallback
} else {
// MySQL fallback
}
Conditional execution with ifCapable()¶
$caps->ifCapable(
\Pramnos\Database\DatabaseCapabilities::FEATURE_TIMESCALEDB,
function () use ($db, $table) {
// runs only on TimescaleDB
$db->query("SELECT create_hypertable('%s', 'time')", $table);
},
function () {
// runs on all other backends — plain table is already created, nothing to do
}
);
Feature constants¶
| Constant | Value | Detected via |
|---|---|---|
FEATURE_TIMESCALEDB |
'timescaledb' |
pg_extension catalog query |
FEATURE_JSON |
'json' |
Always true (MySQL 5.7+, all PG versions in use) |
FEATURE_JSONB |
'jsonb' |
PostgreSQL only |
FEATURE_FULLTEXT |
'fulltext' |
MySQL only |
FEATURE_SPATIAL |
'spatial' |
MySQL with spatial extensions |
API reference¶
__construct(Database $db)¶
has(string $capability): bool¶
Returns true if the connected server supports the given capability constant.
if ($caps->has(\Pramnos\Database\DatabaseCapabilities::FEATURE_JSONB)) {
// PostgreSQL — can use jsonb columns
}
isMySQL(): bool¶
isPostgreSQL(): bool¶
Returns true for both plain PostgreSQL and TimescaleDB.
hasTimescaleDB(): bool¶
Returns true only if the TimescaleDB extension is present and loaded in the connected database.
ifCapable(string $capability, callable $ifTrue, ?callable $ifFalse = null): mixed¶
Executes $ifTrue if the capability is present, $ifFalse otherwise. Returns the return value of whichever callable runs. $ifFalse is optional — if omitted and the capability is absent, the method does nothing and returns null.
$result = $caps->ifCapable(
\Pramnos\Database\DatabaseCapabilities::FEATURE_TIMESCALEDB,
fn() => 'native',
fn() => 'fallback'
);
// $result === 'native' on TimescaleDB, 'fallback' elsewhere
supports(string $capability): bool¶
Fluent alias for has(). Provided for readable migration code that prefers supports() over has().
if ($db->capabilities()->supports(DatabaseCapabilities::TIMESCALEDB)) {
// TimescaleDB-specific DDL
}
BC notes¶
New class — purely additive. No existing code is affected.
Alignment note (planned): The current implementation deviates from the Backport Spec (Section 14.1) in four ways: constant prefix (
FEATURE_TIMESCALEDBvsTIMESCALEDB), instance-level capability cache vs static, missingMATERIALIZED_VIEWSandENUMSconstants and their correspondinghasMaterializedViews()/hasEnums()methods, andifCapable()located here instead of onSchemaBuilder. These will be corrected before the DDL Schema Builder is implemented.
4. DML Query Builder¶
Problem: Raw SQL strings scattered across Model, Datasource, application controllers, and framework internals are fragile, dialect-specific, and hard to test. A query written for MySQL silently breaks on PostgreSQL (different quoting, different parameter placeholders, missing RETURNING). There was no fluent, dialect-aware query construction API.
Solution: QueryBuilder provides a fluent, chainable interface for building SELECT, INSERT, UPDATE, and DELETE statements. It handles dialect differences internally, supports PostgreSQL's RETURNING, and feeds values through Database::prepare() for parameterized execution.
Class: Pramnos\Database\QueryBuilder
Entry point: $db->queryBuilder() — returns a fresh builder bound to the current database connection.
Getting started¶
$db = \Pramnos\Database\Database::getInstance();
// SELECT
$activeUsers = $db->queryBuilder()
->from('users')
->where('active', 1)
->orderBy('created_at', 'desc')
->limit(10)
->get();
while ($activeUsers->fetch()) {
echo $activeUsers->fields['username'] . "\n";
}
// INSERT
$db->queryBuilder()
->table('users')
->insert(['username' => 'jane', 'email' => 'jane@example.com']);
// UPDATE
$db->queryBuilder()
->table('users')
->where('userid', 5)
->update(['active' => 0]);
// DELETE
$db->queryBuilder()
->from('users')
->where('active', 0)
->delete();
API reference — setup¶
$db->queryBuilder(): QueryBuilder¶
Returns a new QueryBuilder instance bound to this Database connection. Always use this rather than instantiating QueryBuilder directly.
API reference — building SELECT queries¶
select(array|string $columns = ['*']): static¶
Sets the SELECT column list. Accepts a single string, a comma-separated string, or an array of column names / expressions. Calling select() multiple times replaces the previous list.
$qb->select('userid', 'username', 'email');
$qb->select(['a.userid', 'a.username', 'b.groupname']);
$qb->select('COUNT(*) as total'); // expression string
$qb->select($qb->raw('COUNT(*) as n')); // raw expression
distinct(): static¶
Adds DISTINCT to the SELECT.
from(string $table): static¶
Sets the FROM table. Accepts a table name with optional alias ('users a', 'users AS a').
table(string $table): static¶
Alias for from(). Preferred when building INSERT/UPDATE/DELETE.
API reference — conditions¶
where(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static¶
Adds a WHERE condition. Supports two calling conventions:
// Two-argument shorthand: column = value
$qb->where('active', 1);
$qb->where('status', 'pending');
// Three-argument: column operator value
$qb->where('age', '>=', 18);
$qb->where('name', 'ILIKE', '%john%');
// Nested closure (parenthesised group)
$qb->where(function ($q) {
$q->where('status', 'active')
->orWhere('role', 'admin');
});
// → WHERE (status = 'active' OR role = 'admin')
orWhere(string $column, mixed $operator = null, mixed $value = null): static¶
Adds an OR WHERE condition. Same calling conventions as where().
$qb->where('role', 'admin')->orWhere('role', 'superuser');
// → WHERE role = 'admin' OR role = 'superuser'
whereIn(string $column, array $values, string $boolean = 'and', bool $not = false): static¶
Adds a WHERE column IN (...) condition.
$qb->whereIn('userid', [1, 2, 3]);
// → WHERE userid IN (1, 2, 3)
$qb->whereIn('status', ['active', 'pending'], 'and', true);
// → WHERE status NOT IN ('active', 'pending')
whereRaw(string $sql, array $bindings = [], string $boolean = 'and'): static¶
Adds a raw SQL fragment as a WHERE condition. Use for dialect-specific expressions that the builder cannot generate natively.
$qb->whereRaw("LOWER(username) = %s", ['johndoe']);
$qb->whereRaw("ST_DWithin(geom, ST_MakePoint(%s, %s)::geography, 1000)", [23.72, 37.98]);
$qb->whereRaw("created_at > NOW() - INTERVAL '7 days'");
API reference — joins¶
join(string $table, string $first, string $operator = null, string $second = null, string $type = 'inner'): static¶
Adds a JOIN clause. The $type parameter accepts any string ('inner', 'left', 'right', 'full', 'cross').
$qb->join('orders o', 'o.userid', '=', 'u.userid');
// → INNER JOIN orders o ON o.userid = u.userid
$qb->join('roles r', 'r.roleid', '=', 'u.roleid', 'left');
// → LEFT JOIN roles r ON r.roleid = u.roleid
leftJoin(string $table, string $first, string $operator = null, string $second = null): static¶
Convenience method equivalent to join(..., 'left').
joinRaw(string $sql): static¶
Adds a completely raw JOIN string. Use for complex join conditions the fluent API cannot express.
API reference — ordering, grouping, pagination¶
orderBy(string $column, string $direction = 'asc'): static¶
orderByRaw(string $sql): static¶
groupBy(string|array $columns): static¶
groupByRaw(string $sql): static¶
having(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static¶
Same calling convention as where().
havingRaw(string $sql, array $bindings = [], string $boolean = 'and'): static¶
limit(int $value): static¶
offset(int $value): static¶
clearOrderingAndPaging(): static¶
Removes all ORDER BY, LIMIT, and OFFSET clauses from the builder in place. Used when you need to manually clone a builder and run a raw aggregate query. For the common "count before paginating" case, prefer count() — it handles cloning and stripping automatically.
// Manual pattern (still works, but count() is simpler)
$countQb = clone $qb;
$countQb->select('COUNT(*) as n')->clearOrderingAndPaging();
$total = (int)($countQb->first()->fields['n'] ?? 0);
// Preferred
$total = $qb->count();
API reference — PostgreSQL RETURNING¶
returning(string|array $columns): static¶
Appends a RETURNING clause to INSERT, UPDATE, or DELETE. No-op on MySQL — the builder silently omits the clause when connected to MySQL.
// INSERT and get the new primary key
$result = $db->queryBuilder()
->table('users')
->returning('userid')
->insert(['username' => 'jane', 'email' => 'jane@example.com']);
$newId = $result->fields['userid'];
// UPDATE and retrieve the modified row
$result = $db->queryBuilder()
->table('users')
->where('userid', 5)
->returning(['userid', 'updated_at'])
->update(['active' => 0]);
API reference — raw expressions¶
raw(string $value): Expression¶
Creates a raw SQL expression that is injected as-is into the query without quoting or escaping. Use for functions, dialect-specific syntax, and computed columns.
$qb->select(
'userid',
$qb->raw("TO_CHAR(created_at, 'YYYY-MM') as month")
);
$qb->orderBy($qb->raw('COALESCE(last_login, created_at)'), 'desc');
Security: Never pass user-supplied values directly into
raw(). UsewhereRaw()with$bindingsfor user input.
API reference — execution¶
get(bool $cache = false, int $cachetime = 60, string $category = ''): Result¶
Compiles and executes the query. Returns a Result object. Optionally caches the result using the framework's query cache.
$result = $qb->from('users')->where('active', 1)->get();
// With caching (cache for 300 seconds)
$result = $qb->from('users')->get(true, 300, 'users_cache');
first(): Result¶
Adds LIMIT 1 and executes. Returns the Result object directly. Check $result->numRows > 0 before accessing $result->fields.
$result = $qb->from('users')->where('username', 'jane')->first();
if ($result->numRows > 0) {
echo $result->fields['email'];
}
count(): int¶
Executes a COUNT(*) aggregate and returns the row count as an integer.
Internally clones the builder (preserving WHERE, JOIN, GROUP BY, HAVING, and all bindings), replaces SELECT with COUNT(*) AS aggregate, and strips ORDER BY / LIMIT / OFFSET — these are meaningless for an aggregate and would waste DB resources. The original builder is not mutated, so count() can be called before get() in the standard pagination pattern.
// Simple count
$total = $qb->from('users')->where('active', 1)->count();
// Pagination — count first, then fetch the page
$qb = $db->queryBuilder()
->from('orders')
->where('status', 1)
->orderBy('created_at', 'desc')
->limit(20)
->offset(40);
$total = $qb->count(); // SELECT COUNT(*) AS aggregate FROM orders WHERE status = 1
$rows = $qb->get(); // SELECT * FROM orders WHERE status = 1 ORDER BY ... LIMIT 20 OFFSET 40
Note:
clearOrderingAndPaging()is still available for manual clone-and-count patterns, butcount()is the preferred API for aggregate queries.
sum(string $column): float¶
Executes SELECT SUM(column) AS aggregate and returns the result as a float. Clones the builder; original is not mutated. Returns 0.0 on an empty result set.
avg(string $column): float¶
Executes SELECT AVG(column) AS aggregate. Returns 0.0 on an empty result set.
min(string $column): mixed¶
Executes SELECT MIN(column) AS aggregate. Returns null on an empty result set.
max(string $column): mixed¶
Executes SELECT MAX(column) AS aggregate. Returns null on an empty result set.
exists(): bool¶
Returns true if at least one row matches the current conditions. Executes as SELECT EXISTS(SELECT 1 FROM … WHERE …) — efficient on all supported engines.
if ($db->queryBuilder()->from('users')->where('email', $email)->exists()) {
throw new \RuntimeException('Email already registered');
}
doesntExist(): bool¶
Inverse of exists().
if ($db->queryBuilder()->from('roles')->where('name', 'admin')->doesntExist()) {
// seed the admin role
}
value(string $column): mixed¶
Adds LIMIT 1, executes, and returns the value of a single column from the first matching row. Returns null when no row matches.
pluck(string $column): array¶
Returns a flat array of the requested column's values across all matching rows.
$emails = $db->queryBuilder()->from('users')->where('active', 1)->pluck('email');
// → ['alice@example.com', 'bob@example.com', ...]
selectSub(QueryBuilder|Closure $query, string $alias): static¶
Adds a subquery as a SELECT column. The subquery is wrapped in parentheses and aliased:
SELECT ..., (SELECT ...) AS alias
The default '*' is replaced when this is the first column added. Bindings from the subquery go into the select binding slot so they precede WHERE bindings in the parameter list.
// Correlated subquery: order count per user
$result = $db->queryBuilder()
->select(['userid', 'username'])
->selectSub(function (\Pramnos\Database\QueryBuilder $sub) {
$sub->select('COUNT(*)')
->from('orders')
->whereRaw('orders.userid = users.userid');
}, 'order_count')
->from('users')
->orderBy('userid')
->get();
// $result->fields['order_count'] is the count for each row
fromSub(QueryBuilder|Closure $query, string $alias): static¶
Uses a subquery as the FROM source — a derived table. The subquery is wrapped in parentheses and given a table alias:
FROM (SELECT ...) AS alias
Bindings from the subquery go into the from binding slot, appearing before join and where bindings in the flattened list.
// Derived table: average price per category, filtered in outer query
$result = $db->queryBuilder()
->select(['category', 'avg_price'])
->fromSub(function (\Pramnos\Database\QueryBuilder $sub) {
$sub->select(['category', $sub->raw('AVG(price) AS avg_price')])
->from('products')
->groupBy('category');
}, 'cat_avgs')
->where('avg_price', '>', 5.00)
->get();
API reference — window functions¶
over(string|Expression $function, string $alias = null, array|string $partition = [], array $order = [], string $frame = ''): Expression¶
Builds an OVER (...) window function expression. Returns an Expression that can be passed to select(), groupBy(), orderBy(), or used inside a CTE.
The $partition and $order column names are quoted automatically by the active grammar — backtick quoting for MySQL, double-quote quoting for PostgreSQL.
Parameters:
| Parameter | Type | Description |
|---|---|---|
$function |
string\|Expression |
Raw function call, e.g. 'RANK()', 'ROW_NUMBER()', 'SUM(price)' |
$alias |
string\|null |
AS alias appended to the expression (optional) |
$partition |
array\|string |
Columns for PARTITION BY — quoted automatically |
$order |
array |
ORDER BY spec: ['col' => 'asc\|desc', ...] or ['col1', 'col2'] (ASC default) |
$frame |
string |
Optional ROWS/RANGE frame clause |
Supported window functions (both MySQL 8.0 and PostgreSQL 14):
| Function | Description |
|---|---|
RANK() |
Rank with gaps (1, 1, 3, ...) |
DENSE_RANK() |
Rank without gaps (1, 1, 2, ...) |
ROW_NUMBER() |
Unique sequential integer |
NTILE(n) |
Divide rows into n buckets |
SUM(col) |
Running / partitioned sum |
AVG(col) |
Running / partitioned average |
MIN(col) / MAX(col) |
Running / partitioned min or max |
COUNT(col) |
Running / partitioned count |
LAG(col, n) / LEAD(col, n) |
Access preceding / following row value |
FIRST_VALUE(col) / LAST_VALUE(col) |
First or last value in the window |
// RANK() within each category, ordered by price ascending
$qb = $db->queryBuilder();
$result = $qb
->select([
'id', 'name', 'category', 'price',
$qb->over('RANK()', alias: 'price_rank',
partition: ['category'],
order: ['price' => 'asc']
),
])
->from('products')
->orderBy('category')
->get();
// ROW_NUMBER() across all rows
$result = $db->queryBuilder()
->select([
'name',
$db->queryBuilder()->over('ROW_NUMBER()', 'rn', order: ['created_at' => 'desc']),
])
->from('users')
->get();
// Cumulative SUM with a ROWS frame
$result = $db->queryBuilder()
->select([
'order_date', 'amount',
$db->queryBuilder()->over(
'SUM(amount)',
'running_total',
order: ['order_date' => 'asc'],
frame: 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW'
),
])
->from('orders')
->get();
// TOP-N per group pattern (via CTE)
$result = $db->queryBuilder()
->with('ranked', function ($sub) use ($db) {
$sub->select([
'*',
$db->queryBuilder()->over('RANK()', 'rn',
partition: ['category'],
order: ['price' => 'desc']
),
])->from('products');
})
->select('*')
->from('ranked')
->where('rn', '<=', 3)
->get();
Note:
$functionis passed verbatim — it is not escaped. Do not pass user-supplied strings directly. For column names inside the function (e.g.SUM(price)), the grammar does not quote the function argument automatically; useraw()wrapping if you need explicit quoting inside the function call.
toSql(): string¶
Returns the compiled SQL string without executing it. Useful for debugging.
echo $qb->from('users')->where('active', 1)->toSql();
// → SELECT * FROM "users" WHERE "active" = 'value-placeholder'
getBindings(): array¶
Returns the array of bound values that will be passed to Database::prepare().
$bindings = $qb->from('users')->where('active', 1)->getBindings();
// → ['where' => [1], 'join' => [], ...]
API reference — write operations¶
insert(array $values): Result¶
Compiles and executes an INSERT. Returns a Result object. If returning() was called, $result->fields contains the returned row.
$db->queryBuilder()
->table('logs')
->insert([
'message' => 'User logged in',
'userid' => 42,
'created_at' => $db->queryBuilder()->raw('NOW()'),
]);
update(array $values): Result¶
Compiles and executes an UPDATE. Returns a Result. Requires at least one where() condition — updating without a condition is a logic error and will update all rows.
$db->queryBuilder()
->table('users')
->where('userid', 42)
->update(['last_login' => $db->queryBuilder()->raw('NOW()')]);
delete(): Result¶
Compiles and executes a DELETE. Returns a Result. Requires at least one where() condition.
$db->queryBuilder()
->from('sessions')
->where('expires_at', '<', $db->queryBuilder()->raw('NOW()'))
->delete();
truncate(): Result¶
Removes all rows from the table and resets any auto-increment counter. Faster than DELETE without a WHERE clause but cannot be rolled back in MySQL.
increment(string $column, int|float $step = 1): int¶
Atomically adds $step to $column for all rows matching the current WHERE conditions. Returns the number of affected rows.
// Increment the view counter for post 123
$db->queryBuilder()->from('posts')->where('postid', 123)->increment('views');
// Add 10 to stock for all active products in category 5
$db->queryBuilder()
->from('products')
->where('category_id', 5)
->where('active', 1)
->increment('stock', 10);
decrement(string $column, int|float $step = 1): int¶
Atomically subtracts $step from $column. Returns affected row count.
insertOrIgnore(array $values): Result¶
Inserts a row, silently doing nothing if a unique/primary key conflict is detected.
- MySQL:
INSERT IGNORE INTO ... - PostgreSQL:
INSERT INTO ... ON CONFLICT DO NOTHING
$db->queryBuilder()
->table('user_subscriptions')
->insertOrIgnore(['userid' => 42, 'topic' => 'alerts']);
// Second call with the same (userid, topic) does nothing — no exception thrown
upsert(array $values, array $conflictColumns, array $updateValues = []): Result¶
Insert-or-update. On conflict, updates the specified columns.
- MySQL:
INSERT INTO ... ON DUPLICATE KEY UPDATE col = VALUES(col), ... - PostgreSQL:
INSERT INTO ... ON CONFLICT (col) DO UPDATE SET col = EXCLUDED.col, ...
$conflictColumns defines the conflict target (PostgreSQL) or the unique key that triggers the update (MySQL). $updateValues is the list of column names to update on conflict; if omitted, all non-conflict columns from $values are updated.
// Insert or update a settings row keyed on (userid, setting_key)
$db->queryBuilder()
->table('user_settings')
->upsert(
['userid' => 5, 'setting_key' => 'theme', 'setting_value' => 'dark'],
['userid', 'setting_key'], // conflict target
['setting_value'] // only update this column on conflict
);
API reference — set operations¶
union(QueryBuilder $query, bool $all = false): static¶
Appends a UNION clause. Duplicate rows are eliminated. The two queries must have the same column count and compatible types.
$active = $db->queryBuilder()->select('userid', 'email')->from('users')->where('active', 1);
$admins = $db->queryBuilder()->select('userid', 'email')->from('admin_users');
$result = $active->union($admins)->get();
unionAll(QueryBuilder $query): static¶
Appends UNION ALL — preserves duplicate rows.
$q1 = $db->queryBuilder()->select('name')->from('buyers');
$q2 = $db->queryBuilder()->select('name')->from('sellers');
$result = $q1->unionAll($q2)->get(); // may contain the same name twice
API reference — extended conditions¶
whereNull(string $column, string $boolean = 'and', bool $not = false): static¶
Adds column IS NULL. Rarely called directly — prefer the named shortcuts below.
whereNotNull(string $column, string $boolean = 'and'): static¶
Adds column IS NOT NULL.
orWhereNull(string $column): static / orWhereNotNull(string $column): static¶
$qb->where('active', 1)->orWhereNull('deactivated_at');
// → WHERE active = 1 OR deactivated_at IS NULL
whereBetween(string $column, array $values, string $boolean = 'and', bool $not = false): static¶
Adds column BETWEEN min AND max. $values must be a two-element array [min, max].
whereNotBetween(string $column, array $values, string $boolean = 'and'): static¶
orWhereBetween(string $column, array $values): static / orWhereNotBetween(string $column, array $values): static¶
$qb->whereBetween('price', [10, 20])->orWhereBetween('price', [100, 200]);
// → WHERE price BETWEEN 10 AND 20 OR price BETWEEN 100 AND 200
API reference — convenience joins¶
rightJoin(string $table, string $first, string $operator = null, string $second = null): static¶
Adds a RIGHT JOIN. Same signature as leftJoin().
crossJoin(string $table): static¶
Adds a CROSS JOIN (Cartesian product). No ON clause.
API reference — convenience ordering / paging¶
latest(string $column = 'created_at'): static¶
Orders by $column DESC — convenience for "most recent first".
$qb->from('posts')->latest()->limit(10)->get();
// → SELECT * FROM posts ORDER BY created_at DESC LIMIT 10
oldest(string $column = 'created_at'): static¶
Orders by $column ASC.
forPage(int $page, int $perPage = 15): static¶
Sets LIMIT and OFFSET for a given page number (1-based). A shorthand for the common offset(($page - 1) * $perPage)->limit($perPage) pattern.
$result = $db->queryBuilder()
->from('products')
->orderBy('name')
->forPage(3, 20) // page 3, 20 per page → LIMIT 20 OFFSET 40
->get();
API reference — conditional queries¶
when(mixed $condition, Closure $callback, ?Closure $default = null): static¶
Conditionally applies $callback to the builder when $condition is truthy. If $condition is falsy and $default is provided, $default is applied instead. Enables building queries without if statements in the call chain.
$qb = $db->queryBuilder()->from('products');
// Adds a WHERE only when $categoryId is set
$result = $qb->when($categoryId, fn($q) => $q->where('category_id', $categoryId))->get();
// With a default fallback
$result = $qb->when($sortField,
fn($q) => $q->orderBy($sortField),
fn($q) => $q->orderBy('created_at', 'desc')
)->get();
API reference — sub-query conditions¶
whereExists(Closure $callback, string $boolean = 'and', bool $not = false): static¶
Adds an EXISTS (subquery) condition. The $callback receives a fresh QueryBuilder instance connected to the same database and grammar.
// Products that have at least one pending order
$result = $db->queryBuilder()
->from('products')
->whereExists(function (\Pramnos\Database\QueryBuilder $sub) {
$sub->select(['1'])
->from('order_items')
->whereRaw('order_items.product_id = products.id')
->whereRaw("order_items.status = 'pending'");
})
->get();
whereNotExists(Closure $callback, string $boolean = 'and'): static¶
Adds a NOT EXISTS (subquery) condition.
// Users with no orders
$result = $db->queryBuilder()
->from('users')
->whereNotExists(function (\Pramnos\Database\QueryBuilder $sub) {
$sub->select(['1'])->from('orders')->whereRaw('orders.userid = users.userid');
})
->get();
orWhereExists(Closure $callback): static / orWhereNotExists(Closure $callback): static¶
OR-connector variants.
API reference — date/time conditions¶
All date-part methods accept an optional $operator (default =). The two-argument form whereDate('col', 'value') is equivalent to whereDate('col', '=', 'value').
The compilation is dialect-transparent:
| Method | MySQL | PostgreSQL |
|---|---|---|
whereDate |
DATE(col) = ? |
(col)::date = ? |
whereYear |
YEAR(col) = ? |
EXTRACT(YEAR FROM col) = ? |
whereMonth |
MONTH(col) = ? |
EXTRACT(MONTH FROM col) = ? |
whereDay |
DAY(col) = ? |
EXTRACT(DAY FROM col) = ? |
whereTime |
TIME(col) = ? |
(col)::time = ? |
// Only events on 2026-03-15
$qb->from('events')->whereDate('event_time', '2026-03-15')->get();
// Events in March
$qb->from('events')->whereMonth('event_time', 3)->get();
// Events after 17:00
$qb->from('events')->whereTime('event_time', '>', '17:00:00')->get();
API reference — locking¶
Locking clauses require an active transaction. Attempting them outside a transaction has no effect on most engines.
lockForUpdate(): static¶
Adds a pessimistic write lock.
- MySQL:
… FOR UPDATE - PostgreSQL:
… FOR UPDATE
$db->query('BEGIN');
$result = $db->queryBuilder()
->from('inventory')
->where('product_id', 42)
->lockForUpdate()
->get();
// … mutate the row …
$db->query('COMMIT');
sharedLock(): static¶
Adds a shared read lock (prevents other connections from acquiring a write lock).
- MySQL:
… LOCK IN SHARE MODE - PostgreSQL:
… FOR SHARE
$db->query('BEGIN');
$result = $db->queryBuilder()->from('products')->where('active', 1)->sharedLock()->get();
$db->query('COMMIT');
API reference — chunked processing¶
chunk(int $size, Closure $callback): void¶
Iterates through all matching rows in batches of $size, passing each batch as a plain PHP array to $callback. The callback also receives the 1-based page number as a second argument.
Stop processing early by returning false from the callback.
Useful for processing large datasets without loading everything into memory at once.
$db->queryBuilder()
->from('users')
->where('active', 1)
->orderBy('userid')
->chunk(500, function (array $rows, int $page) {
foreach ($rows as $user) {
sendWelcomeEmail($user['email']);
}
// return false here to stop early
});
Important: Always include an
ORDER BYwhen usingchunk(). Without a deterministic order, the same row may appear in multiple chunks or be skipped entirely.
Working with Result objects¶
get(), first(), insert(), update(), and delete() all return a Pramnos\Database\Result instance.
Cursor-based iteration (recommended for large result sets)¶
$result = $qb->from('logs')->orderBy('logid', 'desc')->get();
while ($result->fetchNext()) {
echo $result->fields['message'] . "\n";
}
Fetch all rows at once¶
Accessing properties¶
| Property / Method | Description |
|---|---|
$result->fields |
Associative array of the current row (populated by fetch() / first()) |
$result->numRows |
Total number of rows in the result |
$result->eof |
true once all rows have been read |
$result->getNumRows() |
Same as numRows (method form) |
$result->getAffectedRows() |
Rows affected by the last UPDATE / DELETE |
$result->getInsertId() |
Auto-increment ID from the last INSERT (MySQL) |
$result->fetchAll() |
Returns all rows as an array and resets the cursor |
$result->fetch(bool $skipDataFix = false) |
Advances cursor; returns the row array or null at EOF |
$result->free() |
Frees the underlying database result resource |
Complete example — paginated list¶
$db = \Pramnos\Database\Database::getInstance();
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 20;
$qb = $db->queryBuilder()
->select('u.userid', 'u.username', 'u.email', 'g.groupname')
->from('users u')
->leftJoin('usergroups g', 'g.groupid', '=', 'u.groupid')
->where('u.active', 1)
->orderBy('u.username')
->limit($perPage)
->offset(($page - 1) * $perPage);
// count() clones internally — ORDER BY / LIMIT / OFFSET are stripped automatically
$total = $qb->count();
$result = $qb->get();
$users = $result->fetchAll();
// $users = array of rows
// $total = total matching rows (for pagination math)
BC notes¶
QueryBuilder is a new class — purely additive. The existing Database::query(), Database::prepareQuery(), Database::execute(), Database::insertDataToTable(), and Database::updateTableData() methods are unchanged and continue to work exactly as before.
Database::insertDataToTable() and Database::updateTableData() now use QueryBuilder internally, but their public signatures and return values are identical. Existing call sites require no changes.
5. Grammar / Adapter Pattern¶
Problem: The QueryBuilder previously compiled SQL directly, mixing dialect-specific logic (backtick vs. double-quote quoting, INSERT IGNORE vs. ON CONFLICT DO NOTHING, RETURNING clause) into a single class. Adding a new dialect or overriding compilation for a single operation required forking the whole class. Test isolation was impossible without a real database connection.
Solution: SQL compilation was extracted into a stateless Grammar layer. QueryBuilder remains the fluent API for building query ASTs; each Grammar class translates that AST into dialect-correct SQL. Grammars can be replaced or subclassed at any granularity.
Architecture overview¶
QueryBuilder (AST builder + execution)
│
└── GrammarInterface (compile contract)
│
Grammar (abstract base — shared compilation logic)
│
┌───┴──────────────┐
MySQLGrammar PostgreSQLGrammar
│
TimescaleDBGrammar (stub — extends PG, future time_bucket DDL)
Grammars are stateless — every compile method receives the QueryBuilder instance as its first argument and reads the builder's state through read-only accessors. No grammar holds mutable state between calls.
New classes¶
| Class / Interface | Namespace | Purpose |
|---|---|---|
GrammarInterface |
Pramnos\Database\Grammar |
Full compile contract (quoting, SELECT, DML) |
Grammar |
Pramnos\Database\Grammar |
Abstract base — shared implementation via Template Method |
MySQLGrammar |
Pramnos\Database\Grammar |
MySQL 5.7+ / 8.0 dialect |
PostgreSQLGrammar |
Pramnos\Database\Grammar |
PostgreSQL 12+ dialect |
TimescaleDBGrammar |
Pramnos\Database\Grammar |
TimescaleDB (extends PostgreSQLGrammar) |
Expression |
Pramnos\Database |
Raw SQL fragment — injected verbatim, no escaping |
Grammar selection¶
QueryBuilder selects the grammar automatically based on $db->type and $db->timescale:
$db->type |
$db->timescale |
Grammar selected |
|---|---|---|
'mysql' |
any | MySQLGrammar |
'postgresql' |
false (default) |
PostgreSQLGrammar |
'postgresql' |
true |
TimescaleDBGrammar |
Grammar injection¶
To override the grammar for a single query — useful for testing or custom dialects — use setGrammar():
$qb->setGrammar(new \Pramnos\Database\Grammar\PostgreSQLGrammar());
// Inspect the active grammar
$grammar = $qb->getGrammar(); // implements GrammarInterface
setGrammar(GrammarInterface $grammar): static¶
Replaces the active grammar for this builder instance. Fluent — returns $this.
getGrammar(): GrammarInterface¶
Returns the currently active grammar.
Writing a custom grammar¶
Extend Grammar and override the three abstract methods, plus any hooks you need:
namespace MyApp\Database\Grammar;
use Pramnos\Database\Grammar\Grammar;
use Pramnos\Database\QueryBuilder;
class SQLiteGrammar extends Grammar
{
public function quoteColumn(string $column): string
{
return '"' . $column . '"';
}
public function compileInsertOrIgnore(QueryBuilder $qb, array $values): string
{
$quotedCols = array_map(fn($c) => $this->quoteColumn($c), array_keys($values));
$placeholders = array_map(fn($v) => $this->getPlaceholder($v), array_values($values));
return 'INSERT OR IGNORE INTO ' . $qb->getFrom()
. ' (' . implode(', ', $quotedCols) . ')'
. ' VALUES (' . implode(', ', $placeholders) . ')';
}
public function compileUpsert(QueryBuilder $qb, array $values, array $conflictColumns, array $updateValues): string
{
// SQLite 3.24+ supports ON CONFLICT DO UPDATE
// ...implementation...
}
}
// Use it
$qb->setGrammar(new SQLiteGrammar());
Template Method hooks¶
Grammar provides two protected hooks that concrete grammars can override without re-implementing full compile methods:
compileReturning(QueryBuilder $qb): string¶
Returns a RETURNING col1, col2 string when the QB has returning columns set, or '' for dialects that do not support RETURNING. Called by compileInsert(), compileUpdate(), and compileDelete().
- Base implementation: returns
''(MySQL, any dialect without RETURNING) - PostgreSQLGrammar: returns
RETURNING col1, col2when$qb->getReturning()is non-empty
wrapColumnForOperator(string $column, string $operator): string¶
Optionally transforms a column reference based on the comparison operator. Called by compileWheres() for each Basic condition.
- Base implementation: returns
$columnunchanged - PostgreSQLGrammar: appends
::textforLIKE,ILIKE,NOT LIKE,NOT ILIKEoperators, enabling these operators to work on non-text column types without an explicit cast in application code
QueryBuilder state accessors¶
The Grammar layer reads builder state through these public accessor methods on QueryBuilder. They are primarily useful when writing custom grammars:
| Method | Returns | Description |
|---|---|---|
getFrom(): string |
string |
The FROM table name (with prefix token) |
getColumns(): array |
string[] |
Column list for SELECT |
isDistinct(): bool |
bool |
Whether DISTINCT is set |
getJoins(): array |
array |
JOIN clause descriptors |
getWheres(): array |
array |
WHERE condition descriptors |
getGroups(): array |
string[] |
GROUP BY columns |
getHavings(): array |
array |
HAVING condition descriptors |
getOrders(): array |
array |
ORDER BY descriptors |
getLimit(): ?int |
int\|null |
LIMIT value |
getOffset(): ?int |
int\|null |
OFFSET value |
getUnions(): array |
array |
UNION subquery descriptors |
getReturning(): array |
string[] |
RETURNING column list (PostgreSQL) |
GrammarInterface — full contract¶
interface GrammarInterface
{
// Identifier quoting
public function quoteColumn(string $column): string;
// Type-aware parameter placeholder (%i / %d / %b / %s / raw expression)
public function getPlaceholder(mixed $value): string;
// SELECT compilation
public function compileSelect(QueryBuilder $qb): string;
public function compileWheres(QueryBuilder $qb): string;
public function compileHavings(QueryBuilder $qb): string;
// DML compilation
public function compileInsert(QueryBuilder $qb, array $values): string;
public function compileInsertOrIgnore(QueryBuilder $qb, array $values): string;
public function compileUpsert(QueryBuilder $qb, array $values, array $conflictColumns, array $updateValues): string;
public function compileUpdate(QueryBuilder $qb, array $values): string;
public function compileDelete(QueryBuilder $qb): string;
public function compileTruncate(QueryBuilder $qb): string;
}
Dialect differences reference¶
| Feature | MySQLGrammar | PostgreSQLGrammar | TimescaleDBGrammar |
|---|---|---|---|
| Identifier quote | ` (backtick) |
" (double-quote) |
" (double-quote) |
insertOrIgnore |
INSERT IGNORE INTO |
ON CONFLICT DO NOTHING |
ON CONFLICT DO NOTHING |
upsert conflict |
ON DUPLICATE KEY UPDATE col = VALUES(col) |
ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col |
same as PostgreSQL |
RETURNING |
not emitted | RETURNING col1, col2 |
RETURNING col1, col2 |
| LIKE on non-text | no cast needed | col::text LIKE %s |
col::text LIKE %s |
Expression — raw SQL fragments¶
Expression is a value object that bypasses all quoting and placeholder substitution. It is injected verbatim into the compiled SQL.
Class: Pramnos\Database\Expression
// Construct via QueryBuilder::raw() (preferred)
$expr = $qb->raw("NOW()");
$expr = $qb->raw("COALESCE(last_login, created_at)");
// Or directly
$expr = new \Pramnos\Database\Expression("ST_MakePoint(23.72, 37.98)::geography");
Expression implements __toString() returning its raw value. Grammar::getPlaceholder() detects Expression instances and returns (string)$value — the raw SQL — instead of a %s/%i token.
Security: Never construct an
Expressionfrom user-supplied input. UsewhereRaw()with$bindingsfor parameterised user data.
BC notes¶
The Grammar layer is entirely additive. No existing public API changed:
- All
QueryBuilderpublic methods have the same signatures as before. - All compile methods moved from
QueryBuilderintoGrammarclasses — no call-site changes are needed sinceQueryBuilderdelegates transparently. Expressionwas extracted from the bottom ofQueryBuilder.phpinto its own PSR-4 file; the class name, namespace, and behaviour are identical.- The
Grammarnamespace (Pramnos\Database\Grammar) is new — no existing import can conflict.
6. DDL Schema Builder¶
Problem: Creating and modifying tables required raw $db->query("CREATE TABLE ...") calls peppered throughout migration files and application code. Each dialect requires different SQL (backtick vs. double-quote, TINYINT(1) vs. BOOLEAN, AUTO_INCREMENT vs. SERIAL, MySQL ENGINE=InnoDB vs. nothing). Capability-conditional DDL (create a hypertable on TimescaleDB, do nothing on MySQL) had no structured pattern.
Solution: SchemaBuilder provides a fluent, dialect-aware DDL interface. Column types, indexes, constraints, views, and TimescaleDB hypertable operations are compiled to correct SQL for the active backend automatically. An ifCapable() method enables clean conditional DDL without nested if ($db->type === ...) blocks.
Entry point: $db->schema() — returns a fresh SchemaBuilder bound to the current connection.
Getting started¶
$schema = $db->schema();
// Create a table
$schema->createTable('#PREFIX#users', function (\Pramnos\Database\Blueprint $table) {
$table->bigIncrements('userid');
$table->string('username', 100)->unique();
$table->string('email', 255)->unique();
$table->string('password_hash', 255);
$table->boolean('active')->default(true);
$table->timestamps();
$table->foreign('groupid')->references('groupid')->on('#PREFIX#groups')->cascadeOnDelete();
});
// Drop when done
$schema->dropTableIfExists('#PREFIX#users');
// Add a column
$schema->alterTable('#PREFIX#users', function ($table) {
$table->string('phone', 20)->nullable()->after('email');
});
Column type reference¶
| Blueprint method | MySQL | PostgreSQL |
|---|---|---|
tinyInteger(name) |
TINYINT |
SMALLINT |
smallInteger(name) |
SMALLINT |
SMALLINT |
integer(name) |
INT |
INTEGER |
bigInteger(name) |
BIGINT |
BIGINT |
unsignedInteger(name) |
INT UNSIGNED |
INTEGER |
unsignedBigInteger(name) |
BIGINT UNSIGNED |
BIGINT |
increments(name) |
INT UNSIGNED AUTO_INCREMENT PK |
SERIAL PK |
bigIncrements(name) |
BIGINT UNSIGNED AUTO_INCREMENT PK |
BIGSERIAL PK |
char(name, length) |
CHAR(n) |
CHAR(n) |
string(name, length=255) |
VARCHAR(n) |
VARCHAR(n) |
text(name) |
TEXT |
TEXT |
mediumText(name) |
MEDIUMTEXT |
TEXT |
longText(name) |
LONGTEXT |
TEXT |
float(name) |
FLOAT |
REAL |
double(name) |
DOUBLE |
DOUBLE PRECISION |
decimal(name, total, places) |
DECIMAL(p,s) |
DECIMAL(p,s) |
boolean(name) |
TINYINT(1) |
BOOLEAN |
date(name) |
DATE |
DATE |
time(name) |
TIME |
TIME |
dateTime(name) |
DATETIME |
TIMESTAMP |
timestamp(name) |
TIMESTAMP |
TIMESTAMP |
timestampTz(name) |
TIMESTAMP |
TIMESTAMPTZ |
year(name) |
YEAR |
INTEGER |
binary(name) |
BLOB |
BYTEA |
json(name) |
JSON |
JSON |
jsonb(name) |
JSON (fallback) |
JSONB |
uuid(name) |
CHAR(36) |
UUID |
enum(name, values[]) |
ENUM('v1','v2') |
VARCHAR(n) CHECK (col IN (...)) |
geometry(name) |
GEOMETRY |
GEOMETRY |
timestamps() |
nullable created_at + updated_at TIMESTAMP |
same |
timestampsTz() |
nullable TIMESTAMP | nullable TIMESTAMPTZ |
softDeletes() |
nullable deleted_at TIMESTAMP |
same |
Column modifiers¶
All column methods return a ColumnDefinition for chaining:
$table->string('bio', 500)->nullable();
$table->integer('score')->default(0)->unsigned();
$table->string('email')->unique();
$table->bigIncrements('id')->primary();
$table->string('phone')->nullable()->after('email'); // MySQL: position after 'email'
$table->string('name')->comment('User display name');
$table->string('status')->check("status IN ('active','inactive')");
$table->timestamp('created_at')->useCurrent();
Index and constraint helpers¶
// Table-level constraints inside Blueprint callback
$table->primary(['col1', 'col2']); // composite PK
$table->unique(['email', 'tenant_id'], 'uq_name'); // named unique
$table->index(['category', 'created_at'], 'idx_name'); // non-unique index
$table->foreign('user_id')->references('userid')->on('users')->cascadeOnDelete();
// Standalone index methods (outside createTable)
$schema->createIndex('table', 'idx_name', ['col1', 'col2']);
$schema->createUniqueIndex('table', 'uq_name', ['col']);
$schema->dropIndex('table', 'idx_name');
ALTER TABLE¶
$schema->alterTable('#PREFIX#users', function ($table) {
// Add columns
$table->string('phone', 20)->nullable()->after('email');
$table->integer('login_count')->default(0);
// Drop columns
$table->dropColumn('old_field');
$table->dropColumn(['field_a', 'field_b']);
// Rename a column
$table->renameColumn('old_name', 'new_name');
// Modify an existing column (change type and/or attributes)
$table->modifyColumn('bio', 'text'); // type only
$table->modifyColumn('status', 'string', ['length' => 100]) // type + length
->nullable(false)
->default('active');
// Drop an index
$table->dropIndex('idx_old_name');
// Drop a foreign key
$table->dropForeign('fk_old_constraint');
// Add a new unique constraint
$table->unique('email', 'uq_email');
// Add a new index
$table->index(['country', 'city'], 'idx_location');
// Add a new foreign key
$table->foreign('new_col')->references('id')->on('other_table')->nullOnDelete();
});
Modifying existing columns¶
modifyColumn(string $name, string $type, array $attrs = []): ColumnDefinition rewrites the definition of an existing column in place. The same fluent modifiers as column creation are available.
$schema->alterTable('users', function ($table) {
// Change type only
$table->modifyColumn('bio', 'text');
// Change type + length
$table->modifyColumn('username', 'string', ['length' => 150]);
// Change type, remove nullable, set a default
$table->modifyColumn('status', 'string', ['length' => 50])
->nullable(false)
->default('active');
// Change numeric column + default
$table->modifyColumn('score', 'integer')->nullable()->default(0);
});
Dialect behaviour:
| Dialect | Generated SQL |
|---|---|
| MySQL | ALTER TABLE t MODIFY COLUMN name type [modifiers] — single statement |
| PostgreSQL | Up to 3 separate ALTER TABLE t ALTER COLUMN name statements: TYPE new_type USING col::new_type, SET/DROP NOT NULL (only if nullable() was called), SET/DROP DEFAULT (only if default() was called) |
| TimescaleDB | Same as PostgreSQL |
Only attributes that are explicitly set trigger the corresponding PostgreSQL sub-statement. Calling modifyColumn('col', 'text') with no chained modifiers emits exactly one TYPE statement — nullability and default are left untouched.
View operations¶
// Regular views (all backends)
$schema->createView('v_active_users', 'SELECT * FROM users WHERE active = 1');
$schema->createOrReplaceView('v_active_users', 'SELECT * FROM users WHERE active = 1');
$schema->dropView('v_active_users'); // IF EXISTS by default
$schema->dropView('v_active_users', false); // strict — error if not found
// Materialized views (PostgreSQL/TimescaleDB)
// On MySQL: falls back silently to a regular VIEW
$schema->createMaterializedView('mv_stats', 'SELECT region, COUNT(*) FROM orders GROUP BY region');
$schema->refreshMaterializedView('mv_stats');
$schema->refreshMaterializedView('mv_stats', true); // CONCURRENTLY (PG: allows concurrent reads)
$schema->dropMaterializedView('mv_stats');
Introspection¶
if ($schema->hasTable('users')) {
// table exists
}
if ($schema->hasColumn('users', 'email')) {
// column exists
}
TimescaleDB operations¶
All TimescaleDB methods silently return false (no-op) on non-TimescaleDB backends:
$schema->createHypertable('events', 'created_at', [
'chunk_time_interval' => '7 days',
]);
$schema->addSpaceDimension('events', 'device_id', 4);
$schema->enableCompression('events', [
'segmentby' => 'device_id',
'orderby' => 'created_at DESC',
]);
$schema->addCompressionPolicy('events', '60 days');
$schema->addRetentionPolicy('events', '365 days');
// Continuous aggregate — TimescaleDB native / PG MATERIALIZED VIEW / MySQL VIEW
$schema->createContinuousAggregate(
'hourly_events',
"SELECT time_bucket('1 hour', created_at) AS bucket, COUNT(*) FROM events GROUP BY bucket"
);
Capability-conditional DDL¶
ifCapable() on SchemaBuilder runs a callback only when the active backend supports the requested capability. The SchemaBuilder instance is passed to both callbacks:
$schema->createTable('#PREFIX#tokenactions', function ($table) {
$table->bigIncrements('actionid');
$table->string('action', 64);
$table->integer('userid')->nullable();
$table->text('data')->nullable();
$table->timestampTz('action_time')->default(new \Pramnos\Database\Expression('NOW()'));
});
$schema->ifCapable(\Pramnos\Database\DatabaseCapabilities::TIMESCALEDB,
function (\Pramnos\Database\SchemaBuilder $schema) {
$schema->createHypertable('#PREFIX#tokenactions', 'action_time', [
'chunk_time_interval' => '14 days',
]);
$schema->enableCompression('#PREFIX#tokenactions', ['segmentby' => 'action']);
$schema->addCompressionPolicy('#PREFIX#tokenactions', '60 days');
}
// No fallback — table stays as a regular table on MySQL and plain PG
);
// With fallback
$schema->ifCapable(
\Pramnos\Database\DatabaseCapabilities::MATERIALIZED_VIEWS,
fn($s) => $s->createMaterializedView('mv_stats', $query),
fn($s) => $s->createView('mv_stats', $query) // MySQL: regular VIEW
);
API: ifCapable(string $capability, callable $callback, ?callable $fallback = null): mixed
- $callback receives SchemaBuilder $schema (not Database)
- Returns the return value of whichever callable ran, or null if absent and no fallback
Grammar injection¶
// Override the grammar (e.g. in tests or for a custom dialect)
$schema->setGrammar(new \Pramnos\Database\Grammar\PostgreSQLSchemaGrammar());
$grammar = $schema->getGrammar(); // SchemaGrammarInterface
DatabaseCapabilities — new capabilities (v1.2 alignment)¶
Two capabilities added to align with the Backport Spec, usable in ifCapable() calls:
| Constant | True when |
|---|---|
DatabaseCapabilities::MATERIALIZED_VIEWS |
PostgreSQL or TimescaleDB |
DatabaseCapabilities::ENUMS |
PostgreSQL (native CREATE TYPE … AS ENUM) |
Convenience methods added: hasMaterializedViews(): bool, hasEnums(): bool.
Spec-aligned constant aliases (same values as the old FEATURE_* names):
- DatabaseCapabilities::TIMESCALEDB = FEATURE_TIMESCALEDB
- DatabaseCapabilities::JSONB = FEATURE_JSONB
Old FEATURE_* constants are kept for backward compatibility (@deprecated).
Triggers¶
SchemaBuilder supports creating and dropping database triggers in a dialect-aware way.
$schema = $db->schema();
// MySQL: trigger body is a PL/SQL BEGIN … END block
$schema->createTrigger(
'trg_log_insert', // trigger name
'orders', // table the trigger watches
'AFTER', // timing: BEFORE | AFTER
'INSERT', // event: INSERT | UPDATE | DELETE
"BEGIN
INSERT INTO order_audit (order_id, action, created_at)
VALUES (NEW.id, 'insert', NOW());
END"
);
// PostgreSQL: body must reference a trigger function (already created separately)
$schema->createTrigger(
'trg_log_insert',
'orders',
'AFTER',
'INSERT',
'EXECUTE FUNCTION log_order_insert()'
);
// Drop with IF EXISTS guard (safe to call in migrations that may re-run)
$schema->dropTrigger('trg_log_insert', 'orders', ifExists: true);
API:
- createTrigger(string $name, string $table, string $timing, string $event, string $body, string $forEach = 'ROW'): void
- dropTrigger(string $name, string $table, bool $ifExists = true): void
Dialect differences:
| MySQL | PostgreSQL | |
|---|---|---|
| Trigger body | Inline BEGIN … END PL/SQL |
EXECUTE FUNCTION fn_name() (separate function required) |
| DDL verb | CREATE TRIGGER |
CREATE OR REPLACE TRIGGER |
| Drop syntax | DROP TRIGGER [IF EXISTS] name |
DROP TRIGGER [IF EXISTS] name ON table |
Sequences¶
PostgreSQL has native sequences — monotonically increasing integer generators that are independent of any table. They are ideal for:
- Distributed IDs: generating unique IDs across multiple application servers without a shared AUTO_INCREMENT table.
- Batch reservation: reserving a block of IDs up-front (
setVal) and assigning them locally without round-trips. - Cross-table keys: sharing a single counter across several tables (e.g. a unified event ID space).
MySQL does not support standalone sequences. All sequence methods are silent no-ops on MySQL and return 0 as a sentinel so callers can detect the limitation without a separate capability check.
Create and drop¶
$schema = $db->schema();
// PostgreSQL: creates the sequence (no-op on MySQL)
$schema->createSequence(
'order_seq',
start: 1000, // first value returned by nextVal()
increment: 5, // step between values
minValue: 1000,
maxValue: null, // no upper bound
cycle: false // stop (error) when exhausted
);
// Drop (IF EXISTS by default)
$schema->dropSequence('order_seq', ifExists: true);
API: createSequence(string $name, int $start = 1, int $increment = 1, ?int $minValue = null, ?int $maxValue = null, bool $cycle = false): void
nextVal() — advance and read¶
$id = $schema->nextVal('order_seq');
// PostgreSQL: advances the sequence and returns the new value
// MySQL: returns 0 (no-op)
Each call to nextVal() is atomic and guaranteed unique even under concurrent connections — no application-level locking is needed.
setVal() — reposition the sequence¶
// isCalled = true (default): the *next* nextVal() returns value + increment
$schema->setVal('order_seq', 500);
$next = $schema->nextVal('order_seq'); // → 505 (with increment=5)
// isCalled = false: the *next* nextVal() returns exactly value
$schema->setVal('order_seq', 500, isCalled: false);
$next = $schema->nextVal('order_seq'); // → 500
isCalled mirrors PostgreSQL's native setval(name, value, is_called) third argument:
- true means "the sequence has already been called at this value" — so the next call advances first.
- false means "the sequence hasn't been called yet at this value" — so the next call returns it as-is.
Common pattern — batch ID reservation:
// Reserve 1 000 IDs for this application server
$schema->setVal('order_seq', $currentMax + 1000, isCalled: false);
// Now assign IDs locally from $currentMax+1 … $currentMax+1000 without DB round-trips
MySQL compatibility¶
$id = $schema->nextVal('order_seq');
if ($id === 0) {
// Running on MySQL — fall back to AUTO_INCREMENT or a different strategy
$db->query("INSERT INTO orders ...");
$id = $db->insertId();
}
Returning 0 (not null) ensures a strict === 0 check works correctly even if the sequence legitimately returns large integers.
Migration-support helpers (added for Backport migrations)¶
The following public methods were added to expose the migration API cleanly to Migration subclasses and to align with the Backport Spec's calling convention.
Database::statement(string $sql): bool¶
Executes a raw SQL statement and returns true on success, false on failure. Intended for DDL and procedural statements (CREATE, ALTER, CALL, etc.) that produce no result set.
Database::selectOne(string $sql, array $bindings = []): ?array¶
Executes a SELECT query and returns the first row as an associative array, or null if no rows were found. Bindings are positional (?) and are SQL-escaped — not prepared statements.
$row = $this->DB()->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_name = ?",
['users']
);
Database::getDriverName(): string¶
Returns a PDO-compatible driver name: 'pgsql' for PostgreSQL and TimescaleDB, 'mysql' otherwise.
Database::capabilities(): DatabaseCapabilities¶
Returns a DatabaseCapabilities instance bound to this connection.
Migration::DB(): Database¶
Protected helper that returns the database connection from the application. Preferred over $this->application->database in migration code.
Migration::schema(string $schemaName = ''): SchemaBuilder¶
Protected helper that returns a SchemaBuilder bound to the current connection. When $schemaName is given, tables are automatically qualified with that schema.
$this->schema('applications')->create('application_settings', function ($table) { ... });
$this->schema('public')->table('usertokens', function ($table) { ... });
SchemaBuilder::withSchema(string $schema): static¶
Returns a new builder that prepends schema. to every unqualified table name. Equivalent to always passing schema.tableName but without repeating the schema in each call.
$schema = $db->schema()->withSchema('analytics');
$schema->create('events', fn($t) => ...); // creates analytics.events
$schema->dropIfExists('events'); // drops analytics.events
SchemaBuilder::table(string $tableName, callable $callback): void¶
Alias for alterTable(). Matches the Laravel Schema::table() calling convention used in Backport migrations.
SchemaBuilder::dropIfExists(string $tableName): void¶
Alias for dropTableIfExists(). Preferred form in Migration::down() methods.
Blueprint::addColumn(string $name, string $type, array $attributes = []): ColumnDefinition¶
Previously protected; now public. Provides an escape hatch for driver-specific column types not covered by the typed helpers (e.g. inet[], text[] on PostgreSQL).
$table->addColumn('allowed_ips', 'inet[]')->nullable();
$table->addColumn('scope', 'text[]')->nullable();
ColumnDefinition::notNull(): static¶
Alias for ->nullable(false). Reads more naturally in migration code.
ForeignKeyDefinition::name(string $name): static¶
Alias for ->constraintName(). Matches the Backport Spec's ->name() chaining style.
PostgreSQL named UNIQUE constraints¶
PostgreSQLSchemaGrammar now emits CONSTRAINT "name" UNIQUE (columns) instead of anonymous UNIQUE (columns). Named constraints can be reliably referenced in dropUnique() calls and in test assertions.
BC notes¶
SchemaBuilderpreviously existed as a stub (onlycreate(),drop(),truncate(),createHypertable(),addRetentionPolicy()). All those methods still exist with the same signatures. The stubcreate()anddrop()now delegate tocreateTable()anddropTableIfExists().$db->schemaBuilder()continues to work;$db->schema()is the new preferred alias.ColumnDefinition,ForeignKeyDefinition, andBlueprintare new classes — purely additive.- The
SchemaGrammar*classes are in thePramnos\Database\Grammarnamespace — no existing import can conflict. Blueprint::modifyColumn()andColumnDefinition::has()are additive — no existing API changed.Blueprint::addColumn()wasprotected; it is nowpublic. Any subclass that overrode it with aprotectedmodifier will need to widen topublic— this is backward-compatible in PHP 8+.
7. timeBucket() — Dialect-Transparent Time Bucketing¶
8. Characterization Coverage Expansion¶
Problem: Large legacy subsystems carried behavior that was not explicitly specified in tests, making internal refactors risky.
Solution: Expanded framework-native characterization tests across low-coverage subsystems, locking current behavior before further internal migrations.
Added characterization suites¶
Application: Settings, Controller, ViewApplication(extended): Model list/count/API query contracts (getCount,_getList,_getApiList)Application(extended): Api helper contracts (_httpStatusToText,_translateStatus)Application(extended): Api key lifecycle contracts (Api\\Apikeysave/load/getData)Application(extended): Runtime helper contracts (Applicationredirect/breadcrumb/state/maintenance helpers)Application(extended): Log controller helper contracts (LogControllerwhitelist/date-parser/action-buttons paths)Console: CommandsLogs: LogManager + LogViewerUser: token lifecycle +setPassword()branchesCache: FileAdapter + AbstractAdapterHtml: Breadcrumb + Html + DateGeneral: StringHelper + HelpersGeolocation: coordinate validation + distance contractsEmail: fluent setter API + error-state accessorsTheme: content/widget/menu lightweight contracts (without full bootstrap)Media: Thumbnail data-object defaults and mutability
Current validated baseline¶
- Full suite verified with
./dockertest: 736 tests / 1353 assertions passing. - Coverage artifacts are refreshed from the same run (
coverage/index.html,coverage/clover.xml).
Why this matters for v1.2¶
- Reduces regression risk for upcoming internal QueryBuilder/SchemaBuilder migrations.
- Preserves backward-compatible runtime behavior while allowing internal cleanup.
- Captures known edge cases/limitations as executable contracts instead of tribal knowledge.
Problem: Time-series applications need to group rows by time interval (e.g. every 15 minutes, every hour). Each database engine expresses this differently: TimescaleDB has time_bucket(), PostgreSQL uses date_trunc(), and MySQL uses DATE_FORMAT() or FROM_UNIXTIME() arithmetic. Writing dialect branches in application code defeats the purpose of having a grammar layer.
Solution: QueryBuilder::timeBucket(string $interval, string|Expression $column): Expression returns a dialect-appropriate SQL expression. The caller writes once; the grammar translates transparently.
Getting started¶
$db = \Pramnos\Application\Application::getInstance()->db;
$qb = $db->getQueryBuilder();
// Group sensor readings by 15-minute buckets
$bucket = $qb->timeBucket('15 minutes', 'recorded_at');
$result = $qb
->select([$bucket . ' AS bucket', 'AVG(value) AS avg_value'])
->from('sensor_readings')
->groupBy([$bucket])
->orderBy($bucket, 'asc')
->get();
Dialect translation¶
| Interval | TimescaleDB | PostgreSQL | MySQL |
|---|---|---|---|
'1 second' |
time_bucket('1 second', col) |
date_trunc('second', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 1) * 1) |
'1 minute' |
time_bucket('1 minute', col) |
date_trunc('minute', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 60) * 60) |
'15 minutes' |
time_bucket('15 minutes', col) |
to_timestamp(floor(extract(epoch from col) / 900) * 900) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 900) * 900) |
'1 hour' |
time_bucket('1 hour', col) |
date_trunc('hour', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 3600) * 3600) |
'6 hours' |
time_bucket('6 hours', col) |
to_timestamp(floor(extract(epoch from col) / 21600) * 21600) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 21600) * 21600) |
'1 day' |
time_bucket('1 day', col) |
date_trunc('day', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 86400) * 86400) |
'1 week' |
time_bucket('1 week', col) |
date_trunc('week', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col) / 604800) * 604800) |
'1 month' |
time_bucket('1 month', col) |
date_trunc('month', col) |
DATE_FORMAT(col, '%Y-%m-01') |
'1 year' |
time_bucket('1 year', col) |
date_trunc('year', col) |
DATE_FORMAT(col, '%Y-01-01') |
PostgreSQL note: date_trunc is used when count = 1 and the unit maps to an exact precision. For non-standard intervals (e.g. "15 minutes", "6 hours") PostgreSQL falls back to epoch arithmetic via to_timestamp(floor(extract(epoch from …) / N) * N).
API reference¶
QueryBuilder::timeBucket(string $interval, string|Expression $column): Expression¶
Returns a raw SQL Expression containing the dialect-appropriate time-bucket function call.
$interval— A human-readable interval string:"1 second","15 minutes","1 hour","6 hours","1 day","1 week","1 month","1 year". Both singular ("1 minute") and plural ("15 minutes") forms are accepted.$column— Column name (string) or anExpression(e.g. the result of$qb->raw(...)).- Returns an
Expressionthat can be used anywhere a column reference is accepted:select(),groupBy(),orderBy(),where(),having().
Grammar methods¶
Each grammar implements compileTimeBucket(string $interval, string $column): string (defined in GrammarInterface):
| Grammar | Strategy |
|---|---|
TimescaleDBGrammar |
Native time_bucket('interval', col) |
PostgreSQLGrammar |
date_trunc for count=1 units; to_timestamp epoch arithmetic for arbitrary sub-month intervals |
MySQLGrammar (via Grammar base) |
DATE_FORMAT for month/year; FROM_UNIXTIME arithmetic for all sub-month intervals |
Advanced usage¶
// Combine with WHERE, HAVING
$bucket = $qb->timeBucket('1 hour', 'ts');
$result = $qb
->select([$bucket . ' AS bucket', 'COUNT(*) AS cnt', 'MAX(value) AS peak'])
->from('metrics')
->where('ts', '>=', '2026-01-01 00:00:00')
->groupBy([$bucket])
->having('cnt', '>', 10)
->orderBy($bucket)
->get();
// Use an Expression as the column (e.g. timezone conversion)
$col = $qb->raw("recorded_at AT TIME ZONE 'UTC'");
$bucket = $qb->timeBucket('1 day', $col);
BC notes¶
- Purely additive. No existing method signature changed.
GrammarInterfacegainscompileTimeBucket()— any custom grammar implementation must add this method. The baseGrammarclass provides a MySQL implementation as a safe default.
9. Phase 4: Migration System Overhaul¶
Problem: The original Migration base class had minimal metadata ($version, $description, $autoExecute) and no history tracking. There was no way to know which migrations had run, in what order, or whether they had failed. Running a new batch was indistinguishable from the first run.
Solution: Migration gains structured metadata fields, and the new MigrationRunner class handles execution order (topological sort + priority + timestamp), history recording, cutoff filtering, and rollback — all against a persistent framework_migrations history table.
9.1 Enhanced Migration base class¶
New metadata fields (all BC-safe — existing subclasses work without changes):
| Property | Type | Default | Purpose |
|---|---|---|---|
$feature |
string |
'' |
Feature key ('auth', 'queue', …); empty = app migration |
$scope |
string |
'app' |
'app' or 'framework' |
$priority |
int |
50 |
Lower number runs first |
$dependencies |
array |
[] |
Slugs of migrations that must run before this one |
$autorun |
bool |
true |
false = requires --force |
$transactional |
bool |
false |
true = wrap up() in BEGIN/COMMIT/ROLLBACK on PostgreSQL. Has no effect on MySQL (DDL always causes implicit COMMIT). Must remain false for TimescaleDB-native operations such as createHypertable(). |
BC alias: $autoExecute is a PHP 8.4 property hook that reads and writes $autorun. Existing code using $autoExecute continues to work unchanged.
New methods:
// Returns the migration slug derived from the class name.
// CamelCase → snake_case: CreateUsersTable → create_users_table
// Timestamped: 2024_01_15_120000_create_users_table → create_users_table
public function getSlug(): string
// Returns the YYYY_MM_DD_HHmmss prefix, or null for un-timestamped names.
public function getTimestamp(): ?string
Example migration¶
class CreateUsersTable extends Migration
{
public string $feature = 'auth';
public string $scope = 'framework';
public int $priority = 20;
public array $dependencies = ['create_roles_table'];
public string $description = 'Creates the users table';
public function up(): void
{
$this->addQuery("CREATE TABLE IF NOT EXISTS `users` (...)");
$this->executeQueries();
}
public function down(): void
{
$this->addQuery("DROP TABLE IF EXISTS `users`");
$this->executeQueries();
}
}
9.2 MigrationRunner¶
Namespace: Pramnos\Database\MigrationRunner
Constructor:
new MigrationRunner(
?Database $db = null, // null = unit-test mode (sort/filter only)
string $historyTable = 'framework_migrations',
?Application $app = null // enables maintenance-mode integration
)
When $app is provided, run() activates maintenance mode (creates ROOT/var/MAINTENANCE) before executing the batch and deactivates it afterwards in a finally block. If maintenance mode was already active when run() is called it is left as-is (not turned off on exit).
Running migrations¶
$runner = new MigrationRunner($db);
// Run all pending migrations (sorted, filtered, recorded)
$result = $runner->run($migrations);
// $result = ['ran' => ['create_roles_table', ...], 'failed' => [...]]
// With options
$result = $runner->run($migrations, [
'force' => true, // include autorun=false migrations
'cutoff' => '2022_01_01_000000', // skip migrations at or before this date
]);
History table¶
ensureHistoryTable() creates framework_migrations (or the configured table name) if it does not already exist. Safe to call multiple times.
Schema:
migration VARCHAR(255) -- slug, e.g. 'create_users_table'
scope VARCHAR(255) DEFAULT 'app'
feature VARCHAR(255) NULL
batch INT NULL -- same number for all migrations in one run()
execution_time DOUBLE NULL -- seconds
result SMALLINT DEFAULT 1 -- 1=success, 0=failed
error_message TEXT NULL
description VARCHAR(255) NULL
ran_at TIMESTAMP DEFAULT NOW()
Sorting¶
sort(array $migrations, array $alreadyRan = []): array
Returns migrations in execution order:
1. Topological sort — dependencies run before their dependents.
2. Priority ascending — lower $priority number runs first.
3. Timestamp ascending — older YYYY_MM_DD_HHmmss prefix runs first (tie-breaker).
// Throws RuntimeException when a cycle is detected
// Throws RuntimeException for truly unknown dependencies
// (deps satisfied in $alreadyRan are accepted silently)
$sorted = $runner->sort($migrations);
Filtering¶
// Exclude autorun=false migrations (pass force=true to include them)
$runner->filterAutorun($migrations, force: false);
// Skip migrations at or before the cutoff timestamp
$runner->filterCutoff($migrations, cutoff: '2022_01_01_000000');
// Remove already-ran slugs
$runner->filterAlreadyRan($migrations, ranSlugs: ['create_roles_table']);
Rollback¶
// Rolls back the last batch: calls down() on each migration in reverse order
// and removes their history rows.
$result = $runner->rollback($migrations);
// $result = ['rolledBack' => ['create_users_table', ...]]
Pending migrations¶
// Returns migrations whose slug does not appear as result=1 in history.
// Failed (result=0) migrations remain in the pending list for retry.
$pending = $runner->getPending($migrations);
BC notes¶
Migration::$autoExecuteis preserved as a PHP 8.4 property hook. Reading or writing it is equivalent to reading or writing$autorun. No behavior change for existing code.Migration::$version,$description,$autoExecuteremain public and writable.MigrationRunneris entirely additive — it does not replace or change the existingMigration::executeQueries()/addQuery()API.
10. Phase 4: MigrationLoader and CLI Commands¶
Problem: MigrationRunner had no way to discover migration classes from the filesystem, and there were no CLI commands to run migrations outside of PHP code.
Solution: MigrationLoader discovers migration classes from a directory, and five new CLI commands expose the full migration lifecycle from the terminal.
10.1 Migration::getSlug() and getTimestamp() — filename-first derivation¶
getSlug() and getTimestamp() now check the migration file's basename first before falling back to the class short name. This is necessary because PHP class names cannot start with a digit, so the canonical form of a timestamped migration lives in the filename, not the class name.
File: app/Migrations/2024_03_15_143022_create_users_table.php
Class: CreateUsersTable
getSlug() → 'create_users_table' (from filename)
getTimestamp() → '2024_03_15_143022' (from filename)
For legacy non-timestamped files (CreateUsersTable.php), the class name is used as before — fully backward compatible.
10.2 MigrationLoader¶
Namespace: Pramnos\Database\MigrationLoader
Discovers and instantiates Migration subclasses from PHP files in a directory. Each file is included via include_once and classes are matched by their defining file path, so the loader is safe to call multiple times (no duplicate instantiation).
// Load all migrations from the app's Migrations directory
$migrations = MigrationLoader::loadFromDirectory(
ROOT . '/app/Migrations',
$app
);
// Load from multiple directories (e.g. app + framework migrations)
$migrations = MigrationLoader::loadFromDirectories(
[ROOT . '/app/Migrations', ROOT . '/vendor/pramnos/framework/migrations'],
$app
);
Files are sorted alphabetically before loading, so YYYY_MM_DD_HHmmss_ prefixes naturally produce chronological order.
Method reference:
| Method | Description |
|---|---|
loadFromDirectory(string $dir, Application $app): Migration[] |
Load from one directory. Non-Migration PHP classes are silently ignored. |
loadFromDirectories(array $dirs, Application $app): Migration[] |
Load and merge from multiple directories. |
10.3 MigrationRunner additions¶
| Method | Description |
|---|---|
rollback(array $migrations, ['batch' => N]) |
Roll back a specific batch (new batch option). |
rollbackAll(array $migrations): array |
Roll back every batch — equivalent to migrate:reset. |
getHistory(): array |
Return all history rows ordered by batch/id — used by migrate:status. |
hasPendingFromSlugs(array $slugTimestamps, string $cutoff = '') |
Fast slug-based pending check without loading PHP files. |
The $onProgress callback now receives a 4th float $ms argument (elapsed milliseconds). Existing closures that only declare 3 parameters are unaffected — PHP silently ignores extra arguments.
10.4 Auto-run in Application::exec()¶
Framework-level migrations with $autoExecute = true run automatically on every HTTP request via Application::runAutoMigrations(). The check uses a three-phase fingerprint approach to keep the per-request cost minimal:
| Phase | Trigger | DB operations |
|---|---|---|
| 1 — fingerprint hit | Default (99 % of requests) | 1 × PK lookup (SELECT 1 WHERE key = '__fw_auto_…') |
| 2 — pending check | Fingerprint absent (new files) | 1 × full slug SELECT |
| 3 — load + run | Phase 2 finds pending work | PHP file load + run() |
The fingerprint key format is __fw_auto_{count}_{latestTimestamp}[_{cutoff}]. It changes only when new migration files are added or the migration_cutoff in app.php changes.
$autoExecute = false migrations never run automatically and require the CLI command.
migration_cutoff in app.php¶
Any framework migration whose filename timestamp is at or before the cutoff is
skipped entirely — it is not loaded, not checked against the history table, and does
not appear in hasPending results.
Baseline epoch (2020_01_01_*)
All framework migrations that were written before the migration system existed carry the
synthetic timestamp 2020_01_01_000000–2020_01_01_000052. These represent the
"baseline schema" assumed to be in place on any installation that predates the framework
migration system (e.g. Urbanwater production, which set up those same structures via its
own app-level migrations).
Set the following in the application settings of any such installation:
This skips all 2020_01_01_* baseline migrations without affecting any future framework
migrations, which use the actual current date (e.g. 2026_05_28_000001_*).
New framework migrations must always use the current date as the prefix. Using
2020_01_01_* for a new migration would silently skip it on every installation that has
the standard cutoff configured.
Hooks for subclass customisation¶
| Method | Default | Override to… |
|---|---|---|
getFrameworkMigrationDirs(): string[] |
glob(frameworkBase/*) |
Add project-specific dirs |
getMigrationHistoryTable(): string |
'schemaversion' |
Isolate test runs |
10.4 CLI Commands¶
All five commands are registered in Console\Application and available via the framework console binary. They all accept a --path option to override the default migrations directory (ROOT/app/Migrations).
migrate¶
migrate [<migration>] [--scope=app|framework] [--feature=<key>] [--force] [--cutoff=<ts>] [--path=<dir>]
Runs all pending migrations. Filters by scope, feature, or a single migration slug. With --force, includes autorun=false migrations. With --cutoff, skips anything at or before the given timestamp.
migrate:rollback¶
Rolls back the last batch (or --batch=N for a specific one).
migrate:reset¶
Rolls back ALL batches. Prompts for confirmation unless --force is passed.
migrate:refresh¶
Rolls back all batches and immediately re-runs all migrations (reset + migrate in one step).
migrate:status¶
Displays a table with the status of every known migration:
| Migration | Scope | Feature | Status | Batch | Time (s) | Ran At |
|---|---|---|---|---|---|---|
| create_users_table | app | auth | Ran | 1 | 0.0023 | 2024-03-15 14:30:22 |
| create_jobs_table | app | queue | Pending | — | — | — |
Migrations that are no longer in the codebase but exist in history are shown as (removed).
BC notes (10)¶
MigrationLoaderis entirely new — no existing code is affected.MigrationRunner::rollback()is backward compatible; thebatchoption is additive.Migration::getSlug()andgetTimestamp()behavior changes only for migration files named with theYYYY_MM_DD_HHmmss_prefix — all other classes behave identically to before.
11. Phase 4: Feature Registry¶
Overview¶
FeatureRegistry is a static central registry that separates known features
(declared by the framework or add-ons) from enabled features (activated by
the application in app.php). It enforces the invariant that an application
cannot enable a feature that has not been formally registered, and it exposes
metadata (description, Service Provider FQCN, migration paths) that other Phase
4 subsystems — Service Providers, CLI commands — can consume.
The core feature is a permanent special case: it is always considered enabled,
regardless of what is listed in app.php.
New classes¶
| Class | Namespace | File |
|---|---|---|
FeatureRegistry |
Pramnos\Application |
src/Pramnos/Application/FeatureRegistry.php |
UnknownFeatureException |
Pramnos\Application |
src/Pramnos/Application/UnknownFeatureException.php |
FeatureRegistry¶
A pure-static class — no instantiation needed. All state is held in private
static properties and is cleared between tests via reset().
Terminology¶
| Term | Meaning |
|---|---|
| Registered / known | A feature the framework declared via register(). |
| Enabled | A registered feature the application listed in app.php. |
Registration¶
Declares a feature so it can later be enabled. Calling register() a second
time with the same key overwrites the previous definition (last write wins).
$config accepts the following optional keys:
| Key | Type | Default | Description |
|---|---|---|---|
description |
string |
'' |
Human-readable description. |
provider |
string\|null |
null |
FQCN of the ServiceProvider class. |
migrations |
string[] |
[] |
Filesystem paths to migration directories. |
Example — custom add-on:
FeatureRegistry::register('my_plugin', [
'description' => 'My custom plugin',
'provider' => MyPlugin\ServiceProvider::class,
'migrations' => [__DIR__ . '/database/migrations'],
]);
Loading from app.php¶
Marks every key in $features as enabled. Called automatically by
Application::init() with $this->applicationInfo['features'] ?? [].
- Calling it multiple times is safe — it accumulates rather than replaces.
- An empty array is a no-op.
- If any key in
$featureshas not been registered, anUnknownFeatureExceptionis thrown immediately (fail-fast).
app.php example:
Querying¶
Returns true if the feature is currently enabled. 'core' is always true.
Unknown or registered-but-not-enabled keys return false without throwing.
Returns all currently-enabled feature keys. 'core' is always included at
index 0 even if it was not explicitly listed.
Returns all registered feature keys (built-in defaults + any custom registrations).
Returns the FQCN of the Service Provider class, or null when no provider has
been wired up yet (expected during Phase 4 before Phase 2 backports land).
Returns the migration directory paths registered for a feature. Returns []
when none are set — callers do not need to guard against null.
Returns the complete definition array {description, provider, migrations} for
a registered feature, or null for unknown keys.
Lifecycle helpers¶
Registers the five built-in framework features. Called lazily on the first
public-method call after reset(), but can also be called explicitly in tests
that need a clean, pre-populated registry.
Built-in feature keys registered by initDefaults():
| Key | Description |
|---|---|
core |
Core framework — always active |
auth |
Basic Authentication and Authorization |
authserver |
OAuth 2.0 Authorization Server |
messaging |
Messaging System (threads and recipients) |
queue |
Background Job Queue |
Clears all known features, all enabled features, and the $defaultsLoaded
flag. Intended for test isolation only. The next public-method call after
reset() re-triggers initDefaults() automatically.
UnknownFeatureException¶
Thrown by FeatureRegistry::loadFromConfig() when an application tries to
enable a feature key that has not been registered.
Constructor¶
Builds a diagnostic message that names the offending key and lists all currently registered keys so the developer can spot typos immediately.
Example message:
Unknown feature 'typo_key'. Known features: core, auth, authserver, messaging, queue.
Register the feature with FeatureRegistry::register() before enabling it.
Methods¶
Returns the feature key that triggered this exception, so catch blocks can inspect it programmatically without having to parse the message string.
Integration with Application::init()¶
Application::init() now calls:
immediately after the database connection is established and
$this->initialized = true is set. This ensures that every subsystem that
runs after init() can rely on FeatureRegistry::isEnabled() to be accurate.
Usage pattern¶
// Check a feature anywhere after Application::init():
if (FeatureRegistry::isEnabled('auth')) {
// wire auth middleware
}
// CLI command that needs per-feature migration paths:
foreach (FeatureRegistry::getEnabled() as $feature) {
$paths = FeatureRegistry::getMigrationPaths($feature);
// load migrations from $paths
}
// Add-on registration (before Application::init()):
FeatureRegistry::register('my_addon', [
'description' => 'My Add-on',
'provider' => MyAddon\ServiceProvider::class,
'migrations' => [__DIR__ . '/migrations'],
]);
BC notes (11)¶
FeatureRegistryandUnknownFeatureExceptionare entirely new — no existing public API is changed.Application::init()gains one new call toFeatureRegistry::loadFromConfig()— this is additive and backward compatible. Applications that do not define afeatureskey inapp.phppass an empty array and the call is a no-op.- Existing applications will receive an
UnknownFeatureExceptionif they list an unregistered feature key inapp.php. This is intentional fail-fast behavior — it surfaces configuration mistakes at boot rather than silently ignoring them.
12. Phase 4: Service Providers¶
Overview¶
ServiceProvider establishes the register / boot lifecycle for framework
add-ons and application-level bootstrapping code. Providers are an optional,
additive layer — existing add-on bootstrap mechanisms continue to work
unchanged.
Every enabled feature whose FeatureRegistry definition includes a provider
FQCN will have that provider automatically instantiated and run during
Application::init().
New classes¶
| Class | Namespace | File |
|---|---|---|
ServiceProvider |
Pramnos\Application |
src/Pramnos/Application/ServiceProvider.php |
Modified classes¶
| Class | Change |
|---|---|
Application |
Added addProvider(), bootServiceProviders(), $serviceProviders property |
ServiceProvider¶
Abstract base class. Subclasses override one or both lifecycle methods:
Constructor¶
Stores the application instance in $this->app. Called by the framework —
do not call manually outside of tests.
register()¶
Called for all providers before any provider's boot() runs. Use this
phase to bind services, register container aliases, or perform any work that
other providers' boot() methods may depend on.
boot()¶
Called after all providers have been registered. Use this phase to register routes, event listeners, scheduled tasks, middleware, etc.
$app property¶
The current Application instance, available inside both register() and
boot().
Bootstrap lifecycle in Application¶
addProvider(ServiceProvider $provider): void¶
Queues a provider for bootstrapping. Must be called before init().
Intended for application-level providers that are not tied to a registered
feature key:
bootServiceProviders() (protected)¶
Called internally by init() after FeatureRegistry::loadFromConfig().
- Iterates
FeatureRegistry::getEnabled()and instantiates each feature's provider (skips features whose provider FQCN isnullor whose class does not exist). - Merges with any providers queued via
addProvider(). - Calls
register()on all providers. - Calls
boot()on all providers.
The two-phase order ensures that a provider can safely consume bindings made
by another provider in its own boot() method.
Linking a provider to a feature¶
Any application that lists 'my_feature' in app.php will automatically
have MyServiceProvider instantiated and run during init().
Example provider¶
namespace MyAddon;
use Pramnos\Application\ServiceProvider;
class MyServiceProvider extends ServiceProvider
{
public function register(): void
{
// bind MyService so other providers can rely on it during boot()
}
public function boot(): void
{
// register routes, listeners, scheduled tasks …
}
}
BC notes (12)¶
ServiceProvideris entirely new — no existing code is affected.ApplicationgainsaddProvider(),$serviceProviders, andbootServiceProviders()— all additive.- Applications that define no
featuresinapp.phpand call noaddProvider()will havebootServiceProviders()run but do nothing (no providers are instantiated, both loops are empty). - All currently registered built-in features (
core,auth,authserver,messaging,queue) haveprovider: nullin Phase 4 — no providers will be instantiated for them until Phase 2 backports are implemented.
13. Phase 4: Health Check & Observability¶
Overview¶
The Health Check subsystem provides a structured way to verify that the
application and its dependencies are functioning correctly. Checks are
registered with HealthRegistry and can be run via the health:check CLI
command or programmatically.
Exit codes of health:check map directly to monitoring expectations:
0 = all OK, 1 = degraded, 2 = down.
New classes¶
| Class | Namespace | File |
|---|---|---|
HealthStatus (enum) |
Pramnos\Health |
src/Pramnos/Health/HealthStatus.php |
HealthCheckResult |
Pramnos\Health |
src/Pramnos/Health/HealthCheckResult.php |
HealthCheck (interface) |
Pramnos\Health |
src/Pramnos/Health/HealthCheck.php |
HealthRegistry |
Pramnos\Health |
src/Pramnos/Health/HealthRegistry.php |
DatabaseConnectivityCheck |
Pramnos\Health\Checks |
src/Pramnos/Health/Checks/DatabaseConnectivityCheck.php |
DiskSpaceCheck |
Pramnos\Health\Checks |
src/Pramnos/Health/Checks/DiskSpaceCheck.php |
MemoryLimitCheck |
Pramnos\Health\Checks |
src/Pramnos/Health/Checks/MemoryLimitCheck.php |
HealthCheck (command) |
Pramnos\Console\Commands |
src/Pramnos/Console/Commands/HealthCheck.php |
HealthStatus (enum)¶
worst(HealthStatus $other): HealthStatus — Returns the more severe of
two statuses. Severity order: Ok < Degraded < Down.
HealthCheckResult¶
Immutable value object returned by every health check.
class HealthCheckResult
{
public readonly HealthStatus $status;
public readonly string $name;
public readonly string $message;
public readonly array $details; // array<string, mixed>
}
Named constructors¶
HealthCheckResult::ok(string $name, string $message = '', array $details = [])
HealthCheckResult::degraded(string $name, string $message, array $details = [])
HealthCheckResult::down(string $name, string $message, array $details = [])
toArray(): array¶
Returns {status, name, message, details} ready for JSON encoding.
HealthCheck (interface)¶
interface HealthCheck
{
public function getName(): string;
public function run(): HealthCheckResult;
}
run() must never throw — all errors must be caught internally and returned
as a Down or Degraded result.
HealthRegistry¶
Static registry for health checks. The same pattern as FeatureRegistry:
pure-static, reset() for test isolation.
HealthRegistry::register(HealthCheck $check): void
HealthRegistry::get(string $name): ?HealthCheck
HealthRegistry::getNames(): array // string[]
HealthRegistry::run(string $name): HealthCheckResult // throws InvalidArgumentException for unknown name
HealthRegistry::runAll(): array // {status, checks}
HealthRegistry::reset(): void // test isolation only
runAll() report shape:
{
"status": "ok",
"checks": {
"database": {"status": "ok", "name": "database", "message": "Reachable", "details": {"latency_ms": 0.8, "driver": "mysql"}},
"disk_space": {"status": "ok", "name": "disk_space", "message": "12345 MB free (12.3% used)", "details": {...}},
"memory_limit": {"status": "degraded", "name": "memory_limit", "message": "High memory usage: 78.1%", "details": {...}}
}
}
Built-in checks¶
DatabaseConnectivityCheck¶
Executes SELECT 1 on the database and measures round-trip latency.
Reports down on any connection failure or exception.
| Detail key | Type | Description |
|---|---|---|
latency_ms |
float | Query round-trip in milliseconds |
driver |
string | Database type (e.g. 'mysql', 'postgresql') |
DiskSpaceCheck¶
new DiskSpaceCheck(
string $path = '.',
int $degradedThresholdMb = 500,
int $downThresholdMb = 100
)
Checks free disk space on the given filesystem path.
| Detail key | Type |
|---|---|
free_mb |
int |
total_mb |
int |
used_pct |
float |
MemoryLimitCheck¶
Compares current PHP memory usage against the configured memory_limit.
Reports OK when no limit is configured (-1).
| Detail key | Type |
|---|---|
used_mb |
float |
limit_mb |
float |
used_pct |
float |
health:check CLI command¶
php pramnos health:check
php pramnos health:check --json
php pramnos health:check --only=database,disk_space
Registers the three built-in checks automatically, then runs all (or the selected) checks and outputs a table (default) or JSON.
| Exit code | Meaning |
|---|---|
| 0 | All checks OK |
| 1 | One or more checks Degraded |
| 2 | One or more checks Down |
Custom checks¶
class RedisCheck implements \Pramnos\Health\HealthCheck
{
public function getName(): string { return 'redis'; }
public function run(): \Pramnos\Health\HealthCheckResult
{
try {
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
return HealthCheckResult::ok($this->getName(), 'Reachable');
} catch (\Throwable $e) {
return HealthCheckResult::down($this->getName(), $e->getMessage());
}
}
}
// Register inside a ServiceProvider::boot():
HealthRegistry::register(new RedisCheck());
BC notes (13)¶
- All Health Check classes are entirely new — no existing code is affected.
health:checkis a new CLI command — no existing commands are changed.- The
HealthRegistryfollows the same static pattern asFeatureRegistryand usesreset()for test isolation.
14. Phase 4: Scheduled Tasks System¶
Overview¶
The Scheduled Tasks System provides an in-process cron-like scheduler that
does not depend on the system crontab. Tasks are registered programmatically
(typically inside a ServiceProvider::boot()) and are run by a single entry
in the host crontab:
New classes¶
| Class | Namespace | File |
|---|---|---|
CronExpression |
Pramnos\Scheduling |
src/Pramnos/Scheduling/CronExpression.php |
ScheduledTask |
Pramnos\Scheduling |
src/Pramnos/Scheduling/ScheduledTask.php |
Scheduler |
Pramnos\Scheduling |
src/Pramnos/Scheduling/Scheduler.php |
ScheduleRun (command) |
Pramnos\Console\Commands |
src/Pramnos/Console/Commands/ScheduleRun.php |
ScheduleList (command) |
Pramnos\Console\Commands |
src/Pramnos/Console/Commands/ScheduleList.php |
CronExpression¶
Parses and evaluates standard 5-field cron expressions
(minute hour day-of-month month day-of-week).
Supports: * wildcard, exact values, N-M ranges, */N and N-M/N steps,
comma-separated lists, and combinations (1,5-10,*/15).
$cron = new CronExpression('0 2 * * *');
$cron->isDue(new \DateTime('2025-03-15 02:00')); // true
$cron->isDue(new \DateTime('2025-03-15 03:00')); // false
withTime(string $time): self¶
Returns a new expression with the minute and hour replaced by the given time
('HH:MM'). Used internally by ScheduledTask::at().
ScheduledTask¶
Wraps a callable, job object, or CLI command with its timing configuration.
Created via the Scheduler factory — not instantiated directly.
Fluent timing methods¶
| Method | Cron expression |
|---|---|
everyMinute() |
* * * * * |
everyNMinutes(int $n) |
*\/N * * * * |
everyFiveMinutes() |
*\/5 * * * * |
everyTenMinutes() |
*\/10 * * * * |
everyFifteenMinutes() |
*\/15 * * * * |
everyThirtyMinutes() |
*\/30 * * * * |
hourly() |
0 * * * * |
daily() |
0 0 * * * |
weekly() |
0 0 * * 0 |
monthly() |
0 0 1 * * |
yearly() |
0 0 1 1 * |
cron(string $expr) |
custom expression |
at(string $time): static¶
Adjusts the hour and minute of the current expression. Typically chained
after daily() or weekly():
Scheduler::call($fn)->daily()->at('02:30'); // runs at 02:30 every day
Scheduler::call($fn)->weekly()->at('03:00'); // runs at 03:00 every Sunday
withoutOverlapping(string $lockDir = ''): static¶
Skips execution when a previous run of the same task is still active. Uses a PID lock file in the system temp directory (or the specified directory).
description(string $desc): static¶
Sets a human-readable label shown by schedule:list.
isDue(\DateTimeInterface $when): bool¶
Returns true when the task's expression is satisfied at the given moment.
run(): void¶
Executes the task. Respects withoutOverlapping() lock files.
getSummary(): array¶
Returns {type, handler, expression, description, no_overlap} for display.
Scheduler¶
Static factory and registry.
use Pramnos\Scheduling\Scheduler;
// In ServiceProvider::boot():
Scheduler::command('cleanup:temp')->daily()->at('02:00');
Scheduler::call(fn() => Cache::flush())->everyHour();
Scheduler::job(new RefreshAnalyticsJob())->cron('*/15 * * * *');
| Method | Description |
|---|---|
command(string $cmd): ScheduledTask |
Creates a CLI command task |
call(callable $fn): ScheduledTask |
Creates a callable task |
job(object $job): ScheduledTask |
Creates a job object task (handle() method) |
all(): ScheduledTask[] |
Returns all registered tasks |
getDue(\DateTimeInterface): ScheduledTask[] |
Returns tasks due at the given moment |
reset(): void |
Clears all tasks (test isolation) |
CLI commands¶
schedule:run¶
Runs all currently-due tasks. --pretend lists them without executing.
Exit code 0 on success, 1 if any task throws an exception.
schedule:list¶
Displays a table of all registered tasks with type, cron expression, handler/description, and overlap-prevention status.
BC notes (14)¶
- All Scheduling classes are entirely new — no existing code is affected.
schedule:runandschedule:listare new CLI commands.Scheduleris static;reset()is for test isolation only.
15. Phase 4: Policy Engine¶
Overview¶
The Policy Engine executes scheduled database maintenance policies (retention,
aggregate-refresh, compression, cache-rebuild) on backends that do not have a
native scheduler. On TimescaleDB, the engine is a no-op — native policies
handle their own scheduling internally. On MySQL and plain PostgreSQL,
the engine reads the framework_policies table and executes the SQL appropriate
for each policy type.
New classes¶
| Class | Namespace | File |
|---|---|---|
PolicyRecord |
Pramnos\Policy |
src/Pramnos/Policy/PolicyRecord.php |
PolicyEngine |
Pramnos\Policy |
src/Pramnos/Policy/PolicyEngine.php |
PolicyEngine (command) |
Pramnos\Console\Commands |
src/Pramnos/Console/Commands/PolicyEngine.php |
New system migration¶
| File | Feature | Tables |
|---|---|---|
2020_01_01_000002_create_framework_policies_table.php |
core |
framework_policies |
framework_policies table¶
Created by the core system migration. Schema (PostgreSQL / MySQL):
policyid SERIAL / AUTO_INCREMENT PRIMARY KEY
policy_type VARCHAR(50) — 'retention' | 'aggregate_refresh' | 'compression' | 'cache_rebuild'
target VARCHAR(255) — table name or view name
config JSON — type-specific configuration
enabled BOOLEAN DEFAULT TRUE
last_run TIMESTAMP NULL
next_run TIMESTAMP NULL
last_result TEXT NULL
last_error TEXT NULL
created_at TIMESTAMP DEFAULT NOW()
The migration path for the core feature is automatically registered in
FeatureRegistry::initDefaults().
PolicyRecord¶
Immutable value object that represents one row from framework_policies.
Accepts raw associative DB rows. JSON config columns are decoded
automatically (or accepted pre-decoded as arrays). All properties are
readonly.
PolicyEngine¶
run(): array¶
Fetches all due, enabled policies and executes each one. Returns an array of result records:
[
['policyid' => 1, 'policy_type' => 'retention', 'target' => 'logs', 'status' => 'ok', 'error' => null],
['policyid' => 2, 'policy_type' => 'aggregate_refresh', ..., 'status' => 'error', 'error' => '...'],
]
Returns [] immediately on TimescaleDB.
getAllEnabled(): PolicyRecord[]¶
Returns all enabled policies regardless of their next_run timestamp.
register(string $type, string $target, array $config = []): int¶
Inserts a new policy and returns its policyid.
setEnabled(int $policyId, bool $enabled): void¶
Enables or disables a policy by ID.
remove(int $policyId): void¶
Deletes a policy by ID.
Policy types¶
| Type | Action on MySQL / plain PG |
|---|---|
retention |
DELETE FROM target WHERE time_column < NOW() - INTERVAL config.interval |
aggregate_refresh |
REFRESH MATERIALIZED VIEW (PG) or TRUNCATE + INSERT INTO … SELECT * (MySQL) |
compression |
No-op (no native compression) |
cache_rebuild |
TRUNCATE + INSERT INTO … SELECT * (any backend) |
service:policy-engine CLI command¶
php pramnos service:policy-engine
php pramnos service:policy-engine --list
php pramnos service:policy-engine --pretend
| Option | Effect |
|---|---|
| (none) | Runs all due policies |
--list |
Lists all enabled policies without running them |
--pretend |
Shows which policies would run without executing them |
Exit codes: 0 = success, 1 = one or more policies failed.
QueryBuilder migration (PolicyEngine)¶
PolicyEngine::register(), setEnabled(), remove(), loadPolicies(), and
updateHistory() were originally written with dialect-specific raw SQL
($1, $2 PostgreSQL placeholders and ? MySQL placeholders). All five methods
were migrated to QueryBuilder in this session.
Key implementation details:
policyTableNameis resolved once in the constructor via$db->schema()->resolveTableName('pramnos.framework_policies')— this translates the schema-qualified name to the physical name per backend (e.g.pramnos_framework_policieson MySQL with empty prefix).whereRaw('enabled = TRUE')works on both MySQL (synonym for 1) and PostgreSQL (native BOOLEAN literal).whereRaw('(next_run IS NULL OR next_run <= NOW()')is used for the due-policy filter.$qb->raw('NOW()')is used forlast_runandcreated_atfields.nullvalues inupdate()(fornext_run,last_error) are preserved correctly — QB'sarray_filtercallback only removesExpressioninstances, notnull.
SchemaBuilder additions¶
addRetentionPolicy(string $table, string $dropAfter, string $timeColumn = 'created_at'): bool¶
New optional $timeColumn parameter (default: created_at). On non-TimescaleDB
backends, instead of returning false, the method now inserts a retention
policy into pramnos.framework_policies via QB so that the Policy Engine daemon
can execute the DELETE job on schedule.
addContinuousAggregatePolicy(string $view, string $startOffset, string $endOffset, string $scheduleInterval): bool¶
New method. On TimescaleDB: calls add_continuous_aggregate_policy(). On
MySQL / plain PostgreSQL: inserts an aggregate_refresh policy into
pramnos.framework_policies via QB.
Tests (15)¶
| File | Tests |
|---|---|
tests/Characterization/Policy/PolicyEngineCharacterizationTest.php |
8 MySQL integration tests |
Covers: register() persistence, multiple policies, setEnabled() toggle,
remove() permanence, run() no-op policy history update, due/not-due
filtering (next_run IS NULL OR <= NOW()), retention execution (old rows
deleted, new rows preserved), unknown policy type → error result (not exception).
BC notes (15)¶
PolicyRecord,PolicyEngine, andservice:policy-engineare entirely new.- The
corefeature inFeatureRegistry::initDefaults()now includes a migration path — this is additive and does not affect existing code. - The
framework_policiessystem migration uses the 2020 timestamp so existing installations that set amigration_cutoffdate inapp.phpwill skip it automatically. addRetentionPolicy()gains a third optional parameter$timeColumn— no BC break (default preserves old behaviour).addContinuousAggregatePolicy()is entirely new — additive.
16. Phase 4: Framework System Migrations¶
Overview¶
The framework ships a set of system migrations that create the database tables
required by each built-in feature. These migrations live outside the src/ tree in
database/migrations/framework/{feature}/ and are not part of the application's
own migration suite. They use the same Migration base class and SchemaBuilder API
as application migrations.
The migration paths are automatically registered in FeatureRegistry::initDefaults()
so that MigrationLoader can discover them when migrate --scope=framework is invoked.
Location¶
database/migrations/framework/
├── core/
│ ├── 2020_01_01_000001_create_sessions_table.php
│ ├── 2020_01_01_000002_create_settings_table.php
│ └── 2020_01_01_000003_create_framework_policies_table.php
├── auth/
│ ├── 2020_01_01_000010_create_users_table.php
│ ├── 2020_01_01_000011_create_userdetails_table.php
│ ├── 2020_01_01_000012_create_userlog_table.php
│ ├── 2020_01_01_000013_create_usernotes_table.php
│ ├── 2020_01_01_000014_create_usertokens_table.php
│ ├── 2020_01_01_000015_create_urls_table.php
│ └── 2020_01_01_000016_create_tokenactions_table.php
├── authserver/
│ ├── 2020_01_01_000020_create_authserver_schema.php
│ ├── 2020_01_01_000021_create_authserver_roles_table.php
│ ├── 2020_01_01_000022_create_authserver_permissions_table.php
│ ├── 2020_01_01_000023_create_authserver_user_roles_table.php
│ └── 2020_01_01_000024_create_authserver_audit_log_table.php
├── messaging/
│ ├── 2020_01_01_000030_create_mails_table.php
│ ├── 2020_01_01_000031_create_mailtemplates_table.php
│ ├── 2020_01_01_000032_create_messages_table.php
│ ├── 2020_01_01_000033_create_massmessages_table.php
│ └── 2020_01_01_000034_create_massmessagerecipients_table.php
└── queue/
└── 2020_01_01_000040_create_queueitems_table.php
Namespaces¶
Each feature subdirectory uses its own namespace:
| Directory | Namespace |
|---|---|
core/ |
Pramnos\Framework\Migrations\Core |
auth/ |
Pramnos\Framework\Migrations\Auth |
authserver/ |
Pramnos\Framework\Migrations\AuthServer |
messaging/ |
Pramnos\Framework\Migrations\Messaging |
queue/ |
Pramnos\Framework\Migrations\Queue |
These namespaces are not part of the Composer PSR-4 autoload map — they are
loaded on-demand by MigrationLoader::loadFromDirectory().
Schema summary¶
All schemas match the UrbanWater (UW) production database. Schema comments in the migration source files contain the full rationale for each column.
core feature¶
sessions — Visitor session store (shared with User subsystem).
| Column | Type | Notes |
|---|---|---|
visitorid |
VARCHAR(32) PK |
Visitor identifier |
time |
INT |
Unix timestamp of last activity |
userid |
BIGINT nullable |
FK to users if logged in |
sid |
VARCHAR(32) |
PHP session ID |
agent |
VARCHAR(255) |
Browser user agent |
history |
TEXT |
JSON-encoded URL visit history |
ip |
VARCHAR(45) nullable |
IPv4/IPv6 address |
data |
TEXT nullable |
Serialised session payload |
settings — Application key/value configuration store.
| Column | Type | Notes |
|---|---|---|
setting_id |
INT PK auto-increment |
— |
setting |
VARCHAR(255) |
Setting key |
value |
TEXT nullable |
Setting value |
delete |
TINYINT |
Default 1; 0 = preserve on reset |
framework_policies — Scheduled database maintenance policies (retention, aggregate-refresh, compression, cache-rebuild). Used by PolicyEngine.
| Column | Type | Notes |
|---|---|---|
policyid |
INT PK auto-increment |
— |
policy_type |
VARCHAR(50) |
retention, aggregate_refresh, compression, cache_rebuild |
target |
VARCHAR(255) |
Table or view name |
config |
JSON |
Type-specific configuration object |
enabled |
TINYINT |
Default 1 |
last_run |
INT nullable |
Unix timestamp |
next_run |
INT nullable |
Unix timestamp |
last_result |
TEXT nullable |
— |
last_error |
TEXT nullable |
— |
auth feature¶
users — UW production user accounts (BIGSERIAL PK = signed BIGINT).
| Column | Type | Notes |
|---|---|---|
userid |
BIGINT PK auto-increment |
Signed (BIGSERIAL on PG); matches legacy FK in userstogroups |
username |
VARCHAR(100) |
— |
email |
VARCHAR(255) |
— |
password |
VARCHAR(255) |
— |
firstname |
VARCHAR(100) |
— |
lastname |
VARCHAR(100) |
— |
regdate |
INT |
Unix timestamp |
lastlogin |
INT |
Unix timestamp |
active |
TINYINT |
0/1 |
validated |
TINYINT |
Email verified flag |
usertype |
TINYINT |
— |
modified |
INT |
Unix timestamp |
locationid |
INT nullable |
Organisation FK |
fbauth |
VARCHAR(255) nullable |
OAuth2 provider ID |
| … | … | + date/display/locale prefs, otherinfo, theme, etc. |
userdetails — EAV extension table (userid, fieldname composite PK).
userlog — Audit log per user (logid, userid, date, logtype, log, details).
usernotes — Admin notes per user (userid, admin, note, date).
usertokens — Multi-purpose token store (session, API, OAuth, PKCE).
| Column | Type | Notes |
|---|---|---|
tokenid |
INT PK auto-increment |
— |
userid |
BIGINT |
FK → users.userid CASCADE |
tokentype |
VARCHAR(20) |
session, api, access_token, refresh_token, auth_code, password_reset, email_verify |
token |
TEXT |
TEXT (not VARCHAR) — accommodates JWTs of any length |
created |
INT |
Unix timestamp |
notes |
VARCHAR(255) |
Human-readable purpose label |
lastused |
INT |
Unix timestamp, default 0 |
status |
TINYINT |
0=inactive, 1=active, 2=removed |
parentToken |
INT nullable |
Self-referencing FK for refresh↔access pairs |
applicationid |
INT nullable |
OAuth client FK |
actions |
INT |
API call counter |
removedate |
INT |
Unix timestamp, default 0 |
deviceinfo |
TEXT |
JSON-encoded device info |
scope |
TEXT |
Space-separated OAuth scopes |
expires |
INT nullable |
Unix timestamp; NULL = never |
ipaddress |
VARCHAR(45) nullable |
IPv4/IPv6 at token creation |
code_challenge |
VARCHAR(128) nullable |
PKCE challenge (RFC 7636) |
code_challenge_method |
VARCHAR(10) nullable |
plain or S256 |
urls — Deduplicated URL table for tokenactions (CRC32 hash as BIGINT).
tokenactions — API call audit log per token; TimescaleDB hypertable on PG.
| Column | Type | Notes |
|---|---|---|
actionid |
INT auto-increment |
Part of composite PK (actionid, action_time) |
tokenid |
INT |
FK → usertokens.tokenid |
urlid |
INT |
FK → urls.urlid |
method |
VARCHAR(6) |
HTTP method |
params |
TEXT |
Request parameters |
servertime |
INT |
Unix timestamp (legacy) |
return_status |
INT nullable |
HTTP status code |
execution_time_ms |
DECIMAL(10,3) nullable |
Wall-clock ms |
return_data |
JSONB (PG) / JSON (MySQL) nullable |
Response snapshot |
action_time |
TIMESTAMPTZ |
TimescaleDB partition key — 14-day chunks |
On TimescaleDB: hypertable with 14-day chunks, 60-day compression policy.
authserver feature¶
On PostgreSQL, all tables live in the dedicated authserver schema. On MySQL,
tables are in the default schema with the authserver_ prefix.
authserver.roles — RBAC role definitions (scoped to organisation via organization_id).
authserver.permissions — Fine-grained RBAC grants; deny entries take absolute priority over allow.
authserver.user_roles — User-to-role assignments; composite PK (userid, roleid).
authserver.audit_log — Generic event audit trail with polymorphic actor/target/object model. old_values, new_values, and metadata are JSONB on PostgreSQL. organization_context scopes events to a specific organisation (NULL = global).
messaging feature¶
mails — Sent email history (status, from/to/subject, content TEXT, date INT, MD5 hash CHAR(32)).
mailtemplates — Localised email/notification templates (category, language, type lookup index).
messages — UW type-based message state machine (type TINYINT, fromuserid/touserid BIGINT nullable, massid nullable, bbcode/html/smilies flags).
massmessages — Broadcast messages (locationid TEXT for JSON array, totalrecipients INT).
massmessagerecipients — Per-user delivery status for broadcasts; FK CASCADE from massmessages.messageid (INT UNSIGNED).
queue feature¶
queueitems — Background task queue.
| Column | Type | Notes |
|---|---|---|
taskid |
BIGINT PK auto-increment |
BIGSERIAL on PG |
type |
VARCHAR(50) |
Handler class name |
payload |
JSON |
Task input |
status |
VARCHAR(20) (PG) / TINYINT (MySQL) |
pending/processing/completed/failed/warning |
priority |
SMALLINT |
Lower = higher priority; ≤10 treated as urgent |
attempts / maxattempts |
INT |
Retry tracking |
lockedby |
VARCHAR(100) nullable |
Worker identity for atomic claim |
lockexpires |
TIMESTAMP nullable |
Stale-lock detection threshold |
task_hash |
VARCHAR(64) nullable |
SHA-256 for deduplication |
execution_time |
DECIMAL(10,3) nullable |
Wall-clock seconds |
On PostgreSQL: the queue_status ENUM type is created (pending, processing, completed, failed, warning) and the column carries a CHECK constraint enforcing it.
Idempotency¶
Every up() method guards with if ($schema->hasTable('name')) { return; } before
calling createTable(). Running migrate --scope=framework multiple times is safe.
Every down() method uses dropTableIfExists().
Integration tests¶
Two dedicated integration test classes verify all migrations against live database
containers (run via ./dockertest):
| Class | Database | Tests | What is covered |
|---|---|---|---|
tests/Integration/Database/FrameworkMigrationsMySQLTest |
MySQL 8.0 | 17 | Table existence, column types (information_schema), indexes, FK constraints, TINYINT status on queueitems, idempotency |
tests/Integration/Database/FrameworkMigrationsPostgreSQLTest |
TimescaleDB/PG 14 | 22 | Same as MySQL plus: authserver schema existence, schema-qualified table placement, queue_status ENUM type in pg_type, JSONB columns, tokenactions registered as hypertable in timescaledb_information.hypertables |
Priority and dependencies¶
Migrations within a feature are numbered (priority = 10, 20, 30, …) so they sort
deterministically. Cross-table dependencies within a feature are declared via
$dependencies (e.g. messages depends on message_threads). The MigrationRunner
topological sort respects these constraints.
Timestamp rationale¶
All framework migrations use the 2020_01_01_000XXX timestamp prefix. Applications
that configure a migration_cutoff in app.php (e.g. '2024-01-01') will
automatically skip these migrations, since the framework tables may already exist from
a prior installation. The ordering (000002 < 000010 < 000020 < …) still allows
the MigrationRunner to sort them deterministically relative to each other.
Usage examples¶
Run all framework migrations (creates sessions, users, authserver, queue, messaging tables):
Run only a specific feature's migrations:
php bin/pramnos migrate --scope=framework --feature=auth
php bin/pramnos migrate --scope=framework --feature=authserver
php bin/pramnos migrate --scope=framework --feature=messaging
php bin/pramnos migrate --scope=framework --feature=queue
Check migration status without running:
Roll back the last framework migration batch:
Running framework migrations multiple times is safe — every up() guards with
hasTable() checks before creating, and every down() uses dropTableIfExists().
BC notes (16)¶
database/migrations/framework/is an entirely new directory — no existing code is affected.- The
FeatureRegistry::initDefaults()migration paths now point to this directory instead ofsrc/Pramnos/Database/SystemMigrations/(which was only introduced in this development cycle and never shipped). - The deleted
src/Pramnos/Database/SystemMigrations/path was an in-development artefact; its removal is not a BC break.
17. Phase 4: Middleware Pipeline¶
Problem: Applying cross-cutting concerns — authentication checks, rate limiting, CORS headers, maintenance mode — required copy-pasting logic across controllers or hacking it into the bootstrap. There was no structured, reusable mechanism to wrap request handling without touching the controllers themselves.
Solution: A lightweight, opt-in middleware pipeline modelled on the standard "onion" pattern (PSR-15-inspired, but using the framework's own Request class). Middleware is registered per-route, globally on the Router, or per-action on a Controller. Existing routes and controllers that register no middleware continue to work exactly as before — zero changes required.
Getting started — route middleware¶
use Pramnos\Http\Middleware\AuthMiddleware;
use Pramnos\Http\Middleware\ThrottleMiddleware;
// Per-route: get/post/put/delete/patch/options now return the Route
// so you can chain ->middleware() directly.
$router->get('/dashboard', [DashboardController::class, 'index'])
->middleware(new AuthMiddleware());
$router->post('/api/export', fn() => exportData())
->middleware(
new AuthMiddleware(),
new ThrottleMiddleware(maxRequests: 5, perSeconds: 60)
);
Getting started — global middleware (ServiceProvider::boot())¶
use Pramnos\Http\Middleware\CorsMiddleware;
use Pramnos\Http\Middleware\MaintenanceModeMiddleware;
// In your ServiceProvider::boot():
public function boot(): void
{
$router = $this->app->getRouter(); // however your app exposes the router
// Maintenance mode gates ALL routes
$router->addGlobalMiddleware(new MaintenanceModeMiddleware());
// CORS for all routes — wildcard origin for a public API
$router->addGlobalMiddleware(new CorsMiddleware());
// Specific allowed origins for a private API
$router->addGlobalMiddleware(new CorsMiddleware(
allowedOrigins: ['https://app.example.com', 'https://admin.example.com'],
allowCredentials: true
));
}
Getting started — controller middleware¶
class ApiController extends \Pramnos\Application\Controller
{
public function __construct()
{
// Every action in this controller requires login
$this->addMiddleware('*', new AuthMiddleware());
// Only the expensive 'export' action is throttled
$this->addMiddleware('export', new ThrottleMiddleware(5, 60));
// Multiple actions share a middleware
$this->addMiddleware(['delete', 'purge'], new AuthMiddleware('/login'));
parent::__construct();
}
}
Execution order¶
Global middleware (registration order)
└─ Route-specific middleware (registration order)
└─ Permission check (existing — unchanged)
└─ Action method
For controller-level middleware the order is slightly different:
Permission check (existing auth() — unchanged)
└─ Wildcard '*' middleware (registration order)
└─ Action-specific middleware (registration order)
└─ Action method
Writing your own middleware¶
Implement Pramnos\Http\MiddlewareInterface:
use Pramnos\Http\MiddlewareInterface;
use Pramnos\Http\Request;
class JsonOnlyMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): mixed
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (!str_contains($contentType, 'application/json')) {
throw new \Exception('This endpoint only accepts JSON.', 415);
}
return $next($request); // pass control to the next middleware / action
}
}
To short-circuit (return early without calling the action):
public function handle(Request $request, callable $next): mixed
{
if (/* condition fails */) {
// Returning without calling $next stops the pipeline.
return 'error response';
// Or: throw new \Exception('Reason', 403);
}
return $next($request);
}
Using the pipeline standalone¶
MiddlewarePipeline can be used independently of the Router and Controller:
use Pramnos\Http\MiddlewarePipeline;
$result = (new MiddlewarePipeline())
->pipe(new LoggingMiddleware())
->pipe(new AuthMiddleware())
->pipe(new ThrottleMiddleware(60, 60))
->run($request, fn($req) => $controller->myAction());
API reference¶
MiddlewareInterface¶
| Method | Signature | Description |
|---|---|---|
handle |
handle(Request $request, callable $next): mixed |
Process the request. Call $next($request) to continue; return without calling it to short-circuit. |
MiddlewarePipeline¶
| Method | Signature | Description |
|---|---|---|
pipe |
pipe(MiddlewareInterface\|string $middleware): static |
Append a middleware. Pass an instance or a FQCN string for lazy instantiation. |
run |
run(Request $request, callable $destination): mixed |
Build and execute the chain. $destination is fn(Request): mixed. |
Route¶
| Method | Signature | Description |
|---|---|---|
middleware |
middleware(MiddlewareInterface\|string ...$middlewares): static |
Attach one or more middleware to this route. Chainable. |
getMiddleware |
getMiddleware(): array |
Return all attached middleware. |
hasMiddleware |
hasMiddleware(): bool |
Whether any middleware is registered. |
Router¶
| Method | Signature | Description |
|---|---|---|
addGlobalMiddleware |
addGlobalMiddleware(MiddlewareInterface\|string $middleware): static |
Register a middleware that runs for every dispatched route. |
get / post / put / delete / patch / options |
(unchanged signatures) | Now return Route instead of Router, enabling ->middleware() chaining. addRoute() and match() still return Router. |
Controller¶
| Method | Signature | Description |
|---|---|---|
addMiddleware |
addMiddleware(string\|array $actions, MiddlewareInterface\|string $middleware): static |
Attach middleware to named actions or '*' for all actions. |
Built-in middleware¶
AuthMiddleware¶
Throws a 401 exception (caught by Application::exec()) if the session is not logged in. Optionally redirects instead.
CorsMiddleware¶
Sets Access-Control-* headers and handles OPTIONS preflight (returns 204, action never runs).
new CorsMiddleware() // Allow-Origin: *
new CorsMiddleware(['https://app.example.com']) // specific origin
new CorsMiddleware(
allowedOrigins: ['https://app.example.com'],
allowedMethods: ['GET', 'POST'],
allowedHeaders: ['Authorization', 'Content-Type'],
allowCredentials: true,
maxAge: 3600
)
ThrottleMiddleware¶
Rate-limits requests per client IP using APCu counters. Throws 429 when the limit is exceeded. Passes through silently if APCu is unavailable.
new ThrottleMiddleware(60, 60) // 60 req / 60 sec
new ThrottleMiddleware(5, 60, 'export:') // 5 req / 60 sec, custom key prefix
Requirement: PHP APCu extension (apcu). Without it, no rate-limiting occurs (graceful degradation).
MaintenanceModeMiddleware¶
Returns 503 when maintenance.flag exists at the application root (or a custom path).
new MaintenanceModeMiddleware() // ROOT/maintenance.flag
new MaintenanceModeMiddleware('/var/run/app/down.flag') // custom path
# Enable maintenance mode
touch /path/to/maintenance.flag
# Disable maintenance mode
rm /path/to/maintenance.flag
BC notes¶
- All existing
Controllersubclasses work unchanged. The_runThroughMiddleware()helper isprivateand simply calls the action directly when no middleware is registered — no overhead, identical behaviour. Router::addRoute()andRouter::match()still return$this(Router). Only the single-method helpers (get(),post(), etc.) changed from returningRouterto returningRoute. Code that did not use the return value of these methods is unaffected.- The
dispatchSafe()method now also runs middleware; exceptions thrown by middleware are caught and returned in the error envelope as before.
18. Phase 4: Formal Response Object¶
Problem: Controller actions and route handlers return values using a mix of header(), echo, json_encode(), and the Document layer. This works but makes response construction hard to test, hard to inspect in middleware, and inconsistent across the codebase.
Solution: Pramnos\Http\Response — a lightweight, immutable-style fluent builder that collects status code, headers, and body before sending them. It is purely additive; every existing controller, route handler, and Document-based view continues to work without a single line changed.
Getting started¶
use Pramnos\Http\Response;
// Simple HTML / text response
return Response::make('<p>Hello</p>')->send();
// JSON API response
return Response::json(['user' => $user])->send();
// Redirect
return Response::redirect('/dashboard', 302)->send();
// Custom status + headers
return Response::make('Created', 201)
->withHeader('Location', '/api/users/42')
->withHeader('X-Request-Id', $requestId)
->send();
Execution model¶
Response follows an immutable-style design: every mutator (withStatus(), withHeader(), withBody(), etc.) returns a new cloned instance and never modifies the receiver. This makes it safe to share a base response and branch from it:
$base = Response::json([])->withHeader('X-Api-Version', '2');
$ok = $base->withBody(json_encode(['ok' => true]))->withStatus(200);
$error = $base->withBody(json_encode(['error' => 'Not found']))->withStatus(404);
send() is the only side-effectful method — it calls http_response_code(), header(), and echo exactly as existing controllers do.
In middleware¶
Because Response is an inspectable object, middleware can examine or wrap the inner handler's response without touching raw PHP output functions:
class AddSecurityHeadersMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): mixed
{
$response = $next($request); // inner handler returns a Response
if ($response instanceof Response) {
return $response
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Frame-Options', 'DENY');
}
return $response;
}
}
Writing your own factory helper¶
// In a base controller or trait
protected function ok(mixed $data): Response
{
return Response::json($data, 200);
}
protected function created(mixed $data, string $location): Response
{
return Response::json($data, 201)
->withHeader('Location', $location);
}
protected function noContent(): Response
{
return Response::make('', 204);
}
API reference¶
Response — static factories¶
| Method | Description |
|---|---|
Response::make(string $body = '', int $status = 200): static |
Plain body response (HTML, text, anything). |
Response::json(mixed $data, int $status = 200, int $flags = 0): static |
JSON response. Sets Content-Type: application/json. Returns 500 with error payload when json_encode() fails. |
Response::redirect(string $url, int $status = 302): static |
Redirect response. Sets Location header; body is empty. |
Response — fluent mutators (return new instance)¶
| Method | Description |
|---|---|
withStatus(int $code): static |
Override HTTP status code. |
withHeader(string $name, string $value): static |
Append a header value (accumulates multiple values for the same name, e.g. Set-Cookie). |
withRawHeader(string $name, string $value): static |
Replace all existing values for a header with a single new value. |
withoutHeader(string $name): static |
Remove a header entirely. No-op if the header was never set. |
withBody(string $body): static |
Replace the body; preserves status and headers. |
Response — accessors¶
| Method | Returns | Description |
|---|---|---|
getStatusCode(): int |
int |
Current HTTP status code. |
getBody(): string |
string |
Current body string. |
getHeader(string $name): array |
string[] |
All values for a header, or [] if absent. |
getHeaderLine(string $name): ?string |
?string |
All values joined by ", ", or null if absent. |
hasHeader(string $name): bool |
bool |
true when at least one value is set. |
getHeaders(): array |
array<string, string> |
Flat name → joined-values map for all headers. |
Response — emission¶
| Method | Description |
|---|---|
send(): static |
Emits http_response_code(), all headers, and body. Returns $this for fluent use in return statements. No-op on headers if headers_sent(). |
BC notes¶
- Purely additive. No existing
header(),echo,Document, orControllercode is touched. send()is marked@codeCoverageIgnore— it is a thin I/O wrapper; all logic lives in the accessors and is tested without emitting output.
19. Phase 4: Centralized Error / Exception Handler¶
Problem: Application::exec() contained ad-hoc exception handling that only logged SQL-related errors, echoed raw HTML for debug mode, and silently redirected to the homepage in production. JSON API routes received the same HTML error page as HTML routes. The logic was untestable inline.
Solution: Pramnos\Http\ExceptionHandler — a standalone, testable class that centralises all exception rendering and logging. Application::exec() now delegates entirely to it (5 lines instead of 25). The handler produces a proper Response object that can be inspected in tests without emitting output.
Getting started¶
use Pramnos\Http\ExceptionHandler;
// Minimal — inside a catch block
ExceptionHandler::log($exception);
ExceptionHandler::render($exception, 'html', false)->send();
exit();
// With format/debug detection (as used by Application::exec())
$format = $doc->getType() === 'json' ? 'json' : 'html';
$debug = defined('DEVELOPMENT') && DEVELOPMENT === true;
ExceptionHandler::log($exception);
ExceptionHandler::render($exception, $format, $debug)->send();
$this->close();
// Global handler for early-bootstrap or CLI contexts
set_exception_handler(function (\Throwable $e) {
$format = ExceptionHandler::detectFormat();
ExceptionHandler::log($e);
ExceptionHandler::render($e, $format)->send();
exit(1);
});
Output formats¶
| Scenario | Format | Debug | Output |
|---|---|---|---|
| HTML app — production | html |
false |
Friendly <h1>404 — Not Found</h1> page |
| HTML app — development | html |
true |
Full stack trace + class + file + line (HTML-escaped) |
| JSON API — production | json |
false |
{"error": "msg", "code": 422} |
| JSON API — development | json |
true |
+ "exception", "file", "line", "trace" array |
HTTP status mapping¶
The exception's getCode() is used as the HTTP status when it falls in the 400–599 range. Everything else (0, 200, negative values) maps to 500. This means:
throw new \Exception('Not found', 404); // → 404 response
throw new \Exception('Rate limited', 429); // → 429 response
throw new \Exception('General error'); // code 0 → 500 response
throw new \Exception('Weird code', 200); // not an error code → 500 response
Logging¶
log() always records the full exception — message, file, line, and stack trace — via Logger::error(). The previous behaviour only logged exceptions whose messages contained the string "SQL". All exceptions are now logged unconditionally, which is the expected behaviour for a production error handler.
API reference¶
| Method | Description |
|---|---|
ExceptionHandler::render(\Throwable $e, string $format = 'html', bool $debug = false): Response |
Build a Response for the exception. $format is 'html' or 'json'. $debug adds stack trace to the output. |
ExceptionHandler::log(\Throwable $e, string $logFile = 'pramnosframework'): void |
Write full exception detail to the error log via Logger::error(). |
ExceptionHandler::detectFormat(): string |
Sniff $_SERVER['HTTP_ACCEPT']. Returns 'json' only when the client explicitly wants JSON without HTML; otherwise 'html'. Useful for early-bootstrap or CLI handlers where the Document object is not available. |
BC notes¶
Application::exec()now always shows a rendered error page in production instead of redirecting toURL. This is a behavioural improvement (users see an error page instead of being silently bounced to the homepage), but existing applications that relied on the redirect behaviour may want to override the handler.- All logging now uses
Logger::error()— previously only SQL-related messages were logged. - The old ad-hoc HTML was unescaped for some fields; the new debug output is always
htmlspecialchars()-escaped.
20. Phase 4: PHP 8.1 Minimum Version¶
Change: composer.json now declares "php": ">=8.1" (was >=7.4).
Rationale: PHP 7.4 and 8.0 reached end-of-life in November 2022 and November 2023 respectively. They no longer receive security patches. The framework already used PHP 8.1 language features throughout v1.2 (enum, readonly properties, match expressions, named arguments, first-class callable syntax) — the >=7.4 declaration was inaccurate. web-token/jwt-framework 3.x also requires PHP 8.1+ and had been installed since the JWT work in v1.2.
PHP 8.1 features now available across the entire codebase:
| Feature | Example |
|---|---|
| Backed enums | enum Status: string { case Active = 'active'; } |
| Readonly properties | public readonly string $name |
| Intersection types | function foo(Iterator&Countable $c) |
never return type |
function abort(): never { throw new ...; } |
| Fibers | new Fiber(fn() => ...) |
array_is_list() |
Runtime check for sequential 0-indexed arrays |
| First-class callable syntax | $fn = strlen(...) |
Additional cleanup in composer.json:
- web-token/jwt-framework: dropped the ^2.2 constraint (only ^3.0 is supported).
- phpunit/phpunit: dropped ^9.5 (required PHP < 8.1); ^10.0|^11.0 remains.
21. Phase 4: Security — CSRF Hardening¶
Problem: The existing CSRF implementation had two weaknesses:
1. Low entropy — bin2hex(random_bytes(5)) = 40-bit session token. Feasible to brute-force in an offline attack.
2. MD5 fingerprint — getFingerprint() used MD5, which is broken for cryptographic purposes.
There was also no opt-in middleware — CSRF protection required manual checkToken() calls scattered across controllers.
Changes:
Session improvements (BC: all existing public API unchanged)¶
| Change | Before | After |
|---|---|---|
| Session token entropy | random_bytes(5) → 40-bit |
random_bytes(32) → 256-bit |
| Fingerprint algorithm | md5($ua . $ip . $token) |
hash_hmac('sha256', $ua . $ip, $token) |
| Existing sessions | Silently upgraded on first request (strlen < 32 check in start()) |
— |
New methods added to Session:
| Method | Description |
|---|---|
getCsrfToken(): string |
Returns the synchronizer CSRF token for the session (generates lazily, 256-bit). |
verifyCsrfToken(string $submitted): bool |
Timing-safe comparison via hash_equals(). Returns false if no token has been generated. |
regenerateCsrfToken(): void |
Regenerate the CSRF token. Call after login/logout. |
CsrfMiddleware (new)¶
Synchronizer-token pattern. Validates the token on POST, PUT, PATCH, DELETE; passes GET, HEAD, OPTIONS, TRACE through without any check.
// Global protection for all state-changing routes
$router->addGlobalMiddleware(new CsrfMiddleware());
// Per-route
$router->post('/transfer', fn() => ...)
->middleware(new CsrfMiddleware());
// In HTML templates
echo CsrfMiddleware::tokenField();
// → <input type="hidden" name="_csrf_token" value="…" />
// In AJAX (meta tag approach)
echo '<meta name="csrf-token" content="' . CsrfMiddleware::token() . '">';
// Fetch header: 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content
Token lookup order per request:
1. $_POST[$fieldName] (default field: _csrf_token)
2. X-CSRF-Token request header
API reference¶
| Method | Description |
|---|---|
new CsrfMiddleware(string $fieldName = '_csrf_token') |
Constructor; custom field name for legacy forms. |
CsrfMiddleware::token(): string |
Returns the session CSRF token (proxy for Session::getCsrfToken()). |
CsrfMiddleware::tokenField(string $fieldName = '_csrf_token'): string |
Returns an HTML <input type="hidden"> with the token (value is HTML-escaped). |
BC notes¶
- All existing
Session::checkToken(),checkTokenValue(),getTokenField(),getFingerprint()calls continue to work. The fingerprint format changes (64-char HMAC-SHA256 hex vs 32-char MD5 hex) — forms must be re-rendered after upgrade for CSRF validation to pass. This is expected behaviour for a security upgrade. - The new
CsrfMiddlewareuses a separate$_SESSION['csrf_token']key (not$_SESSION['token']). The two systems coexist independently.
22. Phase 4: Security — Session Cookie Hardening¶
Problem: Three gaps in session security that the existing HttpOnly/SameSite=Lax settings did not cover:
- Session fixation — an attacker who plants a known session ID before the victim logs in retains access after login.
reset()did not callsession_regenerate_id(). - PHP session ID injection — PHP by default accepts any session ID submitted by the client (e.g. via
?PHPSESSID=attacker_value).session.use_strict_modewas not set. - HTTPS detection gaps —
isHttps()only checkedHTTPS === 'on'; some servers (IIS, CGI) setHTTPS === '1'.
Changes:
Session::start()¶
- Calls
ini_set('session.use_strict_mode', '1')beforesession_start(). PHP will now only accept session IDs it generated itself.
Session::reset() (called on login and logout)¶
- Calls
session_regenerate_id(true)— issues a new session ID and deletes the old session file. An attacker who captured or planted the old ID is immediately locked out. - Also calls
regenerateCsrfToken()— rotates the synchronizer CSRF token so any pre-login CSRF token cannot be reused.
Session::isHttps(): bool (new static method)¶
Accepts both 'on' and '1' as truthy HTTPS values:
Request::isHttps() (updated)¶
Brought in line with Session::isHttps() — also accepts '1'.
Session cookie settings (unchanged — already correct)¶
HttpOnly, SameSite=Lax, and Secure (when HTTPS) were already set in v1.1. No change needed.
Usage examples¶
The hardening is transparent — existing login/logout code gets the protection automatically. No code changes required for the basic case:
// Session::start() — strict mode set automatically before session_start()
$session = Session::getInstance();
$session->start();
// Session::reset() — call on login and logout (already called by Auth internals)
// Issues new session ID + rotates CSRF token
$session->reset();
Use Session::isHttps() instead of raw $_SERVER checks:
// Before (fragile — missed IIS/CGI '1' value)
if ($_SERVER['HTTPS'] === 'on') { … }
// After (handles 'on', '1', and Request::isHttps() consistently)
if (Session::isHttps()) {
// set Secure cookie, redirect HTTP→HTTPS, etc.
}
Full login flow integration:
// In your Auth addon's onAuth callback:
$session->set('userid', $user->userid);
$session->set('username', $user->username);
$session->reset(); // ← regenerates session ID + CSRF token; call AFTER setting data
BC notes¶
Session::reset()now changes the session ID — code that stores a reference to the old session ID before callingreset()will see a stale ID afterwards. This is intentional security behaviour.Session::isHttps()is new; callers that previously used inline$_SERVER['HTTPS'] === 'on'checks should migrate to this helper for consistency.
23. Phase 4: Security — View Escaping Helpers¶
Problem: PHP templates (.html.php files) had no framework-provided escaping helper. Developers had to remember to call htmlspecialchars() manually on every output — a verbose, error-prone approach that leads to accidental XSS.
Solution: A global e() function (loaded via Composer autoload) and matching View::escape() / View::e() instance methods that wrap htmlspecialchars() with the safest available flags.
Usage in templates¶
<!-- Short form — most common -->
<h1><?php echo e($model->title); ?></h1>
<p><?php echo e($model->description); ?></p>
<input name="q" value="<?php echo e($request->get('q')); ?>">
<!-- Via $this in a View template -->
<h1><?php echo $this->e($model->title); ?></h1>
<a href="<?php echo $this->escape($model->url); ?>"><?php echo e($model->label); ?></a>
<!-- Intentionally un-escaped trusted HTML (generated by framework) -->
<?php echo $doc->getContent(); ?>
What is escaped¶
| Input | Output |
|---|---|
<script>alert(1)</script> |
<script>alert(1)</script> |
" onclick="alert(1) |
" onclick="alert(1) |
it's fine |
it's fine |
AT&T |
AT&T |
null / false |
'' (empty string) |
42 / 3.14 |
'42' / '3.14' |
Flags used: ENT_QUOTES | ENT_SUBSTITUTE — escapes both single and double quotes; replaces invalid UTF-8 sequences with U+FFFD instead of returning an empty string.
What is NOT escaped¶
e() is a general-purpose HTML character escaper. It does not:
- Filter javascript: URIs — validate and whitelist URLs at the application level (or use CSP).
- Strip HTML tags — use strip_tags() or a sanitiser library if you need to allow/deny specific tags.
- Escape JSON strings inside <script> tags — use json_encode() for that.
API reference¶
| Symbol | Where | Description |
|---|---|---|
e(mixed $value, string $encoding = 'UTF-8'): string |
Global (helpers.php) | HTML-escape a value. null/false → ''. |
View::escape(mixed $value, string $encoding = 'UTF-8'): string |
Instance method | Delegates to e(). |
View::e(mixed $value, string $encoding = 'UTF-8'): string |
Instance method | Short alias for escape(). |
BC notes¶
- Purely additive — no existing output is changed.
- The global
e()function is guarded withif (!function_exists('e'))so applications that already define their owne()are not affected.
24. Phase 3: Developer Experience — Scaffolding System¶
Pramnos 1.2 ships a complete project scaffolding system: a scaffolding/ directory of template stubs and theme files, an upgraded init command wizard, and create:middleware generator.
scaffolding/ directory layout¶
scaffolding/
├── templates/ # PHP stubs for code generators
│ ├── controller.stub
│ ├── model.stub
│ ├── migration.stub ← includes transactional=false by default
│ ├── middleware.stub
│ ├── event.stub
│ ├── listener.stub
│ └── test.stub
├── themes/ # Starter theme files per UI system
│ ├── plain-css/ (header.php, footer.php, theme.html.php, style.css)
│ ├── bootstrap/ (header.php, footer.php, theme.html.php, style.css)
│ └── tailwind/ (header.php, footer.php, theme.html.php, style.css)
└── assets.json # Pinned versions for all vendor assets
bin/pramnos init — full project wizard¶
The init command now walks through six steps:
| Step | What it does |
|---|---|
| 1 | Project name + namespace |
| 2 | Framework features (auth, authserver, queue, messaging) → written to app.php |
| 3 | UI system selection (plain-css, bootstrap, tailwind) |
| 4 | Extra library selection — assets downloaded locally to public/assets/vendor/ |
| 5 | Author info |
| 6 | Docker startup → composer install → migrate:framework → summary |
All steps can be skipped interactively or driven via CLI options for CI usage.
Getting started¶
# Interactive wizard
php bin/pramnos init
# Fully non-interactive (CI / testing)
php bin/pramnos init \
--app-name="My App" \
--namespace="MyApp" \
--features="auth,queue" \
--ui-system="bootstrap" \
--docker=y \
--docker-port=8080 \
--cache-system=none \
--db-type=postgresql \
--db-host=db \
--db-name=myapp_db \
--db-user=myapp \
--db-pass=secret \
--db-prefix="" \
--libraries="jquery,chartjs" \
--no-download # record in assets.json but skip HTTP downloads
CLI options reference¶
| Option | Description |
|---|---|
--app-name |
Application display name |
--namespace |
PHP namespace (default: CamelCase of app name) |
--features |
Comma-separated feature keys: auth, authserver, queue, messaging |
--ui-system |
plain-css (default), bootstrap, tailwind |
--docker |
y / n |
--docker-port |
Host port (auto-detected default) |
--cache-system |
none (default), redis, memcached |
--db-type |
mysql, postgresql, timescaledb |
--db-host / --db-name / --db-user / --db-pass / --db-prefix |
Database settings |
--libraries |
Comma-separated library keys from assets.json |
--no-download |
Record selected libraries in manifest without downloading |
--no-migrations |
Skip migrate:framework after Docker startup |
Step 6: migrate:framework¶
When Docker setup is enabled, init automatically runs the framework system migrations after the container is healthy:
This creates all framework tables (users, usertokens, applications, etc.) for the selected features. Without this step, the scaffolded app would start without any schema.
Generated project structure¶
myapp/
├── app/
│ ├── app.php ← name, namespace, features, theme, CSP
│ ├── config/
│ │ ├── settings.php ← DB credentials + development=true
│ │ └── testsettings.php ← separate test DB
│ ├── Migrations/ ← app-level migrations go here
│ ├── language/en.php
│ └── themes/default/ ← theme files from scaffolding/themes/<ui-system>/
├── src/
│ ├── Application.php
│ ├── Controllers/Home.php
│ ├── Models/
│ └── Views/home/home.html.php
├── tests/
│ ├── bootstrap.php
│ ├── BaseTestCase.php
│ └── Unit/ExampleTest.php
├── var/cache/ var/logs/
├── www/
│ ├── index.php
│ ├── .htaccess
│ └── assets/css/style.css
├── docker-compose.yml Dockerfile dockerbash dockertest
└── phpunit.xml
Local asset download¶
When libraries are selected in Step 4, each asset is downloaded from its pinned CDN source and stored at www/assets/vendor/<library>/<version>/. A manifest is written to scaffolding/assets.json recording the version and local paths.
Supported libraries: jQuery, Alpine.js, htmx, DataTables, Select2, Tom Select, Flatpickr, Chart.js, ApexCharts, Dropzone.js, FilePond, SweetAlert2, Toastify, Sortable.js, Cropper.js, Leaflet.js, TinyMCE, Quill, Font Awesome, Bootstrap Icons (bootstrap only), Flowbite (tailwind only).
create:middleware generator¶
Creates src/Middleware/RateLimit.php implementing MiddlewareInterface, plus an auto-generated test stub at tests/Unit/RateLimitMiddlewareTest.php.
Stub template system¶
The Init and Create commands share a renderStub(string $name, array $tokens): string method that:
- Looks for
scaffolding/templates/<name>.stubinside the framework package. - Falls back to an embedded minimal skeleton if the file is absent (safe for installations where
scaffolding/is not present). - Substitutes all
{{ token }}placeholders.
Applications can override stubs by placing their own scaffolding/templates/ directory at project root.
BC notes¶
- All new options on
initare optional — existing usage without options still works interactively. Create::generateTestStub()signature added an optional$baseDirparameter (BC-safe).- The embedded heredoc generators in
Init.phphave been replaced by stub files; the fallback skeleton ensures the command works even in environments wherescaffolding/is absent.
25. Phase 2: Event / Hook System¶
Class: Pramnos\Event\Event
Interface: Pramnos\Event\ListenerInterface
A lightweight, priority-ordered event bus that runs parallel to the existing Addon hook system. Existing Addon::doAction() / Addon::addAction() calls are unaffected — the new system is purely additive.
Static API¶
| Method | Signature | Description |
|---|---|---|
listen |
listen(string $event, callable\|string\|ListenerInterface $listener, int $priority = 10): void |
Register a listener. Lower priority number = earlier execution. |
fire |
fire(string $event, mixed ...$args): list<mixed> |
Fire the event, calling listeners in priority order. Returns array of each listener's return value. |
forget |
forget(string $event = ''): void |
Remove listeners for one event, or all events if called with no argument. |
hasListeners |
hasListeners(string $event): bool |
Returns true if at least one listener is registered. |
getListeners |
getListeners(string $event): list<...> |
Returns all listeners in priority-sorted flat order. |
Listener types¶
Closure / callable:
Class-name string (instantiated on first fire):
ListenerInterface instance:
The ListenerInterface contract:
Priority ordering¶
Event::listen('order.placed', fn() => ..., 10); // runs first
Event::listen('order.placed', fn() => ..., 20); // runs second
Event::listen('order.placed', fn() => ..., 5); // runs before both
Stopping propagation¶
A listener that returns false halts further execution — subsequent listeners (higher priority number) are skipped:
Event::listen('request.handling', function (Request $req): false|null {
if ($req->isBlocked()) {
return false; // stops the chain
}
return null; // continues normally
}, 5);
BC strategy¶
- Existing
Addon::addAction()/Addon::doAction()calls are not modified — they continue to fire as before. - The new
Eventclass is a completely separate registry. - Internal framework hooks (Login, Logout, Auth, etc.) continue to fire via
Addon::doAction(). Over time, they can also fire viaEvent::fire()so that new-style listeners can observe them — but the old-style hooks remain.
Tests¶
tests/Unit/Event/EventTest.php — 17 unit tests covering: basic fire/listen, argument forwarding, multiple arguments, zero-listener contract, return value collection, priority ordering, FIFO within same priority, propagation stopping (false halts chain), null does not stop, class-based listeners, hasListeners, forget(event), forget() all, getListeners order, cross-event isolation.
26. Phase 2: CLI UX — CommandBase (Backport από Urbanwater)¶
Class: Pramnos\Console\CommandBase
Abstract base class for all lock-guarded and interactive console commands. Backported from Urbanwater\ConsoleCommands\CommandBase. Urbanwater commands can now extend Pramnos\Console\CommandBase directly and inherit all infrastructure via PHP's normal inheritance chain.
Contract¶
abstract class CommandBase extends \Symfony\Component\Console\Command\Command
{
abstract protected function getJobName(): string;
}
Every concrete command implements getJobName() which becomes the lock-file name under var/<jobName>.
Feature groups¶
Lock-file job guards¶
| Method | Description |
|---|---|
beginJob(OutputInterface, bool $registerShutdown=true): bool |
Guard: false if already running; creates lock + SIGINT handler. |
endJob(): void |
Removes the lock file (also called by shutdown/signal handlers). |
heartbeat(): void |
touch() the lock file so orchestrator knows the worker is alive. |
checkIfRunning(): bool |
Checks lock file + PID liveness; treats stale locks (>2 h) as gone. |
startJob(): void |
Writes PID to the lock file. |
getLockStaleSeconds(): int |
Override to change the stale-lock threshold (default 7200 s). |
getJobLockFilePath(): string |
Override for custom lock path (default: ROOT/var/<jobName>). |
Terminal control¶
| Method | Description |
|---|---|
clearScreen(OutputInterface) |
\033c + \033[2J + \033[H |
hideCursor(OutputInterface) |
\033[?25l |
showCursor(OutputInterface) |
\033[?25h\033[?0c |
detectTerminalSize(): array{int,int} |
Returns [height, width] via stty size. |
initializeInteractiveTerminal(OutputInterface, bool) |
Clears, hides cursor, registers shutdown. |
Signal / shutdown handling¶
| Method | Description |
|---|---|
configureInterruptHandling(OutputInterface, string) |
Sets SIGINT handler; ignores signal when under orchestrator. |
handleInterruptSignal(int) |
Default Ctrl+C handler: logs, endJob(), exit 130. |
handleShutdown(OutputInterface) |
Shutdown function: showCursor + endJob. |
getOrchestratorCommandName(): string |
Returns 'daemons:start'; override to match your orchestrator. |
Progress bar¶
$output->write("\r" . $this->buildProgressBar($current, $total));
// → " [████████████..........] 60 of 100 (60%)"
| Method | Description |
|---|---|
buildProgressBar(int $current, int $total, int $width=50): string |
Block-char progress bar (█ filled, . empty). Returns string — caller writes with \r. |
Text utilities¶
| Method | Description |
|---|---|
formatBytes(int\|float, int $precision=2): string |
1024 → "1 KB", 1048576 → "1 MB", etc. |
formatTime(int $seconds): string |
3723 → "01:02:03" (HH:MM:SS, no cap at 24 h). |
visibleLength(string): int |
Character count ignoring ANSI escape sequences. |
truncateText(string, int $maxLen): string |
Adds ... if visible length exceeds maxLen. |
wrapDashboardText(string, int $maxWidth): string[] |
Word-wraps to maxWidth (ANSI-aware), returns array of lines. |
Bordered dashboard rendering¶
┌──────────────────────────────────────────┐
│ QUEUE PROCESSOR v2 │
├──────────────────────────────────────────┤
│ Time: 2026-05-05 14:30:00 │ Uptime: 00:12:34 │ CPU: 2.1 │ Memory: 48 MB │
├──────────────────────────────────────────┤
│ Mode: Normal │ State: Running │ Tasks today: 1024 │
├──────────────────────────────────────────┤
│ Controls: Press Ctrl+C to exit │
└──────────────────────────────────────────┘
| Method | Description |
|---|---|
buildDashboardHeader(string $title, int $borderLen): string |
┌──<centered title>──┐ |
buildDashboardSectionSeparator(int $borderLen): string |
├──────────────────────┤ |
buildDashboardFooter(int $borderLen): string |
└──────────────────────┘ |
padDashboardLine(string $content, int $borderLen): string |
Prepends │, right-pads to width, appends │ |
padDashboardRow(string $line, int $borderLen, ?int $visibleLen): string |
As above but line already has left border |
buildDashboardRows(string[] $segments, int $borderLen): string |
Fits segments side-by-side with │ separator; wraps if too wide |
buildSystemStatusSegments(int $startTime, float $cpu, int\|float $mem): string[] |
Returns ['Time: ...', 'Uptime: ...', 'CPU: ...', 'Memory: ...'] |
buildCommandStateSection(int $borderLen, string $mode, string $state, string[] $extra=[]): string |
Mode + State + extra segments |
buildDashboardHelpSection(int $borderLen, string $helpText): string |
Single controls-hint row |
buildDashboardAdventureSection(int $borderLen, string $title, string $statusText, int $countdown=0): string |
Animated mini-game for reconnect failures |
renderDashboardFrame(OutputInterface, string $title, string[] $systemSegments, string[] $sections, int $terminalWidth): void |
Full render: cursor-home → write → erase-below |
renderDashboardFrameAutoSystem(OutputInterface, string $title, string[] $sections, int $terminalWidth, ?string[] $systemSegments=null): void |
As above; auto-builds system segments if null |
renderDashboardGameMode(OutputInterface, string $title, string $failureTitle, string $statusText, int $countdown, int $terminalWidth): void |
Full game-mode dashboard for outage states |
Migrating Urbanwater commands¶
// Before (Urbanwater-local):
use Urbanwater\ConsoleCommands\CommandBase;
class MyDaemon extends CommandBase { ... }
// After (framework-level):
use Pramnos\Console\CommandBase;
class MyDaemon extends CommandBase { ... }
// All methods (beginJob, renderDashboardFrame, buildProgressBar, etc.) inherited.
Urbanwater's CommandBase can then extend \Pramnos\Console\CommandBase and override only what differs (e.g. getOrchestratorCommandName()), so application code remains unchanged.
BC notes¶
CommandBaseis a brand-new class — no existing code is affected.getJobLockFilePath()usesROOTconstant (framework standard) withsys_get_temp_dir()fallback if undefined.getOrchestratorCommandName()returns'daemons:start'by default — matches Urbanwater's current orchestrator command name.
Tests¶
tests/Unit/Console/CommandBaseTest.php — 29 unit tests covering: formatBytes (zero, units, precision), formatTime (zero, conversion, large values), visibleLength (plain, ANSI-stripped, empty), truncateText (passthrough, ellipsis), wrapDashboardText (short passthrough, word-boundary split), buildProgressBar (zero total, 50%, 100%), all dashboard builders (header/separator/footer borders, padDashboardLine, buildDashboardRows, buildSystemStatusSegments, buildCommandStateSection, helpSection, adventureSection with and without countdown), lock-file lifecycle (checkIfRunning false when absent, startJob creates file with PID, endJob removes file).
28. Phase 2: Queue System — QueueManager, Worker, ProcessQueue¶
Problem: Urbanwater contained a complete background job queue (QueueManager, Worker, task handlers, CLI commands) but it was tightly coupled to Urbanwater-specific models, controller names, namespace paths, and dashboard titles, making it impossible to reuse in other applications.
Solution: Full backport to Pramnos\Queue and Pramnos\Console\Commands with configurable hooks at every Urbanwater-specific point. Applications subclass and override only what differs; all infrastructure is inherited.
Namespace structure¶
| Class | Description |
|---|---|
Pramnos\Queue\TaskInterface |
Contract: execute(), validate(), handleFailure(), getDescription() |
Pramnos\Queue\AbstractTask |
Base class with default validate/handleFailure/log implementations |
Pramnos\Queue\QueueItem |
ORM model for queueitems table |
Pramnos\Queue\QueueManager |
Enqueue, claim, status transitions, stats, purge |
Pramnos\Queue\Worker |
Dispatch queue items to registered task handlers |
Pramnos\Console\Commands\ProcessQueue |
queue:process daemon command with live dashboard |
Pramnos\Console\Commands\CleanupQueue |
queue:cleanup maintenance command |
Quick start¶
// 1. Create a task handler
use Pramnos\Queue\AbstractTask;
use Pramnos\Queue\QueueItem;
class SendEmailTask extends AbstractTask
{
public string $name = 'send_email';
public function execute(QueueItem $item): mixed
{
$payload = $this->getPayload($item);
// ... send email ...
$this->log('Sent to ' . $payload->to, $item);
return true;
}
public function getDescription(QueueItem $item): string
{
return 'Send email to ' . json_decode($item->payload)->to;
}
}
// 2. Enqueue
$manager = new \Pramnos\Queue\QueueManager($controller);
$manager->addTask('send_email', ['to' => 'user@example.com', 'subject' => 'Hello']);
// 3. Process via CLI (register handler in your ProcessQueue subclass)
// php bin/app queue:process --daemon --worker-id=1
Configurable hooks¶
QueueItem — URL hooks for admin datatable actions:
class MyQueueItem extends QueueItem {
protected function getItemShowUrl(mixed $id): string { return sURL . 'Jobs/show/' . $id; }
protected function getItemEditUrl(mixed $id): string { return sURL . 'Jobs/edit/' . $id; }
protected function getItemDeleteUrl(mixed $id): string { return sURL . 'Jobs/delete/' . $id; }
}
QueueManager — task type scanning and table name:
class MyQueueManager extends QueueManager {
protected function getTasksDirectory(): string { return __DIR__ . '/Tasks'; }
protected function getTasksNamespace(): string { return 'MyApp\\Queue\\Tasks\\'; }
protected function getQueueTableName(): string { return 'queueitems'; } // default
protected function createQueueItemModel(): QueueItem { return new MyQueueItem($this->controller); }
}
Worker — register handlers and use custom QueueManager:
class MyWorker extends Worker {
protected array $taskHandlers = [
'send_email' => SendEmailTask::class,
'import_csv' => ImportCsvTask::class,
];
protected function createQueueManager($controller, ?string $workerId): QueueManager {
return new MyQueueManager($controller, $workerId);
}
}
ProcessQueue — dashboard title, controller name, worker factory:
class MyProcessQueue extends ProcessQueue {
protected function getDashboardTitle(): string { return ' MY APP QUEUE PROCESSOR '; }
protected function getControllerName(): string { return 'Jobs'; }
protected function createWorker($controller, ?string $workerId): Worker {
return new MyWorker($controller, $workerId);
}
}
Migration from Urbanwater¶
// Before
use Urbanwater\Services\Queue\QueueManager;
use Urbanwater\Services\Queue\Worker;
// After
use Pramnos\Queue\QueueManager;
use Pramnos\Queue\Worker;
// Override hooks to restore Urbanwater-specific behaviour.
BC notes¶
- All queue classes are brand-new — no existing code is affected.
QueueItem::$_dbtable = 'queueitems'— no prefix (framework migration creates the table without one).Worker::$taskHandlersis empty by default — tasks must be registered explicitly.ProcessQueue::getDashboardTitle()returns' QUEUE PROCESSOR '— override for app-specific branding.
Tests¶
tests/Unit/Queue/QueueManagerTest.php (16 tests) — generateTaskHash (determinism, key-order independence, type-separation), getStats (zero total, warning arithmetic), getTaskTypes (empty by default), default/custom table name, configurable directory/namespace hooks, markTaskAsCompleted/Failed/Warning field assignments, retryTask guards.
tests/Unit/Queue/WorkerTest.php (9 tests) — processNextTask returns false on empty queue, fails on missing handler, fails on validation failure, completes on true return, completes with message on array return, sets warning on ['warning'=> ...], fails on false return, catches exceptions, registerTaskHandler chaining.
27. Phase 2: DaemonOrchestrator — Generic Process Supervisor¶
Problem: Urbanwater contained a monolithic DaemonOrchestrator that managed background daemons (queue workers, Kafka consumers). All the process-supervision infrastructure was tightly coupled to Urbanwater-specific settings and entry-points, making it impossible to reuse in other applications.
Solution: Pramnos\Console\DaemonOrchestrator is an abstract base class that provides the full reconcile engine, state persistence, stop-file mechanism, flock singleton guard, dedup scan, git-hash restart detection, and interactive dashboard. Applications override three abstract methods and optionally hook into lifecycle checks.
Extending DaemonOrchestrator¶
use Pramnos\Console\DaemonOrchestrator;
class MyAppOrchestrator extends DaemonOrchestrator
{
protected function getJobName(): string
{
return 'my_orchestrator';
}
protected function getEntryPoint(): string
{
return ROOT . '/bin/myapp';
}
protected function getDashboardTitle(): string
{
return ' MY APP ORCHESTRATOR ';
}
protected function buildDesiredProcesses(): array
{
return [
[
'id' => 'queue-1',
'daemon' => 'queue',
'workerId' => 'worker-1',
'lockFile' => ROOT . '/var/QUEUE_WORKER_1',
'tokens' => ['queue:process', '--worker-id', 'worker-1', '--daemon'],
],
];
}
// Optional — read from application settings:
protected function isOrchestratorEnabled(): bool
{
return \Pramnos\Application\Settings::getSetting('daemon_enabled', true);
}
}
Register the command in your application's Console kernel and run:
php bin/myapp daemons:start
php bin/myapp daemons:start --once # single reconcile cycle
php bin/myapp daemons:start --interactive # live terminal dashboard
php bin/myapp daemons:start --dry-run # show planned actions, no changes
php bin/myapp daemons:start --interval=5 # reconcile every 5 seconds
Process definition keys¶
| Key | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique slot identifier — used for state tracking and log file naming |
daemon |
string | yes | Daemon type label (e.g. 'queue', 'kafka') |
workerId |
string | yes | Value for --worker-id argument — used for pre-spawn dedup guard |
lockFile |
string | yes | Absolute path to the worker's lock file |
tokens |
string[] | yes | CLI arguments passed to getEntryPoint() |
requireLockFile |
bool | no | Whether a healthy lock file is required (default true) |
shellCommand |
string | no | Raw shell command — overrides tokens + getEntryPoint() |
profile |
string | no | Human-readable profile name shown in dashboard |
Reconcile engine behaviour¶
- Desired-vs-actual diff: Every reconcile cycle calls
buildDesiredProcesses()and compares againstloadState(). - Stale heartbeat detection: Lock file not touched for
HEARTBEAT_STALE_SECONDS(300s) → graceful stop requested. - Crash detection: State records a PID but the process is dead → clean up and restart.
- Pre-spawn dedup guard: Scans
/proc(orps) for a process already running with the same--worker-idbefore spawning → adopt instead of duplicate. - Graceful stop:
.stopsentinel file →STOP_GRACE_SECONDS(30s) grace → SIGTERM. - Git-hash restart: Checks
.git/HEADeveryGIT_CHECK_SECONDS(60s) without spawning an external process — new deployment triggers graceful restart of all daemons.
Stop-file mechanism¶
// Request a graceful stop (called automatically by the orchestrator):
$orchestrator->requestStop('/path/to/var/QUEUE_WORKER_1');
// → creates /path/to/var/QUEUE_WORKER_1.stop
// Workers check for this file in their main loop:
if (file_exists($lockFile . '.stop')) {
// Finish current job, then exit cleanly.
}
// Orchestrator clears the stop file before respawning:
$orchestrator->clearStopFile('/path/to/var/QUEUE_WORKER_1');
State file¶
Process state is persisted as JSON at getStateFile() (default ROOT/var/daemon_orchestrator_state.json):
[
{
"id": "queue-1",
"daemon": "queue",
"workerId": "worker-1",
"pid": 12345,
"lockFile": "/app/var/QUEUE_WORKER_1",
"updatedAt": "2025-01-01T00:00:00+00:00"
}
]
Overrideable hooks¶
| Method | Default | Purpose |
|---|---|---|
isOrchestratorEnabled() |
true |
Gate — when false, all managed processes are requested to stop |
getOrchestratorLockFile() |
ROOT/var/DAEMON_ORCHESTRATOR.lock |
Singleton flock guard path |
getStateFile() |
ROOT/var/daemon_orchestrator_state.json |
JSON state persistence path |
getManagedLockFileGlobPattern() |
'*' |
Glob for stale lock cleanup on startup |
Migration from Urbanwater¶
// Before (Urbanwater-local):
use Urbanwater\ConsoleCommands\DaemonOrchestrator;
class MyOrchestrator extends DaemonOrchestrator { ... }
// After (framework-level):
use Pramnos\Console\DaemonOrchestrator;
class MyOrchestrator extends DaemonOrchestrator {
// Only implement: buildDesiredProcesses(), getDashboardTitle(), getEntryPoint()
// All infrastructure (reconcile, state, stop-file, dedup, git-hash, dashboard) is inherited.
}
BC notes¶
DaemonOrchestratoris a brand-new class — no existing code is affected.- Uses
ROOTconstant withsys_get_temp_dir()fallback for all filesystem paths. getOrchestratorCommandName()(inherited fromCommandBase) returns'daemons:start'by default — matches Urbanwater's current command name.
Tests¶
tests/Unit/Console/DaemonOrchestratorTest.php — 26 unit tests covering: buildShellTokens (single, multiple, empty, metacharacter escaping), requestStop/clearStopFile (creates sentinel, removes sentinel, idempotent), loadState/saveState (empty when missing, round-trip, empty-array overwrite), readWorkerPidFromLockFile (returns PID, zero when missing, zero for non-numeric), readOrchestratorPidFromLock (returns PID, zero when missing), getCurrentGitHash (contract: empty or 40-char hex), shouldAnnounceHealthyProcess (first call true, repeat false, pid-change true, zero-pid false, verbose-mode always true), readLastLogLine (no log placeholder, last non-empty line, blank-only placeholder), getProcessLogFile (correct path segments).
28. Phase 3: Migration Wizard & Seeder Generator¶
Overview¶
create migration (no name argument) now launches a fully interactive terminal wizard instead of requiring a name. The wizard collects the complete schema definition, writes a migration with a filled-in up() / down(), and then optionally scaffolds Model, Web Controller, API Controller, and Seeder from the same session — no database connection required.
Interactive wizard flow (v1.2 enhanced)¶
Improvements over v1.1:
| # | Change |
|---|---|
| 1 | Type labels show SQL equivalent — string (VARCHAR), integer (INT), etc. |
| 2 | Empty string default — type '' (two single quotes) to set an empty string as default |
| 3 | Multi-table migrations — after the FK section, "Add another table?" loops back |
| 4 | Schema-first model — model generated from wizard columns even before the migration runs |
| 5 | "Run now?" prompt — option to execute the migration immediately after creation |
| 6 | API Controller defaults to yes |
| 7 | Full CRUD controller + views — UI-aware (Bootstrap/DataTables/Select2) |
| 8 | Smart tests — model property assertions, FK null-guard, CRUD methods |
$ php bin/pramnos create:migration
─── create:migration — Interactive Wizard ────────────────────────────
Migration description: create users table
Table name: #PREFIX#users
Add auto-increment primary key id? [yes]
── Columns ──────────────────────────────────────────────────────────
Tip: to set an empty string as default, type '' (two single quotes).
Column name (Enter to finish): name
Type [string (VARCHAR)]: ← now shows SQL type
Length [255]: 100
Nullable? [no]:
Default value (blank = none, '' = empty string):
Comment: Full name
Unique? [no]:
Column name (Enter to finish): ← blank = done
Add timestamps (created_at / updated_at)? [yes]
Add soft-delete column (deleted_at)? [no]
── Foreign keys ──────────────────────────────────────────────────────
Add a foreign key? [no]:
Add another table to this migration? [no]: ← NEW in v1.2
✓ Migration created: app/migrations/2026_05_06_120000_create_users_table.php
Run this migration now? [yes] ← NEW in v1.2
── Also create ───────────────────────────────────────────────────────
Create Model (Users)? [yes]
Create Web Controller (UsersController)? [yes]
Create API Controller (UsersApiController)? [yes] ← default changed to yes
Create Seeder (UsersSeeder with fake data)? [yes]
UI-aware CRUD controller & views¶
When the wizard creates a Controller, it detects the app's UI setup:
applicationInfo['scaffold_theme']→'bootstrap'|'tailwind'|'plain-css'www/assets/vendor/datatables/exists → DataTables serverSide listwww/assets/vendor/select2/exists → Select2 for FK dropdown fields
Bootstrap + DataTables + Select2 generates:
- List view with $('#table').DataTable({ serverSide: true, ajax: '/ClassName/getApiList?format=datatables' })
- Edit form with $('#fk_col').select2() for foreign key fields
- Show view as a Bootstrap card
Bootstrap only generates a plain <table class="table"> with inline rows.
Plain CSS generates a minimal HTML table with no framework dependencies.
Schema-first model generation¶
The generated model contains all typed public properties from the wizard — no database round-trip required:
// Generated from wizard, before migration is run:
class User extends \Pramnos\Application\Model
{
/** @var int */
public $userid;
/** Full name
* @var string */
public $name;
/** @var int */
public $role_id; // FK: SET NULL guard in save()
protected $_primaryKey = "userid";
protected $_dbtable = "#PREFIX#users";
public function load($userid, $key = null, $debug = false) { ... }
public function save($autoGetValues = false, $debug = false) { ... }
public function delete($userid) { ... }
public function getData() { ... }
public function getApiList(...) { ... }
}
Generated migration¶
public function up(): void
{
SchemaBuilder::create('#PREFIX#users', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->comment('Full name');
$table->string('email', 255)->unique();
$table->unsignedBigInteger('role_id')->nullable();
$table->timestamps();
$table->foreign('role_id')->references('id')->on('roles')->onDelete('CASCADE');
});
}
public function down(): void
{
SchemaBuilder::dropIfExists('#PREFIX#users');
}
Non-interactive usage (unchanged)¶
New public helper methods on Create¶
| Method | Description |
|---|---|
buildMigrationUpBody(tableName, hasPk, columns, timestamps, softDeletes, foreignKeys): string |
Generates the full SchemaBuilder::create(...) closure body |
buildMigrationDownBody(tableName): string |
Generates SchemaBuilder::dropIfExists(...) |
blueprintCall(array $col): string |
Converts one column definition to a $table->...; call string |
generateFakeValue(colName, colType, options): string |
Returns a PHP expression (using $i) that produces plausible fake data |
buildSeederFields(columns): string |
Builds the 'col' => <expr>, block for a seeder template |
Seeder generator¶
Class: Pramnos\Database\Seeder (new abstract base class)
abstract class Seeder
{
abstract public function run(): void;
protected function insert(string $table, array $data): void;
}
Generated seeders extend Seeder, loop 10 times by default, and use name/type heuristics to produce meaningful fake values:
| Column name contains | Fake value |
|---|---|
email |
'user' . $i . '@example.com' |
name |
'Name ' . $i |
status |
['active','inactive','pending'][$i % 3] |
phone |
'+30210' . str_pad(...) |
city |
Choice from Greek cities |
password |
password_hash('password' . $i, PASSWORD_DEFAULT) |
token |
bin2hex(random_bytes(16)) |
uuid type |
sprintf(UUID-v4-pattern) |
boolean type |
($i % 2 === 0) |
decimal/float type |
round($i * 9.99, 2) |
| fallback | 'value_' . $i |
Auto-managed columns (id, created_at, updated_at, deleted_at) are never included in the seed insert.
Standalone seeder creation:
Stub template changes¶
migration.stub— addeduse Pramnos\Database\Blueprint;, replaced hard-coded// TODOwith{{ up_body }}/{{ down_body }}tokensseeder.stub— new template: extends Seeder, loops{{ count }}times, injects{{ fields }}block
BC notes¶
nameargument oncreatecommand changed fromREQUIREDtoOPTIONAL— existing CLI calls are unaffected (the value is still validated inside each branch; onlymigrationaccepts a missing name).createMigration()now passesup_body/down_bodytokens — fallback stub updated in parallel.
Tests¶
tests/Unit/Console/MigrationWizardHelpersTest.php — 18 unit tests covering: blueprintCall (string lengths, unsigned integer/biginteger uses correct method, decimal precision/scale, numeric default unquoted, comment escaping), buildMigrationUpBody (primary key, timestamps flag, soft-deletes flag, foreign key chain completeness), buildMigrationDownBody (correct table name), generateFakeValue (email heuristic overrides type, integer fallback, boolean expression, status heuristic), buildSeederFields (auto-managed columns skipped, one line per non-skipped column with trailing comma).
29. Phase 2: db:seed CLI Command¶
Class: Pramnos\Console\Commands\DbSeed
Command: db:seed
Registered in: Pramnos\Console\Application::registerCommands()
Overview¶
db:seed runs database seeders to populate tables with development or test data. Seeders are PHP classes that extend Pramnos\Database\Seeder and implement run(). The command scans a directory, loads every matching class, and calls run() on each in alphabetical order.
Usage¶
# Run all seeders in database/seeds/
php bin/pramnos db:seed
# Run a single seeder by class name
php bin/pramnos db:seed UsersSeeder
# Run seeders from a custom directory
php bin/pramnos db:seed --path=/custom/seeds/
# Combine: single seeder from custom path
php bin/pramnos db:seed ProductSeeder --path=/custom/seeds/
Arguments & options¶
| Name | Type | Description |
|---|---|---|
seeder |
optional argument | Class name to run (e.g. UsersSeeder). Runs all seeders when omitted. |
--path |
option | Directory to scan. Defaults to <ROOT>/database/seeds/. |
Default seeds path¶
ROOT . '/database/seeds/' where ROOT is the ROOT constant (or getcwd() as fallback).
Seeder contract¶
Every seeder must:
- Extend
Pramnos\Database\Seeder. - Implement
public function run(): void. - Live in a
.phpfile whose base name matches the class name exactly (e.g.UsersSeeder.php→ classUsersSeeder).
use Pramnos\Database\Seeder;
class UsersSeeder extends Seeder
{
public function run(): void
{
for ($i = 0; $i < 10; $i++) {
$this->insert('users', [
'name' => 'User ' . $i,
'email' => 'user' . $i . '@example.com',
]);
}
}
}
Exit codes¶
| Code | Meaning |
|---|---|
0 (SUCCESS) |
All seeders ran without errors (or seeds directory was missing/empty). |
1 (FAILURE) |
One or more seeders failed, or the named seeder was not found. |
BC notes¶
New command — no existing API changed.
30. Phase 2: Token Action Tracking — Token::updateAction() & Sync Trigger¶
Overview¶
Completes the Token Action Tracking system for all database backends:
-
Token::updateAction()for MySQL — removes the early-return guard that prevented response metric recording on MySQL. The method now writesreturn_status,execution_time_ms, andreturn_dataon both MySQL and PostgreSQL. -
Sync trigger migration — the
sync_tokenactions_timePL/pgSQL function and trigger are now created by theCreateTokenactionsTablemigration rather than byupdateAction()'s error-recoveryALTER TABLEfallback.
Token::updateAction() — cross-database behavior¶
// Before (MySQL always returned without writing):
if ($database->type == 'mysql') {
return;
}
// After — runs on all backends:
// MySQL: JSON column, backtick identifiers (converted to double-quotes by prepareQuery)
// PostgreSQL: JSONB column, double-quote identifiers
// Both: UPDATE tokenactions SET return_status=?, execution_time_ms=?, return_data=? WHERE actionid=?
Sync trigger (PostgreSQL only)¶
Installed by the migration's up() for PostgreSQL and TimescaleDB:
CREATE OR REPLACE FUNCTION sync_tokenactions_time() RETURNS TRIGGER AS $$
BEGIN
IF NEW.servertime IS NOT NULL AND NEW.servertime <> 0 THEN
NEW.action_time = TO_TIMESTAMP(NEW.servertime);
ELSE
NEW.action_time = CURRENT_TIMESTAMP;
NEW.servertime = EXTRACT(EPOCH FROM NEW.action_time)::INTEGER;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER sync_tokenactions_time
BEFORE INSERT OR UPDATE ON tokenactions
FOR EACH ROW EXECUTE FUNCTION sync_tokenactions_time();
The trigger keeps servertime (legacy UNIX integer) and action_time (TIMESTAMPTZ — the TimescaleDB partition key) in sync automatically. Legacy code that writes only servertime still gets a correct action_time for time-series range queries.
BC notes¶
Token::updateAction()signature unchanged. New behavior on MySQL is additive — previously the method silently returned without touching the DB; now it writes the metrics.- The sync trigger was previously created as a side-effect of
updateAction()'s exception handler. Moving it to the migration means it is installed once on schema setup rather than lazily on first update failure.
Tests¶
tests/Integration/User/TokenActionMySQLTest.php — 3 tests: verifies return_status/execution_time_ms/return_data are written on MySQL, null return_data stores {}, and actionid=0 is a no-op.
tests/Integration/User/TokenActionPostgreSQLTest.php — 4 tests: same 3 tests on PostgreSQL, plus testSyncTriggerPopulatesActionTimeFromServertime which verifies the trigger populates action_time from servertime on INSERT.
31. Phase 2: OAuth Server — league/oauth2-server Integration¶
Commit: b4d7be9 | Feature key: authserver | Library: league/oauth2-server:^8.5
Overview¶
Full OAuth 2.0 Authorization Server for PramnosFramework. Supports four grant types (Client Credentials, Password, Authorization Code, Refresh Token) backed by the usertokens table. RSA JWT access tokens (RS256). Applications must declare 'authserver' in app.php features.
New classes¶
Pramnos\Auth\Application (src/Pramnos/Auth/Application.php)¶
ORM model for the applications table (OAuth2 client registry). Extends Pramnos\Application\Model.
| Property | Type | Description |
|---|---|---|
appid |
int | Auto-increment PK |
apikey |
?string | OAuth2 client_id |
apisecret |
?string | OAuth2 client_secret |
callback |
?string | Comma-separated or JSON-array redirect URIs |
scope |
?string | Space-separated allowed scopes |
owner |
?int | FK to users.userid |
public_key |
?string | RSA/EC public key PEM (JWT client auth) |
jwks_uri |
?string | JWKS endpoint URL |
Key methods:
- loadByApiKey(string $apikey): static|false — load active application by client_id
- validateCredentials(string $clientId, ?string $clientSecret): bool — verify client_id + client_secret
- getClientIdentifier(), getRedirectUri(), getRedirectUris(), getScopes(), isConfidential()
Pramnos\Auth\OAuth2\OAuth2ServerFactory (src/Pramnos/Auth/OAuth2/OAuth2ServerFactory.php)¶
Factory for the league/oauth2-server Authorization and Resource servers. Wires the 6 repositories and enables the 4 grant types.
$factory = new OAuth2ServerFactory($controller);
$factory->generateKeyPair(); // RSA 2048-bit key pair (first-time setup only)
$authServer = $factory->createAuthorizationServer(); // handles /token and /authorize
$resourceServer = $factory->createResourceServer(); // validates Bearer tokens
RSA keys are stored at ROOT/app/keys/private.key (chmod 0600) and public.key (chmod 0644). The encryption key for auth codes and refresh tokens is persisted at ROOT/app/keys/encryption.key.
Grant type TTLs: | Grant | Access token | Refresh token | Auth code | |---|---|---|---| | ClientCredentials | 1h | — | — | | Password | 1h | 1 month | — | | AuthorizationCode | 1h | 1 month | 10 min | | RefreshToken | 1h | 1 month | — |
Pramnos\Auth\OAuth2\OAuth2Middleware (src/Pramnos/Auth/OAuth2/OAuth2Middleware.php)¶
Standalone Bearer token validator for protecting controller routes.
$mw = new OAuth2Middleware($controller);
// Validate token and check scope — sends 401/403 JSON + exit() on failure
$tokenInfo = $mw->validateAccessToken(['read', 'write']);
// Helpers
$userId = $mw->getCurrentUserId();
$appId = $mw->getCurrentApplicationId();
$mw->revokeToken($bearerToken);
Entities (6) — Pramnos\Auth\OAuth2\Entities\¶
| Class | Interface |
|---|---|
ClientEntity |
ClientEntityInterface |
UserEntity |
UserEntityInterface |
ScopeEntity |
ScopeEntityInterface |
AccessTokenEntity |
AccessTokenEntityInterface |
AuthCodeEntity |
AuthCodeEntityInterface |
RefreshTokenEntity |
RefreshTokenEntityInterface |
Repositories (6) — Pramnos\Auth\OAuth2\Repositories\¶
| Class | Storage |
|---|---|
ClientRepository |
applications table via Application model |
ScopeRepository |
In-memory (extendable via setScopes() / addScopes()) |
AccessTokenRepository |
usertokens (tokentype=access_token) |
AuthCodeRepository |
usertokens (tokentype=auth_code) |
RefreshTokenRepository |
usertokens (tokentype=refresh_token, parentToken link) |
UserRepository |
delegates to User::validateUserCredentials() |
Pramnos\Auth\AuthServerServiceProvider¶
Registered automatically by FeatureRegistry when authserver is in app.php. Currently minimal; boot hook point for future route registration.
Migrations¶
All migrations have $feature = 'authserver' and run only when the authserver feature is enabled.
| File | Table(s) | MySQL | PostgreSQL |
|---|---|---|---|
000025_create_applications_table |
applications |
In default schema | In public schema |
000026_create_device_authorizations_table |
device_authorizations |
In default schema | In authserver schema |
000027_create_jwt_replay_prevention_table |
jwt_replay_prevention |
In default schema | In authserver schema |
000028_create_oauth2_client_auth_methods_table |
oauth2_client_auth_methods |
ENUM auth_method | VARCHAR + CHECK |
000029_create_oauth2_webhooks_tables |
oauth2_webhook_endpoints, oauth2_webhook_events |
JSON columns | JSONB columns |
Custom scopes¶
// In a ServiceProvider or controller bootstrap:
$factory = new OAuth2ServerFactory($controller);
$scopeRepo = $factory->makeScopeRepository();
$scopeRepo->addScopes([
'data:read' => 'Read sensor data',
'data:write' => 'Write sensor data',
]);
Tests¶
tests/Integration/Database/FrameworkMigrationsMySQLTest.php — 5 new tests covering each authserver migration (applications, device_authorizations, jwt_replay_prevention, oauth2_client_auth_methods, oauth2_webhook_endpoints+events) with column type and rollback verification.
tests/Integration/Database/FrameworkMigrationsPostgreSQLTest.php — 5 new tests covering the same migrations on PostgreSQL: schema placement (authserver namespace), PostgreSQL-specific types (VARCHAR+CHECK instead of ENUM, JSONB instead of JSON), and unique constraint verification via pg_constraint.
32. Phase 2: Messaging System — Models & Service Provider¶
Files:
- src/Pramnos/Messaging/Mail.php
- src/Pramnos/Messaging/MailTemplate.php
- src/Pramnos/Messaging/Message.php
- src/Pramnos/Messaging/MassMessage.php
- src/Pramnos/Messaging/MassMessageRecipient.php
- src/Pramnos/Messaging/MessagingServiceProvider.php
Summary: Five ORM models and a service provider for the messaging feature. Each model extends \Pramnos\Application\Model and provides standard CRUD via load(), save(), delete(), getData(), and getList().
Pramnos\Messaging\Mail¶
Maps to the mails table. Represents an email record — either queued for delivery (STATUS_QUEUED = 2), successfully sent (STATUS_SENT = 1), or failed (STATUS_FAILED = 0).
| Property | Type | Description |
|---|---|---|
$id |
int | Auto-increment PK |
$status |
int | 0=failed, 1=sent, 2=queued |
$frommail / $fromname |
string | Sender address and display name |
$tomail / $toname |
string | Recipient address and display name |
$subject / $content |
string | Subject line and full body |
$date |
int | Unix timestamp of creation |
$module / $moduleinfo |
string | Module that triggered the email |
$extrainfo |
string | JSON metadata for debugging |
$hash |
string | MD5 for deduplication |
Constants: Mail::STATUS_FAILED, Mail::STATUS_SENT, Mail::STATUS_QUEUED.
Pramnos\Messaging\MailTemplate¶
Maps to the mailtemplates table. Templates are keyed by (category, language, type) and support email (TYPE_EMAIL=0), SMS (TYPE_SMS=1), and push notifications (TYPE_PUSH=2).
Additional method:
Returns the first template matching the given key, or null if none found.
Constants: MailTemplate::TYPE_EMAIL, TYPE_SMS, TYPE_PUSH, SENDMETHOD_SMTP, SENDMETHOD_SES.
Pramnos\Messaging\Message¶
Maps to the messages table. A single table encodes all inbox/outbox/archive/notification states via the type column (TYPE_NEW=1 = unread inbox, TYPE_SENT=2 = outbox, TYPE_NOTIFICATION_NEW=8, etc.).
Additional methods:
public function countUnread(int $userId): int
public function countUnreadNotifications(int $userId): int
Constants: Message::TYPE_READ, TYPE_NEW, TYPE_SENT, TYPE_INBOX_ARCHIVE, TYPE_OUTBOX_ARCHIVE, TYPE_UNREAD, TYPE_MARKED_READ, TYPE_DELETED, TYPE_NOTIFICATION_NEW, TYPE_NOTIFICATION_READ.
Pramnos\Messaging\MassMessage¶
Maps to the massmessages table. Broadcast header — one row per campaign. Individual per-user delivery records live in massmessagerecipients and per-user copies in messages (via the massid FK).
Constants: MassMessage::TYPE_EMAIL, TYPE_MESSAGE, TYPE_PUSH, STATUS_PENDING, STATUS_SENT, STATUS_SCHEDULED.
Pramnos\Messaging\MassMessageRecipient¶
Maps to the massmessagerecipients table. One row per (massmessage, recipient) pair. Tracks delivery status: STATUS_PENDING=0, STATUS_DELIVERED=1, STATUS_FAILED=2.
Pramnos\Messaging\MessagingServiceProvider¶
Service provider registered when the messaging feature is enabled in app.php. Extends ServiceProvider with empty register() and boot() hooks — application code overrides these to attach event listeners, schedule cleanup tasks, or register custom delivery handlers.
Usage examples¶
use Pramnos\Messaging\Mail;
use Pramnos\Messaging\Message;
use Pramnos\Messaging\MailTemplate;
// --- Mail: record a sent email ---
$mail = new Mail($controller);
$mail->frommail = 'noreply@example.com';
$mail->fromname = 'My App';
$mail->tomail = 'user@example.com';
$mail->toname = 'Alice';
$mail->subject = 'Welcome!';
$mail->content = '<p>Hello Alice</p>';
$mail->status = Mail::STATUS_SENT;
$mail->date = time();
$mail->module = 'auth';
$mail->_save();
// Reload and check status
$mail2 = new Mail($controller);
$mail2->_load($mail->mailid);
echo $mail2->status === Mail::STATUS_SENT ? 'sent' : 'pending';
// --- MailTemplate: find a template by key ---
$tpl = new MailTemplate($controller);
$found = $tpl->findByKey('welcome', 'en', MailTemplate::TYPE_EMAIL);
if ($found) {
$body = str_replace('{name}', 'Alice', $found->content);
}
// --- Message: count unread inbox messages ---
$msg = new Message($controller);
$unread = $msg->countUnread($userId); // inbox unread
$notifications = $msg->countUnreadNotifications($userId); // notification unread
// Create a new inbox message for a recipient
$inbox = new Message($controller);
$inbox->fromuserid = $senderId;
$inbox->touserid = $recipientId;
$inbox->subject = 'Hello!';
$inbox->body = 'Hi there';
$inbox->type = Message::TYPE_NEW;
$inbox->date = time();
$inbox->_save();
Last Updated: 2026-05-08
33. Phase 2: Auth — Pramnos\Auth\Loginlockout¶
Backport from: Authserver\Models\Loginlockout
Progressive brute-force lockout for login endpoints. Tracks failed login attempts per scope+identifier pair and applies escalating lockout durations. Three scopes are supported out of the box: 'user' (by user ID string), 'identifier' (by normalised email/username), and 'ip' (by remote address), though any string value is accepted.
Lockout thresholds (default)¶
| Failures | Lockout duration |
|---|---|
| 3 | 60 s (1 minute) |
| 5 | 300 s (5 minutes) |
| 7 | 900 s (15 minutes) |
| 10+ | 3600 s (1 hour) |
A sliding window of 900 seconds applies: if the gap between the previous failure and the current attempt exceeds the window, the counter resets to 1 (old failures are discarded). This prevents indefinite accumulation from past brute-force campaigns.
Storage¶
The loginlockout table is created by CreateLoginlockoutTable (migration priority 70). All timestamps are stored as Unix integers to avoid timezone ambiguity between MySQL and PostgreSQL.
loginlockout
├── lockoutid INT PK auto-increment
├── locktype VARCHAR(20) — 'user' | 'identifier' | 'ip'
├── lookupvalue VARCHAR(255) — the tracked identifier
├── failedattempts INT DEFAULT 0
├── firstfailedat INT DEFAULT 0 (unix timestamp)
├── lastfailedat INT DEFAULT 0 (unix timestamp)
├── lockoutuntil INT DEFAULT 0 (unix timestamp; 0 = no active lockout)
├── createdat INT
└── updatedat INT
UNIQUE KEY (locktype, lookupvalue)
INDEX (lockoutuntil)
API¶
use Pramnos\Auth\Loginlockout;
$lockout = new Loginlockout();
// Record a failed attempt (call once per scope that applies)
$lockout->recordFailedAttempt('identifier', strtolower($email));
$lockout->recordFailedAttempt('user', (string) $userId);
$lockout->recordFailedAttempt('ip', $ipAddress);
// Check lockout state before processing a login attempt
$status = $lockout->getLockoutStatus('identifier', strtolower($email));
// Returns: ['locked' => bool, 'remaining' => int]
// 'remaining' is seconds until lockout expires (0 when not locked)
if ($status['locked']) {
throw new TooManyAttemptsException($status['remaining']);
}
// Clear state after a successful login
$lockout->clearSuccessfulLoginState('identifier', strtolower($email));
$lockout->clearSuccessfulLoginState('user', (string) $userId);
$lockout->clearSuccessfulLoginState('ip', $ipAddress);
recordFailedAttempt(string $scope, string $identifier): void¶
Increments the failure counter for the given scope+identifier pair. If no row exists, a new one is created. If the previous failure falls outside the sliding window, the counter is reset to 1 before incrementing. Updates lockoutuntil according to the progressive threshold table.
getLockoutStatus(string $scope, string $identifier): array¶
Returns ['locked' => bool, 'remaining' => int]. Returns ['locked' => false, 'remaining' => 0] when no row exists. remaining is always 0 when locked is false.
clearSuccessfulLoginState(string $scope, string $identifier): void¶
Deletes the tracking row for the given scope+identifier pair, fully resetting the failure counter and any active lockout. Call this once per scope after a successful authentication.
Constants¶
| Constant | Value | Description |
|---|---|---|
Loginlockout::DEFAULT_WINDOW_SECONDS |
900 | Sliding window duration (failures older than this are discarded) |
Loginlockout::DEFAULT_STEPS |
[3=>60, 5=>300, 7=>900, 10=>3600] |
Progressive threshold map |
Integration tests¶
tests/Integration/Database/LoginlockoutMySQLTest.php— 11 tests × MySQL 8.0tests/Integration/Database/LoginlockoutPostgreSQLTest.php— 11 tests × PostgreSQL 14 / TimescaleDB
34. Phase 2: Auth — Pramnos\Auth\TOTPHelper + Pramnos\Auth\TwoFactorAuthService¶
Backport from: Authserver\Helpers\TOTPHelper + Authserver\Services\TwoFactorAuthService
Full TOTP two-factor authentication implementation. TOTPHelper is a pure static utility class implementing RFC 6238. TwoFactorAuthService is the stateful service that manages setup, verification, and audit logging via the database.
Pramnos\Auth\TOTPHelper¶
Static utility with no database dependency. Compatible with Google Authenticator, Authy, and any RFC 6238 TOTP app.
use Pramnos\Auth\TOTPHelper;
// Generate a new shared secret (call once per user during setup)
$secret = TOTPHelper::generateSecret(); // e.g., 'JBSWY3DPEHPK3PXP'
// Generate the current 6-digit code (for testing/admin)
$code = TOTPHelper::generateCode($secret); // e.g., '042871'
// Verify a user-submitted code (±1 window drift tolerance)
$valid = TOTPHelper::verifyCode($secret, $userCode); // bool
// Build the otpauth:// provisioning URI (pure, no external calls)
$uri = TOTPHelper::buildOtpAuthUri($secret, 'user@example.com', 'MyApp');
// → 'otpauth://totp/MyApp:user%40example.com?secret=…&issuer=MyApp&…'
// Server-side QR code as inline data URI (requires chillerlan/php-qrcode ^5.0)
// Returns 'data:image/svg+xml;base64,…' or null when library is absent.
// Use this instead of getQRCodeUrl() to avoid leaking TOTP secrets to external APIs
// and to comply with strict Content-Security-Policy (img-src 'self' data:).
$dataUri = TOTPHelper::getQRCodeDataUri($secret, 'user@example.com', 'MyApp');
// @deprecated — sends TOTP secret to external API (use getQRCodeDataUri() instead)
$url = TOTPHelper::getQRCodeUrl($secret, 'user@example.com', 'MyApp');
// Backup codes
$codes = TOTPHelper::generateBackupCodes(10); // ['ABCD2345', ...]
$hash = TOTPHelper::hashBackupCode($codes[0]); // bcrypt hash for storage
$isMatch = TOTPHelper::verifyBackupCode($code, $hash); // bool, case-insensitive
// Utilities
$remaining = TOTPHelper::getRemainingTime(); // seconds until window expires [1, 30]
$isValid = TOTPHelper::isValidSecret($secret); // bool — validates base32 format
Pramnos\Auth\TwoFactorAuthService¶
Stateful service injected with (or defaulting to) the framework database instance.
use Pramnos\Auth\TwoFactorAuthService;
$svc = new TwoFactorAuthService(); // uses Factory::getDatabase()
// or:
$svc = new TwoFactorAuthService($db); // explicit injection for testing
// ── Setup flow ─────────────────────────────────────────────────────────────
// Step 1: generate secret and show QR code to user
$info = $svc->startSetup($userId, $userEmail);
// Returns: ['secret', 'qr_code_data_uri', 'qr_code_url', 'manual_entry_key', 'backup_codes']
// qr_code_data_uri is a 'data:image/svg+xml;base64,…' string (or null if chillerlan absent).
// Prefer qr_code_data_uri for CSP compliance; fall back to qr_code_url otherwise.
// Display $info['backup_codes'] once for user to record.
// Step 2: user scans QR code and enters first code to confirm
$success = $svc->completeSetup($userId, $userSubmittedCode); // bool
// ── Verification ───────────────────────────────────────────────────────────
$valid = $svc->verifyCode($userId, $code); // bool — accepts TOTP or backup code
// Writes a row to twofactor_attempts on every call.
// ── State queries ──────────────────────────────────────────────────────────
$enabled = $svc->isEnabled($userId); // bool
$secret = $svc->getSecret($userId); // string|null
$status = $svc->getStatus($userId);
// Returns: ['enabled' => bool, 'setup' => bool, 'backup_codes_remaining' => int]
$remaining = $svc->getRemainingBackupCodes($userId); // int
// ── Management ─────────────────────────────────────────────────────────────
$svc->disable($userId); // bool — clears secret, sets enabled=0, row retained
$newCodes = $svc->regenerateBackupCodes($userId); // string[]|false
$svc->cleanupExpiredSessions(); // removes used=1 and expired twofactor_setup rows
Database tables¶
Three migrations create the required tables (all in database/migrations/framework/auth/):
| Migration | Table | Description |
|---|---|---|
000018 (priority 80) |
user_twofactor |
One row per user; PK=userid; stores enabled flag, secret, backup code hashes, last_used unix timestamp |
000019 (priority 85) |
twofactor_setup |
Temporary setup sessions; 15-min TTL (expires_at unix timestamp); used=1 after completeSetup() |
000020 (priority 90) |
twofactor_attempts |
Append-only attempt log; TimescaleDB hypertable (7-day chunks, compress after 7 days, retain 2 years) via ifCapable(TIMESCALEDB); plain table on MySQL/plain PostgreSQL |
The twofactor_attempts.attempt_time column is TIMESTAMPTZ on PostgreSQL/TimescaleDB and DATETIME on MySQL. The service inserts formatted UTC strings (gmdate('Y-m-d H:i:s', ...)) for cross-database portability.
Design decisions¶
- Password verification not in service —
disable()andregenerateBackupCodes()do not verify the user's password. The calling controller is responsible for authentication before calling these methods. This keeps the service testable without mocking a user model. - Replay protection —
verifyCode()compares the current 30-second window againstlast_usedinuser_twofactor. If the same window was already used, the code is rejected even if cryptographically valid. - Backup codes are one-time —
verifyAndConsumeBackupCode()removes the matching hash from the JSON array after a successful verification. A code cannot be reused. - Cross-DB upsert —
completeSetup()uses SELECT + INSERT/UPDATE instead ofON CONFLICTto work on both MySQL and PostgreSQL.
Tests¶
tests/Unit/Auth/TOTPHelperTest.php— 15 unit tests (pure computation, no DB required)tests/Integration/Database/TwoFactorAuthServiceMySQLTest.php— 17 integration tests × MySQL 8.0tests/Integration/Database/TwoFactorAuthServicePostgreSQLTest.php— 17 integration tests × PostgreSQL 14 / TimescaleDB (twofactor_attempts is a hypertable)
35. Phase 2: Auth — Pramnos\Auth\Scopes + Pramnos\Auth\OAuthPolicyHelper¶
Backport from: Authserver\Helpers\Scopes + Authserver\Helpers\OAuthPolicyHelper
Static helper classes for OAuth2 scope and policy management.
Pramnos\Auth\Scopes¶
Centralized scope registry. All methods are static and depend only on the scope definitions in getScopes(). Subclass and override getScopes() to add application-specific scopes.
use Pramnos\Auth\Scopes;
// All scopes grouped by category (for consent screen rendering)
$grouped = Scopes::getScopes();
// Returns: ['Personal User Data' => ['profile' => [...], 'email' => [...], ...], ...]
// Flat scope → description map
$descriptions = Scopes::getScopeDescriptions();
// Returns: ['profile' => 'Access to basic profile...', ...]
// Scopes implicitly granted to all clients
$defaults = Scopes::getDefaultScopes(); // ['profile', 'email', 'user']
// Validate a scope string
[$hasInvalid, $invalidList] = Scopes::hasInvalidScopes('profile email unknown_scope');
// $hasInvalid = true; $invalidList = ['unknown_scope']
// Resolve a scope to include all transitively inherited scopes
$resolved = Scopes::resolveInheritedScopes('system:notifications_write');
// Returns: ['system:notifications_read', 'system:notifications_write'] (sorted)
// Also accepts array input and space-delimited strings
$resolved = Scopes::resolveInheritedScopes(['profile', 'openid']);
// Check if a client is permitted to use the requested scopes (requires applications table)
[$allGranted, $problematic] = Scopes::areApplicationScopesGranted('profile email', $apiKey);
Standard scopes defined: profile, email, phone, address, user (user data), openid, offline_access (account actions), system:admin, system:audit_read, system:health, system:notifications_read/write (administration).
Pramnos\Auth\OAuthPolicyHelper¶
Server-wide OAuth2 policy defaults for clients without explicit per-client policy rows.
use Pramnos\Auth\OAuthPolicyHelper;
$methods = OAuthPolicyHelper::getDefaultAllowedAuthMethods();
// Returns: ['client_secret_basic', 'client_secret_post', 'private_key_jwt']
// Note: 'none' (public client / PKCE) is excluded — must be opted in explicitly.
$grants = OAuthPolicyHelper::getDefaultAllowedGrantTypes();
// Returns: ['authorization_code', 'client_credentials', 'device_code', 'refresh_token', 'exchange_token']
// Note: 'password' is excluded (deprecated per RFC 9126 / OAuth 2.1).
Tests¶
tests/Unit/Auth/ScopesTest.php— 12 unit tests (pure static; no DB)tests/Unit/Auth/OAuthPolicyHelperTest.php— 6 unit tests (pure static; no DB)
36. Phase 2: Auth — GDPR Database Migrations¶
Seven migrations adding GDPR compliance infrastructure to the auth feature (all in database/migrations/framework/auth/):
| Migration | Table/View | Type | TimescaleDB config |
|---|---|---|---|
000021 (priority 100) |
user_activity_log |
Hypertable | 1-day chunks; compress after 30 days; retain 24 months |
000022 (priority 105) |
user_privacy_settings |
Plain table | — |
000023 (priority 110) |
user_consents |
Hypertable | 1-month chunks; compress after 6 months; retain 7 years |
000024 (priority 115) |
data_processing_records |
Hypertable | 1-week chunks; compress after 90 days; retain 36 months |
000025 (priority 120) |
gdpr_requests |
Hypertable | 1-month chunks; compress after 1 year; retain 7 years |
000026 (priority 125) |
daily_activity_summary |
Continuous aggregate / mat. view | TimescaleDB: continuous aggregate; plain PG: materialized view; MySQL: plain view |
000027 (priority 130) |
users (ALTER TABLE) |
Column additions | — |
All hypertable creation uses ifCapable(DatabaseCapabilities::TIMESCALEDB) so the same migration works on MySQL (plain table), plain PostgreSQL (plain table), and TimescaleDB (hypertable).
Table schemas¶
user_activity_log: userid, action VARCHAR(100), details TEXT (JSON), ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMPTZ (time dimension). Indexes on (userid, created_at) and (action, created_at).
user_privacy_settings: userid BIGINT (PK), share_usage_analytics TINYINT, marketing_emails TINYINT, data_processing TINYINT, updated_at TIMESTAMPTZ.
user_consents: userid, consent_type VARCHAR(100), granted TINYINT, granted_at TIMESTAMPTZ (time dimension), legal_basis VARCHAR(100), ip_address VARCHAR(45). Append-only consent audit log.
data_processing_records: userid, operation VARCHAR(100), data_category VARCHAR(100), legal_basis VARCHAR(100), processor VARCHAR(100), details TEXT (JSON), processed_at TIMESTAMPTZ (time dimension). GDPR Article 30 records.
gdpr_requests: userid, request_type VARCHAR(50), status VARCHAR(50), requested_at TIMESTAMPTZ (time dimension), completed_at TIMESTAMPTZ, notes TEXT, ip_address VARCHAR(45). Tracks right-to-erasure, right-to-access, portability requests.
daily_activity_summary (view/aggregate): day, userid, action_count, distinct_action_types. On TimescaleDB: continuous aggregate over user_activity_log using time_bucket('1 day', created_at). On plain PG: materialized view. On MySQL: plain view.
users GDPR columns (added via ALTER TABLE): gdpr_consent TINYINT, gdpr_consent_date INT (unix timestamp), gdpr_data_export_requested TINYINT, gdpr_deletion_requested TINYINT, gdpr_deletion_date INT (unix timestamp). The migration is idempotent — each column is added only if hasColumn() returns false.
37. Phase 2: AuthServer — RBAC Schema Migrations (Completion)¶
Problem: The existing authserver schema had basic roles, permissions, user_roles, and audit_log tables (migrations 000020–000024) but lacked the complete RBAC infrastructure needed for the UrbanWater-style multi-organisation permission system: organisation membership, permission/role templates, object hierarchy, the effective_permissions view, and the PL/pgSQL helper functions.
Solution: Six new migrations (000031–000036) complete the RBAC schema. Two schema fixes applied to the existing permissions table (object_id changed from BIGINT to VARCHAR(100); unique constraint added).
New migrations¶
| File | Priority | Creates |
|---|---|---|
000031_create_authserver_user_organizations_table.php |
45 | authserver.user_organizations — org membership |
000032_create_authserver_permission_templates_table.php |
55 | authserver.permission_templates — reusable blueprints |
000033_create_authserver_role_templates_table.php |
60 | authserver.role_templates — role blueprints |
000034_create_authserver_permission_inheritance_table.php |
65 | authserver.permission_inheritance — object hierarchy |
000035_create_authserver_effective_permissions_view.php |
70 | authserver.effective_permissions VIEW |
000036_create_authserver_rbac_functions.php |
75 | 7 PL/pgSQL functions + 2 triggers (PostgreSQL only) |
Table schemas¶
authserver.user_organizations: Composite PK (userid, organization_id). Tracks which users belong to which organisations. A user must be a member before being assigned an organisation-scoped role (organization_id IS NOT NULL). Columns: userid BIGINT, organization_id INT, granted_by BIGINT (nullable), granted_at TIMESTAMP, expires_at TIMESTAMP (nullable), is_active BOOL.
authserver.permission_templates: Blueprint for a single permission grant. templateid SERIAL PK, template_name VARCHAR(100) (unique), template_type (role_template/org_template/user_template), object_type VARCHAR(50), object_id_pattern VARCHAR(100) (supports {organization_id} placeholder), action, grant_type, priority, is_active, created_at, created_by.
authserver.role_templates: Bundles multiple permission templates. role_templateid SERIAL PK, template_name VARCHAR(100) (unique), permission_templateids TEXT (JSON array of templateid integers), is_system_template BOOL, is_active, created_at, created_by.
authserver.permission_inheritance: Defines parent→child relationships between resource objects. inheritanceid SERIAL PK, child_object_type, child_object_id, parent_object_type, parent_object_id, inheritance_type (full/read_only/custom), is_active. Indexes: idx_authserver_pi_child, idx_authserver_pi_parent.
Schema fixes to authserver.permissions (000022)¶
Two corrections applied to the existing permissions migration:
- object_id changed from BIGINT to VARCHAR(100) — required to support wildcards (*) and non-integer IDs (e.g., org_{id} patterns from templates).
- Unique constraint uq_authserver_perms_grant added on (subject_type, subject_id, object_type, object_id, action, grant_type) — required for ON CONFLICT DO NOTHING in apply_permission_template().
authserver.effective_permissions VIEW¶
Aggregates all active, non-expired authserver.permissions rows and resolves the effective grant per (subject_type, subject_id, object_type, object_id, action):
CASE
WHEN MAX(deny_priority) > COALESCE(MAX(allow_priority), 0) THEN 'deny'
WHEN MAX(CASE WHEN grant_type = 'allow' THEN 1 END) = 1 THEN 'allow'
ELSE 'deny' -- default-deny
END AS effective_grant
On MySQL: created as authserver_effective_permissions (schema-as-prefix convention). On PostgreSQL/TimescaleDB: authserver.effective_permissions.
PL/pgSQL functions (PostgreSQL only, 000036)¶
All 7 functions are installed in the authserver schema. On MySQL, the migration is a no-op.
| Function | Signature | Description |
|---|---|---|
set_permission_priority() |
trigger fn | Auto-adds 1000 to priority for deny entries |
check_user_org_membership() |
trigger fn | Blocks role assignment if user is not an org member |
apply_permission_template() |
(templateid, subject_type, subject_id, context_org_id, granted_by) → INTEGER |
Creates one permission row from a template |
apply_role_template() |
(role_templateid, role_name, org_id, created_by) → INTEGER |
Creates a role and applies all its permission templates |
log_audit_event() |
(event_type, actor_userid, actor_type, target_type, target_id, object_type, object_id, old_values, new_values, metadata, organization_context) → INTEGER |
Inserts an audit_log row |
check_permission_with_inheritance() |
(subject_type, subject_id, object_type, object_id, action) → BOOL |
Checks direct + inherited permissions |
get_user_effective_permissions() |
(userid, org_id) → TABLE |
Returns all effective permissions for a user (direct + role-based) |
Triggers:
- trigger_set_permission_priority — BEFORE INSERT OR UPDATE ON authserver.permissions
- trigger_check_user_org_membership — BEFORE INSERT OR UPDATE ON authserver.user_roles
Usage examples¶
Apply migrations first (PostgreSQL/TimescaleDB only for PL/pgSQL functions):
Call PL/pgSQL helper functions via raw QueryBuilder (PostgreSQL only):
$db = \Pramnos\Database\Database::getInstance();
// Check if a user has a specific permission (direct + inherited)
$result = $db->query(
"SELECT authserver.check_permission_with_inheritance(
'user', :subject_id, 'document', :object_id, 'read'
) AS allowed",
[':subject_id' => $userId, ':object_id' => $documentId]
);
$result->fetch();
$allowed = (bool) $result->fields['allowed'];
// Get all effective permissions for a user in an organisation
$perms = $db->query(
"SELECT * FROM authserver.get_user_effective_permissions(:userid, :org_id)",
[':userid' => $userId, ':org_id' => $orgId]
);
while ($perms->fetch()) {
echo $perms->fields['object_type'] . ':' . $perms->fields['action']
. ' → ' . $perms->fields['effective_grant'] . PHP_EOL;
}
// Apply a role template to create a role for an organisation
$db->query(
"SELECT authserver.apply_role_template(:templateid, :rolename, :org_id, :createdby)",
[':templateid' => 1, ':rolename' => 'org_admin', ':org_id' => 42, ':createdby' => $adminUserId]
);
Query the effective_permissions view directly:
$result = $db->queryBuilder()
->from('authserver.effective_permissions')
->where("subject_type = 'user'")
->where("subject_id = {$userId}")
->where("effective_grant = 'allow'")
->get();
while ($result->fetch()) {
// $result->fields: subject_type, subject_id, object_type, object_id, action, effective_grant
}
Tests¶
MySQL (FrameworkMigrationsMySQLTest): 5 new tests covering user_organizations, permission_templates, role_templates, permission_inheritance table creation, and effective_permissions VIEW including deny-takes-priority assertion.
PostgreSQL (FrameworkMigrationsPostgreSQLTest): 6 new tests covering all 5 tables/view + the PL/pgSQL functions test which verifies: all 7 functions exist in pg_proc, trigger fires and adds 1000 to deny priority, apply_permission_template() inserts a real row, down() removes all functions.
Last Updated: 2026-05-11
38. Phase 2: Auth — Pramnos\Auth\WebhookService¶
Backport from: Authserver\Services\WebhookService
File: src/Pramnos/Auth/WebhookService.php
Delivers queued OAuth2 webhook events to registered application endpoints via signed HTTP POST requests. Works on both MySQL and PostgreSQL. On PostgreSQL, the PL/pgSQL function applications.create_webhook_event() enqueues events automatically from stored procedures and triggers; on MySQL (or for explicit PHP-side enqueueing), call queueEvent() directly.
Data Model¶
| Table | Purpose |
|---|---|
applications.oauth2_webhook_endpoints |
Registered endpoint URL + HMAC secret per application and event type |
applications.oauth2_webhook_events |
Delivery queue / audit log; status lifecycle: pending → sent \| failed \| cancelled |
Both tables are created by migration 2020_01_01_000029_create_oauth2_webhooks_tables.php. On MySQL the table names use the applications_ prefix.
Public API¶
queueEvent() — explicit event creation (cross-DB)¶
$svc->queueEvent(
eventType: 'token_revoked',
userId: 42,
payload: ['token_id' => 123, 'reason' => 'logout'],
deviceCode: null, // optional: RFC 8628 device_code
tokenId: 123, // optional: FK to usertokens
): int // count of event rows inserted (one per matching endpoint)
Finds all active endpoints subscribed to $eventType and inserts one oauth2_webhook_events row per endpoint. Safe to call on both MySQL and PostgreSQL.
processQueue() — process pending deliveries¶
Fetches up to $batchSize events with status='pending' and next_attempt_at <= NOW(), sends HTTP POST to each endpoint, and updates the event status:
- Success (HTTP 2xx):
status = 'sent',sent_atset. - Failure:
attemptsincremented; back-off applied (5 min × 2^(attempt-1), capped at 24 h). Whenattempts >= max_attempts:status = 'failed'. - Deleted endpoint:
status = 'cancelled'.
purgeOldEvents() — cleanup¶
Removes sent, failed, and cancelled events older than $daysOld days. Returns the count deleted.
verifySignature() — inbound verification¶
$ok = WebhookService::verifySignature(
payload: $rawBody,
secret: $secret,
signatureHeader: $request->getHeader('X-Webhook-Signature'),
);
Validates the X-Webhook-Signature: sha256=<hex> header using hash_equals() to prevent timing attacks.
buildSignature() — outbound signing helper¶
Request format¶
Every outbound request includes:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Webhook-Signature |
sha256=<HMAC-SHA256(secret, body)> |
X-Webhook-Event-Type |
event type string (e.g. token_revoked) |
X-Webhook-Timestamp |
Unix timestamp of delivery attempt |
User-Agent |
PramnosFramework-Webhook/1.0 |
Event types¶
| Type | Trigger |
|---|---|
user_deauthorized |
applications.deauthorize_user_from_app() |
token_revoked |
trigger_token_revocation_webhook (AFTER UPDATE on usertokens) |
gdpr_request |
applications.create_gdpr_request() |
user_profile_changed |
applications.notify_user_profile_changed() |
device_deauthorized |
Application-level |
account_deleted |
Application-level |
scope_changed |
Application-level |
Retry policy¶
Exponential back-off: delay = min(300 × 2^(attempt−1) seconds, 86400 seconds)
| Attempt | Delay |
|---|---|
| 1 | 5 min |
| 2 | 10 min |
| 3 | 20 min |
| 4 | 40 min |
| 5+ | up to 24 h |
The max_attempts per endpoint comes from the retry_count column in oauth2_webhook_endpoints (default: 3).
Added: 2026-05-11
39. Auth Controllers — Pramnos\Auth\Controllers\*¶
Added: 2026-05-11 — ROADMAP item 271
A set of pre-built controllers that implement the HTTP layer for the OAuth2 / OpenID Connect server. All live under src/Pramnos/Auth/Controllers/ and extend Pramnos\Application\Controller.
39.1 Discovery¶
Pure-JSON, fully public controller (no authentication required).
| Action | HTTP | Endpoint |
|---|---|---|
configuration |
GET | /.well-known/openid-configuration |
jwks |
GET | /.well-known/jwks.json |
oauth2Metadata |
GET | /.well-known/oauth-authorization-server |
health |
GET | /.well-known/health |
configuration() — Full OpenID Connect discovery document (RFC 8414 + OpenID Core §4). Includes all supported response types, grant types, scopes (sourced from Scopes::getScopeDescriptions()), PKCE methods, and backchannel logout flags. Cached 1 hour.
jwks() — JSON Web Key Set. Reads <ROOT>/app/keys/public.key, extracts the RSA modulus (n) and exponent (e) as base64url, and returns them in RFC 7517 JWK format. Returns an empty keys array if the key file is absent. Cached 24 hours.
oauth2Metadata() — RFC 8414 server metadata subset (issuer, endpoints, grant types, scopes). Cached 1 hour.
health() — Verifies database connectivity with SELECT 1. Returns {"status":"healthy"} on success, {"status":"unhealthy"} + HTTP 503 on failure.
39.2 Session¶
Manages session state checks for AJAX clients and OAuth2 relying parties. Supports both session cookies ($_SESSION) and Bearer tokens (Authorization: Bearer <token> header) transparently.
All actions are registered as public (addaction). CORS * headers are set in the constructor.
| Action | HTTP | Description |
|---|---|---|
check |
GET | Is the session / token still valid? |
heartbeat |
GET/POST | Extend session last_activity |
info |
GET | Detailed user + active-token data |
refresh |
POST | Extend session lifetime (session-based only) |
check() — Returns {"status":"active","logged_in":true,...} or {"status":"expired","logged_in":false}. For session auth, updates $_SESSION['last_activity'].
heartbeat() — Returns {"status":"ok","expires_in":<int>}. Returns HTTP 401 when not authenticated.
info() — Returns full user data, auth method, and a per-application token summary (grouped by app_name). For session auth, also includes session.started, session.last_activity, and session.max_lifetime.
refresh() — Returns HTTP 400 for Bearer-token clients (use the refresh_token grant instead). For session clients, updates last_activity and returns the new expires_in.
Bearer token validation reads from usertokens JOIN applications and verifies the JWT signature against <ROOT>/app/keys/public.key (RS256) or falls back to HS256 with the apikey as the HMAC secret.
39.3 TwoFactorAuth¶
Wraps Pramnos\Auth\TwoFactorAuthService and Pramnos\Auth\TOTPHelper.
All actions except display and test require login (addAuthAction).
| Action | HTTP | Description |
|---|---|---|
display |
GET | 2FA settings overview (HTML view: twofactor) |
setup |
GET/POST | Start setup / verify initial code |
disable |
POST | Deactivate 2FA (requires password confirmation) |
backup |
GET/POST | View / regenerate backup codes |
status |
GET | JSON status endpoint for AJAX |
test |
GET | Debug: generate fresh TOTP code (disable in production) |
Views are resolved via $this->getView('twofactor') and rendered with the named sub-view passed to $view->display($subview).
39.4 Gdpr¶
GDPR data-management endpoints. All actions are public-registered but enforce authentication internally via session or Bearer token. Admin users may target a different user_id by including it in the request body.
| Action | HTTP | Description |
|---|---|---|
request |
POST | Create a GDPR export/delete/portability request |
status |
GET | Query request status (?request_id=<id>) |
listRequests |
GET | Paginated request list |
deauthorizeAll |
POST | Revoke all tokens for a user |
notifyChange |
POST | Queue a profile_changed webhook event |
request() — Inserts a row into oauth2_gdpr_requests and queues a gdpr_request_created webhook event via WebhookService::queueEvent(). Valid types: export, delete, portability.
deauthorizeAll() — Sets status = 0 on all active usertokens rows for the target user, then queues a token_revoked event. Valid reasons: user_revoked, admin_revoked, gdpr_deletion, security_violation.
notifyChange() — Queues a profile_changed event with the supplied changes array (e.g. ["email","name"]).
Webhook events are delivered asynchronously by WebhookService::processQueue() (typically called from a cron/daemon).
Route registration example¶
Register all auth controller routes in a ServiceProvider::boot():
use Pramnos\Routing\Router;
class AuthServiceProvider extends \Pramnos\Application\ServiceProvider
{
public function boot(Router $router): void
{
// OpenID Connect discovery (public, no auth)
$router->get('/.well-known/openid-configuration', 'Pramnos\Auth\Controllers\Discovery@configuration');
$router->get('/.well-known/jwks.json', 'Pramnos\Auth\Controllers\Discovery@jwks');
$router->get('/.well-known/health', 'Pramnos\Auth\Controllers\Discovery@health');
// Session checks (Bearer-token or session cookie)
$router->get('/auth/session/check', 'Pramnos\Auth\Controllers\Session@check');
$router->get('/auth/session/info', 'Pramnos\Auth\Controllers\Session@info');
$router->match(['GET','POST'], '/auth/session/heartbeat', 'Pramnos\Auth\Controllers\Session@heartbeat');
$router->post('/auth/session/refresh', 'Pramnos\Auth\Controllers\Session@refresh');
// OAuth2 / OIDC server endpoints
$router->match(['GET','POST'], '/oauth/authorize', 'Pramnos\Auth\Controllers\Oauth@authorize');
$router->post('/oauth/token', 'Pramnos\Auth\Controllers\Oauth@token');
$router->post('/oauth/revoke', 'Pramnos\Auth\Controllers\Oauth@revoke');
$router->post('/oauth/introspect', 'Pramnos\Auth\Controllers\Oauth@introspect');
$router->match(['GET','POST'], '/oauth/userinfo', 'Pramnos\Auth\Controllers\Oauth@userinfo');
$router->post('/oauth/logout', 'Pramnos\Auth\Controllers\Oauth@logout');
$router->post('/oauth/device_authorization', 'Pramnos\Auth\Controllers\Oauth@deviceauthorization');
// Device flow verification
$router->match(['GET','POST'], '/oauth/device', 'Pramnos\Auth\Controllers\Device@display');
// Account dashboard (requires login)
$router->get('/account', 'Pramnos\Auth\Controllers\Dashboard@display');
$router->get('/account/applications', 'Pramnos\Auth\Controllers\Dashboard@applications');
$router->post('/account/revokeapplication', 'Pramnos\Auth\Controllers\Dashboard@revokeapplication');
$router->get('/account/exportdata', 'Pramnos\Auth\Controllers\Dashboard@exportdata');
$router->match(['GET','POST'], '/account/deleteaccount', 'Pramnos\Auth\Controllers\Dashboard@deleteaccount');
$router->match(['GET','POST'], '/account/privacy', 'Pramnos\Auth\Controllers\Dashboard@privacy');
$router->get('/account/security', 'Pramnos\Auth\Controllers\Dashboard@security');
$router->match(['GET','POST'], '/account/changepassword', 'Pramnos\Auth\Controllers\Dashboard@changepassword');
// 2FA management (requires login)
$router->match(['GET','POST'], '/account/2fa', 'Pramnos\Auth\Controllers\TwoFactorAuth@display');
$router->match(['GET','POST'], '/account/2fa/setup', 'Pramnos\Auth\Controllers\TwoFactorAuth@setup');
$router->post('/account/2fa/disable', 'Pramnos\Auth\Controllers\TwoFactorAuth@disable');
$router->match(['GET','POST'], '/account/2fa/backup', 'Pramnos\Auth\Controllers\TwoFactorAuth@backup');
// GDPR endpoints
$router->post('/gdpr/request', 'Pramnos\Auth\Controllers\Gdpr@request');
$router->get('/gdpr/status', 'Pramnos\Auth\Controllers\Gdpr@status');
$router->get('/gdpr/list', 'Pramnos\Auth\Controllers\Gdpr@listRequests');
$router->post('/gdpr/deauthorize', 'Pramnos\Auth\Controllers\Gdpr@deauthorizeAll');
$router->post('/gdpr/notify-change', 'Pramnos\Auth\Controllers\Gdpr@notifyChange');
}
}
40. Pramnos\Auth\Controllers\Oauth¶
Added: 2026-05-11 — ROADMAP item 271 (completamento)
Controller per il server OAuth2 / OpenID Connect. Usa league/oauth2-server (tramite OAuth2ServerFactory) per la generazione dei token e nyholm/psr7 come implementazione PSR-7.
Azioni pubbliche¶
| Azione | HTTP | Descrizione |
|---|---|---|
authorize |
GET/POST | Authorization endpoint (RFC 6749 §4.1) |
token |
POST | Token endpoint — tutti i grant types via League |
revoke |
POST | RFC 7009 token revocation |
introspect |
POST | RFC 7662 token introspection |
userinfo |
GET/POST | OIDC UserInfo (OpenID Connect Core §5.3) |
logout |
POST | Logout Bearer token + revoca sessione |
deviceauthorization |
POST | RFC 8628 device authorization |
Token endpoint¶
Delega interamente a AuthorizationServer::respondToAccessTokenRequest(). Il PSR-7 ServerRequest è costruito manualmente dai globali PHP ($_SERVER, $_POST, php://input) tramite Nyholm\Psr7\Factory\Psr17Factory.
Grant types supportati (configurati in OAuth2ServerFactory):
- authorization_code (con PKCE, 10 min code TTL, 1h access, 1 month refresh)
- client_credentials (1h access)
- password (1h access, 1 month refresh)
- refresh_token (1 month)
Authorization endpoint¶
Flusso manuale (senza League per questo endpoint):
1. Valida parametri (client_id, redirect_uri, response_type=code, PKCE)
2. Verifica login utente, redirect a /login se non autenticato
3. Auto-approva se l'utente ha già autorizzato l'app con scope uguali o superiori
4. Mostra form di consenso (view OAuth2 / sub-view authorize)
5. POST: registra consenso in oauth2_user_consents, genera auth code in usertokens, redirect
PKCE (RFC 7636)¶
code_challenge_method: S256 o plain. Per S256: validazione regex [A-Za-z0-9\-._~]{43,128}.
Device authorization (RFC 8628)¶
device_code = bin2hex(random_bytes(32)) (64 hex chars)
user_code = 8 char da alfabeto BCDFGHJKLMNPQRSTVWXZ (no cifre, no vocali) formato XXXX-XXXX
Salva in oauth2_device_codes. Polling interval: 5s. Scadenza: 600s.
Dipendenze aggiunte¶
nyholm/psr7: ^1.8 — aggiunto a composer.json.
HTTP examples (curl)¶
Authorization code flow:
# Step 1 — redirect user to authorization endpoint
GET /oauth/authorize?response_type=code
&client_id=my_app
&redirect_uri=https://app.example.com/callback
&scope=read:profile
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256
# Step 2 — exchange code for tokens
curl -X POST /oauth/token \
-d grant_type=authorization_code \
-d code=<auth_code> \
-d redirect_uri=https://app.example.com/callback \
-d client_id=my_app \
-d code_verifier=<plain_verifier>
Client credentials flow:
curl -X POST /oauth/token \
-u client_id:client_secret \
-d grant_type=client_credentials \
-d scope=read:users
Refresh token:
curl -X POST /oauth/token \
-d grant_type=refresh_token \
-d refresh_token=<token> \
-d client_id=my_app
Revoke a token:
UserInfo (OIDC):
curl /oauth/userinfo \
-H "Authorization: Bearer <access_token>"
# → {"sub":"42","name":"Alice","email":"alice@example.com",...}
41. Pramnos\Auth\Controllers\Device¶
Added: 2026-05-11 — ROADMAP item 271
Controller per la verifica del codice utente nel device flow (RFC 8628).
| Azione | Descrizione |
|---|---|
display (GET) |
Form di inserimento codice, o schermata di conferma se l'utente è già loggato |
display (POST, action=verify) |
Approva o nega la richiesta del device |
Sub-views HTML: display (form), confirmation, success, deny, errormessage.
Alla approvazione: UPDATE oauth2_device_codes status='authorized' + evento webhook device_authorized. Alla negazione: status='denied' + evento device_deauthorized. Entrambi via WebhookService::queueEvent().
HTTP / curl example (device flow)¶
# Step 1 — device requests codes
curl -X POST /oauth/device_authorization \
-d client_id=my_tv_app \
-d scope=read:profile
# → {"device_code":"...","user_code":"BCDF-GHJK","verification_uri":"/oauth/device","expires_in":600,"interval":5}
# Step 2 — user visits /oauth/device in a browser and enters user_code BCDF-GHJK
# (GET shows form, POST with action=verify approves or denies)
# Step 3 — device polls until approved
curl -X POST /oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=<device_code> \
-d client_id=my_tv_app
# → {"access_token":"...","token_type":"Bearer","expires_in":3600}
42. Pramnos\Auth\Controllers\Dashboard¶
Added: 2026-05-11 — ROADMAP item 271. QB migration + view templates: 2026-05-11.
Dashboard διαχείρισης λογαριασμού. Όλες οι actions απαιτούν σύνδεση (addAuthAction). Όλες οι private DB helper methods έχουν migrated σε QueryBuilder.
| Action | HTTP | Description |
|---|---|---|
display |
GET | Overview: authorized apps + recent activity |
applications |
GET | List apps with active tokens |
revokeapplication |
POST | Revoke app access (AJAX JSON or redirect) |
exportdata |
GET | Download personal data as JSON (GDPR Art. 20) |
deleteaccount |
GET/POST | Account deletion (GDPR Art. 17) |
privacy |
GET/POST | Privacy / consent settings |
security |
GET | Security overview + 2FA status |
changepassword |
GET/POST | Change password (policy: ≥8 chars, ≥1 digit, ≥1 symbol) |
revokeapplication sets usertokens.status = 3 (revoked, kept for audit trail) and deletes oauth2_user_consents. Supports AJAX (X-Requested-With: XMLHttpRequest) and standard form POST.
deleteaccount deletes rows from usertokens, oauth2_user_consents, user_activity_log, user_privacy_settings, user_twofactor, twofactor_setup, then users.
changepassword verifies bcrypt password, enforces policy, stores new hash via password_hash(..., PASSWORD_BCRYPT). The modified column is stored as Unix timestamp (time()).
View templates — scaffolding/themes/{bootstrap,tailwind,plain-css}/views/:
| Template | View dir | Action | Variables |
|---|---|---|---|
dashboard.html.php |
dashboard/ |
display |
user, authorizedApps[], recentActivity[], twoFactorEnabled |
authorized_applications.html.php |
OAuth2/ |
applications |
authorizedApps[] |
delete_account.html.php |
OAuth2/ |
deleteaccount |
— (errors via $_GET['error']) |
privacy_settings.html.php |
OAuth2/ |
privacy |
privacySettings = {analytics: bool, marketing: bool} |
security.html.php |
OAuth2/ |
security |
recentActivity[], twoFactorEnabled |
change_password.html.php |
OAuth2/ |
changepassword |
— (errors via $_GET['error']) |
authorizedApps[] fields: {appid, name, apikey, description, last_used, token_count}.
recentActivity[] fields: {action, created_at, ip_address, user_agent}.
Tests: tests/Characterization/Auth/DashboardCharacterizationTest.php — 8 integration tests (MySQL) covering all QB-migrated private helpers.
HTTP / JavaScript examples¶
Revoke an application (AJAX):
fetch('/account/revokeapplication', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
body: new URLSearchParams({ appid: 7 })
})
.then(r => r.json())
.then(data => {
if (data.success) removeAppFromList(7);
});
Export personal data (GDPR Art. 20):
curl /account/exportdata \
-H "Authorization: Bearer <access_token>"
# → JSON file download with all personal data
Change password:
curl -X POST /account/changepassword \
-H "Cookie: PHPSESSID=<session>" \
-d current_password=OldPass1! \
-d new_password=NewPass2@ \
-d confirm_password=NewPass2@
35. 2FA View Templates (Scaffolding)¶
Path: scaffolding/themes/{bootstrap,tailwind,plain-css}/views/twofactor/
Τρία έτοιμα HTML/PHP view templates για κάθε scaffolded theme, που χρησιμοποιούνται από τον TwoFactorAuth controller:
| File | Action | Περιγραφή |
|---|---|---|
twofactor.html.php |
display |
Overview σελίδα: status badge, αριθμός backup codes, link για Manage Backup Codes, disable 2FA modal |
setup.html.php |
setup |
3-βήμα flow: scan QR code (api.qrserver.com), save backup codes (one-time display), enter verify code form |
backup.html.php |
backup |
Remaining codes counter, new-codes panel (εμφανίζεται μόνο μετά regenerate), regenerate form με password confirmation |
Variables (available as $this->varname μέσα στο template):
| Template | Variable | Τύπος | Περιγραφή |
|---|---|---|---|
twofactor.html.php |
user |
User |
Current user object |
status |
array |
{enabled: bool, setup: bool, backup_codes_remaining: int} |
|
setup.html.php |
setupData |
array |
{secret, qr_code_url, manual_entry_key, backup_codes[]} |
user |
User |
Current user object | |
backup.html.php |
user |
User |
Current user object |
remainingCodes |
int |
Αριθμός εναπομεινάντων backup codes | |
newBackupCodes |
string[]|null |
Εμφανίζεται μόνο μετά από επιτυχή regeneration | |
success |
string|null |
Success message | |
error |
string|null |
Error message | |
setupComplete |
bool |
true όταν ο χρήστης μόλις ολοκλήρωσε το setup |
Χρήση: Αντιγράψτε τον φάκελο views/twofactor/ από το theme scaffolding στον views directory της εφαρμογής σας. Ο TwoFactorAuth controller αναζητά αυτά τα αρχεία μέσω του View system (getView('twofactor')->display() / display('setup') / display('backup')).
43. Phase 5 QA Coverage — Complete¶
Closed: 2026-05-12 (session 62)
Όλα τα Phase 5 QA Coverage items έχουν κλείσει. Σύνολο test suite: 1932 tests, 5363 assertions, 3 skipped (GD absent), 0 failures.
HTTP Layer Coverage (69 tests — pre-existing)¶
CsrfTest(20 tests): CSRF token generation, verification, regeneration, entropy, field + header token variants, 419 error responsesSessionSecurityTest: HMAC-SHA256 fingerprint, IP-aware variants, session token lifecycleRequestTest: GET/POST/PUT/DELETE/JSON parsing, cookie management, URL calculation, validation helpersSessionTest: session lifecycle, fingerprinting
Theme / View Layer Coverage¶
Asset enqueuing — DocumentTest (4 tests, tests/Unit/Pramnos/Document/DocumentTest.php):
- registerStyle / enqueueStyle → renderCss() outputs <link> tags
- registerScript / enqueueScript → renderJs() outputs head + footer <script> tags
- Dependency resolution: enqueuing a child style auto-enqueues its declared dependencies in the correct order
- processHeader() is idempotent — double-calling renderCss() does not duplicate output
Widget management — 6 new tests in ThemeCharacterizationTest:
- testAddWidgetToRegisteredAreaReturnsTrue — happy path: parse_str → stored in $widgets, returns true
- testAddWidgetToNonExistentAreaReturnsFalse — guard clause for unknown area
- testAddWidgetWithMissingWidgetIdReturnsFalse — guard clause for missing widgetId key
- testGetWidgetsWithNoFilterReturnsAll — all widgets regardless of area
- testGetWidgetsFilteredByAreaReturnsOnlyMatchingWidgets — per-area filter contract
- testAddWidgetDebugModeReturnsDescriptiveString — debug mode returns string with area + data
Email & Media Coverage¶
SMTP building — EmailCharacterizationTest (13 tests, pre-existing):
Fluent setter/getter contracts for setSubject, setBody, setTo (string + array), setFrom, setCc, setBcc, setDebug, addHeader; hasError / getLastError / getLastException error-state API.
Image pipeline — ResizeToolsCharacterizationTest (6 tests):
- testDefaultPropertyValues — all public properties have documented defaults (no GD needed)
- testMaxsizeExceededClampsThumbWToDefaultWidth — maxsize guard runs before GD; uses minimal PNG for getimagesize(), catches GD error if absent, verifies thumbW = defaultwidth
- testZeroDimensionsFallBackToDefaultWidth — zero-dimensions guard: !thumbW && !thumbH → thumbW = defaultwidth
- testResizeCreatesOutputFile (requires gd) — full pipeline: JPEG in → JPEG out, valid IMAGETYPE_JPEG, width ≤ requested
- testWidthOnlyResizeProducesProportionalOutput (requires gd) — width-only resize produces correct info[0]
- testExportFileNameEmbedsDimensions (requires gd) — exportfile contains -WxH. suffix
Coverage Reports / clover.xml fix¶
dockertest script updated: ./dockertest --coverage now passes both --coverage-html coverage and --coverage-clover coverage/clover.xml to ensure HTML and XML artifacts are regenerated in the same PHPUnit pass, eliminating the stale mtime issue.
44. Phase 6: PSR Compliance Layer¶
Closed: 2026-05-12 (session 63)
Four PSR standards are now directly supported by native framework classes, allowing any PSR-aware third-party library to integrate without adapter glue code.
PSR-3 Logger: PsrLogger¶
File: src/Pramnos/Logs/PsrLogger.php
Namespace: Pramnos\Logs
Implements: Psr\Log\LoggerInterface (via AbstractLogger)
Bridges the static Logger infrastructure to PSR-3. Each instance is bound to a named channel (log file).
// Via factory (preferred)
$log = Logger::channel('payments');
$log->error('Payment failed for order {orderId}', ['orderId' => 42]);
// Direct instantiation
$log = new PsrLogger('myapp');
$log->info('User {username} signed in', ['username' => 'alice']);
// Inject into PSR-3 aware library
$httpClient = new GuzzleHttp\Client(['logger' => Logger::channel('http')]);
Key behaviours:
- All 8 PSR-3 level constants are validated; an unknown level throws Psr\Log\InvalidArgumentException.
- {placeholder} tokens in the message are replaced from $context before writing.
- getChannel(): string returns the bound log file name.
- Logger::channel(string $file): PsrLogger is a static factory shortcut.
PSR-11 Container: Container¶
File: src/Pramnos/Application/Container.php
Namespace: Pramnos\Application
Implements: Psr\Container\ContainerInterface
IoC container with three registration strategies and automatic constructor-injection autowiring.
$c = new Container();
// Transient (new instance on every get)
$c->bind(LoggerInterface::class, PsrLogger::class);
// Singleton (one shared instance)
$c->singleton(Database::class, fn($c) => new Database(getenv('DB_DSN')));
// Pre-built object
$c->instance('config', $configObject);
// Resolve
$db = $c->get(Database::class);
$log = $c->make(PsrLogger::class, ['file' => 'audit']);
// Probe without resolving
if ($c->has(SomeService::class)) { ... }
Autowiring rules:
1. Type-hinted constructor parameters are resolved recursively via make().
2. Parameters with default values use the default when unresolvable.
3. Nullable parameters without a default receive null.
4. Unresolvable required parameters throw ContainerException.
Exception classes:
- NotFoundException — thrown by get() when no binding exists and the identifier is not an instantiable class. Implements Psr\Container\NotFoundExceptionInterface.
- ContainerException — wraps any other resolution failure. Implements Psr\Container\ContainerExceptionInterface.
PSR-16 SimpleCache: SimpleCache¶
File: src/Pramnos/Cache/SimpleCache.php
Namespace: Pramnos\Cache
Implements: Psr\SimpleCache\CacheInterface
Adapts the existing Cache class to the PSR-16 Simple Cache interface.
$cache = new SimpleCache(Cache::getInstance());
$cache->set('user:42', $userData, 3600); // int TTL
$cache->set('token', $jwt, new \DateInterval('PT30M')); // DateInterval TTL
$user = $cache->get('user:42', null); // default on miss
// Batch operations
$cache->setMultiple(['a' => 1, 'b' => 2]);
$values = $cache->getMultiple(['a', 'b']);
$cache->deleteMultiple(['a', 'b']);
$cache->has('user:42'); // bool
$cache->delete('user:42');
$cache->clear(); // wipe all
Key validation: PSR-16 reserved characters {}()/\@: and empty strings throw SimpleCacheInvalidArgumentException (implements Psr\SimpleCache\InvalidArgumentException).
TTL normalisation: null → driver default; int → seconds (clamped to ≥ 0); DateInterval → converted to integer seconds via DateTimeImmutable::diff.
PSR-7 / PSR-15 HTTP: ServerRequestCreator + Pipeline¶
Files:
- src/Pramnos/Http/Psr/ServerRequestCreator.php
- src/Pramnos/Http/Psr/Pipeline.php
Namespace: Pramnos\Http\Psr
ServerRequestCreator builds a Psr\Http\Message\ServerRequestInterface from PHP superglobals or a $_SERVER-style array. Uses nyholm/psr7-server when available, with a manual fallback for minimal environments.
// Production: build from current request
$request = ServerRequestCreator::fromGlobals();
// Tests: build from an array (no superglobals needed)
$request = ServerRequestCreator::fromServerParams([
'REQUEST_METHOD' => 'POST',
'REQUEST_URI' => '/api/users',
'HTTP_HOST' => 'example.com',
'HTTPS' => 'on',
]);
Pipeline is a FIFO, immutable PSR-15 middleware pipeline. It implements MiddlewareInterface itself, so pipelines can be composed.
$response = (new Pipeline())
->pipe(new AuthMiddleware())
->pipe(new RateLimitMiddleware())
->pipe(new CorsMiddleware())
->process($request, $finalHandler);
// Nested pipelines
$apiPipeline = (new Pipeline())->pipe(new JsonBodyParser());
$appPipeline = (new Pipeline())
->pipe(new LoggingMiddleware())
->pipe($apiPipeline); // Pipeline implements MiddlewareInterface
Immutability: pipe() returns a new clone; the original pipeline is unmodified. This makes it safe to build a base pipeline once and branch it per route.
Tests¶
| Class | Test file | Tests |
|---|---|---|
PsrLogger |
tests/Characterization/Logs/PsrLoggerCharacterizationTest.php |
8 |
Container + exceptions |
tests/Characterization/Application/ContainerCharacterizationTest.php |
11 |
SimpleCache + exception |
tests/Characterization/Cache/SimpleCacheCharacterizationTest.php |
12 |
ServerRequestCreator + Pipeline |
tests/Characterization/Http/PsrHttpCharacterizationTest.php |
11 |
45. Phase 9: Full ORM Layer¶
Closed: 2026-05-12 (session 63)
A complete ORM extension layer built on top of the existing Model class. Applications that do not use ORM features are completely unaffected — the existing Model class is unchanged.
Entry point: Extend OrmModel instead of Model:
class User extends OrmModel {
protected $_dbtable = 'users';
protected array $fillable = ['name', 'email'];
protected array $casts = ['is_admin' => 'bool', 'prefs' => 'json'];
protected bool $softDelete = true;
protected bool $timestamps = true;
public function posts(): HasMany {
return $this->hasMany(Post::class, 'user_id');
}
}
Mass Assignment (HasAttributes)¶
$user->fill(['name' => 'Alice', 'email' => 'alice@example.com']);
$user->isFillable('name'); // true
$user->isGuarded('admin'); // true
Properties are only accepted by fill() if listed in $fillable (or not listed in $guarded).
Casting (HasAttributes)¶
Declare $casts to automatically convert attribute types on read and write:
| Cast type | PHP type on read | Storage type |
|---|---|---|
int / integer |
int |
unchanged |
float / double |
float |
unchanged |
bool / boolean |
bool |
unchanged |
string |
string |
unchanged |
array / json |
array |
JSON string |
datetime / date |
DateTimeImmutable |
string |
timestamp |
int (Unix) |
int |
Accessors / Mutators (HasAttributes)¶
// Accessor — transforms value on read
public function getFullNameAttribute(string $value): string {
return strtoupper($value);
}
// Mutator — transforms value on write
public function setEmailAttribute(string $value): string {
return strtolower(trim($value));
}
Timestamps (HasTimestamps)¶
When $timestamps = true (default), _save() automatically sets:
- created_at on INSERT (only if currently null/empty)
- updated_at on every save
Override column names by redefining $createdAtColumn / $updatedAtColumn.
Soft Deletes (HasSoftDeletes)¶
$model->softDelete = true; // enable per-model
$model->delete(); // sets deleted_at instead of hard DELETE
$model->restore(); // clears deleted_at
$model->forceDelete(); // actual hard DELETE even if $softDelete = true
$model->trashed(); // bool: is this record soft-deleted?
// Query scope helpers (applied to _getList automatically)
$model->withTrashed(); // include soft-deleted in queries
$model->onlyTrashed(); // only soft-deleted records
Model Events (HasEvents)¶
// Register via observer object
class PostObserver {
public function creating(Post $post): void { /* validate */ }
public function created(Post $post): void { /* notify */ }
public function deleting(Post $post): bool { return false; } // cancel
}
Post::observe(new PostObserver());
// Register via callback
Post::on('updated', fn(Post $post) => Cache::forget("post:{$post->id}"));
// Remove all listeners (useful in test tearDown)
Post::flushEventListeners();
Events: creating, created, updating, updated, deleting, deleted.
Returning false from a before-event (creating, updating, deleting) cancels the operation.
Scopes (HasScopes)¶
Local scopes:
class Post extends OrmModel {
public function scopePublished(string $filter): string {
return $this->appendCondition($filter, 'status = "published"');
}
}
$posts = $post->applyScope('published')->_getList();
Global scopes (auto-applied to all queries):
Post::addGlobalScope('tenant', fn($f) => $f === '' ? 'tenant_id = 1' : "($f) AND (tenant_id = 1)");
Post::removeGlobalScope('tenant');
$model->withoutGlobalScope('tenant'); // disable for one query
Relationships (HasRelationships + Orm\Relations\)¶
class User extends OrmModel {
public function profile(): HasOne { return $this->hasOne(Profile::class, 'user_id'); }
public function posts(): HasMany { return $this->hasMany(Post::class, 'user_id'); }
}
class Post extends OrmModel {
public function author(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); }
public function tags(): BelongsToMany {
return $this->belongsToMany(Tag::class, 'post_tags', 'post_id', 'tag_id');
}
}
// Lazy loading via __get
$user->profile; // triggers HasOne query on first access
$user->posts; // triggers HasMany query
$post->author; // triggers BelongsTo query
// Eager loading (N+1 prevention)
$users = $user->with('posts')->_getList();
Collections (Orm\Collection)¶
OrmModel::getCollection() returns a typed Collection. Also returned by HasMany / BelongsToMany relations.
$users = $user->getCollection();
$admins = $users->filter(fn($u) => $u->is_admin);
$names = $users->pluck('name');
$byRole = $users->groupBy('role');
$sorted = $users->sortBy('name');
$first = $users->first();
$total = $users->count();
foreach ($users as $user) { /* IteratorAggregate */ }
json_encode($users); // JsonSerializable
File structure¶
src/Pramnos/Application/
├── OrmModel.php — main class (extends Model)
└── Orm/
├── Collection.php — typed result collection
├── Concerns/
│ ├── HasAttributes.php — casts, accessors/mutators, mass assignment
│ ├── HasTimestamps.php — auto created_at/updated_at
│ ├── HasSoftDeletes.php — deleted_at pattern
│ ├── HasEvents.php — creating/created/... lifecycle events
│ ├── HasScopes.php — local + global query scopes
│ └── HasRelationships.php — relationship definitions + eager loading
└── Relations/
├── Relation.php — abstract base
├── HasOne.php
├── HasMany.php
├── BelongsTo.php
└── BelongsToMany.php
Tests¶
46 characterization tests in tests/Characterization/Application/OrmModelCharacterizationTest.php covering all features above (pure unit tests, no DB required).
29 integration tests in tests/Integration/Application/OrmRelationsMySQLTest.php (MySQL) and 29 in OrmRelationsPostgreSQLTest.php (PostgreSQL/TimescaleDB) covering all four relation types (HasOne, HasMany, BelongsTo, BelongsToMany), OrmModel CRUD paths (_save/_load/_delete), event cancellation, and soft-delete behaviour against real databases.
Bug fixes included¶
Model::getFullTableName()changed fromprotectedtopublic— relation classes call it from outside the model class hierarchy.Model::getChanges()extended to detect fields stored in$_data(ORM dynamic fields) in addition to declared PHP properties, so UPDATE correctly tracks mutations on ORM models.
46. Phase 7 — Modern Routing Engine¶
Overview¶
Three additive enhancements on top of the existing Route / Router classes. All existing public APIs remain unchanged.
#[Route] Attribute — src/Pramnos/Routing/Attributes/Route.php¶
PHP 8 repeatable method-level attribute that declares a controller method as a route handler:
use Pramnos\Routing\Attributes\Route;
class UserController
{
#[Route('/users', methods: 'GET', name: 'users.index')]
#[Route('/users', methods: 'POST', name: 'users.store', permissions: ['write:users'])]
public function index(): string { … }
#[Route('/users/{id}', methods: ['PUT','PATCH'], name: 'users.update')]
public function update(): string { … }
}
Constructor parameters (all readonly):
| Parameter | Type | Default | Description |
|---|---|---|---|
$uri |
string |
— | URI pattern (supports {param} / {param?}) |
$methods |
string\|string[] |
'GET' |
One or more HTTP methods |
$name |
?string |
null |
Logical name for URL generation |
$permissions |
string[] |
[] |
Required permission scopes |
$middleware |
string[] |
[] |
FQCN middleware classes |
Named Routes — Route::name() + Router::route()¶
// Registration
$router->get('/users/{id}', fn($id) => ...)->name('users.show');
// URL generation
$url = $router->route('users.show', ['id' => 42]); // '/users/42'
// Optional parameter present
$router->get('/archive/{year}/{slug?}', fn() => ...)->name('archive');
$router->route('archive', ['year' => 2026, 'slug' => 'my-post']); // '/archive/2026/my-post'
// Optional parameter absent (stripped automatically)
$router->route('archive', ['year' => 2026]); // '/archive/2026'
// Lookup by name
$route = $router->getByName('users.show'); // Route|null
Parameter values are rawurlencode()-encoded. Throws \InvalidArgumentException for unknown names.
Implementation detail: Route::name() uses a closure callback (injected by Router::addSingleRoute()) to register in $router->namedRoutes[] at the moment name() is called — no circular class dependency.
Route Discovery — RouteDiscovery + Router::loadFromDirectory()¶
// Via Router convenience method:
$router->loadFromDirectory(
__DIR__ . '/Controllers',
'App\\Controllers'
);
// Or direct:
(new RouteDiscovery($router))->discover(
__DIR__ . '/Controllers',
'App\\Controllers'
);
discover() recursively scans every .php file, derives the FQCN from the path, requires the file, reflects on public methods, and registers any #[Route]-annotated methods. Subdirectories are traversed automatically.
Also adds Router::head() shortcut (mirrors get(), post(), etc.) to support methods: 'HEAD' in attributes.
File structure¶
src/Pramnos/Routing/
├── Attributes/
│ └── Route.php — PHP 8 #[Attribute] class
├── Route.php — +name(), +getName(), +setNameRegistrationCallback()
├── Router.php — +namedRoutes[], +route(), +getByName(), +buildUrl(), +head(), +loadFromDirectory()
└── RouteDiscovery.php — recursive directory scanner
Tests¶
26 characterization tests in tests/Characterization/Routing/RoutingCharacterizationTest.php with fixture controllers in tests/Characterization/Routing/Fixtures/.
47. Phase 10 — File Storage Abstraction (Pramnos\Storage\)¶
Problem: The framework had Pramnos\Filesystem\Filesystem for local file ops, but no unified abstraction for writing to S3, FTP, or swapping disks in tests. Apps that needed cloud storage had to wire AWS/FTP SDKs directly.
Solution: A new Pramnos\Storage\ namespace — zero breaking changes to existing code. Pramnos\Filesystem\Filesystem is untouched; LocalDriver delegates to it internally.
StorageInterface¶
20 methods covering all common storage operations:
interface StorageInterface {
// Read
public function get(string $path): string;
public function readStream(string $path); // returns resource
// Write
public function put(string $path, $contents, array $options = []): bool;
public function prepend(string $path, string $data): bool;
public function append(string $path, string $data): bool;
// Existence / metadata
public function exists(string $path): bool;
public function missing(string $path): bool;
public function size(string $path): int;
public function lastModified(string $path): int;
public function mimeType(string $path): string|false;
// Operations
public function delete(string|array $paths): bool;
public function move(string $from, string $to): bool;
public function copy(string $from, string $to): bool;
// Directories
public function files(string $directory = ''): array;
public function allFiles(string $directory = ''): array;
public function directories(string $directory = ''): array;
public function makeDirectory(string $path): bool;
public function deleteDirectory(string $path): bool;
// URLs
public function url(string $path): string;
public function temporaryUrl(string $path, \DateTimeInterface $expiration, array $options = []): string;
}
Drivers¶
| Driver | Class | Requires |
|---|---|---|
| Local | Drivers\LocalDriver |
nothing (delegates to Filesystem) |
| Amazon S3 | Drivers\S3Driver |
aws/aws-sdk-php (optional, runtime guard) |
| FTP | Drivers\FtpDriver |
ext-ftp (optional, runtime guard) |
Storage façade — bootstrap¶
// In your ServiceProvider or bootstrap file
use Pramnos\Storage\Storage;
Storage::init([
'default' => 'local',
'disks' => [
'local' => ['driver' => 'local', 'root' => ROOT . '/storage/app'],
'public' => ['driver' => 'local', 'root' => ROOT . '/www/uploads', 'url' => '/uploads'],
's3' => [
'driver' => 's3',
'key' => env('AWS_KEY'),
'secret' => env('AWS_SECRET'),
'bucket' => env('AWS_BUCKET'),
'region' => env('AWS_REGION'),
],
'ftp' => [
'driver' => 'ftp',
'host' => env('FTP_HOST'),
'username' => env('FTP_USER'),
'password' => env('FTP_PASS'),
'root' => '/public_html/uploads',
],
],
]);
Basic usage¶
use Pramnos\Storage\Storage;
// Write
Storage::put('invoices/2026-001.pdf', $pdfContent);
Storage::append('logs/app.log', date('c') . " Request\n");
// Read
$content = Storage::get('invoices/2026-001.pdf');
$stream = Storage::readStream('videos/intro.mp4');
// Check
if (Storage::exists('config/override.json')) { ... }
$size = Storage::size('exports/report.csv');
// Delete / move / copy
Storage::delete('tmp/upload_1234.tmp');
Storage::move('uploads/tmp_img.jpg', 'images/avatar_42.jpg');
Storage::copy('templates/base.html', 'pages/new.html');
// Named disk
Storage::disk('s3')->put('backups/db.sql.gz', $stream);
$signedUrl = Storage::disk('s3')->temporaryUrl('reports/q1.pdf', new \DateTime('+1 hour'));
// URL
$url = Storage::disk('public')->url('avatars/alice.jpg'); // → /uploads/avatars/alice.jpg
// Directory listing
$files = Storage::allFiles('invoices/2026'); // recursive
$dirs = Storage::directories('projects');
LocalDriver delegates to Filesystem¶
LocalDriver uses Pramnos\Filesystem\Filesystem internally for directory operations, maintaining a single implementation:
// deleteDirectory() delegates to Filesystem::destroyDirectory()
// allFiles() delegates to Filesystem::listDirectoryFiles()
// copy() on a directory delegates to Filesystem::recurseCopy()
// delete() delegates to Filesystem::removeFile()
Testing — inject mock¶
use Pramnos\Storage\Storage;
use Pramnos\Storage\StorageManager;
$mock = $this->createMock(\Pramnos\Storage\StorageInterface::class);
$mock->method('exists')->willReturn(true);
$manager = new StorageManager(['default' => 'mock', 'disks' => []]);
$manager->extend('mock', $mock);
Storage::setManager($manager);
File structure¶
src/Pramnos/Storage/
├── StorageInterface.php — 20-method contract
├── StorageManager.php — factory + registry (lazy disk creation)
├── Storage.php — static façade
└── Drivers/
├── LocalDriver.php — delegates to Filesystem for dir ops
├── S3Driver.php — optional AWS SDK
└── FtpDriver.php — optional ext-ftp
Tests¶
37 characterization tests in tests/Characterization/Storage/StorageCharacterizationTest.php.
48. Validation Layer — Extended Rules & Custom Rules¶
Problem: Pramnos\Validation\Validator existed with a working core (required, email, min, max, in, between, url, json, csrf) but lacked many rules that application code needs daily: string format checks, date comparisons, cross-field confirmation, conditional required logic, and a way to register project-specific rules without forking the class.
Solution: 22 new rules added across four categories, plus a custom rule registry (Validator::extend()) and a RuleInterface for reusable rule objects. All existing behaviour is unchanged.
New common rules¶
| Rule | Description |
|---|---|
alpha |
Only Unicode letters (\pL\pM — accented characters pass) |
alpha_num |
Letters and digits (\pL\pM\pN) |
digits:n |
Exactly n digit characters (no letters, no floats) |
regex:/pattern/ |
Matches a PCRE pattern |
ip |
Valid IPv4 or IPv6 address |
uuid |
RFC 4122 UUID (case-insensitive) |
not_in:v1,v2,... |
Value must NOT be in the list |
starts_with:prefix |
String must start with prefix |
ends_with:suffix |
String must end with suffix |
array |
Value must be a PHP array |
size:n |
Exact length (string), count (array), or numeric equality |
confirmed |
Field must match a <field>_confirmation key in the same data |
$request->validate([
'username' => 'required|alpha_num|min:3|max:20|not_in:admin,root',
'password' => 'required|min:8|confirmed', // expects 'password_confirmation'
'pin' => 'required|digits:4',
'ip_addr' => 'nullable|ip',
'device_id' => 'nullable|uuid',
'postcode' => 'regex:/^\d{5}$/',
'tags' => 'array|max:10',
'plan' => 'required|size:3', // exactly 3 chars: 'pro', 'ent', etc.
]);
Date rules¶
| Rule | Description |
|---|---|
date |
Any string parseable by strtotime() |
date_format:format |
Strict PHP date format match (e.g. Y-m-d) |
before:date |
Must be before the given date |
before_or_equal:date |
Must be on or before the given date |
after:date |
Must be after the given date |
after_or_equal:date |
Must be on or after the given date |
The threshold date accepts any strtotime()-parseable string, including relative values (today, +1 year).
$request->validate([
'start_date' => 'required|date_format:Y-m-d|after:today',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'birth_date' => 'required|date|before:today',
]);
Note:
after_or_equal:start_datecompares the string"start_date"as astrtotime()argument, not the value of thestart_datefield. For cross-field date comparison, use a custom rule (see below).
Conditional required rules¶
These rules make a field required only when specific conditions are met. They are handled before the per-rule validation loop and generate the same error message as plain required.
| Rule | Required when… |
|---|---|
sometimes |
Never runs if the field is absent — useful for PATCH endpoints |
required_if:other,value |
$data['other'] === 'value' |
required_unless:other,value |
$data['other'] !== 'value' |
required_with:f1,f2,... |
Any of the listed fields is present and non-empty |
required_without:f1,f2,... |
Any of the listed fields is absent or empty |
// PATCH endpoint: only validate fields that were actually submitted
$request->validate([
'email' => 'sometimes|required|email',
'name' => 'sometimes|required|string|min:2',
]);
// Billing address required only when paying by invoice
$request->validate([
'payment_method' => 'required|in:card,invoice',
'billing_address' => 'required_if:payment_method,invoice',
]);
// Tax ID required for all account types except 'individual'
$request->validate([
'account_type' => 'required',
'tax_id' => 'required_unless:account_type,individual|string',
]);
// City required when street is provided
$request->validate([
'street' => 'sometimes|string',
'city' => 'required_with:street|string',
]);
// At least one contact method must be supplied
$request->validate([
'email' => 'sometimes|email',
'phone' => 'required_without:email|string',
]);
Custom rules¶
Via callable — inline one-off rules¶
Validator::extend('no_reserved', function (string $attr, mixed $value, array $params): bool {
$reserved = $params ?: ['admin', 'root', 'system'];
return !in_array(strtolower((string) $value), $reserved, true);
});
$request->validate([
'username' => 'required|alpha_num|no_reserved:admin,root,guest',
]);
Callable signature: fn(string $attribute, mixed $value, array $parameters): bool
Via RuleInterface — reusable domain rules¶
use Pramnos\Validation\RuleInterface;
class UniqueEmailRule implements RuleInterface
{
public function __construct(private \Pramnos\Database\Database $db, private ?int $excludeId = null) {}
public function passes(string $attribute, mixed $value): bool
{
$sql = "SELECT COUNT(*) AS cnt FROM users WHERE email = %s";
if ($this->excludeId !== null) {
$sql .= " AND id != " . (int) $this->excludeId;
}
$result = $this->db->query($this->db->prepareQuery($sql, $value));
return (int) ($result->fields['cnt'] ?? 1) === 0;
}
public function message(): string
{
return 'The :attribute has already been taken.';
}
}
Inline (per-request):
Registered globally (bootstrapping):
// In a service provider or bootstrap file
Validator::extend('unique_email', new UniqueEmailRule($db));
// In a controller
$request->validate(['email' => 'required|email|unique_email']);
API summary¶
| Method | Description |
|---|---|
Validator::validate(array $data, array $rules, array $messages = [], array $attributes = []): array |
Validate data; returns the validated subset; throws ValidationException on failure |
Validator::extend(string $name, callable\|RuleInterface $rule): void |
Register a custom rule globally |
ValidationException::errors(): array |
Returns ['field' => ['message1', ...]] |
ValidationException::getStatus(): int |
Returns 422 |
$request->validate(...) |
Delegates to Validator::validate($request->all(), ...) |
$request->errors() |
Flashed errors from a previous redirect |
$request->old($key) |
Old input from a previous redirect |
49. HTTP Client — Pramnos\Http\Client¶
A fluent, zero-dependency HTTP client built on ext-curl. Supports one-off static calls, shared-config instance usage, automatic retries with exponential backoff, and a fake-response system for tests.
Classes¶
| Class | Description |
|---|---|
Pramnos\Http\Client |
Fluent builder and sender |
Pramnos\Http\ClientResponse |
Immutable response value object |
Pramnos\Http\ClientException |
Thrown on transport failures (not 4xx/5xx) |
Static one-off requests¶
use Pramnos\Http\Client;
$response = Client::get('https://api.example.com/users')
->bearerToken($token)
->timeout(10)
->send();
if ($response->ok()) {
$users = $response->json();
}
Shared-config instance (recommended for multiple calls)¶
$api = (new Client('https://api.example.com'))->bearerToken($token);
$users = $api->make('GET', '/users')->send()->json();
$orders = $api->make('POST', '/orders')->json(['status' => 'open'])->send()->json();
POST with JSON body¶
$response = Client::post('https://api.example.com/users')
->json(['name' => 'Alice', 'email' => 'alice@example.com'])
->send()
->throw(); // throws ClientException on 4xx/5xx
POST with form body¶
$response = Client::post('https://api.example.com/login')
->form(['username' => 'alice', 'password' => 'secret'])
->send();
Retries with exponential backoff¶
$response = Client::get('https://api.example.com/status')
->retry(3, 200) // up to 3 retries, starting at 200 ms
->send();
Retries fire on connection errors and 5xx responses. 4xx responses are never retried.
Delay between attempts: delayMs × 2^(attempt−1) (200 ms → 400 ms → 800 ms).
throwOnError()¶
// Throws ClientException on 4xx or 5xx instead of returning the response
$data = Client::get('https://api.example.com/resource')
->throwOnError()
->send()
->json();
Equivalent to calling $response->throw() manually after send().
Response inspection¶
$response = Client::get('https://api.example.com/users')->send();
$response->status(); // int — HTTP status code
$response->ok(); // bool — 2xx
$response->successful(); // alias for ok()
$response->failed(); // bool — 4xx or 5xx
$response->clientError(); // bool — 4xx
$response->serverError(); // bool — 5xx
$response->redirect(); // bool — 3xx
$response->body(); // string — raw body
$response->json(); // mixed — decoded JSON
$response->json('user.email'); // dot-notation pluck from JSON
$response->header('content-type'); // string — case-insensitive header
$response->headers(); // array<string, string> — all headers
Testing with fakes¶
use Pramnos\Http\Client;
use Pramnos\Http\ClientResponse;
// Register fake responses before exercising the code under test
Client::fake([
'https://api.example.com/users' => ClientResponse::make(['id' => 1, 'name' => 'Alice'], 200),
'https://api.example.com/errors' => ClientResponse::make('Internal error', 500),
'https://api.example.com/*' => ClientResponse::make(['error' => 'not found'], 404),
]);
// ... run code that calls Client::get(...)->send() ...
// Always clean up in tearDown()
Client::resetFakes();
Callable fakes allow simulating transient failures:
$attempt = 0;
Client::fake([
'https://api.example.com/flaky' => function (Client $req) use (&$attempt): ClientResponse {
$attempt++;
return $attempt < 3
? ClientResponse::make('error', 503)
: ClientResponse::make(['ok' => true], 200);
},
]);
ClientResponse::make()¶
// String body
ClientResponse::make('Hello world', 200);
// Array → auto-JSON-encoded + content-type: application/json
ClientResponse::make(['id' => 1, 'name' => 'Alice'], 200);
// With custom headers
ClientResponse::make('', 204, ['x-request-id' => 'abc123']);
ClientException¶
Thrown when a transport-level failure occurs (connection refused, DNS error, timeout, SSL). Not thrown for 4xx/5xx responses.
try {
$response = Client::get('https://unreachable.example.com')->send();
} catch (\Pramnos\Http\ClientException $e) {
$curlErrno = $e->getCurlErrno(); // libcurl CURLE_* value, or 0
$message = $e->getMessage();
}
API summary¶
| Method | Description |
|---|---|
Client::get(string $url): static |
Static factory — one-off GET |
Client::post(string $url): static |
Static factory — one-off POST |
Client::put(string $url): static |
Static factory — one-off PUT |
Client::patch(string $url): static |
Static factory — one-off PATCH |
Client::delete(string $url): static |
Static factory — one-off DELETE |
Client::head(string $url): static |
Static factory — one-off HEAD |
(new Client($baseUrl))->make(string $method, string $path): static |
Instance factory — inherits base URL and auth |
->header(string $name, string $value): static |
Set a request header |
->headers(array $headers): static |
Merge multiple headers |
->bearerToken(string $token): static |
Authorization: Bearer |
->basicAuth(string $user, string $pass): static |
Authorization: Basic |
->json(array\|object $data): static |
JSON body + Content-Type |
->form(array $data): static |
URL-encoded form body |
->body(string $body, string $contentType): static |
Raw body |
->timeout(int $seconds): static |
Total request timeout |
->connectTimeout(int $seconds): static |
TCP connection timeout |
->retry(int $times, int $delayMs = 100): static |
Retry on error/5xx |
->withoutSslVerification(): static |
Disable SSL (dev only) |
->throwOnError(): static |
Throw on 4xx/5xx |
->send(): ClientResponse |
Execute and return response |
Client::fake(array $responses): void |
Register test fakes |
Client::resetFakes(): void |
Remove all fakes |
ClientResponse::make($body, $status, $headers): static |
Factory for fakes |
50. Faker — Pramnos\Support\Faker¶
A zero-dependency Faker generator built into the framework. No external packages required. Provides all common data-generation methods out of the box, with a full Greek locale provider (el_GR) for addresses, names, VAT numbers, ΑΜΚΑ, phone numbers, and more.
Classes¶
| Class | Description |
|---|---|
Pramnos\Support\Faker |
Main generator — dispatches calls to providers |
Pramnos\Support\FakerProvider |
Abstract base for all providers |
Pramnos\Support\FakerBaseProvider |
Generic methods (lorem, names, internet, numbers, dates) |
Pramnos\Support\FakerGrProvider |
Greek locale (extends BaseProvider) |
Pramnos\Support\FakerUniqueProxy |
Ensures unique values per method |
Creating a generator¶
use Pramnos\Support\Faker;
$faker = Faker::create(); // el_GR by default
$faker = Faker::create('el_GR'); // explicit Greek
$faker = Faker::create('en_US'); // English
Available methods (all locales)¶
Lorem ipsum / text
$faker->word() // single word
$faker->words(3) // list of 3 words
$faker->sentence(6) // sentence of ~6 words
$faker->sentences(3) // list of 3 sentences
$faker->paragraph(3) // paragraph of ~3 sentences
$faker->text(200) // text up to ~200 characters
Names & internet
$faker->firstName() // "Alice" / "Νίκος" (el_GR)
$faker->firstName('female') // gender hint
$faker->lastName() // surname
$faker->name() // full name
$faker->userName() // "alice.smith_42"
$faker->safeEmail() // "alice.smith@example.com"
$faker->email() // includes real-looking domains
$faker->url() // "https://example.com/path"
$faker->slug(3) // "foo-bar-baz"
$faker->ipv4() // "192.168.1.42"
$faker->password(8, 20) // random password
Numbers & booleans
$faker->numberBetween(1, 100) // int in [1, 100]
$faker->randomFloat(2, 0.0, 100.0) // float with 2 decimals
$faker->randomNumber(5) // 5-digit number
$faker->randomDigit() // 0–9
$faker->randomDigitNotZero() // 1–9
$faker->randomLetter() // 'a'–'z'
$faker->randomElement(['a', 'b']) // one element at random
$faker->boolean(70) // true 70% of the time
Dates & times
$faker->unixTime() // int timestamp up to now
$faker->dateTime() // \DateTime up to now
$faker->dateTimeBetween('-1 year', 'now')
$faker->date('Y-m-d') // string date
$faker->time('H:i:s') // string time
$faker->year(1970, 2025)
Formatters
$faker->numerify('###-###') // "412-879"
$faker->lexify('????') // "abcd"
$faker->bothify('## ??') // "42 xy"
$faker->uuid() // RFC 4122 v4 UUID
Greek locale — additional methods (el_GR)¶
$faker->city() // "Θεσσαλονίκη"
$faker->streetName() // "Εγνατία"
$faker->streetAddress() // "Εγνατία 42"
$faker->postcode() // "54626"
$faker->address() // full Greek address
$faker->region() // "Κεντρική Μακεδονία"
$faker->phoneNumber() // "2310 123456"
$faker->mobileNumber() // "6971234567"
$faker->vatNumber() // 9-digit ΑΦΜ
$faker->amka() // 11-char ΑΜΚΑ (DDMMYYXXXXX format)
unique()¶
// Returns a different email on every call
$faker->unique()->safeEmail();
// Reset all tracked unique values
$faker->unique(true)->safeEmail();
Property-style access¶
$name = $faker->name; // equivalent to $faker->name()
$uuid = $faker->uuid; // equivalent to $faker->uuid()
Custom providers¶
class MyProvider extends \Pramnos\Support\FakerProvider
{
public function sku(): string
{
return 'SKU-' . strtoupper(substr(md5(rand()), 0, 8));
}
}
$faker = Faker::create();
$faker->addProvider(new MyProvider($faker));
echo $faker->sku(); // "SKU-A3F9BC12"
The last-registered provider wins when multiple providers define the same method name.
API summary¶
| Method | Description |
|---|---|
Faker::create(string $locale = 'el_GR'): static |
Create a configured generator |
->addProvider(FakerProvider $provider): static |
Register additional provider |
->getProviders(): array |
Registered providers (last-registered first) |
->unique(bool $reset = false): FakerUniqueProxy |
Unique-value proxy |
->__call(string $name, array $args): mixed |
Dispatch to providers |
->__get(string $name): mixed |
Property-style method call |
51. Factory — Pramnos\Database\Factory¶
Abstract base for model data factories. Used to generate in-memory test data or seed the database with deterministic or Faker-powered records. Designed with immutable fluent chaining so a single factory can be branched into multiple variants without mutation.
Defining a factory¶
use Pramnos\Database\Factory;
class UserFactory extends Factory
{
protected string $table = 'users';
public function definition(): array
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'role' => 'user',
'active' => true,
'created_at' => $this->faker->date('Y-m-d H:i:s'),
];
}
// Named state helper
public function admin(): static
{
return $this->state(['role' => 'admin']);
}
}
Generating in-memory data (no DB)¶
// Single record — returns flat array
$user = UserFactory::new()->make();
// Multiple records — returns list of arrays
$users = UserFactory::new()->count(5)->make();
// With per-call overrides
$admin = UserFactory::new()->make(['role' => 'admin']);
// Via a named state method
$admin = UserFactory::new()->admin()->make();
Inserting into the database¶
// Single insert — returns flat array
$user = UserFactory::new()->create();
// Multiple inserts — returns list of arrays
$users = UserFactory::new()->count(10)->create();
// Overrides applied to each row
$user = UserFactory::new()->create(['email' => 'fixed@example.com']);
state() — attribute overrides¶
// Array state — merged over definition
$admin = UserFactory::new()->state(['role' => 'admin', 'active' => true]);
// Callable state — receives current attrs + Faker instance
$factory = UserFactory::new()->state(function (array $attrs, Faker $faker): array {
return ['slug' => strtolower(str_replace(' ', '-', $attrs['name']))];
});
// Chained states — applied left to right (last wins)
$factory = UserFactory::new()
->state(['role' => 'moderator'])
->state(['role' => 'admin']); // 'admin' wins
state() is non-destructive — returns a new instance, original unchanged.
count()¶
count() is non-destructive. count(1) returns a flat array (same as the default). count(0) or negative is clamped to 1.
sequence() — cycling attribute sets¶
// Alternating roles: admin, user, admin, user ...
$users = UserFactory::new()
->count(4)
->sequence(['role' => 'admin'], ['role' => 'user'])
->make();
// More sets than records — stops at record count
$users = UserFactory::new()
->count(2)
->sequence(['color' => 'blue'], ['color' => 'green'], ['color' => 'red'])
->make();
// Result: blue, green
Locale¶
// Greek Faker (default)
$factory = UserFactory::new('el_GR');
// English
$factory = UserFactory::new('en_US');
The locale is forwarded to Faker::create() internally.
Testing without a live DB¶
Override insertRow() in a test subclass to capture writes without a real connection:
$factory = new class extends UserFactory {
public array $inserted = [];
protected function insertRow(array $data): void
{
$this->inserted[] = $data;
}
};
$factory->count(3)->create();
// $factory->inserted → 3 rows
API summary¶
| Method | Description |
|---|---|
Factory::new(?string $locale = null): static |
Static named constructor |
->definition(): array |
Abstract — return the attribute blueprint |
->count(int $n): static |
Clone with count set to n (min 1) |
->state(array\|callable $state): static |
Clone with state added |
->sequence(...$sets): static |
Clone with cycling sequence |
->make(array $overrides = []): array |
Generate records in memory |
->create(array $overrides = []): array |
Generate and insert into DB |
$this->faker |
Pramnos\Support\Faker instance available in definition() |
52. Seeder — Pramnos\Database\Seeder (updated)¶
The Seeder base class has been updated for v1.2 to integrate tightly with Factory and provide a composable seeder system. Seeders populate the database with deterministic or factory-generated data for development and testing.
Defining a seeder¶
use Pramnos\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call(UserSeeder::class);
$this->call(PostSeeder::class);
}
}
class UserSeeder extends Seeder
{
public function run(): void
{
// 20 random users via factory
$this->factory(UserFactory::class)->count(20)->create();
// Fixed admin user
$this->factory(UserFactory::class)
->state(['role' => 'admin', 'email' => 'admin@example.com'])
->create();
// Direct insert for data that has no factory
$this->insert('settings', ['key' => 'site_name', 'value' => 'Demo']);
}
}
factory()¶
$this->factory(UserFactory::class) // default locale (el_GR)
$this->factory(UserFactory::class, 'en_US') // explicit locale
Returns a new Factory instance. Supports the full fluent Factory API:
->count(), ->state(), ->sequence(), ->make(), ->create().
Throws InvalidArgumentException if the given class is not a Factory subclass.
insert()¶
$this->insert('settings', ['key' => 'site_name', 'value' => 'Demo']);
$this->insert('#PREFIX#posts', ['title' => 'Hello', 'body' => 'World']);
Inserts a single row via Database::getInstance()->insertDataToTable(). The table name may include the #PREFIX# placeholder.
call()¶
Instantiates and runs another seeder. Useful for composing a top-level DatabaseSeeder from smaller, focused seeders without repeating setup logic.
Throws InvalidArgumentException if the given class is not a Seeder subclass.
Testing seeders¶
Because insert() is protected, test subclasses can override it to capture writes without a live database:
$seeder = new class extends UserSeeder {
public array $inserted = [];
public function insert(string $table, array $data): void
{
$this->inserted[] = ['table' => $table, 'data' => $data];
}
};
$seeder->run();
// assert on $seeder->inserted
API summary¶
| Method | Description |
|---|---|
run(): void |
Abstract — implement seeding logic here |
factory(string $factoryClass, ?string $locale = null): Factory |
Create a Factory instance |
insert(string $table, array $data): void |
Insert one row into the database |
call(string $seederClass): void |
Instantiate and run another seeder |
53. Template Engine — Blade-inspired directives for views¶
PramnosFramework 1.2 adds a lightweight template-compilation layer to the existing View system. The design principle is zero breaking changes: all existing .html.php templates continue to work exactly as before.
Two template file types¶
| Extension | Compiled? | Syntax |
|---|---|---|
.html.php |
No — included directly | Plain PHP (<?php echo $this->e($var); ?>) |
.tpl.php |
Yes — via TemplateCompiler |
Blade-style directives ({{ $var }}, @if, @extends, …) |
Both file types run inside the same View object, so $this->section(), $this->yield(), etc. are available in both.
Template inheritance¶
Child template (page.tpl.php):
@extends('layouts/main')
@section('title')My Page Title@endsection
@section('content')
<h1>{{ $title }}</h1>
@foreach($items as $item)
<li>{{ $item }}</li>
@endforeach
@endsection
Layout template (layouts/main.html.php or layouts/main.tpl.php):
<!DOCTYPE html>
<html>
<head><title><?php echo $this->yield('title', 'Default Title'); ?></title></head>
<body>
<?php echo $this->yield('content'); ?>
</body>
</html>
Directives reference¶
| Directive | Compiles to |
|---|---|
{{ $expr }} |
<?php echo e($expr); ?> — HTML-escaped |
{!! $expr !!} |
<?php echo $expr; ?> — raw (unescaped) |
{{-- comment --}} |
(stripped entirely) |
@extends('path') |
<?php $this->layout('path'); ?> |
@section('name') |
<?php $this->section('name'); ?> |
@endsection / @stop |
<?php $this->endsection(); ?> |
@yield('name') |
<?php echo $this->yield('name'); ?> |
@yield('name', 'default') |
<?php echo $this->yield('name', 'default'); ?> |
@include('partial') |
<?php $this->insert('partial'); ?> |
@include('partial', $data) |
<?php $this->insert('partial', $data); ?> |
@if(expr) |
<?php if(expr): ?> |
@elseif(expr) |
<?php elseif(expr): ?> |
@else |
<?php else: ?> |
@endif |
<?php endif; ?> |
@foreach(expr) |
<?php foreach(expr): ?> |
@endforeach |
<?php endforeach; ?> |
@for(expr) |
<?php for(expr): ?> |
@endfor |
<?php endfor; ?> |
@while(expr) |
<?php while(expr): ?> |
@endwhile |
<?php endwhile; ?> |
@isset($var) |
<?php if(isset($var)): ?> |
@endisset |
<?php endif; ?> |
@empty($var) |
<?php if(empty($var)): ?> |
@endempty |
<?php endif; ?> |
@php … @endphp |
Raw PHP block |
View methods (usable in both .html.php and .tpl.php)¶
// Declare parent layout (child templates only)
$this->layout('layouts/main');
// Capture a named section
$this->section('content');
echo '<h1>Hello</h1>';
$this->endsection();
// Output a section in a layout (with optional default)
echo $this->yield('content');
echo $this->yield('sidebar', '<aside>Default</aside>');
// Include a sub-template (partial)
$this->insert('partials/card', ['item' => $item]);
Compiled template cache¶
.tpl.php files are compiled once and cached in ROOT/var/viewcache/. The cache is invalidated automatically when the source file's mtime changes. The cache directory can be overridden at bootstrap:
New classes¶
Pramnos\Application\Template\TemplateCompiler¶
Pure string transformer — no I/O, stateless, reusable.
| Method | Description |
|---|---|
compile(string $source): string |
Compile a .tpl.php source string to executable PHP |
Pramnos\Application\Template\TemplateCache¶
File-based cache with mtime invalidation.
| Method | Description |
|---|---|
__construct(string $cacheDir = '') |
'' = use ROOT/var/viewcache or sys_temp_dir fallback |
resolve(string $sourcePath, callable $compiler): string |
Return compiled path; recompile if stale |
getCachedPath(string $sourcePath): string |
Compute the cache path (sha1 key) |
isUpToDate(string $sourcePath): bool |
True if cached file is at least as new as source |
store(string $sourcePath, string $compiled): string |
Write compiled PHP to cache |
flush(): void |
Delete all compiled files |
getCacheDir(): string |
Return configured cache directory |
setCacheDir(string $dir): void |
Override cache directory after construction |
New View methods¶
| Method | Description |
|---|---|
layout(string $layoutName): void |
Declare parent layout for this template |
section(string $name): void |
Start capturing output into a named section |
endsection(): void |
Close the current section |
yield(string $name, string $default = ''): string |
Return a section's captured content |
insert(string $template, array $data = []): void |
Include a sub-template |
static setTemplateCacheDir(string $dir): void |
Override the cache directory globally |
static getTemplateCacheDir(): string |
Return the configured cache directory |
PF-9 — Native output caching for views¶
withCache() and cache() add a fluent, one-shot output-caching layer on top of getTpl().
withCache(int $ttl = 3600, ?string $key = null): static¶
Marks the next getTpl() / display() call to be read from (and written to) the Cache::getInstance('views') adapter. The TTL and key are consumed atomically — they reset to null after the first render so repeated calls without withCache() render fresh.
// Cache for 1 hour with an auto-generated key (view name + tpl + type)
$view->withCache()->display();
// Cache for 10 minutes with an explicit key
$view->withCache(600, "home::featured::v1")->display();
Key auto-generation: when $key is null, the cache key is:
view::{name}::{tpl}::{type} — stable across requests for the same view configuration.
Graceful fallback: if the cache adapter is unavailable (throws), getTpl() renders normally and the result is not cached. No exception escapes to the caller.
cache(string $key, int $ttl, callable $fn): string¶
Helper for caching partial output inside a template. Wraps Cache::remember() and falls back to calling $fn directly when the cache is unavailable.
// Inside a template or controller:
$html = $view->cache('widget::sidebar', 300, function () {
return $this->renderExpensiveWidget();
});
Type contract: the callable's return value is cast to string. Exceptions from $fn propagate normally; only cache-adapter failures are silenced.
54. OAuth2 Policy Helper — Descriptive Registries¶
Problem: Urbanwater needed descriptive lists of OAuth2 authentication methods, grant types, and webhook event types for developer-facing UIs. These were maintained in Urbanwater's PermissionHelper class, creating a drift risk.
Solution: Three new static methods added to Pramnos\Auth\OAuthPolicyHelper:
| Method | Returns | Description |
|---|---|---|
getAuthenticationMethods(): array |
array<{method, name, description}> |
All supported OAuth2 client authentication methods |
getGrantTypes(): array |
array<{method, name, description}> |
All supported OAuth2 grant types |
getWebhookTypes(): array |
array<{type, name, description}> |
All supported webhook event types |
Each entry is a keyed array suitable for rendering in UI dropdowns or documentation pages.
55. Scopes — addDefaultScopesToToken()¶
Problem: When issuing tokens, callers needed to merge the requested scopes with the server's default scopes. This logic was duplicated in Urbanwater.
Solution: New static method in Pramnos\Auth\Scopes:
| Method | Description |
|---|---|
addDefaultScopesToToken(string $tokenScopesString): string |
Strips optional […] brackets, splits the scope string on whitespace, merges with getDefaultScopes(), and returns a single space-delimited string of unique scopes. |
56. Helpers — Coordinate and IP Validation¶
Problem: Geographic coordinate validation and IP/CIDR validation were implemented in Urbanwater's HelperFunctions but are general-purpose utilities with no application-specific logic.
Solution: Two new static methods added to Pramnos\General\Helpers:
| Method | Description |
|---|---|
isValidCoordinate($latitude, $longitude): bool |
Returns false if either value is non-numeric, if both are exactly zero (null island), or if lat/lon are out of WGS-84 range. |
validateIpOrCidr(string $ip): bool |
Validates a single IPv4/IPv6 address or a CIDR range. Accepts IPv4 prefixes 0–32 and IPv6 prefixes 0–128. |
57. Scaffolding — Auth Views for All Themes¶
Problem: The scaffolding system had dashboard, twofactor, and OAuth2 account-management views for all three themes, but was missing the auth flow views (login, registration, OAuth2 consent, device authorization, etc.) that every deployed application needs.
Solution: 51 new view templates added across all three themes (plain-css, bootstrap, tailwind):
| Group | Views |
|---|---|
login/ |
login.html.php, login_2fa.html.php, forgotpassword.html.php, newpassword.html.php, message.html.php |
OAuth2/ |
OAuth2.html.php, authorize.html.php, errormessage.html.php |
device/ |
device.html.php, confirmation.html.php, deny.html.php, success.html.php, errormessage.html.php |
register/ |
register.html.php |
profile/ |
profile.html.php |
sso/ |
sso.html.php |
home/ |
home.html.php |
Each view is a clean starter template with CSRF token field support, standardized variable documentation in the file header, and appropriate CSS classes for the target theme.
58. ScaffoldingHelper — Pramnos\Application\ScaffoldingHelper¶
Static utility class that centralises all logic for locating the framework's bundled scaffolding directory. Previously scattered across Init.php and Controller.php; now a single canonical source.
// Locate the scaffolding/ directory in any installation
$dir = ScaffoldingHelper::resolveScaffoldingDir(); // → /path/to/pramnosframework/scaffolding
// Get a specific theme's view root
$themeDir = ScaffoldingHelper::getThemeDir('bootstrap'); // → …/scaffolding/themes/bootstrap
// Read scaffold_theme from app/app.php config
$theme = ScaffoldingHelper::getScaffoldTheme($appConfig); // → 'bootstrap' or null
// Get all theme dirs that exist on disk (canonical order)
$dirs = ScaffoldingHelper::getAvailableThemeDirs(); // → ['.../plain-css', '.../bootstrap', ...]
// Enumerate view groups for a theme (used by scaffold:views --list)
$groups = ScaffoldingHelper::listViewGroups('tailwind');
// → ['device' => ['device/device.html.php', ...], 'login' => ['login/login.html.php', ...], ...]
Constants:
- THEMES = ['plain-css', 'bootstrap', 'tailwind'] — canonical, ordered list of theme identifiers.
Used by: Controller::getView() (scaffolding fallback), Init command (resolveScaffoldingDir), scaffold:views command.
59. Controller — Scaffolding View Fallback¶
Controller::getView() now has a final fallback step: if no view is found in any project path, it searches the framework's bundled scaffolding views before throwing the "cannot find view" exception.
This means auth flows (login, 2FA, OAuth2 consent, device authorization, etc.) work out of the box on a freshly initialised project — without requiring a scaffold:views step.
Fallback resolution order:
1. _priorityPaths (controller-specific overrides)
2. _extraPaths
3. Application paths (INCLUDES + extra paths + _lastPaths)
4. NEW — Scaffolding fallback:
- If app/app.php has scaffold_theme, only that theme's directory is tried.
- If scaffold_theme is absent (legacy project), all three theme directories are tried in canonical order.
Developers can publish and customise the views at any time using scaffold:views, after which the customised file takes precedence (it lives in the app's own path, which is searched before the fallback).
New private method: Controller::_getScaffoldingFallbackDirs(): string[]
61. Cache System — Phase 11 Completion¶
New in this release¶
ArrayAdapter — in-memory cache adapter¶
src/Pramnos/Cache/Adapter/ArrayAdapter.php
An AdapterInterface-compliant adapter backed by a plain PHP array. Intended for unit tests and environments without an external cache daemon. No APCu, Redis, Memcached, or file-system access required.
use Pramnos\Cache\Cache;
// Instantiate a Cache that stores everything in-process
$cache = new Cache(null, null, 'array');
$cache->save('hello', 'world', 60);
$val = $cache->load('hello'); // 'world'
Key characteristics:
- TTL is honoured: expired entries are pruned on access (lazy expiry).
- ttl = 0 means never expires.
- Instance-isolated: two ArrayAdapter instances do not share data.
- Prefix isolation: new ArrayAdapter('app_') keeps keys separate from new ArrayAdapter('test_').
Cache::remember() — lazy-fetch pattern¶
$value = $cache->remember('expensive-query', 3600, function () use ($db) {
return $db->queryBuilder()->table('stats')->count();
});
If the key is present in the cache the stored value is returned immediately.
If not, the callback is called once, its return value is stored under the key with the given TTL, and then returned to the caller.
Signature: public function remember(string $key, int $ttl, callable $callback): mixed
CacheServiceProvider — feature lifecycle integration¶
src/Pramnos/Cache/CacheServiceProvider.php
Registers the 'cache' feature in FeatureRegistry and warms the Cache singleton during the application bootstrap so that Factory::getCache() always returns a fully-configured instance.
Enable in app.php:
Configure the adapter in settings.php:
$settings->cache->method = 'redis'; // array | file | redis | memcached
$settings->cache->hostname = '127.0.0.1';
$settings->cache->port = 6379;
RateLimitMiddleware — sliding-window rate limiter via Cache¶
src/Pramnos/Http/Middleware/RateLimitMiddleware.php
Unlike ThrottleMiddleware (which requires APCu), RateLimitMiddleware works with any Cache adapter — including ArrayAdapter for tests, FileAdapter, RedisAdapter, or MemcachedAdapter.
Algorithm: each request appends the current Unix timestamp to a list stored in Cache under the client-IP key. On every call the list is filtered to discard entries older than now − perSeconds (the sliding window). If the remaining count reaches maxRequests the request is rejected with Exception(code: 429) and a Retry-After header.
use Pramnos\Http\Middleware\RateLimitMiddleware;
// Global throttle: 120 requests per 60 seconds per IP
$router->addGlobalMiddleware(new RateLimitMiddleware(120, 60));
// Stricter limit on an expensive endpoint
$router->post('/api/export', fn() => …)
->middleware(new RateLimitMiddleware(5, 60, 'export:'));
// Inject a Cache instance (useful in tests)
$cache = new Cache(null, null, 'array');
new RateLimitMiddleware(10, 60, 'test:', $cache);
Constructor:
RateLimitMiddleware(
int $maxRequests = 60,
int $perSeconds = 60,
string $keyPrefix = 'ratelimit:',
?Cache $cache = null // defaults to Factory::getCache()
)
Test coverage¶
| Class | Test file | Tests |
|---|---|---|
ArrayAdapter |
tests/Unit/Pramnos/Cache/ArrayAdapterTest.php |
18 |
Cache::remember() |
tests/Unit/Pramnos/Cache/CacheTest.php |
3 |
RateLimitMiddleware |
tests/Unit/Pramnos/Http/Middleware/RateLimitMiddlewareTest.php |
8 |
60. scaffold:views — Incremental View Publishing¶
New console command for publishing bundled scaffold views into an existing project. Complements pramnos init (which scaffolds everything at once) by allowing individual view groups to be added later.
# See what's available for the current project theme
./pramnos scaffold:views --list
# Publish all view groups (uses scaffold_theme from app/app.php)
./pramnos scaffold:views --all
# Publish specific groups only
./pramnos scaffold:views --group=login,device
# Override theme
./pramnos scaffold:views --group=oauth2 --theme=tailwind
# Overwrite existing files
./pramnos scaffold:views --all --force
# Publish to a custom directory
./pramnos scaffold:views --all --dest=resources/views
Options:
| Option | Description |
|---|---|
--all |
Publish every view group |
--group=a,b |
Publish only named groups (comma-separated) |
--theme=x |
Override the theme (plain-css, bootstrap, tailwind). Reads scaffold_theme from app/app.php if omitted. |
--dest=path |
Destination relative to project root (default: src/Views) |
--force / -f |
Overwrite existing files |
--list |
Print available groups and files without writing anything |
Exit codes: 0 = success, 1 = invalid theme / unknown group / missing required option.
UrbanWater Schema Backport — Phase 2¶
Status: In Progress (v1.2-dev)
The Pramnos Framework v1.2 incorporates advanced schema elements from the UrbanWater reference application to provide production-grade monitoring, security, and compliance features out of the box.
New Tables¶
applications.application_settings¶
Stores per-application configuration for rate limiting, CORS, pagination, and IP locking.
Columns:
- id (serial, PK)
- appid (int, FK → applications.appid)
- rate_limit_requests (int, default 1000) — requests per time window
- rate_limit_window_seconds (int, default 3600) — time window in seconds
- rate_limit_burst (int, default 100) — burst capacity
- enforce_pagination (bool, default true)
- max_page_size (int, default 100)
- default_page_size (int, default 20)
- ip_lock_enabled (bool, default false)
- allowed_ips (INET[], PostgreSQL; JSON, MySQL)
- blocked_ips (INET[], PostgreSQL; JSON, MySQL)
- require_https (bool, default true)
- cors_enabled (bool, default false)
- cors_origins (TEXT[], PostgreSQL; JSON, MySQL)
- created_at (timestamp)
- updated_at (timestamp, auto-update)
Indexes:
- Unique index on appid
- Index on updated_at for cleanup queries
Triggers:
- trg_update_application_settings_timestamp (PostgreSQL) — auto-update updated_at
Migration: CreateApplicationSettingsTable (000044)
applications.application_stats (TimescaleDB Hypertable)¶
Time-series metrics table for API performance, request counts, response times, and status codes.
Columns:
- time (timestamp, partition key on TimescaleDB)
- appid (int, FK → applications.appid)
- total_requests (bigint)
- successful_requests (bigint)
- failed_requests (bigint)
- avg_response_time (numeric 10,3, milliseconds)
- min_response_time (numeric 10,3)
- max_response_time (numeric 10,3)
- status_2xx, status_3xx, status_4xx, status_5xx (bigint)
- rate_limited_requests (bigint)
- rate_limit_violations (int)
- bytes_sent (bigint)
- bytes_received (bigint)
- unique_ips_approx (int, HyperLogLog approximation)
- country_code (char 2, optional)
Hypertable Settings (TimescaleDB): - Chunk interval: 14 days - Compression: enabled after 30 days - Compression policy: automatic
Regular Table Behavior (MySQL/PostgreSQL): - Created as standard table with composite index on (appid, time)
Index:
- Composite index on (appid, time DESC) for efficient range queries
Migration: CreateApplicationStatsTable (000045)
authserver.user_app_authorizations¶
OAuth consent and authorization scope grants per user/application pair. Tracks when users authorize applications and enables revocation.
Columns:
- id (serial, PK)
- userid (bigint, FK → users.userid)
- appid (int, FK → applications.appid)
- scope (TEXT[], PostgreSQL; JSON, MySQL)
- status (enum: 'granted', 'revoked', 'pending', 'expired', default 'granted')
- granted_at (timestamp)
- revoked_at (timestamp, nullable)
- expires_at (timestamp, nullable)
- last_used_at (timestamp, nullable)
- requested_by (bigint, FK → users.userid, nullable) — admin who approved
- user_agent (varchar 255, nullable)
- ip_address (varchar 45, nullable) — IPv4 or IPv6
Indexes: - Unique index on (userid, appid) - Index on userid - Index on appid - Index on status - Index on revoked_at
Foreign Keys: - userid → users.userid (CASCADE) - appid → applications.appid (CASCADE) - requested_by → users.userid (SET NULL)
Migration: CreateUserAppAuthorizationsTable (000044 in authserver/)
Foreign Keys Added to Existing Tables¶
usertokens¶
parentToken→ usertokens.tokenid (SET NULL) — allows token chain referencesapplicationid→ applications.appid (SET NULL) — link tokens to OAuth applications
tokenactions¶
tokenid→ usertokens.tokenid (CASCADE)urlid→ urls.urlid (CASCADE)
applications¶
owner→ users.userid (SET NULL) — application owner reference
users¶
locationid→ locations.locationid (SET NULL, ON UPDATE CASCADE) — conditional: only added when the parent application's schema includes alocationstable. The framework itself does not definelocations; this FK is app-level. The migration checkshasTable('locations')before creating the constraint.
GDPR Tables (all)¶
userid→ users.userid (CASCADE) for:- user_activity_log
- user_privacy_settings
- user_consents
- data_processing_records
- gdpr_requests
Migration: AddMissingForeignKeysToExistingTables (000050 in core/)
Indexes Added to Existing Tables (000052)¶
Syncs the framework schema with the UrbanWater production schema by adding indexes
that were present in the production DB but absent from the original CREATE TABLE
migrations. All operations are guarded with hasTable() / hasColumn() / indexExists()
so the migration is safe to run on any installation regardless of schema state.
sessions¶
idx_sessions_useridonuserididx_sessions_timeontime
users¶
idx_users_photoonphoto(conditional: only whenphotocolumn exists)
usertokens¶
idx_usertokens_parentTokenonparentToken(conditional: column must exist)idx_usertokens_tokenontoken— PostgreSQL: full TEXT index; MySQL: prefix(255)
tokenactions¶
idx_tokenactions_tokenidontokenididx_tokenactions_urlidonurlididx_tokenactions_return_statusonreturn_status(conditional: column must exist)idx_tokenactions_execution_timeonexecution_time_ms(conditional: column must exist)idx_tokenactions_time_methodon(action_time DESC, method)(conditional: action_time column must exist)
Migration: AddMissingIndexesToExistingTables (000052 in core/)
broadcast:serve — Local Development WebSocket Server¶
php ./bin/pramnos broadcast:serve
Pure-PHP WebSocket server for local development. Implements a subset of the
Pusher Wire Protocol v7 so pramnos-echo.js clients can connect without
any configuration change. No Ratchet / ReactPHP dependency required.
How it works¶
- The application is configured to use
LogDriverpointing to a shared JSONL file (e.g.,var/broadcast.jsonl). broadcast:servestarts listening onws://localhost:6001and tails the log file.- When a new JSON line appears in the file, the daemon parses it and pushes the broadcast to all WebSocket clients subscribed to that channel.
Options¶
| Option | Default | Description |
|---|---|---|
--host |
0.0.0.0 |
Bind address |
--port / -p |
6001 |
Listen port |
--log-file |
auto | Path to LogDriver JSONL file; auto-resolved from container config |
--app-key |
pramnos-local |
Pusher app key expected in the WebSocket URL |
--verbose / -v |
off | Print connection count changes each tick |
Client configuration (pramnos-echo.js)¶
Classes¶
Pramnos\Broadcasting\LocalBroadcastServer— WebSocket server engine;run(host, port),broadcast(channel, event, data),stop(),onTick(cb)Pramnos\Console\Commands\BroadcastServe— Symfony console command
Analytics and Monitoring Views¶
applications Schema Views¶
api_performance_summary Analyzes API performance by application, method, and status code. Includes percentile response times, success rates.
Columns: app_name, method, return_status, total_requests, avg_execution_time_ms, median_execution_time_ms, p95_execution_time_ms, max_execution_time_ms, success_rate_percent
Time window: Last 24 hours
application_health Overall health indicators per application (uptime, error rates, latency trends).
application_stats_daily (Materialized View) Daily aggregate of API metrics by application.
application_stats_hourly (Materialized View) Hourly aggregate of API metrics by application.
rate_limit_status Current rate limiting state per application: requests used, remaining, usage percentage, whether currently rate-limited.
Joins: applications + application_settings + current tokenactions
slow_api_calls API calls exceeding 5-second threshold for performance debugging.
Columns: app_name, token, method, urlid, execution_time_ms, return_status, action_time, params
ip_violations IPs that violate IP lock settings (allowed/blocked lists).
Columns: appid, app_name, ip_address, violation_count, last_violation
oauth2_active_tokens (applications) Active OAuth tokens by status and application.
usage_statistics (Materialized View) Aggregate usage statistics: requests, bandwidth, unique users per application.
top_applications Applications ranked by request volume.
authserver Schema Views¶
recent_twofactor_attempts 2FA attempts in the last 24 hours for security monitoring.
Columns: userid, ip_address, success, attempt_time, user_agent, status
failed_twofactor_summary Summary of failed 2FA attempts in the last hour (3+ failures minimum) with IP and user aggregation.
Columns: ip_address, userid, failed_attempts, last_attempt, first_attempt
daily_2fa_stats (Materialized View on TimescaleDB) Daily 2FA statistics: attempts, successes, failures, unique users.
gdpr_compliance_report User data processing and consent summary for GDPR compliance reporting.
geographic_analysis Login locations and geographic patterns from authentication logs.
alert_high_failure_rate Authentication failures spike detection for security alerts.
alert_suspicious_ips Suspicious IP activity alerts based on failed attempts.
oauth2_active_tokens (authserver) Active OAuth tokens with detailed authorization information.
Implementation Status¶
Completed:
- ✅ application_settings table (migration 000044 in applications/)
- ✅ application_stats hypertable (migration 000045 in applications/)
- ✅ user_app_authorizations table (migration 000044 in authserver/)
- ✅ Missing foreign keys backport (core/000050)
- ✅ Analytics and monitoring views — 18 total (migrations 000046 in applications/ and authserver/)
- ✅ authserver.sync_consent_timestamp() trigger on oauth2_user_consents (migration 000047)
- ✅ Schema repositioning: authserver.slow_api_calls → applications.slow_api_calls (migration 000048)
- ✅ Integration tests: MySQL, PostgreSQL, TimescaleDB
In Progress: - ⏳ Model/API controller scaffolding updates
BC Notes¶
All new tables and views are additive — no breaking changes to existing code. Applications that do not use OAuth or need advanced monitoring can ignore these new schema elements entirely.
62. View & Template System — Complete Guide¶
This section provides a unified reference for the view system. Individual subsystems are documented in detail in §53 (Template Engine), §57 (Auth Views), §58 (ScaffoldingHelper), §59 (Scaffolding Fallback), and §60 (scaffold:views).
Architecture overview¶
Every Pramnos application renders HTML through View objects. A controller fetches a view by name via $this->getView('viewName'), sets variables on it, and calls $view->display(). The view object locates the template file, executes it in its own scope, and returns the rendered HTML string.
The view system has three layers:
| Layer | Role |
|---|---|
| Controller | Locates the view directory and instantiates a View object |
View |
Executes the template file, handles layout inheritance, captures sections |
| Template file | .html.php or .tpl.php — the actual markup |
Directory conventions¶
A view is a directory, not a single file. The directory name is the view name. Inside it lives at least one template file:
src/
└── Views/
└── login/ ← view directory (name = "login")
├── login.html.php ← default template (name matches directory)
└── login_2fa.html.php ← sub-template (called via display('login_2fa'))
The framework looks for Views/ (capital V) first, then views/ (lowercase). Both work.
Using views from a controller¶
// Get a view object (searches all paths — see search order below)
$view = $this->getView('login');
// Pass variables (public properties are available as $this->... in the template)
$view->username = 'john';
$view->errors = [];
// Render the default template (login/login.html.php)
echo $view->display();
// Render a specific sub-template (login/login_2fa.html.php)
echo $view->display('login_2fa');
View search order (5-step fallback chain)¶
When getView('login') is called, the framework searches for a Views/login/ directory in this exact order. The first match wins.
1. _priorityPaths[] Controller-registered priority overrides
2. _extraPaths[] Additional paths registered at runtime
3. Application paths:
ROOT/INCLUDES/{AppName}/ App's own view directory (or ROOT/INCLUDES/ if no app name)
application->extraPaths Paths registered by the application object
_lastPaths[] Final controller fallback paths
4. Scaffolding fallback: Framework's bundled views (scaffolding/themes/)
→ If scaffold_theme set in app.php: only that theme's directory is searched
→ Otherwise: plain-css, bootstrap, tailwind tried in that order
5. Exception "Cannot find view: login" — only if all above fail
Step 4 means auth flows (login, 2fa, oauth2, register, etc.) work out of the box on a fresh project — no configuration required.
Override mechanism¶
To override a framework view, create a matching directory in the application's own path (step 3). The app's path is always checked before the scaffolding fallback (step 4).
# Framework bundled view (step 4 — fallback):
scaffolding/themes/bootstrap/views/login/login.html.php
# App override (step 3 — wins):
src/Views/login/login.html.php
No registration required — placing the file in the right path is enough.
For partial templates and layouts (used inside a view via @include or @extends), the search order within View::resolveTemplatePath() is:
- Absolute path
- Relative to the current view's directory (
$this->path) - Relative to
ROOT/views/ - Theme directory (
themeObject->fullpath/views/) — only ifallowsViewOverrides()returns true
Template file types¶
| Extension | Compiled | Syntax |
|---|---|---|
.html.php |
No — included directly | Plain PHP: <?php echo $this->e($var); ?> |
.tpl.php |
Yes — via TemplateCompiler, cached in ROOT/var/viewcache/ |
Blade-style: {{ $var }}, @if, @extends, … |
Both types run inside the same View object — all $this->* methods are available in both.
When to use which:
- .html.php — simpler templates, no compilation overhead, easier to debug
- .tpl.php — template inheritance with layouts, cleaner syntax for complex templates
Passing variables to templates¶
Any public property set on the View object is available inside the template as $this->propertyName:
// Controller:
$view = $this->getView('profile');
$view->user = $userObject;
$view->isAdmin = true;
$view->items = $db->queryBuilder()->from('orders')->where('userid', $id)->get();
// Template (profile/profile.html.php):
<h1><?= $this->e($this->user->username) ?></h1>
<?php if ($this->isAdmin): ?>
<a href="/admin">Admin Panel</a>
<?php endif; ?>
The template also receives:
- $this->model — the default model (if attached via addModel())
- $this->request — current Request object
- $this->errors — validation errors flashed from the previous request
- $lang — language object (available as $lang local variable)
Template inheritance (layouts)¶
Templates can declare a parent layout. The child populates named sections; the layout renders them.
Child template (dashboard/dashboard.tpl.php):
@extends('layouts/main')
@section('title')Dashboard@endsection
@section('content')
<h2>Welcome, {{ $this->user->username }}</h2>
@endsection
Layout (layouts/main.html.php):
<!DOCTYPE html>
<html>
<head><title><?= $this->yield('title', 'My App') ?></title></head>
<body>
<?php echo $this->yield('content'); ?>
</body>
</html>
The same mechanism works in .html.php using $this->layout() / $this->section() / $this->endsection() / $this->yield() directly. See §53 for the full directives reference.
Scaffold workflow¶
A fresh project contains no view files — the scaffolding fallback (step 4) serves everything from the framework's bundled templates. This is intentional: auth flows work immediately without any manual step.
When you want to customise a view, publish it first:
# See what's available
./pramnos scaffold:views --list
# Publish the login group for your project's theme
./pramnos scaffold:views --group=login
# Publish everything
./pramnos scaffold:views --all
# Override theme (e.g. switch to bootstrap)
./pramnos scaffold:views --all --theme=bootstrap
# Overwrite previously published files
./pramnos scaffold:views --group=login --force
Published files land in src/Views/ by default (configurable with --dest). Once published, the app's copy takes precedence over the bundled one — edit freely.
scaffold_theme config key¶
Set in app/app.php (written automatically by pramnos init):
This tells both scaffold:views (which theme to copy from) and the scaffolding fallback (which theme directory to use at runtime). Without this key, all three themes are tried in order — plain-css first.
Quick reference¶
| Task | How |
|---|---|
| Render a view from a controller | $view = $this->getView('name'); echo $view->display(); |
| Render a sub-template | $view->display('sub_name') |
| Pass a variable to the template | $view->myVar = $value; — access as $this->myVar |
| Override a framework view | Create src/Views/{name}/{name}.html.php in the app |
| Publish a bundled view for editing | ./pramnos scaffold:views --group=login |
| Use a layout | @extends('layouts/main') (.tpl.php) or $this->layout('layouts/main') (.html.php) |
| Safe output (XSS-escaped) | <?= $this->e($value) ?> or {{ $value }} in .tpl.php |
| Raw (unescaped) output | <?= $value ?> or {!! $value !!} in .tpl.php |
| Include a partial | $this->insert('partials/card', ['item' => $item]) or @include('partials/card', $data) |
| Add a priority search path | $this->addPriorityPath($dir) in the controller |
45. Phase 20: HTTP Testing Infrastructure¶
Problem: Testing controllers and views in Pramnos was difficult because the framework historically assumed an active web server environment, deeply intertwined global state, and relied on exit() or die() at the end of the request lifecycle (via Application::close()). This made writing PHPUnit Feature tests almost impossible without terminating the test runner or requiring complex end-to-end setups.
Solution: The Pramnos Framework now features a robust, native HTTP testing infrastructure that allows developers to write expressive Feature tests for their web controllers without booting a real server.
TestClient¶
An in-memory client that simulates HTTP requests directly within the framework's router and application lifecycle.
- Automatically handles application singletons and bootstrapping safely within a test environment.
- Intercepts HTTP redirects (RedirectException) and gracefully handles exceptions to prevent the test suite from terminating.
- Bypasses the traditional Application::exec() exit pathways, wrapping both Router (v1.2) routes and classic MVC Application controllers.
- Provides expressive HTTP methods: $client->get('/route'), $client->post('/route', ['data' => 'val']).
TestResponse¶
A fluent wrapper around the framework's Response object that provides modern, intuitive assertion methods.
- HTTP Assertions: assertStatus(int), assertSuccessful(), assertRedirect()
- Content Assertions: assertSee(string), assertDontSee(string), assertSeeText(string)
- JSON Assertions: assertJson(array), assertJsonPath(path, value)
- DOM Assertions: Uses symfony/dom-crawler to provide assertSelectorExists(string), assertSelectorContains(selector, text), and assertSelectorAttribute(selector, attribute, value).
Scaffolding Integration¶
The CLI tool (php bin/pramnos create:controller) seamlessly incorporates these new testing tools. When generating a controller, it now creates a full Feature Test inside tests/Feature/<Controller>Test.php. The generated test automatically uses TestClient to dispatch a GET request and makes a basic DOM assertion to ensure the view renders properly.
Usage Example¶
public function testDisplayWorks(): void
{
$client = new \Pramnos\Testing\TestClient();
$response = $client->get('/users');
$response->assertSuccessful();
$response->assertSelectorExists('.user-list');
$response->assertSee('Admin User');
}
63. MakeCommandBase Service Decomposition¶
MakeCommandBase (3 000+ lines) was decomposed into four focused, independently testable service classes under Pramnos\Console\Make\. The make:* CLI commands continue to work unchanged — all public methods on MakeCommandBase are preserved as thin delegation wrappers.
New classes¶
Pramnos\Console\Make\BlueprintCompiler¶
Pure, stateless DDL-string builder. Zero dependencies.
| Method | Description |
|---|---|
getSingularPrimaryKey(string $tableName): string |
'#PREFIX#users' → 'userid' |
blueprintCall(array $col): string |
Column definition array → "$table->string('name', 100);" |
buildMigrationUpBody(string $tableName, bool $hasPk, array $columns, bool $timestamps, bool $softDeletes, array $foreignKeys): string |
Full up() closure body |
buildMigrationDownBody(string $tableName): string |
"SchemaBuilder::dropIfExists(…);" |
Pramnos\Console\Make\FakeDataGenerator¶
Pure fake-value heuristics. Zero dependencies. Uses $i as a 1-based loop counter in all generated expressions.
| Method | Description |
|---|---|
generateFakeValue(string $colName, string $colType, array $options = []): string |
Name hints first, type fallback second |
buildSeederFields(array $columns): string |
Full {{ fields }} block; skips id, created_at, updated_at, deleted_at |
Name hints (checked via str_contains): email, first_name, last_name, username, login, name, title, phone, mobile, address, city, country, description, body, content, slug, url, ip, password, token, status, type, color, latitude, longitude, lat, lon, lng, price, amount, score, sort, order, position, weight.
Pramnos\Console\Make\NamespaceResolver¶
All-static utility for PHP class name, namespace, and filesystem path derivation. Depends only on \Pramnos\General\StringHelper.
| Method | Description |
|---|---|
getProperClassName(string $name, bool $forceSingular = true): string |
'users' → 'User' (model); 'user' → 'Users' (view) |
getModelTableName(string $name): string |
'User' → '#PREFIX#users' |
resolveBaseNamespace(array $applicationInfo, string $appName): string |
['namespace'=>'App'] + 'MyApp' → 'App\MyApp' |
resolveBasePath(string $root, string $includes, string $appName): string |
/var/www/html + src + MyApp → /var/www/html/src/MyApp |
Pramnos\Console\Make\StubRenderer¶
Loads <stubsDir>/<name>.stub and performs {{ token }} substitution. Falls back to embedded skeletons when the file is absent.
// Auto-resolves scaffolding/templates/ via ScaffoldingHelper
$renderer = new StubRenderer();
// Or point at a custom directory (useful in tests)
$renderer = new StubRenderer('/path/to/custom/templates');
$php = $renderer->render('controller', [
'namespace' => 'App\Controllers',
'class' => 'Users',
'view' => 'users',
]);
Fallback stubs are available for: middleware, event, listener, migration, seeder, controller, model, test, controller_test.
Removed¶
Pramnos\Console\Commands\Create— legacypramnos create model/controller/...alias command. All functionality is available viacreate:model,create:controller, etc.
MakeCommandBase delegation¶
MakeCommandBase now delegates every method to the appropriate service:
// These public methods are preserved for backwards compatibility:
$this->renderStub($name, $tokens) // → StubRenderer::render()
$this->blueprintCall($col) // → BlueprintCompiler::blueprintCall()
$this->buildMigrationUpBody(...) // → BlueprintCompiler::buildMigrationUpBody()
$this->buildMigrationDownBody($table) // → BlueprintCompiler::buildMigrationDownBody()
$this->generateFakeValue($col, $type) // → FakeDataGenerator::generateFakeValue()
$this->buildSeederFields($columns) // → FakeDataGenerator::buildSeederFields()
static::getProperClassName($name) // → NamespaceResolver::getProperClassName()
static::getModelTableName($name) // → NamespaceResolver::getModelTableName()
The four service classes are independently instantiable and fully testable without any CLI or application context — they are the canonical unit-test targets.
64. Router::group() + #[RouteGroup]¶
Phase 7 — Φάση 7: Modern Routing Engine
Route groups allow shared attributes (URI prefix, middleware, permissions, name prefix) to be applied to a set of routes without repeating them on every individual route.
Programmatic groups — Router::group()¶
$router->group([
'prefix' => '/api/v1',
'middleware' => [ApiAuthMiddleware::class, ThrottleMiddleware::class],
'permissions' => ['api:access'],
'name' => 'api.v1.',
], function (Router $r): void {
$r->get('/users', [UserController::class, 'index'])->name('users.index');
$r->post('/users', [UserController::class, 'store'])->name('users.store');
$r->get('/users/{id}', [UserController::class, 'show'])->name('users.show');
});
// Registered routes:
// GET /api/v1/users named api.v1.users.index
// POST /api/v1/users named api.v1.users.store
// GET /api/v1/users/{id} named api.v1.users.show
// URL generation still works:
echo $router->route('api.v1.users.show', ['id' => 42]); // /api/v1/users/42
Supported attributes:
| Key | Type | Description |
|---|---|---|
prefix |
string |
URI prefix prepended to every route URI |
middleware |
array |
Middleware prepended before each route's own middleware |
permissions |
array |
Permission scopes merged with each route's own permissions |
name |
string |
Logical name prefix prepended to every named route |
Nested groups¶
Groups stack — inner attributes add on top of outer attributes:
$router->group(['prefix' => '/api', 'name' => 'api.'], function (Router $r): void {
$r->group(['prefix' => '/v2', 'name' => 'v2.'], function (Router $r): void {
$r->get('/items', fn() => ...)->name('items.index');
// → GET /api/v2/items named api.v2.items.index
});
$r->get('/status', fn() => ...);
// → GET /api/status (only outer prefix)
});
Middleware ordering¶
Group middleware runs before per-route middleware (prepended via Route::prependMiddleware()):
#[RouteGroup] attribute¶
Apply group attributes declaratively on a controller class. RouteDiscovery reads the attribute and wraps all #[Route] methods in a Router::group() call automatically:
use Pramnos\Routing\Attributes\Route;
use Pramnos\Routing\Attributes\RouteGroup;
#[RouteGroup(
prefix: '/api/v1',
middleware: [ApiAuthMiddleware::class],
permissions: ['api:access'],
name: 'api.v1.',
)]
class UserController
{
#[Route('/users', methods: 'GET', name: 'users.index')]
#[Route('/users', methods: 'POST', name: 'users.store')]
public function index(): void { … }
#[Route('/users/{id}', methods: 'GET', name: 'users.show')]
public function show(int $id): void { … }
}
#[RouteGroup] constructor parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
prefix |
string |
'' |
URI prefix |
middleware |
array |
[] |
Middleware FQCN strings |
permissions |
array |
[] |
Permission scopes |
name |
string |
'' |
Name prefix |
New files¶
| File | Description |
|---|---|
src/Pramnos/Routing/Attributes/RouteGroup.php |
#[RouteGroup] PHP 8 attribute (TARGET_CLASS) |
Modified files¶
| File | Change |
|---|---|
src/Pramnos/Routing/Router.php |
Added $groupStack, group(), modified addSingleRoute() to merge group context |
src/Pramnos/Routing/Route.php |
Added prependMiddleware() for group MW ordering |
src/Pramnos/Routing/RouteDiscovery.php |
Reads #[RouteGroup] on class, wraps discovery in Router::group() |
Tests¶
tests/Unit/Pramnos/Routing/RouteGroupTest.php — 15 tests covering:
prefix application, double-slash normalization, routes outside group unaffected, middleware ordering (group before route), group middleware without per-route MW, permission merging (deny partial / allow full), name prefix, getByName() with prefix, nested prefix stacking, nested name prefix stacking, context restored after nested group, #[RouteGroup] attribute data model, RouteDiscovery applies group attribute.
65. JsonResponseMiddleware + ApiAuthMiddleware¶
Phase 15 — Unified Application / API middleware layer
Two new middleware classes extracted from the inline logic of Api::exec() into reusable, independently testable components.
JsonResponseMiddleware¶
Sets the Content-Type response header before passing to $next. No short-circuiting — always a pass-through.
// Content-Type: application/json; charset=utf-8 (default)
// Content-Type: application/xml; charset=utf-8 (when HTTP_ACCEPT=application/xml)
new \Pramnos\Http\Middleware\JsonResponseMiddleware()
ApiAuthMiddleware¶
Validates the HTTP_APIKEY header via a caller-supplied checker callable, then (optionally) validates a JWT HTTP_ACCESSTOKEN. On success sets $_SESSION['logged'] and $_SESSION['user']. On failure short-circuits and returns a JSON error envelope.
new \Pramnos\Http\Middleware\ApiAuthMiddleware(
apiKeyChecker: fn(string $k) => $app->checkApiKey($k),
authKey: $app->authenticationKey, // HS256 / RS256 key
appNamespace: $app->applicationInfo['namespace'] ?? null,
)
Error envelopes follow the same status / statusmessage / message / error structure as Api::_translateStatus():
| Condition | status |
error |
|---|---|---|
HTTP_APIKEY missing |
403 | APIKeyMissing |
| API key invalid | 401 | APIKeyInvalid |
| JWT malformed / unreadable | 403 | InvalidAccessToken |
| JWT valid but user not found in DB | 403 | InvalidAccessToken |
Api::exec() refactor¶
Api::exec() is now a thin wrapper over a three-layer pipeline:
- All inline CORS headers, content-type logic, and auth code removed from
exec(). - Core dispatch logic extracted to
_executeCore(float $startTime): mixed(public so it can be extended by subclasses). cors_originsis now configurable via$applicationInfo['cors_origins'](default:['*']).- BC: existing
new Api('appname'); $api->exec('controller')usage unchanged.
New files¶
| File | Description |
|---|---|
src/Pramnos/Http/Middleware/JsonResponseMiddleware.php |
Sets Content-Type header, always passes through |
src/Pramnos/Http/Middleware/ApiAuthMiddleware.php |
API key + Bearer token validation with JSON error envelopes |
Tests¶
tests/Unit/Http/Middleware/JsonResponseMiddlewareTest.php— 4 tests: default JSON, XML accept, shorthand XML, return value preservedtests/Unit/Http/Middleware/ApiAuthMiddlewareTest.php— 7 tests: missing key 403, invalid key 401, valid key passes, checker receives key, malformed token 403, error envelope structure
66. REST API Scaffolding — pramnos init --rest-api¶
Phase 15 | src/Pramnos/Console/Commands/Init.php
pramnos init now includes a Step 2b question after feature selection:
Answering y (default; or passing --rest-api=y) triggers the full REST API scaffold:
Files created¶
src/Api/Controllers/ ← namespace for API controllers
src/Api/routes.php ← Router bootstrap + group() example with /v1 prefix
src/Api.php ← namespace-specific Api class (extends \Pramnos\Application\Api)
www/api/index.php ← API entry point (served by Apache from www/api/)
www/api/.htaccess ← URL rewriting for the API subdirectory
src/Api/routes.php content (the router must be created and dispatched inside this file,
because Api::_executeCore() includes it with $this bound to the Api instance):
<?php
declare(strict_types=1);
// API routes — included by Api::_executeCore() with $this bound to the Api instance.
// Return value of this file is the dispatched response (passed back to the caller).
$router = new \Pramnos\Routing\Router($this);
$newRequest = new \Pramnos\Http\Request();
$router->group(
['prefix' => '/v1'],
function (\Pramnos\Routing\Router $r): void {
// Example: $r->get('/hello', [\MyApp\Api\Controllers\HelloController::class, 'index']);
}
);
return $router->dispatch($newRequest);
www/api/index.php entry point:
<?php
define('ROOT', dirname(dirname(__DIR__)));
define('SP', 1);
require ROOT . '/vendor/autoload.php';
$app = new \MyApp\Api();
$app->init();
$app->exec();
echo $app->render();
Requests to http://localhost:PORT/api/v1/... are served by www/api/index.php via
Apache's per-directory .htaccess. The CORS, ApiAuth, and JsonResponse middleware
pipeline runs automatically inside Api::exec().
app.php — 'api' section¶
When REST API is requested, app/app.php gains an 'api' key:
This config block is read by Api::exec() to configure CORS and routing. It is not written when REST API is not requested, keeping app.php minimal for non-API projects.
CLI option¶
Tests¶
tests/Unit/Console/InitCommandUnitTest.php— 6 tests:testRestApiOptionScaffoldsApiDirectoryAndRoutesFile— all 5 REST API files created when--rest-api=ytestRestApiRoutesFileContainsRouterGroupAndNamespaceComment— routes.php has$router = new Router($this),group(),/v1prefix,dispatch()return, namespace substitutedtestRestApiOptionAddsApiSectionToAppPhp—app.phpcontains'api'sectiontestNoRestApiOptionSkipsApiScaffolding— nosrc/Api/directory when--rest-api=ntestHtaccessUsesRParameterForRouting—www/.htaccessuses?r=(required byRequest::calcParams())testIndexPhpUsesDirectApplicationInstantiation—www/index.phpusesnew \Namespace\Application()
67. Database-driven CORS (PF-43) + Phase 15 Convergence Test¶
PF-43 / Phase 15 | src/Pramnos/Http/Middleware/CorsMiddleware.php, src/Pramnos/Application/Api.php
CorsMiddleware — new factory methods¶
getAllowedOrigins(): array¶
Exposes the configured origins. Mainly useful for testing.
fromCorsData(bool $enabled, array|string|null $rawOrigins): self¶
Constructs a CorsMiddleware from pre-fetched application_settings row data.
- $enabled = false → wildcard ['*'] (disabled policy is permissive)
- $enabled = true, $rawOrigins = ['https://app.com'] → specific origins
- $enabled = true, $rawOrigins = '["https://app.com"]' → JSON string parsed automatically
- $enabled = true, $rawOrigins = [] or null → wildcard fallback (misconfiguration protection)
fromApplicationSettings(string $appName, ?Database $db = null): self¶
Queries application_settings joined with applications by name to load CORS policy from the DB.
Falls back to wildcard when:
- DB is unavailable or table not migrated (authserver feature not enabled)
- No row found for $appName
- cors_enabled = false
Api::exec() — CORS resolution priority¶
// app/app.php
'api' => [
'cors_from_db' => true, // ← use DB; needs application_settings table
// OR:
'cors_origins' => ['https://spa.example.com'], // ← config-based
// OR: omit both → wildcard ['*']
],
When cors_from_db: true, Api::exec() calls CorsMiddleware::fromApplicationSettings($applicationInfo['name']). The DB lookup is transparent to existing code — existing cors_origins config continues to work unchanged.
Phase 15 convergence test¶
tests/Unit/Http/Middleware/ApiWebConvergenceTest.php (6 tests):
- API pipeline (CorsMiddleware → JsonResponseMiddleware → ApiAuthMiddleware) without key → JSON 403
- Invalid key → JSON 401
- Valid key → controller reached, returns its value
- Web pipeline (no auth) → controller always reached
- Same request handled differently by API vs web pipeline (core Phase 15 claim)
- Error envelope always has 4 required keys
Tests¶
tests/Unit/Http/Middleware/CorsMiddlewareTest.php— 11 teststests/Unit/Http/Middleware/ApiWebConvergenceTest.php— 6 tests
68. SPA-style Auth — Session Cookie as API Credential (Phase 16)¶
Phase 16 | src/Pramnos/Http/Middleware/UnifiedAuthMiddleware.php, src/Pramnos/User/Token.php, src/Pramnos/Http/Middleware/CsrfMiddleware.php, src/Pramnos/User/User.php, src/Pramnos/Application/Application.php
Problem solved¶
Web users who wanted to call API endpoints from JavaScript had to either:
- Send the password hash as an HTTP_USERAUTH header (insecure — deprecated in v1.2)
- Duplicate controller logic in a separate web controller method
Phase 16 introduces cookie-based SPA auth (Laravel Sanctum pattern): the session cookie is accepted by API endpoints on same-origin AJAX requests. No Bearer token required in the JS code.
Token type constants¶
All token type strings are now defined as class constants on Pramnos\User\Token:
| Constant | Value | Description |
|---|---|---|
Token::TYPE_WEB_SESSION |
'web_session' |
Created on web login; accepted by UnifiedAuthMiddleware |
Token::TYPE_API |
'auth' |
Standard API / auth token |
Token::TYPE_ACCESS_TOKEN |
'access_token' |
OAuth2 Bearer access token |
Token::TYPE_REFRESH_TOKEN |
'refresh_token' |
OAuth2 refresh token |
Token::TYPE_AUTH_CODE |
'auth_code' |
OAuth2 authorization code |
Token::TYPE_APNS |
'apns' |
Apple Push Notification device token |
Token::TYPE_GCM |
'gcm' |
Google Cloud Messaging device token |
UnifiedAuthMiddleware¶
new \Pramnos\Http\Middleware\UnifiedAuthMiddleware(
authKey: $app->authenticationKey,
appNamespace: $app->applicationInfo['namespace'] ?? null,
)
Auth resolution order:
Authorization: Bearer <jwt>— validates JWT (HS256/RS256), loads user fromusertokenswith explicit scopes.- Session cookie +
X-CSRF-Tokenheader — if$_SESSION['usertoken']is an activeTYPE_WEB_SESSIONtoken and the CSRF header matches, the user is authenticated with wildcard scopes (['*']). The controller still enforces its own$user->usertype/ policy checks. - No credentials → 401 JSON envelope.
Compared to ApiAuthMiddleware: UnifiedAuthMiddleware does NOT require an API key — designed for first-party (same-app) route groups.
$router->group([
'prefix' => '/api/v1',
'middleware' => [
new CorsMiddleware(['https://myapp.com']),
new JsonResponseMiddleware(),
new UnifiedAuthMiddleware(authKey: $app->authenticationKey),
],
], function (Router $r): void {
$r->get('/profile', [ProfileController::class, 'show']);
});
JavaScript (same-origin, no Bearer token needed):
fetch('/api/v1/profile', {
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf"]').content
}
}).then(r => r.json());
CsrfMiddleware::csrfMeta()¶
Returns a <meta> tag for JavaScript to read the CSRF token:
// In HTML <head>:
<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::csrfMeta(); ?>
// Outputs: <meta name="csrf" content="abc123..." />
Custom meta name:
User::createWebSessionToken()¶
Call at the end of a successful web login to create a TYPE_WEB_SESSION token:
// In your auth addon or login controller:
$user->createWebSessionToken($_SERVER['REMOTE_ADDR']);
// Token is persisted in usertokens and stored in $_SESSION['usertoken']
User::invalidateWebSessionToken()¶
Call on logout to mark the token inactive and remove it from the session:
Application::exec() — web request audit trail¶
When a TYPE_WEB_SESSION token is present in the session, Application::exec() now calls $token->addAction() — web page requests appear in the tokenactions audit log alongside API requests, giving a unified view of all user activity.
Deprecated: HTTP_USERAUTH bridge¶
The HTTP_USERAUTH header path in ApiAuthMiddleware (which matched the session password hash) is marked @deprecated since v1.2. It still works for BC but new code should use UnifiedAuthMiddleware + session-cookie auth.
Tests¶
tests/Unit/Http/Middleware/UnifiedAuthMiddlewareTest.php— 12 tests
69. Universal List API & Widget-agnostic Data Grid (Phase 17)¶
Phase 17 | src/Pramnos/Application/Model.php
Phase 17 adds DataTables 2.x server-side output format to the existing _getApiList() method, unifies schema introspection across MySQL and PostgreSQL in _getJsonList(), and fixes a pre-existing empty-WHERE-clause bug in the paginated API path.
_getApiList($format = 'datatables')¶
A new 16th parameter $format (default '') wraps the standard API envelope in the DataTables 2.x format when set to 'datatables':
$result = $model->_getApiList(
fields: ['id', 'name', 'status'],
search: '',
order: 'id ASC',
filter: '',
join: '',
group: '',
table: 'users',
key: 'id',
page: 1,
itemsPerPage: 25,
format: 'datatables' // ← new param
);
// Returns: { draw, data, recordsTotal, recordsFiltered }
Standard format (default, $format = ''):
{
"data": [...],
"pagination": { "totalitems": 5, "currentpage": 1, "totalpages": 3 },
"fields": [...]
}
DataTables 2.x format ($format = 'datatables'):
The draw value echoes $_REQUEST['draw'] (anti-CSRF sequence counter used by the DataTables plugin). Defaults to 0 if the key is absent.
Both the paginated ($page > 0) and non-paginated ($page = 0) paths support the format parameter. For the non-paginated path, recordsTotal / recordsFiltered are derived from count($data).
BC note: All existing callers are unaffected — the parameter is additive and defaults to '' (standard format).
_getJsonList() — introspection unification¶
_getJsonList() (DataTables 1.9 legacy endpoint) previously ran SHOW COLUMNS inline — a MySQL-only statement that caused a syntax error on PostgreSQL. The inline query has been replaced with $this->_getAllTableFields(), which uses information_schema on PostgreSQL and SHOW COLUMNS on MySQL.
// Before (MySQL-only):
$fields = $db->query("SHOW COLUMNS FROM `$table`");
// After (cross-DB):
$fields = $this->_getAllTableFields();
_getJsonList() is marked @deprecated since v1.2. New code should use _getApiList() instead.
Empty-WHERE-clause bug fix¶
A pre-existing bug caused _getApiList() to emit WHERE (empty WHERE clause) when no filter or search was provided, resulting in a database syntax error on the paginated path. Root cause: a spurious leading space in the _combineFilters() call:
// Before (bug):
$finalFilter = ' ' . $this->_combineFilters($filter, $searchFilter);
// After (fix):
$finalFilter = $this->_combineFilters($filter, $searchFilter);
The fix is dialect-neutral (MySQL and PostgreSQL). Pagination now works correctly with no filter arguments.
_getJsonList() → delegate¶
_getJsonList() now delegates internally to _getApiList() instead of calling Datasource::getList() directly. This unifies the code path while preserving the DataTables 1.9 aaData/sEcho response format for backward compatibility:
// Internal flow (transparent to callers):
// _getJsonList() → _getApiList() → _getList() / _getPaginated()
// ↓
// convert associative rows → positional arrays
// ↓
// { aaData: [[v1, v2, ...], ...], iTotalRecords: N, sEcho: N }
The _jsonactions mechanism (Urbanwater BC — appends edit/delete links to rows) is preserved. Datasource::getList() is no longer called from _getJsonList().
Client-side adapters¶
Two thin JavaScript adapters ship in scaffolding/resources/vendor/pramnos/ and are copied to www/assets/vendor/pramnos/1.2.0/ by pramnos init:
pramnos-datatable.js — DataTables 2.x¶
Translates the DataTables serverSide protocol {draw, start, length, search, order, columns} to the Pramnos API format ?page=N&search=...&order=FIELD dir&fields=f1,f2.
<table id="users-table" class="table"
data-dt-api="/api/1.0/users">
<thead>...</thead>
</table>
<script>
$(function() {
PramnosDataTable.init('#users-table', {
columns: [
{ data: 'userid', title: 'ID' },
{ data: 'username', title: 'Username' }
]
});
});
</script>
PramnosDataTable.init(selector, options) reads the API URL from the data-dt-api attribute (or data-api), builds a fully-configured ajax block with serverSide parameter translation, and passes it to DataTables 2.x.
CSRF: reads <meta name="csrf-token"> (Phase 16) and sends X-CSRF-Token on every request.
pramnos-gridjs.js — Grid.js 6.x¶
Returns a configuration object for the Grid.js constructor:
var cfg = PramnosGridJS.createConfig('/api/1.0/users', {
fields: ['userid', 'username', 'email'],
itemsPerPage: 15,
search: true,
sort: true
});
new gridjs.Grid({
columns: cfg.columns,
server: cfg.server,
pagination: cfg.pagination,
search: cfg.search,
sort: cfg.sort
}).render(document.getElementById('grid-wrapper'));
Grid.js uses 0-based pages; the adapter converts to 1-based before sending to the API. The handle function converts {data, pagination} → {data, total} as Grid.js expects. CSRF token is injected via headers.
PramnosGridJS.init(container, apiUrl, columns, options) is a convenience wrapper that creates and renders the Grid instance in one call.
assets.json — bundled library entry¶
{
"pramnos-adapters": {
"version": "1.2.0",
"bundled": true,
"source_path": "resources/vendor/pramnos",
"js": ["pramnos-datatable.js", "pramnos-gridjs.js"],
"local_path": "assets/vendor/pramnos/1.2.0"
}
}
pramnos init copies these from the framework's scaffolding/resources/vendor/pramnos/ instead of downloading from a CDN (copyBundledAssets() in Init.php).
Scaffolding update¶
create:model no longer emits a getJsonList() method. The generated model has only getApiList(). The DataTables list view generated by create:migration → wizard uses the PramnosDataTable adapter:
<!-- table has data-dt-api attribute; adapter reads it automatically -->
<table id="entity-table" data-dt-api="<?php echo sURL;?>entity/getApiList?format=datatables">
</table>
<script>
$(function() {
PramnosDataTable.init('#entity-table', { columns: [...] });
});
</script>
The generated controller retains getApiList() as the single API endpoint; the legacy get{Class}() DT 1.9 endpoint is no longer generated.
Tests¶
tests/Characterization/Application/ModelListApiCharacterizationTest.php— 10 tests (MySQL)testGetApiListDataTablesFormatReturnsDrawDataRecordsOnMysqltestGetApiListDataTablesFormatNoPaginationOnMysqltestGetJsonListUsesAllTableFieldsAndReturnsAaDataOnMysqltestGetApiListWithPaginationReturnsPaginatedRows(updated — empty-WHERE fix)tests/Characterization/Application/ModelListApiPostgreSQLCharacterizationTest.php— 9 tests (PostgreSQL)testGetApiListDataTablesFormatOnPostgresqltestGetJsonListWorksOnPostgresqlAfterIntrospectionUnificationtestGetApiListWithPaginationReturnsPaginatedRows(updated — confirms fix is dialect-neutral)
69. Auth Feature Wiring in init app¶
Problem: When the auth feature was enabled during pramnos init app, the framework migrations created the users, sessions, and related tables, but the application was left with no login form, no logout action, and no navbar links — the auth infrastructure was present but completely unwired at the application layer.
Solution: The init app command now scaffolds three additional files when auth is included in the feature list:
Scaffolded files (auth feature only)¶
| File | Purpose |
|---|---|
src/Controllers/Login.php |
Login form display, POST handler, logout action |
src/Controllers/Account.php |
Thin wrapper extending \Pramnos\Auth\Controllers\Dashboard |
src/Views/login/login.html.php |
Bootstrap or plain-CSS login form view |
src/Views/account/dashboard.html.php |
Minimal account overview view |
Login controller¶
Login extends Controller and registers dologin and logout as no-render actions (they always redirect). Authentication is delegated to \Pramnos\Auth\Auth::getInstance()->auth() via the addon system, which picks up the UserDatabase addon automatically.
// POST /login/dologin
$auth = Auth::getInstance();
if ($auth->auth($username, $password, $remember)) {
$this->redirect(sURL);
} else {
$_SESSION['login_error'] = $auth->lastResponse['message'] ?? 'Invalid username or password.';
$this->redirect(sURL . 'login');
}
Account controller¶
Account extends \Pramnos\Auth\Controllers\Dashboard directly, making all framework account management actions (/account, /account/security, /account/changepassword, etc.) available under the app namespace:
class Account extends \Pramnos\Auth\Controllers\Dashboard
{
// Extend or override methods here as needed for this application.
}
Theme navbar¶
When auth is in the feature list, the generated app/themes/default/header.php includes a PHP conditional block that renders Login/Logout/Account links based on session state:
<?php if (\Pramnos\Http\Session::staticIsLogged()): ?>
<li ...><a ... href="<?php echo sURL; ?>account">Account</a></li>
<li ...><a ... href="<?php echo sURL; ?>login/logout">Logout</a></li>
<?php else: ?>
<li ...><a ... href="<?php echo sURL; ?>login">Login</a></li>
<?php endif; ?>
Without auth the nav is purely static.
Tests¶
tests/Unit/Console/InitCommandUnitTest.php — 6 new tests:
- testAuthFeatureScaffoldsLoginController — Login.php exists, correct namespace, all 3 actions present, noRender registered
- testAuthFeatureScaffoldsAccountControllerExtendingDashboard — Account.php extends \Pramnos\Auth\Controllers\Dashboard
- testAuthFeatureScaffoldsLoginView — view exists, posts to dologin, has username/password fields, shows error
- testAuthFeatureAddsAuthLinksToBootstrapNavbar — header contains staticIsLogged(), login/logout/account hrefs
- testNoAuthFeatureOmitsAuthLinksFromNavbar — header has no staticIsLogged() or logout link without auth feature
- testNoAuthFeatureSkipsLoginAndAccountControllers — neither Login.php nor Account.php created without auth feature
70. AuthServer + Logs Wiring in init app¶
Problem: After session 113, auth was wired but authserver (OAuth2 server) was left unwired — no controller wrapper existed to route /oauth/authorize, /oauth/token etc. Similarly, every new app should expose the log viewer at /logs (following the Urbanwater pattern) but no Logs controller was scaffolded.
Solution: Two new scaffolding methods in Init::execute():
authserver feature → src/Controllers/Oauth.php¶
A thin wrapper extending \Pramnos\Auth\Controllers\Oauth so all OAuth2 server endpoints are routable. The OAuth2 consent views (authorize, authorized_applications, change_password, delete_account, privacy_settings, security) already exist as scaffolding fallback views in all themes and do not need to be copied into the app.
class Oauth extends \Pramnos\Auth\Controllers\Oauth
{
// Extend or override endpoints here as needed for this application.
}
Routes available after scaffolding: /oauth/authorize, /oauth/token, /oauth/revoke, /oauth/introspect, /oauth/userinfo, /oauth/logout, /oauth/deviceauthorization.
Always → src/Controllers/Logs.php¶
Scaffolded for every new application (no feature flag needed). Follows the Urbanwater pattern: thin wrapper extending \Pramnos\Application\Controllers\LogController. Developers override $whitelist and $blacklist to control visible log files.
Authentication is enforced by the framework LogController via addAuthAction.
Theme navbar updates¶
- Logs link (
/logs) — always present in the navbar, visible to all users (the controller's auth guard redirects unauthenticated requests to login) - OAuth Apps link (
/oauth) — added to navbar only whenauthserverfeature is enabled
Note on view fallback¶
_getScaffoldingFallbackDirs() in Controller resolves views against scaffolding/themes/{uiSystem}/views/ when no app-level view is found. This means all OAuth2 and auth views (login, 2FA, forgot password, account dashboard, consent form, etc.) work out-of-the-box without being copied to the app. Only the controller wrappers are required for URL routing.
Tests¶
tests/Unit/Console/InitCommandUnitTest.php — 5 new tests:
- testAuthserverFeatureScaffoldsOauthControllerWrapper — Oauth.php exists, correct namespace, extends framework Oauth
- testNoAuthserverFeatureSkipsOauthController — Oauth.php not created without authserver feature
- testAuthserverFeatureAddsOauthLinkToNavbar — header contains /oauth href and "OAuth Apps" text
- testLogsControllerIsAlwaysScaffolded — Logs.php exists in every app, extends LogController
- testLogsLinkAlwaysInNavbar — /logs href always present in navbar
Phase 25.3 — MD5 Legacy Password Support with Auto-Upgrade¶
Pramnos\Addon\Auth\UserDatabase::onAuth()¶
The MD5 password fallback path is now opt-in and supports automatic upgrade to bcrypt on successful login.
Configuration (in app.php)¶
'auth' => [
'legacy_md5' => true, // default: false — must be explicitly enabled for legacy apps
'auto_upgrade' => true, // default: true — upgrade MD5 → bcrypt on next login
],
Behavior matrix¶
legacy_md5 |
auto_upgrade |
MD5 password | Result |
|---|---|---|---|
false (default) |
any | present | Rejected (400 Wrong Password) |
true |
true |
present | Accepted + hash upgraded to bcrypt in DB |
true |
false |
present | Accepted, hash left unchanged |
| any | any | bcrypt | Normal bcrypt path, no change |
Upgrade mechanics¶
When legacy_md5 + auto_upgrade are both true and a plain-MD5 match is detected:
- A new bcrypt hash is computed:
password_hash($password . md5($salt . $userid), PASSWORD_DEFAULT) - An
UPDATE users SET password = ?is executed immediately in the same request - The response
authfield carries the new bcrypt hash
This ensures that each login transparently migrates the credential without requiring any batch migration script.
Phase 25.2 — DatabaseAuthDriver: Native Authentication (No Addon Required)¶
Authentication now works out of the box without registering Addon\Auth\UserDatabase in app.php. The DatabaseAuthDriver is the built-in default and verifies credentials against the users table automatically.
New classes¶
Pramnos\Auth\Drivers\AuthDriverInterface¶
interface AuthDriverInterface {
public function verify(
string $username,
string $password,
bool $encryptedPassword = false
): AuthResult;
}
Implement this interface to supply a custom authentication backend (LDAP, OAuth2 introspection, API token, etc.).
Pramnos\Auth\Drivers\AuthResult¶
Immutable value object returned by every driver:
readonly class AuthResult {
public bool $success;
public int $statusCode;
public string $message;
public string $username;
public int $uid;
public string $email;
public string $auth;
public static function success(string $username, int $uid, string $email, string $auth, int $statusCode = 1): self;
public static function failure(string $message, int $statusCode = 0): self;
// Convert to legacy array for Addon\User\User::onLogin() / Auth::$lastResponse
public function toArray(bool $remember = true): array;
}
Status codes follow the UserDatabase convention: 0=inactive, 2=deleted, 5=banned, 400=wrong password, 404=not found.
Pramnos\Auth\Drivers\DatabaseAuthDriver¶
Default driver — extracts the logic from Addon\Auth\UserDatabase::onAuth():
$driver = new DatabaseAuthDriver([
'legacy_md5' => false, // default: reject MD5 hashed passwords
'auto_upgrade' => true, // default: upgrade MD5 → bcrypt on first login
]);
$result = $driver->verify('alice', 'my_password');
if ($result->success) {
// $result->username, ->uid, ->email, ->auth
}
Config can also be set in app.php under the 'auth' key:
app.php.
Modified class: Pramnos\Auth\Auth¶
New methods¶
| Method | Description |
|---|---|
setDriver(AuthDriverInterface $driver): static |
Replace all drivers with a single one |
addDriver(AuthDriverInterface $driver): static |
Append a driver to the chain |
clearDrivers(): static |
Remove all drivers (used in tests; also causes warning+false if no addons) |
Updated auth() resolution order¶
- Addon-based handlers (
Addon\Auth\*) — tried first for backward compatibility - Registered
AuthDriverInterfacedrivers (or defaultDatabaseAuthDriverif none configured) - If neither is available → log warning + return
false
Backward compatibility¶
Apps that already have Addon\Auth\UserDatabase in app.php continue to work identically — the addon path is tried first. Addon\Auth\UserDatabase is now @deprecated (the driver is preferred) but remains functional.
Phase 25.5 — SessionTrackingMiddleware + BotDetector¶
Extracts Addon\System\Session::onAppInit() into an explicit, opt-in middleware. DB session tracking is no longer a hidden side-effect of the addon boot.
New classes¶
Pramnos\Http\Middleware\BotDetector¶
Standalone bot-detection service with 100+ patterns:
$detector = new BotDetector();
$detector->isBot('Mozilla/5.0 (compatible; Googlebot/2.1; ...)'); // true
$detector->isBot('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...'); // false
$detector->botName('Mozilla/5.0 (compatible; Googlebot/2.1; ...)'); // 'Googlebot'
Pramnos\Http\Middleware\SessionTrackingMiddleware¶
Opt-in middleware that replaces Addon\System\Session. Opt in via app.php:
What it does per request:
1. Deletes expired session rows (time < now-300)
2. Detects bots via BotDetector
3. Manages visitorid cookie + lastseen tracking
4. Resolves real IP (Cloudflare CF-Connecting-IP aware)
5. Force-logout: if sessions.logout=1 for this visitor, clears session + auth
6. Upserts a row in the sessions table
Custom BotDetector injection (for testing):
Backward compatibility¶
Addon\System\Session remains functional (@deprecated). Apps that register it in app.php under 'addons' continue to work unchanged.
Phase 25.4 — Built-in Login/Logout Lifecycle¶
The session-variable, cookie, and database-update work that Addon\User\User performed on every login/logout is now built into Auth::auth() and Auth::logout(). New apps work without any user addon registered in app.php.
Modified class: Pramnos\Auth\Auth¶
New methods¶
| Method | Description |
|---|---|
afterLogin(callable(array): void $callback): static |
Register a callback invoked after every successful login (after built-in lifecycle) |
afterLogout(callable(): void $callback): static |
Register a callback invoked after every logout (after built-in lifecycle) |
Built-in login lifecycle (Phase 25.4)¶
When no Addon\User\* addon is registered, Auth::auth() automatically:
- Sets
$_SESSION['logged'],['uid'],['username'],['auth'] - Sets cookies (
logged,uid,username,auth,language) for users with uid > 1 when$remember=true UPDATE sessions SET uname, time, guest=0 WHERE host_addr(graceful, exception-caught)UPDATE users SET lastlogin, language WHERE userid(graceful, exception-caught)
Built-in logout lifecycle (Phase 25.4)¶
When no Addon\User\* addon is registered, Auth::logout() automatically:
DELETE FROM sessions WHERE uname(graceful)- Clears cookies:
logged,uid,username,auth,language - Calls
Session::reset() - Sets
$_SESSION['logged'] = false
Example: custom hook after login¶
$auth = Auth::getInstance();
$auth->afterLogin(function (array $response) {
// e.g. notify an audit service
AuditLog::record('login', $response['uid'], $response['username']);
});
Backward compatibility¶
Apps with Addon\User\User registered in app.php continue to work identically — the addon path is taken when detected, bypassing the built-in lifecycle. Addon\User\User is now @deprecated but remains functional.
Phase 25.6 — Warning When No Auth Handlers Are Registered¶
Pramnos\Auth\Auth::auth()¶
When auth() is called with an empty addon registry AND no drivers registered (e.g. after clearDrivers()), the method logs a warning to auth.log and returns false.
Auth::auth() — no auth handlers registered. Add an auth addon
(e.g. Pramnos\Addon\Auth\UserDatabase) to your app.php 'addons' array.
In normal operation the default DatabaseAuthDriver handles auth automatically, so this warning fires only when explicitly cleared (rare — mainly useful in tests).
Phase 24 — Navigation Registry (NavRegistry)¶
Replaces all hardcoded navbar links with a central registry. The theme header.php now calls NavRegistry::getForUser() once and iterates — no feature-conditional PHP inside any template.
New classes¶
Pramnos\Application\NavSection (enum)¶
enum NavSection: string {
case Main = 'main';
case User = 'user';
case Admin = 'admin';
case Feature = 'feature';
}
Pramnos\Application\NavItem (readonly class)¶
| Property | Type | Description |
|---|---|---|
id |
string |
Unique key; re-registering by same id replaces the previous entry |
label |
string |
Display text |
url |
string |
Full URL |
section |
NavSection |
Which nav cluster |
position |
int |
Sort order within section (lower = first) |
requireAuth |
bool |
Hidden for guests when true |
minUserType |
int |
Minimum usertype integer (0 = any authenticated user) |
permission |
?string |
RBAC permission name; null skips RBAC check |
feature |
?string |
Required feature key; null = always visible |
icon |
?string |
Optional icon CSS class |
guestOnly |
bool |
Hidden for authenticated users when true (e.g. Login link) |
Pramnos\Application\NavRegistry (static registry)¶
| Method | Description |
|---|---|
register(NavItem) |
Add or replace item by id |
remove(string $id) |
Remove item (silent no-op if not found) |
reset() |
Clear all items (test isolation) |
getIds(): string[] |
List registered ids |
getForUser(?User, array $features): array<string, NavItem[]> |
Filtered + sorted by section |
Filtering rules (all must pass):
0. guestOnly=true + user is logged in → hidden (hides Login link after login)
1. requireAuth=true + guest → hidden
2. minUserType > 0 + user->usertype < min → hidden
3. permission set + RBAC active → PermissionEngine::userHas() check
4. permission set + RBAC inactive → permission check skipped (fallback to minUserType)
5. feature set + feature not in $enabledFeatures → hidden
Application::registerDefaultNavItems(array $features)¶
Called automatically at the end of init(). Registers the following items by default:
| id | section | minUserType | guestOnly | feature |
|---|---|---|---|---|
main.home |
Main | — | false | — |
user.login |
User | — | true | — |
user.account |
User | 1 | false | — |
user.logout |
User | 1 | false | — |
admin.users |
Admin | 80 | false | — |
admin.settings |
Admin | 80 | false | — |
admin.logs |
Admin | 80 | false | — |
admin.oauth |
Admin | 80 | false | authserver |
Applications can call NavRegistry::remove($id) after init() to suppress unwanted items or NavRegistry::register() to add their own.
Scaffolded header.php¶
All three scaffold themes (plain-css, bootstrap, tailwind) now produce a generic, feature-agnostic nav snippet:
$_nav = \Pramnos\Application\NavRegistry::getForUser(
\Pramnos\User\User::getCurrentUser(),
Application::getInstance()->applicationInfo['features'] ?? []
);
// then iterate over NavSection::Main, ::User, ::Admin, ::Feature
No more feature conditionals inside any template.
Phase 23 — Framework Admin CRUD Controllers¶
23.1 Pramnos\Application\Controllers\UsersController¶
Full CRUD + account management for the #PREFIX#users table.
| Action | Method | Description |
|---|---|---|
display() |
GET /users | DataTable list (500 rows, ordered by userid DESC) |
view(?int $id) |
GET /users/view/:id | Read-only detail page: profile, stats, recent tokens |
edit(?int $id) |
GET /users/edit/:id | Create/edit form |
save() |
POST /users/save | Create or update user |
delete(?int $id) |
GET /users/delete/:id | Deactivate user (active=0) |
lock(?int $id) |
GET /users/lock/:id | Set active=0 |
unlock(?int $id) |
GET /users/unlock/:id | Set active=1 |
sessions(?int $id) |
GET /users/sessions/:id | List sessions for user |
tokens(?int $id) |
GET /users/tokens/:id | List and manage tokens for user |
deactivateToken() |
POST /users/deactivateToken | Deactivate a token (status=0) |
deleteToken() |
POST /users/deleteToken | Delete a token (status=2) |
resetpassword(?int $id) |
GET /users/resetpassword/:id | Send password reset email (admin-initiated) |
view() loads: user object, getDataUsageStats() (total tokens, unique apps), active session count from sessions table, and the 5 most recent tokens.
resetpassword() generates a bin2hex(random_bytes(32)) token, stores it via User::addToken('password_reset', $token), and emails the user a link. The link URL defaults to sURL . 'home/resetpassword/' . $token; override getPasswordResetUrl(string $token): string in a subclass to use a different route.
All actions require authentication (addAuthAction) + requiredUserType >= 80.
23.5 Pramnos\Application\Controllers\SettingsController¶
CRUD for the #PREFIX#settings key-value table.
| Action | Method | Description |
|---|---|---|
display() |
GET /settings | DataTable list of all settings |
edit(?string $key) |
GET /settings/edit/:key | Create/edit form |
save() |
POST /settings/save | Create or update setting |
delete(?string $key) |
GET /settings/delete/:key | Remove setting |
$readonlyKeys (protected, overridable): hostname, database, schema, user, password, collation, prefix, type, cache — these cannot be modified via the UI.
23.2 Pramnos\Auth\Controllers\ApplicationsController¶
OAuth2 client application management. Feature-gated: requires authserver.
| Action | Description |
|---|---|
display() |
DataTable of registered OAuth2 apps |
view(?int $id) |
Read-only detail page: credentials, token stats (total/active/revoked), recent users |
edit(?int $id) |
Create/edit form |
save() |
Create (generates apikey/apisecret via random_bytes) or update |
delete(?int $id) |
Revoke all active tokens (status=3), then soft-delete app (status=0) |
tokens(?int $id) |
List active tokens for an application |
rotate(?int $id) |
Regenerate apisecret without touching existing tokens |
view() shows full application metadata, the API key with a copy button, the secret (toggle-visible), and the 5 most recent users who accessed the app via their token's lastused timestamp.
requiredUserType >= 90 (admin level — OAuth2 management is more sensitive than manager operations).
23.3 Pramnos\Auth\Controllers\TokensController¶
OAuth2 access token management. Feature-gated: requires authserver.
| Action | Description |
|---|---|
display() |
DataTable of tokens with optional filters (userid, applicationid, status) |
revoke(?int $id) |
Revoke a single token (status=3, removedate=time()) |
revokeall() |
Bulk-revoke; requires at least one filter (userid or applicationid) to prevent full-table revocation |
requiredUserType >= 90.
23.4 Pramnos\Auth\Controllers\PermissionsController¶
RBAC permission grant management. Feature-gated: requires authserver.
| Action | Description |
|---|---|
display() |
DataTable with filters: subject_type, subject_id, object_type, action |
edit(?int $id) |
Create/edit form |
save() |
Create or update permission grant |
delete(?int $id) |
Hard-delete permission grant |
assign() |
Quick-assign a named permission to a user (subject_type='user') |
requiredUserType >= 90 — prevents manager-level self-escalation.
23.6 Pramnos\Application\Controllers\EmailsController¶
Email log viewer. Always scaffolded when the mails table is present (messaging feature migration).
| Action | Description |
|---|---|
display() |
DataTable of sent emails (recipient, subject, sent_at, status) |
show(?int $id) |
Full email preview (HTML body) |
resend(?int $id) |
Re-queue a failed email (status=0→2); sent emails cannot be re-queued |
requiredUserType >= 80. All three actions are auth-protected.
23.7 Pramnos\Queue\Controllers\QueueController¶
Job queue management. Feature-gated: requires queue.
| Action | Description |
|---|---|
display() |
DataTable of queue items filtered by status (pending/processing/completed/failed/deleted) |
retry(?int $id) |
Reset a failed job to pending |
retryall() |
Reset all failed jobs to pending |
delete(?int $id) |
Soft-delete a single job (status='deleted') |
clear() |
Soft-delete jobs by status; only 'failed', 'completed', 'deleted' are allowed — not 'pending'/'processing' |
stats() |
JSON: counts per status, throughput, avg processing time (backend-specific: TIMESTAMPDIFF on MySQL, EXTRACT(EPOCH FROM …) on PostgreSQL) |
requiredUserType >= 80. Hard DELETE is never performed — all removals use soft-delete via status field.
23.8 Pramnos\Application\Controllers\ServicesController¶
Daemon/worker lifecycle management. Always scaffolded.
| Action | Description |
|---|---|
display() |
List of registered services with enriched status (running/stopped/error), PID, uptime |
stop(?string $name) |
Write {lockFile}.stop sentinel file to request graceful stop |
start(?string $name) |
Remove .stop sentinel file to allow DaemonOrchestrator to respawn |
restart(?string $name) |
Stop + start in sequence |
logs(?string $name) |
Tail last N lines (maxLogLines=200) from ROOT/var/logs/{daemon}-{workerId}.log |
status() |
JSON status summary of all services |
State is read from ROOT/var/daemon_orchestrator_state.json. The controller does not spawn processes directly — it uses the stop-file sentinel mechanism; the DaemonOrchestrator CLI command handles actual spawning.
enrichServiceEntry() (private): computes status field — 'running' (lock file + PID alive), 'stopped' (no lock file or dead PID), 'error' (stop-file present = stop requested but process still running).
requiredUserType >= 80.
23.9 Pramnos\Application\Controllers\OrganizationsController¶
Organization management. Always scaffolded; uses existing organizations + user_organizations migrations.
| Action | Description |
|---|---|
display() |
DataTable of organizations |
edit(?int $id) |
Create/edit form |
save() |
Create or update organization |
delete(?int $id) |
Soft-delete: is_active = 0 |
members(?int $id) |
List users belonging to the organization |
addmember(?int $orgId) |
Add a user to an organization |
removemember(?int $orgId, ?int $userId) |
Remove a user from an organization |
resolveOrgMembershipTable() and resolveOrgColumn() read authserver_organization_table and authserver_organization_column settings to allow schema customisation.
requiredUserType >= 80.
23.10 Pramnos\Auth\Controllers\TokenActionsController¶
Read-only API audit log. Always scaffolded when auth feature is enabled.
| Action | Description |
|---|---|
display() |
DataTable with filters: token_id, user_id, status_code, date_from, date_to |
show(?int $id) |
Full detail view of a single audit log entry |
stats() |
Performance dashboard delegating to ApiPerformanceService |
export() |
CSV export with same filters as display(); capped at maxExportRows=10000; streams directly via php://output + fputcsv |
Intentionally read-only — no write/delete actions to protect audit log integrity.
requiredUserType >= 80.
Scaffold wiring (complete)¶
pramnos init app scaffolds the following wrapper controllers:
| Wrapper file | Source controller | Condition |
|---|---|---|
src/Controllers/Users.php |
UsersController |
always |
src/Controllers/Settings.php |
SettingsController |
always |
src/Controllers/Dashboard.php |
DashboardController |
always |
src/Controllers/Services.php |
ServicesController |
always |
src/Controllers/Organizations.php |
OrganizationsController |
always |
src/Controllers/Emails.php |
EmailsController |
always |
src/Controllers/TokenActions.php |
TokenActionsController |
auth feature |
src/Controllers/Applications.php |
ApplicationsController |
authserver feature |
src/Controllers/Tokens.php |
TokensController |
authserver feature |
src/Controllers/Permissions.php |
PermissionsController |
authserver feature |
src/Controllers/Queue.php |
QueueController |
queue feature |
Scaffolded tests (non-placeholder)¶
pramnos init app now generates real test files instead of assertTrue(true) placeholders:
tests/Unit/Controllers/HomeControllerTest.php— always: verifies class hierarchytests/Unit/Controllers/LoginControllerTest.php— auth feature: verifies action registrationtests/Integration/AuthFlowTest.php— auth feature: end-to-end login flow against real DB
Phase 23.11 — Statistics & Analytics Dashboard¶
Pramnos\Application\Statistics\ActiveUsersService¶
Queries #PREFIX#sessions to count authenticated (non-guest, guest=0) active users across five standard time windows.
| Method | Description |
|---|---|
getCounts(): array |
Returns {now, last_1h, last_24h, last_7d, last_30d} counts |
countSince(int $since): int |
Authenticated sessions since Unix timestamp |
countAllSince(int $since): int |
All sessions (including guests) since timestamp |
Time-window constants: WINDOW_NOW=300, WINDOW_1H=3600, WINDOW_24H=86400, WINDOW_7D=604800, WINDOW_30D=2592000.
Pramnos\Application\Statistics\DatabaseStatsService¶
Collects DB server metrics using backend-specific queries. Degrades gracefully when stat views are inaccessible (returns null for each metric).
| Method | Description |
|---|---|
getStats(): array |
Returns metrics; shape depends on backend (see below) |
Common keys: type, version, db_size_bytes, connections_total, connections_active, cache_hit_ratio.
version is a human-readable string such as "PostgreSQL 14.5" or "MySQL 8.0.36", or null if the query fails.
PostgreSQL-only: xact_commit, xact_rollback.
MySQL-only: queries.
Pramnos\Application\Statistics\ApiPerformanceService¶
Queries #PREFIX#tokenactions for throughput and latency metrics. Falls back gracefully when the table or nullable columns do not yet exist.
| Method | Description |
|---|---|
getSummary(int $window = WINDOW_24H): array |
Throughput, error rate, avg/p95/p99 latency, status breakdown |
getTopSlowEndpoints(int $n, int $window): array |
Top N endpoints sorted by avg execution time |
getTopCalledEndpoints(int $n, int $window): array |
Top N most-called endpoints |
Time-window constants: WINDOW_1H, WINDOW_24H, WINDOW_7D. p95/p99 use PERCENTILE_CONT on PostgreSQL and nearest-rank OFFSET approximation on MySQL.
Pramnos\Application\Controllers\DashboardController¶
Admin/ops overview controller. Distinct from \Pramnos\Auth\Controllers\Dashboard (user account management).
| Action | Description |
|---|---|
display() |
HTML overview: active users + DB stats + API performance (24h) + health badges |
activeusers() |
JSON: active user counts per window |
apistats() |
JSON: API performance summary; accepts ?window= query param |
dbstats() |
JSON: DB server metrics |
database() |
HTML detail page: processes, replication, table sizes, public views, TimescaleDB detail |
cache() |
HTML detail page: cache adapter, namespace cards, item browser (key/size/TTL/type), clear button |
cacheitem() |
JSON GET ?key=…: single cache item content + metadata (size, type, created, TTL) |
clearcache() |
JSON POST: clears all cache entries via Cache::clear() |
database() delegates to DatabaseInspector and TimescaleInspector (see below).
cache() calls Cache::getStats(), getCategories(), and getAllItems() per category (limit 50 items). Detects Memcached limitation (item enumeration unavailable) and shows an explanatory banner instead.
All eight actions are auth-protected (addAuthAction). requiredUserType = 80 (manager level).
Pramnos\Database\Inspector\DatabaseInspector¶
Queries database-engine internals for admin/ops dashboards. All methods return empty arrays on unsupported databases or when the feature is unavailable.
| Method | Description |
|---|---|
getProcessList(): array |
PostgreSQL: pg_stat_activity (pid, user, datname, application, client_addr, backend_start, duration_sec, state, query); MySQL/MariaDB: SHOW PROCESSLIST |
getTableSizes(): array |
Top 30 tables by total bytes. PostgreSQL: pg_total_relation_size + pg_relation_size + reltuples estimate; MySQL: information_schema.tables |
getReplicationStatus(): array |
PostgreSQL only — pg_stat_replication rows (client_addr, state, sync_state, lag_sec). Empty on non-PostgreSQL or standalone. |
getPublicViews(): array |
PostgreSQL only — information_schema.views WHERE table_schema='public' (view_name, view_definition) |
Each method has its own try/catch so a single failing query does not suppress the rest.
Pramnos\Database\Inspector\TimescaleInspector¶
Queries TimescaleDB extension metadata. Returns an empty result set on non-PostgreSQL databases or when the TimescaleDB extension is not installed.
getData() returns:
[
'ts_version' => string|null, // e.g. "2.14.2"; null = not installed
'hypertables' => array,
'aggregates' => array,
'jobs' => array,
'jobHistory' => array, // last 200 execution records
'chunkCount' => int,
]
Detection uses two mechanisms:
1. Live pg_extension query — returns actual version string.
2. Database::$timescale === true fallback — returns 'unknown' when config marks the connection as TimescaleDB but the extension row is temporarily missing (e.g. during upgrade).
getContinuousAggregates() has a fallback query that omits materialization_hypertable_schema for compatibility with older TimescaleDB versions. Each sub-query (getHypertables, getScheduledJobs, getJobHistory, getChunkCount) has its own try/catch so a single failing query does not hide ts_version or other data.
Scaffold wiring update¶
pramnos init app now also scaffolds src/Controllers/Dashboard.php (always, alongside Users and Settings wrappers).
Phase 13: MCP Server — AI Developer Tooling¶
Stdio-based MCP (Model Context Protocol) server that allows AI assistants (Claude Code, Copilot, etc.) to explore the application's schema, migrations, routes, and models without a separate npm-based DB proxy.
Pramnos\Mcp\McpServer¶
JSON-RPC 2.0 message loop over stdio. Dispatches MCP protocol methods:
| Method | Description |
|---|---|
initialize |
Handshake — returns protocolVersion, capabilities, serverInfo |
tools/list |
Enumerate all registered tools |
tools/call |
Invoke a tool by name, return content or isError response |
resources/list |
Enumerate registered file resources |
resources/read |
Return the content of a resource by URI |
ping |
Keepalive — returns empty result |
$server = new McpServer('MyApp', '1.0.0');
$server->addTool(new ListTablesTool($db));
$server->addResource(new McpResource('file://CLAUDE.md', 'Project guide', ROOT.'/CLAUDE.md'));
$server->run(); // reads STDIN, writes STDOUT
Pramnos\Mcp\McpToolInterface¶
Contract for pluggable tools:
| Method | Return | Description |
|---|---|---|
name(): string |
string |
Machine-readable ID (e.g. 'list-tables') |
description(): string |
string |
One-sentence description shown in tools/list |
inputSchema(): array |
array |
JSON Schema of the input parameters |
execute(array $input): mixed |
mixed |
Execute the tool; return any JSON-serialisable value |
Pramnos\Mcp\McpResource¶
Immutable readonly value object representing a file resource:
new McpResource(uri: 'file://CLAUDE.md', name: 'Project guide', filePath: ROOT.'/CLAUDE.md', mimeType: 'text/plain')
| Method | Description |
|---|---|
read(): ?string |
Returns file content, or null if missing/unreadable |
toListItem(): array |
Serializes to MCP list-item format {uri, name, mimeType} |
Built-in Tools¶
| Tool name | Class | Description |
|---|---|---|
list-tables |
ListTablesTool |
All tables + approximate row counts (MySQL + PostgreSQL) |
query-schema |
QuerySchemaTool |
Columns, types, indexes, foreign keys for a specific table |
migration-status |
MigrationStatusTool |
Pending/applied count, pending slugs, last applied migration |
model-inspect |
ModelInspectTool |
Reflects on an OrmModel class — table, fillable, casts, relations, methods |
route-list |
RouteListTool |
All registered routes with method, URI, action, permissions |
Pramnos\Mcp\McpServiceProvider¶
Opt-in service provider. Feature key: 'mcp'.
Registers McpServer as mcp.server singleton in the container. On boot, auto-wires all five built-in tools and exposes standard file resources (CLAUDE.md, README.md, app/app.php).
Custom tools can be added in a downstream service provider:
pramnos mcp:serve CLI Command¶
Starts the MCP server on stdio. Suitable for .mcp.json configuration:
If McpServiceProvider has been booted (mcp feature enabled in app.php), the command uses the container-bound server with any app-specific tools. Otherwise it builds a default server with the five built-in tools.
§66 — Debug Toolbar (Phase 13)¶
HTML debug toolbar που εγχέεται αυτόματα πριν το </body> κάθε HTML response κατά το development. Zero external dependencies — pure PHP + inlined CSS/JS (Catppuccin Mocha theme).
Architecture¶
| Class | Description |
|---|---|
Pramnos\Debug\DebugBar |
Singleton. Holds registered collectors; renders the HTML widget |
Pramnos\Debug\DebugBarMiddleware |
Injects toolbar before </body>; passes non-HTML unchanged |
Pramnos\Debug\DebugBarServiceProvider |
Auto-activates in development/debug mode; uses ob_start() to intercept output |
Pramnos\Debug\Collectors\CollectorInterface |
Contract: name(): string + collect(): array |
Collectors¶
| Collector | Tab label example | What it shows |
|---|---|---|
QueryCollector |
SQL (3 · 42ms) |
All logged queries with SQL, time, slow-query highlight (>100ms) |
TimeCollector |
Time (123ms) |
Request wall-clock time + named timers (including migration segments) |
MemoryCollector |
Mem (4.2 MB) |
Peak + current memory usage |
RouteCollector |
Route |
Matched URI, method, action |
LogCollector |
Logs (5) |
Last N log entries (ring buffer, configurable cap) |
SessionCollector |
Session (3 keys) |
Session data; sensitive keys (password, auth, token…) masked with *** |
MigrationsCollector |
Migrations (2 ran) |
Migrations that ran this request + full history from schemaversion |
Pramnos\Debug\DebugBar¶
$bar = DebugBar::getInstance();
$bar->addCollector(new QueryCollector($db));
$bar->addCollector(new TimeCollector());
// Convenience timer statics
DebugBar::startTimer('render');
// ... work ...
DebugBar::stopTimer('render');
// Migration recording (called automatically by Application::runAutoMigrations)
DebugBar::recordMigration('2026_06_01_000001_create_foo', 42.5, 'ran');
$html = $bar->render(); // '' when no collectors registered
Key methods:
| Method | Description |
|---|---|
getInstance(): static |
Singleton accessor |
reset(): void |
Resets singleton — for tests only |
addCollector(CollectorInterface $c): static |
Register a collector |
getCollector(string $name): ?CollectorInterface |
Retrieve by name |
startTimer(string $name): void |
Delegates to registered TimeCollector |
stopTimer(string $name): void |
Delegates to registered TimeCollector |
recordMigration(string $slug, float $ms, string $status = 'ran'): void |
Records a migration in both timeline and Migrations tab |
render(): string |
Returns full HTML widget, or '' if no collectors |
TimeCollector::addCompletedSegment()¶
Retroactively inserts a timeline segment for work that has already completed (e.g. a migration that ran before the collector was consulted). The start offset is back-calculated from now − durationMs.
// Add a 100ms segment that ended ~100ms ago
$tc->addCompletedSegment('migration:create_foo', 100.0);
Pramnos\Debug\DebugBarMiddleware¶
// Manual wiring (if not using the service provider)
$middleware = new DebugBarMiddleware(DebugBar::getInstance());
$response = $middleware->handle($request, fn() => $innerResponse);
Detects HTML by presence of </body> tag. Non-HTML responses (JSON, images, etc.) pass through unchanged.
Pramnos\Debug\DebugBarServiceProvider¶
Auto-activation: The toolbar activates automatically when any of the following is true — no explicit feature declaration needed:
| Condition | Notes |
|---|---|
APP_DEBUG env var is set and not '0'/'false' |
Standard env-based flag |
DEVELOPMENT PHP constant is true |
Defined in bootstrap |
'debug' app setting is truthy |
settings.php key debug |
'development' app setting is truthy |
settings.php key development |
Optional explicit feature key 'debug' (for apps that want explicit control):
// app.php — optional, the bar activates automatically during development
'features' => ['auth', 'debug'],
Uses ob_start() with a callback to inject the toolbar at the end of the request lifecycle — no dependency on router access. Non-HTML responses (JSON, redirects) pass through unchanged.
Also calls $db->enableQueryLog() on the registered database connection so that QueryCollector has data.
Database::enableQueryLog() / getQueryLog() / clearQueryLog()¶
Opt-in, zero-overhead query logging added to Pramnos\Database\Database:
$db->enableQueryLog();
// ... run queries ...
$log = $db->getQueryLog();
// [['sql' => '...', 'time' => 0.023, 'at' => 1716123456.789], ...]
$db->clearQueryLog();
When logging is disabled (default), no memory is consumed.
pramnos debug:status CLI Command¶
Prints:
- APP_DEBUG env var value
- debug setting value
- Whether the toolbar is active
- Xdebug: loaded/version/mode/port 9003
§67 — DevPanel: Developer / Admin Dashboard (Phase 14)¶
Web-accessible developer dashboard. Opt-in via 'devpanel' feature flag.
Outputs a self-contained HTML page (Catppuccin Mocha dark theme, no app theme dependency) and exits — works with any host application.
Activation¶
Access at yourapp.test/devpanel (controller=devpanel) — requires usertype >= 90 by default.
Tabs / Actions¶
| Action | URL | Description |
|---|---|---|
display |
/devpanel |
Overview: DB, PHP, memory, uptime, git HEAD, migrations, queue |
db |
?action=db |
Tables by size; TimescaleDB hypertables |
cache |
?action=cache |
Cache stats (adapter, items, namespaces); item browser (top-100, namespace filter); flush button (POST) |
cache (item inspector) |
?action=cache&key=X |
AJAX: JSON {ok, key, content} — var_export() of item, truncated to 50 KB |
users |
?action=users |
Active sessions (with clickable token/user links); login lockouts |
users (token detail) |
?action=users&token=X |
Paginated tokenactions history for token X (50/page) |
users (user log) |
?action=users&user=X |
Paginated userlog entries for user X (50/page) |
performance |
?action=performance |
Slowest endpoints + slowest users/applications — time range filter |
git |
?action=git |
Branch, hash, subject, author, date, local branches, remotes |
phpinfo |
?action=phpinfo |
phpinfo() — inner content only, no double wrapping |
Auth policy¶
Or inject a custom closure in a downstream ServiceProvider:
$ctrl = new DevPanelController();
$ctrl->policyCallback = fn($user) => $user?->can('devpanel.access');
Pramnos\Application\Controllers\Devpanel¶
Framework routing bridge — inherits from DevPanelController and sits in Pramnos\Application\Controllers\ so getFrameworkController() resolves it automatically. No explicit route registration required.
Pluggable Panels¶
Applications can add custom tabs to the DevPanel at any point before the first request (typically in a ServiceProvider):
use Pramnos\DevPanel\DevPanelController;
DevPanelController::registerPanel(
slug: 'myapp', // URL: ?action=myapp
label: 'My App', // Tab label in navigation bar
renderer: function(): string {
return '<p>Custom panel HTML.</p>';
},
);
API
| Method | Description |
|---|---|
registerPanel(string $slug, string $label, callable $renderer): void |
Register a custom panel. $renderer returns the HTML string for the panel body. |
getCustomPanels(): array |
Returns all registered panels (for inspection / testing). |
resetCustomPanels(): void |
Clears the registry. For test isolation only. |
- Custom panel slugs are automatically added to
actions_authsoController::exec()requires login. - Dispatch is handled by
__call()— unrecognised method names are checked against the static registry before falling through. - Custom tabs appear in the navigation bar after the built-in tabs, in registration order.
§68 — GitInfo: Pure-PHP Git Reader (Phase 14)¶
Reads git repository information directly from .git/ — no exec(), shell_exec(), or any external process.
Pramnos\Framework\GitInfo¶
$git = new GitInfo('/path/to/repo'); // defaults to framework/app root
$git->getBranch(); // 'v1.2-dev' (or short hash if detached HEAD)
$git->getHash(); // '40-char hex or null'
$git->getShortHash(); // '7-char hex or "0000000"'
$git->getSubject(); // 'feat(debug): Phase 13 — DebugBar…'
$git->getAuthor(); // 'Alice Wonderland'
$git->getDate(); // Unix timestamp (int) or null
$git->getLocalBranches(); // ['main', 'v1.2-dev', …] — sorted
$git->getRemotes(); // ['origin', …]
Resolution order: loose ref file → packed-refs. Commit data is read by decompressing the object file with gzuncompress().
Pramnos\DevPanel\GitInfo¶
Thin alias that extends Framework\GitInfo — provides the DevPanel namespace without code duplication.
Usage in app footer¶
$git = new \Pramnos\Framework\GitInfo(ROOT);
$badge = $git->getBranch() . '@' . $git->getShortHash();
§69 — HealthController (web health dashboard + JSON endpoint)¶
Framework-level controller in Pramnos\Application\Controllers\Health — auto-discovered by Application::getFrameworkController(). No route registration required.
Actions¶
| Action | Auth | Description |
|---|---|---|
display |
Required | HTML dashboard: all check results, DB info, cache adapter, active sessions, PHP version, peak memory |
check |
Public | JSON {"status":"ok\|degraded\|down","checks":{…}} — HTTP 200/503 — for uptime monitors |
phpinfo |
Required + usertype ≥ 90 | phpinfo() inner content (no double HTML wrapping) |
check() JSON format¶
{
"status": "ok",
"checks": {
"database-connectivity": {
"status": "ok",
"name": "database-connectivity",
"message": "Connected",
"details": {}
}
}
}
Returns HTTP 200 when status is ok, 503 for degraded or down.
Usage¶
GET /health → display() HTML dashboard (login required)
GET /health/check → check() JSON endpoint (public)
GET /health/phpinfo → phpinfo() (admin only)
Scaffolding¶
pramnos init generates src/Controllers/Health.php in every new application:
No routes needed — Application::getFrameworkController() auto-discovers it.
Application::registerDefaultNavItems() adds a "Health" link to the Admin nav section (position 11, next to "Logs") — visible to users with usertype >= 80.
Built-in health checks (auto-registered)¶
Application::init() calls registerBuiltInHealthChecks() which automatically registers three checks with HealthRegistry:
| Check | Class | Notes |
|---|---|---|
database-connectivity |
Pramnos\Health\Checks\DatabaseConnectivityCheck |
Only registered when $this->database !== null && $this->database->connected |
disk-space |
Pramnos\Health\Checks\DiskSpaceCheck |
Always registered |
memory-limit |
Pramnos\Health\Checks\MemoryLimitCheck |
Always registered |
Applications can override built-in checks by re-registering a check with the same name — HealthRegistry::register() replaces existing entries.
registerBuiltInHealthChecks() is protected — subclasses may override it to customise the built-in set.
Views (scaffolding fallback)¶
display() uses the view system (getView('health')). Views live in scaffolding/themes/{theme}/views/health/ and are resolved via ScaffoldingHelper fallback:
| File | Description |
|---|---|
health/health.html.php |
Full dashboard: check table + system info (DB, cache, sessions, memory, PHP version) |
health/check.html.php |
Compact check-results table for embedding |
All three built-in themes ship these views: plain-css, bootstrap, tailwind.
Tests¶
| File | Coverage |
|---|---|
tests/Unit/Health/HealthControllerTest.php |
15 unit tests: class hierarchy, public/auth actions, HealthRegistry::runAll() shape, display() HTML (ok badge, down badge, check list, empty state, system info), check() HTTP code mapping, admin.health NavItem |
tests/Integration/Health/HealthDbInfoMySQLTest.php |
4 integration tests: SELECT VERSION() returns non-empty string, version contains "8.", display() HTML contains "Mysql" label and real version string, PHP_VERSION row |
tests/Integration/Health/HealthDbInfoPostgreSQLTest.php |
4 integration tests: SELECT VERSION() returns non-empty string, version contains "PostgreSQL", display() HTML contains "Postgresql" label and real version string, PHP_VERSION row |
§70 — Broadcasting System (Phase 12)¶
Real-time event dispatch system. Activated via 'broadcasting' feature key. Transport-agnostic — drivers can be swapped without changing application code.
Architecture¶
BroadcastingManager
└─ DriverInterface
├─ NullDriver (default — no-op)
└─ LogDriver (development / testing)
BroadcastingManager¶
$manager = new BroadcastingManager();
$manager->addDriver(new LogDriver('/var/log/broadcast.log'));
$manager->setDefault('log');
// Dispatch on the default driver
$manager->broadcast('room.42', 'message.created', ['body' => 'Hello!']);
// Fan-out to a specific driver
$manager->via('log', 'room.42', 'message.created', ['body' => 'Hello!']);
| Method | Description |
|---|---|
addDriver(DriverInterface) |
Register a driver |
setDefault(string $name) |
Switch active driver |
driver(?string $name) |
Get driver (active or named) |
getDriverNames() |
All registered driver names |
broadcast(channel, event, payload) |
Dispatch via default driver |
via(driver, channel, event, payload) |
Dispatch via specific driver |
Channel conventions¶
| Prefix | Meaning |
|---|---|
| (none) | Public |
private- |
Requires auth |
presence- |
Member list visible to subscribers |
LogDriver¶
$driver = new LogDriver('/tmp/bcast.log');
$driver->broadcast('ch', 'ev', ['key' => 'val']);
$entries = $driver->getEntries();
// [['timestamp' => '...', 'channel' => 'ch', 'event' => 'ev', 'payload' => ['key' => 'val']]]
$driver->clear(); // truncate log
Broadcastable trait¶
class Message extends OrmModel
{
use \Pramnos\Broadcasting\Broadcastable;
protected string $broadcastChannel = 'messages';
}
// In a lifecycle hook:
$message->broadcastCreated(); // fires 'message.created' on 'messages'
$message->broadcastEvent('typing', []); // fires 'message.typing' on 'messages'
Requires a 'broadcasting' singleton in the application container (registered by BroadcastingServiceProvider).
Configuration (app.php)¶
'features' => ['broadcasting'],
'broadcasting' => [
'default' => 'log', // 'null' | 'log' | 'pusher'
'log_path' => ROOT . '/logs/broadcasting.log',
],
PusherDriver¶
Publishes events via the Pusher HTTP API. Supports both the Pusher cloud service and any Pusher-compatible self-hosted server (e.g. Laravel Reverb).
Requirements — optional Composer package:
A RuntimeException is thrown at construction time if the package is not installed, so the error surfaces at start-up rather than at the first broadcast call.
Configuration:
'broadcasting' => [
'default' => 'pusher',
'pusher' => [
'driver' => 'pusher',
'app_id' => env('PUSHER_APP_ID'),
'app_key' => env('PUSHER_APP_KEY'),
'app_secret' => env('PUSHER_APP_SECRET'),
'cluster' => env('PUSHER_APP_CLUSTER', 'eu'),
'encrypted' => true,
// For Reverb (self-hosted):
// 'host' => '127.0.0.1',
// 'port' => 8080,
// 'scheme' => 'http',
],
],
Usage:
$manager = new BroadcastingManager();
$manager->addDriver(new PusherDriver([
'app_id' => 'my-app-id',
'app_key' => 'my-key',
'app_secret' => 'my-secret',
'cluster' => 'eu',
]));
$manager->setDefault('pusher');
$manager->broadcast('orders', 'order.created', ['id' => 42]);
pramnos-echo.js — Browser client¶
A lightweight Pusher-compatible browser client for subscribing to channels.
Location: scaffolding/resources/vendor/pramnos-echo/pramnos-echo.js
Requirements — include the Pusher JS SDK first:
<script src="https://js.pusher.com/8.4.0/pusher.min.js"></script>
<script src="/assets/vendor/pramnos-echo/pramnos-echo.js"></script>
Setup:
<script>
PramnosEcho.configure({
key: 'YOUR_PUSHER_APP_KEY',
cluster: 'eu',
// For Reverb (local dev):
// wsHost: '127.0.0.1',
// wsPort: 8080,
// forceTLS: false,
// enabledTransports: ['ws', 'wss'],
});
</script>
API:
// Public channel
PramnosEcho.channel('orders').listen('order.created', function (data) {
console.log('New order:', data);
});
// Private channel (requires auth endpoint)
PramnosEcho.private('orders.42').listen('order.paid', function (data) {
console.log('Order paid:', data);
});
// Presence channel
PramnosEcho.presence('room.1').listen('member.joined', function (data) { ... });
// Stop a specific listener
PramnosEcho.channel('orders').stopListening('order.created', myCallback);
// Unsubscribe completely
PramnosEcho.leave('orders');
// Disconnect
PramnosEcho.disconnect();
| Method | Description |
|---|---|
configure(config) |
Connect to Pusher/Reverb backend |
channel(name) |
Subscribe to a public channel |
private(name) |
Subscribe to private-{name} |
presence(name) |
Subscribe to presence-{name} |
leave(name) |
Unsubscribe and remove all listeners |
disconnect() |
Disconnect and clear all subscriptions |
CSRF token is automatically read from <meta name="csrf-token"> and sent with private/presence channel auth requests.
§71 — WebhookHandler (Phase 15)¶
Git webhook receiver. Verifies HMAC signatures, maps branches to command sequences, and logs deploys via Pramnos\Logs.
Quickstart¶
// www/webhook.php
$handler = new \Pramnos\Webhook\WebhookHandler(
secret: $_ENV['WEBHOOK_SECRET'],
repoDir: ROOT,
logChannel: 'webhook',
);
$handler->onBranch('main', [
'git fetch --all',
'git reset --hard origin/main',
'composer install --no-dev --optimize-autoloader',
'php pramnos migrate',
]);
$handler->onBranch('develop', [
'git fetch --all',
'git reset --hard origin/develop',
]);
$handler->handle();
Security¶
secretmust be non-empty — constructor throwsInvalidArgumentExceptionon empty string.- Signature verified via
hash_equals()(timing-safe): X-Hub-Signature-256— GitHub SHA-256 (preferred)X-Hub-Signature— Bitbucket / GitHub SHA-1 legacy fallback- Invalid/missing signature → HTTP 403
Supported events¶
| Event | Trigger |
|---|---|
GitHub push |
Executes commands for the pushed branch |
GitHub release (published) |
Executes commands for target_commitish branch |
GitHub workflow_run (completed success) |
Executes commands for head_branch |
Bitbucket push |
Detected from push.changes payload structure |
| Any other event | HTTP 204 No Content (silently ignored) |
HTTP responses¶
| Code | Meaning |
|---|---|
| 200 | Commands executed successfully; body = {status, branch, commands_run, elapsed_ms} |
| 204 | Event/branch ignored (no action taken) |
| 400 | Invalid JSON payload |
| 403 | Invalid or missing HMAC signature |
| 500 | Command execution failed; body = {status, branch, commands_run, failed[]} |
API¶
| Method | Description |
|---|---|
__construct(string $secret, string $repoDir = '', string $logChannel = 'webhook', int $timeout = 120) |
Constructor. Throws InvalidArgumentException if $secret is empty. |
onBranch(string $branch, string[] $commands): static |
Register commands for a branch. Fluent; replaces existing mapping. |
getBranchMap(): array |
Returns the registered branch→commands map (for inspection / testing). |
handle(?string $rawBody = null, ?array $headers = null): never |
Process the request and exit. Reads php://input by default. |
pramnos make:webhook¶
Generates www/webhook.php in the current application:
php pramnos make:webhook # generates www/webhook.php
php pramnos make:webhook --force # overwrite existing file
php pramnos make:webhook --branch=main # pre-fill branch name (default: main)
Also appends WEBHOOK_SECRET= to .env.example when found.
CLI name is auto-detected from app/app.php namespace or root *.php entry point.
WebhookServiceProvider¶
Feature key: 'webhook' → WebhookServiceProvider. Reads webhook.{secret,repo_dir,log_channel,timeout} from app.php and binds a WebhookHandler singleton as 'webhook' in the container.
Logging¶
When logChannel is non-empty, deploys are logged via Pramnos\Logs\Logs::getInstance()->write().
Logging is best-effort — a Logs failure never breaks the webhook response.
Tests¶
tests/Unit/Webhook/WebhookHandlerTest.php — 21 tests covering:
- Empty secret constructor guard
- onBranch() registration + replace + multiple branches
- SHA-256 valid/invalid signature; SHA-1 fallback; missing signature; SHA-256 priority
- GitHub push/release/workflow_run event detection; Bitbucket push; unknown event ignored
- Branch extraction from GitHub ref, tag push (null), Bitbucket payload, workflow_run
- executeCommands() result per command + fail-fast on non-zero exit code
§72 — API Documentation Scaffolding (Phase 18)¶
Node.js-based pipeline that converts PHP @api* PHPDoc annotations (apiDoc format) to OpenAPI 3.0 JSON specs and an interactive RapiDoc HTML viewer.
Files¶
| File | Description |
|---|---|
scaffolding/scripts/apidoc-to-openapi.js |
The converter script — copies to scripts/apidoc-to-openapi.js in the project |
scaffolding/templates/api-doc.json.stub |
Template for src/Api/apidoc.json (placeholders: {{ APP_NAME }}, {{ API_URL }}, {{ PRIMARY_COLOR }}, {{ APP_KEY }}) |
scaffolding/templates/openapi-overrides.json.stub |
Empty src/Api/openapi-overrides.json with usage comments |
scaffolding/templates/doc.sh.stub |
scripts/doc.sh shell wrapper that runs the Node.js converter |
src/Api/apidoc.json schema¶
All application-specific settings for the documentation pipeline:
{
"name": "My App REST API",
"description": "API Documentation",
"version": "1.0.0",
"url": "https://api.example.com",
"primaryColor": "#4CAF50",
"theme": "dark",
"prefsKey": "myapp-api-prefs",
"additionalServers": [
{ "url": "https://staging.example.com", "description": "Staging" }
]
}
pramnos init wizard¶
Step 2d (shown only when REST API is enabled):
Generate API documentation tooling (apidoc → OpenAPI + RapiDoc)? [y/N]
Production API base URL [https://api.example.com]:
Primary color for docs UI [#4CAF50]:
CLI options for non-interactive use: --api-docs=y, --api-url=<url>, --api-color=<hex>.
When accepted, scaffoldApiDocs() creates:
- src/Api/apidoc.json (from stub, tokens filled)
- src/Api/openapi-overrides.json
- scripts/apidoc-to-openapi.js (copied from scaffolding)
- scripts/doc.sh (executable)
- package.json entries: "apidoc" and "docs" npm scripts, rapidoc devDependency
- .gitignore entries: www/api/openapi*.json, www/api/docs/
Usage¶
npm run apidoc # or: node scripts/apidoc-to-openapi.js
# Reads: src/Api/Controllers/**/*.php (PHPDoc @api* annotations)
# Reads: src/Api/apidoc.json (title, URL, colors, servers)
# Reads: src/Api/openapi-overrides.json (manual overrides, deep-merged)
# Writes: www/api/openapi-v1.json (OpenAPI 3.0 spec per version)
# Writes: www/api/docs/index.html (RapiDoc viewer)
71. Scaffold — Rich Settings Page, Full Application Edit, User Token Management¶
SettingsController — Categorized System Settings¶
Problem: The scaffold SettingsController only offered a flat key/value DataTable. There was no structured UI for the many settings the framework uses internally (sitename, SMTP, login lockout policy, DevPanel configuration, etc.).
Solution: SettingsController now exposes two separate interfaces:
| Action | Route | Purpose |
|---|---|---|
display() |
GET /settings |
Rich tabbed settings page (4 tabs) |
saveSystem() |
POST /settings/saveSystem |
Save all categorized settings |
list() |
GET /settings/list |
Raw key/value DataTable (old interface) |
edit() |
GET /settings/edit/:key |
Edit a single raw setting |
save() |
POST /settings/save |
Save a single raw setting |
delete() |
GET /settings/delete/:key |
Remove a raw setting |
Tabs on the System Settings page:
- General —
sitename,site_url,admin_mail,admin_replymail,default_language,timezone,debug,forcessl - Email / SMTP —
smtp_host,smtp_port,smtp_user,smtp_pass,smtp_tls - Security —
securitySalt(write-only field),loginlockoutwindowseconds,loginlockoutsteps(dynamic card builder, same format as Urbanwater) - DevPanel —
devpanel.min_usertype,devpanel.mount
Helper methods added to SettingsController:
protected function normalizeYesNo(string $value): string;
protected function normalizeIntRange(string $value, int $min, int $max, int $default): int;
protected function normalizeLoginLockoutSteps(string $value, ?array &$errors = null): string;
public const DEFAULT_LOCKOUT_STEPS = [3 => 60, 5 => 300, 7 => 900, 10 => 3600];
public const DEFAULT_LOCKOUT_WINDOW_SECONDS = 900;
Views updated (all 3 themes: bootstrap, tailwind, plain-css):
- settings.html.php — replaced with rich tabbed form
- list.html.php — new file, old DataTable moved here
- edit.html.php — fixed field names (key/value instead of skey/svalue), added original_key hidden input, Cancel → settings/list
ApplicationsController — Full Model Field Coverage¶
Problem: The scaffold ApplicationsController::save() only persisted name, description, callback, scope, status. All other fields of the applications table were ignored.
Solution: save() now handles all application model fields organized into four logical groups:
| Group | Fields |
|---|---|
| Basic | name, description, apptype, accesstype, apiversion, appversion, public, status |
| Organisation | organization, organizationurl, url, supportemail |
| OAuth2 / API | callback, scope, public_key, jwks_uri |
| Legal | termsurl, privacyurl |
New migration: authserver/2020_01_01_000049_add_extended_info_to_applications.php (priority 61) adds supportemail, termsurl, privacyurl, appversion, logourl columns to the applications table.
Edit views updated (all 3 themes): Now show 4-tab layout (Basic, Organisation, OAuth2/API, Legal) with all fields, including the Rotate Secret button and a View Tokens link.
UsersController — Token Management¶
Problem: There was no UI for viewing or managing individual user tokens.
New actions:
| Action | Route | Purpose |
|---|---|---|
tokens($id) |
GET /users/tokens/:id |
List all tokens for a user |
deactivateToken() |
POST /users/deactivateToken |
Set token status=0 (POST: userid, tokenid) |
deleteToken() |
POST /users/deleteToken |
Delete token via User::deleteToken() (POST: userid, tokenid) |
New view tokens.html.php (all 3 themes): Shows tokenid, type, status badge, IP, created/last-used/expires timestamps, and Deactivate / Delete action buttons. Expired tokens highlighted. Deactivate action only shown for active (status=1) tokens.
Edit form updated (bootstrap theme): Tokens and Sessions quick-links added to the action bar of the user edit form.
Phase 21 — Advanced DX: Form Requests¶
Pramnos\Validation\FormRequest¶
Base class for form-level validation requests. Subclasses declare rules, messages, and attributes; calling validated() runs validation, returns clean data on success, or stores errors in session and redirects on failure — without the controller ever seeing invalid input.
Usage:
// 1. Declare a request class
class StoreUserRequest extends \Pramnos\Validation\FormRequest
{
public function rules(): array
{
return [
'username' => 'required|string|min:3|max:50',
'email' => 'required|email',
];
}
}
// 2. Use it in a controller action
public function save(): void
{
$data = (new StoreUserRequest())->validated();
// If we reach here, $data is guaranteed valid.
// On failure, failWith() stored errors in $_SESSION and redirected — exit was called.
}
// 3. Display errors in a view
if (\Pramnos\Validation\FormRequest::hasErrors()):
foreach (\Pramnos\Validation\FormRequest::errors() as $field => $msgs):
foreach ($msgs as $msg): echo htmlspecialchars($msg); endforeach;
endforeach;
endif;
// Repopulate form fields after failed submission
$username = \Pramnos\Validation\FormRequest::old('username');
API:
| Method | Description |
|---|---|
abstract rules(): array |
Validation rules (same format as Validator::validate()) |
messages(): array |
Custom error messages keyed by "field.rule" |
attributes(): array |
Human-readable field names for error messages |
input(): array |
Request data ($_GET + $_POST). Override for JSON bodies. |
validated(): array |
Run validation; return clean data or redirect on failure |
getRedirectUrl(): string |
Redirect target on failure: $redirectTo → HTTP_REFERER → / |
static hasErrors(): bool |
True if session contains validation errors |
static errors(?string $field): array |
All errors or errors for a specific field |
static old(string $field, mixed $default): mixed |
Old input value for form repopulation |
static clearErrors(): void |
Remove errors + old input from session after successful processing |
Customisation:
- protected string $redirectTo = '' — hard-code a failure redirect URL
- Override getRedirectUrl() for dynamic URL resolution
- Override failWith() in subclasses/tests to intercept the redirect
Tests: 12 unit tests (tests/Unit/Validation/FormRequestTest.php) covering success/failure paths, session storage, and all static helpers.
Phase 21 — Advanced DX: Model Factories¶
Pramnos\Support\ModelFactory¶
Base class for ORM model factories. Subclasses declare definition() returning an array of attribute→value pairs (typically using Pramnos\Support\Faker). The factory creates and optionally persists model instances — without ever requiring a database connection for in-memory usage.
Usage:
// 1. Declare a factory
class UserFactory extends \Pramnos\Support\ModelFactory
{
protected string $model = User::class;
public function definition(): array
{
$faker = \Pramnos\Support\Faker::create();
return [
'username' => $faker->username,
'email' => $faker->email,
'active' => 1,
'usertype' => 0,
'regdate' => time(),
'lastlogin' => 0,
];
}
}
// 2. Create instances via the model
User::factory()->create(); // 1 persisted user
User::factory()->count(50)->create(); // 50 persisted users
User::factory()->make(); // 1 in-memory user (not saved)
// 3. Override specific attributes
User::factory()->create(['usertype' => 90]); // admin user
// 4. Chain state + count
User::factory()->state(['active' => 0])->count(3)->make();
API:
| Method | Description |
|---|---|
abstract definition(): array |
Base attribute set; called once per model instance |
count(int $n): static |
Number of models to produce; returns a clone |
state(array $attrs): static |
Merge extra overrides; returns a clone |
make(array $attrs = []): OrmModel\|array |
Build model(s) without saving |
create(array $attrs = []): OrmModel\|array |
Build model(s) and call save() on each |
static new(): static |
Alternative to new static() |
Attribute priority (lowest → highest): definition() → state() overrides → direct make()/create() argument.
OrmModel::factory() convention:
// Looks for {ModelClass}Factory in the same namespace first.
// Override with an explicit property to use a different name:
protected static string $factory = MyCustomUserFactory::class;
If no factory class is found, throws RuntimeException('Factory class not found…').
How instantiation works: The factory uses ReflectionClass::newInstanceWithoutConstructor() so no Controller or live DB connection is required at creation time. All declared property defaults ($_data, $_isnew, etc.) are initialized by PHP's object model.
Tests: 13 unit tests (tests/Unit/Support/ModelFactoryTest.php) covering make(), create(), state/count cloning, attribute priority, and error cases.
Phase 21 — Advanced DX: Notification Channels¶
Pramnos\Notification namespace¶
Unified notification dispatch system. A single $user->notify(new InvoicePaidNotification($invoice)) call sends to all configured channels simultaneously — Email, Database log, WebSocket broadcast, or file log.
Usage:
// 1. Declare a notification
class InvoicePaidNotification implements \Pramnos\Notification\NotificationInterface
{
public function __construct(private Invoice $invoice) {}
public function via(mixed $notifiable): array
{
return ['mail', 'database']; // dispatch to two channels
}
public function toMail(mixed $notifiable): array
{
return [
'subject' => 'Invoice #' . $this->invoice->id . ' has been paid',
'body' => '<p>Thank you for your payment of €' . $this->invoice->amount . '.</p>',
];
}
public function toDatabase(mixed $notifiable): array
{
return [
'message' => 'Invoice paid',
'invoice_id' => $this->invoice->id,
'amount' => $this->invoice->amount,
];
}
}
// 2. Make User notifiable (add trait + implement interface)
class User extends OrmModel implements \Pramnos\Notification\NotifiableInterface
{
use \Pramnos\Notification\NotifiableTrait;
}
// 3. Dispatch
$user->notify(new InvoicePaidNotification($invoice));
// 4. Bulk dispatch
(new \Pramnos\Notification\Notifier())->send([$user1, $user2], new InvoicePaidNotification($invoice));
Built-in channels:
| Alias | Class | Requires | Data method |
|---|---|---|---|
'mail' |
MailChannel |
toMail($notifiable): array |
subject, body, optional from |
'database' |
DatabaseChannel |
toDatabase($notifiable): array |
any JSON-serialisable array |
'broadcast' |
BroadcastChannel |
toBroadcast($notifiable): array |
channel, event, payload |
'log' |
LogChannel |
optional toLog() or toDatabase() |
— falls back to empty {} |
Custom channels: pass a fully-qualified class name implementing ChannelInterface as an element of via().
API:
| Class / Interface | Key methods |
|---|---|
NotificationInterface |
via(mixed $notifiable): string[] |
ChannelInterface |
send(mixed $notifiable, NotificationInterface $n): void |
NotifiableInterface |
notify(NotificationInterface $n): void, routeNotificationFor(string $channel): mixed |
NotifiableTrait |
Default notify() via Notifier + routeNotificationFor() (mail → $email, database → $userid) |
Notifier |
send(array $notifiables, …), sendNow(mixed $notifiable, …), registerChannel(string $alias, string $fqcn) |
Database channel persistence:
Rows are written to #PREFIX#notifications (created by migration CreateNotificationsTable). Schema: id CHAR(36) PK, type, notifiable_type, notifiable_id, data TEXT (JSON), read_at DATETIME NULL, created_at. All new notifications start with read_at = NULL.
Tests: 25 unit tests + 6 MySQL integration tests (tests/Unit/Notification/, tests/Integration/Notification/DatabaseChannelMySQLTest.php).
72. PF-40 — Client-side Group-by in Pramnos\Html\Datatable¶
Overview¶
Datatable now supports client-side row grouping: rows with the same value in a chosen column are visually gathered under a shared group header row, without a server round-trip.
New properties¶
| Property | Type | Default | Description |
|---|---|---|---|
$groupByColumn |
int\|null |
null |
0-based index of the column to group by on first render. null = no grouping. |
$groupBySelector |
bool |
false |
When true, renders a column-picker <select> above the table so the user can change (or clear) the group-by column interactively. |
Both properties default to their pre-existing-behaviour values, so existing Datatable usage is fully backward-compatible.
Usage¶
// Static grouping — group by column index 2 without a UI picker
$dt = new Datatable('myTable', '/api/data');
$dt->groupByColumn = 2;
echo $dt->render();
// Interactive selector — user picks the group-by column at runtime
$dt = new Datatable('reportTable', '/api/data');
$dt->groupBySelector = true; // adds a dropdown above the table
$dt->groupByColumn = 1; // pre-select column 1 on first load
echo $dt->render();
How it works¶
- HTML (
renderTable()): when$groupBySelector === true, a<div>containing a<select id="pf40_groupby_{name}">is injected before the<table>. The select lists all columns that have a non-empty label. The "None" option (value="-1") removes grouping. - JavaScript (
renderJs()): when grouping is active, apf40_doGroup_{name}()function is registered on the DataTablesdraw.dtevent. After every DataTables draw (sort, search, page change) the function scans<tbody>rows and inserts<tr class="pramnos-group-row">separator rows wherever the grouped column's value changes. - Change handler: when
$groupBySelector === true, achangelistener on the<select>updates the group column variable and calls{name}.fnDraw()to trigger a re-group.
Group-by CSS¶
Group header rows carry class="pramnos-group-row" with inline background:#f5f7fa; font-weight:bold — override in your application CSS as needed.
Tests¶
tests/Unit/Pramnos/Html/DatatableGroupByTest.php — 8 unit tests covering property defaults, selector presence/absence, option list, pre-selection, and "None" default.
§73 — Scaffolding improvements (Session 145)¶
Scaffolding bug fixes¶
DebugBar visibility fix¶
pramnos init app no longer sets 'development' => true in the generated app/config/settings.php. The DebugBar only appears when the application runs with development explicitly enabled in the config.
Cache adapter in settings.php¶
When redis or memcached is selected during pramnos init app, the generated app/config/settings.php now includes:
'cache' => [
'method' => 'redis', // or 'memcached'
'hostname' => 'cache',
'port' => 6379, // or 11211 for memcached
],
Cache class reads this config on construction and connects to the correct adapter.
Dashboard DB type label¶
DatabaseStatsService::getPostgreSQLStats() now:
- Never returns null for version — falls back to 'PostgreSQL' instead
- Automatically detects TimescaleDB via pg_extension and appends · TimescaleDB X.Y.Z to the version string
- getMySQLStats() similarly falls back to 'MySQL' instead of null
The dashboard overview template fallback was also updated to map raw type names (postgresql → PostgreSQL, mysql → MySQL) instead of displaying them as-is.
Document::isScriptRegistered() / isStyleRegistered()¶
Two new public methods on \Pramnos\Document\Document:
$doc->isScriptRegistered('datatables'); // bool — safe to call before enqueueScript()
$doc->isStyleRegistered('datatables'); // bool
Views use these to conditionally enqueue libraries without throwing if the library wasn't registered.
User profile page in Auth Dashboard¶
\Pramnos\Auth\Controllers\Dashboard gained a new profile action:
- GET /account/profile — renders account/profile.html.php with the current user's data (firstname, lastname, email, phone)
- POST /account/profile — validates input, saves changes, redirects with ?message=profile_saved
The scaffolded src/Views/account/ directory now includes:
- dashboard.html.php — improved overview with sidebar navigation (links to profile, security, privacy, logout)
- profile.html.php — edit form for name, email, phone (all three themes: bootstrap, tailwind, plain-css)
DataTable on list views¶
The following scaffolded list views now auto-initialise DataTables when the datatables library is registered:
- users/users.html.php (all 3 themes)
- applications/applications.html.php (all 3 themes)
- organizations/organizations.html.php (all 3 themes)
The pattern used in each view:
$_doc = \Pramnos\Framework\Factory::getDocument();
$_hasDt = $_doc->isScriptRegistered('datatables');
if ($_hasDt) { $_doc->enqueueScript('datatables'); $_doc->enqueueStyle('datatables'); }
When DataTables is active:
- The table element gets id="dt-{name}" for targeting
- A $(document).ready() block initialises DataTables with pageLength:25, order:[]
- Manual pagination HTML is hidden (DataTables takes over pagination)
§74 — QR code local generation, DataTable JS fix, "View" link (Session 146)¶
2FA QR code — no more external API¶
chillerlan/php-qrcode moved from suggest to require in composer.json. The library was already integrated in TOTPHelper::getQRCodeDataUri() but was optional, causing a fallback to https://api.qrserver.com/v1/create-qr-code/ which violated CSP img-src 'self' data:.
Changes:
- composer.json: chillerlan/php-qrcode ^5.0 in require
- TOTPHelper::getQRCodeDataUri(): fixed constant OUTPUT_MARKUP_SVG → MARKUP_SVG (v5 API), imageBase64 → outputBase64
- TwoFactorAuth::test() (debug endpoint): uses getQRCodeDataUri() instead of getQRCodeUrl()
The setup views (all 3 themes) already preferred qr_code_data_uri over qr_code_url — now qr_code_data_uri is always non-null and the external URL is never used.
Document::addInlineScript()¶
New public method on \Pramnos\Document\Document:
Appends raw JavaScript (without <script> tags) to the document footer — after all enqueued scripts are output by renderJs(). Use this instead of raw <script> blocks inside view templates when the code depends on jQuery or other footer-loaded libraries.
DataTable init via addInlineScript¶
All 9 list views (users, organizations, applications × 3 themes) now call $_doc->addInlineScript(...) instead of emitting a <script> tag inline in the view body. This fixes Uncaught ReferenceError: $ is not defined which occurred because the view body renders before footer.php calls renderJs().
"View" link in users list — bootstrap + plain-css¶
scaffolding/themes/bootstrap/views/users/users.html.php and plain-css/views/users/users.html.php now include a "View" link before "Edit" in each row, matching the tailwind theme added in session 145b.
DataTable AJAX architecture — server-side mode for all list views¶
All three admin list controllers now use proper DataTables server-side AJAX instead of loading all rows up-front (which would not scale beyond a few thousand records).
Datasource::render() — DataTables 1.10+ parameter auto-detection¶
Datasource::render() now auto-detects DataTables 1.13+ POST params (draw, start, length, search[value], order, columns) and:
- Translates them to the legacy internal format the method already understood.
- Returns a DataTables 1.10+ response (
draw,recordsTotal,recordsFiltered,data) instead of the legacyaaData/sEchoenvelope.
Legacy callers (DT 1.9) are unchanged — detection is based on the presence of draw without sEcho.
Datatable::renderJs() — modern serverSide options¶
The JS generated by \Pramnos\Html\Datatable::renderJs() now uses DataTables 1.10+ options:
"serverSide": true,
"ajax": { "url": "<source>", "type": "POST", "data": function(d){...} },
"order": [[0, "desc"]],
Previously it emitted DT 1.9 options (bServerSide, sAjaxSource, fnServerData) which are ignored by DataTables 1.13.8.
Each column definition also gets "data": N injected in server-side mode so DataTables maps positional array values to the correct column.
Three controllers gain data() AJAX endpoints¶
UsersController, OrganizationsController, and ApplicationsController each gain a new data() action:
public function data(): void
{
// ...
$result = \Pramnos\Html\Datatable\Datasource::getList($table, $fields, false);
// decorate rows with action HTML
echo json_encode($result);
exit;
}
The display() action now creates a \Pramnos\Html\Datatable with $dt->source = sURL . 'entity/data' and passes the datatable object to the view.
Nine list views simplified¶
All 9 list views (users/organizations/applications × tailwind/bootstrap/plain-css) were replaced with a minimal wrapper that delegates entirely to the datatable object:
render() produces the <table> HTML, enqueues datatables CSS/JS, and emits the JS initialization block — the view needs no manual <table>, <thead>, foreach rows, or DataTable init scripts.
pramnos-datatable.js — perpage param fix¶
The Phase 17 REST adapter now sends perpage: length alongside page to the server, allowing the API to apply the correct LIMIT:
Two new tests added to tests/js/adapters.test.js verifying perpage is forwarded correctly.
§74 — DebugBar enhancements (Session 148)¶
Overview¶
Extended the DebugBar with new collectors, a visual timeline, cache-hit tracking, a CSP-safe SQL copy button, and a Laravel-style info strip above the tabs.
New collectors¶
| Class | Purpose |
|---|---|
Pramnos\Debug\Collectors\ViewsCollector |
Records view renders with template path, wall-clock time, and a fromCache flag |
Pramnos\Debug\Collectors\ModelsCollector |
Tracks model load / save / delete operations |
Pramnos\Debug\Collectors\ExceptionsCollector |
Captures exceptions thrown during the request |
All three are auto-registered by DebugBarServiceProvider.
Visual request timeline¶
TimeCollector now stores absolute timestamps for every named timer. DebugBar::render() draws an SVG timeline bar with colour-coded segments per timer inside the Time tab.
Cache hit tracking¶
// Database.php — called automatically on query cache hits
public function logCacheHit(string $sql): void;
Every entry in _inMemoryQueryLog now carries a 'from_cache' boolean (false for live queries, true for cache hits). QueryBuilder::get() calls logCacheHit() on its internal cache hit path. QueryCollector::collect() exposes a 'cached' total and a per-row from_cache indicator.
CSP-safe SQL copy button¶
The copy button in the Query tab stores SQL in a data-sql attribute on the button element. A single delegated click listener (no inline onclick) reads the attribute and copies to clipboard — compatible with strict Content-Security-Policy headers.
Laravel-style info strip¶
Renders a compact badge row above the tabs showing: - Memory — peak usage - DB — live query count + cached query count - Route — matched controller action - Env — development / production tag
Memory and route data were moved out of the tabs entirely and are now shown exclusively in the strip.
§75 — Database::pgRewriteDmlLimit — PostgreSQL DML LIMIT rewrite (Session 149)¶
Problem¶
PostgreSQL does not support LIMIT in DELETE or UPDATE statements (a MySQL extension). Existing application code that issues:
would produce a PostgreSQL syntax error.
Solution¶
Database::pgRewriteDmlLimit(string $sql): string is a private method called in both prepare() and prepareQuery() for PostgreSQL connections. It rewrites the offending forms:
DELETE FROM t WHERE cond LIMIT N
→ DELETE FROM t WHERE ctid IN (SELECT ctid FROM t WHERE cond LIMIT N)
UPDATE t SET ... WHERE cond LIMIT N
→ UPDATE t SET ... WHERE ctid IN (SELECT ctid FROM t WHERE cond LIMIT N)
An UPDATE without a WHERE clause has its LIMIT silently stripped (PostgreSQL syntax). SELECT queries and any query without a trailing LIMIT \d+ are returned unchanged.
The rewrite runs before #PREFIX# substitution so double-quoted table names like "#PREFIX#settings" are handled correctly.
Tests¶
tests/Unit/Database/DatabasePureMethodsTest.php — 7 unit tests via ReflectionMethod covering all branches: DELETE with WHERE, DELETE without WHERE, UPDATE with WHERE, UPDATE without WHERE (LIMIT stripped), SELECT passthrough, no-LIMIT passthrough, quoted schema.table names.
§76 — Removed legacy Factory accessors (BC note)¶
Three long-dead static accessors were removed from Pramnos\Framework\Factory:
| Removed method | Returned (legacy) class |
|---|---|
Factory::getSearch() |
pramnos_search |
Factory::getForm() |
pramnos_html_form |
Factory::getJquery() |
pramnos_jquery |
Why this is not a practical BC break¶
The three pramnos_* classes these accessors instantiated no longer exist anywhere in
src/. Any call to one of these methods already threw a fatal Class not found error
long before 1.2 — the methods were dead code that could not be invoked successfully. Their
removal only deletes an unreachable code path; it changes no behaviour that any working
application could have relied on.
Migration guidance¶
There is no direct replacement (the underlying rendering helpers were retired). Application code should use the current equivalents:
- Search UI / datatables →
Pramnos\Html\Datatable - Form building → the framework's form / scaffolding helpers (
Pramnos\Html\*) - jQuery/asset inclusion → the theme/asset pipeline
Verified that the Urbanwater integration project (5 176-test suite) contains no references to any of the three removed methods, so their removal has no impact there.
§77 — Bugfix: Model::_ensurePrimaryKeyInSelect — ambiguous PK column in JOINs¶
Problem¶
Model::_getList() / _getApiList() guarantee the primary key is part of the SELECT
list (needed to key hydrated rows). The helper _ensurePrimaryKeyInSelect() compared each
field token against the primary key without stripping identifier quotes. Generated
queries qualify and quote the fields (e.g. a.`supplyid` on MySQL, a."supplyid"
on PostgreSQL), so an already-present PK was not recognised and a duplicate bare
supplyid was prepended:
SELECT supplyid, a.`supplyid`, ..., b.`supplyid` AS `b_supplyid`
FROM watersupplies a
LEFT JOIN watersupplydetails b ON b.supplyid = a.supplyid
When the query JOINs a table that also owns a column of the same name, the database raises
column reference "supplyid" is ambiguous (both MySQL and PostgreSQL). The query fails and
getList() returns an empty array — silent data loss on a very common JOIN pattern,
not just a test failure.
Fix¶
The comparison now normalises both sides — stripping the table prefix, surrounding identifier quotes (backticks / double quotes) and whitespace, and lower-casing — before matching. An already-present quoted/qualified PK is therefore correctly detected and left untouched, so no duplicate bare column is added. Behaviour when the PK is genuinely absent is unchanged (it is still prepended as before).
Tests¶
tests/Unit/Pramnos/Application/ModelEnsurePrimaryKeyInSelectTest.php— 6 unit tests (backtick- and double-quote-qualified PK detection, bare PK, wildcard/empty pass-through, absent-PK prepend).tests/Characterization/Application/ModelListApiCharacterizationTest.phpand…PostgreSQLCharacterizationTest.php— integration testtestGetListWithJoinSharingPrimaryKeyColumnNameReturnsRowsreproducing the exact JOIN / shared-PK-column scenario on MySQL and PostgreSQL/TimescaleDB (the PG test runs against thetimescaledbcontainer, covering both engines).
Discovered via¶
Running the Urbanwater integration suite against v1.2-dev: this was a genuine regression
(the _ensurePrimaryKeyInSelect helper is new in 1.2) that broke Watersupply list/filter
endpoints. Confirmed by baseline comparison (main → pass, v1.2-dev unpatched → fail,
patched → pass, identical database state).
§78 — Bugfix: log date filtering (JSON newline + non-slash date styles)¶
Problem¶
The log date-range filter (LogController::filter/export via
LogManager::getFilteredLogEntries, and LogController::readLogWithTimestamp) derived each
entry's timestamp with two flaws:
- JSON entries were mis-detected — the "is this line JSON?" check tested that the last
character is
}, butfgets()keeps the trailing\n, so every JSON log line failed the check and fell back totime()(the current time) instead of its real timestamp. - Only slash dates were parsed — the bracketed-timestamp regex accepted only
[\d\/]+(thed/m/Ystyle emitted byLogger), so ISO (Y-m-d) and dash-month (d-M-Y) timestamps — whichLogManagerand PHP's own error log emit — were not recognised and also fell back totime().
Because unparsed entries defaulted to "now", date-range filtering silently dropped older entries whenever the requested range was in the past — and any test exercising it only passed while "now" happened to fall inside the hard-coded range.
Fix¶
Both readers now trim() the line before inspection (so JSON detection sees the real last
character) and the timestamp regex accepts slash, ISO and dash-month date styles
([\d\/\-A-Za-z]+). strtotime() then resolves the actual entry time, so date filtering
works for every format the framework produces.
Tests¶
LogControllerTest::testExportDateRangeJsonWithJsonLines and
testFilterStandardLogInvalidDateFormat now assert against the entry's own parsed date and
are therefore deterministic regardless of the current date. All 244 Log* tests pass.