Skip to content

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:

// Throws RuntimeException if reconnect fails
$db->refresh(throwOnFailure: true);

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)

$caps = new \Pramnos\Database\DatabaseCapabilities($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

if ($caps->isMySQL()) { /* ... */ }

isPostgreSQL(): bool

Returns true for both plain PostgreSQL and TimescaleDB.

if ($caps->isPostgreSQL()) { /* ... */ }

hasTimescaleDB(): bool

Returns true only if the TimescaleDB extension is present and loaded in the connected database.

if ($caps->hasTimescaleDB()) { /* ... */ }

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_TIMESCALEDB vs TIMESCALEDB), instance-level capability cache vs static, missing MATERIALIZED_VIEWS and ENUMS constants and their corresponding hasMaterializedViews() / hasEnums() methods, and ifCapable() located here instead of on SchemaBuilder. 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.

$qb = $db->queryBuilder();

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.

$qb->select('country')->distinct()->from('users');
// → SELECT DISTINCT country FROM users

from(string $table): static

Sets the FROM table. Accepts a table name with optional alias ('users a', 'users AS a').

$qb->from('users');
$qb->from('users a');   // with alias

table(string $table): static

Alias for from(). Preferred when building INSERT/UPDATE/DELETE.

$qb->table('users')->insert([...]);

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').

$qb->leftJoin('profiles p', 'p.userid', '=', 'u.userid');

joinRaw(string $sql): static

Adds a completely raw JOIN string. Use for complex join conditions the fluent API cannot express.

$qb->joinRaw("LEFT JOIN permissions p ON p.userid = u.userid AND p.active = 1");

API reference — ordering, grouping, pagination

orderBy(string $column, string $direction = 'asc'): static

$qb->orderBy('created_at', 'desc');
$qb->orderBy('username');           // defaults to 'asc'

orderByRaw(string $sql): static

$qb->orderByRaw('COALESCE(last_login, created_at) DESC');

groupBy(string|array $columns): static

$qb->groupBy('country');
$qb->groupBy(['country', 'city']);

groupByRaw(string $sql): static

$qb->groupByRaw("DATE_TRUNC('month', created_at)");

having(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static

Same calling convention as where().

$qb->groupBy('country')->having('total', '>', 100);
// → GROUP BY country HAVING total > 100

havingRaw(string $sql, array $bindings = [], string $boolean = 'and'): static

$qb->havingRaw("COUNT(*) > %i", [5]);

limit(int $value): static

$qb->limit(25);

offset(int $value): static

$qb->offset(50);  // page 3 with limit(25)

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(). Use whereRaw() with $bindings for 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, but count() 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.

$total = $db->queryBuilder()->from('orders')->where('status', 'paid')->sum('amount');

avg(string $column): float

Executes SELECT AVG(column) AS aggregate. Returns 0.0 on an empty result set.

$mean = $db->queryBuilder()->from('products')->avg('price');

min(string $column): mixed

Executes SELECT MIN(column) AS aggregate. Returns null on an empty result set.

$cheapest = $db->queryBuilder()->from('products')->min('price');

max(string $column): mixed

Executes SELECT MAX(column) AS aggregate. Returns null on an empty result set.

$priciest = $db->queryBuilder()->from('products')->max('price');

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.

$email = $db->queryBuilder()->from('users')->where('userid', 42)->value('email');

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: $function is 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; use raw() 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.

$db->queryBuilder()->from('tmp_imports')->truncate();

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.

$db->queryBuilder()->from('wallets')->where('userid', 42)->decrement('balance', 9.99);

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.

$qb->whereNotNull('email');
// → WHERE email 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].

$qb->whereBetween('age', [18, 65]);
// → WHERE age BETWEEN 18 AND 65

whereNotBetween(string $column, array $values, string $boolean = 'and'): static

$qb->whereNotBetween('score', [0, 50]);
// → WHERE score NOT BETWEEN 0 AND 50

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().

$qb->from('products p')->rightJoin('order_items oi', 'oi.product_id', '=', 'p.id');

crossJoin(string $table): static

Adds a CROSS JOIN (Cartesian product). No ON clause.

$qb->from('sizes')->crossJoin('colors');
// → ... FROM sizes CROSS JOIN colors

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.

$qb->from('audit_log')->oldest('logged_at')->get();

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 BY when using chunk(). 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.

$result = $qb->from('logs')->orderBy('logid', 'desc')->get();

while ($result->fetchNext()) {
    echo $result->fields['message'] . "\n";
}

Fetch all rows at once

$rows = $qb->from('users')->get()->fetchAll();
// $rows is a plain PHP array of associative arrays

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, col2 when $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 $column unchanged
  • PostgreSQLGrammar: appends ::text for LIKE, ILIKE, NOT LIKE, NOT ILIKE operators, 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 Expression from user-supplied input. Use whereRaw() with $bindings for parameterised user data.

BC notes

The Grammar layer is entirely additive. No existing public API changed:

  • All QueryBuilder public methods have the same signatures as before.
  • All compile methods moved from QueryBuilder into Grammar classes — no call-site changes are needed since QueryBuilder delegates transparently.
  • Expression was extracted from the bottom of QueryBuilder.php into its own PSR-4 file; the class name, namespace, and behaviour are identical.
  • The Grammar namespace (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.

$this->DB()->statement("CREATE OR REPLACE FUNCTION ...");

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.

if ($this->DB()->getDriverName() === 'pgsql') { /* PostgreSQL branch */ }

Database::capabilities(): DatabaseCapabilities

Returns a DatabaseCapabilities instance bound to this connection.

if ($this->DB()->capabilities()->hasTimescaleDB()) { /* hypertable branch */ }

Migration::DB(): Database

Protected helper that returns the database connection from the application. Preferred over $this->application->database in migration code.

protected function up(): void
{
    $this->DB()->statement("ALTER TABLE ...");
}

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.

$table->bigInteger('userid')->unsigned()->notNull();

ForeignKeyDefinition::name(string $name): static

Alias for ->constraintName(). Matches the Backport Spec's ->name() chaining style.

$table->foreign('appid')->references('appid')->on('applications')->name('fk_app');

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

  • SchemaBuilder previously existed as a stub (only create(), drop(), truncate(), createHypertable(), addRetentionPolicy()). All those methods still exist with the same signatures. The stub create() and drop() now delegate to createTable() and dropTableIfExists().
  • $db->schemaBuilder() continues to work; $db->schema() is the new preferred alias.
  • ColumnDefinition, ForeignKeyDefinition, and Blueprint are new classes — purely additive.
  • The SchemaGrammar* classes are in the Pramnos\Database\Grammar namespace — no existing import can conflict.
  • Blueprint::modifyColumn() and ColumnDefinition::has() are additive — no existing API changed.
  • Blueprint::addColumn() was protected; it is now public. Any subclass that overrode it with a protected modifier will need to widen to public — 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, View
  • Application (extended): Model list/count/API query contracts (getCount, _getList, _getApiList)
  • Application (extended): Api helper contracts (_httpStatusToText, _translateStatus)
  • Application (extended): Api key lifecycle contracts (Api\\Apikey save/load/getData)
  • Application (extended): Runtime helper contracts (Application redirect/breadcrumb/state/maintenance helpers)
  • Application (extended): Log controller helper contracts (LogController whitelist/date-parser/action-buttons paths)
  • Console: Commands
  • Logs: LogManager + LogViewer
  • User: token lifecycle + setPassword() branches
  • Cache: FileAdapter + AbstractAdapter
  • Html: Breadcrumb + Html + Date
  • General: StringHelper + Helpers
  • Geolocation: coordinate validation + distance contracts
  • Email: fluent setter API + error-state accessors
  • Theme: 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 an Expression (e.g. the result of $qb->raw(...)).
  • Returns an Expression that 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.
  • GrammarInterface gains compileTimeBucket() — any custom grammar implementation must add this method. The base Grammar class 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::$autoExecute is 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, $autoExecute remain public and writable.
  • MigrationRunner is entirely additive — it does not replace or change the existing Migration::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

// app/Config/app.php  (or settings table)
'migration_cutoff' => '2020_01_02_000000',

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_0000002020_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:

migration_cutoff = 2020_01_02_000000

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

migrate:rollback [--batch=<n>] [--path=<dir>]

Rolls back the last batch (or --batch=N for a specific one).

migrate:reset

migrate:reset [--force] [--path=<dir>]

Rolls back ALL batches. Prompts for confirmation unless --force is passed.

migrate:refresh

migrate:refresh [--force] [--path=<dir>]

Rolls back all batches and immediately re-runs all migrations (reset + migrate in one step).

migrate:status

migrate:status [--path=<dir>]

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)

  • MigrationLoader is entirely new — no existing code is affected.
  • MigrationRunner::rollback() is backward compatible; the batch option is additive.
  • Migration::getSlug() and getTimestamp() behavior changes only for migration files named with the YYYY_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

namespace Pramnos\Application;

class 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

public static function register(string $key, array $config = []): void

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

public static function loadFromConfig(array $features): void

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 $features has not been registered, an UnknownFeatureException is thrown immediately (fail-fast).

app.php example:

return [
    'features' => ['auth', 'queue'],
];

Querying

public static function isEnabled(string $key): bool

Returns true if the feature is currently enabled. 'core' is always true. Unknown or registered-but-not-enabled keys return false without throwing.

public static function getEnabled(): array  // string[]

Returns all currently-enabled feature keys. 'core' is always included at index 0 even if it was not explicitly listed.

public static function getKnown(): array  // string[]

Returns all registered feature keys (built-in defaults + any custom registrations).

public static function getProvider(string $key): ?string

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).

public static function getMigrationPaths(string $key): array  // string[]

Returns the migration directory paths registered for a feature. Returns [] when none are set — callers do not need to guard against null.

public static function getDefinition(string $key): ?array

Returns the complete definition array {description, provider, migrations} for a registered feature, or null for unknown keys.

Lifecycle helpers

public static function initDefaults(): void

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
public static function reset(): void

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

namespace Pramnos\Application;

class UnknownFeatureException extends \RuntimeException

Thrown by FeatureRegistry::loadFromConfig() when an application tries to enable a feature key that has not been registered.

Constructor

public function __construct(string $key, array $knownKeys = [])

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

public function getFeatureKey(): string

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:

FeatureRegistry::loadFromConfig($this->applicationInfo['features'] ?? []);

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)

  • FeatureRegistry and UnknownFeatureException are entirely new — no existing public API is changed.
  • Application::init() gains one new call to FeatureRegistry::loadFromConfig() — this is additive and backward compatible. Applications that do not define a features key in app.php pass an empty array and the call is a no-op.
  • Existing applications will receive an UnknownFeatureException if they list an unregistered feature key in app.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

namespace Pramnos\Application;

abstract class ServiceProvider

Abstract base class. Subclasses override one or both lifecycle methods:

Constructor

public function __construct(Application $app)

Stores the application instance in $this->app. Called by the framework — do not call manually outside of tests.

register()

public function register(): void  // default: no-op

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()

public function boot(): void  // default: no-op

Called after all providers have been registered. Use this phase to register routes, event listeners, scheduled tasks, middleware, etc.

$app property

protected Application $app;

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:

$app->addProvider(new MyApp\Providers\AppServiceProvider($app));
$app->init();

bootServiceProviders() (protected)

Called internally by init() after FeatureRegistry::loadFromConfig().

  1. Iterates FeatureRegistry::getEnabled() and instantiates each feature's provider (skips features whose provider FQCN is null or whose class does not exist).
  2. Merges with any providers queued via addProvider().
  3. Calls register() on all providers.
  4. 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

FeatureRegistry::register('my_feature', [
    'provider' => MyAddon\MyServiceProvider::class,
]);

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)

  • ServiceProvider is entirely new — no existing code is affected.
  • Application gains addProvider(), $serviceProviders, and bootServiceProviders() — all additive.
  • Applications that define no features in app.php and call no addProvider() will have bootServiceProviders() run but do nothing (no providers are instantiated, both loops are empty).
  • All currently registered built-in features (core, auth, authserver, messaging, queue) have provider: null in 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)

enum HealthStatus: string
{
    case Ok       = 'ok';
    case Degraded = 'degraded';
    case Down     = 'down';
}

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

new DatabaseConnectivityCheck(Database $db)

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

new MemoryLimitCheck(float $degradedPct = 75.0, float $downPct = 90.0)

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:check is a new CLI command — no existing commands are changed.
  • The HealthRegistry follows the same static pattern as FeatureRegistry and uses reset() 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:

* * * * * php pramnos schedule:run >> /dev/null 2>&1

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

php pramnos schedule:run
php pramnos schedule:run --pretend

Runs all currently-due tasks. --pretend lists them without executing. Exit code 0 on success, 1 if any task throws an exception.

schedule:list

php pramnos 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:run and schedule:list are new CLI commands.
  • Scheduler is 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.

PolicyRecord::fromRow(array $row): self

Accepts raw associative DB rows. JSON config columns are decoded automatically (or accepted pre-decoded as arrays). All properties are readonly.


PolicyEngine

$engine = new PolicyEngine($app);

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:

  • policyTableName is 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_policies on 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 for last_run and created_at fields.
  • null values in update() (for next_run, last_error) are preserved correctly — QB's array_filter callback only removes Expression instances, not null.

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, and service:policy-engine are entirely new.
  • The core feature in FeatureRegistry::initDefaults() now includes a migration path — this is additive and does not affect existing code.
  • The framework_policies system migration uses the 2020 timestamp so existing installations that set a migration_cutoff date in app.php will 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):

php bin/pramnos migrate --scope=framework

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:

php bin/pramnos migrate:status --scope=framework

Roll back the last framework migration batch:

php bin/pramnos migrate:rollback --scope=framework

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 of src/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.

new AuthMiddleware()                    // throws 401
new AuthMiddleware('/login')            // redirects to /login with 302

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 Controller subclasses work unchanged. The _runThroughMiddleware() helper is private and simply calls the action directly when no middleware is registered — no overhead, identical behaviour.
  • Router::addRoute() and Router::match() still return $this (Router). Only the single-method helpers (get(), post(), etc.) changed from returning Router to returning Route. 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, or Controller code 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 to URL. 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 entropybin2hex(random_bytes(5)) = 40-bit session token. Feasible to brute-force in an offline attack. 2. MD5 fingerprintgetFingerprint() 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 CsrfMiddleware uses a separate $_SESSION['csrf_token'] key (not $_SESSION['token']). The two systems coexist independently.

Problem: Three gaps in session security that the existing HttpOnly/SameSite=Lax settings did not cover:

  1. Session fixation — an attacker who plants a known session ID before the victim logs in retains access after login. reset() did not call session_regenerate_id().
  2. PHP session ID injection — PHP by default accepts any session ID submitted by the client (e.g. via ?PHPSESSID=attacker_value). session.use_strict_mode was not set.
  3. HTTPS detection gapsisHttps() only checked HTTPS === 'on'; some servers (IIS, CGI) set HTTPS === '1'.

Changes:

Session::start()

  • Calls ini_set('session.use_strict_mode', '1') before session_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:

// Apache / nginx → 'on'    IIS / CGI → '1'
$secure = Session::isHttps();

Request::isHttps() (updated)

Brought in line with Session::isHttps() — also accepts '1'.

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 calling reset() 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> &lt;script&gt;alert(1)&lt;/script&gt;
" onclick="alert(1) &quot; onclick=&quot;alert(1)
it's fine it&#039;s fine
AT&T AT&amp;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 with if (!function_exists('e')) so applications that already define their own e() 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:

docker-compose exec -T app php bin/pramnos migrate:framework

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

php bin/pramnos create:middleware RateLimit

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:

  1. Looks for scaffolding/templates/<name>.stub inside the framework package.
  2. Falls back to an embedded minimal skeleton if the file is absent (safe for installations where scaffolding/ is not present).
  3. Substitutes all {{ token }} placeholders.

Applications can override stubs by placing their own scaffolding/templates/ directory at project root.

BC notes

  • All new options on init are optional — existing usage without options still works interactively.
  • Create::generateTestStub() signature added an optional $baseDir parameter (BC-safe).
  • The embedded heredoc generators in Init.php have been replaced by stub files; the fallback skeleton ensures the command works even in environments where scaffolding/ 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:

Event::listen('user.registered', function (array $user): void {
    // send welcome email
});

Class-name string (instantiated on first fire):

Event::listen('user.registered', SendWelcomeEmail::class);

ListenerInterface instance:

Event::listen('user.registered', new SendWelcomeEmail());

The ListenerInterface contract:

interface ListenerInterface
{
    public function handle(mixed ...$args): mixed;
}

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 Event class is a completely separate registry.
  • Internal framework hooks (Login, Logout, Auth, etc.) continue to fire via Addon::doAction(). Over time, they can also fire via Event::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

  • CommandBase is a brand-new class — no existing code is affected.
  • getJobLockFilePath() uses ROOT constant (framework standard) with sys_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::$taskHandlers is 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

  1. Desired-vs-actual diff: Every reconcile cycle calls buildDesiredProcesses() and compares against loadState().
  2. Stale heartbeat detection: Lock file not touched for HEARTBEAT_STALE_SECONDS (300s) → graceful stop requested.
  3. Crash detection: State records a PID but the process is dead → clean up and restart.
  4. Pre-spawn dedup guard: Scans /proc (or ps) for a process already running with the same --worker-id before spawning → adopt instead of duplicate.
  5. Graceful stop: .stop sentinel file → STOP_GRACE_SECONDS (30s) grace → SIGTERM.
  6. Git-hash restart: Checks .git/HEAD every GIT_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

  • DaemonOrchestrator is a brand-new class — no existing code is affected.
  • Uses ROOT constant with sys_get_temp_dir() fallback for all filesystem paths.
  • getOrchestratorCommandName() (inherited from CommandBase) 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 equivalentstring (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 list
  • www/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)

php bin/pramnos create:migration create_users_table   # blank stub, no wizard

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:

php bin/pramnos create:seeder Product   # bare skeleton

Stub template changes

  • migration.stub — added use Pramnos\Database\Blueprint;, replaced hard-coded // TODO with {{ up_body }} / {{ down_body }} tokens
  • seeder.stub — new template: extends Seeder, loops {{ count }} times, injects {{ fields }} block

BC notes

  • name argument on create command changed from REQUIRED to OPTIONAL — existing CLI calls are unaffected (the value is still validated inside each branch; only migration accepts a missing name).
  • createMigration() now passes up_body / down_body tokens — 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:

  1. Extend Pramnos\Database\Seeder.
  2. Implement public function run(): void.
  3. Live in a .php file whose base name matches the class name exactly (e.g. UsersSeeder.php → class UsersSeeder).
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:

  1. Token::updateAction() for MySQL — removes the early-return guard that prevented response metric recording on MySQL. The method now writes return_status, execution_time_ms, and return_data on both MySQL and PostgreSQL.

  2. Sync trigger migration — the sync_tokenactions_time PL/pgSQL function and trigger are now created by the CreateTokenactionsTable migration rather than by updateAction()'s error-recovery ALTER TABLE fallback.

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:

public function findByKey(string $category, string $language, int $type): ?static

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.0
  • tests/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 servicedisable() and regenerateBackupCodes() 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 protectionverifyCode() compares the current 30-second window against last_used in user_twofactor. If the same window was already used, the code is rejected even if cryptographically valid.
  • Backup codes are one-timeverifyAndConsumeBackupCode() removes the matching hash from the JSON array after a successful verification. A code cannot be reused.
  • Cross-DB upsertcompleteSetup() uses SELECT + INSERT/UPDATE instead of ON CONFLICT to 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.0
  • tests/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_priorityBEFORE INSERT OR UPDATE ON authserver.permissions - trigger_check_user_org_membershipBEFORE INSERT OR UPDATE ON authserver.user_roles

Usage examples

Apply migrations first (PostgreSQL/TimescaleDB only for PL/pgSQL functions):

php bin/pramnos migrate --scope=framework --feature=authserver

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

use Pramnos\Auth\WebhookService;

$svc = new WebhookService($db);

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

$stats = $svc->processQueue(batchSize: 50);
// returns ['sent' => int, 'failed' => int]

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_at set.
  • Failure: attempts incremented; back-off applied (5 min × 2^(attempt-1), capped at 24 h). When attempts >= max_attempts: status = 'failed'.
  • Deleted endpoint: status = 'cancelled'.

purgeOldEvents() — cleanup

$deleted = $svc->purgeOldEvents(daysOld: 30);

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

$header = WebhookService::buildSignature($body, $secret);
// "sha256=abc123..."

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:

curl -X POST /oauth/revoke \
  -H "Authorization: Bearer <access_token>" \
  -d token=<token_to_revoke>

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 templatesscaffolding/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 responses
  • SessionSecurityTest: HMAC-SHA256 fingerprint, IP-aware variants, session token lifecycle
  • RequestTest: GET/POST/PUT/DELETE/JSON parsing, cookie management, URL calculation, validation helpers
  • SessionTest: session lifecycle, fingerprinting

Theme / View Layer Coverage

Asset enqueuingDocumentTest (4 tests, tests/Unit/Pramnos/Document/DocumentTest.php): - registerStyle / enqueueStylerenderCss() outputs <link> tags - registerScript / enqueueScriptrenderJs() 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 buildingEmailCharacterizationTest (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 pipelineResizeToolsCharacterizationTest (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 && !thumbHthumbW = 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

$model->withoutTimestamps(); // disable for one 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 from protected to public — 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_date compares the string "start_date" as a strtotime() argument, not the value of the start_date field. 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):

$request->validate([
    'email' => ['required', 'email', new UniqueEmailRule($db)],
]);

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();
}
$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()

$factory = UserFactory::new()->count(20);

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()

$this->call(UserSeeder::class);
$this->call(PostSeeder::class);

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:

// In bootstrap / service provider
View::setTemplateCacheDir('/path/to/cache');

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:

return [
    'features' => ['auth', 'cache', 'queue', …],

];

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 references
  • applicationid → 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 a locations table. The framework itself does not define locations; this FK is app-level. The migration checks hasTable('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_userid on userid
  • idx_sessions_time on time

users

  • idx_users_photo on photo (conditional: only when photo column exists)

usertokens

  • idx_usertokens_parentToken on parentToken (conditional: column must exist)
  • idx_usertokens_token on token — PostgreSQL: full TEXT index; MySQL: prefix(255)

tokenactions

  • idx_tokenactions_tokenid on tokenid
  • idx_tokenactions_urlid on urlid
  • idx_tokenactions_return_status on return_status (conditional: column must exist)
  • idx_tokenactions_execution_time on execution_time_ms (conditional: column must exist)
  • idx_tokenactions_time_method on (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

  1. The application is configured to use LogDriver pointing to a shared JSONL file (e.g., var/broadcast.jsonl).
  2. broadcast:serve starts listening on ws://localhost:6001 and tails the log file.
  3. 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)

PramnosEcho.configure({
    host:   'localhost',
    port:   6001,
    scheme: 'ws',
    appKey: 'pramnos-local',
});

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_callsapplications.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:

  1. Absolute path
  2. Relative to the current view's directory ($this->path)
  3. Relative to ROOT/views/
  4. Theme directory (themeObject->fullpath/views/) — only if allowsViewOverrides() 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):

return [
    'scaffold_theme' => 'bootstrap',   // 'plain-css' | 'bootstrap' | 'tailwind'
    // ...
];

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 — legacy pramnos create model/controller/... alias command. All functionality is available via create: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()):

global middleware → group middleware → per-route middleware → action

#[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:

CorsMiddleware → JsonResponseMiddleware → ApiAuthMiddleware → _executeCore()
  • 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_origins is 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 preserved
  • tests/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:

Step 2b — REST API
Scaffold a REST API layer? [Y/n]

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:

'api' => [
    'prefix'       => '/api/v1',
    'cors_origins' => ['*'],
    'version'      => 'v1',
],

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

vendor/bin/pramnos init --rest-api=y   # non-interactive

Tests

  • tests/Unit/Console/InitCommandUnitTest.php — 6 tests:
  • testRestApiOptionScaffoldsApiDirectoryAndRoutesFile — all 5 REST API files created when --rest-api=y
  • testRestApiRoutesFileContainsRouterGroupAndNamespaceComment — routes.php has $router = new Router($this), group(), /v1 prefix, dispatch() return, namespace substituted
  • testRestApiOptionAddsApiSectionToAppPhpapp.php contains 'api' section
  • testNoRestApiOptionSkipsApiScaffolding — no src/Api/ directory when --rest-api=n
  • testHtaccessUsesRParameterForRoutingwww/.htaccess uses ?r= (required by Request::calcParams())
  • testIndexPhpUsesDirectApplicationInstantiationwww/index.php uses new \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 tests
  • tests/Unit/Http/Middleware/ApiWebConvergenceTest.php — 6 tests

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:

  1. Authorization: Bearer <jwt> — validates JWT (HS256/RS256), loads user from usertokens with explicit scopes.
  2. Session cookie + X-CSRF-Token header — if $_SESSION['usertoken'] is an active TYPE_WEB_SESSION token and the CSRF header matches, the user is authenticated with wildcard scopes (['*']). The controller still enforces its own $user->usertype / policy checks.
  3. 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:

CsrfMiddleware::csrfMeta('_token'); // → <meta name="_token" content="..." />

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:

$user->invalidateWebSessionToken();
session_destroy();

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'):

{
  "draw": 7,
  "data": [...],
  "recordsTotal": 5,
  "recordsFiltered": 5
}

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)
  • testGetApiListDataTablesFormatReturnsDrawDataRecordsOnMysql
  • testGetApiListDataTablesFormatNoPaginationOnMysql
  • testGetJsonListUsesAllTableFieldsAndReturnsAaDataOnMysql
  • testGetApiListWithPaginationReturnsPaginatedRows (updated — empty-WHERE fix)
  • tests/Characterization/Application/ModelListApiPostgreSQLCharacterizationTest.php — 9 tests (PostgreSQL)
  • testGetApiListDataTablesFormatOnPostgresql
  • testGetJsonListWorksOnPostgresqlAfterIntrospectionUnification
  • testGetApiListWithPaginationReturnsPaginatedRows (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.

class Logs extends LogController
{
    // protected $whitelist = ['app.log', 'php_error.log'];
}

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 when authserver feature 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:

  1. A new bcrypt hash is computed: password_hash($password . md5($salt . $userid), PASSWORD_DEFAULT)
  2. An UPDATE users SET password = ? is executed immediately in the same request
  3. The response auth field 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:

'auth' => ['legacy_md5' => true, 'auto_upgrade' => true],
Constructor config takes priority over 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

  1. Addon-based handlers (Addon\Auth\*) — tried first for backward compatibility
  2. Registered AuthDriverInterface drivers (or default DatabaseAuthDriver if none configured)
  3. 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:

'middleware' => [
    \Pramnos\Http\Middleware\SessionTrackingMiddleware::class,
],

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):

$middleware = new SessionTrackingMiddleware(new BotDetector());

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:

  1. Sets $_SESSION['logged'], ['uid'], ['username'], ['auth']
  2. Sets cookies (logged, uid, username, auth, language) for users with uid > 1 when $remember=true
  3. UPDATE sessions SET uname, time, guest=0 WHERE host_addr (graceful, exception-caught)
  4. 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:

  1. DELETE FROM sessions WHERE uname (graceful)
  2. Clears cookies: logged, uid, username, auth, language
  3. Calls Session::reset()
  4. 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 hierarchy
  • tests/Unit/Controllers/LoginControllerTest.php — auth feature: verifies action registration
  • tests/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.

$inspector = new DatabaseInspector($db);
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.

$tsData = (new TimescaleInspector($db))->getData();

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'.

// app.php
'features' => ['auth', 'queue', '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:

$server = $app->container->get('mcp.server');
$server->addTool(new MyCustomTool());

pramnos mcp:serve CLI Command

Starts the MCP server on stdio. Suitable for .mcp.json configuration:

{
  "mcpServers": {
    "myapp": {
      "command": "./bin/pramnos",
      "args": ["mcp:serve"]
    }
  }
}

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

./bin/pramnos debug:status

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

// app.php
'features' => ['auth', 'debug', 'devpanel'],

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

// Default: usertype >= 90
// Override in app.php:
'devpanel.min_usertype' => 80,

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_auth so Controller::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.

$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:

class Health extends \Pramnos\Application\Controllers\Health {}

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:

composer require pusher/pusher-php-server

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

  • secret must be non-empty — constructor throws InvalidArgumentException on 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:

  • Generalsitename, site_url, admin_mail, admin_replymail, default_language, timezone, debug, forcessl
  • Email / SMTPsmtp_host, smtp_port, smtp_user, smtp_pass, smtp_tls
  • SecuritysecuritySalt (write-only field), loginlockoutwindowseconds, loginlockoutsteps (dynamic card builder, same format as Urbanwater)
  • DevPaneldevpanel.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: $redirectToHTTP_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, a pf40_doGroup_{name}() function is registered on the DataTables draw.dt event. 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, a change listener 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
],
The 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 (postgresqlPostgreSQL, mysqlMySQL) 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)


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_SVGMARKUP_SVG (v5 API), imageBase64outputBase64 - 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:

$doc->addInlineScript('$(document).ready(function(){ ... });');

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().

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:

  1. Translates them to the legacy internal format the method already understood.
  2. Returns a DataTables 1.10+ response (draw, recordsTotal, recordsFiltered, data) instead of the legacy aaData/sEcho envelope.

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:

<?php echo $this->datatable->render(); ?>

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.jsperpage param fix

The Phase 17 REST adapter now sends perpage: length alongside page to the server, allowing the API to apply the correct LIMIT:

return {
    page    : page,
    perpage : length,
    // ...
};

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

DebugBar::renderInfoStrip(array $memData, array $routeData): string

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:

DELETE FROM t WHERE cond LIMIT N
UPDATE t SET col = val WHERE cond LIMIT N

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 / datatablesPramnos\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.php and …PostgreSQLCharacterizationTest.php — integration test testGetListWithJoinSharingPrimaryKeyColumnNameReturnsRows reproducing the exact JOIN / shared-PK-column scenario on MySQL and PostgreSQL/TimescaleDB (the PG test runs against the timescaledb container, 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:

  1. JSON entries were mis-detected — the "is this line JSON?" check tested that the last character is }, but fgets() keeps the trailing \n, so every JSON log line failed the check and fell back to time() (the current time) instead of its real timestamp.
  2. Only slash dates were parsed — the bracketed-timestamp regex accepted only [\d\/]+ (the d/m/Y style emitted by Logger), so ISO (Y-m-d) and dash-month (d-M-Y) timestamps — which LogManager and PHP's own error log emit — were not recognised and also fell back to time().

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.