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-consentscripts - The scaffolded daemons container runs as www-data
api:docsdeclares the responses every operation sharesuserlogis 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
Databaseno 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
usergroupsfeature.initasks Enable User groups [usergroups]? [y/N]. The default is no, and the question is only asked withauth, because a membership row references a user. On an existing project:project:reconfigure --enable-feature=usergroups, thenmigrate. - Migration
2026_10_01_000001_create_usergroups_tablescreatesusergroupsanduserstogroups. 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-builtgroupid, 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-viewsknowsgroups, 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 underusergroups, 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.
userstogroupsis 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(migration2026_10_01_000002, usergroups feature) has the same shape asuser_roles, withgroupidforuserid.PermissionResolverfolds 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_changedfor that member: a group role given or withdrawn, a member joining or leaving a group that holds roles, or such a group deleted.WebhookServicecounts group members among a role's holders, andRole::delete()removes the role's group assignments.
A permission check about a group was answered about a user¶
Fixed¶
Permissions::isAllowed($id, …, 'group')on theauthserver.permissionsstore resolved$idas 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 sentpermissions_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 andserver: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.
Cookie consent switched off allows everything in the browser too¶
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.json{enabled: false}grants every optional category:has()istrue, gated scripts are released,onGrant()runs, and Consent Mode gets an all-grantedupdateafter 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.
A SPA that declined the cookie banner never released its data-consent scripts¶
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 everytype="text/plain" data-consentscript 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 fordata-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¶
initemitsuser: www-dataon thedaemonsservice — the user the image maps to the host's ids, the same one every wrapper scriptexecs as. An existing project adds the line by hand and givesvar/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-responsesandx-pramnos-secured-responsesin the--overridesfile. The first is merged into every operation, the second into every operation with asecurityrequirement. An operation's own response wins, and neither key is published.- A
nullin 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
404produced an operation with no summary, tags, parameters or security. - The viewer's title came from
--titleonly. It is now taken from the merged document, soinfo.titlein the overrides namesdocs/index.htmltoo.
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_tablemigration is removed. A new installation has nouserlog. An existing one keeps its table and its data; nothing drops it. - The DevPanel's user log reads
userlogwhere it exists. Where it does not, the page says user events are inchangelog_eventsinstead 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' => [...]]inapp.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:statusnames the pending migrations that still change an excluded table, so excludingcreate_usertokens_tablepoints at the later migrations that alterusertokens. 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()andMigrationLoader::tablesWrittenBy().Application::migrationScope()returns anexcludekey.
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'andfeature = 'core'. Rows already in the ledger keep the scope they were recorded with; nothing reads it there exceptmigrate:status's Scope column. - A test loads every migration under
database/migrations/framework/and fails on any whose$scopeis notframework.
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 callsconnect()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 hasnameandcontents, plusfilenamefor a file and an optionaltype. 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.