Skip to content

1 October 2026

13 changes:

  • User groups, as an optional feature
  • Roles held by a user group
  • A permission check about a group was answered about a user
  • The SQL and column caches tell apart databases with the same name
  • Cookie consent switched off allows everything in the browser too
  • A SPA that declined the cookie banner never released its data-consent scripts
  • The scaffolded daemons container runs as www-data
  • api:docs declares the responses every operation shares
  • userlog is deprecated, and new installations do not get it
  • An application can exclude individual framework migrations
  • Two framework migrations said they were the application's
  • Destroying a cloned Database no longer disconnects the original
  • Client::multipart() sends fields and files in one request

User groups, as an optional feature

User groups already existed in part. User::getGroups() read the membership table, the legacy permission store granted to a group through it, and the mass-message screen offered a group as an audience. But no migration created either table and there was no screen to fill them, so on a stock installation the feature was simply absent.

Added

  • The usergroups feature. init asks Enable User groups [usergroups]? [y/N]. The default is no, and the question is only asked with auth, because a membership row references a user. On an existing project: project:reconfigure --enable-feature=usergroups, then migrate.
  • Migration 2026_10_01_000001_create_usergroups_tables creates usergroups and userstogroups. A membership is deleted with its user and with its group. An installation that already has either table keeps it. In that case there is no foreign key to a hand-built groupid, because MySQL refuses a key between columns of different types.
  • /admin/Groups, in all three themes, under People while the feature is on. You can list, create, edit and delete groups, and add a member by username, email or id or remove one. The controller is found through the framework's fallback, so enabling the feature later needs no scaffolding. project:publish-views knows groups, and the admin breadcrumb knows the three screens.

Fixed

  • A membership change applied to permissions up to a minute late. getGroups() cached under no category, so nothing could flush it. It now reads under usergroups, which the screen flushes on every change.
  • Erasing an account removes its group memberships explicitly, rather than relying on a foreign key that a hand-built table may not have. userstogroups is listed as personal data.
  • User::setupDb() builds the group tables from the new migration. It carries no DDL of its own any more.

Documentation

  • Authorization Guide: User groups: an optional feature. It covers turning it on, the screen, and what reads membership.

Roles held by a user group

Added

  • A group can hold roles, and every member holds them. authserver.group_roles (migration 2026_10_01_000002, usergroups feature) has the same shape as user_roles, with groupid for userid. PermissionResolver folds a user's group roles in beside their own, by the same rules: active, unexpired, role active. In a check scoped to an organisation, a system-wide role counts, and an organisation's role counts only for a member of it.
  • A Roles section on the group's page, in all three themes, to give and withdraw roles. A withdrawn role is switched off and kept, as Role::revokeFrom() does. Giving it again switches the same row back on.
  • PermissionResolver::resolveForGroup($groupId, $appId) returns the permissions of the roles a group holds.
  • Every change that alters a member's permissions sends permissions_changed for that member: a group role given or withdrawn, a member joining or leaving a group that holds roles, or such a group deleted. WebhookService counts group members among a role's holders, and Role::delete() removes the role's group assignments.

A permission check about a group was answered about a user

Fixed

  • Permissions::isAllowed($id, …, 'group') on the authserver.permissions store resolved $id as a user id, so a check about group 5 returned the grants of user 5. It is now answered from the roles the group holds. See the Upgrade Guide row.
  • Deleting a role reached no relying party. Role::delete() removed the assignments and only then sent permissions_changed, whose recipients are found from those assignments. It now sends first.

The SQL and column caches tell apart databases with the same name

Fixed

  • Two databases with the same name on different servers or drivers shared cache entries. The SQL result cache and the column cache were scoped by database and schema only. Staging and production behind one Redis, or a MySQL and a PostgreSQL database both called app, answered each other's queries. The scope now includes the driver and server:port. It was found by running the same group test on both drivers with one cache.
  • Every existing entry is under the old key after the upgrade, so the caches start cold once.

With the banner off, PHP answered "everything is allowed" and the browser answered "nothing is". pf-consent.js returned early on {enabled: false}, so has() stayed false for every optional category, onGrant() callbacks waited for ever and type="text/plain" scripts were never released. A SPA shell loads the script whatever the setting, so switching the banner off silently killed its analytics; an MVC page got no script at all from tag(), so the guide's own gated Google Analytics example never ran.

Fixed

  • pf-consent.js on {enabled: false} grants every optional category: has() is true, gated scripts are released, onGrant() runs, and Consent Mode gets an all-granted update after its default. Nothing is drawn and no cookie is written.
  • CookieConsent::tag() with the feature off emits the script configured {"enabled":false} instead of '', so gated scripts on an MVC page are released.

A behaviour change — see the Cookie consent switched off row in the Upgrade Guide.

Fixed

  • The SPA shell of a project that answered no to the cookie banner now loads pf-consent.js, configured {"enabled":false} inline. Switched off, the script releases every type="text/plain" data-consent script and grants every category, but only if it runs, and that shell used to leave it out to save a request. So those scripts ran on an MVC page and never in a SPA. The configuration is in the tag, so the shell still fetches nothing. Turning the banner on later means swapping that attribute for data-config-url, as the guide says.

The scaffolded daemons container runs as www-data

The daemons service init writes into docker-compose.yml had no user:, so it ran as root. On a fresh clone it is usually the first process to touch var/, which it created root-owned; ./dockertest then died in the bootstrap with Unable to open lock file, and the application's logs could not be written either.

Fixed

  • init emits user: www-data on the daemons service — the user the image maps to the host's ids, the same one every wrapper script execs as. An existing project adds the line by hand and gives var/ back: docker compose exec -u root app chown -R www-data:www-data var.

api:docs declares the responses every operation shares

An operation read from a route file documented 200: Successful response and nothing else. The 403 an API answers without a key and the 401 it answers without a session come from middleware, so saying so took one override per operation, which for one application meant 192 of them, maintained by hand as the routes changed.

Added

  • x-pramnos-default-responses and x-pramnos-secured-responses in the --overrides file. The first is merged into every operation, the second into every operation with a security requirement. An operation's own response wins, and neither key is published.
  • A null in an override removes the key, so a default can be taken back from a public address.
  • OpenApiGenerator::fromDirectoryAndRoutes() generates one document from both sources, merging the overrides once.

Fixed

  • An override on an operation only the router knew replaced it. The overrides were merged into the attribute document first, where that operation did not exist, so a single extra 404 produced an operation with no summary, tags, parameters or security.
  • The viewer's title came from --title only. It is now taken from the merged document, so info.title in the overrides names docs/index.html too.

See Routing Guide — Responses every operation shares.

userlog is deprecated, and new installations do not get it

The framework created userlog, read it in the DevPanel and never wrote to it. It also duplicated pramnos.changelog_events, which records the same events with the actor, the source and JSON details, for any entity. userlog has only the user a row is about.

Changed

  • The create_userlog_table migration is removed. A new installation has no userlog. An existing one keeps its table and its data; nothing drops it.
  • The DevPanel's user log reads userlog where it exists. Where it does not, the page says user events are in changelog_events instead of reporting an error.

What breaks

An application that writes to userlog keeps working on every database it already has. On a new installation the table is missing: create it in an application migration, or move the writes to Model::logEvent(). See the userlog is deprecated row in the Upgrade Guide.

Documentation

An application can exclude individual framework migrations

There was no way to keep the framework off one table. migration_cutoff skips an epoch, 'framework' => false skips every framework migration, and a feature gate skips a whole directory. An application that managed one framework table itself had to accept the migration or switch off all of them.

Added

  • 'migrations' => ['exclude' => [...]] in app.php, by slug. An excluded migration never runs, is never recorded and is never counted as pending, on every path: migrate, the per-request auto-run, migrate:status, the DevPanel card and the MCP status tools. It counts as satisfied for migrations that depend on it, and the dependency pool does not pull it back in.
  • migrate:status names the pending migrations that still change an excluded table, so excluding create_usertokens_table points at the later migrations that alter usertokens. It also reports any slug that matches no migration.
  • migrate <slug> for an excluded migration exits 1 and says it is excluded.
  • MigrationRunner::OPTION_EXCLUDE, MigrationRunner::filterExcluded(), MigrationLoader::unknownExclusions() and MigrationLoader::tablesWrittenBy(). Application::migrationScope() returns an exclude key.

See Migration Guide — Leaving a framework table alone.

Two framework migrations said they were the application's

add_missing_foreign_keys_to_existing_tables and add_missing_indexes_to_existing_tables ship with the framework but declared no $scope, so they took the default app. migrate --scope=framework left them out and their ledger rows were filed under the application.

Fixed

  • Both declare scope = 'framework' and feature = 'core'. Rows already in the ledger keep the scope they were recorded with; nothing reads it there except migrate:status's Scope column.
  • A test loads every migration under database/migrations/framework/ and fails on any whose $scope is not framework.

Destroying a cloned Database no longer disconnects the original

clone $db copies the link handle, not the connection, and both objects closed it. Whichever was destroyed first disconnected the other, and the second close raised mysqli object is already closed from a destructor. In the suite it surfaced as PHPUnit dying between two unrelated tests, long after a test had cloned the singleton.

Fixed

  • A clone does not own the link it inherited. Its close() and destructor drop the handles and close nothing; the original closes the link. A clone that calls connect() owns, and closes, the link it opened.

A behaviour change for code that closed a clone to end the original's connection; see the A cloned Database row in the Upgrade Guide.

Client::multipart() sends fields and files in one request

Pramnos\Http\Client could send JSON, a URL-encoded form or a raw body, but not multipart/form-data. Every platform that takes a file with a message (a chat webhook's payload_json and files[n], a media upload) needs it, so each caller built boundaries by hand.

Added

  • ->multipart(array $parts): each part has name and contents, plus filename for a file and an optional type. The body is built as a string, so retries and fakes see the same bytes. A line break in a name, filename or type throws, and a double quote is sent as %22.
  • ->multipartParts() returns the parts, so a callable fake can assert on them without parsing the body.

See HTTP Client Guide — Files with a message.