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
userstable - 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 TABLEstatements for columns added (or a table commented) via alter. - MySQL is unchanged: it carries comments inline in the column definition
(
... COMMENT '...') and itscompileCommentStatements()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()emitsCOMMENT ON COLUMNon PostgreSQL and keeps the comment inline (no separate statement) on MySQL. - Integration (
SchemaBuilderPostgreSQLTest): a column added viatable()on a real PostgreSQL database has its comment stored, verified throughpg_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)— appendsWHERE (<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 trailingASC/DESCkept. - A partial
unique()is compiled asCREATE UNIQUE INDEX ... WHERE ...(a partial unique cannot be a table constraint), both increateTable()(moved out of the inline column list to a post-create statement) and intable()/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 withDESC, partial unique as aCREATE UNIQUE INDEX, partial unique moved out ofCREATE TABLE, and a MySQL plain-index BC check. - Integration (
SchemaBuilderPostgreSQLTest): a real partial index (withDESC) and a real partial unique index, verified throughpg_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 theCREATE 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 noCREATE TYPEis 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 noCREATE TYPE; a shared type is created once. - Integration (
SchemaBuilderPostgreSQLTest): a real native enum type with its labels in order, and the column reported asUSER-DEFINED/ the enumudt_name, verified viapg_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'])isfalse, so the route did nothing (a silent no-op — no controller, no response); - a static method →
is_callableistrue, butnew \ReflectionFunction([...])threwTypeError: 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 plainnew), 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 bothsetUp()andtearDown(); - use
DELETEinstead ofTRUNCATEon that table, sinceTRUNCATEis rejected by MySQL while theuserstogroupsforeign key references it; - drop and recreate their minimal
#PREFIX#usertokensfixture aftersetupDb(), 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}.key — Oauth::__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.json — MakeCommandBase::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:
$_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/subscribewith read-timeout idle ticks, reconnect and channel prefixing.DatabaseDriver(+BroadcastEventStore/DatabaseEventStore) — a polling backplane for hosts without Redis; ships abroadcast_eventsmigration.BroadcastingServiceProvidernow registersredis/database/pusherfromapp.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.