Skip to content

27 July 2026

8 changes:

  • Column comments are now applied when altering a table
  • Partial indexes and column ordering in the schema builder
  • Native enum types in the schema builder
  • Attribute-routed controller actions now dispatch
  • Auth controller tests no longer drop the shared users table
  • The test suite no longer leaves an app/ directory in the repo
  • Realtime: pluggable SSE + WebSocket backplane
  • Cross-cutting improvements: flat-key cache, log output modes, media alpha fix

Column comments are now applied when altering a table

Adding a documented column to an existing table via Schema::table() / alterTable() now applies its ->comment(). Previously the comment was silently dropped: SchemaGrammar::compileAlter() never emitted the comment statements, so on PostgreSQL a migration that added a column with a comment produced the ALTER TABLE ... ADD COLUMN but no COMMENT ON COLUMN.

Fixed

SchemaGrammar::compileAlter() now appends compileCommentStatements() — the same step compileCreate() already runs — so table and column comments are applied in alter mode too.

  • PostgreSQL emits the separate COMMENT ON COLUMN / COMMENT ON TABLE statements for columns added (or a table commented) via alter.
  • MySQL is unchanged: it carries comments inline in the column definition (... COMMENT '...') and its compileCommentStatements() returns [], so no duplicate or extra statement is produced.
// Now applies the comment on PostgreSQL as well as MySQL:
$app->database->schema()->table('messages', function ($table) {
    $table->string('pinned_track', 500)->nullable()
          ->comment('Snapshot of the now-playing track pinned to this message.');
});

Why it went unnoticed

No framework migration had ever added a commented column through the alter path — comments were only used when creating tables (compileCreate), which was unaffected. The gap surfaced when an application migration added a documented column to an existing table.

Backward compatibility

Additive and fully backward compatible: no public signatures change, and behaviour only changes where a comment was previously being discarded. MySQL output is identical to before.

Tests

  • Unit (SchemaGrammarTest): compileAlter() emits COMMENT ON COLUMN on PostgreSQL and keeps the comment inline (no separate statement) on MySQL.
  • Integration (SchemaBuilderPostgreSQLTest): a column added via table() on a real PostgreSQL database has its comment stored, verified through pg_description.

Partial indexes and column ordering in the schema builder

Blueprint::index() and Blueprint::unique() now return an IndexDefinition whose ->where() makes the index PARTIAL, and index columns may carry an ASC / DESC sort direction.

Added

$schema->table('messages', function ($table) {
    // Partial index + descending order
    $table->index(['is_deleted', 'created_at DESC'], 'idx_active')
          ->where('is_deleted = false');

    // Partial UNIQUE (only rows where email IS NOT NULL are unique)
    $table->unique('email', 'uq_email')->where('email IS NOT NULL');
});
  • ->where(string $predicate) — appends WHERE (<predicate>) to the created index. The predicate is passed through verbatim, so it is dialect-specific SQL.
  • Column ordering — a column written as "created_at DESC" is emitted as "created_at" DESC: the identifier is quoted, the trailing ASC/DESC kept.
  • A partial unique() is compiled as CREATE UNIQUE INDEX ... WHERE ... (a partial unique cannot be a table constraint), both in createTable() (moved out of the inline column list to a post-create statement) and in table()/alter.

On PostgreSQL these become native partial indexes. IndexDefinition is returned additively (index()/unique() previously returned void), so existing callers are unaffected. MySQL keeps inlining plain indexes as before; a WHERE predicate is Postgres-specific.

Tests

  • Unit (SchemaGrammarTest): partial index with DESC, partial unique as a CREATE UNIQUE INDEX, partial unique moved out of CREATE TABLE, and a MySQL plain-index BC check.
  • Integration (SchemaBuilderPostgreSQLTest): a real partial index (with DESC) and a real partial unique index, verified through pg_indexes.indexdef.

Native enum types in the schema builder

Blueprint::enumType() adds a column backed by a NATIVE enum type — a real PostgreSQL CREATE TYPE ... AS ENUM, or an inline MySQL ENUM(...). This complements the existing enum(), which stores a VARCHAR + CHECK on PostgreSQL.

Added

$schema->createTable('users', function ($table) {
    $table->increments('id');
    $table->enumType('role', 'user_role', ['root', 'administrator', 'moderator', 'simple_user'])
          ->default('simple_user');
});
  • PostgreSQL emits CREATE TYPE "user_role" AS ENUM (...) before the CREATE TABLE (via a new pre-create step) and types the column as "role" "user_role". A type shared by several columns is created once.
  • MySQL has no named types, so the column is an inline ENUM('root', ...) and no CREATE TYPE is produced.

Choose enumType() when you want a real database enum type; keep enum() when you prefer the portable VARCHAR + CHECK representation on PostgreSQL.

How it works

A new compilePreCreateStatements() hook on SchemaGrammar (empty by default) lets a dialect emit statements that must run before CREATE TABLE. The PostgreSQL grammar overrides it to emit the CREATE TYPE for each distinct native-enum type; column type enum_native maps to the quoted type name on PostgreSQL and to an inline ENUM(...) on MySQL.

Additive and backward compatible: enum() is unchanged, and enumType() / compilePreCreateStatements() are new.

Tests

  • Unit (SchemaGrammarTest): PostgreSQL creates the type before the table and types the column as it (no CHECK); MySQL is inline with no CREATE TYPE; a shared type is created once.
  • Integration (SchemaBuilderPostgreSQLTest): a real native enum type with its labels in order, and the column reported as USER-DEFINED / the enum udt_name, verified via pg_type / pg_enum / information_schema.

Attribute-routed controller actions now dispatch

Route::execute() now runs [Controller::class, 'method'] actions — the shape RouteDiscovery builds for #[Route(...)] attributes — resolving the controller through the IoC container and injecting matched URI parameters.

Fixed

Previously Route::execute() only handled closures: it called new \ReflectionFunction($this->action) and guarded with is_callable(). For an array action that meant:

  • a non-static controller method → is_callable(['Ctrl', 'index']) is false, so the route did nothing (a silent no-op — no controller, no response);
  • a static method → is_callable is true, but new \ReflectionFunction([...]) threw TypeError: must be of type Closure|string, array given.

So attribute-routed controller classes never dispatched, and the $container passed to execute() was unused. RouteDiscovery produces exactly these array actions, so the two halves of the router were inconsistent.

execute() now:

  • reflects the method (ReflectionMethod) for array actions,
  • resolves the controller via the container (make()/get(), falling back to a plain new), so constructor dependencies are autowired,
  • invokes it — static or on the resolved instance — with URI parameters passed by name (unchanged injection behaviour).

Closures, plain function names, [$object, 'method'] and invokable objects keep working exactly as before. Backward compatible.

Tests

RouteTest gains coverage for a non-static controller action (the regression) and a static one; the full routing suite (unit + characterization) stays green.

Auth controller tests no longer drop the shared users table

Three unit test classes dropped #PREFIX#users in tearDown() and replaced it with a three-column stub in setUp(). Because that table is shared state with live foreign keys pointing at it, the whole suite became order- and history-dependent — most visibly as TwoFactorAuthTest failing on MySQL with "table users doesn't exist / already exists".

Fixed

TwoFactorAuthTest, TokensControllerTest and TokenActionsControllerTest each did, in effect:

// setUp()
$db->query("DROP TABLE IF EXISTS `#PREFIX#users`");
$db->query("CREATE TABLE `#PREFIX#users` (userid, username, email)");
// tearDown()
$db->query("DROP TABLE IF EXISTS `#PREFIX#users`");

#PREFIX#users is not private test state. User::setupDb() creates #PREFIX#userstogroups and #PREFIX#usertokens with FOREIGN KEY (userid) REFERENCES #PREFIX#users (userid), and MySQL keeps those constraints when the parent table disappears. After these classes ran, the test database was left with child tables whose parent was gone:

mysql> SHOW TABLES LIKE 'user%';
userdetails
usergroups
userstogroups        -- FK → users
                     -- (no `users`)

mysql> INSERT INTO userstogroups (userid, groupid) VALUES (2,1);
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint
fails (`pramnos_test`.`userstogroups`, CONSTRAINT `userstogroups_ibfk_1`
FOREIGN KEY (`userid`) REFERENCES `users` (`userid`) …)

Which test blew up — and whether the error read doesn't exist or already exists — depended purely on execution order and on state left behind by the previous run, since User::setupDb() uses CREATE TABLE IF NOT EXISTS and therefore never repairs a stub table it finds in place. Running a class in isolation passed; the full suite did not, reproducibly only for some orderings.

All three classes now:

  • build the users schema idempotently through \Pramnos\User\User::setupDb() (the real production schema, single source of truth) instead of a hand-rolled stub, and never drop it;
  • own only their fixture row — DELETE FROM #PREFIX#users WHERE userid = … in both setUp() and tearDown();
  • use DELETE instead of TRUNCATE on that table, since TRUNCATE is rejected by MySQL while the userstogroups foreign key references it;
  • drop and recreate their minimal #PREFIX#usertokens fixture after setupDb(), so the fixture schema still wins.

Each class' assertions and coverage are unchanged. The test database is now left in a consistent state after every run: users present, no dangling foreign keys.

The test suite no longer leaves an app/ directory in the repo

Every run wrote a root-owned app/ into the framework checkout — an RSA key pair plus a model registry — that nothing cleaned up and that the host user could not even list, let alone delete. Two unrelated side effects, both now cleaned up by the tests that cause them.

Fixed

app/keys/{private,public,encryption}.keyOauth::__construct() runs

$this->oauth2Factory = new OAuth2ServerFactory($this);   // no paths given
$this->oauth2Factory->generateKeyPair();

With no explicit paths OAuth2ServerFactory defaults to ROOT . '/app/keys/private.key' / public.key, and its constructor persists encryption.key next to them. Under PHPUnit ROOT is the framework checkout, so OauthTest, OauthCoverageTest and OauthControllerIntegrationTest each mkdired app/keys (0750) and generated a real RSA-2048 pair into the repo. OAuth2MiddlewareTest left an encryption.key behind the same way — OAuth2Middleware::__construct() also builds the factory without paths.

The four classes now use the new Pramnos\Tests\Support\PreservesAppKeys trait: snapshotAppKeys() in setUp() records what already exists, restoreAppKeys() in tearDown() deletes only what the test created. Key files that were there beforehand are never touched — running the suite from inside a real project must not destroy its signing key, which would invalidate every issued token. Empty app/keys and app/ directories are removed regardless: an empty one carries no information, and rmdir() refuses to touch a populated app/.

app/model-registry.jsonMakeCommandBase::registerModelInRegistry() writes (and mkdirs) ROOT/app/model-registry.json. MakeCommandBaseExtendedTest already filtered its own entries out in tearDown(); MakeCommandGeneratorsTest did not, so TestEntity, IntroModelEntity, SchemaModel and TestCrudEntity accumulated there permanently. It now removes its four entries the same way, deleting the file — and the app/ directory when it is left empty — instead of leaking them.

The three registry tests that already deleted the file (MakeCommandBaseExtendedTest, MakeCommandBaseCoverageTest, MakeCommandBaseRegistryAndWizardTest) left the app/ directory itself behind; they now rmdir() it too, which fails harmlessly on a real project's populated app/. testRegisterModelInRegistryHandlesCorruptRegistryFile() seeds the registry file directly, so it creates the directory first rather than assuming a previous test left it there.

Because PHPUnit runs as root inside the Docker container, the leaked directory was created drwxr-x--- root:root: git status reported it as untracked forever while ls and rm -rf from the host both failed with Permission denied. (It also makes the leak easy to misdiagnose — a host-side find app returns the directory and silently nothing inside it.) Removing a stale one, if you still have it:

docker exec pramnos_php rm -rf /var/www/html/app

$_SERVER['PHP_SELF'] in the console application — Symfony's DumpCompletionCommand::configure(), registered by every Pramnos\Console\Application, reads $_SERVER['PHP_SELF'] unguarded and passes it to basename(). PHP always populates it on a real CLI run, but an embedded console application can be built with it absent — and a dozen test classes reset $_SERVER = [] — which produced an Undefined array key "PHP_SELF" warning plus a basename(): Passing null to parameter #1 deprecation across 23 tests in some execution orders. The constructor now back-fills it (from SCRIPT_NAME / SCRIPT_FILENAME, else 'pramnos') alongside the HTTP_HOST defaults it already sets, and never overwrites an existing value.

Added

/app in .gitignore, as a backstop should another code path create it.

Tests

ConsoleApplicationCoverageTest covers both branches of the PHP_SELF back-fill: filled in when missing (with the completion command still registered), left untouched when already set.

Realtime: pluggable SSE + WebSocket backplane

The broadcasting subsystem grows from publish-only into a full realtime stack. An application now chooses how live events reach the browser — Server-Sent Events on shared hosting, the built-in WebSocket server on a custom box, or Pusher/Reverb — by flipping one config key, with SSE and WebSocket sharing the same Redis (or database) backplane underneath.

See the new Realtime Guide for the full walkthrough with examples.

Added

Subscribable backplane. SubscribableDriverInterface adds subscribe(channels, onEvent, options) on top of DriverInterface, with a symmetric {event, payload, timestamp} envelope and a legacy raw-message fallback for incremental migration. SubscriptionOptions carries transport-agnostic loop tuning (readTimeout, maxRuntime, onIdle, onError).

  • RedisDriver\Redis::publish / subscribe with read-timeout idle ticks, reconnect and channel prefixing.
  • DatabaseDriver (+ BroadcastEventStore / DatabaseEventStore) — a polling backplane for hosts without Redis; ships a broadcast_events migration.
  • BroadcastingServiceProvider now registers redis / database / pusher from app.php['broadcasting'].

SSE transport. Pramnos\Http\StreamedResponse (callback body, incremental flush) and Pramnos\Http\Sse\SseWriter (event/comment/ping/retry + a stream() pump that forwards a backplane into the response, pings while idle, and emits a reconnect event before a Cloudflare-style edge timeout).

WebSocket hardening. LocalBroadcastServer gains a pluggable ConnectionAuthorizer (PusherAuthorizer enforces the app key + Pusher HMAC signatures on private-/presence- channels; AllowAllAuthorizer is the dev default) and a non-blocking Redis ingest (RedisSubscriberSocket, raw RESP over stream_select — no blocking client, no fork). broadcast:serve wires both from config via a new --channels option.

Transport selection. RealtimeConfig::forClient() produces a client-safe config per transport (never leaking app_secret), and pramnos-realtime.js connects the right way — EventSource for sse, pramnos-echo.js for websocket / pusher.

Notes

transport (client edge) and default (backplane driver) are independent, so e.g. default: redis + transport: sse publishes to Redis and serves browsers over SSE. Kafka remains a one-class seam: implement SubscribableDriverInterface.

Cross-cutting improvements: flat-key cache, log output modes, media alpha fix

A batch of cache / logging / media improvements driven by real app-integration friction — each one makes a framework subsystem fit uses it previously couldn't, rather than forcing the app to work around it.

Cache

FlatCache — a flat-key PSR-16 cache over any cache adapter (see the Cache guide). Where Cache/SimpleCache are category-based and mangle keys (and PSR-16 forbids :), FlatCache stores keys verbatim under a fixed prefix — so apps that address the cache with explicit colon-namespaced keys (chat:messages:hash) can use the framework cache directly. Backend-agnostic: ArrayAdapter in tests, Redis/File/Memcached in production.

Bug fix — ArrayAdapter double-prefixed keys. ArrayAdapter::load/save/ delete re-prepended $this->prefix even though the Cache layer already embeds the prefix in the key (as the docblocks state and as RedisAdapter treats it), so through the Cache class an ArrayAdapter with a configured prefix stored every entry under a doubled prefix. Now stores the key verbatim, matching the other adapters. (This consistency is also what let FlatCache be adapter-agnostic.)

Logging

Output mode — file / stream / both. The Logger only wrote to files (great for the LogViewer, awkward for containers). It now supports STDERR output too: Logger::setOutputMode('both'), or the PRAMNOS_LOG_MODE env var / LOG_MODE constant. Default stays file, fully backward-compatible. See the Logging guide.

Media

Bug fix — ResizeTools::fastimagecopyresampled() lost alpha. The quality (multi-step) path built an opaque intermediate image, flattening the alpha channel of transparent PNG/GIF sources. It now preserves transparency through the intermediate, so the optimised resampler is safe for transparency-preserving callers.