Skip to the content
Documentation
Help Start
Reading in:
  • German: 9,059 of 9,059 pages
About the help in other languages
All help pages

Exponential 6.0.15

Not released yet. The 6.0.15 line follows v6.0.14 (2026-06-07); no tag v6.0.15 existed on 2026-10-12. This page lists what the line contains on that date.

Download as PDF

Address: https://doc.exponential.earth/exphelp/viewer/changelogs/6.0/6.0.15.md

On this page

Exponential 6.0.15

Not released yet. The 6.0.15 line follows v6.0.14 (2026-06-07); no tag v6.0.15 existed on 2026-10-12. This page lists what the line contains on that date.

Read this page if you run 6.0.14 or older and plan to move to the current code, or if you want to know what the line brings. It is long because the line is: four months of work. Use the sections by period.

Upgrading

The console does it in one call (Upgrading with the console); on an installation that is not a git checkout with git_manager, update the code first as the Upgrading guide, part C, says:

./console exp:upgrade:status            # what is done and what is pending; changes nothing
./console exp:upgrade --to=v6.0.15      # dry run: the code, the libraries and every pending step
./console exp:upgrade --apply --to=v6.0.15   # backup, code, libraries, database, scripts, deploy, a check of the site
./console exp:upgrade:rollback          # the backups, should you need to go back

The list below is what those steps do, kept as a reference; each item names its step (exp:upgrade:steps lists them all). What you already did by hand counts: the step says not-needed. Read the checklist of each period you pass, in this order:

  1. Behaviour changes of July and August 2026

  2. Behaviour changes of 16 to 30 September 2026

  3. Behaviour changes of 1 to 2 October 2026

  4. Upgrade notes: e-mail preferences and the mail gate

  5. For the changes of 10 to 12 October 2026: the operator notes of the autoload generator (run php bin/php/ezpgenerateautoloads.php -e once) and of user accounts that exist only as a draft; expai and expqueue arrive inactive, see Exponential Intelligence in the distribution.

  6. Authorizing an OAuth client needs the policy oauth/authorize: run php bin/php/addoauthauthorizepolicy.php --dry-run, then without --dry-run, so the roles that let users sign in keep authorizing REST clients. Step 6.0.15-oauth-authorize-policy. php bin/php/addoauthauthorizepolicy.php (a dry run), then with --apply, so the roles that let users sign in keep authorizing REST clients.

  7. Well-known passwords are refused, and a forgot-password link expires: new passwords are checked against a list of well-known ones (WeakPasswordCheck), and a reset link works for one hour (ForgotPasswordKeyLifetime). Existing passwords keep working.

  8. Demo mode: nothing changes unless the environment variable EXP_DEMO_MODE=1 is set, which is only for public demos. Check that no production process has it. Under Velocity it is taken only from the installation's site envvars (/etc/vc/sites-available/<site>.envvars), never from the shared /etc/vc/envvars: move it before restarting a demo.

  9. Translated documentation: the documentation moved to doc/eng-us/, the German translation is doc/ger-de/ (a language directory is the locale code in lower case). Links, scripts and settings overrides of your own that name a documentation file need eng-us/ after doc/, and ones written for the interim names (en/us/ and de/ below doc/) need the new names; help addresses do not change, and the old ones (/exphelp/viewer/en/us/<id>, ?lang=en-us) are redirected or still accepted.

  10. Help maintained as content: DITA export, stored e-books, overdue reviews: on a site that runs help as content, setting a help object to Released now builds and stores the manuals' e-books as file objects (EbooksOnRelease); run ./console exp:help:content ebooks --apply once after the deploy.

  11. PKCE and state for the OAuth authorization server: ezprest_authcode has two new columns, code_challenge and code_challenge_method (in the database update below); until they exist no authorization code can be issued (step 6.0.15-oauth-pkce-columns). Clients made in the administration are not affected otherwise. CORS for the REST API is new and off by default.

  12. The help's manuals made from the Markdown help: on a site that runs help as content, run ./console exp:help:content install --apply -s admin and ./console exp:help:content build-manuals --apply -s admin --release after the deploy, and again on each release.

  13. The public policies of the Anonymous role: an installation made between 12 and 19 October 2026 refuses every registration; run php bin/php/addanonymouspublicpolicies.php --dry-run, then without --dry-run (--skip=user/register keeps registration closed). Step 6.0.15-anonymous-public-policies (with the command's defaults: to keep registration closed, skip the step and run the command). php bin/php/addanonymouspublicpolicies.php (a dry run), then with --apply (--skip=user/register keeps registration closed).

  14. The everyday policies of the Editor role: editors of a new installation could not hide, restore from the trash, see what uses an object or multi-upload; run php bin/php/addeditorpolicies.php (a dry run), then with --apply, and read its WARN lines; since 30 October it also adds the interface choices and the media site's classes (settings/editorpolicies.ini) and removes duplicate policies. Step 6.0.15-editor-policies.

  15. Member management: editors manage the accounts below Members, never those of Administrator users or Editors; run php bin/php/addmembermanagement.php (a dry run), then with --apply (step 6.0.15-member-management). user/setting now also asks for content/edit of the account ([MemberGuardSettings] SettingRequiresEdit).

  16. The installers write the cron job: new installations get the crontab lines that run their cronjob parts (the queue, the imports, the notifications, the search index); on an installation that exists run php bin/php/installcrontab.php --add (a dry run), then with --apply, as the owner of the installation (or as root). The dashboard's block Cronjobs warns when the parts do not run.

  17. plain_site's Editor role without content/*: on an installation of plain_site, php bin/php/addeditorpolicies.php (a dry run) now also lists content/* to be replaced by the content functions the other packages give editors; run it with --apply (--keep-content-wildcard keeps it).

  18. Approval of new user accounts: editors may create editor accounts, which wait for an administrator's approval; run php bin/php/adduserapproval.php --dry-run, then without --dry-run, and clear the INI cache (--clear-tag=ini). Step 6.0.15-user-approval; the finish stage clears the caches. When content/publish (before) runs a workflow already, which wait for an administrator's approval; run php bin/php/adduserapproval.php (a dry run), then with --apply, and clear the INI cache (--clear-tag=ini). When content/publish (before) runs a workflow already, the event goes first into it.

  19. Help maintained as content: review workflow, "Was this helpful?", token clients, EPUB checks, history, public pages: on a site that runs help as content, the author of a help object can no longer release it (ReleaseByAuthor), and two tables are new (exphelp_help_id, exphelp_feedback, in the database update); run ./console exp:help:content install --apply -s admin and check -s admin --fix once after the deploy. the event goes first into it. Approve and Deny of such an account ask "Verify it's you" (group users) first.

  20. The second step on the other sign-in paths: nothing changes by default. With sevenx_authentication_2fa 1.0.17, REST, SOAP, WebDAV and the expservices login still sign an Enforce2FA account in with its password alone. A site that wants one of them to refuse that lists it in sevenxauthentication2fa.ini [OtherSignInPaths] Guard[] (expservices, webdav, rest, soap); before that, give the clients of such accounts app passwords (user2fa/setup), API keys or OAuth tokens, and the expservices clients the field otp.

  21. OAuth tokens: disabled users, client binding, refresh token lifetime and token sources: a REST client that sends ?oauth_token= in the address gets 401 until it sends the Authorization header (or rest.ini [OAuthSettings] AllowTokenInQuery=enabled is set); refresh tokens expire after 30 days (RefreshTokenTTL, 0 = never).

  22. The user audit log: a new table, expuser_audit, keeps the security events about user accounts and groups (in the database update, or php update/common/scripts/6.0/createuseraudittable.php on any engine; step 6.0.15-user-audit-table); until it exists nothing is stored there. Give user/audit to the roles that may read it, and run the cronjob group infrequent (or the part useraudit) for its retention.

  23. Your devices: a new table, expuser_session_info, keeps the host, the place and the second step of each signed-in session (in the database update, or php update/common/scripts/6.0/createusersessiontable.php on any engine); until it exists the sessions are listed without these details. Addresses of sessions are now stored as the audit stores them ([UserSessionSettings] RecordIP=audit: the network by default); set RecordIP=full to keep full addresses.

  24. Commands only change things with --apply: the setup, policy, maintenance, mail, notification, crontab, installer and Velocity configuration commands only show what they would change unless --apply is given. Add --apply to every script, runbook and crontab line that runs one of them to make the change (demo rebuild scripts: exp:install, collaborationsampledata, preload). ezpgenerateautoloads, exp:cache and ezcache.php act at once as before.

  25. The SOAP entry script is now index_soap.php (Deutsch): on an installation with soap.ini [GeneralSettings] EnableSOAP=true, let index_soap.php through the web server (the .htaccess, the HTTPS virtual host, Plesk's additional directives, the nginx server block) and point the SOAP clients (the imports of syndication, middleware) at it, then test with curl (a GET answers "only understand POST methods", a POST of an envelope a SOAP fault, never 404). soap.php keeps answering as a shim that writes a notice to var/log/warning.log once a day; it will be removed in a later major version.

  26. The WebDAV entry script is now index_webdav.php (Deutsch): where WebDAV is on, change the virtual host (HTTP and HTTPS) and the rewrite rules from webdav.php to index_webdav.php: Apache RewriteRule, Plesk's additional directives for HTTP and for HTTPS, nginx location and fastcgi_param SCRIPT_FILENAME, IIS rules, and any allowlist of root scripts. webdav.php forwards to index_webdav.php and writes one warning a day to warning.log until it is removed in a later major version. Test with curl -i -X PROPFIND -H 'Depth: 1' https://www.example.com/index_webdav.php/ (207 Multi-Status). Velocity does not serve WebDAV under either name.

  27. Why Exponential supports PHP 7.4 (Deutsch): the extension packages that need PHP 8 moved from the root composer.json into the metapackage se7enxweb/exponential-php-floor. A project that copies the kernel's require keeps working; one that lists those packages itself should keep them only where its PHP is 8.0 or later. Run composer update with the PHP that serves the site. Code that names the view classes Exponential\View\Kernel\Class\... keeps working through class_alias; new code uses Exponential\View\Kernel\ContentClass\....

  28. Fixes from the Opencontent and Mugo Web forks (Deutsch): a URL alias deeper than 30 path elements is not found (site.ini [URLTranslator] MaxPathDepth), every eZLog::write() entry is one line, uploads with php4, php5, php7, php8, phps, shtml, cgi, pl, asp, aspx, jsp or htaccess are refused (file.ini [FileSettings] FileExtensionBlackList[]), and ezjscnode::subtree returns at most 500 items (ezjscore.ini [ezjscServer_ezjscnode] HardLimit). Nothing to run; raise a setting where a site needs more.

  29. The record of exp:upgrade is exp_migration: only an installation that ran a pre-release of 6.0.15 has the table expupgrade_step. Step 6.0.15-upgrade-record-table copies its rows into exp_migration, checks that every row is there and drops it (dry run first: ./console exp:upgrade:database). Every other installation has nothing to do.

  30. The switch to expcomponents (Deutsch): the kernel requires its libraries as se7enxweb/expcomponents-*, Exponential's fork of the Zeta Components, instead of zetacomponents/*. The libraries stage swaps them (composer install from the release's lock file; in a Composer project composer update se7enxweb/exponential --with-all-dependencies). Same ezc* classes, no setting or database change. A project composer.json that pins an exact zetacomponents/* version must loosen or rename it; the mail fork refuses addresses with whitespace or < > , ; ".

  31. The site packages no longer carry ezpaypal or bcwebshop. ezpaypal is installed by Composer (the default install requires se7enxweb/ezpaypal); the site packages (ezwebin_site, ezflow_site, ezdemo_site, sevenx_site and their _clean variants) no longer require the ezpaypal_extension package, so the package server's older copy is no longer written over Composer's. Their installers still activate ezpaypal (ActiveExtensions[]=ezpaypal in the site.ini they write). bcwebshop is no longer installed by any package (the sevenx_extension package dropped it) nor activated by the installers: the kernel's shop covers what it added, and its shop module and kernel override conflicted with it. Existing installations keep working; nothing is removed. To move a site from bcwebshop to the kernel's shop: remove ActiveExtensions[]=bcwebshop from settings/override/site.ini.append.php (and any siteaccess ActiveAccessExtensions[]=bcwebshop), run php bin/php/ezpgenerateautoloads.php -e and php bin/php/ezcache.php --clear-all, then check the basket, checkout and order list (Webshop tab); orders, VAT rules and currencies are kernel tables and stay. A site that installed ezpaypal from the package server and now gets it from Composer needs nothing more than composer update (the extension/ezpaypal folder becomes Composer's).

The database update for the line is update/database/<engine>/6.0/dbupdate-6.0.0-6.0.15.sql, followed once by php update/common/scripts/6.0/createaudittables.php; ./console exp:upgrade:database does the same on every engine in 21 steps (6.0.15-upgrade-record-table to 6.0.15-site-version), and ./console exp:upgrade:scripts the update scripts of update/common/scripts/6.0/ (6.0.15-missing-user-settings, 6.0.15-trash-records-to-columns; the audit, API key, bookmark, passkey, session, transfer and user audit tables are database steps). Not steps: the crontab (item 16), the help content commands (items 10, 12 and 19) and the demo mode variable (item 8).

How this page is organised

June and the first half of September are told on the chronicle pages June 2026, second half and September 2026, first half. Every single change, with its commit, is in the ledger of the root repository.

July and August 2026

The changes between 1 July and 31 August 2026. The stories are in July 2026 and August 2026; the upgrade checklist is Behaviour changes of July and August 2026.

Added

  • ezpm.php, the package manager, runs through bin/php/console. It installs content packages with a lock, delayed indexing, batched commits, a destination node and a progress line. Feature.
  • bin/php/resetuserpassword.php resets a password, authorised by an administrator or by the root user. Feature.
  • The Kickstarter (bin/php/kickstarter.php, exp:kickstarter) installs a site from kickstart.ini. Feature, upgrade notes.
  • The Template Editor orders overrides by priority, chooses the source, matches by object id, edits and removes match conditions, and has a menu entry. Feature.
  • ShowTemplatePathComments (site.ini [TemplateSettings]) wraps every template in START and STOP comments that name the override source. Feature.
  • AdditionalExtensionDirectories[] and eZExtension::extensionName(): extensions in more than one folder. Feature.
  • Icon themes in extensions (icon.ini [ExtensionSettings] IconExtensions[]) and a default job icon. Feature.
  • Hooks for a Redis or Valkey content view cache and compiled INI cache. Feature, the extension.
  • Setup > Extensions sorts, shows info cards and downloads an extension in four formats. Feature.
  • The installation name output filter, and the installation_name and is_production_system operators. Feature.
  • fetch( 'content', 'class_list' ), ezscript.ini [eZScriptSettings] RootDelay, a Layouts tab in the admin node view (Layouts), and the PHPUnit workflow of GitHub Actions. Specification.

Updated

  • The setup wizard needs privileges only on your own database; packages install their dependencies first; the seed data defaults to eng-US and describes Exponential; the kickstart siteaccesses are site and admin. Feature.
  • Security: tokens and activation hashes come from random_bytes(); header and shell injections are fixed; a login fails closed when the user has no ezuser_setting row; CDN scripts load over https; jQuery 3.7.1 with Migrate 3.4.1 at that time. Specification.
  • HTML5 markup in kernel and design output; PHP 7.4 to 8.5 clean-ups; INI files are saved so that untouched keys stay as they were; permission SQL works with empty limitation lists; imported Ibexa DocBook output; better diagnostics for image aliases and missing objects; SQLite schema and debug output fixes; MongoDB logs through eZDebug. Upgrade notes.
  • The media theme directory is part of the repository, no longer a submodule.
  • The default sort of the extension list is the loading order since 29 September, not A to Z. Feature.

Renamed

  • kickstart.ini is shipped as kickstart.ini--example.
  • The progress reporter eZPMStatus is expScriptStatus.

Related: Continuous integration and the test suite, Template override ordering (how the editor's Priority is used).

16 to 30 September 2026

The story is in September 2026, second half; the upgrade checklist is Behaviour changes of 16 to 30 September 2026.

Added

  • Velocity (./console exp:velocity): a persistent-worker application server with the Qbix, FrankenPHP and PHP built-in engines, HTTPS, HTTP/2, and the deploy, config, ssl and ext verbs. Feature, Deploying guide.
  • The server control commands exp:webserver, exp:frankenphp and exp:solr. Feature.
  • ./console exp:install --apply: an installation in one command on SQLite, MySQL, PostgreSQL, MongoDB or Oracle, with the logs var/log/setup.log and var/log/kickstart.log. Feature, specification, Getting started.
  • Maintenance mode (bin/php/maintenance.php, exp:maintenance, Setup > Maintenance). Feature.
  • The role-aware HTTP cache and the SQL query cache, both off by default. HTTP cache, SQL query cache.
  • The store dashboard (shop/dashboard) with fourteen order statuses, and permanent order receipts (shop/orderreceipt/<token>, shop.ini [OrderViewSettings] OrderLinkView). Store dashboard, order receipts.
  • A package contents browser, package compare and selective import (package/compare), package licenses from package.ini [LicenseSettings], and Semantic Versioning for packages. Compare and import, licenses and versions.
  • Cache clears that move directories aside (site.ini [FileSettings] RenameBeforeDelete) and the cache_cleanup cronjob part. Feature.
  • The "form expired" 403 page (design:error/kernel/6.tpl). Feature.
  • The loading order card of Setup > Extensions and [SiteAccessSettings] ExtensionSettingsSiteAccess; drag and drop order of template overrides; policy IDs, sorting and order buttons in roles; moving a class attribute to the top or bottom. Loading order, override ordering, role policy order, class attribute move.
  • exp:cache; ./console --version, --about and --copyright; view templates for DocBook tags. Cache console, chronicle.

Updated

  • Security defaults: headers on every page, the session cookie, uniform answers of login and forgot-password, stricter file rules, [HttpCacheSettings] TagHeader. Specification, Security and audit guide.
  • About 60 datatype and helper fixes: arrays posted as text, script URLs, directory parts in file names, header and INI injection. Specification.
  • Driver and installer fixes for SQLite, PostgreSQL, MySQL, Oracle and MongoDB; SQLite is the wizard's default. Specification, MongoDB.
  • The administration: the content structure tree starts at the top node; the header tabs read Media, Users and Store; the About and Copyright pages list every extension with description, license and website. Content tree, About and package pages.
  • Translations: a finished eng-US, complete German, and valid lang values for twenty locales. Feature.
  • Translations: the French administration is complete, a new and complete Ukrainian one is offered by the interface language switch, and the German one uses one term per concept (Siteaccess, Extension, Sektion, Eigentümer, Ausstehend, USt) with the wrong translations of "Action" and "Discard" corrected. Languages.
  • Translations: the German administration passes the translation quality test, which now checks it too: lost placeholders, tags, entities, spaces and line breaks restored, and the English texts left in it translated. Languages.
  • Start preloading runs in the background. Feature.

Removed

  • The setting AnonymousOrderLinkView: every order links to its receipt when OrderLinkView=orderreceipt.
  • The SQLite demo data files that no installer loads.

Renamed

  • [HttpCacheSettings] ProxyHeaders is TagHeader; ProxyHeaders=enabled in an override still means xkey.
  • The site's service worker is /index.js; /sw.js loads it.
  • The version class is ExponentialSDK, and the example crontab is exponential.cron.
  • The engine package is se7enxweb/exponential-velocity.
  • The top-level alias of Setup is x-setup.

All five: upgrade notes.

1 and 2 October 2026

The story is in October 2026; the upgrade checklist is Behaviour changes of 1 to 2 October 2026.

Added

  • Bookmark folders: bookmarks can be organised in a tree of virtual folders of any depth, in the bookmark page, the Bookmarks box, the browse dialog, the online editor (TinyMCE 3 and 8) and the expbookmark remote services. Needs the database update (a table expbookmark_folder and two columns on ezcontentbrowsebookmark); existing bookmarks stay at the top level and the flat bookmark list is unchanged. Feature, upgrade.
  • Added: Bookmarks of system pages: "Bookmark this page" on every module view of the administration keeps the page's address with its parameters (without the siteaccess) in the user's bookmark folders, with a name of its own; the Bookmarks box and My bookmarks list both kinds, and a page the user may no longer open is shown without a link. New table expbookmark_view (update/common/scripts/6.0/createbookmarkviewtable.php). Feature, upgrade, help.
  • Added: Bookmarks of system pages know what they are and why they cannot be opened. A search is bookmarked with its search text (a saved search, with a magnifier and the type Search); every other system page has the icon of its module. A page that cannot be opened in the siteaccess it is shown in is greyed with the reason (switched off in this siteaccess, no role allows it, the view no longer exists ...). Views that only act on a posted form, sign in or out or edit cannot be bookmarked. All of it is set in the new settings/bookmark.ini, where an extension also adds its own kind of bookmark (expBookmarkType); the button is in the admin3 design too. The help shows every step in admin4, the classic design and the editor interface, light and dark, in English and German. Feature, help, bookmark.ini.
  • Added: A bookmark of a system page keeps its address taken apart as well: expbookmark_view.module_name, view_name and view_parameters (JSON of the ordered, unordered and query parameters), indexed by module and view, so bookmarks can be checked and moved by module and view. createbookmarkviewtable.php --rename=<old>=<new> moves the bookmarks of a renamed module or view. A table made by an earlier 6.0.15 build gets the columns from ./console exp:upgrade --apply (steps 6.0.15-bookmark-view-columns and 6.0.15-bookmark-view-fill) or createbookmarkviewtable.php; bookmarks keep working until then. Feature, upgrade.
  • Audit trail: a hash-chained, signed, searchable record of sign-ins, content, settings, commerce and deploys. It comes with the exp:audit command, the Audit tab and console, sinks (syslog, journald, signed webhook, mail), alert rules, archives in five formats and the import of the 4.x logs. On by default. Feature, specification, upgrade notes.
  • Content jobs: large remove, copy, move, hide, section, state and location operations run as resumable background jobs, with content/job/<id>, content/jobs, ./console exp:expcontentjob, the contentjobs cronjob part and [ContentJobSettings]. Feature, upgrade notes.
  • exp:ini, a settings editor that changes INI files line by line in every scope. Feature, upgrade notes.
  • The Exp Debug bar: tabs, a live summary, settings with presets and undo, cache controls, minimise. Feature, upgrade notes.
  • Table options of the sub-items list: 129 registered columns, attribute columns, presets, CSV export, columns defined by INI and PHP or template. Feature, upgrade notes.
  • Request rules (settings/requestrules.ini, bin/php/ezrequestrules.php) and the policy content/view_system_url. Feature, upgrade notes.
  • expservices: about 1,100 remote services over ezjscore, personal API tokens, expfeed::runImport, the expeditor services and two portal front ends. Feature, specification, upgrade notes, Remote services guide.
  • The online editor on TinyMCE 8.9.2, an opt-in second engine, with ezoe dialogs for embed, links, custom tags, literal tags, tables and general tags, an engine registry, /ezoe/engine and browser tests. Feature, upgrade notes.
  • The admin4 design, light and dark. Feature.
  • The editor siteaccess and design, installed by the setup wizard, with EditorAccess, EditorAccessPort and EditorAccessHostname in kickstart.ini; a new look for the setup wizard. Feature.
  • HiddenTabs[] in menu.ini [TopAdminMenu] and admininterface.ini [WindowControlsSettings]. Feature.
  • A page for missing Composer libraries that repairs the installation from the browser, with exprepair.php. Feature, upgrade notes.
  • Exponential\Runnable base classes (Runnable, Command, CronjobPart, ModuleView), events around every command, cronjob part and view, [RunnableSettings], and shared services (trash, sessions, drafts). Specification, upgrade notes.
  • Every column of the order list sorts it. Feature.
  • The trash records who trashed each item and from where. Feature.
  • A queue for SQLite writers: a transaction waits at its start for the writers ahead of it, up to [DatabaseSettings] SQLiteTransactionWait seconds (default 25). Upgrade notes.
  • An opcode cache profile for comparing engines, off unless the file var/tmp/opcache_profile.on exists. Feature.
  • English and German texts for the debug bar, the audit console, content jobs, setup/rad and about 500 other strings that showed up untranslated.

Updated

  • jQuery 4.0.0 and jQuery UI 1.14.2 in ezjscore 1.4.0; the admin runs on jQuery 4 and Exponential UI. Since 2026-11-02 ezjsc::jquery loads jQuery 4 without jQuery Migrate: Migrate 4.0.2 is still shipped, but not loaded unless ezjscore.ini [eZJSCore] LocalScripts[jqueryMigrate] names it. Feature.
  • The SQL query cache knows Oracle's statements (ezoracle 2.3.2). Specification.
  • The static cache refreshes the pages of a publish once each, in parallel (staticcache.ini [CacheSettings] FetchConcurrency, default 8). In the measured case 4975 ms became about 500 ms. Feature.
  • Velocity keeps the kernel's regenerated cache files in the opcode cache (file_update_protection 0 for the server), so a rendered page costs about what it does under PHP-FPM. Before, the opcode cache served 2.7% of the includes under Velocity against 97% under PHP-FPM; 644 ms against 166 ms of CPU. Feature.
  • The admin dashboard, menus and top tabs show a link only to a user who can open it. Feature, upgrade notes.
  • The online editor's upload checks the file type on the server; poll votes, form submissions and the VAT country choice need content/read. Upgrade notes.
  • exp:velocity deploy always clears the design_base cache and, with --packer, the ezjscore packer cache. Deploying guide.
  • Admin details: the header search popup works in both copies; the Store sidebar lists orders and products first; long names in the side columns wrap; the page-not-found error names the missing page; the debug report remembers whether it is open.
  • The classic grey administration design has the Exponential logo, a left menu that collapses and resizes, and the Layouts pages in its look. Every logo image of the kernel designs shows the Exponential logo.
  • Every copyright notice names 1998 - 2026 7x & Exponential Foundation first. Specification.
  • Kernel commands, cronjob parts and module views are classes; their entry points are unchanged. Every command shows a description in bin/php/console list.
  • Empty trash purges in batches, like the command; Remove timed out sessions also removes their baskets; the clusterpurge part uses 30 days; a copy of an object with an image owns its own image files.
  • Fixed: SQLite "database is locked" under concurrent writes (transactions now queue for the write lock); subString() without a length; the asynchronous publisher shares no connection with its fork and no longer stops with a type error on PHP 8; the template path comments of a request no longer read another siteaccess's override list.
  • Fixed: PHP 8.5 notices on Oracle (preferences, class attribute categories, ezpExtension::getInfo, the template compiler, eZHTTPTool::redirect(), the INI setting datatype, ristring, {if}); E_STRICT in the tree menu and the REST front controllers; recurring warnings in the logs.
  • Fixed: removing a draft removes every image file of that draft; removing a media attribute keeps a file that another row names; removing a subtree that holds every location of an object removes the object; a node's path_identification_string is rewritten from the stored value; removing a VAT rule no longer stops with a fatal error; the PDF export of a content node no longer answers 500.
  • Fixed: the CSS packer keeps the space before a colon in selectors; the Velocity warm-up never takes the setup wizard's lease.
  • Fixed: a module redirect carries the request query (?gclid=...) only to relative targets and to targets on the own host; redirects to another host, including another host-matched siteaccess such as the admin host, and protocol-relative (//host) or other-scheme targets are left untouched, so a sealed payment URL is no longer invalidated by tracking parameters. The query joins with & when the target has one and stays before a #fragment. See the redirect note.
  • Fixed: ezjscore/call answers a PHP Error in a server function with a generic text and writes the details to the log, instead of breaking the JSON response; exceptions still pass their message, and the { ok, error } envelope of the remote services is unchanged.
  • Fixed: an image attribute that never stored a file no longer asks the image manager for every alias on every request.
  • Fixed: the lazy image alias write-back no longer reverts a published image to stale draft paths.
  • New releases of the required extensions; see Required packages.

Removed

  • YUI, from the admin, admin3, admin4 and standard designs and from the bundled ezjscore 1.5.0: the YUI libraries, ezjsc::yui2, ezjsc::yui3, ezjsc::yui3io and their settings, and the YUI 2 calendar script of the standard design. Upgrade notes.

Renamed

  • explayouts 1.4.10: the component blocks are exp_component_<type>, and block links name their node by its remote id. Two upgrade scripts rename the stored blocks and links. The media theme 1.5.29 and expsite_data_media 1.0.6 follow.
  • The debug report, and the bar, are named "Exp Debug".

3 and 4 October 2026: notifications

The upgrade checklist is Upgrade notes: notifications. The system is described in the user's guide, the administrator's guide and the specification.

Added

  • Notification status page (Setup, Notification) with pending events, digest items, subscriptions, the last runs and a problem list; Run now and a dry-run preview as background jobs; cleanup of handled events, old events and subscriptions to deleted content.
  • Commands exp:notification:status, run, events and subscriptions (aliases exp:notify:*), with --dry-run, --mail-file-dir (mail to files for one process), --at, --event. Command reference.
  • expNotificationService: one place that runs, plans, records and audits notification runs, under a lock. The cronjob part notification can run alone (php runcronjobs.php notification).
  • Confirmation pages for adding and removing a notification, and guides for administrators and developers, the settings reference and the command reference.
  • Failed mail is kept: a notification the mail transport refuses stays in the queue and is tried again at every run for [RuleSettings] RetryHours (default 72); the run record, exp:notification:run (exit code 1) and the status page report it, and a message older than that is given up and counted.

Updated

  • My notification settings is an overview (admin4, admin4l): the followed items with path, class and last change, a filter, paging, a confirmed removal, notices, a clearer digest and collaboration choice; English and German texts.
  • Opening /notification/addtonotification/<node> asks first instead of subscribing on a plain GET.
  • The notification filter survives a failing handler, can run for given events, and removes handled events nothing waits for.
  • Fixes: the digest settings are validated and a user without a settings row can save them, a single address no longer breaks the mail transport, the subtree form refuses unreadable, missing and duplicate nodes, collaboration rules are looked up by user id, eZNotificationCollection::addItem() stores its send date and fetchByDate() finds due items.
  • eZFileTransport writes to [MailSettings] FileTransportDirectory (default var/log/mail).

The upgrade checklist is Upgrade notes: e-mail preferences. The system is described in the user's guide, the administrator's guide, the developer's guide, the specification and the law checklist (CAN-SPAM, GDPR and ePrivacy, CCPA/CPRA, CASL, RFC 8058).

Added

  • E-mail preferences: one page (/mailpreferences/settings, also in the administration under My e-mail preferences, and without login by a personal link) where every person turns each optional kind of e-mail on or off, chooses how often, turns all optional e-mail off with one button, sees the history of their choices and downloads their data as JSON or CSV. Every optional kind is off until the person turns it on. Essential mail (account security, orders, legal notices, admin alerts) is listed and always sent.
  • The mail gate in eZMailTransport::send(), for every transport: mail declares a category with eZMail::setCategory(); optional mail goes only to people who turned it on, one mail each, with a footer (why, one-click unsubscribe, preferences, the organisation and its postal address) and the RFC 8058 headers List-Unsubscribe and List-Unsubscribe-Post. Mail without a category is sent as before and counted.
  • Unsubscribe in one click (/mailpreferences/unsubscribe/<token>), also from the mail program's own button; a page opened by a link changes nothing until its button is pressed. Double opt-in for newsletters, offers and a new e-mail address. Send me a link for people without an account.
  • Consent log of every change with the exact text shown, the source, the IP address and the acting user; suppression list stored as salted hashes; links encrypted with AES-256-GCM under a site secret; export and erasure that keeps the proof of a withdrawal; retention by the cronjob part mailpreferences (group infrequent).
  • Administrator's pages under Setup > E-mail preferences: status, categories (create, rename, describe), suppression list, consent log with filters and CSV export, a user's preferences. Policies mailpreferences/administrate and mailpreferences/export.
  • Commands exp:mail:status, exp:mail:preferences, exp:mail:suppression, exp:mail:consent, exp:mail:gate. Command reference.
  • Unticked boxes for the optional kinds on the registration form; English and German texts.
  • Tables expmail_category, expmail_preference, expmail_consent_log, expmail_suppression, expmail_pending in the kernel schema and the database update.
  • Tests: MailPreferencesCoreTest (20 cases of the core and the gate) and MailPreferencesComplianceTest (one case per law requirement of the checklist), live style with the file transport and .invalid addresses.

Updated

  • Optional mail to several recipients is sent as one mail per recipient ([GateSettings] SplitRecipients).
  • The profile, the notification settings, the current user box and the menus link to the e-mail preferences.
  • The text footer of optional mail keeps its line breaks: each link ends its line.
  • Collaboration mail follows the frequency chosen on the preference page: daily or weekly it goes into the digest, which now has a collaboration part, and the digest lists its items again (its loops and includes rendered nothing).
  • A new e-mail address of an account (user/edit, the administration's edit, the account services) takes effect only once it is confirmed from the new mailbox, and the old address gets a notice ([EmailChangeSettings] Confirm).
  • The downloads of a person's e-mail data, the consent log and suppression list exports and the RFC 8058 one-click answer end the request through one helper (MailPreferencesPage::sendResponse()). Under Velocity they never answered (504) although the change was saved: the loop that emptied the output buffers did not stop at the worker's own buffer. The setup views that stream (preloader, static cache, cronjob log) and the Ajax answer of the cronjob console had the same loop; all of them now use eZExecution::discardOutputBuffers().
  • The history shows a waiting e-mail address change as E-mail address: New address asked for, waiting for confirmation, no longer as "All optional e-mail".
  • The footer and the confirmation and "send me a link" mails name the public site (the siteaccess of the request when it is public, else the default siteaccess), never an administration siteaccess, also from a command or a cronjob; OrganisationName still wins when set.
  • Closed gaps of the law checklist: DefaultOn=true of an optional category is treated as off and shown on the status page unless [CategorySettings] AllowDefaultOn=enabled; removing an account erases its e-mail preferences and anonymises its consent log (the withdrawals stay as proof, the suppression list stays); optional mail with an empty From gets [MailSettings] EmailSender or AdminEmail (logged, and not sent without either); the footer and the preference page link the privacy notice ([FooterSettings] PrivacyURL, else the node of menu.ini [SiteInfo] PrivacyPolicyID); unsubscribe and manage links never live shorter than 60 days.
  • Tests: MailPreferencesHardeningTest (MPH-01 to MPH-09), including the one-click POST and the downloads over HTTP on the site's web server and on Velocity.

5 October 2026: PHP 8.0 is the oldest supported version

PHP 8.0, the stock PHP of Red Hat Enterprise Linux 9, runs the kernel and the shipped extensions again (Velocity needed 8.1 until its release 0.0.4.54 of 20 October 2026, which runs on 8.0 too; see 20 October 2026). Details, the full list of findings and the RHEL 9 notes: PHP 8.0 support.

Added

  • lib/phpcompat.php: array_is_list() for PHP 8.0, read by autoload.php only on PHP older than 8.1.
  • The Quality workflow's job php80: php -l with PHP 8.0 on every PHP file, a production install with 8.0 and the boot smoke test tests/bin/bootsmoke.php (every kernel and extension class, the compatibility functions, a template with wash); make lint80 and make lint80-all.

Updated

  • htmlspecialchars() and the other HTML escaping functions get the PHP 8.1 default flags explicitly in 28 kernel, library, ezjscore, ezoe and expservices files, the wash operator and the simple tags operator included: single quotes are escaped on PHP 8.0 as on 8.1 and later. Clear the template cache once after upgrading.
  • The CSV export of sub-items writes its "\r\n" lines on PHP 8.0 too (fputcsv() has no $eol there).
  • The OAuth authorize view passes implode() its arguments in the order PHP 8 requires; the LDAP login no longer calls crypt() without a salt.
  • The checks of pull request #113 test PHP 8.0 instead of 8.1: phpcs.xml.dist testVersion 8.0-, the pre-commit hook (EXP_PHP80), composer.dev.json platform PHP 8.0.30.
  • README, the PHP 8 page, the console and CI pages state PHP 8.0 as the minimum.

Updated

  • Fixed: eZContentObject::relatedObjects() (and the fetches related_objects and reverse_related_objects) sorted by class_name no longer fails on MySQL and PostgreSQL with "Unknown column 'ezcontentclass.id' in 'on clause'": ezcontentclass is now the last table of the FROM list, the one the class name join can see. SQLite was not affected. The other queries that append the sorting joins of createSortingSQLStrings() (the node list and count of eZContentObjectTreeNode, the content structure tree, the keyword fetch, the trash list) were checked: they build their FROM as a chain of INNER JOINs that includes ezcontentclass, so the class name join sees it; only relatedObjects() used a comma list.

5 October 2026: database sessions

Updated

  • Fixed: ezpSessionHandler::setSaveHandler() no longer calls session_module_name( 'user' ), which throws a ValueError on PHP 8 and stopped every request of an installation with [Session] Handler=ezpSessionHandlerDB. See bug 4 on the session handler page.
  • Updated: setSaveHandler() gives PHP the handler as an object (ezpSessionHandlerAdapter, implementing SessionHandlerInterface) instead of six callbacks, which PHP 8.4 reports as deprecated on every request; the handlers themselves are unchanged. See bug 5 on the same page.

5 October 2026: view cache files of cache_ttl=0

Updated

  • Fixed: a node view whose template sets cache_ttl to 0 no longer stores the whole page in its view cache file. The file keeps only the no-cache advice (no_cache, cache_ttl 0, a few bytes), which the next requests still read to generate the page without the cache lock and without storing it. The number of files stays the same: one per cache key, as before.

5 October 2026: expiry timestamps per siteaccess VarDir

Updated

  • Fixed: with siteaccesses that have a [FileSettings] VarDir of their own (several sites on one installation), clearing a cache of one site no longer expires the caches of every site. The expiry handler is created before the siteaccess is known, with the expiry.php of the default VarDir, and kept it; eZSiteAccess::change() now calls eZExpiryHandler::resetForCurrentCacheDirectory(), which stores the old instance and drops it when its file is not the one of the current cache directory. Installations with one VarDir for all siteaccesses see no change. Under Exponential Velocity the shared instance does not outlive a request either: $GLOBALS['eZExpiryHandlerInstance'] is not in velocity.ini [ApplicationSettings] KeepGlobals[] or its built-in defaults, so a persistent worker reads expiry.php anew for every request and sees the timestamps another worker or PHP-FPM wrote.

5 October 2026: benchmark command

Added

  • ./console exp:benchmark (exp:bench, bin/php/benchmark.php): measures an installation. HTTP mode times the front page, the first menu pages and the admin login page (or --url, --urls-file) with PHP's curl, warm or --cold (a unique query string past every response cache), and compares two or more servers back to back with --compare=A,B in alternating rounds over kept-alive connections. Kernel mode times boot, the full view render cold and warm, INI load, content fetch, node list, database round trip, cache write and read and an image alias lookup in-process. Output as a table with min, median, p95, p99, max, requests per second, errors, status codes, bytes and cache answers, or --json / --csv; --save writes a run and --baseline compares with one, exiting with status 1 when a metric got worse by more than --threshold (15 %) and the --min-delta noise floor (1 ms). ab, wrk and oha are used with --tool= when installed. Read-only and polite by default (100 requests, concurrency 4); more than 1000 requests or a concurrency above 16 against a host that is not loopback needs --force, and a host the site does not serve is only measured when its URLs are named. Guide: Benchmark.
  • expBenchmark, expBenchmarkHttp and expBenchmarkKernel (kernel/classes/), with tests in tests/tests/kernel/classes/benchmark/ that need no network: the HTTP client is tested against a php -S server on 127.0.0.1 that the test starts and stops.

5 October 2026: digest send date without an item

Updated

  • Fixed: eZNotificationSchedule::setDateForItem() accepts null as item and then only returns the next send date of the digest, instead of failing with "Call to a member function setAttribute() on null". A digest handler of an extension that needs the date without an item no longer stops the request. The kernel handlers always pass an item and are unchanged.
  • Fixed: a digest goes out at the chosen hour of the local clock across a change between summer and winter time; the send date was the current time plus a fixed number of seconds and was an hour off then. The date is now built from calendar fields (eZNotificationSchedule::nextSendDate()).
  • Fixed: a monthly digest on a day the next month does not have (the 31st, in a month with 30 days or in February) falls on that month's last day; it used to run into the month after (31 January + 31 days = 3 March).
  • Added: setDateForItem() takes the time to compute from as an optional third argument, for tests.

5 October 2026: edit access to objects that were never published

Updated

  • Fixed: eZContentObject::checkAccess( 'edit' ) on an object that was never published:
    • The rule that someone who may create the object under its parent may edit it applies at every version, not only at version 1; a rejected first version is edited as version 2 and later. Without a node assignment the rule denies instead of failing with "Call to a member function attribute() on null".
    • A Subtree limitation also counts the location the object will be published under (the parent of the main node assignment of the current version) for edit, so an approver with an edit policy for that subtree can edit it. Read and the other functions are unchanged, the owner rule too, and a policy for another subtree still gives no access.
  • Updated: the same edit rules reach every place that wrote them out. A role for edit assigned for a subtree (User_Subtree) counts the location an object that was never published will be published under, as the Subtree limitation does; eZContentObjectTreeNode::checkAccess() (the preview node of content/versionview), eZContentObjectVersion::checkAccess() and content/removeeditversion apply the create rule at every version and deny without a node assignment, through the new eZContentObject::draftCreateAccess(); the rule reads the main node assignment (draftMainNodeAssignment()), not the first one. A parent node that no longer exists is skipped instead of warning. Who may now edit what, and how a site notices: Edit access to objects that were never published. Tests: eZContentObjectDraftLocationTest (no database), eZContentObjectDraftAccessLiveTest (live).

5 October 2026: new version of an object that was never published

Updated

  • Fixed: a new version of an object that was never published keeps the location of the version it is copied from. eZContentObject::copyVersion() skipped every node assignment without remote_id, and createNewVersion() rebuilds the others only from existing nodes, which such an object does not have; after an approval workflow rejected the first version, editing it again lost the location, and publishing asked for one again (or used the default location from the version view). A location still to be created (op_code CREATE without remote_id) of an object that was never published is now copied as it is. Published objects are unchanged.

5 October 2026: version preview path of an object that was never published

Updated

  • Fixed: the path of content/versionview for an object that was never published shows the folder the object will be published in. The preview node of such an object has no node ID, so its path string ends with the parent node and fetchPath() dropped the parent as the node's own. \Exponential\View\Kernel\Content\Versionview::previewParentNodes() reads that path including the last element; published objects are unchanged.
  • Updated: eZContentObjectTreeNode::fetchPath() (the path attribute) reads the path of a node without node ID including its last element (pathStringEndsWithParent()), so a template that reads $node.path of the preview node gets the folder too; previewParentNodes() uses it. Test: eZContentObjectTreeNodePreviewPathTest (no database).

5 October 2026: changing your password

Added

  • user/password lists the password's requirements and reports each problem next to its field, with a summary at the top that links to the fields. A successful change gets its own success state. The rules are in one class, expPasswordPolicy. The optional rules are in the new block site.ini [PasswordSettings] (lowercase, uppercase, digit, symbol, a number of character kinds, not the login, not the current password); all of them are off by default, so no site's rules get stricter. Guide: Changing your password.
  • After a change the session id is renewed. With the database session handler the user's other sessions are ended (EndOtherSessions, new method ezpSessionHandler::deleteOtherSessionsOfUser(), audit event access.session.revoke), and the mail "Your password was changed" is sent in the essential category security (ChangeNotificationMail, template design:user/password_changed_mail.tpl). The mail never contains the password.
  • Template variables for the modern page: $password_rules, $field_errors, $error_summary, $password_changed, $min_length, $password_js_config_json and others. Old templates keep working unchanged. See the compatibility note.
  • Translations: the contexts kernel/user/password, kernel/user/password/js, kernel/user/password/mail and design/standard/user/password_changed_mail, in English and German.

Updated

  • user/password no longer writes typed passwords back into the page ($oldPassword, $newPassword and $confirmPassword are always empty). When the current password is right, every problem with the new password is reported at once. access.user.password.change.failed names the rule that failed. Test: expPasswordPolicyTest.

5 October 2026: workflow process list

Updated

  • Fixed: workflow/processlist lists a waiting workflow process that has no main operation memento, for example one deferred to cron. The trigger is read from the child memento, and the main memento was never used; requiring it left such processes out, so the list could be empty although workflows were waiting. The grouping is \Exponential\View\Kernel\Workflow\Processlist::processesByTrigger().
  • Updated: processesByTrigger() takes the function that finds a process's trigger as an optional second argument (default Processlist::triggerOfProcess(), which reads the child memento), looks each trigger up once per listing and skips a memento that does not name its operation instead of warning. Test: eZWorkflowProcessGroupingTest (no database).

5 October 2026: more tests, and what they found

Added

  • Unit tests that need no database, in the suites CI runs: the template engine (about 150 sources rendered processed and compiled, both must match), every locale of share/locale, the input validators, character transformations and code pages, eZMimeType, eZPHPCreator, the PHP error handler of eZDebug, the template operators month_overview, autolink, simpletags, alphabet and eztoc, the author, option, range option, multi-option and matrix values, ISBN checksums, toString()/fromString() of the simple datatypes, object name patterns, mobile device detection, administration list paging, and the REST routes, prefix filter, request parser and OAuth token lookup. tests/tests/kernel/common is part of the kernel-classes suite. Test counts and line coverage: continuous integration.

Updated

  • A debug message no longer removes the PHP error handler installed below eZDebug's (setHandleType() restored a handler for HANDLE_TO_PHP and HANDLE_NONE, which install none).
  • A template edited while a process runs (a persistent Velocity worker, a CLI script) is no longer compiled again from the tree parsed before the edit; the stale tree had made the new compiled file carry the old template for every process.
  • nl2br gives the same output processed and compiled (<br /> before each line break).
  • eZLocale::internalCurrency() uses the currency's thousands separator; a locale code with a charset such as eng-GB.utf-8 keeps its digits; swe-SE numbers use the full stop for thousands instead of the comma that is also their decimal symbol, so "1,00" is no longer read as 100.
  • eZCharTransform::transform() sets the charset before naming its cache file; the bork translator no longer warns on a text that starts with a placeholder.
  • eZDateTimeValidator accepts digits only in a time and checks the seconds of a date and time; eZRegExpValidator::fixup() no longer changes the validator's own rule.
  • eztoc escapes the header text it prints; month_overview no longer warns without a next month.
  • A range option with a decimal step no longer loses its last value (0 to 0.3 by 0.1 has four options).
  • Mobile device detection sees a WAP type that is the first of the Accept header.
  • Object name patterns: several groups in one pattern, the alternative after a group (<(<first_name> <last_name>)|login>) and a group without values resolve correctly; objects whose class uses such a pattern get the corrected name when they are next published.
  • REST: a request no longer keeps the API provider and version of the request before it in a persistent worker, and ResponseGroups[] or _method[] in a request no longer end it with a TypeError.
  • sum, sub, mul and div give the same result processed and compiled, with the fractions of their operands: sum(1.5,2.25) is 3.75 (it was 3 processed), div(1,0.5) is 2; a word or a division by zero in a variable gives 0 in a compiled template too, where it was a fatal error.
  • Compiled lt, le, gt and ge compare counts as the processed ones do (array count, string length, the number); they compared the raw values, so lt(array(1),array(2)) and gt("abc","z") differed.
  • eZDebug::printReport() returns the report when $returnReport is set, so command line scripts write their debug report to STDERR instead of printing it with their output.
  • Currency conversion applies shop.ini [MathSettings] RoundingType (none, round, ceil, floor) and a type set with setRoundingType(); every type had come out as none.
  • eZURI::convertFilterString() writes the converted namefilter back and returns it; a URI's namefilter is decoded once, as before.
  • gettime gives a Sunday its ISO-8601 week number (date( 'W' )); it was one higher, week 54 for 3 January 2021.

5 October 2026: other sessions end after a password change; the same rules on every password form

Added

  • A sign-in keeps a password stamp in the session (eZUserPasswordStamp: a keyed hash of the user id and the stored password hash, made with the site secret; never the hash itself). eZUser::currentUser() signs out a session whose password has changed since, so a password change ends the user's other sessions with every session handler. The session that made the change stays signed in. Sessions from before the upgrade, the anonymous user and accounts without a password of their own (LDAP, SSO) are not checked. The setting is [PasswordSettings] EndOtherSessions; the audit event is access.session.revoke (reason password_changed); the user/password page has the new template variable $other_sessions_signed_out. Guide: Changing your password.
  • Translations: the context kernel/user/password/account, in English and German.

Updated

  • Registration, content/edit of a user account, the administration's user edit and bin/php/resetuserpassword.php check passwords with the rules of user/password (expPasswordPolicy::accountErrors()). With the defaults this is the length check of before, with the same sentence. The edit form's unchanged password (_ezpassword) is no longer length-checked, which refused every edit of a user that left the password alone when MinPasswordLength was above 11. A generated console password is generated again until it meets the rules.

  • The UpdateHash re-hash at sign-in returns the user with the hash it stored, and purges that user's cache, which kept the old hash until it expired. See the compatibility note. Tests: expPasswordSessionStampTest, expPasswordPolicyTest (PWD-12, PWD-13).

  • A session is no longer signed out because a server's OPcache still held an outdated user cache file: a stamp that does not match is checked once against the stored row. eZUser::purgeUserCacheByUserId() also invalidates the cache file in OPcache, so a change made through the site reaches every worker of that server at once; a change made on the console reaches a server when its OPcache revalidates (opcache.revalidate_freq).

5 October 2026: cjw_newsletter 4.2.0

Added

  • The root composer.json requires se7enxweb/cjw_newsletter ~4.2.0. The release takes over the features of the old eznewsletter extension and builds them on the e-mail preferences of this line: bounces into the suppression list, rate limits and batches, subscribe by e-mail; recurring sends, article pools, the approval of editions in the collaboration inbox; conditions, interests, an edition in several languages, new skins and plain text views; open and click statistics per person only with consent, A/B subject tests; newsletters by SMS with a code double opt-in; the CSV import with a column mapping, the subscriber export and the migration from eznewsletter. Guide: cjw_newsletter; upgrade from 4.1: the extension's doc/upgrade-4.2.md.
  • Translations: the e-mail preference categories "Newsletter statistics" and "Newsletters by SMS" have their names and descriptions in the context kernel/mailpreferences/categories, in English and German.

Updated

  • expCollaborationInbox::stateOf() asks the collaboration handler of an item for its state: a handler of another type than ezapprove with a method inboxState( $item ) that returns waiting, approved, denied, open or closed sets the state of its items, so the approvals of an extension (the newsletter's cjwnewsletterapproval) are filtered and shown as approvals are. Any other answer, or no such method, keeps the state from the item's status; only active handlers are asked. Test: CollaborationSampleDataTest (CS-06b).

5 October 2026: forwarded headers are trusted only from configured proxies

Upgrade notes, examples for Apache, nginx, Velocity and cloud load balancers: Forwarded headers are trusted only from configured proxies.

Added

  • site.ini [HTTPHeaderSettings] TrustedProxies[]: the proxies whose forwarded headers are believed, as addresses and ranges, IPv4 and IPv6. Default 127.0.0.1 and ::1, a proxy on the same machine; an empty list trusts none.
  • eZTrustedProxy (lib/ezutils/classes/eztrustedproxy.php): address and range matching, the right-to-left walk of X-Forwarded-For, the trusted hop count. eZSys::trustedProxies() and eZSys::isFromTrustedProxy().
  • Tests: eZTrustedProxyTest and eZSysTrustedProxyTest (suite lib): a visitor's X-Forwarded-Proto: https is not HTTPS, a trusted proxy's is; spoofed X-Forwarded-For chains; CIDR ranges in IPv4 and IPv6; invalid addresses; an empty and a missing setting.

Updated

  • eZSys::isSSLNow() reads X-Forwarded-Proto, X-Forwarded-Port and X-Forwarded-Server only when REMOTE_ADDR is a trusted proxy. Before, any visitor could make a plain HTTP request count as HTTPS (absolute URLs, redirects, CookieSecure=auto, cache keys with the scheme). $_SERVER['HTTPS'] and the port are checked first, as before.
  • eZSys::hostname() uses X-Forwarded-Host only from a trusted proxy.
  • eZSys::clientIP() with ClientIpByCustomHTTPHeader reads the header only from a trusted proxy, and from the right: the first address that is not a trusted proxy is the visitor, every entry checked with FILTER_VALIDATE_IP. Before, it took the left-most entry, which the visitor writes, so DebugByIP, the sign-in lockout's TrustedIPList, the audit log and the consent log could be given any address.
  • The HTTP cache (httpcache.ini) works out scheme and host as the kernel does: the contract carries the trusted list and the lookup believes the forwarded headers only from a trusted REMOTE_ADDR. Test: HC-15, HC-16.
  • The debug bar's address test reports the proxy header as used only for a request from a trusted proxy.
  • Installation guide: §8.8 "Behind a reverse proxy" and §13.10 "Behind a proxy: trusted headers" describe the setting.

5 October 2026: saved settings files are no longer world-writable

Upgrade note, with the one-time fix for files saved before: Settings files saved through the admin are no longer world-writable.

Added

  • config.php constant EZP_INI_SAVE_FILE_PERMISSION (default 0640): the mode of a settings file a save creates. eZINI::newSaveFileMode(), eZINI::saveFileMode(), eZINI::saveFileOwnership().
  • Tests: eZINISaveFilePermissionsTest (suite lib), and the mode of a new file in expIniEditorTest INI-06.

Updated

  • eZINI::save() keeps the mode, owner (as root) and group of the file it replaces, without write for other users, and gives a new file 0640 with the owner and group of its directory, or of the nearest one above it that root does not own. Before, every save made the file 0666, settings/override/site.ini.append.php with the database password included. New settings directories are 0755 instead of 0777; the temporary file is 0600 until it is in place.
  • expIniEditor creates a new settings file 0640 with the same owner rule (was 0644) and keeps its temporary file 0600 until it is in place.
  • INI cache files (var/cache/ini) are 0644 instead of 0666; EZP_INI_FILE_PERMISSION now applies to them and to generated autoload files only.
  • config.php-RECOMMENDED and the installation guide §13.11 describe both constants.

5 October 2026: eng-US instead of eng-GB as the content language fallback

Upgrade note: Content language fallback: eng-US and a setting instead of a hard-coded eng-GB.

Added

  • site.ini [RegionalSettings] ContentObjectFallbackLocale (default eng-US): the locale class and attribute names are stored in when no content language is prioritized and ContentObjectLocale is not set. eZSerializedObjectNameList::fallbackLanguageLocale() and configuredLanguageLocale().
  • Tests: eZSerializedObjectNameListFallbackLocaleTest (suite kernel-classes, no database).

Updated

  • eZSerializedObjectNameList::defaultLanguageLocale() falls back to that setting, then eng-US; it was a hard-coded eng-GB, which left class descriptions in a language the site did not have.
  • The default site.ini [RegionalSettings] ContentObjectLocale is eng-US (was eng-GB).
  • The SQL clean data (kernel/sql/common/cleandata.sql, kernel/sql/sqlite/cleandata.sql) is in eng-US, like share/db_data.dba.
  • kickstart.ini--example no longer adds eng-GB as a second language; kickstart.ini-dist shows eng-US.
  • The subitems column "Language codes" gives eng-US, ger-DE as its example; the micro benchmark's in-memory class attributes use eng-US.
  • The multisite installer packages name the folder attributes in the configured locale, else the kernel's fallback, never eng-GB.

5 October 2026: personal API keys and the REST administration

Guide: Personal API keys. Upgrade note: Personal API keys: a table, a module and a policy.

Added

  • Personal API keys: a signed-in user with the new policy apikey/create makes keys on My account > API access (apikey/list): a name, scopes from what the user's own policies allow, a lifetime. The key (expk_<id>_<secret>) is shown once; only an HMAC-SHA-256 hash with a salt per key is stored. A REST request with Authorization: Bearer expk_... runs as the owner, limited to the key's scopes, the owner's policies and, for write routes, the owner's rights on the node. Last use and address are noted at most once a minute; a rate limit per key is counted in APCu.
  • Table expapikey in every schema (share/db_schema.dba, MySQL, PostgreSQL, SQLite) and in the 6.0.0 to 6.0.15 update files (created only when missing); update/common/scripts/6.0/createapikeytable.php for any engine.
  • Module apikey (view list, policy create with a Scope limitation, fetch functions can_create and counts); rest.ini [ApiKeySettings].
  • Setup > oAuth admin > API keys (oauthadmin/keys): every key with owner, scopes, last use and status, a search, a status filter, one user's keys (/(user)/<id>), revoking with a confirmation (oauthadmin/keyaction). The user settings page links there.
  • Audit events access.apikey.create, access.apikey.revoke, access.apikey.use (the first use) and access.apikey.use.failed (with the reason); the catalogue has 140 names.
  • Tests: expApiKeyTest and ApiKeySchemaSqlTest (suite kernel-classes, no database); ApiKeyLiveTest (group network-live: makes, uses, refuses and revokes keys over HTTP on Apache and Velocity).

Updated

  • The REST applications pages (oauthadmin/list, view, edit, the removal confirmation) are redesigned in the look of the cronjobs page: figures, a card per application with its users and valid tokens, the secret folded away. Every address, form field and button is unchanged.
  • A new REST application gets a client identifier and secret from random_bytes() (was md5 of the name and uniqid()), and removing an application removes its authorizations, tokens and codes. Client secrets are compared in constant time.
  • HTTP basic authentication of the REST interface (ezpRestBasicAuthStyle) checks the password with the user's own hash type through eZUser::authenticateHash(). It compared md5("login\npassword") in SQL, so every user on a modern hash was refused. A wrong password counts as a failed login.
  • The REST layer reads Authorization: Bearer <token> as well as OAuth <token>, and answers insufficient_scope with 403 instead of 401 (the status is sent after the WWW-Authenticate challenge, which PHP turns into a 401).
  • The form token filter keeps a view's Cache-Control: no-store instead of replacing it with its own private, no-cache, must-revalidate.

6 October 2026: REST permission checks and routes

Guide: Permissions of the REST interface.

Added

  • expRestContentPermission (kernel/private/rest/classes/auth/content_permission.php): one check of the current user's rights for a REST read, create, edit or remove, done as the content module does it (canRead(), checkAccess( 'create', class, parent class, language ), canEdit(), canRemove() of every location and of the subtree). The ezprestapi content controller (1.2.5) asks it before every call, for OAuth tokens, personal API keys, basic authentication and the anonymous user alike, and answers 403 access_denied with the policy named; the API key write guard asks the same helper. Before, an OAuth token could create and remove content where its user had no content/create or content/remove.
  • ezpRestVersionedRoute takes a list of versions (array( 1, 2 )) and getVersions(); a URL it generates keeps the version of the current request. ezpMvcRailsRoute::anyMethod() builds a map that runs one action for every method.

Updated

  • The REST router tries the next route when one matches the path but not the method, and answers 405 with an Allow header listing every matching route's methods only when none takes it.
  • The authentication routes (/auth/oauth/login, /auth/http-basic-auth) and the error route answer every method: a POST or DELETE without a valid token, or refused by a key's scope, answered 405 instead of 401 or 403, which made /api/ezp/v2/content/node/create and .../delete/<id> look unreachable.
  • The REST answer cache keeps one entry per user and only for GET and HEAD: one user's read was served to another for ten minutes past content/read, and a repeated create or delete was answered from the cache without running. It keeps no refusals: a cached 403 could not be read back (its status object has no __set_state()), so the second identical refused read answered 500, on both web servers since they share the cache. A cache file that cannot be read back now counts as expired.
  • The content/create check of the REST interface takes the parent class from the parent object (it asked the node for an attribute it does not have, logged an error and checked the ParentClass limitation against nothing).
  • ezprestapi 1.2.5: reads answer at /api/ezp/v1/... again (404 since the extension took the ezp provider over from ezprestapiprovider; existing clients such as a mobile app read through v1), writes at v2; delete takes DELETE as well as POST; create answers 201 and delete 200 with the ids (a delete that worked answered 500). Velocity needs exp:velocity deploy --kernel for the kernel classes.

6 October 2026: access and view cache filters for extensions

Added

  • Five filters of ezpEvent through which an extension takes part where it had to patch the kernel; for the four access filters only true allows. Guide: Access and view cache filters.
    • content/view/cachekeys: the keys of a view cache file name, now named (userroles, userlimitedlist, viewparameters, ...), with the user and the node; the file names do not change.
    • content/download/access: the answer of content/download, with the object, the attribute and the version. That the attribute belongs to the object stays checked first.
    • content/edit/access: the new eZContentObject::editAccess( $version, $language ), which content/edit, content/history, content/removeeditversion, content/versionview and the upload and tag dialogs of the online editor use for their edit check.
    • collaboration/item/access: whether the current user may open a collaboration item; the template gets is_participant, and only a participant files the item in a group of the inbox.
    • content/notification/create: whether the publish operation creates the notification event.
  • The events are described in the settings extension wizard. Test: expPermissionHooksTest (no database).

Updated

  • Every edit check of content/edit (a new draft, the choice of a language or of a draft, the check of one version), of content/multiedit and of the REST interface goes through content/edit/access, so a listener decides the same way on every path; eZContentObject::filterEditAccess() passes any edit answer of the kernel through the filter, and without listeners no time is spent on it (ezpEvent::hasListeners()).
  • content/download refuses the attribute of another object or version before any listener is asked.
  • collaboration/action acted on any item it was posted, for anyone (a comment on an item the user does not take part in; a missing item was a fatal error). It now acts only on an existing item of the posted type that the user may open, which collaboration/item/access decides.
  • content/view/cachekeys: node, view mode, language, offset and layout always stay; non-scalar values are left out.
  • A debug notice says when a listener changed an access answer.
  • The settings extension wizard writes listeners of these filters with their arguments and a commented example that reads its rule from the extension's settings.
  • The guide explains what a listener can narrow and widen, where edit access is asked and where not, with worked examples.

6 October 2026: version preview in other view modes

Added

  • content/versionview shows a version in another view mode with the parameter (view_mode), for example content/versionview/<object>/<version>/<language>/(view_mode)/print for node/view/print.tpl in a print or PDF layout. The view modes allowed are listed in the new setting content.ini [VersionView] ViewModes[] (full by default); another one answers "not available". Without the parameter nothing changes. The view mode is the viewmode design key of the preview and the template variable view_mode; design/standard renders the node in it, and the admin designs pass it on to the preview frame. Test: eZContentVersionviewViewModeTest (no database).
  • design/standard/templates/node/view/print.tpl, a print view of a node (its name and the attributes with content), as an example for ViewModes[]=print.

Updated

  • With more than one view mode listed, the View control box of the preview (admin, admin3, admin4) offers them, and Update view keeps the chosen one; it went back to full before.
  • Only a name of letters, digits, _ and - is a view mode, whatever the setting lists.
  • Guide: Version preview in other view modes.

6 October 2026: Cancel in user/edit

Updated

  • Fixed: Cancel in user/edit goes back where the user came from: the page the form names in RedirectIfDiscarded (as in content/edit and user/register), else the page viewed last, else the sitemap of the users. It always went to content/view/sitemap/5, also for an editor changing their own account on the site. A host that site.ini [SiteSettings] AllowedRedirectHosts does not list is refused as before. Test: eZUserEditCancelTest (no database).
  • Updated: One set of rules for every redirect target from outside the code, in eZRedirectManager::safeURI() and unsafeReason(): a path of this site or an allowed host; never a backslash (/\evil.example, \\evil.example), a control character (CR/LF), a scheme other than http(s) (javascript:, data:), a user in the URL, or the percent-encoded forms of these (%2F%2Fevil.example). eZModule::redirectTo() answers 403 to a target that breaks them ("Redirection requested to an unsafe address"); before, a backslash or javascript: target of user/login, content/action or user/edit went out as the Location. eZRedirectManager::redirectURI() checks its preferred URI and the page from the session the same way; eZModule::allowedRedirectHosts() is public.
  • Updated: Cancel and Discard go back to the page the form was opened from in user/password, user/setting, section/edit, state/edit, state/group_edit, role/edit, class/edit and class/groupedit, as in user/edit: the form's RedirectIfDiscarded, else the page viewed last, else where they always went. eZRedirectManager::returnURI() never returns the running view (a loop), a view of the new site.ini [SiteSettings] DisallowedReturnViews (content/action, user/logout, content/download, ...) or a page viewed last that the user can no longer view. The views set redirect_if_discarded and their standard, admin and admin4 templates send it; user/edit in the standard design has a Cancel button.
  • Updated: user/password checks its RedirectOnCancel and RedirectURI (both redirected to as posted before), user/register its RedirectIfDiscarded (used as the default before), and Discard in content/edit the page kept in the session; content/edit_redirection.tpl escapes the two hidden fields it writes.
  • Added: The page Safe redirects and "back to where you came from", and eZRedirectManagerSafeURITest (no database): the rules case by case, and the Cancel target of every view. Velocity needs exp:velocity deploy --kernel for the kernel classes.

6 October 2026: event listeners without a callback

Updated

  • Fixed: an entry of site.ini [Event] Listeners[] that is not of the form <event>@<callback> (no @, or an empty event or callback) is logged as an error and skipped. It raised the PHP warning "Undefined array key 1" on every request and attached a listener that could never be called, or one to an event named ''. A callback is split off at the first @ only. [RunnableSettings] Listeners[] already skipped such entries. Test: ezpKernelResultAndEventTest (no database).
  • Blanks around the event or the callback of an entry are dropped (content/view @ myClass::method attached a listener to the event content/view that was never sent). A callback that is not written as the name of a function or of a Class::method (a typo such as ::: or a blank inside) is logged and skipped without being looked up. The same entry listed twice, by the site and by an extension say, is attached once; it ran twice for every event.
  • A mistake in the entries, and a listener whose class, method or function does not exist, is logged once in the life of the PHP process instead of once per request or per event; under Velocity a worker logged it on every request.

6 October 2026: content policy limitations of extensions

Added

  • An extension can add a policy limitation of its own to a content function (content/read, content/edit, ...). The filter module/functionlist (new, in eZModule::initialize()) adds it to the function list, so the role editor offers it; a class implementing ezpContentLimitationHandler, named in the new setting site.ini [RoleSettings] LimitationHandlers[<limitation>], evaluates it in the checkAccess() methods of objects, nodes and versions, in the SQL of list and tree fetches (permissionSQL()) and for subtree notifications. Guide: Content policy limitations of extensions; listed in Setup > RAD.
  • checkAccess() of eZContentObject, eZContentObjectTreeNode and eZContentObjectVersion and eZContentObject::draftCreateAccess() take the user to check for as the last argument ($userID, default the current user); eZUser::accessUser() returns that user. An ID that is no user gets no access.

Updated

  • A limitation the kernel does not know and no handler evaluates denies everywhere. Fetches ignored it and listed objects whose pages checkAccess() refused; subtree notifications ignored it and sent the mail.
  • eZContentObjectVersion::checkAccess() stops at the first limitation of a policy that denies; a Language that did not match, or a limitation it did not know, could be allowed again by the next limitation.
  • Subtree notifications check the Group limitation ("Self group") of content/read.
  • A class that overrides one of the checkAccess() methods must accept the new argument; see behaviour changes. Tests: ezpContentLimitationTest, eZContentPermissionSQLTest (no database), eZContentAccessForUserLiveTest (live).

6 October 2026: content limitation handlers, hardened and extended

Guide: Content policy limitations of extensions.

Added

  • exp:access:check (bin/php/accesscheck.php): whether a user (ID, login or anonymous) may read, edit, remove ... a node or object, and which policy and limitation refused it, with the handler of an extension limitation or the note that none evaluates it; ALLOWED or DENIED, or JSON. expContentAccessReport::check() gives the answer to PHP.
  • eZContentObject::editAccess() and filterEditAccess() take the user as a further argument: that user's content/edit and user/selfedit, and the filter content/edit/access gets that user's ID.
  • The settings extension wizard writes a working limitation handler (<class>MaxDepth) next to a listener of module/functionlist, with the commented [RoleSettings] LimitationHandlers[] line; Setup > RAD points at it.
  • A limitation added through module/functionlist may carry a label, which the role screens show.
  • permissionSQL() may answer array( 'column' => ..., 'values' => ..., 'type' => 'int'|'string' ); the kernel writes the IN statement and casts or escapes the values.

Updated

  • Fixed: a handler that is missing, does not implement the interface, cannot be made, or throws in checkAccess() or permissionSQL() denies and is logged once per request. An exception ended the request (a list page with one such object answered 500), and a missing handler was looked up and logged for every node of a list.
  • Fixed: a string condition from permissionSQL() must be self-contained (quotes and parentheses balanced; no ; or comment outside quoted strings): 1 = 1 ) OR ( 1 = 1 closed the parentheses it was put in and gave every user of the role the whole tree in fetches. Any answer that is not false, such a string or a column condition denies.
  • The handler of a limitation is made once per request and again for the next one, also in a persistent Velocity worker, and when LimitationHandlers[] changes. Handlers get the values as a list of strings.
  • The listeners of module/functionlist run once per module and request, not on every module lookup; only when one is attached. What they return is kept where it has the form of module.php; a listener that throws or returns no list leaves the module its own functions instead of ending every request. The RAD survey lists the event again.
  • eZUser::accessUser() accepts a positive whole number only, gives the current user's own object for its ID and no user for a disabled account, and keeps other users for the request (forgotten by the next request, eZUser::cleanupCache() and a purge of that user's cache).
  • The role screens (role view and editor, policy lists of the node and user views, policy forms; admin, admin4 and standard designs) show a limitation's label, washed, with "(no handler, denies)" next to a content limitation no handler evaluates, and the stored values of one whose extension is gone. The editors store only values they offered; a single value posted instead of a list no longer ends the request in in_array().
  • The values of the Class, Section, Node and object state limitations are cast to integers in the fetch SQL.
  • expservices decides edit access to content as content/edit and the REST interface do: object updates through editAccess() in the language written, price and community item changes through filterEditAccess().
  • Fixed: the stand-in object of expRestContentPermissionTest takes the user argument of checkAccess(); PHP refused to load the test since the handlers came in. The ezjscore module definition reads its function list without ezjscore.ini, and two expservices methods mark their optional arguments nullable (PHP 8.4).
  • Tests: ezpContentLimitationTest (CL-11 to CL-18), eZContentPermissionSQLTest, eZContentAccessForAnotherUserTest, eZPolicyLimitationLabelsAndValuesTest, expRadWizardListsTest (no database). Velocity needs exp:velocity deploy --kernel for the kernel classes.

6 October 2026: host matching by the start of a host, and a redirect to the language for addresses without one

Added

  • The host map of siteaccess matching (MatchOrder=host, HostMatchType=map) can match a host by its start, end or a part, as host_uri already could: the new setting site.ini [SiteAccessSettings] HostMatchMethod (strict by default, as before) or a third field of an entry, HostMatchMapItems[]=www.example.com;site;start. With start, a test host such as www.example.com.test.local reaches the siteaccess of www.example.com without entries of its own. Both probes use the new eZSiteAccess::hostMatches(). As for host_uri, the static cache only knows strict matching.
  • An address that no probe of MatchOrder matches, where DefaultAccess would apply (typically the one without siteaccess or language segment), can get its siteaccess from the new setting site.ini [SiteAccessSettings] DefaultHostUriMatchMapItems[] (host;uri;siteaccess[;method[;language]]), with any MatchOrder: the first entry for the host whose language the browser accepts (Accept-Language, the most wanted first, de also for de-CH), else the first entry without a language; an empty host with part matches any host. A GET or HEAD request is sent on (302), once, to the same address with the uri part, kept as it was asked for, also for a site with one language: / to /ger, the URL alias /news/an-article to /ger/news/an-article, with its query. So every cached page has one address, and an address a probe matches (/ger/..., /admin/..., after switching the language too) is never sent on. The redirect needs a probe that takes the address with the uri part for that siteaccess; without one, for other requests or with DefaultHostUriRedirect=disabled the page is shown at the address itself with links carrying the uri part. When the host has more than one language variant, the redirect, and a page shown this way, are sent with Vary: Accept-Language and Cache-Control: private, and the role-aware HTTP cache does not store them. New eZSiteAccess::matchDefaultHostUri(), acceptedLanguages(), reachesSiteAccess() and ezpKernelWeb::languageRedirectURI(). Guide: Siteaccess by host prefix, and the redirect to the language. Test: eZSiteAccessMatchTest (no database).

Updated

  • The redirect to the language sends a path on the host that was asked for (Location: /ger/news?x=1), not a URL built from the Host header, so a forged host is never written into it. The path, which the kernel keeps decoded, is encoded again: an encoded line break cannot split the header, %2F%2F or a backslash cannot turn it into an address on another host, and empty and dot segments are dropped. It starts with the index file and the segment of the entry instead of the address built for links, which RemoveSiteAccessIfDefaultAccess=enabled shortened by the segment of the default siteaccess: with DefaultAccess the language siteaccess, the browser was sent to the address it had asked for, endlessly. New ezpKernelWeb::languageRedirectLocation(), which never answers with the address asked for itself.
  • The answer at an address without segment also varies by Accept-Language where an entry names a language and the entries lead to another siteaccess with the same segment, or where no entry applies without a language (a German browser went to /ger, any other got DefaultAccess at the same address without Vary, and a shared cache could hand the one answer to the other). A Vary from [HTTPHeaderSettings] is kept and extended.
  • The HTTP cache's early exit (ezpHttpCacheContract::resolveSiteAccess()) knows HostMatchMethod, the method of a host map entry and the hosts of DefaultHostUriMatchMapItems; before, it served the page of DefaultAccess kept for / instead of letting the kernel choose by language.
  • Host matching (host map, host_uri, the language default) compares host names without regard to case and to a trailing dot, ignores the port unless the entry names one, reads a Unicode entry as its ASCII form, and end matches the end of the host also where the listed host comes twice (example.com.example.com). An entry of DefaultHostUriMatchMapItems whose siteaccess is not in AvailableSiteAccessList is ignored and logged. acceptedLanguages() reads at most 32 languages, skips what is not a language tag and counts a q above 1 as 1. RedirectOnNormalize no longer sends a redirect while reachesSiteAccess() only asks where an address leads. Tests: eZSiteAccessMatchTest, ezpHttpCacheContractTest (no database).
  • Only the web page request uses the language default: eZSiteAccess::match() takes DefaultHostUriMatchMapItems into account only when its new fifth parameter $languageDefault is true, which ezpKernelWeb alone passes. The REST interface (ezpKernelRest), the tree menu (ezpKernelTreeMenu), extensions and scripts match as before #131: the probes, then DefaultAccess, never by the browser's language, without redirect or Vary. Test: eZSiteAccessMatchTest (no database).

6 October 2026: the users and groups of a role a page at a time

Updated

  • role/view shows the users and user groups a role is assigned to a page at a time, sorted by name, on the parameter (assignment_offset). The new setting site.ini [RoleSettings] AssignmentsPerPage (default 50, 0 lists all) sets the page size. A role given to thousands of users loaded every one of them with its object. The heading counts all of them ($assignment_total; $assignment_count is what the name filter keeps); the admin designs page with the navigator, design/standard with previous and next links. New eZRole::assignmentCount() and eZRole::assignmentPage( $offset, $limit ); fetchUserByRole() still returns all. A design that overrides role/view.tpl must add a pager, see behaviour changes. Test: eZRoleAssignmentPageLiveTest (live).
  • A page of role/view costs the same few queries whatever its size: the objects, main nodes and limitation nodes of a page come in one query each, and the name shown is the one fetched with the object (a role with 114 assignments: 106 queries before, 10 now). The sorting is the same on every database: assignments whose object is gone first, then by name without regard to the case of a-z, then by assignment (checked on SQLite, MySQL, PostgreSQL, Oracle and MongoDB, which sorts in php).
  • An assignment whose user or group no longer exists is listed first as such and counted above the list, so it can be removed; it was an empty link. A limitation whose node or section is gone says "not found".
  • Paging or sorting the policies keeps the place in the assignments and the other way round, and the form (Remove selected, Assign) comes back to the same page; past the last page shows the last page.
  • The audit of removing assignments reads the rows of the role without loading their objects (it loaded every object of the role once per removed assignment) and records the user id of a gone object instead of 0.

Added

  • A name filter on the assignments of role/view: (assignment_filter)/<text>, without regard to case, % and _ matched as themselves. New eZRole::assignmentOrphanCount(), eZRole::assignmentRows() and the optional $filter of assignmentCount() and assignmentPage().
  • role/list shows how many users and groups each role is assigned to (column Assigned, one query per page: eZRole::assignmentCounts()). The role name in the design/standard role list is escaped.
  • Translations (eng-US, ger-DE, untranslated) of the new texts. Guide: Role assignment paging. Tests: eZRoleAssignmentSortTest (no database) and new cases of eZRoleAssignmentPageLiveTest (gone object, filter, role list counts, query count).

Updated

  • url/list is an overview (links in published content, valid, invalid, never checked, the last link check), a search over the addresses (?q=), the lists all, valid, invalid and the new unchecked, an order ((sort)/checked, (sort)/modified, address A to Z by default) and one card per link with its state, how the link check treats it (e-mail, on this site, link to content, not tested, other address), its times, the published objects that use it (two queries per page) and View, Edit and Open. Mark selected valid and Mark selected invalid use the SetValid and SetInvalid actions the module always had but no template offered, and say how many links changed. The page size comes from admininterface.ini [PaginationSettings] (url/list).
  • Fixed: url/list printed the address unescaped and made it a link whatever its scheme; Open is now offered for web, mail and site addresses only, with rel="noopener noreferrer".
  • content/urltranslator leads with the creation form, each field explained and its error on the field; what was typed stays in the form when an alias cannot be created (it was emptied). The list searches the last segment and destination (?q=), filters redirecting or direct aliases ((kind)/...), shows each alias's whole path and what its destination resolves to, and marks an alias whose module no longer exists. Remove all asks in place how many aliases go. The destination of an alias is escaped.
  • content/urlwildcards refuses a destination with a placeholder the pattern has no * for (new info code error-wildcard-placeholder), searches pattern and destination, filters by kind, and Try an address (?test=) says which wildcard an address matches, what it becomes and what that resolves to, as the wildcard cache would. Remove all asks in place.
  • search/stats says which siteaccesses record searches (site.ini [SearchSettings] LogSearchStats, disabled by default) and warns when none does, counts phrases, searches and those that found nothing, searches the phrases, filters those that found nothing ((show)/none), orders by count, phrase or fewest results ((sort)/...), draws each count as a bar and runs a phrase again with Search now. Reset statistics asks in place first (it had no confirmation). The page size the template used is now the one the view pages with.
  • The four pages share the look of the section and cronjobs pages, in design/admin and design/admin4 (the url and search sections of stylesheets/modules/workflow.css), in the light and dark modes, and work without javascript. Every field name, button and template variable they had is kept.
  • eZURL::fetchList() and fetchListCount() take last_checked (never, checked), search (with only_published) and sort (address, checked, modified, id) on every database; on MongoDB the published listing now also returns created and modified.
  • The link check (linkcheck.php, part infrequent) decides with the new expLinkCheck. https:// is requested like http:// (it was never tested): HEAD, then GET when HEAD fails, redirects followed one by one up to MaxRedirects, TLS verified, a timeout and a User-Agent of its own. 429 and 503, and 401 and 403 (a site refusing automated requests), decide nothing and keep the state. Links to content (ezlocation://, eznode://, ezobject://) are valid while their node or object exists, is published and is not hidden; they were looked up as paths and always marked invalid. A path of the site is found behind its siteaccess name, query and fragment, and a module view counts. file: addresses are no longer tested. mailto: and other schemes are treated as before.
  • The link check never requests an address on a private, loopback, link-local or reserved network, also behind a redirect, unless AllowedPrivateHosts[] names its host, address or range, and pins each request to the address it checked (SSRF). It waits HostDelay between requests to one host, skips links checked less than RecheckInterval seconds ago, checks the ones checked longest ago first and ends with its counts, requests and time. New keys in cronjob.ini [linkCheckSettings]: Timeout, ConnectTimeout, MaxRedirects, HostDelay, RecheckInterval, MaxURLsPerRun, UserAgent, AllowedPrivateHosts[]. On the demonstration server the first run checked 350 links in 9.5 minutes with 725 requests (valid 284 to 254, invalid 66 to 96, never checked 21 to 0).
  • The badges of url/list follow the new rules ("https: not tested" is gone; "Other address" for other schemes).
  • The search and kind filter of content/urltranslator and content/urlwildcards are conditions of the database query (they read every row and filtered in PHP): new eZURLAliasQuery properties search (last segment or action) and redirects, new eZURLWildcard::fetchFilteredList() and fetchFilteredListCount(), on every engine and MongoDB.

Added

6 October 2026: RSS feeds, triggers, workflow groups and class groups

Updated

  • rss/list is an overview (exports, active exports, imports, active imports, imported objects), a search and a filter, then one card per export with its public feed address, format in words, sources with their class, number of items, when the feed was last written (the newest cached copy) and what needs attention, and one card per import with its destination, class, owner, the objects it has created and the newest of them. A status bar names the cronjob part that runs rssimport.php, its schedule and next run. Sorting, paging and the page size are as before; new template variables rssexport_info, rssimport_info, rss_summary, rss_import_cronjob, rss_feed_cache_time, rss_feedback. Guide: RSS feeds.
  • Removing RSS exports or imports takes two steps: Remove selected shows design:rss/confirmremove.tpl with what removing means, and only ConfirmRemoveButton posted with the same ids removes. A removed export now takes its sources and OPML lines with it (removeThis(); they were left behind).
  • rss/edit_export and rss/edit_import are forms in the same look, each field with what it is used for: the export in the feed, what it lists (one card per source with its location, class and attribute mapping) and the Apple Podcasts or OPML part; the import in four steps (feed, where the items go, what each item becomes, active). The export form shows the public feed address (new template variable rss_feed_url); validation messages are escaped. An import is stored only with a name and an http or https source address (\Exponential\View\Kernel\Rss\EditImport::validate(), validation_errors), and says how many objects it has created (rss_import_imported). The content browser descriptions of the RSS pickers and the lock pages (edit_export_denied.tpl, edit_import_denied.tpl, new in design/admin and design/admin4) use the same look. Every field and button name is unchanged.
  • trigger/list names every operation in words ("Before publishing content"), grouped by module, with the workflow it runs, its events, whether it is disabled and its waiting processes; triggers without a workflow are marked "Not set". A trigger whose workflow is no longer offered by the select keeps it as an extra choice, so Apply changes no longer drops it unnoticed; one whose workflow was removed is marked. Stored triggers for operations no longer in AvailableOperationList are listed and can be removed (the existing RemoveButton). Apply changes says how many triggers changed after the redirect. Workflow names are escaped. Guide: Workflows, section 11.
  • workflow/grouplist shows each group with its workflows, their triggers, waiting processes, other groups and last change, and what removing the group does. Removal asks first (design:workflow/confirmremovegroup.tpl, ConfirmRemoveButton). A workflow that is also in another group now stays there instead of being removed, a disabled workflow of the group is removed too instead of being left without a group, and a removed workflow's events and temporary version go with it, as in workflow/workflowlist. Guide: Workflows, section 12.
  • class/grouplist shows each group with its classes and their published objects, the last change of the group or its classes, and how many classes and objects removing it would remove; an overview counts classes in more than one group and in none. Removal still goes through class/removegroup. Guide: Class groups.
  • The other class pages use the same look: class/classlist (the classes of a group with their objects, the other groups they are in, View, Edit and Copy, a search, New class in a chosen language), class/view (settings, attributes as cards, the class groups, translations and override templates behind chips), class/edit (the class settings and one card per attribute with every field explained; the attribute order script, Apply and Cancel back to the page the form came from work as before), class/groupedit, the language choice, the edit lock page and the confirmations of class/removeclass, class/removegroup and translation removal, which now say how many objects go. Class names, override template paths, translation names and validation messages that were printed unescaped are escaped. Copy on the class list is a form button that posts to class/copy/<id>. Every field, button and template variable is unchanged.
  • class/copy/<id> copies only for a POST that carries CopyClassButton or ConfirmCopyButton (the form token protects it). Opened by a link it shows what the copy will be (design:class/copy_confirm.tpl, new in design/admin, design/admin4 and design/standard) with a Copy button; before, following a link made a copy. The copy links of design/standard are form buttons now. Test: expAdminListsRedesignTest.
  • The four pages use the look of the redesigned section pages in design/admin and design/admin4, light and dark, with module-local exp_list_script.tpl and the module stylesheets under stylesheets/modules/. Every existing template variable, field and button name is unchanged. Processlist::scriptCronjob() finds the cronjob part of any script (workflowCronjob() uses it). Test: expAdminListsRedesignTest (no database).

6 October 2026: sessions and collected information

Updated

  • Setup > Sessions (setup/session) is redesigned for the admin and admin4 designs. With a session table (ezpSessionHandlerDB): figures (sessions, users active within ActivityTimeout, sessions of signed in users, anonymous sessions, timed out sessions still stored), a search over names, logins and e-mail addresses beside the user filter, one card per user or per session with last activity and idle time in words, sorting by last activity, name, login, e-mail and count ((sortby), (order)), and page sizes (admininterface.ini [PaginationSettings] ItemsPerPageList_setup_session[], 50, 25, 100). Remove selected and Remove all ask first on a confirmation page (ConfirmSessionRemoval); the session the administrator is using is never removed by a selection, and removing one's own user keeps it. Session keys no longer reach the page: one user's sessions are posted as references (SessionRefArray[]), SessionKeyArray[] from older overrides is still accepted for that user's keys only. Every button and field name is kept. The list reads names, logins and e-mail addresses in its one query instead of loading every user, and a page past the end shows the first page instead of redirecting.
  • Without a session table (ezpSessionHandlerPHP, the default) the page said only that the handler is not supported. It now names the handler and PHP's storage, lists who signed in within the last hour, day or session lifetime from ezuservisit with a search, sorting and paging, and explains how to switch to the database handler.
  • Setup > Collected information (infocollector/overview) is redesigned: figures (objects with collections, collections, the last 30 and 7 days, the latest collection, objects removed or in the trash whose collections are not listed), a search by name, sorting by name, collections, last or first collection and type, one card per object with its recent collections and links to the collections, the object and the CSV export, and the published objects that can collect but have nothing yet. A page is one query instead of three per row. The removal confirmation (also used by infocollector/collectionlist) lists what goes, says what stays and that the data can be exported first, and the overview says how many collections were removed or that nothing was selected.

Added

  • infocollector/export/<ObjectID>: everything an object collected as CSV (collection ID, sent, changed, user ID and one column per information collector attribute), formula cells guarded, at most 50 000 rows, audited as data.export.csv without values. Policy infocollector/read.
  • Guides: Sessions and Collected information. Tests: SessionPageTest (no database).

6 October 2026: PDF exports

Updated

  • A PDF export generated once wrote its file and then stopped with "[] operator not supported for strings" (PHP 8): the second template of the generation was handed a string where the template engine appends to an array. The export was never stored and its file stayed behind with nothing pointing at it. A PDF generated on the fly was streamed from inside a template (pdf(stream): the whole file in a string, ob_clean() on a buffer that may not exist, no file name, cleanExit() in the middle of a template); it is now written to a temporary file and sent in chunks with Content-Disposition, after the page's buffers are dropped and the session is closed, with cleanExit() outside any catch block, so it works under Velocity's persistent workers.
  • The list linked a stored file at var/<site>/storage/pdf/<name>, which the web server does not serve (only images are served from the storage directory): the link was always a 404. Every download goes through pdf/edit/<id>/generate, which now sends the stored file of an export generated once instead of an empty page.
  • The file name came from the form through basename() only: an empty name, a hidden file or any character reached the storage path. It is now letters, digits, ., _ and -, at most 100 characters, ending in .pdf (added when missing); removing an export deletes its file only for such a name.
  • The edit form ticked class 1 when class 12 was chosen (a substring test on "12:16"), kept the old classes when every class was unticked, and labelled the file name "if generated on the fly" while it is the stored file's name.
  • pdf/edit without an id made a new draft on every reload; it now redirects to pdf/edit/<id>.
  • A stored PDF is public at var/<site>/storage/pdf/<name>.pdf, linked from its card: one rule for storage/pdf in .htaccess_root, .htaccess_root_static and the nginx example (storage/pdf/*.pdf only).
  • Setup > PDF export (pdf/list) is redesigned for the admin and admin4 designs: what an export is, figures (exports, generated once, on the fly, stored files, their size, needing attention), the drafts of new exports that were never saved, a search over title, file name, ID, source node and classes, filters (generated once, on the fly, with a stored file, needing attention), sorting by title, modified, generated, file size and ID, page sizes (admininterface.ini [PaginationSettings] ItemsPerPage[pdf/list], ItemsPerPageList_pdf_list[], remembered per user), and one card per export: its source node and whether it still exists, what it contains, its stored file with size and date, Download, Regenerate (writes the file anew; greyed out with the reason when the source is gone) and Edit. Remove selected asks first and lists each export with the file that goes with it.
  • pdf/edit explains every field in five groups (title, content, front page, footer, export type), checks them (a title, an existing source node, at least one class for a tree, a safe file name no other stored export uses) and names the field of each error; it says when somebody else has the export open. A new file is written to a temporary name first and replaces the old one only when complete. Cancel goes back to the page the form was opened from (safe redirect rules), else to the list.
  • Every field and button name (Title, SourceNode, ExportType, ClassList[], DestinationType, DestinationFile, ExportPDFButton, DiscardButton, DeleteIDArray[], RemoveExportButton, NewPDFExport) and every template variable is kept; new are ConfirmRemoveButton and RegenerateButton. generatePDF() is kept and calls the new classes.

Added

  • expPDFExportFile, expPDFExportGenerator, expPDFExportInfo and expPDFExportForm (kernel/classes/pdfexport): the file name and stored file, the generation, what the pages say about an export, and the form. Tests: expPDFExportTest (no database).
  • Guide: PDF exports.

6 October 2026: system information

Updated

  • Setup > System information (setup/info) is redesigned for the admin and admin4 designs. It leads with the server that answered (Apache with PHP-FPM and its pool, Exponential Velocity with its release and worker model, FrankenPHP, PHP's built-in server), then health checks with ok, warning, failure and note and what to do for each, overview cards (Exponential, server, PHP, OPcache and APCu, database, storage, caches, cronjobs, mail, locale and time, machine), and the detailed parts folded below them (extensions with their versions, PHP extensions and settings, site addresses, OPcache and APCu, Velocity, the engine archive, the HTTP cache, database queries, the response cache, the database connection). Every template variable of the earlier page is kept.
  • OPcache was reported as "not installed" under PHP-FPM when opcache_get_status is in disable_functions (Plesk sets it per domain); it is now "enabled, statistics not available".
  • Velocity's own server was said not to be started by exp:velocity when its worker could not read the TLS certificate; the configured HTTPS port is compared instead.
  • The PHP settings no longer report safe_mode and register_globals, removed from PHP (the template keys stay, always off).
  • setup/info/php calls phpinfo() without the request variables and the environment, which carried the HTTP sign-in and the session cookie, and as plain text under Velocity.
  • Full server paths (installation root, engine archive, cache directories, Velocity's log) are shown relative to the installation.

Added

  • expSystemReport: the facts of the process that runs it, read only and in about 10 ms, the health checks and the cards; directory and database sizes only on request (setup/info/sizes, three seconds at most). expSystemReportMask: the masking rules (secret names, credentials in addresses, password=... pairs, session ids and tokens, paths).
  • The report for a support request: setup/info/report (text) and setup/info/json, and Copy report as text.
  • ./console exp:system:info [--json] [--sizes] (bin/php/systeminfo.php), exit code 1 when a check fails.
  • Guide System information. Tests: expSystemReportTest, expSystemReportMaskTest (no database).
  • The buttons of the HTTP cache (purge, remove dead entries, reset counters), the query cache (clear, reset counters) and the SQL profile (on, off) are on Setup > Caches as well, in a block of their own. Both pages post the same fields to one code path, expCacheManager::sharedActionFromPost(), which checks setup/managecache; every post carries the form token.
  • expSecretRule: the one rule of what is a secret, shared by exp:ini (expIniEditor::isSecret()), the audit log, the settings pages (expSettingsSecretRule) and the system report (expSystemReportMask); names ending in pwd or dsn now count as secrets everywhere. A password inside an address keeps its user name on every page (mysql://user:***@host). Tests: expSecretRuleTest.

6 October 2026: the history of a draft for its editors

Updated

  • content/history opens for who may edit the object as well as for who may read it (\Exponential\View\Kernel\Content\History::canOpen(), with eZContentObject::editAccess()). A draft that was never published is readable for its owner only, so an approver or a further editor that an extension lets in through the filter content/edit/access could not reach the versions to make the next one from a rejected one. The forms of the page post without a version, which the check does not need; the diff still asks can_diff. Test: eZContentHistoryAccessTest (no database).
  • The view's policy functions are read or edit (they were read and edit, so a user without any read policy never reached the view). History::canOpen() lets in who may edit the object, and who may read it with an edit policy for some content, as before; the anonymous user and other readers without an edit policy stay out.
  • Fixed: the page showed the content of versions the user may not read. It now compares, copies and links to the version view only for versions whose content the user may see (History::canSeeVersionContent()): the published and archived versions and one's own, a rejected version for who may edit the object, and someone else's draft or pending version only with content/versionread for it. This also closes the comparison of other users' drafts for readers, which content/diff alone allowed. A comparison or copy of such a version sent in a request is not done, and the page says so (design:content/history_access_messages.tpl).
  • Fixed: removing versions takes only versions of this object in the statuses the page offers (draft, archived, rejected, untouched draft) and says which versions were not removed; a pending version in a running workflow can no longer be removed by a made-up request. Copying starts the new draft in one of the version's own translations and reports a language the user may not edit on the page instead of a 403. An unknown version in the edit action returns to the list instead of ending in an error, and a comparison skips an attribute one version lacks.
  • An editor who may edit but not read the object gets a note on the page; the copy button of a version they may not see says why it is disabled. New strings in eng-US, ger-DE and the untranslated catalogue.
  • Guide: Edit access to objects that were never published, section "The versions of such an object". Tests: eZContentHistoryAccessTest (10 cases, no database) and eZContentHistoryAccessLiveTest (8 cases with its own users, roles and drafts).
  • Fixed: the Back button of content/history opened directly went to the edit the session had last sent to the history ("Manage versions", even of another object, an edit that makes a new draft), or to the page viewed last. It now goes to where the page was opened from: the edit of a version of this object that opened it with "Manage versions" (taken once), else the page the browser came from, each only when eZRedirectManager::safeURI() and the return rules take it (never the history itself, never content/edit/<object> without a version, never an edit of another object). Without an origin it goes to the object's own location in the full view (/content/view/full/<main node>), or to the content dashboard for an object never published. The origin is carried in the form (RedirectURI, now escaped), so comparing, copying and removing keep it. All four designs. History::originURI(), History::originCandidate(); test eZContentHistoryAccessTest HA-11.

6 October 2026: My drafts and My pending items in the look of the redesigned pages

Updated

  • Fixed: content/draft removed any version whose id a request named (DeleteIDArray[] with Remove selected), someone else's draft or a published version included, for every user who may open the page. It now removes only the current user's own drafts (and untouched drafts), each checked again at the moment it is removed (expContentDraftList::removable()); every other id is left as it is and the page says how many.
  • content/draft is figures (drafts, of new objects, not modified for 30 days, translations, classes), a search (?q=), filters by translation, class and age (7, 30, 90, 365 days), an order (last modified, oldest, name, class), paging (admininterface.ini [PaginationSettings], key content/draft, preference admin_draft_list_limit) and one card per draft: name, class, location or "new object", translation, version, created, modified and age, with Edit, View and Remove confirmed in place. Remove selected, the new Remove old drafts (RemoveOldButton, OldDraftDays) and Remove all are confirmed in place; one draft is removed with RemoveDraftButton[<id>]. RemoveButton, DeleteIDArray[] and EmptyButton are kept.
  • content/pendinglist lists the user's own pending versions and, new, the versions waiting for the user's approval that the user may read (content/versionread), each with the state of its approval and its approvers, the workflows whose process holds it, who sent it and when, its location, and links to the version, the approval (when the user takes part in it) and the published page; figures, filters by list and class, an order and paging (key content/pendinglist, preference admin_pending_list_limit).
  • Both pages are in design/admin and design/admin4 with the look of the versions page (the history and draft sections of stylesheets/modules/content.css); design/standard keeps its pages. The views are thin; the lists are expContentDraftList and expContentPendingList, which need no database.

Added

6 October 2026: the versions page in the look of the redesigned pages

Updated

  • content/history is an introduction to versions and to making a new draft from one, the notices of the last action, figures (all versions, each status, translations, your drafts), filters by status, translation and creator, an order (newest, oldest, last modified), paging (admininterface.ini [PaginationSettings], key content/history, preference admin_history_list_limit) and one card per version: its number, status, translations, creator and dates, and the actions you have on it (View, Edit, Compare, Make a new draft in a translation you may edit, select for removal). An action you do not have says why on the card, or once above the list when no version on the page allows it.
  • Comparing is a two-picker panel offering only the versions whose content you may read, and a Compare button on each card (new POST CompareButton[n]: with the current version, or for the current one with the newest other version you may read). The differences are shown above the list as inline changes, block changes, the old or the new version, in colours that read on white.
  • Removing ticked versions is confirmed in place with the list of versions; the page then says which were removed and which were not (new refusal remove-none when nothing was ticked). Making a new draft says which draft was made; untick "Stay on this page" (DoNotEditAfterCopy) to open it in the editor at once.
  • The edit warnings and the access notices (content/history_access_messages.tpl, now also in design/admin and design/admin4) are notices in the look of the redesigned pages. The same page is in design/admin, design/admin3 and design/admin4 (the history section of stylesheets/modules/content.css); the design/standard page is unchanged and keeps working. Every POST name and template variable of the page is kept; new variables history_rows, history_overview, history_choices, history_links, history_filters, history_suffix, history_count, history_offset, history_limit, history_limit_choices, history_status_names, history_edit_languages, history_feedback, diff_language.
  • The view is a sequence of steps (Back, compare, version limit warning, remove, edit, copy, list); the list, its filters and the actions of each version are expContentHistoryList (kernel/classes/expcontenthistorylist.php), which needs no database. Who may open the page and whose content they see is unchanged (History::canOpen(), History::canSeeVersionContent()).

Added

6 October 2026: My bookmarks

Updated

  • content/bookmark is an overview and a list in the look of the section, link list and session pages: figures (bookmarks, folders, not in a folder, hidden, no longer available), the folders with their counts beside the list, the folder shown with its path, counts and Rename, Move (with Move up and down) and Remove folder, a search over names, classes, locations and folders, five orders (your own, name, recently added, type, recently modified) and paging (admininterface.ini [PaginationSettings] ItemsPerPageList_content_bookmark[], 25, 50 and 100 by default).
  • The bookmarks are grouped by folder, one card each with the class icon, the location, the last change, Hidden or Hidden by a parent, and Not found or No access for a bookmark whose item is gone or no longer readable (those were left out of the list before, so they could not be removed). View, Edit where allowed, Move up and Move down.
  • Bookmarks move between folders by selecting them (Move the selected bookmarks to) or, as an extra, by dragging them onto a folder. Remove selected and Remove folder ask first; Remove folder says whether what is inside moves to the folder it was in or is removed with it. Add items adds to the folder shown and only items the user may read.
  • Every POST answers with a redirect to the same folder, order and search and a notice. The Remove of the context menu (NeedRedirectBack) goes back only to a page of this site. Every POST name and the template variables view_parameters and bookmark_notice are unchanged; the page works without JavaScript, has 4.5:1 contrast in the light and dark modes and holds from 390 pixels and at 200% zoom.

Added

  • Arranging on content/bookmark, in Your order: a grip on every bookmark and every folder of the folder list is dragged to a new place in its folder (the folder is outlined) or a bookmark is dropped on a folder to move it there (labelled "Move into this folder"); the arrow keys on a focused grip move it one place and keep the focus; a position field moves a bookmark to any place in its folder, also across pages; Move up and Move down remain without JavaScript. One POST saves the new order (BookmarkOrderButton, OrderType, OrderFolderID, OrderIDs) through eZContentBrowseBookmarkFolder::setOrder(), which refuses ids of another folder or user and repeated ids; moveToPosition() places one entry. In the other orders the page says "Switch to Your order to arrange". Tests: expBookmarkPageTest BP-14 to BP-17, BookmarkFoldersTest BF-14.
  • expBookmarkPage (kernel/classes/expbookmarkpage.php): what the page shows, from plain arrays; eZContentBrowseBookmarkFolder::shift(): one place up or down within a folder; the button BookmarkShiftButton.
  • Guide: Bookmarks. Tests: expBookmarkPageTest (no database), BookmarkFoldersTest BF-13.

6 October 2026: the INI settings page (settings/view, settings/edit)

Updated

  • Security: settings/edit and the remove action of settings/view took the INI file, the siteaccess and the place to write from the request as they were, so a POST with ../ in INIFile, SiteAccess or SettingPlacement could write an .append.php file outside the settings tree. Every name is now checked against what the installation lists (the INI files found, RelatedSiteAccessList, the active extensions), block and setting names must be ones eZINI reads back, read-only settings ([eZINISettings] ReadonlySettingList) are refused, and the values shown on the edit page are escaped. eZINI::save() already refused line breaks and */ in values.
  • Security: secrets were shown in plain text (the values of six settings named like Password, Token or Key in site.ini and one in velocity.ini), also in the edit field. Their values are now masked everywhere on both pages, never searched and never put back into a form; a secret left empty on the edit page keeps its value.
  • A saved setting did not take effect: config.php switches eZINI's file time check off, and the page never cleared the INI cache, so neither PHP-FPM nor Velocity used the new value. Saving and removing now clear the INI cache and say which caches were cleared, and whether Velocity must be restarted for the setting.
  • The origin of array elements was wrong after a reset (Name[]) in a later file: eZINI's placements keep counting past a reset, so 1833 of 21542 array elements on the demonstration server were attributed to a file they do not come from. The origin is now worked out from the files themselves in eZINI's load order (expSettingsChain), checked against eZINI for every setting of every file in every siteaccess (41728 settings, no difference).
  • The page is redesigned for the admin and admin4 designs: figures for the file, the files read in their order, a card per block with a jump list, every setting with its value in effect, its origin, its default when changed and its whole override chain (what each file does: sets, empties the array, adds an element, sets a key; in effect or overridden), arrays as numbered lists with the origin of each element. The edit page shows the chain before the form, explains each place to save, and fills an array's field with an empty first line so saving it as shown replaces the array instead of adding the same elements again. Every form field, button and template variable of the old pages is kept; the pages work without JavaScript.
  • settings/edit accepts the setting names eZINI accepts (Class_identifier, names with -, @, *), and an empty array can be saved.

Added

  • Search across settings (block, name and value) in one file or in every INI file of a siteaccess, a filter for the settings changed from the default, and a comparison of a file between two siteaccesses.
  • "Not in effect yet": the page compares the files with the values the serving process runs with (PHP-FPM on 443, Velocity on 8080) and lists the settings still waiting for an INI cache clear. Files of the load order the server cannot read are named.
  • site.ini [SettingsViewSettings] MaskedNameList[], UnmaskedNameList[] and MaskRevealLastCharacters (which names are secrets, on top of the rule of exp:ini and the audit log), and RestartSettingList[] (the settings Velocity reads only when it starts).
  • Classes expSettingsChain, expSettingsSecretRule, expSettingsTarget and expSettingsPage (kernel/classes/ini/). Guide: The settings page. Tests: expSettingsPageTest (no database).

6 October 2026: the cache preloader

Updated

  • Setup > Preload (setup/preload) is redesigned for the admin and admin4 designs: a status bar with the run going on (site, start, a progress bar of pages warmed against the page limit, the address being requested, Stop) or the last run, the live output with the figures of the run when it ends, a form that explains its fields, which server's caches a run warms (Apache/PHP-FPM and Velocity share the view cache, image aliases and compiled templates; Velocity's response cache keeps a page DefaultTtl seconds only), the command and a crontab line for the choices made, and the last runs with duration, pages, image aliases made and failures with their addresses, status codes and linking pages. The page works without JavaScript (the form posts Action=start|dryrun|stop, the names setup/preloadjob takes) and picks a running run up again when it is opened. Every template variable of the earlier page is kept.
  • One preload runs at a time: a run holds a lock (var/<var dir>/preload/run.lock) from the page, the shell and cron alike; a second one is refused (409 from setup/preloadjob, exit code 2 from the command). Each run writes a status file (<id>.json) beside its events, so a run whose process is gone is reported as ended instead of after two minutes without output, a fatal error is recorded with its message, and the runs of the shell are listed with the others. The last 20 runs are kept.
  • bin/php/preload.php runs the same crawler as the page (expPreloadJob::execute()) instead of wget and curl, and writes nothing to /tmp. New options: --max-pages (default 1000), --max-depth, --images, --dry-run, --target. The last three lines of its output say how the run ended, the pages warmed and the image aliases made.
  • The address of a siteaccess is worked out from MatchOrder and every match setting, as a request is matched (expPreloadAddress): a HostMatchMapItems[] entry with a third field (start, end, part) and HostMatchMethod reach the same siteaccess as for a visitor, URIMatchType=map and HostUriMatchMapItems[] give their own prefixes, and an address the matching does not send to the siteaccess is warned about.
  • setup/preloadstream no longer runs a crawl inside the request (it held a worker for minutes and started a load from a plain GET); it answers the events of the run going on or the last one.
  • The preloader takes links and images from the page's own markup only: nothing inside <script>, <template>, <style> or comments, and no value that holds a quote, a JavaScript or template expression (' +, ${, {{ }}) or whitespace. Inline scripts that build markup in strings were reported as broken links (/' + esc(p.path) + '). Velocity's own panel (/Q/...) is never requested.
  • The preloader warmed only the front page of a site whose SiteURL already ends in its siteaccess prefix (uri matching, latest.demo.exponential.earth/site): the prefix was rightly not added twice, so the site was taken for one matched by its host, and every /site/... link was dropped as another siteaccess's. The site is now everything below the path of SiteURL plus the prefix, in every matching mode (host, uri, host_uri, an installation in a folder). Phase 1 also warms the section pages, the top level pages the starting pages link to (/site/fitness), when URLTranslationKeyword names none.

Added

  • What to warm: Also check the images on each page (--images) requests every image the warmed pages show, once, and lists the missing ones with the pages that show them. Image aliases made counts the alias files the rendered pages created during a run.
  • Dry run on the page and --dry-run from the shell: the address, the prefix, the starting pages, the limits and the command, without a request.
  • GET setup/preloadjob/status (the run going on and the last runs), POST Action=dryrun.
  • expPreloadAddress, expPreloadLock, expPreloadHistory; guide Preloading caches. Tests: expPreloadAddressTest, expPreloadLockAndHistoryTest (no database).

6 October 2026: classic menus (Setup > Menus (classic))

Updated

  • Setup > Menus (classic) (visual/menuconfig, until now "Menu management") explains what it is for: the top and left menus of sites built with the classic designs (base, ezwebin, the demo and simple designs), chosen per siteaccess in menu.ini [SelectedMenu]. Sites built with Exponential Layouts set their menus in the layout editor, which the page links to.
  • The page finds for every siteaccess of RelatedSiteAccessList, from the page layout its designs resolve to, whether it uses these settings, renders through Layouts, is an administration siteaccess or has a design that reads no classic menu. It opens on a siteaccess where they matter, folds the others away with the reason, says when none uses them, and asks for a tick before saving for a siteaccess they do nothing for.
  • For the siteaccess shown: the current arrangement, the file each value comes from, a warning when settings/override decides it, the classes the menus list, and the file saving writes to. Each arrangement has a preview built from the installation's own pages, says what its menus list (parent page, classes, depth, hidden pages) and which template file draws it, and shows the lines saving writes.
  • Five recipes with their exact lines and what a visitor sees (top menu only; top plus a left menu for the current section; menus limited to folders and landing pages; hiding a page; a different menu per siteaccess), the designs of the installation that draw classic menus, and "Use in your templates": copy-ready template examples for a page layout, a top menu and a section menu of your own, and where to put them.
  • Saving writes only [SelectedMenu] CurrentMenu, TopMenu and LeftMenu into settings/siteaccess/<siteaccess>/menu.ini.append.php, through the INI editor of exp:ini (only the touched lines change, the PHP wrapper stays, a copy of the old file goes to var/backup/ini, the write is audited), reads the file back and puts the old one back when it does not hold what was written. Before, it saved every menu.ini setting the siteaccess read into that file, which froze the other menu settings for it. Only an arrangement of AvailableMenuArray is accepted, and only a known siteaccess whose directory lies in settings/siteaccess.
  • After saving, the INI caches, the compiled page layouts and the template blocks are cleared and the page says that no restart of PHP-FPM or Velocity is needed.
  • The form keeps CurrentSiteAccess, SelectCurrentSiteAccessButton, MenuType and StoreButton and every template variable, and works without javascript; new is /visual/menuconfig/(siteaccess)/<name>. Redesigned in design/admin and design/admin4, light and dark.
  • The Setup and Design menus list the page as "Menus (classic)".

Added

  • expClassicMenuSiteAccessInspector and expClassicMenuSettings (kernel/classes/classicmenu): which siteaccesses and designs read the classic menu settings, and reading and writing them; the template examples in kernel/classes/classicmenu/examples. Tests: expClassicMenuSettingsTest (no database).
  • Guide: Classic menus.

6 October 2026: publish without notification

Added

  • "Publish without notification" next to the publish button of content/edit and of the version preview, shown when the new setting notification.ini [NotificationSettings] PublishWithoutNotification is enabled (default disabled, the button hidden). The version is published as with the other button, but no notification event is made, so the subscribers of its location get no mail. The publish operation takes the new optional parameter notify (true by default), kept in the memento of a publication a workflow holds back, and eZContentOperationCollection::createNotificationEvent() gives it to the filter content/notification/create instead of true. Buttons in the admin, admin3, admin4 and standard designs, translated into German. Guide: Publish without notification, and section 8 of the notifications administrator's guide. Tests: eZPublishWithoutNotificationTest (no database), eZPublishWithoutNotificationLiveTest (live).
  • Policy function content/publish_without_notification (no limitations; content/* includes it). The button is shown to, and counts for, users who have it; for anybody else, and with the setting disabled, a posted PublishNotNotifyButton or PreviewPublishNotNotifyButton publishes with notification and logs a warning. eZContentOperationCollection::canPublishWithoutNotification( $user ) and publishWithoutNotificationEnabled().

Updated

  • The conflict page of content/edit (content/edit_conflict.tpl, admin, admin4, standard) offers "Publish without notification" again when it was pressed before the conflict was found; its Publish data button notified.
  • With content.ini [PublishingSettings] AsynchronousPublishing=enabled, a publication without notification is published at once instead of queued (sendToPublishingQueue() takes notify): the asynchronous publisher runs the operation with the object and the version only, so it notified.
  • createNotificationEvent() takes false, 0 and "0" as no, and its debug line says when a version was published without notification rather than blaming a listener.

6 October 2026: Setup > Caches

Updated

  • Setup > Caches (setup/cache) is redesigned for the admin and admin4 designs. It lists every cache in groups (content and views, templates, INI and settings, images, pages, queries and the server, other) with what it holds, its id and tags, its directory relative to the installation, its size and number of files (measured on request, setup/cache/(sizes)/1, within three seconds), when it was last cleared where the kernel records it, and badges for a Velocity restart, Velocity's response cache and a disabled cache. Above it: figures (caches, enabled, groups, total size, last expiry), search and a group filter.
  • Clear selected, clear a group (ClearGroupButton), clear by tag and clear all each name what goes in an in-place confirmation (details elements, so it works without javascript; "Clear selected" confirms with javascript), and end in a notice with the caches cleared, the time it took, the restart or response cache hint and the command.
  • The HTTP cache, query cache, SQL profile, Velocity's response cache (ClearVelocityCacheButton, new) and PHP's OPcache and APCu are rows of the group "Pages, queries and the server" instead of separate boxes. The static cache and the commands that do the same from a shell are folded below.
  • OPcache was reported as "not loaded" under PHP-FPM when opcache_get_status is in disable_functions; it is enabled, and Reset OPcache now works there too (expCacheManager::phpCacheState(), resetOPcache()).
  • Every POST name and template variable of the earlier page is kept; the view is thin and its actions are methods of their own.

Added

  • expCacheCatalogue: the catalogue of the caches (groups, what each holds, masked directories, sizes within a time budget, last cleared, Velocity hints, commands); it only describes, clearing stays expCacheManager's. Tests: expCacheCatalogueTest (no database).
  • "Last cleared" on Setup > Caches also reads the audit trail's system.cache.clear records (one bounded query of the audit index and the end of the newest system file), with who cleared for users who may read the system channel: clears through the page, System information, exp:cache and, since the next entry, bin/php/ezcache.php (and so exp:velocity deploy) are recorded. Times read "just now", "12 minutes ago". Tests: expCacheCatalogueTest (CC-07, CC-08).
  • bin/php/ezcache.php, and so exp:velocity deploy, writes one system.cache.clear audit record per run that clears something (--clear-all, --clear-tag, --clear-id, with --purge and --expiry), with the ids cleared and the operating system user of the shell, through eZCacheHelper::auditCleared() and the same eZCache::auditCleared() that exp:cache and the administration use. A failed audit write is a warning on stderr; the caches are cleared either way. Setup > Caches names a shell as "from a shell, os:root".
  • expAuditReader::linesBackwards() lost records under Velocity: through a userland stream wrapper fread() returns 8 KB at most, and the rest of each 64 KB chunk was skipped. It reads until the chunk is complete. Test: expAuditReaderChunkTest.
  • Guide Caches.

6 October 2026: faster access arrays for users with many role assignments

Updated

  • The access array of a user (eZRole::accessArrayByUserID()) is built from the policies, limitations and values of all the user's roles loaded in three queries, the limitations of a policy are turned into their part once for all the assignments of its role, and the parts are merged in one call. A user with 600 subtree assignments (120 teamrooms × 5 member roles of 20 policies) took 3,550 ms and about 48,600 queries, now 244 ms and 4 queries. The array is the same, in the same order (the values of a limitation by value, as read one by one). New setting site.ini [RoleSettings] AccessArrayPrefetch (enabled; disabled builds it role by role). Guide: Faster access arrays. Tests: eZPolicyPrefetchedAccessArrayTest (no database), eZRoleAccessArrayPrefetchLiveTest (live).
  • Fixed (in the same change, before release): the values of a limitation loaded ahead came in the order of their ids instead of by value; while the rows were loaded, a role, policy or limitation outside them was answered as having none (a policy then as unlimited) instead of asking the database; an IN () list could pass Oracle's 1000 (now at most 500 ids per query); a failed query left policies without their limitations, that is unlimited (the array is now built role by role then); a build inside a build dropped the outer one's rows. Compared on the demonstration server with === against the code before for every user, assignee and throwaway roles of every limitation kind: identical; 177,651 queries and 14.3 s for 31 subjects before, 104 and 1.6 s now.

Required packages (root composer.json, 2026-10-01 to 2026-10-02)

The constraints of the root composer.json after the last commit of 2026-10-02. The last column gives the reason of the repin commit in the range. Each package's own release notes are in the extensions' release notes.

PackageRequired at the end of 2026-10-02Why it was repinned
exponential-velocity~0.0.4.42persistent workers report each request's own start time and read sessions without a warning
ezoracle~2.3.3Oracle driver in the SQL query cache (2.3.2)
expui~1.0.0.5jQuery 4 admin tables, dialogs, uploads, dates, autosave; the datatable of the subitems columns
ezjscore (bundled)1.5.4YUI removed (1.5.0); identical to its own releases 1.5.2 to 1.5.4
explayouts / explayouts-ui / explayouts-ui-api~1.4.12 / ~1.3.9 / ~1.3.10Exponential names, jQuery 4 editor and admin
sevenx-themes-media, expsite-data-media, expsite-installer~1.5.31, ~1.0.6, ~1.0.2renamed blocks; ezinfo.php works on PHP 8
ezwebin, ezdemo, ezwt, ezflow~6.0.16, ~6.0.9, ~6.0.9, ~6.1.5date fields on Exponential UI; YUI removed
ezautosave, eztags, ezmultiupload, ezstarrating, ezodf~6.0.8, ~2.4.11, ~6.0.8, ~6.0.8, ~6.1.6Exponential UI; YUI removed
cjw_newsletter~4.1.16YUI removed; tests; command helpers
ezupdate, git_manager, expchangeclass, bccie, ngclasslist~1.1.10, ~2.0.14, ~1.0.5, ~1.1.11, ~1.1.3Installed packages view; releases of the day
syndication, birthday, hcaptcha~1.3.2, ~1.3.2, ~1.2engine-neutral schema; about-page name, version, license and website

6 October 2026: the upgrade check

Added

  • expFileConsistencyReport (kernel/private/classes/expfileconsistencyreport.php): compares the files with share/filelist.md5 and with the manifest an extension carries of its own, with result states (modified, missing, unreadable; unlisted, malformed and unordered as notes), counts, the time taken, a CSV and a text report. It needs no kernel; bin/php/checkmanifest.php and the upgrade check both use it, so they agree on the same tree.
  • expSchemaConsistencyReport (kernel/private/classes/expschemaconsistencyreport.php): the database check by table (missing tables, tables not in the schema, changed fields and indexes with their shipped and current definition), the SQL per table, what removes something, and engine notes. Display only; nothing is run.
  • php bin/php/checkmanifest.php --all --extensions also checks every extension's own manifest; --csv prints the findings as CSV. Guide: The upgrade check.
  • Test: expUpgradeCheckReportsTest (no database): matching, modified, missing, malformed lines, CRLF, unreadable files, extension manifests, unlisted and unordered lines, the downloads, and the schema report.

Updated

  • Setup > Upgrade check (setup/systemupgrade) is redesigned for the admin and admin4 designs, light and dark, styled in stylesheets/modules/setup.css: the version, the manifest's entries and dates and how many active extensions carry a manifest; the two checks side by side; the file result grouped by state with a search, a filter by state and by extension and what to do about each kind of finding; the database result table by table with its SQL; the time each check took. Download CSV, Download text and Download SQL (DownloadFileReportButton, DownloadSchemaReportButton) send the report as a file. MD5CheckButton, DBCheckButton and the template variables md5_result, failure_reason, upgrade_sql and mongo_* are kept; new ones are file_report, file_groups, schema_report, upgrade_info and upgrade_check.
  • The database check no longer calls a database consistent while hiding differences, nor inconsistent over noise: differences the engine's schema handler writes no SQL for (on SQLite, 19 tables whose longtext reads as text or whose AUTOINCREMENT key reads as int) are listed as engine notes and do not fail the check.
  • A manifest with CRLF line ends is read like one with LF (the command line check reported every line as missing); a manifest line naming a path outside the installation is skipped and reported instead of being read; an empty or unreadable share/filelist.md5 fails the check with its own message instead of passing.
  • The audit event system.upgrade.run counts the findings of the new reports and names the format of a download.
  • The SQLite schema reader reads a key declared integer NOT NULL PRIMARY KEY AUTOINCREMENT as auto_increment (it read int NOT NULL DEFAULT 0: expbookmark_folder and the five expmail_* tables), and the database check on SQLite compares only what SQLite can tell apart (eZSQLiteSchema::normalizeForComparison(): text and longtext, the width of an auto_increment key, a default left out or written as false), writing the SQL from the shipped definitions. The 19 engine notes on an SQLite installation are gone; a missing field, another type or length, NOT NULL and another default still show. Test: expSQLiteSchemaReaderTest (a throwaway SQLite file).

6 October 2026: the Extensions page

Updated

  • Setup > Extensions (setup/extensions) is one list instead of two: the separate Loading order card and the paged table with checkboxes are combined into one list of cards, redesigned for the admin and admin4 designs, light and dark, styled in stylesheets/modules/setup.css. Each card shows the position in ActiveExtensions, name, title, version, description, license and website, whether a siteaccess activates it as an access extension or uses one of its designs, its declared dependencies (requires, uses, extends) and the active extensions that need it, whether it is a git checkout, and its problems. Active extensions are moved with up and down buttons (without JavaScript too) or by drag and drop and the arrow keys on a grip; extensions are activated and deactivated in the same list, an activated one placed after what it requires and before what it extends, otherwise at the end. A search, a filter (all, active, inactive, problems) and an order (loading order or name) work in place and without JavaScript.
  • Nothing is written until Review changes shows the extensions activated, deactivated and moved, the risks (deactivating an extension another one requires, the only provider of a design a siteaccess uses, ezformtoken or ezjscore; a move that lets another extension's full settings file win, as an installation settings extension moved after ezoracle; a reorder the declared dependencies undo) and the resulting ActiveExtensions[] lines; red risks need a confirmation. Apply changes writes the new set and order together, clears the INI, template override, design and active extension caches, regenerates the autoload arrays and says whether Velocity needs exp:velocity restart. A list changed elsewhere in the meantime is not written over.
  • The page shows where each active extension really loads: with ExtensionOrdering=enabled the kernel sorts by the declared dependencies, and on a typical installation most extensions load at another position than written (51 of 55 on the reference installation); the former Loading order card showed only the written order.
  • Saving the list edits settings/override/site.ini.append.php as text: only the ActiveExtensions[] lines change, and the comments, the layout and the <?php /* wrapper of the file stay. eZINI::save() had rewritten the file and dropped every comment in it. A comment directly above an extension's line moves with it.
  • The posts of the page before the redesign still work (ActivateExtensionsButton with ActiveExtensionList[], and the JSON ReorderExtensions); the template variables of the earlier page are still set. The list is no longer paged, and (sort) takes order or name (other values fall back to the loading order).
  • Guide: The Extensions page.

Added

  • expExtensionCatalogue (kernel/classes/expextensioncatalogue.php): what is known about each extension (metadata, dependencies, designs, settings files, git checkout, access extensions and design users per siteaccess) and the rows of the list.
  • expExtensionChangePlan (kernel/classes/expextensionchangeplan.php): moving, activating and deactivating, the diff, problems, risks and the order the kernel really loads in; ezpActiveExtensions::replaceInText() and fromText() for the file.
  • Test: ExtensionChangePlanTest (no database): reorder, activate, deactivate, dependency warnings and risks, no change gives no write, and a round trip of the settings file that keeps every other byte.

6 October 2026: the package pages

Updated

  • Setup > Packages (package/list) shows every repository by default (it showed only local, so the setup wizard's own packages were out of sight): figures over all repositories, one tile per repository with its packages, size, install states, installer sources and the directories without a package definition it holds, and a warning naming every directory whose package.xml cannot be read (one such file ended the whole page with a fatal error). One card per package with its version, type, install state, summary, repository, maintainers, the packages it requires and those that require it, size, files and last change; a search, filters by repository, type and state, a sort, and pages whose size follows [PaginationSettings] and keep the filter (the pager lost the repository). Packages the installer takes as a source (the setup wizard's repository, site packages and what they require) are marked.
  • The removal of packages asks on a page of its own (package/confirmremove.tpl) that says what goes: each directory with its files and size, whether it is installed, the packages that require it and why it is an installer source; an installer source needs its own tick. It checks package/remove for each package's type (it checked nothing), removes only what the confirmation offered, refuses a package whose directory contains a link (the recursive delete would follow it), and is recorded as system.package.remove. Its title read "Remove section?".
  • A package's page (package/view/full/<name>[/<repository>]) leads with its actions (Compare, Install, Download, Uninstall, Remove, each by its own policy), then its details, what it carries (classes, content, extensions, settings files, files by kind) and its changelog, above the contents browser. A viewed file shows its first megabyte. The upload, install, uninstall and creation pages are redesigned the same way, in design/admin and design/admin4 (the package section of stylesheets/modules/structure.css), light and dark.
  • Downloads (package/export, package/viewfile) are sent in 256 KiB pieces after the page's output buffers are closed, answer Range with 206 or 416, quote the file name, and end outside any try/catch (Velocity). package/export and package/viewfile take the repository as a further parameter.
  • Fixed: an upload extracted every entry where the archive said: ../ paths, absolute paths, symbolic and hard links and devices were written outside the package (zip-slip). eZPackageUploadInspector refuses such archives, wrong suffixes, files that are not gzip, archives over package.ini [UploadSettings] (new) and malformed definitions before anything is written, with the reason on the page; eZPackage::import() refuses them too, so the setup wizard and ezpm import are covered. A vendor that gives no directory name is refused (it put the package at the top of the storage).
  • Fixed: a package name or repository in an address or a form is checked before it is joined to a path (eZPackageRequestGuard); ../7x/x made eZPackage::fetch() read a package.xml outside the repository. An unknown view mode is "not found" (it drew an empty page). Install and uninstall check package/install for the package's type.
  • Fixed: eZPackage::parseDOMTree() returns false for a definition without a name and reads a missing <ezpublish> or <packaging> as empty (both were fatal errors); exportToArchive() writes to a temporary file of its own, so a stale archive.tmp no longer makes every later export of the user fail.

Added

  • eZPackageCatalog, eZPackageRemovalPlan, eZPackageUploadInspector, eZPackageDownload and eZPackageRequestGuard (kernel classes), with tests that need no database: eZPackageCatalogTest, eZPackageUploadInspectorTest (malformed package.xml, path traversal and links in archives), eZPackageRequestGuardTest, eZPackageDownloadTest.
  • Guide: Packages.

6 October 2026: roles, policies and unactivated users

Updated

  • The role list (role/list) shows each role as a card with its policies, the users and groups it is assigned to, and a Full access badge for a policy over every module or Can change roles for one over the role module. A search by name or id (?q=) and the orders name, id, number of policies and number of assignments, either way round ((sort), (dir)); Remove selected asks first, in place.
  • The role page (role/view) shows every policy as a sentence ("May read content, in section Standard, of the classes Article and Folder"), with its module, function and limitations under it, ordered by role order, module (grouped), function or limitation; the figures say how many users the role reaches in all, members of its groups counted once. Removing assignments asks first.
  • The role editor (role/edit) lists the policies the same way, says when the draft differs from the saved role, asks before the page is left with unsaved changes, and asks before policies are removed. Policies are reordered by drag and drop on a grip, by the arrow keys on the grip, by the up and down buttons and by a position field (MovePolicyTo, MovePolicyPosition[<id>]), all as eZRole::movePolicy() steps in the draft: Save keeps the order, Cancel drops it. Dragging is offered in the role's own order.
  • The policy wizard shows its three steps, explains each choice and warns before full access is added; the policy editor and the section step of "Assign with limitation" are redesigned the same way. Every value is escaped.
  • role/copy shows what the copy will hold; the copy is made by the page's form (CopyRoleButton), no longer by opening the address.
  • Unactivated users (user/unactivated): the count is of the users the list holds; a search by login, e-mail or name; the orders registration date, login, e-mail and name; the age of each registration, old ones marked; Activate, Send the activation mail again (new, ResendButton) and Remove, each confirmed in place; Remove all (RemoveAllButton) for every unactivated user or every one the search matches, in batches of 50, each checked again before removal, never the anonymous user, admin or the user removing, reported as "N removed, M skipped" and recorded as access.user.remove with the counts.
  • All pages in design/admin and design/admin4, in the look of the redesigned pages (stylesheets/modules/people.css), light and dark. Every POST name and template variable of the pages is kept.

Added

  • expRolePolicySentence, expRolePage (kernel/classes/role) and expUnactivatedUsers (kernel/classes/user), with expRolePagesTest (no database). Guide: Roles, policies and unactivated users.

Fixed

  • A policy id posted to the role editor of another role removed that policy from its role at once (eZPolicy::removeByID() removes any policy); the editor removes only policies of its draft. Likewise role/view removed any assignment by id; it removes only assignments of the role shown.
  • "Assign with limitation" put the posted limitation type into the address unchecked; only subtree and section are taken, and role/assign checks the role, the limitation and its value, and returns from the content browser only to a page of this site.
  • role/policyedit/<id> for a policy that does not exist was a fatal error; it is "not available".
  • Activate on user/unactivated ran the whole register operation again, which for an account without a pending registration sent a new activation mail and disabled the account again; it now publishes the user object only. Remove on that page removes only users who are still unactivated.
  • An unknown sort field on user/unactivated raised a notice; an AssignRole browse result returned to role/list was a fatal error.

6 October 2026: the account page

Updated

  • The account page (user/edit, and user/edit/<id> for another user) is redesigned in the look of the other redesigned administration pages, in design/admin and design/admin4, light and dark, with its own style (the user section of stylesheets/modules/people.css). Until now the administration drew the plain page of design/standard (or that of the two-factor extension, without Cancel), outside the admin's card.
  • An overview card shows the name, login, e-mail, groups, roles, the last sign-in, the number of sign-ins, when the password was last changed (from the audit index) and the state of the account (active, disabled or locked).
  • Security hints: disabled or locked, failed sign-ins, two-step sign-in off, a password stored with an old method or changed more than a year ago, never signed in.
  • A card per page to go on to, shown only when the viewer may open it: profile, password, account settings, two-step sign-in (when its extension is active), API keys, bookmarks, notifications and e-mail preferences; on another user's page API keys and e-mail preferences open the administration pages of that user. Every POST name (EditButton, ChangePasswordButton, ChangeSettingButton, CancelButton) and template variable is kept; Cancel still goes back to the page the editor came from.

Added

  • expUserAccountOverview (kernel/classes/user), with expUserAccountOverviewTest (no database); the view sets $account_overview. Guide: Security and audit, section 6.

6 October 2026: no privilege escalation through roles; activation completes the registration

Updated

  • A user who may edit roles can only grant what their own access covers (site.ini [RoleSettings] PreventPrivilegeEscalation=enabled, new, default on). The policy wizard takes a policy they do not hold out again; Save of the role editor refuses a draft with a new or changed policy they do not hold; the policy editor refuses a broader change; role/copy refuses to copy, and role/assign and role/view to assign, a role with a policy beyond their access (an assignment's subtree or section limitation narrows the role first). Each refusal names the policies. Users with every function of every module may grant anything; disabled gives back the old behaviour.
  • Activate on user/unactivated sends the activated user the "registration approved" mail again, as the original flow does (site.ini [UserSettings] ActivationByAdministratorSendsApprovalMail=enabled, new; EmailRegistrationInfo still switches that mail off everywhere).

Added

  • expRoleGrantCheck (kernel/classes/role): whether an access array covers a policy, with subtree containment, nodes inside subtrees, value limitations, the limitations of roles assigned for a subtree or section, and a policy covered by several policies together; expRoleGrantCheckTest (no database).
  • expUserActivation (kernel/classes/user): completes an activation by resuming a pending registration, or by publishing and sending the approval mail when none is pending.

Fixed

  • user/activate (the link in the activation mail) ran user/register afresh for an account without a pending registration: the account was disabled again and mailed a new link. It now completes the registration as above; a pending registration resumes as before.
  • The publish step of an activation (eZUserOperationCollection::publishUserContentObject()) compared the object's version with !== '1'; a database driver that types its results (SQLite) gives 1, so every activation was cancelled there: the user object stayed unpublished and no approval mail went out.

6 October 2026: permission reads fail closed, roles leave no policies behind

Updated

  • The access array fails closed. A read that fails while it is built (the roles of a user, the policies of a role, the limitations of a policy, the values of a limitation, the role ids of the user cache) denies what it could not read: the role grants nothing, the policy is left out. Before, a policy whose limitations could not be read became unlimited (*). Such an array is used for that request only and not stored in the user cache, and one error is logged per build (eZRole::accessArrayByUserID). When loading the rows ahead fails, the array is built role by role, as before. With a healthy database the array is the same: compared on the demonstration server with === for every user, assignee and the throwaway roles of every limitation kind. Guide: Faster access arrays. Behaviour change: October 2026. Test: eZAccessArrayFailClosedTest (no database).

Fixed

  • Removing a role left behind the copies role/policyedit makes of its policies while one is edited (original_id set); eZRole::removeThis() removes every policy of the role.
  • Discard in role/edit for a role that was still new removed only its row and left its policies behind; they are removed with it.

6 October 2026: two-step sign-in

Added

  • A guide for two-step sign-in with sevenx_authentication_2fa, linked from the guides: setting it up as a user, turning it on or requiring it as the owner, a lost phone, social login, what protects it (doc/eng-us/guides/two-factor-login.md).

Updated

  • The admin4 login page shows the social login buttons of sevenx_authentication_2fa where that extension is active and enables a provider ({if ezmodule( 'user2fa/oauth' )}); nothing changes where it is not.
  • sevenx_authentication_2fa (released on its own): the second step, its setup, the social login answers and the 2FA field are drawn in the look of the redesigned admin pages (admin4 light and dark, admin, Admin UI) and of the media design; the password alone no longer signs in before the second step; codes are accepted once, limited per sign-in and counted as failed logins; a pending second step is kept in the session only; TOTP secrets can be encrypted at rest; the steps before signing in work in administration siteaccesses (PolicyOmitList); every redirect passes the safe redirect rules. See the extension's README.
  • Two-step sign-in is on at the demonstration server, as opt-in (Enforce2FA=disabled); the guide says how it is set up there and how to require it. The admin login, the standard registration and the standard profile include the extension's social login buttons and two-step sign-in link where it is active, instead of the extension shipping its own copies of those templates, which hid the kernel's newer ones.
  • Every role on the demonstration server that signs in has user2fa/setup and user2fa/verify (Editor and the custom editor role besides Member), so every user can set up two-step sign-in; the guide says so and that a new role needs both.

6 October 2026: kernel and library fixes from the logs

Fixed

  • Run from the engine archive (EXP_ENGINE_PHAR), the audit log looked for its directory inside the archive, so every record failed and was written to the error log instead. expAuditConfig::root() uses the installation directory (EXP_ROOT_DIR) when it is loaded from an archive. Test: expAuditConfigRootTest.
  • The same mistake in the repair queue of the missing-libraries page (ezpRepairQueue::root()), the SQL, MongoDB and OPcache profile switches in var/tmp, and expContentJob::rootDir(), which besides named kernel/ rather than the installation without eZSys. Test: EngineArchiveInstallationRootTest.
  • eZModule::addGlobalPathList() warned "Undefined global variable" when no module path list had been set yet.
  • The SOAP server warned "Undefined array key 1" on every call to a plain function, and stopped with a TypeError when no function was registered. Test: eZSOAPServerFunctionNameTest.
  • eZPolicy::saveTemporary() compared original_id with === 0; the database returns "0", so a policy that is not a temporary copy was not refused. Test: eZPolicySaveTemporaryTest.
  • A login without a RedirectURI field logged the error "No such action parameter: UserRedirectURI".

6 October 2026: the setup's package download

Fixed

  • The setup's package download tried once and, when it failed, said only "Download of package 'X' failed": the reason went to the debug output alone. A package server that is being republished answers with errors for a few minutes, and an install started then stopped. eZStepSiteTypes::downloadFile() now tries up to three times, waiting 2, 5 and 10 seconds ([RepositorySettings] DownloadAttempts and DownloadRetryDelays[] in package.ini), with a timeout for the whole transfer and for the connection (DownloadTimeout=300, DownloadConnectTimeout=30). An attempt fails on a curl error, an HTTP status other than 200, an empty or truncated file and an .ezpkg that is not gzip data, and leaves no partial file behind. The message names the reason, e.g. "Download of package 'sevenx_multisite' failed after 3 attempts: HTTP 404, 9379 bytes received", on the setup page, on the command line and in var/log/setup.log, where each failed attempt is a warning. The download without curl reports and retries the same way. Test: SetupPackageDownloadTest.

6 October 2026: permission checks for users with many role assignments

Updated

  • eZContentObject::checkAccess() loads the locations of the object, the parent paths of an unpublished object and the node of a Node limitation once per call instead of once per policy, and canCreateClassList() hands the locations from one classListFromPolicy() call to the next (new optional third parameter). For a member of 120 teamrooms, checking read access to an article took 120 queries and 27.8 ms, now 1 query and 1.0 ms; the create menu of a folder 601 queries, now 2.
  • The content fetches merge the read policies that differ only in User_Subtree and leave out those that cannot give access to a fetched node (eZContentObjectTreeNode::mergeLimitationList(), pruneLimitationList(), permissionFetchScope(), optional fourth parameter of createPermissionCheckingSQL()). The children of a folder in a teamroom took 24.4 ms with 800 read policies, now 1.2 ms; the fetches return the same nodes. New setting site.ini [RoleSettings] PermissionSQLOptimization (enabled; disabled ORs every policy as before). Guide: Permission checks for users with many role assignments.

Fixed

  • On SQLite every content fetch of a user with 1,000 read policies or more failed ("Expression tree is too large") and listed nothing. Merged, the 1,203 policies of the member above are 5, with 240 subtree conditions, and OR chains of more than 100 parts are written in groups (permissionSQLOr()), so thousands of policies or subtrees stay below the limit. Tests: eZContentPermissionSQLOptimizationTest (no database), eZContentPermissionSQLOptimizationLiveTest (live).
  • Before it reached main, the merging was corrected: the subtrees of a merged policy are put in parentheses (without them Class AND subtree 1 OR subtree 2 gave subtree 2 to every class and listed nodes users may not read), a Class limitation without values no longer leaves its policy out under a class filter, class ids are compared as numbers and the class filter type as the fetches read it, and policies that add no condition, left alone after the others were left out, give 0 = 1 rather than no condition. Checked on the demonstration server for every user, the read policies of every role and throwaway users of every limitation kind: 3,294 fetches, 28,224 access checks and 2,712 create lists give the same answers as before.

6 October 2026: renamed nodes keep their old addresses as redirects

Fixed

  • On MySQL, PostgreSQL, SQLite and Oracle, renaming or moving a node left its old address as a second original address instead of a history entry: the old link kept answering 200 instead of 301 to the new address, and a node could have several original addresses per language. eZURLAliasML::storePath() had lost its SQL history step when the MongoDB support was added; it runs again for every driver except MongoDB (an old entry of the language becomes history and links to the new one, an entry shared with other languages only loses the language's bit, children are reparented, custom and global aliases are not touched). On an installation that ran without it, publishing a node again, or updateSubTreePath(), turns its stale addresses into redirects; run such scripts with the siteaccess the editors publish from (-s admin), since a siteaccess with another RootNode names the content root differently. Tests: eZURLAliasMLHistoryLiveTest (live), eZURLAliasMLMongoStorePathTest.
  • On MongoDB, a URL alias entry was stored without the always-available bit of its object, and every call of storePath() warned about an undefined $alwaysMask.

6 October 2026: no read access lists no content, and a disabled user is signed out once

Fixed

  • A user without any content/read policy, and a user whose access array could not be read (fail-closed), was listed every node by the content fetches, search, trash, keyword lists and the admin's child counts: createPermissionCheckingSQL( false ) gave no condition. It now gives 0 = 1; full access still gives none. See the behaviour changes of October 2026. Test: eZContentPermissionSQLTest::testNoReadAccessGivesAConditionNoNodeMeets.
  • A disabled user in the session ended the request in "Allowed memory size exhausted": eZUser::currentUser() signed the user out, the audit record of the sign-out asked currentUser() who acts, which signed out again, without end. While the sign-out is recorded, currentUser() now answers with the user being signed out (eZUser::$recordingLogout, reset in a finally block and by Velocity between requests). Test: eZUserDisabledCurrentUserLiveTest (live).

7 October 2026: logs, INI cache and expiry timestamps per site

Added

  • Settings for sites with a VarDir of their own on one installation (multi-site hosting), all in site.ini [FileSettings] and off by default: UseGlobalLogDir=disabled writes the debug logs and the default logs of eZLog into the log directory of the site, LogDir may be an absolute path, ExpiryDir keeps expiry.php outside the cache directory (for a cache on a memory file system), INICacheDir=site caches the INI files of a site in its own cache directory, LogVarDir and CacheVarDir (e.g. var_log, var_cache) put the logs or caches of every site into a tree of their own with one line (var/<site> → var_cache/<site>); Velocity serves the public caches there (expVelocity::staticPaths()), .htaccess_root and the nginx example have the rule to enable. eZSiteAccess::change() derives all of them again on every change, for web requests, Velocity workers and scripts. New: eZSys::logDirectory(), eZSys::relocatedVarDirectory(), eZDebug::setLogDirectory() and logDirectory(), eZExpiryHandler::filePath(), eZSiteAccess::updateINICacheDirectory() and updateLogDirectory(), eZUpdateDebugLogDirectory(), eZSys::cacheDirectoryOf() (the cache directory of another siteaccess, which the classic menu, the toolbar and the package datatype now use instead of a path of their own). Guide: Logs, INI cache and expiry timestamps per site. Tests: eZSiteLogAndExpiryPathsTest, eZSiteAccessSitePathsTest, eZSiteLogReadersTest (no database), cases in expVelocityEnginesTest.

Updated

  • What reads the logs follows where they are written: the setup log counts the errors of a step in the error.log eZDebug writes (the log directory of the site with UseGlobalLogDir=disabled), and Setup > System information names that directory, warns when it cannot be written and finds the cronjob logs there. Audit records stay inside VarDir (audit.ini [AuditSettings] LogDir), which LogVarDir does not move; the old text files of 4.x audit names now honour an absolute [AuditSettings] LogDir, and an absolute VarDir is no longer put inside the installation.
  • Velocity serves the public caches below a CacheVarDir set for some siteaccesses only (read from every siteaccess when it starts), and its warm-up puts the log and INI cache directories of the site it rendered back (eZSiteAccess::resetSitePaths()).
  • eZDir::recursiveDelete() deletes inside the cache, log, expiry and INI cache directories of the site also when site.ini puts them outside the root, and the cache directory as a whole, as it does inside the root. A directory that contains the installation is never one of them. An [FileSettings] AllowedDeletionDirs entry that does not exist allows nothing (it allowed every path), and an entry is a directory, not a prefix: /srv/cache no longer allows /srv/cache-other. An installation that relied on either lists the directory it deletes from.
  • Velocity reads the CacheVarDir of every siteaccess on an eZINI of its own when it starts, without eZSiteAccess::load(): no extension is activated, no INI or extension cache file is written and the current siteaccess, the exception handler and the global INI instances stay as they were.
  • The Velocity warm-up finds the installation root and the snapshot of the globals its entry script takes, which it lost when its code moved into VelocityWarmup::run() (the pool requires the entry script inside a function): a site in maintenance is no longer rendered (the marker was looked for at /var/maintenance.json), each render has SCRIPT_FILENAME <root>/index.php, and the final sweep removes only the globals added after the snapshot instead of every global but the superglobals and Q.webserver.keepGlobals. Without a snapshot the sweep is skipped.

7 October 2026: the dialogs of the online editor and pasted styles

Fixed

  • The dialogs of the online editor (ezoe/dialog, ezoe/relations) open for whoever may read the object, as before, and for whoever edits the version named in the address: the editors of a draft of an object that was never published, which no one can read yet, could not insert a link, an image or an embedded object. "Edits the version" is decided as content/edit decides it: the version is a draft (a draft, an internal draft or one to be repeated) of that object made by the current user, the object is not in the trash, and eZContentObject::editAccess() allows it in the version's own language (a Language limitation applies, a listener of content/edit/access has its say). Someone else's draft, a published, pending or archived version, or a version that does not exist opens nothing for who may not read the object. ezoe/dialog opens only a dialog whose name is a plain template name (letters, digits, _ and -). Exponential\View\Extension\Ezoe\Ezoe\Dialog::mayOpen(), ::mayEditVersion(), ::isDialogName(). Tests: expOEDialogAccessRulesTest (no database), expOEDialogAccessTest (live).
  • A pasted style declaration without a colon (style="color; text-align: right") raised two PHP warnings; it is left out now, and a value keeps the colons it holds (an address). Only a plain CSS property name becomes an attribute: a declaration named xmlns or xml... no longer puts a namespace declaration into the stored XML (with xmlns:x the stored XML did not load again without a warning). An alignment (text-align, float, align) is taken only as a keyword, in lower case, without !important and without a browser prefix (-webkit-center is center); markup, a script address or an expression in it is left out. Tests: expOEPastedStyleRulesTest (no database), expOEPastedStylesTest (live).

7 October 2026: uploads, the embed dialog and the markup of the online editor

Fixed

  • An upload from the online editor (ezoe/upload) asked only for edit access to the object, whatever version number the address named, so it could create an object and relate it to someone else's draft or to a published or archived version. It now writes only into a draft of the user's own that they may edit in its language, decided as content/edit decides it (Dialog::mayEditVersion()), and checks that before it fetches or creates anything. ezoe/upload (the dialog) and ezoe/tags open by the rule of the other dialogs together with edit access to the object: whoever could open them before still can, and who may edit but not read the object opens them only for a draft of their own. Dialog::mayOpenForEditing(), Upload::mayUpload().
  • The uploaded object became a relation of version 1 (the version of the new object) instead of the version being edited; it now goes to the version being edited. Upload::addEmbedRelation().
  • The online editor put the stored class, align, width, border, colspan, rowspan, href, target, title, id, view, size and anchor name, the name of a custom tag and of an embedded object, the address of a custom tag image and the style of a custom attribute into its markup unescaped, so a stored value with a quote could add attributes or markup in the editor. Each value is escaped once where it goes into the markup (eZOEXMLInput::markupValue()); values that are escaped already are not escaped again, and opening and saving gives the stored values back unchanged. The text of a header with an anchor is escaped as other text is.
  • ezoe/relations without an EmbedID, or with a malformed one, raised undefined variable and undefined key warnings before its message; it ends with the message now. The EmbedID is an object id, eZObject_<id> or eZNode_<id> (Relations::parseEmbedId()).
  • The editor no longer raises deprecation notices for a missing user agent on the command line, no longer rebuilds the style map of the custom attributes on every call when ezoe.ini gives none, and saving a paragraph whose custom tag was removed no longer reads a missing parent. Tests: expOEDialogAccessRulesTest, expOEMarkupEscapeTest, expOERelationsEmbedIdTest (no database), expOEUrlFetcherTest, expOEEditorViewAccessTest (live).
  • ezoe/load reads its EmbedID as ezoe/relations does (Relations::parseEmbedId()): a missing or malformed one answers false without a PHP warning. The content type of ezoe/upload and ezoe/relations, which goes into the path of a template (design:ezoe/upload_<type>.tpl, design:ezoe/tag_embed_<type>.tpl), must be one of the editor's types (objects, images, files) or a relation group of content.ini [RelationGroupSettings], and a plain template name; anything else ends with a message. Dialog::contentTypes(), Dialog::isContentType(). Test: expOEContentTypeAndLoadTest (no database).

7 October 2026: copy the debug bar's messages

Added

  • A Copy Messages button on the Messages tab of the debug bar copies the messages that are shown (level buttons and filter box applied) as plain text: "Level: source", a tab and the time, then the message, a blank line between entries. It copies with navigator.clipboard and falls back to a hidden text field where the page is not a secure context; a status line says "Copied N messages" or that the copy failed. Translated in eng-US and ger-DE (context design/standard/debugbar). expDebugBarReport::copyMessagesHtml(). Guide: The Exp Debug bar. Test: expDebugBarReportCopyMessagesTest (no database).

7 October 2026: downloads from streams that return short reads

Fixed

  • eZFile::downloadContent() added the size it asked fread() for to the bytes sent, not the size it got. A stream that returns less per read (a userland stream wrapper hands back one 8 KB buffer, network and cluster mounts return what has arrived) ended the transfer after the first read, and the client got a truncated file with status 200. It now counts what was read and stops at the end of the file or an empty read; ranges and plain files behave as before. The DFS/MySQLi cluster gateway (index_cluster.php) sent a range with a single fread() and had the same result; it reads until the requested length. Test: eZFileDownloadShortReadTest (no database).

7 October 2026: content limitations of extensions in searches

Added

  • A content limitation handler can also restrict searches in a search engine that filters by the policies of the user itself, such as eZ Find: it implements ezpContentLimitationSolrHandler (solrFilter()) besides ezpContentLimitationHandler. The search extension asks ezpContentLimitation::solrFilter(), which gives the filter in parentheses, to join with AND to the other limitations of the policy. When the policy gives no access in the search the filter is ezpContentLimitation::DENY_SOLR, which matches no document, and never false: a search extension that left the policy out would filter by nothing once no policy is left. That happens without a handler, for a handler without the interface, for a kernel limitation, when the handler fails, and for an answer that is not a self-contained filter (string filters may not leave their parentheses or range brackets, nor use local parameters or nested queries). Field conditions (array( 'field' => ..., 'values' => ..., 'not' => false )) are written and escaped by the kernel (ezpContentLimitation::solrValue(), which also keeps a value AND, OR or NOT a term); a not that is not a boolean, or a value that is not UTF-8, gives no access. Guide: Content policy limitations of extensions. Test: ezpContentLimitationSolrTest, including an allow/deny matrix of mixed policies.

Added

  • site.ini [Session] CookieAlwaysAddToHttpResponse (disabled by default): with it enabled, the session cookie is sent with every response of a running session, not only when the session starts, for a load balancer that keeps a user on one server by the session id in the cookie. Same name, value, path, domain, lifetime and flags as the session cookie (a lifetime counts again from each response), sent once (not again when PHP already set it in the response), and nothing without a running session: an anonymous visitor without a session gets none. Such a response is kept out of shared caches: its Cache-Control (all its lines, read together) becomes private (public, s-maxage and proxy-revalidate dropped, max-age and the rest kept for the browser; no-store needs no private), a Surrogate-Control becomes no-store and an X-Accel-Expires 0, so no proxy, CDN or service worker stores a session id for the next visitor. Velocity's response cache stores no response that sets a cookie anyway; the role-aware HTTP cache (httpcache.ini) keeps pages without their cookies, and a page it answers before the kernel starts carries no session cookie. Off, nothing changes. eZSession::sendResponseCookie(), responseCookie(), cookieOnEveryResponse(), sessionCookie(), privateCacheControl(), privateCacheHeaders(), listsCookie().

Updated

  • The is_logged_in cookie (for HTTP caches) is sent with the path, domain, lifetime, Secure and SameSite of the session cookie (site.ini [Session] CookiePath, CookieDomain, CookieTimeout, CookieSecure, CookieSameSite), so it goes wherever and as long as the session cookie does; it was sent without flags, for the installation directory and until the browser closed. It stays readable by scripts (not HttpOnly), as scripts of cached pages read it. With CookieAlwaysAddToHttpResponse enabled it is sent with every response of a signed-in user, as the session cookie is. An is_logged_in set before this change for another path or without the domain is not removed at sign-out and stays until the browser closes; a cache then only passes that browser's requests to the server. eZSession::siteCookieOptions(); the parameters of the session cookie are available without setting them: eZSession::cookieParams(). Test: eZSessionResponseCookieTest (no database; several requests in one process as in a persistent worker).

8 October 2026: updates of persistent objects on Oracle

Fixed

  • eZPersistentObject::storeObject() wrote the WHERE condition of the UPDATE of an existing row with the long field names, while the field list, the INSERT and the lookup of the row use the short names of a database that has them (Oracle, eZDBInterface::useShortNames()). A key with a short name named a column the table does not have, and the update failed. In the kernel this is eZEnumObjectValue (contentobject_attribute_version is contentobject_attr_version), whose row is updated when a value is stored again for the same attribute version; eZURLObjectLink has the same key but no other fields, so it is never updated. The condition now uses the short names too.
  • The other queries eZPersistentObject builds name a field with a short name by it as well: ORDER BY and GROUP BY of fetchObjectList(), the SET and WHERE of updateObjectList(), the order column and condition of newObjectOrder(), and the UPDATEs of reorderObject(). A long text field with a short name that is bound as a variable (more than 2000 characters on Oracle) was written into the UPDATE as a literal instead of its bind variable. A column with a short name that is NULL came back under its short name, and a definition whose fields are plain strings stopped with a TypeError on Oracle. No kernel definition hits these today; they matter for extension definitions with short names.
  • bin/php/convertezenumtoezselection.php removed the converted enum values with its own SQL and the long column name; it removes them through eZEnumObjectValue's definition now.
  • Databases without short names (MySQL, PostgreSQL, SQLite, MongoDB) get exactly the same queries as before. Test: eZPersistentObjectShortNameTest (no database; compares every query with and without short names). Verified end to end against Oracle Database 23ai Free with the ezoracle driver: every path above works there; the UPDATE, ORDER BY, GROUP BY, updateObjectList() and newObjectOrder() failed there before.

8 October 2026: notifications for some classes only, and notify for eZContentFunctions

Added

  • notification.ini [NotificationSettings] NotificationFilterByClassIdentifier and IncludeClasses[]: with the setting enabled, only publications of the listed classes make a notification event; any other class counts as published without notification (the filter content/notification/create gets false). disabled by default, so nothing changes without it. The list takes class identifiers; an entry of digits is taken as a class ID. eZContentOperationCollection::notificationIncludesClass().
  • eZContentFunctions::createAndPublishObject( $params, $notify = true ) and updateAndPublishObject( $object, $params, $notify = true ) pass notify to the publish operation, so imports and scripts can publish without notification. Callers without the argument notify as before; false, 0 and "0" mean no, anything else yes (eZContentOperationCollection::notifyRequested(), which the publish operation's notification step uses too). Guide: Publish without notification. Tests: eZPublishWithoutNotificationTest (PN-13 to PN-15, no database), eZPublishWithoutNotificationLiveTest (PL-05 to PL-09, live, with the file mail transport set in-process).

Fixed

  • eZContentFunctions::createAndPublishObject() returned the object it had instantiated, which still said draft (status, no current version published, no main node) after the publish operation had published it; it now returns the object as stored after publishing.

8 October 2026: limits for the modes of new files and directories

Added

  • The constants EZP_DIR_MODE_MAX and EZP_FILE_MODE_MAX (in config.php, see config.php-RECOMMENDED) set the widest mode any directory and any file the installation creates may get. A mode asked for is only narrowed (0777 becomes 0750, 0600 stays 0600), and autoload.php sets the umask of the process from them, so files written without a mode of their own stay inside them as well. Without the constants nothing changes. eZFile::fileMode(), eZDir::dirMode(), eZFile::creationUmask() in place of umask( 0 ), eZFile::applyCreationUmask(); used by eZDir::mkdir(), eZDir::directoryPermission(), eZFile::create(), eZLog, eZDebug, eZINI (cache directory, cache and saved files), eZPHPCreator, eZFileHandler, the autoload generator, uploads (eZHTTPFile), image variations (eZImageHandler), the cluster file handlers and the static cache. One constant gives the other limit; a value that is no mode, or a limit that would take rights from the owner (0), is ignored (no limit, as without it) and reported once to the PHP error log. With 0770/0660 for a web server and command line scripts of different users, the directories of var/ need the set-group-ID bit of the common group (see the guide). Guide: Limits for the modes of new files and directories. Test: expFileModeLimitsTest (security suite, no database), which also fails for a new native chmod(), mkdir() or umask() that bypasses the helpers; the calls still to be moved are listed in it.

Fixed

  • With the limits set, the autoload generator creates var/autoload with a directory mode instead of EZP_INI_FILE_PERMISSION (a file mode, which left the directory without its search bit) and limits its files by EZP_FILE_MODE_MAX, which takes away the execute bits of the 0777 they got. Without the limits both stay as they were.

8 October 2026: every new file and directory within the limits

Updated

  • Every native chmod(), mkdir() and umask() in the kernel, the libraries, the scripts and the extensions of the repository now goes through eZFile::fileMode(), eZDir::dirMode(), the new eZFile::executableMode() (for the FrankenPHP binary and the scripts bin/php/console runs, limited by EZP_DIR_MODE_MAX so they stay executable) or eZFile::creationUmask(): the audit trail, content jobs, Velocity and FrankenPHP, the INI editor, mail preferences, setup and installer, HTTP cache, debug bar, maintenance, notifications, packages, templates written in the admin, shop receipts, the query cache and others; command line scripts without the .php ending are checked too. Without EZP_DIR_MODE_MAX / EZP_FILE_MODE_MAX the modes are the same as before; narrow umasks set on purpose stay at least as narrow. expAuditWriter::ownLikeParent() limits a directory as a directory and a file as a file. The list of calls still to be moved in expFileModeLimitsTest is empty, so the test fails for any new call that bypasses the helpers. The HTTP cache early exit and the repair queue, which run before the autoloader, load the helpers themselves. Files that the web server and the command line both write need one user, or one group with 0770/0660 and set-group-ID directories, once limits are set. Files written without a mode of their own keep to both limits, also for a pair that does not match (0750/0600 gives 0600 and 0710). Guide: Limits for the modes of new files and directories.

8 October 2026: the column conditions of content limitation handlers

Fixed

  • A column condition that a content limitation handler returns from permissionSQL() (array( 'column' => ..., 'values' => ..., 'not' => ... )) was inverted by any not that PHP reads as true, so 'not' => 'false' turned "only these" into "all but these", and with no values into a condition that matches every row; 'not' => 0 did the reverse. As in search filters, only a real boolean inverts now: any other not makes the answer no condition, and the policy gives no access in fetches (ezpContentLimitation::DENY_SQL). ezpContentLimitation::sqlCondition() and solrCondition() share the check. Guide: Content policy limitations of extensions. Test: ezpContentLimitationTest.

8 October 2026: fatal errors of the kernel without E_USER_ERROR

Fixed

  • PHP 8.4 deprecates passing E_USER_ERROR to trigger_error(). The kernel did so when a MySQLi query failed with the standard error handling, when the cluster gateway (index_cluster.php, ezpClusterGateway) answered with a 500, when eZExpiryHandler could not read expiry.php, and in eZDebug::writeError() while errors are handed to PHP (a command line script before eZScript::initialize(), or a message written while a log file is written). They call the new eZFatalUserError::raise(), which does what PHP did, without the deprecation: the error handler that is set gets E_USER_ERROR with the message, file and line, and the script goes on when the handler takes it (eZDebug's handler, set for every web request, logs the error and returns, as before); without a handler, or when it answers false, the script stops with an uncaught eZFatalUserError (an Error, so code that catches Exception does not take it): exit code 255 on the command line, a 500 on the web, and a persistent worker (Velocity) answers the request with a 500 and serves the next one instead of ending. Test: eZFatalUserErrorTest, which also fails for any new call that passes E_USER_ERROR to trigger_error().

9 October 2026: SOAP calls to servers with an internal certificate

Added

  • eZSOAPClient::setCAFile() trusts the CA certificates of a file of its own for the certificate of an HTTPS server, for a service whose certificate an internal CA issued. setVerifyPeer( false ) turns the check off for one client only and logs a warning on every call; by default the certificate is verified, as before. caFile() and verifyPeer() read them; curlOptions() gives the cURL options of a call. A class extending eZSOAPClient that declares members of these names has to rename them. Guide: SOAP calls over HTTPS. Test: eZSOAPClientTlsOptionsTest (no server).

Fixed

  • eZSOAPClient without the PHP extension curl sent a call over HTTPS as plain text to the TLS port; it is refused with an error now (send() returns 0, ErrorString and the error log say that HTTPS needs curl). This is a behaviour change: see Behaviour changes of 1 to 2 October 2026, "Missing libraries". Test: eZSOAPClientTlsOptionsTest (in a PHP started without curl).
  • eZSOAPClient::setCAFile(): a name with a NUL byte sets no CA file (reported), instead of a ValueError in send(); under open_basedir a CA file outside it is no longer reported as unreadable on every call, since cURL reads it anyway.

9 October 2026: leaving out a cronjob part

Added

  • runcronjobs.php asks the ezpEvent filter cronjob/part/run with the part, the siteaccess, the scripts and whether a single script runs (--script) before the first script of a part runs. A listener in site.ini [RunnableSettings] Listeners[] returns true to run the part; anything else leaves it out with a notice and exit code 0, so a stop switch for a release no longer needs a patched runcronjobs.php. A listener that throws ends the run with exit code 1 before any script runs. Without listeners every part runs as before. Also on Setup > RAD. Feature. Test: CronjobPartRunFilterTest (no database).

9 October 2026: sendmail without an envelope sender

Added

  • site.ini [MailSettings] SendmailEnvelopeSender=disabled makes the sendmail transport leave out -f <sender>. msmtp with --read-envelope-from (for instance sendmail_path="msmtp --read-envelope-from -t") refuses -f together with that option, so every mail failed there. A mail without a sender of its own (no sender address and no From extra header) then gets a From header with EmailSender, else AdminEmail, when one of them is a valid address (also written Name <address>); msmtp reads the envelope sender from it. The header is written for that one call: the eZMail object keeps the sender it had. The default enabled keeps -f and the options as before. Test: eZSendmailEnvelopeSenderTest (no database; mail() runs in a child process against a capturing sendmail_path, which the child checks before it sends, so nothing is sent).

9 October 2026: one User-Agent for the mails of the site

Added

  • site.ini [MailSettings] UserAgent sets the User-Agent header of every mail sent through eZMail (sendmail, SMTP and file transports; ASCII only), for instance the name of the portal. Empty (the default) keeps the mails as they were: Exponential, Version <version> over sendmail and file, Apache Zeta Components over SMTP. eZMail::setUserAgent() still sets it for one mail. Line breaks in the setting do not start a header. Guide: After installing, 10.4. Test: eZMailUserAgentTest (no database).

Fixed

  • eZMail::setUserAgent() reaches mails sent by SMTP. The mail library wrote its own User-Agent when it generated the headers, so the value was lost there; ezpMail now keeps the one eZMail gave it. Code that already calls setUserAgent() therefore sends its own name over SMTP from now on instead of Apache Zeta Components; over sendmail and file nothing changes. ezpMail::$userAgent is public: a value set on it directly gets the same line break cleaning as setUserAgent(), so it cannot add a header either.

9 October 2026: who trashed what, in the database

Updated

  • Who moved an object to the trash and from where are columns of the trash row now: ezcontentobject_trash.trashed_by (user content object id) and trashed_via (web <siteaccess>, cli <script>), written by eZContentObjectTrashNode::createFromNode() in the same transaction as the row. <VarDir>/trash/trashed.json, which was local to one web server and outside transactions and backups, is no longer written once the columns exist; the trash view still reads it for older rows, and update/common/scripts/6.0/movetrashrecords.php copies it into the columns. The filter by user runs in SQL. The database update of each engine adds the columns. Guide. Tests: TrashedByColumnsTest (no database), eZContentObjectTrashRecordTest (installation database).
  • Code and database update can come in either order. Until the columns exist, eZContentObjectTrashNode::hasTrashedByColumns() says so (asked of the database's catalogue, never by a query that could fail inside a transaction, again on each trash move until the columns are found, so a Velocity worker needs no restart) and definition() leaves them out: moving content to the trash stores the row without them and records who did it in the file as before, and fetching a trash row, the trash view with its filter by user and restoring keep working. Before, every move to the trash ended with "A database transaction failed" until the update had run. Test: eZContentObjectTrashOldSchemaTest (an installation database without the columns; skipped on an updated one), TrashedByColumnsTest.
  • trashed_via holds printable ASCII within 100 bytes (anything else becomes ?): a script name in another encoding is no valid UTF-8, which PostgreSQL refuses inside the trash move's transaction, and 100 multibyte characters exceed a column that counts bytes (Oracle). Test: TrashedByColumnsTest.
  • movetrashrecords.php stops with exit status 1 when trashed.json holds no JSON object (cut short, edited by hand): it was read as empty, so --remove-file deleted it with every entry. Its line of counts adds the rows whose entry names no user, so the numbers add up. An entry without node_id or trashed matches no row without a PHP warning. Tests: TrashedByColumnsTest, eZContentObjectTrashRecordTest.
  • The trash view no longer reads the whole trash for every page: TrashList asks the database for what the page needs (the trash rows among the ancestors of its items, a count on the indexed path_string for the nodes below each, counts in SQL for the summary and the user list), and ezcontentobject_trash gets the index ezcobj_trash_trashed_by (in the same update files). With 100,000 rows in the trash a page took 2.7–3.2 s and 160 MB on SQLite, now 0.54–0.90 s and 2–3 MB; the measurements are in the guide. The user list and the "known for" count of the summary count trash rows, not objects. On MongoDB the summary and the user list come from aggregations (the driver translates neither NOT EXISTS nor SUM( CASE ... ) nor GROUP BY, so they showed nothing); a path that ends in a line break is refused by belowCondition() like any other non-path. Tests: TrashedByColumnsTest, TrashListPagedTest, eZContentObjectTrashRecordTest.
  • MongoDB keeps who trashed what in the trash documents too, and its trash view works. hasTrashedByColumns() asks the driver's schema (expMongoDB::declaredColumns(), from the shipped .dba), which declares both fields; new trash documents carry them. The trash list and its count were one SQL statement with joins, which the driver cannot translate: on MongoDB the view listed and counted nothing, and so emptied nothing. They now come from one aggregation (eZContentObjectTrashNode::trashListMongo(), trashListFilterMongo(), trashListSortMongo()) with the same filters, sort keys and paging; a user whose content/read is limited by policies is shown an empty trash rather than all of it, and an AttributeFilter likewise lists nothing. The class filter's options (TrashList::classOptions(), also a join) come from two single-table reads. Nothing changes on the SQL engines. Guide. Test: TrashListMongoTest.
  • The MongoDB driver reads and compares a field that a document lacks as the SQL engines read the column: a column the .dba declares NOT NULL with a default holds that default in every row of a SQL table, also in a row written before the column was added, while a document written before then has no field, which MongoDB's $eq, $in and ranges never match and $ne and $nin always do. So "WHERE trashed_by = 0" missed every trash document older than the field, and movetrashrecords.php reported rows as given a trashed_by that its UPDATE never reached. The WHERE of UPDATE, DELETE and single-table SELECT now matches the missing field exactly when the default satisfies the comparison (=, <>, IN, NOT IN, <, <=, >, >=; IS NULL and LIKE are unchanged), and a SELECT that names such a column returns the default for a document without it, NULL for a declared column without one. Test: expMongoDBDeclaredDefaultTest (also against a real server with EXP_TEST_MONGO_SCRATCH).

9 October 2026: keeping items in the trash for a number of days

Added

  • content.ini [TrashSettings] KeepItemsForDays makes the trashpurge cronjob part purge only what has been in the trash for at least that many days. Empty or 0, the default, purges everything as before; a value that is not a whole number of days up to 36500 purges nothing and reports an error. bin/php/trashpurge.php --trashed-days=<days> is unchanged. Items trashed before 6.0 have no trash time and go on the first run (see the guide). Guide: The trash view. Test: TrashPurgeKeepDaysTest (no database). The cronjob reads the setting past the INI cache, so a value just added counts on the next run instead of the cached "purge everything".

Fixed

  • bin/php/trashpurge.php --trashed-days with a huge number of days purged the whole trash: the date overflowed into the future. Some numbers overflow into a past that is not that many days back instead, which purged against the wrong date. eZScriptTrashPurge::trashedBefore() now accepts only a date within a day of that many days back and the purge refuses anything else with an error. Test: TrashPurgeKeepDaysTest.

9 October 2026: the HTTP cache with request-shield in front

Added

  • The role-aware HTTP cache works with request-shield in front: a request it marks allow-uncached ($_SERVER['REQUEST_SHIELD']) is answered and never stored (X-Exp-Cache: BYPASS (request-shield: not for a cache)), and with set cache-unknown-query hit-only the early exit answers a request with a query parameter no key holds from the page request-shield names in REQUEST_SHIELD_CACHE_LOOKUP (X-Exp-Cache: HIT (request-shield lookup)). Without request-shield nothing changes. Under Velocity only the first applies. Guide: With request-shield in front. Test: ezpHttpCacheContractTest (HC-18 to HC-20).

Updated

  • With ContentChangePurges=tags a content change also purges rl of every node and r of every object, the tags Ibexa's HTTP cache purges, for pages an extension tagged that way. More than 10000 purged tags within MaxAge now purge every page instead, so the state every lookup reads stays small. Test: ezpHttpCacheContractTest (HC-21).

Fixed

  • The lookup of REQUEST_SHIELD_CACHE_LOOKUP is taken only when it names exactly the request's own path and parameters that QueryStringParameters[] lists: a request-shield whose cache-query left one of them out had /news?page=2&x=7 answered with /news. Its answer is Cache-Control: private, so a proxy in front keeps no made-up address, and an expired page is served to it as STALE without the lookup taking the page's refresh, which it would never store.

9 October 2026: the RAD survey's source sweep skips dependency trees

Updated

  • Setup > RAD no longer walks into tests, vendor, node_modules or .git directories when it collects the php files of the kernel, lib and the extensions. It already ignored what it found there, but it still entered them, and every check of the kept sweep looked at each of their directories again: an extension that carries the node_modules of its JavaScript build doubled the directories checked (8,558 to 4,146 on one installation), and the first load after a cache clear ran long enough to time out. The php files collected are the same.

9 October 2026: the online editor keeps an embedded gallery's view mode

Updated

  • settings/content.ini lists gallery_view_mode among the custom attributes of [embed]. The embed view of a gallery reads it (slider or grid), but the online editor keeps only the custom attributes content.ini names, so every save of an article dropped it and the gallery fell back to the grid. Installations and the site settings the installers write add their own [embed] attributes to this one, so every siteaccess gets it.

10 October 2026: the content finder

Added

  • Setup > Content finder (/contentfinder/search, kernel module contentfinder) and a search box below the content structure in the Content tab: find the content object behind an object, node, attribute or version id, a remote id, a URL alias, a page URL, or the path or URL of an image, binary or media file below var/ (exactly, or every file below a prefix). Each result shows the object's metadata, every version with a link to the version view, every location with its URL alias, and its trash entry (where it was, when and by whom it was trashed). From the result an object can be moved to the trash, deleted permanently, purged from the trash or restored, and a version removed, each through a confirmation page that lists every location, the nodes below them and the versions; what cannot be undone needs an explicit tick. The actions use the kernel's own operations (content/delete, the trash service's purge, content/restore, version removal as in the history).
  • The policies contentfinder/search (use the finder) and contentfinder/delete (its actions). Each result shows only what the user's content policies allow (content/read, content/versionread, content/restore or content/cleantrash), and each action needs its content policies as well; something the user may not see is answered exactly like a miss. Only the Administrator role holds the new policies in a new installation.
  • settings/contentfinder.ini (Enabled, SiteAccessList[], ResultLimit, FileRowLimit): the finder answers on administration siteaccesses only.
  • The audit events content.finder.search and content.finder.action (channel content, on by default): every search with its type, text and number of results, every action done or refused, with the kernel's removal events as its children. The catalogue has 143 names.
  • German translations of the finder. Feature. Tests: expContentFinderInputTest (no database), expContentFinderSandboxTest (writes, so only in a throwaway copy named by EXP_CONTENTFINDER_SANDBOX); --testsuite kernel-contentfinder.

10 October 2026: a per-request file named in config.php

Added

  • index.php runs the file that config.php names in the constant EXP_REQUEST_PREPEND_FILE at the start of every request, after the settings and before the kernel. Under a persistent application server (Exponential Velocity) config.php runs once per worker, so code there cannot act on each request; this is the place for a request filter in front of the site. Only config.php can name the file. Without the constant nothing changes.

Updated

  • config.php-RECOMMENDED documents EXP_REQUEST_PREPEND_FILE, with request-shield under Velocity as the example, names the product Exponential in its comments and gives the real default of CLUSTER_HEADER_X_POWERED_BY.

11 October 2026: the content finder by the address a visitor sees, on every database engine

Updated

  • The content finder finds a page by the address a visitor sees on a siteaccess with a PathPrefix: /site/showcase/test-product, its full URL and the bare showcase/test-product now find the alias stored as fit-healthy/showcase/test-product. The siteaccess is matched as the kernel matches it (URI, host, host and URI), its own PathPrefix is put in front (honouring PathPrefixExclude[]), and a bare alias is tried with each distinct prefix after the exact match; Found by names the siteaccess and prefix. Tests: expContentFinderURLAliasTest.
  • The content finder matches remote ids and file paths in their case on every database engine: MySQL and MariaDB compare without case, and so did a file prefix on SQLite. Its database tests run on MySQL/MariaDB, PostgreSQL, Oracle and MongoDB too (in a throwaway database named by EXP_CONTENTFINDER_SANDBOX_DATABASE); the test fixtures use single-table queries. Tests: expContentFinderSandboxTest CS-12.
  • The content finder finds a URL alias by its last part (test-product) or a shorter tail (showcase/test-product) when nothing else resolves: every alias element with that text, checked upwards against the rest of the path, listed with its full path; old addresses (history) only when nothing current matches, and marked. Exact comparisons only. Tests: expContentFinderSandboxTest CS-13.
  • MongoDB: the driver translates LIKE '…' ESCAPE '!' (the content finder's file prefixes found nothing) and LOWER( column ) = '…' / UPPER; the persistent object fetch on MongoDB compares a LOWER( login ) condition in the server, so eZUser::fetchByName() and fetchByEmail() find the user again; eZURLAliasML::fetchByPath() (and fetchNodeIDByPath()) reads a path one element at a time instead of the join the driver refuses. Tests: expMongoDBWhereTextTest, expContentFinderSandboxTest on MongoDB.
  • The content finder finds a URL alias by a part of it when nothing else matches: product, test or test product find fit-healthy/showcase/test-product (words in order, any of -, _ and space between, without case; %, _ and quotes are text). Current entries first, history only when nothing current matches; the closest matches first; at least 3 characters (with a hint below that). New contentfinder.ini settings PartialAliasSearch, MinimumPartLength, PartRowLimit. The MongoDB driver translates LOWER( column ) LIKE. Tests: expContentFinderURLAliasTest CU-06, expContentFinderSandboxTest CS-14, expMongoDBWhereTextTest.
  • The content finder guide lists PostgreSQL 13, MongoDB and Oracle 26ai among the tested databases, with the cost of the part search on each. Feature.

11 October 2026: a JSON format for packages

Added

  • A package can be kept as package.json and JSON documents instead of package.xml and XML documents. Every XML document of the package (the definition, the files of its items, the object files) is mirrored element for element in JsonML, rich text kept as its XML string, so the package handlers and datatypes read the same DOM from either format and a package converts from XML to JSON and back without loss. The classic format stays the default; a classic package, its archive and its installation are unchanged. share/package/schema/package-v1.json is the JSON Schema of the format (expJsonSchemaValidator), and package.json carries the SHA-256 of every file.
  • expPackageJson (mapping, conversion of directories and archives, validation, checksums) and expPackageConflictReport (what an install meets here, by remote id). eZPackage exports in either format, detects the format of an import by its content and keeps package metadata (<metadata><entry key>) for tools.
  • Install as drafts, a generic install option: content objects become drafts, nothing is published and no published object changes; an existing object gets a new draft or is left alone, an object of another class is a conflict; other items are listed instead of installed.
  • ezpm export --format=json, ezpm convert, ezpm validate, ezpm check and ezpm install --as-drafts.
  • Setup > Packages: Download as JSON, the package's format and metadata, the install page's report by remote id and the option to install as drafts. The upload check and the package catalog accept package.json.
  • settings/package.ini [PackageFormatSettings] (RawXMLDatatypes[], MaxDocumentBytes). Guide: The JSON package format. Test: expPackageJsonTest.

Updated

  • The JSON package format page shows rich text without the editor's namespace URIs.

11 October 2026: headers an extension decides per page in the HTTP cache

Added

  • The filter event httpcache/store/headers: a listener (site.ini [Event] Listeners[]=httpcache/store/headers@<callback>) gets the headers the role-aware HTTP cache keeps with a page when it stores it, and returns them with its own added. A page answered from the cache is assembled without the kernel, from the headers stored with it; those were only the content type and the security headers, so a header an extension adds for one page (the content license of xrowmetadata, for example) was missing on every cache hit. Guide: Headers an extension adds to a page.

11 October 2026: Exponential Velocity streams, deploys without a needless reload, and starts each worker clean

Added

  • settings/velocity.ini [ServerSettings] documents StreamTimeout (seconds a stream may run instead of the request timeout; engine default 300), StreamShare (the share of the workers event streams may hold; 0.25), MaxStreams (the same as a number of workers) and RetryWithin (seconds within which a GET whose worker died is run again, once; 10), and exp:velocity passes them to the engine. All are empty by default, so the engine's own defaults apply. Past the stream cap an event stream is answered 503 with Retry-After, so pages keep the rest of the pool. Guide: Streaming responses.
  • [DeploySettings] WarmUpURL[]: the last step of exp:velocity deploy requests each URL once, one after the other, from a detached background process, so the first visitor does not rebuild the compiled templates and the INI, override, translation, design and packed-file caches. The deploy does not wait for it.

Updated

  • [DeploySettings] PhpFpmReload=auto (the default) skips the PHP-FPM reload when the pool's OPcache revalidates files (validate_timestamps on, revalidate_freq at most 60 s) and waits revalidate_freq plus one second instead, so the rendered-output caches are still cleared only after PHP-FPM runs the new code. A graceful reload waits for the longest request on every pool of that PHP version, which made the first admin request after a deploy take 50 to 100 s. always and never are the other values; deploy --fpm-reload forces a reload (php.ini or pool changed, new extension, files removed or renamed in place). Guide: Deploying.
  • The Velocity warm-up clears the response it rendered (its state, the response object, the headers and the status) before the workers are forked. The first request of every worker began with the front page's headers already set, so a sign-in page, an access-denied answer or a JSON call could go out with the front page's content license, X-Exp-Cache: MISS and Cache-Control.
  • The users and access services of expservices declare their lists in the class body instead of assigning them after the class: a persistent Velocity worker puts a class's statics back to their declared value before its next request, so from a worker's second request on those 207 services answered "not declared". Test: expUsersDeclarationsTest. Guide: Backend ezjscore services.

11 October 2026: read services as tools of the assistant

Added

  • A service declaration of expservices may carry 'ai' => true (or 'ai' => array( 'summary' => '...' )): the assistant of Exponential Intelligence may then call it. One declaration serves HTTP (ezjscore/call), MCP (tools generated from the catalogue) and the assistant, each with the caller's own permissions. Services stay private to the assistant by default, and a service that writes is never offered; the catalogue reports the flag as ai. Opted in, as reads checked by the content's own read access: expnode childrenNames, childrenCount, breadcrumb, tree, latest and find; expsection list and ofNode; expstate groups and ofObject; exptags roots, children, tree, search and ofnode. Guide: Backend ezjscore services.

11 October 2026: the online editor's engine page

Updated

  • ezoe/engine lists every entry of ezoe.ini [EditorSettings] Engines[] with its name, version, description and status (available, in use now, not available because its files are missing, or why it cannot be used), so an engine an extension registers (Tiptap, for example) shows up with what it is. An engine may answer the optional version() and description(); expOEEditorEngineBase answers both from [Engine_<id>] Version and Description, and the TinyMCE 3 and 8 engines read their version from the bundled script. English and German translations. Guide: The online editor engines. Tests: expOEEditorRegistryTest, expOEEngineViewTest.

11 October 2026: fixes found by the CI workflows

Updated

  • The setup's package download and the Solr check read the HTTP status on PHP 8.0 to 8.3 too: they open the URL with fopen() and take the headers from the stream's wrapper_data. On those versions a failed package download said "HTTP 0" and the Solr check called a running Solr unreachable.
  • The class-based views content/versionview, error/view and user/forgotpassword read their optional view parameters (FromLanguage, ExtraParameters, HashKey) from the view's scope, which static analysis can follow; no change in behavior.
  • Tests that expected old contracts: the REST layer's OAuth token test expects the Bearer scheme it reads since the personal API keys (and that a personal key is never taken as an OAuth token), and the older MongoDB adapter test expects the statements the driver translates now.
  • The eng-US, German and untranslated translations carry the Syndication tab, the debug quick settings of the administration and the content finder's Setup entry and page path, so the debug output lists no missing translation for them.

12 October 2026: the autoload generator leaves out the tests of every extension

Fixed

  • bin/php/ezpgenerateautoloads.php -e and -o took the tests/ directory of every extension into var/autoload/ezp_extension.php and ezp_override.php. The exclusion compared the extension's path as the root gives it (extension/foo, relative) with the files, which are listed under their real path, so it never matched. A test bootstrap that defines stand-ins for eZDB, eZINI or eZSys then became a kernel override, and with EZP_AUTOLOAD_ALLOW_KERNEL_OVERRIDE the site loaded those stand-ins. eZAutoloadGenerator now anchors the exclusion at the real path of the extension, as it already did for --exclude and .autoloadignore. Run php bin/php/ezpgenerateautoloads.php -e (and -o where kernel overrides are allowed) once after the update. Test: eZAutoloadGeneratorTest.

Updated

  • The exclusion names the directory itself: tests/ of an extension followed by the separator or the end of the path, so tests_old/, testsuite/ or a file tests.php stay in the arrays. An extension whose path does not resolve is skipped (it lists no files anyway) instead of getting an expression built from the path as given; --exclude and .autoloadignore quote that fallback too. Test: eZAutoloadGeneratorTest.
  • tests/ and Tests/ of a Composer package vendored inside an extension (extension/<name>/vendor/<vendor>/<package>/tests) are left out of ezp_extension.php and ezp_override.php as well: test cases and fixtures of that package, never runtime classes, and not in the package's own Composer autoloader either. Test: eZAutoloadGeneratorTest. On one installation the extension array went from 2481 to 2272 entries: 154 classes of extensions' tests/ and 55 of a vendored package's tests left it, nothing else changed.
  • Test classes load through a tests autoload instead of the runtime arrays. ezpAutoloader::enableTests() adds the test array var/autoload/ezp_tests.php (ezpgenerateautoloads.php -s), and ezpAutoloader::enableTests( $dir ) the classes declared under a tests directory, read there and then (eZAutoloadGenerator::testsClassMap()), so a class added a moment ago is found without generating anything. The kernel's PHPUnit bootstrap (tests/bootstrap.php, also used by tests/runtests.php) turns it on; ezexec.php turns it on for a script that lies in a tests/ directory (php bin/php/ezexec.php extension/foo/tests/bar_test.php finds the classes of extension/foo/tests); an extension's own PHPUnit bootstrap calls it with its tests directory. The site itself no longer loads ezp_tests.php; define( 'EZP_AUTOLOAD_TESTS', true ); in config.php loads it everywhere as before. Tests: eZAutoloadGeneratorTest.

Operator note

Run php bin/php/ezpgenerateautoloads.php -e once after the update (and -o where config.php allows kernel overrides with EZP_AUTOLOAD_ALLOW_KERNEL_OVERRIDE), then reload PHP-FPM and restart Velocity (./console exp:velocity deploy does all of it). Check afterwards that grep -c "/tests/" var/autoload/ezp_extension.php prints 0. Test scripts and suites of an extension that relied on their classes being in ezp_extension.php load them through the tests autoload above; run ezpgenerateautoloads.php -s where the kernel's own tests are run.

12 October 2026: a user account that exists only as a draft

Fixed

  • The account of a user object whose ezuser row is not stored yet, but whose edit left a draft (login, e-mail address, password hash) in the attribute, now shows that draft: eZUserType::objectAttributeContent() returns a new account with the draft applied instead of null, so the edit form keeps what was entered and the object has a title. Without a draft it stays null as before. Storing the first version of such an account and publishing its draft create the ezuser_setting row that a new account needs to sign in, enabled, unless it has one already (a disabled one stays disabled). Test: eZUserDatatypeDraftTest (no database).

Updated

  • The setting row of a new account follows one decision wherever it is made (the default account of a user object without one, the first version of an account, a published draft): an existing ezuser_setting row is never changed, so an account an administrator disabled, or one waiting for its activation, stays disabled. The default account used to store a new, enabled setting row over an existing one each time an object without an ezuser row was stored. A new row is enabled, as before, and disabled when the account has an account key (ezuser_accountkey, waiting for activation). Activation itself is unchanged: user/register disables the row and stores the key ([UserSettings] VerifyUserType), user/activate enables it.
  • A draft becomes a new account only with a login, and only when no other account has that login (or its e-mail address, where RequireUniqueEmail=true and AuthenticateMatch includes email), the checks of the edit form. The draft may be older than the account that took them, or not come from the form at all: a copy of another user's object carried that user's draft (the published attribute keeps one after user/password), and storing the copy made a second account with the same login and password, able to sign in. Such a draft is not applied: the object keeps no account, the draft is cleared and the reason is logged as an error.
  • A copy of a user object into a new object (content/copy) leaves the original's draft behind (eZUserType::initializeObjectAttribute()); a new version or translation of the same object keeps it.
  • What counts as a draft is narrower: the four fields as strings, numbers or null (not true/false), a hash type that eZUser knows (PASSWORD_HASH_*), and a plaintext "hash" only on a site with HashType=plaintext, where on any other site it would set a password nobody chose. Anything else in data_text (invalid JSON, a list, a serialized PHP array, a login of blanks) makes no account and writes nothing; publishing it created a setting row for an object without account. The draft holds the password hash, never the password.
  • Telling a new account from a stored one when the first version is stored keeps its lookup of the ezuser row by its primary key: the eZUser in hand does not say whether it was read or made, and the first version is stored only while the account is created. The login (and e-mail) check and the account key lookup run only for a new account. Tests: eZUserDatatypeDraftSecurityTest (no database), eZUserDatatypeDraftTest.

Operator note

Nothing to run. User objects without an ezuser row, or accounts without an ezuser_setting row, are left as they are: an account without a setting row cannot sign in (the sign-in fails closed) and user/setting cannot change it either, so check for such accounts after importing users by SQL and give them a setting row on purpose. Setting rows that belong to no object (left by an import or a removal outside eZUser::removeUser()) grant nothing; a new user object whose id one of them has keeps that row as it is, where the default account used to enable it.

12 October 2026: faster Setup > Extensions, Info > About and audit dashboard

Updated

  • eZExtension::extensionInfo() no longer walks an extension's whole tree on every call to find its newest file ("Modified" on Setup > Extensions). The walk stats every file (52 000 files for 85 extensions here, close to a second) and Setup > Extensions, Info > About, the Online Editor dialogs and the update inventory asked it on every view. eZExtension::newestFileTime() keeps the result in <cache>/exp/extension-file-times.json for an hour and walks again before that when the active extensions cache is cleared (ezcache --clear-tag=ini, --clear-all, exp:velocity deploy, a change on Setup > Extensions); the extension's own extension.xml, ezinfo.php and composer.json are looked at every time. Setup > Extensions went from 1.0 s to 0.3 s, Info > About from 0.6 s to 0.1 s. Test: eZExtensionNewestFileTimeTest.
  • The audit dashboard sums the records per channel of today and of the week from the per-day counts its chart reads, instead of two groupings that read the whole index table on SQLite (half a second each with 240 000 records). expAuditQuery finds a family of event names (access.*, system.expai.request.*) on SQLite through a range of the name index together with the LIKE, which SQLite cannot plan with that index: the AI panel's counts went from 280 ms each to 2 ms. Other engines keep the LIKE. A dashboard built anew (its figures are kept a minute) takes about 1.5 s instead of 4 to 8 s. Test: expAuditDashboardVolumeTest.

12 October 2026: one count of extensions on the dashboard, Info > About and the update dashboard

Fixed

  • The administration showed three different numbers of extensions for one installation: the dashboard 61, Info > About 63 and the update dashboard "85 Extensions, 64 active", each counting something else under the same word. The dashboard counted ActiveExtensions only; About added the siteaccess's own ActiveAccessExtensions (here two extensions, switched on for the admin and editor siteaccesses only); the update dashboard counted every directory in extension/ and called active what any siteaccess switches on (adding exp_adminui, active in the adminui siteaccess only). All three now take their figures from eZExtension::extensionCounts() and say what they count: "63 active in this siteaccess, 85 on disk" on the dashboard and About, "85 Extensions on disk, 64 active in any siteaccess" on the update dashboard and its installed packages page.

Added

  • eZExtension::extensionLists() and eZExtension::extensionCounts(): the extensions of the installation by kind, on_disk (every directory of every extension root, ExtensionDirectory and AdditionalExtensionDirectories, each name once), global (ActiveExtensions), here (active in the current siteaccess, in loading order), anywhere (active in at least one siteaccess of AvailableSiteAccessList), inactive and missing (named in the settings, not on disk). Every active kind counts extensions found on disk only. Template fetch: fetch( 'setup', 'extension_counts', hash( 'all_siteaccesses', true() ) ); without all_siteaccesses the kinds that read every siteaccess's settings (anywhere, inactive) are left out, which keeps the dashboard at 0.5 ms for the count. Test: eZExtensionCountsTest.

Updated

  • Info > About lists the extensions of eZExtension::extensionLists() here, in the loading order the kernel uses (ExtensionOrdering), where it listed ActiveExtensions and then ActiveAccessExtensions.
  • The update dashboard's inventory reads every extension root, and an extension named in the settings but not on disk is listed under "Need attention" only, no longer counted as an extension or as active.

Fixed

  • ezinfo/about and ezinfo/copyright had no default_navigation_part, so the administration fell back to the first navigation part and highlighted "Content structure" with the content tree as the left menu. Both views now name ezsetupnavigationpart: the Setup tab is selected and the Setup menu is shown, as on the other pages about the installation. ezinfo/is_alive answers with plain text and is unchanged.

12 October 2026: Exponential Intelligence in the distribution

Added

  • composer.json requires se7enxweb/expai ~0.0.1.0: Exponential Intelligence, the writing assistant of the administration interface (feature page, its own documentation in the expai repository). Composer puts it in extension/expai; it stays inactive until it is activated for the admin siteaccesses, its tables are created and a provider is configured. It writes drafts as the signed-in editor, with that editor's permissions, and never publishes on its own.
  • The job queue se7enxweb/expqueue ~0.1.0 (required since 11 October), which runs the queued work of Exponential Intelligence and of any extension that registers job types (feature page).
  • The kernel parts the assistant builds on are generic and documented on their own: extension sources of the audit console (below), registries declared in rad.ini (below), read services opted in with 'ai' => true (11 October), install as drafts and the JSON package format (11 October), the event package/install/drafts (below), and the marked version in the admin4 node header (below).

12 October 2026: Setup > Packages end to end

Added

  • Validation of every upload before it is imported: each document well formed, the JSON documents against the schema, the checksums of package.json. A package that fails is refused with the list of what failed; the result is shown on the package's install and view pages until the next action on it. Validate on a package's page runs the same validation (a POST, then a redirect back) and shows what was checked or every problem.
  • Install in the background: the content job type packageinstall (expContentJobPackageInstall, content.ini [ContentJobSettings] JobTypes[packageinstall]) installs a package with the wizard's choices as the requesting user. The install page offers it, and suggests it above the content jobs' SynchronousLimit; the package's page shows its progress object by object and lists the drafts when it is done.
  • The install page's report lists every object with what the install does with it and where its main location goes (at the top, below a node here, below a node of the package, or below a missing one), with the counts of each.
  • The notify event package/install/drafts (eZPackage::notifyDraftsInstalled()) after every install as drafts: the admin's, eZPackage::install() (ezpm) and a background install, so an extension can mark or record the drafts. For the command line and the background worker the listeners of this event are attached from site.ini [Event] Listeners[].
  • The contents browser lists JSON documents by their real kind (Package definition, Content object, Content class) and shows them as JSON or as the XML they mirror, and an XML document as JSON; long lines wrap, with a No wrap switch. What a file is comes from its content: PNG, JPEG, GIF and WebP images are shown inline, SVG only as text, any other binary as a card with its type, size, SHA-256 and whether it matches package.json. package/viewfile sends only such images inline, everything else as an attachment.
  • New package asks in its information step which format the package is kept in (Classic XML or JSON); a JSON package is converted in place when the wizard finishes (expPackageJson::convertInPlace()).

Updated

  • The content handler reads the remote id of a per-object file of any length (only an md5's 32 characters were read, so objects with readable remote ids missed their post-install step) and reports its progress to an install that asks for it.
  • The documents of a package (its license) open in the package's contents browser (package/view/full/<name>/<repository>/(file)/<index>) instead of a link into the storage, which the web server answered with 404. Guides: Packages, The JSON package format. Test: eZPackageFileBrowserJsonTest.

12 October 2026: the audit console's extension sources, a streamed and background export, repeated records

Added

  • Sources of extensions: an extension that records its own audit events registers a source in audit.ini [AuditSourceSettings] Sources[<identifier>] with a class implementing expAuditSource (best extending expAuditSourceBase). The dashboard shows a panel per readable source (cached a minute per user); console, recent, charts, alerts and export show a line of its links; the charts draw its series per day; an event of its channel shows the source's fields and links; alerts name the source of a rule; Setup of the audit lists the registered sources and what keeps one from working; the Channel limitation of audit/read names an extension's channel after its source. Without a registered source every view is as before. Exponential Intelligence (channel ai) is the reference source.
  • A source declares its own CSV columns (expAuditSource::exportColumns()), added after the generic ones to the CSV of its channel in audit/export and exp:audit export --channel=<channel> --format=csv.
  • The audit export runs as the content job auditexport (expContentJobAuditExport, "Export in the background", any of the three formats), up to [AuditConsoleSettings] MaxBackgroundExportRecords (1000000). The export page lists the user's background exports with their state and progress and offers the finished file at audit/export/(download)/<id>, only to the user who asked for it, recorded as system.audit.read with the job.
  • fetch( 'audit', 'hidden_menu_links' ): the audit left menu leaves out a link into a channel the user may not read (an audit view filtered on the channel, or a link that menu.ini names with AuditChannel_<key>=<channel>).

Updated

  • The download of audit/export is streamed (expAuditExportStream): the file starts at once and is sent while it is read, also from a Velocity pool worker (X-Accel-Buffering: no). A large filter is read in order by id in segments instead of OFFSET pages: the content channel's CSV (26,889 records) took 38.3 s on Apache and was a 504 on Velocity; it now starts within about a second and ends in 3.3 to 4.1 s, with the same bytes.
  • A process forked with pcntl_fork() no longer writes its parent's buffered audit records a second time: the child drops its copy on its first event, flush or reset and takes a request id of its own, and keeps the job, the run and the parent stack. Before, the same records reached the channel once per child, each copy with a new seq and hash.
  • exp:audit verify reports lines that repeat a record id as a repeated_ids notice per file (not a break) and prints the distinct count next to the record count; exp:audit export leaves a repeated id out, as the index does.
  • A test run writes its audit records into a directory of its own under var/tmp (expAuditConfig::setTestLogDir(), set by tests/bootstrap.php), never into the site's live audit trail.
  • The alerts of the audit dashboard break rule names only at their underscores and say "1 address" and "1 problem" for one. Guide: The audit guide (extension sources, streaming the export, repeated records). Tests: expAuditSourceTest, expAuditExportStreamTest, expAuditForkTest.

12 October 2026: registries declared in rad.ini

Added

  • setup/rad counts the registries that the kernel and extensions declare in rad.ini: [RegistrySettings] Registries[] and a [Registry_<key>] block naming the INI blocks (Ini, Section, Entries[<variable>]=<check>) or the class list (List, Implements). The survey counts every entry, checks it (a class or interface, callable, listener, template, section:, in:, one-of:, name) and shows a failing entry or declaration as broken; InTotal=disabled lists a registry without adding it to the total. Only active extensions are read. settings/rad.ini declares the kernel's event listeners, cronjob part scripts and audit console sources.
  • The survey counts every policy limitation of every module, with its class, and its event sweep finds events announced on a variable or property holding ezpEvent (package/install/drafts and others); the catalogue has the mechanisms registry and filter.

Updated

  • ezjscore (server functions, key ezjscserver) and ezoe (editor engines, key ezoeengines) declare their registries in their own settings/rad.ini.append.php; the survey's code no longer names them.
  • A later file's Registries[] (or Sections[] in a registry's block) without a value empties the list, as it does in the settings.
  • rad.ini declarations are no longer counted as settings. Guides: The RAD extension surface, RAD extension points. Tests: RadSurveyDeclaredRegistriesTest, RadSurveyRegistriesTest.

12 October 2026: Setup > System information under Velocity

Updated

  • Setup > System information shows the sizes of one shared measurement: var/, each cache directory and a database on a server are measured in one walk (within ten seconds) and kept for ten minutes in var/<site>/cache/exp/system-report-sizes-<key>.json, shared by PHP-FPM and Velocity. Measure again measures anew (posted with the form token), and the note under the buttons says when the sizes were measured. The page took about 2 s under Apache and 4 to 7 s under Velocity on every view. Each part of the report is a debug accumulator.
  • The Velocity box reads the process table once and sends its own server no health check request (expVelocity::status( false ), expVelocity::reuseProcessTable()); it took 0.4 to 0.7 s of every view.
  • The page lists a closure autoloader by its file and line: under Velocity spl_autoload_functions() includes a closure, which the template could not print, so the page answered 500 on the Velocity port. Guide: System information. Test: expSystemReportTest.

12 October 2026: smaller fixes in the administration

Updated

  • The PDF export's table operator counts its parameters (count( $operatorParameters ) > 2): it counted a boolean, which PHP 8 refuses with a TypeError, so every PDF export with a table answered 500. Guide: PDF export.
  • user/preferences/set opened directly, from a bookmark or from another site (Sec-Fetch-Site none or cross-site) shows a confirmation (user/preferences_confirm.tpl in design standard, admin and admin4) whose form posts with the form token, then stores the preference and returns to the page the address named. Before, it stored nothing and redirected to the front page. A click on the site's own link stores it at once, as before.
  • The show/hide tabs button of the content view shows the icon of the state the tabs are in (admin, admin3, admin4); the two images were swapped.
  • The version in the header of a node (admin4) is marked (span.exp-object-version with data-object-id, and span.exp-object-version-number), so a page script can show a newly published version without a reload.
  • The extension file time cache sets its file mode through eZFile::fileMode() alone, so the site's mode limits apply.
  • The editor siteaccess the setup makes keeps the node view tabs of its access extensions: its AdditionalTabs[] list (roles, policies, eztags, authors) is followed by the tabs each extension of the admin's ActiveAccessExtensions[] declares in its own settings/admininterface.ini.append.php (eZStepCreateSites::editorAdditionalTabs()). The editor's list starts empty and an access extension's settings are read before the siteaccess's, so expai was switched on there without its Intelligence tabs. Test: EditorSiteaccessTabsTest.
  • An installation without a package language map (exp:install, the kickstarter) falls back to the language of site package content that is in none of the chosen languages: the language is added after the clean data's to SiteLanguageList (no siteaccess is made for it) and exists before the import, so the import and the site package's steps find that content (eZStepCreateSites::contentFallbackLanguages(), reading every object of the content packages with eZContentObjectPackageHandler::objectLanguageLists()). The CJW Multisite starter content is in ger-DE with English translations of a fifth of it; installed with eng-US alone, the install logged 125 errors ("pathWithNames() failed to fetch path" for every image, the starter's hidden folder and privacy page "not installed") and the site could not show that content. Test: SetupContentFallbackLanguagesTest.
  • The response cache clear that switching maintenance on starts (exp:velocity cache clear, a process of its own) carries the setup run's id when a setup run starts it (EXP_SETUP_RUN), and its audit entry looks no user up then (expAudit::canLookUp()): it ran with the settings of before the installation, and every installation logged four failed connections to the database those name. Test: SetupRunCommandAuditTest.
  • A file attribute of a type without a text extraction handler (a video, an image, an archive) is indexed by its name alone with a notice, not a warning ("Mimetype video/mp4 not supported for indexing" on every publish of the multisite demo content's videos); a handler that is configured but missing still warns.

12 October 2026: the main branch resolves as 6.0.x-dev

Updated

  • composer.json carries the branch alias "dev-main": "6.0.x-dev". Installed from main, the root package was dev-main, which satisfies no version constraint, so a package that requires se7enxweb/exponential ^6.0.14 (expqueue 0.1.0 does) could not be resolved and the dependency installs of the test and quality workflows stopped. With the alias the constraint accepts the main branch. Guide: Getting the code.

12 October 2026: tests read their servers from the environment

Added

  • expAuditConfig::siteLogDir() names the directory the site's own requests write their audit records into ([AuditSettings] LogDir), also while a test run writes into a directory of its own. Test: eZSiteLogReadersTest.

Updated

  • The MongoDB integration tests (expMongoDBIntegrationTest, sevenxMongoDBIntegrationTest) read their servers from the environment and skip when they are not set: EXP_TEST_MONGO_URI and EXP_TEST_MONGO_DATABASE (default exp), and EXP_TEST_MYSQL_USER, EXP_TEST_MYSQL_PASSWORD, EXP_TEST_MYSQL_DATABASE, EXP_TEST_MYSQL_HOST (default localhost) and EXP_TEST_MYSQL_PORT (default 3306). They carried an account name and password of their own before; an installation that used those values for a real account should change that account's password. bin/mongodb/import_all.sh needs MONGO_URI set, it has no default connection string any more.
  • ApiKeyLiveTest takes the administrator's password only from APIKEY_LIVE_ADMIN_PASSWORD and skips the administrator's revocation (AL-06) without it.
  • The live API key tests (ApiKeyLiveTest, RestContentPermissionLiveTest) count the refused uses the web servers recorded in the site's audit directory again (expAuditConfig::siteLogDir()). Since a test run writes its own records into a directory under var/tmp, they read that directory instead and found none ("one scope refusal per server, 0 is identical to 2"); the servers had recorded every refusal. Guide: Tests that need a live server.

10 to 12 October 2026: Exponential Layouts

Updated

  • explayouts 1.4.22, explayouts_core 1.0.7 and explayouts_ui_api 1.3.15 (required since 10 October): Copy in the legacy layout list and Duplicate block in the layout editor copy a block with its collection items, its dynamic query with parameters, offset and limit, its item view type and, for a container, its child blocks with their own collections.
  • explayouts 1.4.23 and explayouts_ui 1.3.14 (required since 12 October): the layouts lists (dashboard, layout list, shared layouts) show the minutes of the modification time, 2026-10-04 01:17 instead of 2026-10-04 01:Oct (the date format used %M, the abbreviated month, where the minutes are %i).
  • explayouts 1.4.24 (required since 12 October): the legacy layouts administration pages select the Layouts tab instead of Setup, the pages that render in the administration layout (Dashboard, Setup, Layouts, Edit Layout, Edit Block, Layout Rules, Edit Rule) show the Layouts menu in the left column, and their breadcrumb starts at "Layouts", linked to the layouts dashboard ("Layouts / Dashboard", "Layouts / Edit Block"). The template editor renders its page through the template engine (it listed nothing before), reads and writes only .tpl files inside [TemplateEditorSettings] AllowedTemplateRoots[], also after symbolic links are resolved (earlier versions accepted any file name under extension/: update from 1.4.23 or earlier), loads and saves through the current siteaccess, and no longer writes a debug log on every failed read.
  • On the main branches of explayouts_ui and explayouts_ui_api, after their releases 1.3.14 and 1.3.15, so in their next ones: the same breadcrumb starting at "Layouts" and the Layouts menu on Setup and Edit Block of the Layouts UI pages, and the layouts editor and its JSON views name the Layouts navigation part. Guide: Exponential Layouts.

12 October 2026: uncaught errors under Velocity, the rich text editor's upload form

Updated

  • An uncaught exception or error under Exponential Velocity gets the site's error page (error.ini StaticErrorPage[500]) with its reference, and the line in error.log, as under PHP-FPM. eZExecution::registerShutdownHandler() installs defaultExceptionHandler again whenever no exception handler is in place (eZExecution::currentExceptionHandler()): a persistent worker takes away the handler a request installed once the request has ended, while the class's statics keep the flag that it was installed, so from a worker's second request on such an error got the server's own error page and was written only to Velocity's console log. A handler other code installed is kept. Test: eZExecutionExceptionHandlerTest.
  • The upload view of the rich text editor reads its text fields (uploadUrl, location, objectName, ContentObjectAttribute_*) through Upload::postString(): a field sent as a list (name[]) counts as empty, where it was a TypeError and HTTP 500. Test: expOEUploadPostStringTest.
  • Upgrade note: a changed kernel class reaches Velocity only with a restart (exp:velocity deploy); until then its workers keep the classes they had loaded. A real PNG uploaded from the editor's dialog answered 500 on the Velocity port while the search index's new integer range check (eZDBInterface::clampInteger()) was on disk and the workers still had the database classes from before it; Apache, which loads every class again on each request, was not affected. The upload works on both servers after the deploy.

12 October 2026: clean-up handlers and shutdown functions for each request under Velocity

Updated

  • Under Exponential Velocity's persistent workers every request gets its own clean-up and fatal error handlers and its own shutdown functions (eZExecution's clean-up handler and eZSession::stop()), as under PHP-FPM. A worker puts the statics back to what they were after its warm-up before each request, and the flag that the shutdown functions were registered was among them: from a worker's second request on none was registered (no clean-up and no error page after a fatal error), and every request ran the warm-up's handlers beside its own. The handlers, the clean exit flag and the shutdown functions now belong to one request (eZExecution::requestKey(), the request's REQUEST_TIME_FLOAT); a handler added twice in one request runs once; a shutdown function left by an earlier request (the warm-up's) does nothing (eZExecution::runForRequest()). The cluster file handler's cleanup handler, the SQL profile, the query cache's counters, the log context's repeat counts and the audit's handlers are registered per request the same way. Nothing changes under Apache/PHP-FPM or on the command line. Test: eZExecutionPerRequestRegistrationTest. Guide: Velocity.
  • Upgrade note, for developers: a registration that must happen once per request is guarded with eZExecution::requestKey(), not with a true flag, which a worker keeps from its warm-up.
  • Exponential Velocity 0.0.4.51 ends the warm-up's request itself before it forks its workers, for every application on the engine, not only this kernel: the shutdown functions the warm-up's render registered are dropped without running, and its headers, error and exception handlers, session, ini and environment changes and open output buffers are put back. A worker's first request (with ForkPerRequest, every request) used to run the warm-up's shutdown functions beside its own; eZExecution::runForRequest() stays as the kernel's own guard. A request's $_SESSION now also ends with the request, as under PHP-FPM.

13 October 2026: the location in the version preview

Fixed

  • The version preview (content/versionview) of a version with more than one location marks the location it shows: the templates of every design read $placement, which the view did not hand over, so the error log had "Unknown template variable 'placement'" for each preview and no location was checked. kernel/content/versionviewframe.php sets it from the view's placement. Test: eZContentVersionviewPlacementTest.

Fixed

  • A page of one site that lists content of another site of the same installation links to that content on the site that serves it. On the Bold Agency tag pages (/bold/tags/view/Topics/HIIT and seven more) every Fit & Healthy article linked to /bold/fit-healthy/..., which answered 404 (kernel (20), module not found); it now links to /video/... (or /site/video/... where Fit & Healthy is on /site). Links within a site, module links and every link of an administration siteaccess are unchanged.

Added

  • The extension ezurlmultisite (se7enxweb/ezurlmultisite 1.1.1) is required and activated by the multisite installers (sevenx_multisite, sevenx_multisite_clean, cjw_multisite). Their new post-install step postInstallWriteMultisiteURLSettings writes settings/override/ezurlmultisite.ini.append.php ([MultisiteSettings] SiteAccessList[], RootNodeList[]) from the PathPrefix, RootNode and IndexPage the install wrote, so the node ids are those of the new installation.
  • The media theme's own link operators (exp_path, exp_url, content_link ...) follow it (sevenx-themes-media 1.5.55).
  • Links to content of another site of a multisite installation.

Operator note

  • An existing multisite installation adds ActiveExtensions[]=ezurlmultisite and its own settings/override/ezurlmultisite.ini.append.php (the page above), regenerates the extension autoloads and clears the INI, template and content caches. Without the settings file the public siteaccesses with a PathPrefix take part, with the root node of their RootNode or IndexPage.

14 October 2026: the query cache on Oracle

Fixed

  • On Oracle the query cache never hit across requests: ezoracle sets NLS_NUMERIC_CHARACTERS, NLS_LENGTH_SEMANTICS and CURSOR_SHARING with one ALTER SESSION on every connect, which eZDBQueryCache::noteWrite() counted as a write of unknown tables, so every request made every cached result stale. An ALTER SESSION SET of only session parameters that change a result by their value alone (NLS_*, CURSOR_SHARING, TIME_ZONE) is no write now (eZDBQueryCache::isNeutralAlterSession()); any other ALTER SESSION (CURRENT_SCHEMA, EDITION, CONTAINER, EVENTS, ...) still makes everything stale. Measured on Oracle 11g XE with ezoracle 2.3.6: from 2 hits of 12 lookups per request to 10 of 12. Test: eZDBQueryCacheTest QC-16.
  • The parameters such a statement sets are part of the key of every statement of that connection (eZDBQueryCache::sessionSettings(), sessionKey(), kept per driver object): a result is answered only to a session with the same settings. With OracleCaseInsensitive=enabled the driver adds NLS_COMP and NLS_SORT, and the cache now hits across requests there too; a session with another NLS_SORT is never answered a result sorted by the first. Tests: QC-19, QC-20. The key of every statement changes once with this release, so results cached before it are not answered again (they expire after MaxAge).

14 October 2026: the query cache in the debug output

Added

  • With the debug output on, the query cache shows what it did for the request: the accumulators of the group Query cache (hits, misses, stores, statements not cached), a timing point where its state is read, and a Query cache report at the bottom with the request's counters, the hit rate, the database time the hits saved (each entry now records how long its statement took when stored), why statements were not cached and the writes that made everything stale (string literals masked). Switch: querycache.ini [DebugSettings] Report (default enabled); with the debug output off nothing is measured. Described in the query cache page. Tests: eZDBQueryCacheTest QC-17, QC-18.
  • The literal masking (eZDBQueryCache::maskLiterals()) reads a literal both as SQL-standard quoting ('') and as MySQL's backslash escapes (\'), and masks an unterminated one to the end, so no part of a literal reaches the report under either reading. Test: QC-21.
  • After eZDebug::reset() in the same process (a worker between two jobs) the report starts again with its own counters, and the timing point comes again, at once when the process already holds the state. The report's counters are its own, so Setup > System information flushing the request's counters leaves them as they are; with the debug output switched off before a reset nothing is registered. Test: QC-22.
  • With the debug output off the cache tests per statement only whether it is on, without a call (measured within the noise of a lookup hit).

14 October 2026: Oracle 11g and 12.1 (ezoracle 2.3.7)

Fixed

  • URL aliases resolve again on Oracle before 12.1 (11g): since ezoracle 2.3.1 every lookup by text_md5 (eZURLAliasML, eZURLAliasQuery) failed with ORA-00904, as STANDARD_HASH() only exists from 12.1 on. Before 12.1 the driver hashes the string literals the kernel passes in PHP, which is the value text_md5 holds; from 12.1 on nothing changes (se7enxweb/ezoracle pull request #2).
  • The DFS cluster backend for Oracle lists its files with a limit before 12.1 too (ROWNUM instead of OFFSET/FETCH), and the driver reads the server version again after a reconnect.

Updated

  • composer.json requires se7enxweb/ezoracle ~2.3.7.

14 October 2026: the query cache in the Exp Debug bar

Added

  • The Exp Debug bar shows the SQL query cache at a glance: a chip in the pinned bar with the request's hit rate and the database time it saved (QC 83 % · -186 ms), amber below 50 % or when a write made everything stale, red below 10 % or in shared mode without APCu (debugbar.ini [DebugBarSettings] Thresholds[querycache_hit_rate]). The cache's report and accumulators stand in the SQL tab. The Cache tab shows what the server holds: APCu entries and memory, the counters since the last reset, the state, the cached results grouped by the tables they read (entries, rows, size, hits) and the largest entries, with buttons to clear the cache and reset the counters (expdebugbar::cache by=querycache_counters). New: eZDBQueryCache::debugSnapshot(), inventory(), expDebugBarSummary::queryCacheLevel(). Tests: eZDBQueryCacheTest QC-23, QC-24, expDebugBarQueryCacheTest.
  • Refined on merging: the level of the chip and the figures of the summary have one source, expDebugBarReport::queryCacheLevel() and queryCacheFigures(), which expDebugBarSummary uses too (the bar's own fallback had a copy of the rules). debugSnapshot() gives the figures of the report's own counters and reads no state, so the chip and the report agree after eZDBQueryCache::flushStats() and after eZDebug::reset(). inventory() leaves out the state file's path itself. In the Cache tab the counters button reads "Reset", the section is read again after a clear or a reset, and the query cache is no longer listed a second time under "By ID". Tests: eZDBQueryCacheTest QC-23, QC-24 extended, expDebugBarQueryCacheTest DQ-05.
  • The query cache tables of the Cache tab have a header row and their figures right aligned, from classes of expdebugbar.css with its light and dark tokens (no inline style); checked in a browser on Apache and Velocity at 1440 px and at 960 px with scale 2, light and dark.

14 October 2026: the query cache in files

Added

  • shared mode keeps its results in files where there is no APCu: querycache.ini [QueryCacheSettings] Storage, auto (default: APCu where the PHP process has it, else files), apcu (only APCu, without it shared works as request as before) or file (always files, for web servers that share var/ on NFS or a SAN). Upgrade note: a web server with Mode=shared and without APCu now writes files instead of keeping only the request's memo; schedule the cronjob part cache_cleanup or set Storage=apcu for the old behaviour. Cronjobs and scripts without APCu keep the memo only under auto. One file per result in var/<site>/cache/querycache/entries/<2>/<key>, read with allowed_classes off, written to a temporary file and renamed; age and state are checked as for APCu. New cronjob querycache_cleanup.php in the part cache_cleanup removes the files older than MaxAge or from before the last clear; a clear removes them at once. Setup > System information, the debug report, the Exp Debug bar's chip and Cache tab show the storage; the chip is red only when shared has no storage. New: eZDBQueryCache::storage(), entriesDir(), cleanupFiles(). Described in the query cache page. Tests: eZDBQueryCacheTest QC-25 to QC-28, expDebugBarQueryCacheTest.
  • Refined on merging: Storage=auto uses the files in the workers of Exponential Velocity too (their SAPI is cli, so they had kept only the memo); a result file is only ever named by a key (hex digits only); without the cronjob the requests sweep the files themselves, at most every 10 minutes (or MaxAge) for the installation, one process at a time and for at most half a second (eZDBQueryCache::sweepFiles()), so they cannot grow without bounds; the Exp Debug bar never receives the path of the result files. Tests: QC-29 to QC-31.

14 October 2026: an APCu reserve for the query cache

Added

  • The query cache leaves room in APCu for the PHP-FPM pool's other users: querycache.ini [QueryCacheSettings] ApcuReserve (percent, default 25, 0 off). With that share or less of APCu free, shared mode stops adding results to APCu and keeps them for the request only. APCu full with valid entries empties itself completely, and the HTTP cache, API keys or a request filter's counters in the same pool went with it. The free share is read from apcu_sma_info() before every store. Since APCu frees expired entries only when they are deleted by key or an allocation fails, below the reserve a miss deletes its key's old entry and eZDBQueryCache::purgeExpired() deletes the cache's expired entries once a minute for the pool (up to 30,000 APCu entries and below 80 % of memory_limit; in a web request after the page). Setup > System information and the Exp Debug bar's Cache tab count the results held back (counter reserve; eZDBQueryCache::$stats has the new key reserve), the debug report lists them. New: eZDBQueryCache::apcuRoom(), purgeExpired(). Tests: eZDBQueryCacheTest QC-32 to QC-34.
  • Refined on merging: in a worker of Exponential Velocity the purge runs at the end of the request, registered once per request, instead of while the visitor waits (Velocity's SAPI is cli); a script still runs it at once. The APCu reserve warning of Setup > System information uses the shared admin components (a4-notice is-warn): its old class was no longer styled. apcu_sma_info() measured at 0.2 µs a store. Tests: QC-35, AdminRetiredClassesTest. Setup > System information and the Cache tab of the Exp Debug bar show the setting Storage next to the storage it gives, and ApcuReserve (status() has storage_setting).

14 October 2026: a resizable Exp Debug panel

Added

  • The open panel of the Exp Debug bar can be resized: a handle on its top edge is dragged with the mouse, a pen or a finger, or focused (role="separator" with aria-valuenow, aria-valuemin, aria-valuemax) and moved with the arrow keys (16 px, with Shift 64 px; Home and End for the limits); a double-click gives the default height (60 % of the window) back. The height stays between 120 px and the window's height minus the bar, also when the window changes size. It is saved once at the end of a drag or 0.6 s after the last key, in the user preference expdebugbar_panel_height (new server function expdebugbar::panelheight, POST with the form token), and the report prints it on the panel as data-exp-debug-panel-height, so the panel opens at that height in any browser of the user. An anonymous visitor (the bar shown by IP) keeps the height in the browser's localStorage (exp-debug-panel-height, read back and clamped before the panel opens): no preference is read or saved for one and no request is sent, so the bar never starts a session; a logged-in user's preference is authoritative. New: expDebugBarReport::panelHeight(), savedPanelHeight(), three English and German texts. Guide: The panel's height. Tests: expDebugBarReportPanelHeightTest, expDebugBarReportCopyMessagesTest.

Updated

  • The page cache (exphttpcache) no longer keeps a page that carries the debug report of its request (X-Exp-Cache: BYPASS (debug report)). A signed-in user's front page was answered from it with the report of an earlier request, so the resized debug panel opened at the height from before the last resize after a reload, the timings and queries were those of the stored request, and a report shown by IP could reach every visitor sharing the anonymous context. Test: ezpHttpCacheContractTest HC-23.
  • The debug bar's Velocity tab says where the request entered and which process rendered the report, in place of "Not running on Velocity.": "Received by Velocity on port 8080 and rendered by a Velocity worker." or "Served by Apache / PHP-FPM (port 443), not by Velocity.", also for the Velocity parent, php-cgi started by Velocity, mod_php and FrankenPHP, with the rows Entered (port and forwarded headers), Received by, Rendered by, Rendered at and Cached. A report that still comes out of a cache is marked as such, with when and by which process it was rendered. On :8080 the tab had shown PHP-FPM: the signed-in front page came from the role-aware page cache, stored by PHP-FPM for a request on 443 and answered by Velocity from the same store. The OPcache box says when opcache_get_status() is switched off. English and German texts. Guide: Velocity: where the request entered. Test: expDebugBarReportEngineTest.

Fixed

  • A page with the debug report is sent Cache-Control: private, no-store: Velocity's response cache had kept an anonymous page with the report of a request from an address in DebugIPList and answered every visitor of the Velocity port with it.

14 October 2026: the query cache's saved time against the page's time

Added

  • The query cache chip of the Exp Debug bar also gives the database time saved in percent of the page's time (QC 83 % · -186 ms (186 %)); its title says how long the page would have taken without the cache ("without the cache about 286 ms instead of 100 ms"). The debug report's row Database time saved (estimated) gives the same share of the request so far. A share above 100 % means the hits saved more than the page now takes. New: eZDBQueryCache::savedShare(), debugSnapshot() has request_ms and saved_share. Translated in ger-DE and eng-US. Tests: expDebugBarQueryCacheTest DQ-02, DQ-06, eZDBQueryCacheTest QC-17, QC-23.
  • Refined on merging: the report row and the chip measure the same span (eZDBQueryCache::requestMs()): from the script start the web kernel sets for every request, also in a persistent worker of Exponential Velocity (else REQUEST_TIME_FLOAT), to where the bar's output starts (setRequestEnd()); a start that would give a negative time or more than a day gives no share. From 100 % on both also say how many times as long the page would have taken (savedFactor(): "286 ms, 3 times as long"), so "186 %" no longer reads like a part of the page. Translated in ger-DE and eng-US. Tests: QC-36, QC-37, DQ-07.

14 October 2026: Bootstrap Icons in admin4

Added

  • The icon theme bootstrap-icons (share/icons/bootstrap-icons/): Bootstrap Icons 1.13.1 as SVG, unchanged, with their MIT licence, for class_icon, mimetype_icon, classgroup_icon, icon and the content tree's icon_info. It maps every class and MIME type of crystal-admin, the classes of the website interface and demo packages and the OOXML, EPUB, RTF, CSV, XML, JSON and SVG types; one file serves every size. An admin siteaccess switches to it in its icon.ini (Theme=bootstrap-icons, AdditionalThemeList[]=crystal-admin); nothing changes until then. A class the theme does not map keeps its crystal-admin PNG in class_icon, but the content tree (icon_info, first theme only) shows the theme's Default for it: add it to ClassMap. Guide: the admin4 design: icons.
  • The shared admin components .a4-icon with .a4-bi-<name> (a Bootstrap icon as a mask in the text colour, 1em or 12, 16, 22 px) and .a4-icon-btn (a link or button that is only an icon), in both copies of components.css. Guide: Styling the administration.

Updated

  • admin4 draws its icons with Bootstrap Icons in the text colour of the light and the dark mode: the messages, the sorted columns, the tabs switch, the sub-items table (row menu, image preview, the buttons above it and their menus), the search box, the context menus and the content tree (a caret right and down on the column's background, so the connector line no longer crosses the symbol) in its stylesheets, in place of the GIF and PNG sprites; edit, invert selection, up one level, trash, move, the current user and the inline SVGs of buttons, badges and the header in its templates. The classic designs keep their images.
  • The image buttons of class editing (top, up, down, bottom) and of the Locations window (move) in admin4 are <button>s that post the same variables (MoveUp = the attribute id, as eZHTTPTool made of MoveUp_<id>_x). The invert selection images are buttons a keyboard reaches.
  • In dark mode a filter turns the <img> icons of the bootstrap-icons theme light.
  • Test: AdminIconsTest (every file the theme, a class or a stylesheet names exists; no file is left over; every file is an unchanged Bootstrap icon; admin4's templates draw no GIF icon, inline SVG or image button).

14 October 2026: language codes instead of flags in admin4

Added

  • eZLocale attribute content_language_code (contentLanguageCode()): the locale's language tag from its locale file ([HTTP] ContentLanguage, e.g. de-DE), which site.ini [RegionalSettings] HTTPLocale does not override.
  • The include design:parts/language_badge.tpl and the shared admin components .a4-lang and .a4-langs in both copies of components.css. Guide: the admin4 design: languages.

Updated

  • admin4 shows a content language as a short code badge (DE, EN; the language part only since 15 October) and its name instead of a flag: the node view's meta line, the sub-items table's Translations column, the Translations tab, the edit page and its language choice, version view, history, drafts, pending items, edit conflicts, URL aliases, states, classes and the top node. The badges have the language's name as title and, where it is not written next to them, as accessible name; the meta line marks the language shown with aria-current instead of dimming the others. The classic admin design shows the code in the sub-items table's Translations column too (its own copy of the include and of ezajaxsubitems_expdatatable.js, which stays identical to admin4's); its other templates and the other designs keep their flags.
  • Test: AdminLanguageBadgeTest (no flag in an admin4 template or script, every badge through the include, the sub-items column escapes and names its badges, the component in both stylesheets, the include's optional parameters carry the prefix badge_ and every include passes only known parameters, no flag in either design's sub-items table).

Fixed

  • design:parts/language_badge.tpl no longer reads a variable of the template that includes it: its optional parameters are badge_name, badge_named, badge_plain and badge_class. Called class and name, they read the content class of class/view.tpl and class/edit.tpl, and class/view ended in a fatal error (HTTP 500).

14 October 2026: admin4 context menus from the tokens and the keyboard

Updated

  • admin4's context menus (content tree, sub-items, class icon, bookmarks and their submenus) are drawn from the admin tokens: card, edge, radius and shadow of the light and dark mode, 14 px text in compact rows of about 27 px (at least 24 px, the WCAG 2.2 target size), a width that fits the longest item up to 22rem (longer items end in an ellipsis), the title as a quiet row instead of a fixed grey bar (4.39:1), the accent tint instead of a blue for the hovered and focused row, a forced-colors fallback. Before, the shadow was a hard light grey that glowed around the menu in dark mode. Guide: the admin4 design: context menus.
  • ezpopupmenu.js (admin4 and admin) gives the menus the roles of the WAI-ARIA menu pattern when it shows them and handles the keys: Enter, Space, Shift+F10 or the context menu key open a menu under its button with the focus on the first item; arrows, Home and End move; Arrow Right and Left open and close submenus; Escape closes and returns the focus; Tab closes. A menu opened with the mouse is reached with Arrow Down from its button. Its public functions are unchanged.
  • The content tree's menu button is a named role="button" with aria-haspopup="menu"; the sub-items row button lets ezpopupmenu.js place a menu opened from the keyboard.
  • A submenu opens beside its parent menu's right edge (the position used the submenu's own width).
  • Test: AdminContextMenuTest (no colour literal in the menu's styles, rows and focus outline, roles and keys in the script, the tree button's attributes, the design/admin copies identical, a keyboard click told by its click count and not by its position on the page).

Fixed

  • On a scrolled page, a context menu opened with Enter, Space or Shift+F10 opened in the window's top left corner and the focus stayed on the button: ezpopupmenu.js took the click for a mouse click because its pageX/pageY carry the scroll offset. A click now counts as made with the keyboard by its click count (detail 0).

14 October 2026: landmarks and names in admin4

Added

  • admin4's frame has a skip link ("Skip to main content", the first stop of the Tab key, visible only while focused) and landmarks: the header as banner, the top menu, side menu and breadcrumb as named navigation, the right column as complementary, the footer as content info and #maincontent as main, also on the edit-type pages that bring their own #maincontent; a page without the left column whose template brings none (remove, copy, move ...) is wrapped in #a4-main, a main landmark without styles. The roles are on the existing elements, so no selector changes. Component .a4-skip-link in both copies of components.css. Guide: the admin4 design: landmarks and names.
  • English and German texts for the skip link, the landmark names, the menu button and the new control names.

Updated

  • admin4: the breadcrumb and the top menu mark the current page (aria-current); disabled top menu entries are aria-disabled and out of the Tab order; the logo, "Open the site" and the phone-width menu button have names, and the menu button answers Enter and Space and reports aria-expanded; the section select of the Details tab, the sorting selects, the selection checkboxes and main language and location radios of the Translations and Locations tabs (with the row's language or location), the embed code fields of the relations list, the year, month and day fields of the date attribute and the disabled header search have names; the version preview frame has a title; the duplicate image link of the current user is hidden from screen readers and the Tab key.
  • Every header cell of admin4's templates has scope="col".
  • Test: AdminLandmarksTest.

14 October 2026: keyboard use of admin4's tabs, content tree and date fields

Updated

  • admin4's node tabs follow the WAI-ARIA tabs pattern (tablist, tab with aria-selected and aria-controls, tabpanel): only the selected tab is in the Tab order (the first one when none is selected), the arrow keys, Home and End move between the tabs and open them. A click on an element inside a tab's link opens that tab (the handler took the link's parent of the clicked element). Guide: the admin4 design: keyboard.
  • The show/hide tabs toggle is a button named "Show tabs" whose aria-pressed says whether the tabs are shown; its title says what a press does; Space presses it.
  • The content tree's open/close link is a button named "Items below <node>" with aria-expanded (aria-busy while loading) that keeps the focus after a press; Space presses it. In the admin and admin4 designs (same script).
  • The calendar of the date and date/time fields of admin4 opens from a button the keyboard reaches (the classic designs keep the image).
  • English and German texts for the tree button, the calendar button and the tabs toggle. Test: AdminKeyboardTest.

14 October 2026: the content tree is a tree for screen readers and keyboards

Updated

  • The content tree of the admin and admin4 designs follows the WAI-ARIA tree view pattern: a tree named "Content tree", each node's name link a treeitem with level, position, aria-expanded and (current node) aria-current="page", the sub items a group the item owns. One item is in the Tab order; Up/Down, Right (open, or first sub item), Left (close, or parent), Home, End and first-letter type-ahead move through the shown items, Enter follows the link, Shift+F10 or the context menu key opens the node's own menu. The open/close button and the node icon stay for the mouse, out of the Tab order and hidden from screen readers. A load error is read out through aria-describedby; a node whose sub items are all hidden has no aria-expanded. Guide: the admin4 design: keyboard.
  • English and German text "Content tree". Test: AdminTreeViewTest.

14 October 2026: every form field of admin4 has a name

Updated

  • admin4: every input, select and textarea has an accessible name, about 230 fields in 76 templates that had only a title, a placeholder or their position: template creation, the toolbar editor, package export and creation, workflow events and the workflow editor, shop forms and lists (registration, currencies, VAT, discounts, order and product lists), the Setup > RAD wizards, RSS/OPML export, the version preview, relations, URL aliases, trash, browse lists and the remaining tables. Visible text names a field (<label for>, a label around the text of a radio or checkbox, the column header and the row's name, the card heading); radio groups are named groups. Fields that a title used to name keep the title and carry the same text as aria-label.
  • New component .a4-inline-label in both copies of components.css: a label around text that was plain before, drawn like that text (core.css draws every label bold, larger and as a block).
  • design/standard/templates/shop/country/edit.tpl takes an optional select_id, so a label can name the country select.

Fixed

  • The Cancel button of the remove-location confirmation (node/removenode.tpl) lost its closing quote.

Added

  • Test: AdminFormLabelsTest: no form field of admin4 without a name; every <label for> and aria-labelledby points at an id of its template (or one an include is given); no aria-label replaces a label that has text; the includes that take their id as a parameter (select_id) get it, with a label, from every admin4 template that includes them; and the checker's own cases.
  • English and German texts for the five new field names.

14 October 2026: contrast of the admin4 colours

Updated

  • The focus ring token --a4-ring is opaque: #c2410c light (5.2:1 on a card, 4.6:1 on the page), #ff9a5c dark. The translucent orange it replaces was 1.8:1 in light mode, below the 3:1 a focus indicator needs.
  • The current item of the header menu is drawn in --a4-accent-strong (white text 5.2:1); #f26a21 gave 3.1:1.
  • Filled buttons keep 4.5:1 when hovered: the hover fill is --a4-accent-press and its text the new token --a4-on-accent-hover (white in light mode, dark ink on the light orange of dark mode). It replaces three dark-mode rules that borrowed --a4-code-bg as a text colour; the password wizard's primary button had none and was 2.3:1 in dark mode.
  • Fields have one edge colour, --a4-field-line, raised to #80879a light and #6f7a93 dark (3:1 on every surface); the fields of #page used --a4-line-strong (1.6:1).
  • --a4-chart-3, -4 and -5 are darker in light mode (3:1 on a card).
  • The header search placeholder, the current item of the left menu, the dashboard's version line and the focus outlines of the package pages take the tokens instead of five other oranges and a grey placeholder that was 1.7:1 in dark mode.
  • The Info pages (ezinfo.css), package compare (packagecompare.css), the extension order page (setup-extensions.css) and the package view take every colour from the tokens, so they follow the dark mode.
  • A focused default button keeps the accent; theme/rounded.css painted it teal with yellow text.
  • Status edges of package compare (changed rows, import entries, the import result) are drawn in the state colour itself (3:1), not its pale line colour; the package view's "ok" badge is the ok tint like the other pages; the community block's edge is the strong line colour.
  • A just-moved bookmark or role policy is marked with the accent tint and edge instead of a second focus-like ring.
  • Tests: AdminTokenContrastTest computes the contrast of the token pairs in both modes, each state's text on its own tint included (a translucent tint laid over the card); AdminStylingRulesTest checks admin4.css and the page stylesheets for colour literals.

14 October 2026: admin4 focus indicators

Updated

  • The header search's button, scope switch and the scope pane's close button show the focus ring again; their :focus-visible rules took the outline away and left a tint of about 1.1:1.
  • The frame's fields that draw their focus as a box-shadow ring (the fields of #page outside .a4-page, the header search field, the light/dark switch, the sign-in fields and the password wizard's fields) set a transparent outline instead of none, so Windows high contrast (forced colors), which drops shadows, still shows the focus.
  • The sidebar buttons' focus is the 3px ring of every other control (it was a 2px orange outline, 2.9:1 on the soft background).
  • Test: AdminStylingRulesTest fails on a :focus rule of an admin4 stylesheet that removes the outline (none, hidden or width 0 in any order; :focus:not(:focus-visible) and :focus-within aside).

14 October 2026: admin4 and the modes the browser asks for

Updated

  • Each admin theme sets color-scheme, so scrollbars, select popups, date pickers and autofill are dark in dark mode.
  • Forced colors (Windows high contrast): buttons, fields, cards, badges and chips get an edge, the current tab and selected rows and cards an outline in Highlight, a checked chip and the current step of a wizard a Highlight fill, and the data drawn as fills (status dots, meters, bar charts) keep their colours.
  • prefers-reduced-motion: reduce ends every transition and animation of admin4 at once (the sidebar slide, the dashboard's lift, the fades); the two transition: all rules name their properties.
  • On touch screens the text fields, selects and text areas of admin4 use 16px text (date and time fields included), so a phone's browser does not zoom in when one gets the focus.
  • A window narrower than 1024px no longer stores the sidebars as closed: there they open over the page one at a time, and a wide window shows them again as the user last left them there. Only a click on a wide window saves the choice, as the user's eZPreferences admin_left_sidebar and admin_right_sidebar (like the column width), which the page layout writes into <body data-a4-sidebars> and its cache key, so it follows the user from browser to browser. The browser storage keys used before (ezpAdminLeftSidebar, ezpAdminRightSidebar) held the "closed" every narrow visit had written and are removed, so both sidebars show again once. The width comes from matchMedia.
  • The narrow padding of the content wrapper and the frame's narrow rules start below 1024px, not at 1024px too.
  • Test: AdminStylingRulesTest checks the colour scheme, forced-colors, touch and reduced-motion rules (with the selectors that must win), that no stylesheet transitions all, and that the sidebar script stores only in one place, inside a try.

14 October 2026: one spacing and type scale for admin4

Added

  • Tokens --a4-space-7 (48px), --a4-text-2xl (26px), --a4-text-3xl (32px) and --a4-leading-tight (1.25, for headings), in both copies of components.css.

Updated

  • admin4.css and components.css take every font size and spacing from the scales: about 30 pixel sizes between 11 and 32px and some 240 spacing lengths (3, 5, 6, 7, 9, 10, 11, 14, 18, 20, 22px …) are the nearest step now. No text of the frame is smaller than 12px (the dashboard's bar values and weekday labels and the node badges were 11 and 11.5px).
  • Targets of at least 24 x 24px (WCAG 2.5.8): the sidebar tabs are 24px wide (the content column keeps a 24px gutter on both sides, so they cover no content), the search scope switch takes the click in a 24px area, the dialog close, the search scope pane's close and the page numbers are 24px. The content tree's open/close takes the click in a 20 x 20px area (more would cover the next row) and the row checkboxes are 18px; both rely on the spacing exception of 2.5.8 (rows about 27px apart). The light/dark switch and the search field are both 36px high.
  • The password wizard keeps room for the wider show/hide button, and an invalid field's text does not move when its border grows to 2px. Its success heading is 16px again, below the page heading.
  • Page numbers and the dashboard's bar labels wrap on a 360px phone instead of running out of their card.
  • The node view's tabs on/off control (a.show-hide-tabs, #maincontent-show / #maincontent-hide) was a 16px icon in the gap above the tabs; it is a 36 x 24px switch (track and knob in the tokens, accent when on, forced-colors edges) with the visible label "Tabs" (new string in design/admin/parts/my/menu, ger-DE "Reiter"), at the right end of the tab row. It is styled by its class, ids and aria-pressed, so a button with aria-pressed in place of the link keeps the look.
  • Test: AdminStylingRulesTest fails on a pixel font size or an off-scale spacing length in admin4.css or components.css (a few layout offsets listed per selector aside), and checks that the 24px targets stay.

14 October 2026: admin4 content editing says which fields are required and which failed

Added

  • Components .a4-required-mark, .a4-required-hint and .a4-error-summary in both copies of components.css; .a4-bi-x-circle-fill; the module classes .a4-content-attr, .a4-content-attr-error in modules/content.css. New string "Required field" in design/admin/content/edit (ger-DE "Pflichtfeld").
  • Test: AdminFormErrorsTest.

Updated

  • Content editing in admin4 (content/edit_attribute.tpl, edit_validation.tpl; the attributes of edit, multiedit, user registration and the template look editor): each attribute is a named group (role="group", id="ezcca-attr-<attribute id>", aria-labelledby on its name; a grouped input keeps its fieldset and legend as name). A required one shows a red * with "required" for screen readers instead of the visible "(required)"; one hint above the attributes explains the mark, once per page and only when a field is required. An invalid one shows its message under the label (.a4-field-error with an icon, id="ezcca-error-<id>", the description of the group or of the grouped input's fieldset) and a red edge. The page's script sets aria-required, aria-invalid and aria-describedby on the data controls the datatype templates draw, for every datatype at once: not on buttons, file or reset inputs, remove/selection checkboxes, the original side of a translation, or a control inside .a4-no-field-mark (the opt-out for datatype templates). The summary of a failed store is an alert that takes the focus on load, and its entries link to the fields; every attribute it names is marked invalid (also one that failed without a message of its own) and its collapsed group opens. Field names and posted values are unchanged.

14 October 2026: admin4 card parts

Added

  • The card component (.a4-card, both copies of components.css) has a foot, .a4-card-foot: the card's actions on a strip at its bottom edge; .a4-card-actions at the end of .a4-card-head; a <p> in the head is the subtitle. .a4-card.is-flush lets a table run to the card's edges; .a4-card.is-link makes the whole card follow the link in its title (focus ring around the card where the browser has :has(), the controls in body, foot and head actions stay clickable); .a4-card.is-busy with aria-busy="true" veils the card with a spinner while it loads (no motion when reduced motion is asked for; a script also sets inert). Foot and flush head take the card's padding from --a4-card-pad-x/-y when a density setting defines them, else from the spacing scale.

Updated

  • 16 cards that opened with a section head use the card's head and body: the maintenance page (3), RSS import (4) and export (3), the policy limitations (3, the node and subtree pickers with their buttons in the card's foot), the class view and edit, global URL aliases. Field names and posted values are unchanged.
  • Test: AdminCardsTest.

14 October 2026: admin4 layout polish

Added

  • Token --a4-control-h (32px): the height of the controls that stand in a row. Token --a4-zebra (light #f2f4f7, dark #212734) for the even rows of data tables. In both copies of components.css.

Updated

  • Zebra rows in every data table: table.list, .a4-table and the sub-items table stripe every second data row with --a4-zebra, counting only rows with data cells (a header row of th in tbody or a hidden row is skipped, so the first data row is plain; a row a script hides still counts); a row header (th scope="row") takes its row's colour. The hovered row takes --a4-info-bg (it was --a4-soft, the same as a stripe), a selected row keeps --a4-accent-soft. The bglight/bgdark row classes no longer set the colour; the RSS import's own unstriped list follows the others. Layout tables stay plain, and the VAT rule and currency forms, laid out as a table.list, take a4-plain. --a4-accent-text is #bc3f0b in light mode (was #c2410c): 4.7:1 on a hovered row too.
  • The node view's language choice is as high as the buttons beside it (32px; Chrome kept a native select at 19px): drawn by the page with the button edge and radius and a chevron in the tokens; forced colors keep the native control.
  • The sub-items toolbar (Select, Create new, Create multiple, More actions, Table options) and the pager buttons draw like the node's action buttons: 32px high, the button radius and edge, ink text in weight 600, the same hover, the global 3px focus ring (the toolbar was 24px with 3px corners and orange text, the pager 27 x 33px). The current page number is the accent tint.
  • The side columns: no rule above the first heading of a card, which starts at the card's top (the right column's first heading sat 19px down, now 8px). The content tree's rows are 24px apart at the small and medium column sizes (a 20px line and 4px), the smallest spacing WCAG 2.5.8 allows for its links; the large size gets a 1.25em line. The connector elbow and the open/close symbol sit on the line's middle. In forced colors the language choice is the native control without the page's chevron.
  • The left column's width grip was a grey gradient bar; it is a small card on the column's edge with two rows of dots, the resize cursor and the accent on hover, drag and focus. It is a focusable separator now (role="separator", aria-valuenow in pixels): the arrow keys change the width by 16px (Shift: 64px), Home and End go to the narrowest and widest (the widest follows the window), the width is saved when the key is let go. It is named by its aria-label "Width of the left column" (new string in design/admin/pagelayout, ger-DE, eng-US).
  • Tests: AdminStylingRulesTest checks the shared zebra and that no admin4 stylesheet paints rows in a colour of its own, the controls' common height and the grip; AdminTokenContrastTest checks text on --a4-zebra.

14 October 2026: admin4 lists and the path in the content tree

Updated

  • Data tables (table.list, .a4-table, the sub-items table): cells sit in the middle of their row (.a4-plain form tables keep their labels at the top); a row whose selection checkbox is ticked takes the selection tint without a script.
  • Empty lists say so in the .a4-empty component (28 templates: roles and policies, shop lists and currencies, trash, content jobs, relations, states, workflows, template overrides).
  • The content tree shows where the page is: the current node's name in the accent tint with a straight bar on its left (square left corners; a Highlight edge in forced colors), the nodes above it in a stronger weight. CSS only, from the existing currentnode class and :has().
  • The choices above a list (p.table-preferences: 10 | 25 | 50 items, the shop archive's sort field and order, the filters of Setup > RAD) are a segmented control on the tokens: surface background, --a4-line edges, links in the accent text colour, the current choice in the accent tint with ink text in weight 650, targets of at least 28px, Highlight for the current choice in forced colors. Before, theme/rounded.css (admin4's copy only) drew them as a light grey gradient with fixed colours, a light box in dark mode whose current value could not be read.
  • Tests: AdminStylingRulesTest::testListsMarkTickedRows, testTreeShowsThePathToTheCurrentNode, testEmptyListsUseTheEmptyState, testTablePreferencesAreASegmentedControlOnTheTokens.

14 October 2026: admin4 stacking layers and the side column tabs

Added

  • One scale of stacking tokens --a4-z-* in both copies of components.css (raised, sticky, frame-grip, frame-control, hover-preview, popover, scroll-top, navbar, sidebar, sidebar-handle, controlbar, drag-target, header, sidebar-toggle, menu, menu-sub, overlay, modal, toast, drag, drag-mask). All 29 z-index numbers of admin4's stylesheets take one of them, in the order they had; a number is left only for 0, 1 and -1 inside one component. The duplicate, dead div.popupmenu#EditSubmenu rule is gone; the base #overlay-mask rule of theme/modalwindow.css takes --a4-z-overlay too. Guide: Styling the administration.

Updated

  • Context menus lie above the fixed header (--a4-z-menu 1040, the edit submenu 1050; they were 1010 and 1020), so a menu that opens upwards is not cut off.
  • A dialog's dimming mask and the dialog (#overlay-mask, .modal-window) lie above the header, the side column tabs and menus (--a4-z-overlay 1060, --a4-z-modal 1070; they were 121 and 122, under the header).
  • The tabs on the window edges that show and hide the side columns are named after their column ("Left column", "Right column"), say with aria-expanded whether it shows and with aria-controls which it is; on a narrow window Escape closes a column open over the page and gives the focus back to its tab. New strings in design/admin/pagelayout.
  • Tests: AdminStylingRulesTest::testStackingComesFromTheLayerTokens (no other z-index, the order of the layers), testSidebarTabsSayWhatTheyControl.

14 October 2026: admin4 page header and breadcrumb

Added

  • Components .a4-pagehead (pretitle, title, subtitle or meta line, actions on the right that wrap under the title on narrow windows) and ol.a4-crumbs (the breadcrumb), in both copies of components.css.

Updated

  • The breadcrumb (page_toppath.tpl) is an ordered list in the #path navigation landmark ("Breadcrumb", new string in design/admin/pagelayout, ger-DE, eng-US): the separators come from the stylesheet and are not read out, the current step carries aria-current="page", "You are here:" is read by screen readers only, role="list" keeps the list semantics in Safari. Names are no longer cut to 18 characters: a long name ends in an ellipsis (28 characters, the current page 48) and keeps its full name as title (from 20 characters on, as the cut is by width). On windows up to 600px the middle steps fold into one "..." step for the eye only: they stay readable for screen readers and come back when they take the keyboard focus. An empty path renders nothing. The old #path span colour, which beat the breadcrumb's own colours, is gone.
  • One title style for every page: the kernel's h1.context-title, the node view's title and the dashboard's greeting take --a4-text-xl (22px, weight 650; they were 1.35em, 1.45em and 26px), --a4-text-l (16px) on windows up to 600px, one breakpoint for all three. The node view's header row is built from .a4-pagehead.
  • Test: AdminPageHeaderTest (the components in both stylesheets, the breadcrumb's list, current step and full names, the landmark, the one title size).

14 October 2026: admin4 notices with icons, and toasts

Added

  • Toasts: window.a4Toast( message, kind ) in admin4.js shows a short feedback of an action done in the page in one of two live regions of the frame (#a4-toasts, polite; #a4-toasts-alert, assertive, for kind bad). A toast does not take the focus, closes after 5 s, waits while the pointer or the focus is on it, and has a close button; without motion when the system asks for reduced motion. Components .a4-toasts, .a4-toast in both copies of components.css. Used by the node view's copy chips (node, object and remote id) and the menu configuration's copy buttons, whose tick was only seen.
  • .a4-notice-title (a bold first line) and .no-icon for .a4-notice.
  • New strings: "Dismiss" (design/admin/pagelayout), "Copied to the clipboard" (design/admin/node/view/full, design/admin/visual/menuconfig), in ger-DE and eng-US.

Updated

  • .a4-notice.is-ok, .is-bad, .is-warn and .is-info show their state by an icon too, not by colour alone (WCAG 1.4.1): check, cross, triangle and i in the state's colour left of the text; in forced colors in the text colour. New icon info-circle-fill in share/icons/bootstrap-icons/svg/.
  • The classic div.message-* that modules render after an action look like .a4-notice (a state tint and edge, the heading's icon in the state's colour) instead of a 6px black or coloured frame, and carry a role: 25 message-error are role="alert", the 48 warnings, feedbacks and confirmations role="status". A status region that is already in the page is usually not announced when the page loads, so a warning about a standing state (audit switched off) does not interrupt; screen readers differ here. A message without a heading takes the icon on the block itself.
  • The setup wizards' notices lose their hand-written "!" and tick, which stood next to the icon now; a tick notice is is-ok.
  • A copy in the node view or the menu configuration that fails says so ("Could not copy", a toast for errors), and the textarea fallback gives the focus back to the chip. Toasts with the same text replace each other, at most three stay; a toast closed with its button gives the focus back to the element the action came from. li.a4-notice has no list marker.
  • Test: AdminNoticesTest (an icon for every state, the forced-colors rule, the toast component and live regions, the toast writes text only and never moves the focus, every classic message has its role, every copy chip names its toast).

14 October 2026: admin4 follows the system's light or dark mode on request

Updated

  • The header's mode switch (admin4, admin4l) steps light -> dark -> system. "System" follows prefers-color-scheme, also when the operating system changes it while the page is open. The icon (sun, moon, new half circle .a4-bi-circle-half from Bootstrap Icons 1.13.1) and the button's accessible name say the mode in use ("Colour mode: light", "… dark", "… system"); the title names the next step ("Switch to dark mode"), and a status region beside the switch announces a press. The button no longer carries aria-pressed, which can only say two states. admin4l's switch draws its sun and moon as admin icons too (they were inline SVGs in black, lost in dark mode and forced colours). Other open admin tabs follow the change. Without a stored choice the admin stays light, as before; "system" as the default would be a separate decision. The stylesheets keep reading html[data-a4-theme] (light or dark); html[data-a4-theme-choice] is new. An older copy of the switch without data-label-system keeps stepping between light and dark. New strings "Color mode: light/dark/system" and "Switch to light/dark/system mode" in design/admin/pagelayout (ger-DE "Farbmodus: …", "Zum … Modus wechseln", eng-US). Test: AdminThemeSwitchTest.

14 October 2026: a timeline for admin4's audit events

Added

  • The component .a4-timeline (both copies of components.css): an ordered list, newest first, one event per item with its time (<time datetime>, the UTC instant), the event, its meta and a state badge; a dot on a line marks each event in the state's colour (.is-ok, .is-warn, .is-bad, .is-info), which the badge also says in words. Two columns, one on narrow windows; the line is an edge and the dots keep their colour in forced colors. For short chronological lists without actions per row; a list with checkboxes or buttons per row stays a table.
  • design/admin4/templates/parts/audit_timeline.tpl (events, optional show_reason): audit events as a timeline, actor and result named for screen readers.

Updated

  • admin4 draws the dashboard's audit block (dashboard/audit.tpl) and the node view's Audit tab (tabs/audit/node.tpl) as a timeline instead of a four-column table; the fetches and links are unchanged, the other designs keep their tables. The full event list (audit/recent, six columns) and the version history (a removal checkbox per row) stay tables.
  • Test: AdminTimelineTest (the component in both stylesheets, the dots per state, the line as an edge and the forced-colors rule; the part as an ordered list with datetime, washed values and named actor and result; the two audit lists use the part).

14 October 2026: one release of each jQuery library in ezjscore

Removed

  • ezjscore no longer ships the older jQuery copies that nothing loaded: jquery-3.7.1.js, jquery-3.7.1.min.js, jquery-migrate-3.4.1.min.js and jquery-ui-1.10.3.custom.min.js (jQuery UI 1.10.3 has known vulnerabilities, among them CVE-2016-7103, CVE-2021-41182, CVE-2021-41183 and CVE-2021-41184). No template, setting or script of the distribution named them; ezjsc::jquery and ezjsc::jqueryUI load jQuery 4.0.0, Migrate 4.0.2 and jQuery UI 1.14.2 as before.
  • Test: ezjscoreVendoredLibrariesTest (one release of each library in the folder, every LocalScripts[] file of ezjscore.ini exists, the CDN entries and extension.xml name the shipped release; mutation-checked; and no other extension's, siteaccess's or override's ezjscore.ini names a jQuery file that no design holds).
  • ezjscore 1.7.0 (the kernel copy and the standalone package) carries this change; sevenx_themes_simple 1.0.22 no longer sets the older files for its siteaccesses.

Upgrade notes

  • A template or extension of your own that names one of the removed files directly (for example {ezscript_require( 'jquery-3.7.1.min.js' )}) gets no script any more. Use ezjsc::jquery or ezjsc::jqueryUI instead.
  • An ezjscore.ini of your own (in settings/override, a siteaccess or an extension) that sets LocalScripts[jquery], [jqueryMigrate] or [jqueryUI] to one of the removed files loads nothing for that key: remove the line, so ezjscore's default applies, or name jquery-4.0.0.min.js, jquery-migrate-4.0.2.min.js or jquery-ui-1.14.2.min.js.

14 October 2026: normal or compact density in admin4

Added

  • A density switch in admin4's header beside the light/dark button: a button named "Compact view" whose aria-pressed says whether the compact density is on (new strings "Compact view" and "Switch between normal and compact view" in design/admin/pagelayout, ger-DE, eng-US). The choice is kept in the browser (localStorage "exp-admin4-density", guarded for blocked storage) and applied before the page is drawn, like the mode.
  • Density tokens in both copies of components.css: --a4-list-pad (table.list cells, .4em as before), --a4-cell-pad-y/-x (.a4-table), --a4-card-pad-y/-x (.a4-card); html[data-a4-density="compact"] sets only them and --a4-control-h anew (.25em, 4/8 px, 8/12 px, 28 px). Every target keeps 24 px (WCAG 2.5.8); the content tree keeps its row pitch. The normal density looks as before.
  • Test: AdminDensityTest (the compact block sets only density tokens, compact controls stay at 24 px or more, the tree is not tightened, tables and cards read the tokens, the switch is a pressed button applied in the head script, the texts are translated; mutation-checked).

14 October 2026: the admin4 dashboard's figures

Updated

  • The key figures of the admin4 dashboard (content/dashboard.tpl) have an icon tile each (Bootstrap Icons 1.13.1: new graph-up-arrow, person-check, arrow-up-right, arrow-down-right, dash and their .a4-bi-* classes), and a figure that is a link shows an arrow in its corner. Tiles are at least 180px wide, one column on a phone.
  • "Published in the last 7 days" says how its full days compare with the 6 days before them: +5 against the 6 days before, without today (today is not over, so the trend leaves it out), with an arrow, in the muted text colour (a rise is not always good). Both weeks are summed from the 14 days the chart already counts, so the dashboard runs one query fewer than before.
  • The chart's numbers fold out under it as a table ("Show the numbers"), so the data is there in words for everyone, not only as bar heights.
  • New strings in design/admin/dashboard (ger-DE, eng-US). Test: AdminDashboardTest.

14 October 2026: admin4 left menu groups

Added

  • menu.ini can group a long left menu: Groups[] (order), GroupNames[<group>] (title), GroupLinks_<group>[] (link keys). admin4's parts/ini_menu.tpl draws each group as a <details> with a small-capital title (summary.a4-nav-title, a caret that turns), open unless the user closed it (admin4.js keeps the choice per browser in localStorage) and always open when it holds the current page; links in no group follow under "More". Leftmenu_setup is grouped into Content model, Site and URLs, Workflows, Users and messages and System. Without Groups[], or with Groups[] reset to no values, a menu is one list, as before; a link named in two groups shows in the first; the access checks per link are unchanged.
  • The current page's link in a left menu carries aria-current="page" and takes the accent tint with a straight bar on its left (square left corners; a Highlight edge in forced colors); a plain menu's list is named by its heading (aria-labelledby). The links are drawn by the new include parts/ini_menu_items.tpl.
  • The left column is a navigation landmark named "Side menu" (page_leftmenu.tpl).
  • New strings: "Side menu" (design/admin/pagelayout), the group titles and "More" (design/admin/parts/setup/menu), ger-DE and eng-US.
  • Test: AdminLeftMenuGroupsTest (every setup link in exactly one group, every group named, the details markup and the flat fallback, the unchanged access checks, aria-current and the current link's square left corners, the landmark, the stored state never closing the current page's group).

15 October 2026: a flat admin4 header

Changed

  • admin4's header is one row of 56 px from 1280 px: logo and site link, the main menu, the search, light/dark, the user menu and logout. From 576 px it is two rows (48 px for the logo and the tools, 40 px for the menu), on a phone one row of 56 px with the phone menu as before. It was 145 px at 1320 px with the menu of a few extensions: the tabs wrapped onto two lines and "Logout: admin" took a third.
  • The main menu stays on one line at every width from 576 px. The tabs that do not fit go, in their order, into a "More" list at the row's end (a disclosure: aria-expanded, aria-controls; arrow keys, Home, End and Escape), which looks like the selected tab while the current page's tab is in it. A ResizeObserver refits the row. Without the script the row scrolls sideways.
  • Logout is an icon button of its own (box-arrow-right) beside the user menu, named "Logout (admin)" in its aria-label and tooltip, on every width. The new user menu (person icon) shows the user's name and login and the links of the "Current user" box (change information, change password, e-mail preferences), which stays in the right column. On edit pages both are drawn disabled, as the menu tabs are. The markup is the new include parts/a4_header_user.tpl; admin4l's admin_theme_switch.tpl includes it, and its admin_tab_menu.tpl has the "More" item instead of the logout link.
  • The header search is 36 px high (its button was 43 px) and narrower from 1280 px (9 to 15 rem); "/" outside a field puts the focus in it (aria-keyshortcuts="/").
  • --header-height lives on the root element: admin4.css sets the drawn heights as defaults per width and admin4.js sets the measured height on <html> instead of .dashboard-flex, so the side columns, the sticky edit bar and scroll-margin-top read one value.
  • The header's cache blocks (admin4 and admin4l pagelayout.tpl) are keyed by the user's object id as well: the header names the signed-in user.
  • New strings: "Logout (%login)" and "User menu (%name)" (design/admin/pagelayout), ger-DE and eng-US. New icons: box-arrow-right.svg, chevron-down.svg (Bootstrap Icons 1.13.1), classes .a4-bi-box-arrow-right and .a4-bi-chevron-down.
  • Test: AdminHeaderTest (the rows and their heights, the "More" disclosure and its script, logout outside the user menu with its name, the cache keys, the "/" key, no literal colours in the new rules).

Upgrade notes

  • A site that overrides page_topmenu.tpl keeps its header. To take the new one, add {include uri='design:parts/a4_header_user.tpl'} and the li.a4-nav-more item of the new template; the old #header-usermenu-logout id is now on the logout icon.
  • Clear the template cache after the update: the header's cache blocks have a new key.

15 October 2026: admin4's content edit forms take its components

Changed

  • The attribute forms of content/edit (also translate, multiedit, user registration and the template look editor) draw the datatype templates' legacy markup as admin4's components, from modules/content.css: inside an attribute (.a4-content-attr) every text field, select and textarea looks like .a4-input (32 px, 14 px text, the field edge, the ring on focus, a drawn chevron on a one-line select), every input.button, input.button-disabled, button.button and input.defaultbutton like .a4-btn (outlined, the main action filled, a disabled button faded instead of hatched), a checkbox or radio like in .a4-check (18 px in the accent), a file field's button like a button. The form itself carries .a4-content-edit, so the buttons of the left column and the relations box take the same look; the sticky button bars keep their own rules. Forced colors get the native select back and keep the main action's thicker edge; touch screens get 16 px field text.
  • The attribute's name is a label or legend of the class .a4-content-attr-label (13 px, semibold) without the trailing colon; a grouped input (author, matrix, date and time, image, user account ...) is a fieldset of the class .a4-content-attr-group without a box or grey background of its own; the parts of an attribute take the .a4-label look (12 px, semibold) above their field, and a datatype template that still floats its parts (div.block div.element) has them in a row.
  • admin4 carries its own edit templates of the date, date and time, time, URL, price, image, file, option and user account datatypes: their parts stand in a row (.a4-content-parts) with a label above each field and no colon, headings without a control of their own are .a4-label paragraphs, the calendar is an .a4-btn-icon with the new icon class .a4-bi-calendar3 instead of calendar_icon.png (admin4's theme/rounded.css drops its #page .datepicker-icon rule, which the button no longer carries), the account status is a badge, the option list's columns name its fields and an empty option list is an .a4-empty. The left column's "Base translation on" radios are .a4-check rows. admin4 also carries ezmedia, ezmultioption, ezrangeoption, ezmultiprice, ezinisetting, ezpackage and ezobjectrelationlist: no colons, parts in a row, list fields named by their columns, the relation list's radio and checkbox lists as .a4-check rows and its invert-selection control as an icon button instead of toggle-button-16x16.gif, its "create new object" field with a label and shown through the hidden attribute instead of style, empty lists as .a4-empty, the INI setting warning as a status.
  • The text of the rich text editors (TinyMCE 3 and 8) follows admin4's light and dark mode: admin4.js adds components.css and the new editor_content.css (named by the edit form's data-a4-editor-css) to each editor's document and copies html[data-a4-theme] there; text, links, literal blocks, custom tags and embeds take the tokens. The editor's toolbar keeps its skin. Upgrade: an override of one of these datatype templates in another design keeps working. An extension's own datatype template inside the edit form now gets the field and button look; one that wants to keep its own look gives its controls a class with more weight than .a4-content-attr input (one class plus the element).
  • AdminContentEditFormsTest checks the rules and the templates (the copied datatype templates post the same fields, ids and label targets as design/standard's); AdminKeyboardTest and AdminFormErrorsTest follow the new classes.
  • The label of an attribute in admin4's edit form is the label of its field (for the field's id ezcoa-<class attribute id>_<identifier>, in the edit and the translation branch), so a text line, text block, number or e-mail field has an accessible name of its own; a grouped input keeps its fieldset and legend. Test: AdminFormLabelsTest.
  • design.ini [AdminDesignSettings] IconList[] follows the mask rules of components.css (the width switch's and the date fields' icons in, the removed compact view's icon out).

15 October 2026: one density in admin4

Removed

  • The compact view of admin4 and its header switch ("Compact view"): admin4 has one density, tuned for 1440 to 1920 px windows. Removed: the button in page_topmenu.tpl and admin4l's admin_theme_switch.tpl, the head script that applied html[data-a4-density], the html[data-a4-density="compact"] token block and the switch's styles, the class .a4-bi-view-stacked (the icon file stays: theme/rounded.css uses it). Upgrade: nothing to do. A signed-in user's eZPreference admin_density is no longer read and does nothing (delete it with DELETE FROM ezpreferences WHERE name = 'admin_density' if you like); a value an anonymous page kept in the browser (localStorage "exp-admin4-density") is removed on the next page. The strings "Compact view" and "Switch between normal and compact view" stay in the translation files, unused. An extension that styled html[data-a4-density="compact"] keeps its rule, which no longer applies.

Changed

  • One control height: .a4-btn, .a4-input and the fields of .a4-field, .a4-tabs, .a4-chip, the pager sizes and the confirm summary take --a4-control-h (32 px; they were 30 to 38 px), with 4 px block padding. The legacy text fields take the field text size (--a4-text-m, 14 px) like .a4-input.
  • table.list cells are 8 px above and below like .a4-table cells and 6 px a side (--a4-list-pad, was .4em, about 5 px): a trash or class list of ten columns still fits a 944 px content column.
  • No line between blocks: the dashed rule above every .box-header (theme/rounded.css) and admin4's hairline are gone, in the side columns, the node's children and every box; blocks in a side column are 24 px (--a4-gutter) apart (the header's padding, so no margins collapse), the width switch under a left menu too. The right column is --a4-right-width (16rem) wide, so its card keeps 208 px beside the gutter.
  • The density tokens (--a4-gutter, --a4-control-h, --a4-list-pad, --a4-cell-pad-*, --a4-card-pad-*) keep one value each in the token block. Card padding is unchanged (12/16 px).

Added

  • site.ini [SiteSettings] FrontSiteAccess: the site an admin siteaccess belongs to. Its "Open the site" link (admin3, admin4, admin4l) and the node view's "View on site" go there; empty (the default) keeps DefaultAccess. Before, every admin of an installation that serves several sites from one tree linked to the same default site. Set it in the admin siteaccess's site.ini.append.php. The template operator siteaccess_url() without a parameter now calls the new ezpSiteAccessURL::front(); with a parameter, and ezpSiteAccessURL::root(), nothing changes. A siteaccess that is not in AvailableSiteAccessList falls back to DefaultAccess with a debug warning. The header cache blocks key on the address, so each admin caches its own link. Compiled templates still call the old code until the template cache is cleared: after updating, clear the caches of each admin siteaccess (php bin/php/ezcache.php --clear-all -s <admin siteaccess>). Tests: ezpSiteAccessURLTest (front() without, with and with an unknown FrontSiteAccess, padded value), eZKernelTemplateOperatorsTest (the compiled template calls front()).

14 October 2026: the admin4 accessibility series, merged and refined

The 24 changes above (icons, language badges, context menus, landmarks, keyboard, tree view, form names, contrast, focus, modes, scale, tables, lists, layers, form errors, page header, notices, mode switch, timeline, density, dashboard, cards, menu groups, front site access) were merged together and refined:

Added

  • window.a4Pref (the first script of page_head_style.tpl) and design:parts/a4_preference_attributes.tpl: the frame's remembered choices are the signed-in user's eZPreferences, written on <html> before the page is drawn: admin_color_mode (light, dark, system), admin_density (normal, compact), admin_left_sidebar and admin_right_sidebar (the columns on a wide window), admin_navgroups (closed left menu groups). An anonymous page keeps them in the browser only; a choice the browser held before is taken along once. Test: AdminPreferencesTest.
  • window.a4CardBusy( card, on, message ) (admin4.js): a busy card sets aria-busy and inert together and announces a short message in a polite live region.
  • Layer tokens --a4-z-skip-link, --a4-z-controlbar-top, --a4-z-card-veil, --a4-z-card-spinner; the frame tokens --a4-explayouts-nav, --a4-explayouts-nav-edge and --a4-pw-toggle-room (each with its dark value).

Updated

  • admin4l (the admin siteaccess): its page layout has the skip link, the landmarks, the named side and top menus, the width grip as a focusable separator and the toast regions of admin4; its header blocks are copies of admin4's top menu again (Bootstrap icons, column tabs, mode and density switches), and AdminIconsTest keeps them in step.
  • No text on an admin4 page is below 12px: core.css sets the body to --a4-text-s (13px) and every em size of the base and page stylesheets that would land below 12px takes --a4-text-xs. The styling test keeps no color or spacing exception list any more.
  • Tables: the zebra's row filter sits in :where(), so a hovered or selected row shows its state; compact list rows keep 24px targets.
  • Context menus: Enter runs an item without a link too, a letter moves to the next item starting with it, the title is no item of the menu, the tree's menu buttons are named "Actions for <node>", and the class and node icon menus carry aria-haspopup before their first use.
  • The content tree's buttons name a node as it is called (no doubled entities for umlauts or &), its items name Shift+F10 in aria-keyshortcuts; the node tabs move with Arrow Left and Right only; each calendar button is named after its field.
  • Language badge links: the accessible name starts with the code shown (WCAG 2.5.3).
  • Contrast: the current and just-moved row bars, the header search edge, the language select edge and the tabs switch's on edge reach 3:1; mask icons and status dots stay visible in forced colors; reduced motion also stops jQuery's effects.
  • Side column tabs name their column, report aria-expanded, and on a page without that column name no target; the narrow and wide rules meet at 1023.98px.
  • Content editing: every validation message is escaped exactly once in the summary and at the field, and a summary link scrolls its field below the fixed header. A warning that reports a failed form is role="alert".
  • Toasts lie on --a4-z-toast above dialogs and above the debug bar; an error toast stays until dismissed; Escape closes a focused toast.
  • Breadcrumb: the last step is the current page also while editing; a name over 20 characters keeps its full name as title.
  • Dashboard: the trend compares full days only ("against the 6 days before, without today"); the bar chart leaves its numbers to the table for screen readers.
  • Audit timeline results, the left menu's "More" group and the mode switch ("Color mode") are translated; the left menu marks only the page itself as aria-current="page".
  • ezpSiteAccessURL::front() warns about an unknown FrontSiteAccess once per request also on Velocity's persistent workers.

Upgrade notes

  • Clear the template, template-override, template-block and content caches after the update (new templates and stylesheets), and hard-reload a browser that still shows the old stylesheet.

14 October 2026: admin4's base stylesheets on the tokens

Fixed

  • Links of the base stylesheets (core.css a, ul.tabs, the datatable's editable cells) take --a4-accent-text: the old #0b4e7a was 1.8:1 on a dark card (now 7.7:1; 5.5:1 in light mode).
  • The diff marks of content.css are drawn from the tokens everywhere, as the history page already was: added text --a4-ok, removed text --a4-bad, the shown side's changes ink on --a4-warn-bg (light text on yellow was 1.1:1 in dark mode, now 10.5:1), block changes on the ok and bad tints.
  • The focused header search field kept a pale yellow background under light text in dark mode (1.1:1); it is the field colour. The search button's hover is the header's own, not a teal border.
  • A focused input.button looks like a hovered one (theme/rounded.css painted it rgba(0, 0, 0, .1) over admin4.css); the ring marks the keyboard focus.
  • .a4-meter fills with --a4-accent-text: 5.1:1 (light) and 7.0:1 (dark) on its track, where --a4-accent was 2.9:1 in light mode (both copies of components.css).
  • The context menu's focus ring is var(--a4-ring) in both modes, without a dark-mode rule of its own.
  • The search scope pane fits a phone (min(300px, 100vw - 32px)); its title line takes --a4-line.
  • The tabs on the window edges that show the side columns take --a4-field-line with the card colour on it (3.2:1 and 4.0:1 on the page, the arrow 3.6:1 and 3.8:1 on the tab), instead of a fixed grey.
  • In forced colors (Windows high contrast) every module state shown only by border, background or shadow (a current chip or tab, a selected or moved card or row, a pressed preset, a changed settings bar) gets a Highlight outline (modules/*.css, both copies).

Added

  • A print style for admin4: the content alone on the full width, no header, menus, side columns, control bars, toasts or footer, no shadows; the head script prints in the light colours and goes back to the chosen mode afterwards. No dark fallback without JavaScript: the admin stays light then, its documented default.
  • Tests: AdminStylingRulesTest::testBaseStylesheetsUseOnlyColourTokens (no colour literal in core.css, content.css, pagelayout.css and the three theme stylesheets; the allowlist for kept literals is empty and only shrinks), AdminBaseStylesheetsTest (links, diff marks, forced-colors outlines, meter, button focus, menu ring, header parts, removed files, no vendor-prefixed leftovers, print) and the meter pairs in AdminTokenContrastTest; each new assertion was mutation-checked.

Updated

  • The rest of the colour literals of the base stylesheets are tokens as well (messages, rulers, the datatable and modal window headers, the default button's base colours, the footer links, the phone menu); the ones admin4.css overrode keep looking the same.

Removed

  • design/admin4/stylesheets/responsive.css: all but one rule was commented out. That rule (the padding of the status messages) is in pagelayout.css; BackendCSSFileList[]=responsive.css is gone from settings/design.ini, and admin3 names its own responsive.css in page_head_style.tpl, so admin3 looks the same.
  • setup-extensions.css in admin4 and admin: no kernel template loaded it. The Admin UI design (exp_adminui) loaded it through its design fallback and ships its own copy from exp_adminui 1.0.0.17 on.
  • Rules of admin4's base stylesheets for markup no admin4 template writes: the admin3 login page (.loginpage, .login-input-wrapper, #icon-login and the old #logintext/#loginbutton look, which admin4's sign-in page replaced), #header-topmenu, #header-usermenu, #canvas-top/-bottom, p.versions; and -moz-, -ms-, -o- transitions, shadows and gradients, filter: alpha(), zoom: 1 and the invalid linear-gradient(top, …).

Upgrade notes

  • A site whose own design.ini override still lists BackendCSSFileList[]=responsive.css keeps working: admin4 then finds admin3's identical file through the design fallback.
  • An extension that styled admin3's login page through admin4's pagelayout.css (.login-input-wrapper ...) needs its own rules; admin4's sign-in page never used them.

14 October 2026: names and request values in the admin designs are inserted as text

Fixed

  • Node, class, section and language names in the content tree menu, the popup menus and the relations list are inserted as text, and the sub-items name filter is escaped. The content tree keeps each node's menu data in the script and its menu button carries only data-node-id; one click listener opens the menu (mouse, Enter, Space, Shift+F10 and the context menu key as before). ezpopmenu_showTopLevel() keeps its parameters; its menuHeader is now set as plain text, and the %...% values of a menu item's content go into the item as text and attribute values (in all three copies of ezpopupmenu.js). ezpopmenu_submitForm() also fills the placeholders of the form's action.
  • admin, admin3, admin4 (and standard where the same line exists): request values and stored texts that were printed raw are escaped for where they stand (the login form's redirect address, the template list filter, the shop list offset, URL and workflow fields, currency and package error texts, validation messages, relation attribute and translation names, left menu names); names passed to a popup menu from an onclick attribute are escaped for the script and then for the attribute. The link class preview links only to http(s), mailto and addresses without a scheme and shows anything else as text.
  • wash( 'javascript' ) also escapes <, > and & (as \x3C, \x3E, \x26), line breaks (\n, \r) and the line and paragraph separators, so its output is safe inside a script element and inside an HTML attribute as well as in a JavaScript string; the value a script reads is unchanged. A JS-escaped value in an HTML attribute (an onclick) is HTML-escaped as well (|wash(javascript)|wash). The sub-items name filter reaches the server URL-encoded and as typed.
  • After the confirmation, content/hide returns to the page the user came from: the view has the administration UI context, so its confirmation page is not stored as the page to return to.
  • content/hide hides or reveals a node only on a POST; a link (GET) shows the confirmation form, in English and German. The Advanced popup menu's "Hide / unhide" and the Hide and Reveal buttons of the Locations tab (admin, admin4; new shared class .a4-link-button) submit a form.
  • The content tree icon's title is escaped once (a translation with an apostrophe showed &#039;). The redirect page of DebugRedirection escapes its address.
  • Tests: AdminTreeMenuEscapingTest (also: no kernel design links to content/hide), eZTemplateOperatorMatrixTest (wash javascript in a script and with &).

Upgrade notes

  • A custom template or extension that links to content/hide/<node> now gets the confirmation page first; post a form to the same address to act at once. A menuHeader passed to ezpopmenu_showTopLevel() with markup in it shows the markup as text. Output of wash( 'javascript' ) used outside JavaScript now shows \x3C for < and \x26 for &. Without ezformtoken, content/hide is protected by the POST and the browser's SameSite default only; ezformtoken adds the form token. Clear the template and template-override caches after the update: compiled templates keep the old escaping until then.

14 October 2026: the search of the administration

Fixed

  • content/search and content/advancedsearch take the section, the class, the class attribute, the period, the subtrees and the time limit of the request as integers; anything that is no positive number is none. The ezjscore search (ezjscServerFunctionsJs::search()) passes only positive integer ids (makePostIDArray()) and an offset and limit of at least 0 and 1. A class may still be named by its identifier (SearchContentClassID=article, SearchContentClassIdentifier): both searches look it up to its id (makePostClassIDArray(), AdvancedSearch::classID()), and an identifier that names no class is no class filter. Before, the values reached the search engine as they were sent, and eZ Find wrote them into its filter queries. Updating eZ Find as well is recommended: its query builder now casts the ids itself.
  • A section that does not exist no longer stops content/search and content/advancedsearch with a fatal error: the search is not limited to a section then.
  • content/advancedsearch: a class attribute that does not exist, or that belongs to another class than the one searched, is no attribute filter (before: an error with eZ Find, or no result at all). The page opened without anything to search for runs no search; before it listed every object.
  • A negative offset is 0; a search text is cut to site.ini [SearchSettings] MaximumSearchTextWords words (16) and MaximumSearchTextLength characters (1024), with a note on the page. Every word is a clause of the search engine's query, and a very long text made the search fail.
  • The pages of a search keep where it searches: every subtree as SubTreeArray[] and the section (before: the subtrees joined by commas, which the view dropped, and no section); also in the browse mode. The advanced search's pages pass on the words as typed, not with the phrase in front of them a second time.
  • The title of content/advancedsearch is escaped, as content/search's already was.
  • The built-in search engine (eZSearchEngine, without eZ Find) writes the class attribute and the subtrees into its SQL as integers (eZSearchEngine::subTreeNodeIDList()); a subtree id that was not above 0 no longer leaves a stray comma in the node query.

Updated

  • admin4, content/search and content/advancedsearch: a search the engine could not run shows an alert (.a4-notice.is-bad, role="alert") instead of "No results" and search tips; an empty text shows a prompt instead of "Search for <>"; the search form is a search landmark with a visible label and a search field. Each result shows the path of its location, so objects of the same name can be told apart.
  • admin4 pager (navigator/google.tpl): a navigation landmark "Pages", the current page marked with aria-current="page".
  • admin4 header search: the sections of its popup are fetched once for both copies (page_topmenu.tpl), the name of the node searched in comes from the page's path when it is on it, and the browse mode's page size never exceeds MaximumSearchLimit.
  • admin4 list filters of class groups, classes, workflow groups and sections say that they search only the page shown when the list has more than one page. Setup > RAD survey: the filter reacts to every input (typing, pasting, dictation), announces its count and is translated.
  • New strings (design/admin/content/search, design/admin/navigator, design/admin/setup/rad and the four lists), ger-DE and eng-US.
  • Tests: ContentSearchRequestTest, ezjscServerFunctionsJsSearchIDsTest, AdminSearchTest, eZSearchEngineIdsTest.

Upgrade notes

  • Clear the template and INI caches after the update. A site design of your own that overrides content/search.tpl or content/advancedsearch.tpl can read the new template variables search_requested and search_text_shortened, and $search_extras.error (eZ Find) for a failed search.
  • These rules apply on every siteaccess, the front ends included: without AllowEmptySearch, a search without a text (a link such as content/search?SubTreeArray[]=<node> used to list a subtree) runs no search and shows the prompt; a search text is cut to 16 words and 1024 characters (MaximumSearchTextWords, MaximumSearchTextLength). A value that is neither a positive number nor a class identifier is ignored, so the search is then not limited by it.

14 October 2026: administration hardening

Action required

exporting extensions is now off by default — list the extension in package.ini [ExtensionExportSettings] AllowedExtensions[] (or * for every extension) and grant the policy package/export_extension to the role. Without both, Setup > Extensions shows no download buttons and the package wizard does not let you choose the extension; the command line (ezpm.php add) needs the list entry.

Updated

  • Fixed: the AJAX uploader keeps the stored file on the server between its steps; the location form carries a one-time token bound to the user and the upload target instead of a file path (ajaxuploader/location.tpl of design/admin and design/admin4), and the file is taken only from the storage directory. The upload step checks the handler's canUpload() as the other steps do. [ezjscServer_ezajaxuploader] declares Functions[]=ezajaxuploader with RequirePolicy=disabled; enabled also asks for ezjscore/call with that function list.
  • Fixed: the AJAX uploader's browse step (ezajaxuploader::browse) asks the upload handler's canUpload() like the other steps, before it reads the node; the call names the handler (ezajaxuploader::browse::<handler>::<node>::<class>) and posts its target (ajaxuploader/browse.tpl, ajaxuploader/location.tpl and expajaxuploader.js of design/admin and design/admin4).
  • Fixed: audit/recent shows only the channels the Channel limitation of audit/read allows, refuses a channel outside it, and its "Verify now" button needs audit/manage, as on the console; the button keeps the channel filter.
  • Fixed: Setup > Extensions downloads a package through a button of its form (POST) instead of a link; it says why when it cannot and removes the temporary package. No package takes an extension that holds secret values or key material: one rule, eZExtensionPackageHandler::packRefusal(), refuses it for Setup > Extensions, the package wizard (which shows the reason and removes the package it had begun), the command line and addExtension() itself. The check runs on the extension before anything is copied and once more on the copy addExtension() makes in the package, which decides: what is packed is exactly what was read, and a refused copy is removed. It walks the same entries the package copies (packEntries()), hidden files and links included, and fails closed: a pattern that fails (PCRE backtrack or JIT limit), a name over 256 bytes, a value over 64 KB, more than 200,000 entries, a missing or unreadable directory and any other error refuse the extension. It refuses an extension with:
    • key material by name, any case: *.key, *.p12, *.pfx, *.jks, *.ppk, *.p8, *.gpg ..., also *.key.* (server.key.old), credentials*, *service-account*, .env and .env.* (not .env.example, .sample or .template), id_rsa*, .htpasswd, .netrc, .pgpass, and every file in a directory named jwt, .ssh, .gnupg, *.pem or *.key;
    • a symbolic link that leads outside the extension, or anything that is neither file, directory nor link;
    • a copy of a settings file (*.bak, *.orig, *.dist, *.old, *~ ...) below settings/ or named *.ini*;
    • in every file, text or binary, read whole and byte for byte as it is packed (and once more without NUL bytes, for UTF-16 text without a byte order mark): a private key (-----BEGIN ... PRIVATE KEY-----), Authorization: Bearer ..., service tokens (sk-..., AKIA..., AIza..., ghp_..., github_pat_..., xoxb-..., glpat-..., sk_live_...), define( '..._KEY', '...' ) (KEY, SECRET, TOKEN, PASS ...) and an address with a password;
    • in settings files (below settings/, any depth, or named *.ini*), every name = value, read as text, not as eZINI reads it (commented lines, Key[]=, blanks, quotes, BOM, CRLF), and in configuration files (*.yml, *.yaml, *.conf, *.cfg, *.properties, *.toml, .env* ...) also name: value and YAML values on the next line: under a secret name (password, secret, token, key, pass, pw, auth, bearer ...) every value but an empty one, a whole placeholder (..., xxx, ***, <your key>, Enter your key here), a switch (enabled, disabled, true, false, none, null, 0), a template placeholder (%name%, {name}, ${NAME}, {{ name }}, %env(NAME)%) or a measure or version the name announces (MaxTokens=4096, IEDisableWithPassword[]=11); an address, header name, type, mode or method (TokenURL, ApiKeyHeader) and a public key (PublicKey) are read only for a secret inside them; under another name a value with a real secret in it;
    • a file with a UTF-16 or UTF-32 byte order mark, a settings or configuration file with a NUL byte or over 4 MB, and a file or directory that cannot be read to its end. The text is read as it is: no byte order mark is removed, no line end changed, nothing decoded. A line holds the one pair it starts with, and the rest of the line is its value, read for secrets inside it; lines end at CR, LF, FF and VT; a YAML value may stand on any following, deeper line. Certificates (*.pem, *.crt, *.der, *.asc, such as a CA bundle) are refused only for a private key in them. In comments, constants and addresses of documents and tests, the usual example values pass (# Password=changeme, https://user:pass@..., Bearer test-...); an active setting is taken at its word (Password=secret is refused). The package wizard accepts only names of extensions.
  • Added: an extension can list findings of the check that are known examples in a package_check.ini at its root (AllowedFindings[]=<path>:<setting or (kind)>); key material, private keys, copies of settings files and links can never be listed. Guide: doc/eng-us/bc/6.0/extension-package-secret-check.md.
  • Updated: an extension package leaves out node_modules (the dependencies of a JavaScript build), which the check does not read. The extensions that ship example values under secret names carry a package_check.ini from ezauthorize 1.4.1, ezodf 6.1.8, hcaptcha 1.3 and recaptcha 1.4.6 on; on a development installation every extension packs except those with stray local files (editor backups of settings files, a test key file), which the check names.
  • Updated: audit/recent shows its "Verify now" button only to users with audit/manage.
  • Updated: the AJAX uploader's token lives an hour; files whose token expired or was dropped leave the storage. A location that is refused keeps the token and the stored file, so another location can be chosen, and the temporary copy of each upload stands in a directory of its own and is removed in every case.
  • Added: package.ini [ExtensionExportSettings], closed by default: AllowedExtensions[] lists the extensions that may be exported (empty, the default: none; *: every extension the check lets through; applies to every way, the command line included), and RequirePolicy=enabled (the default) makes exporting an extension in the administration (Setup > Extensions, the package wizard) need the new policy function package/export_extension (the Administrator role with all functions has it; disabled drops that requirement). Where exporting is not allowed, Setup > Extensions shows the reason instead of the download buttons, the package wizard disables the extension, and both say in a notice how to switch exporting on.
  • Fixed: Setup > System information > PHP leaves out the web server's request headers and environment and masks secret values (names ending in pw, pass, passwd, auth, bearer or authorization included, expSystemReportMask::isSecretSettingName()); Setup > Site data masks secret values; Setup > Sessions no longer gives session keys to the template (design/standard posts and selects session references like the admin designs). The system information names the front site of the admin (FrontSiteAccess) as the public site.
  • Fixed: the dashboard reads a block's Priority as a number.
  • Tests: ezjscServerFunctionsAjaxUploaderTokenTest, expAuditRecentChannelsTest, ExtensionDownloadTest, SetupInfoMaskTest.

Upgrade notes

  • Action required: exporting extensions is now off by default — list the extension in package.ini [ExtensionExportSettings] AllowedExtensions[] and grant package/export_extension.
  • Clear the template and INI caches after the update. A site with its own override of ajaxuploader/location.tpl replaces the hidden UploadFile field with <input type="hidden" name="UploadToken" value="{$upload_token|wash()}" />. A site with its own override of ajaxuploader/browse.tpl puts the handler in its links: ezajaxuploader::browse::{$handler_id|wash()}::<node>::<class>.

14 October 2026: admin4 clean-up

Fixed

  • On a siteaccess that requires a sign-in (RequireUserLogin=true, normally the admin), ezjscore/call needs a signed-in user: ezjscore's site.ini no longer lists it in [SiteAccessSettings] AnonymousAccessList. Front ends (RequireUserLogin=false) do not use the list and are not affected. The debug bar shown by IP on an anonymous admin page (the sign-in page) then shows "Could not load" in its server panels.
  • The header's logo names the release only to signed-in users; the anonymous pages of the admin (the 401 page, user/register, user/forgotpassword) draw the same frame and said "Exponential <version>" (admin4, admin4l).
  • menu.ini: twelve PolicyList_* entries named a view instead of a policy function (visual/toolbarlist, class/grouplist, role/list, workflow/processlist ...); those modules have no policy functions, so any limited policy answered "no". They are <module>/*. The Design tab opens content/view/full/58 (the design top node, DesignRootNode) instead of the URL alias Design/.
  • setup/designextension: the file anchors used the operator simplify_tag_name, which does not exist; they are numbered now (admin, admin4).
  • The version preview's full-screen link has a text alternative; the product previews translate "Category".
  • The crystal-admin icon of the job class was missing in the ghost and original sizes; MimeMap[video/video/...] in the crystal and crystal-admin icon.ini is video/vnd.rn-realvideo.
  • Scripts (admin4, and the identical copies in admin): ezpopupmenu.js declares its variables, places a submenu without a parent menu instead of throwing, and hides the menus on an outside press through an event listener instead of replacing document.onmousedown; the content tree draws a node of a class its cached parameters do not know (its parent stayed busy) and loads a node again when it is pressed after an error; both tab switch links are handled by node_tabs.js (the show link reloaded the page through a GET that changed the preference); the relations search uses keydown/e.key and reads error_text only from an answer; the upload dialog decodes its answer inside a try and adds its hidden fields once; the sub-items table has no repeated id="thumbfield", and Create here is disabled where no class may be created (the item id was passed with quotes).

Added

  • admin4 carries the 21 templates it took from design/admin: audit/* (eleven), parts/audit/menu.tpl, tabs/audit/node_header.tpl, contentfinder/* (six), infocollector/collectionlist.tpl and view.tpl, adapted to admin4's checks (scoped header cells, status messages, an icon button and named checkboxes). AdminSelfContainedTest fails for a template the kernel or an admin4 template names that only design/admin or design/admin3 has.
  • German translations of 87 admin4 texts (mostly the content jobs pages) and the same sources in eng-US.
  • The Quality workflow and the pre-commit hook check changed JavaScript files with node --check.
  • Tests: AdminMenuPolicyListTest, AdminScriptsTest, AdminSelfContainedTest, ezjscoreAnonymousAccessTest, the crystal themes in AdminIconsTest, the brand name in AdminPageHeaderTest. AdminStylingRulesTest scans override/templates too and fails for a listed design that does not exist (it listed admin2).

Removed

  • From admin4: six templates nothing used (test.tpl, page_menuhead*.tpl, pdf/edit_validation.tpl, content/confirmtranslationremove.tpl), the user tab overrides window_controls_user.tpl and windows_user.tpl (no override rule has matched them since 4.6), 37 images no admin4 template, stylesheet or script reaches (images/2/*, most of images/3/*, old message and sprite images), leftmenu_widthcontrol.js (design/admin's column grip), and the unused functions wrapTable() and adjustHeaderHeight() of ezadmin_menubar.js, whose other functions are no longer globals.
  • The inline style of embed_image.tpl (admin, admin4) and template_look_edit.tpl (admin).

Upgrade notes

  • An admin siteaccess that relied on anonymous ezjscore/call (none of the shipped pages does) must sign the user in first. A front end with RequireUserLogin=false (the default for front ends) is unaffected; a front end with RequireUserLogin=true whose pages call ezjscore/call anonymously (rating, captcha refresh, …) adds AnonymousAccessList[]=ezjscore/call to its own siteaccess site.ini.append.php.
  • The Design tab opens content/view/full/58, the node content.ini [NodeSettings] DesignRootNode names in a standard installation; a site whose design node has another id overrides [Topmenu_design] URL[default] in menu.ini as well as DesignRootNode.
  • A role that should open Setup > Classes, Roles, Workflows, Triggers, URLs, Search statistics, Settings, the template list, the toolbars or the menu settings through the left menu needs the module (class/* ...), as before; a custom PolicyList_* that names a view of these modules is better written as <module>/*.
  • A site whose admin override rules point at window_controls_user.tpl or windows_user.tpl removes the rules, as since 4.6. Clear the template and template-override caches after the update.

15 October 2026: admin4 on phones, tablets and wide windows; one size for legacy controls

Updated

  • admin4's legacy buttons, selects and text fields (input.button, input.defaultbutton, button.button, fields without a class) take the size of its buttons and fields (--a4-control-h), with a select chevron in the tokens and the native select back in forced colors; every left menu shares one look and ends with one width switch (parts/left_width_control.tpl: three icon buttons in a POST form, the current width pressed) and one trash link (parts/left_trash.tpl); the node's tab box is a grid, so the end of the open tab is no longer cut off. New: .a4-tabs.is-segmented / .is-icons. An extension's navigation part without the ez prefix finds its left menu in admin4 and in admin4l. Test: AdminLegacyControlsTest.
  • admin4 fits phones, tablets and wide windows: an opaque phone menu, a top menu on one line from 576 to 1279px, the right column as a drawer below 1280px, a content column capped at --a4-content-max (1680px), body text from 13 to 14px on very wide windows (--a4-text-body), one gutter (--a4-gutter), the node's tabs scrolling on a phone, search results with their path under the name, 24px targets on touch screens. Tests: AdminResponsiveTest, AdminSidebarDrawerTest.
  • The right drawer remembers whether it is open: the eZPreference admin_right_drawer (localStorage on an anonymous page), saved and restored on windows from 576 to 1279px (960px at 200% zoom included), never on a phone, and apart from the wide-window choice admin_right_sidebar. Opening a drawer with its tab moves the focus into it, Tab and Shift+Tab stay inside the open drawer and its tab, Escape closes it and returns the focus to the tab.

Fixed

  • A link of the online editor whose target is gone no longer says only "Invalid link: ... Target node does not exist". The message names the link by its text and, when the target is in the trash, its name, class, place and the time it was trashed, and says how to repair the link; a link by location id (eznode://42) also says that restoring the target does not repair it, because the restored object gets a new location. Name, class and place are shown only to a user who may read the target; a trashed object has no location, so a user whose read policy is limited to subtrees or nodes gets the short message that the target is in the trash. The trash is looked up only for a link whose target is gone, so a link to a live target costs no query. German translations included.
  • The messages of the validation log are plain text, and every design prints them escaped exactly once: the online editor's link messages keep the target's name, its place, the link text and address as typed, the XML text parsers store their messages as characters (eZXMLInputParser::plainText(): several translations spell tag names as &lt; and &gt;), and design/admin, design/admin4 and design/standard print every validation, location, custom rule and field message with wash (content/edit, info collection, user registration, the PDF export form); the step in admin4 that turned &lt; and &gt; back before washing is gone. exp_adminui 1.0.0.18 and sevenx_themes_media 1.5.56 do the same in their own edit_validation.tpl. Test: expOEValidationMessagesEscapedOnceTest renders each design's template with a name holding markup, &, quotes and umlauts.
  • Test: expOEDeadLinkMessageTest.
  • A script link or an invalid e-mail address in the online editor marks the input invalid (eZOEInputParser::isValid() is false): the parser set isInputValid, a property it does not have, instead of IsInputValid, which PHP 8.2 reported as a dynamic property, and the e-mail check read the undefined property errorLevel. The messages and the saving of the text are unchanged; ezoe does not read isValid() when it saves. Test: expOEInputValidFlagTest.
  • The online editor recognizes a script link in every spelling a browser follows (character references with or without ;, &colon;, &Tab;, tabs and line breaks in the scheme, leading spaces and control characters, any case) and refuses javascript:, vbscript:, livescript: and data: with anything but a plain image type; "javascript" in a path, query or file name stays an ordinary link. Every address of a mailto: link is checked (several addresses, percent-encoding, a query), and internationalized domains are accepted (kontakt@bücher.de, an xn-- top level domain), which the previous pattern refused. The script link message is translatable. Test: expOELinkAddressCheckTest.

Upgrading

  • A design of your own that overrides content/edit_validation.tpl (or the info collection, registration and PDF export messages) prints each message with |wash, once. The messages no longer carry &lt;/&gt;; a template that printed them raw now shows a tag name as markup, and one that turned the entities back keeps working but can drop that step.

15 October 2026: the online editor's parser and a line break with attributes

Fixed

  • The online editor's input parser no longer warns "foreach() argument must be of type array|object, false given" (eZOEInputParser::setAttributes()) for a <br> that carries attributes. The output tag br declares 'attributes' => false, and the override that maps attribute names in any case iterated over it. The handler writes every empty table cell as <p><br data-mce-bogus="1"/></p>, so an editor that posts the text back unchanged (switching the editor, saving without an edit) raised one warning per empty cell; where errors are displayed it was printed before the doctype and put the page in quirks mode. The stored XML is unchanged. Test: expOEInputBreakAttributesTest.

15 October 2026: findings of an end-to-end test of the administration

Fixed

  • content/history: a version's Compare button orders the two versions by number (History::comparedPair()); an explicit From/To choice stays as chosen. Test: expContentHistoryListTest.
  • The AJAX uploader refuses a missing or incomplete AjaxUploadHandlerData with "Arguments error." instead of an ArgumentCountError. Test: ezjscServerFunctionsAjaxUploaderHandlerArgumentsTest.
  • Browse: the quick select of the current location is pre-checked only for radio buttons ("Add locations" added a location under the start node). Test: AdminBrowseCurrentNodeTest.
  • content/hide: after the confirmation the view returns to the page the form was opened from (RedirectURI, checked as a local return address), not to the page last viewed in the session. Test: expContentHideReturnTest.
  • The "Edit in" menus name languages like the other menus; German and American English for the content job pages; a keyboard-opened context menu stays at its trigger and below the fixed header; the edit bar's save status moves no button and the editor toolbars stay below the bar (TinyMCE 3 and 8). Tests: AdminJobTranslationsTest, AdminContextMenuTest, AdminEditBarTest.

Removed

  • Script and stylesheet links carry no version query: with packing off the packer writes each file's own address (the packed files keep their time in their name), and the TinyMCE 8 edit template no longer appends its cache key to its links. A browser that kept an old copy is reloaded by hand. Test: ezjscPackerNoVersionQueryTest (no template, script or class of the kernel and its extensions puts ?v= on a link).
  • ezjscore 1.8.0 (dual release with se7enxweb/ezjscore): the uploader's browse step asks the handler, its arguments are checked, unpacked links carry no version query.

15 October 2026: the admin4 Setup overview

The Setup tab opens an overview of every Setup function as a tile, grouped as the Setup left menu. Feature.

Added

  • setup/menu in admin4 (setup/setupmenu.tpl): a page header and one section per group of the Setup left menu (Content model, Site and URLs, Workflows, Users and messages, System, and "More" for links an extension adds). Each tile is a card with a Bootstrap icon, the function's name as its link (the whole card is the target, one focus ring around it) and one line on what it does; the description is the link's description (aria-describedby). The grid counts the content column (container queries): one column on a phone, then two, three and four. Light and dark, forced colors.
  • The tiles are the left menu: parts/ini_menu.tpl takes menu_layout='tiles' and draws the same links, groups and access checks (PolicyList_<link>[], CheckViewAccess with fetch( 'user', 'can_open' ), NoAccessLinks) through the new include parts/ini_menu_tiles.tpl. A function the user may not open has no tile (with NoAccessLinks=disabled: a tile without link and without figure); a link an extension adds to [Leftmenu_setup] shows under "More".
  • Icon, description and figure of a tile are settings of the menu's section, per link key, so an extension sets them for its own link in its menu.ini.append.php: LinkIcons[<key>] (an icon of the new design.ini [AdminDesignSettings] IconList[], every .a4-bi-* mask rule of components.css; an unlisted name or none draws the plug icon), LinkDescriptions[<key>] (translated in the menu's context, like LinkNames[]) and LinkFigureTemplates[<key>] (a design: template included with the key). The core Setup links carry theirs in settings/menu.ini. An extension adds an icon of its own with its SVG, a mask rule in a stylesheet of BackendCSSFileList[] and IconList[]=<name>.
  • Small figures where one cheap fetch gives them (setup/setupmenu_figure.tpl): classes, sections, content languages, the active extensions that exist (expinfo( 'active' )), signed-in users; one and many are two strings. Only on a tile the user may open.
  • menu.ini LeadLinks[]: links shown before the groups of a grouped left menu, in none of them. [Leftmenu_setup] starts with Links[overview]=setup/menu ("Overview"), its lead link, marked with aria-current="page" on the overview; the overview itself leaves it out.
  • The classic designs show a short page at setup/menu (design/admin/templates/setup/setupmenu.tpl): the heading, a pointer to the Setup menu and the setup toolbar a site configures in toolbar.ini, as before.
  • 35 icon classes .a4-bi-* in both copies of components.css and 25 Bootstrap Icons 1.13.1 SVGs, unchanged (MIT), in share/icons/bootstrap-icons/svg/.
  • New strings in design/admin/setup/setupmenu (title, subtitle, the figures) and in design/admin/parts/setup/menu ("Overview", one description per function), ger-DE and eng-US.
  • Test: AdminSetupOverviewTest (the tab's address and the lead link, every Setup link with an icon and a description in menu.ini, every icon listed with a rule and a Bootstrap file, IconList[] equal to the rules, no icon used twice, each grouped link once, the access checks before the tiles, cheap figures in the link branch only, no image but the icon masks, the grid and focus rules, the translations; the tiles of a fixture menu drawn by the template engine, with an extension's icon, description and figure, an unlisted icon falling back to the plug and a tile without access; who opens setup/menu, evaluated by eZUser::hasAccessToView()). AdminLeftMenuGroupsTest allows the lead links outside the groups.

Updated

  • [Topmenu_setup] URL[default] is setup/menu instead of setup/cache.
  • The view setup/menu opens with any one of the setup module's functions (setup, administrate, managecache, managecronjobs, preload, system_info) instead of setup only, so a role that reached the Setup tab through setup/managecache keeps it; the page shows only what the user may open. A user with none of them gets the access denied page, which names the last function asked (system_info).

Added: tabs into Setup

  • [Leftmenu_setup] GroupTabs[<group>]=<tab>: a group of the Setup menu and overview that stands for a top tab and is drawn only while that tab is not in the top menu (left out of Tabs[] or named in HiddenTabs[]); its pages then mark the Setup tab and draw the Setup menu (parts/a4_navigation_part.tpl, used by page_topmenu.tpl, page_leftmenu.tpl and admin4l's header and left menu blocks). GroupSections[<group>]=<section> adds the links of another left menu to the group with their names, access checks and tile settings; a Setup link to an address that menu has too shows there only. Feature.
  • Groups Design (design_node, the Design tab's page, plus [Leftmenu_design]) and Audit ([Leftmenu_audit], its channel limits as in the audit menu) in [Leftmenu_setup], for the Design and Audit tabs. Every tab stays by default: HiddenTabs[] is empty.
  • LinkIcons[] and LinkDescriptions[] in [Leftmenu_design] and [Leftmenu_audit]; six Bootstrap Icons 1.13.1 SVGs (archive, brush, exclamation-triangle, list-ul, speedometer, terminal) with their .a4-bi-* rules and IconList[] entries; the descriptions in design/admin/parts/design/menu and design/admin/parts/audit/menu, "Design", "Design content" and its description in design/admin/parts/setup/menu, ger-DE and eng-US.
  • [TopAdminMenu] SetupTabs[] (empty by default): tabs that leave the top menu only for a user who may open setup/menu, who finds their group under Setup; a user who may not (a role with only the tab's own policy) keeps the tab and its menu. Read by the topmenu operator (eZTopMenuOperator::canOpenSetupOverview()), the Setup menu, parts/a4_navigation_part.tpl and the logo. Test: eZTopMenuOperatorSetupTabsTest.
  • Without the Dashboard tab the logo leads to the dashboard for a user who may open it, instead of the content root.
  • A page of a moved tab draws the Setup menu also when its view names a left menu of its own: the audit views ($module_result.left_menu, design:audit/menu.tpl) drew the audit menu beside the marked Setup tab. While the tab is in the top menu, the view's own menu stays (page_leftmenu.tpl, admin4l's left menu block). Test: AdminSetupTabsLeftMenuTest.
  • parts/ini_menu_tiles.tpl reads icon, description (with its context) and figure from each item, which parts/ini_menu.tpl takes from the section the link comes from.
  • Test: AdminSetupOverviewTest (the Design and Audit groups, their sections, pages, icons, descriptions and translations, no tab hidden by default, the navigation part drawn with the tabs shown and hidden, the logo); AdminLeftMenuGroupsTest allows a group whose links all come from GroupSections[].

Upgrade notes

  • To take Design, Audit or the Dashboard out of the top menu, list them in [TopAdminMenu] HiddenTabs[] of the admin siteaccess and clear the INI and template caches.
  • A site that wants the Setup tab to open the cache page again sets [Topmenu_setup] URL[default]=setup/cache in its menu.ini.append.php. A site that gave Leftmenu_setup its own Links[] adds Links[overview]=setup/menu and LeadLinks[]=overview for the overview's left menu entry.
  • Clear the INI, template and template-override caches after the update.

15 October 2026: the Healthy Eating landing page has its own subtitle

Fixed

  • The Healthy Eating landing page showed the Fitness subtitle ("State of complete physical, mental, and social well-being"): the text block under its title was a copy of the Fitness one, as in the upstream reference. It now reads "Simple, nourishing food for an active life". The layout seed carries the new text in explayouts 1.4.25 and sevenx_themes_media 1.5.57 (one block parameter; no row renumbered), so a new installation shows it too.

Upgrading

  • An existing installation keeps the old text until an editor changes the block in the layout editor (Landing Page / Healthy Eating, header zone).

15 October 2026: pausing cronjob parts

Added

  • Setup > Cronjobs pauses a cronjob part, or every part, until someone resumes it. runcronjobs.php leaves a paused part out whoever starts it (the crontab, ./console cron:<part>, the console), before it asks the cronjob/part/run filter; a script run alone is left out while a part that lists it is paused. The cards show Paused, since when and by whom, a notice says so while every part is paused, the Pauses section lists who paused and resumed what (var/<vardir>/log/cronjob_pause.log), and a paused part cannot be run from the console. The state is a file per part (<cache>/cronjob_control/cronpart_deactivated_<part>.txt, 1 = paused), the layout extensions that paused parts already use. New: expCronjobPause, cronjob.ini [PauseSettings], Pausing cronjob parts. Test: expCronjobPauseTest.

Updated

  • A part paused while it runs finishes the script that runs, and runcronjobs.php leaves out the scripts after it (it asks the pause again before each further script) instead of running the part to its end.
  • Every run a pause leaves out is written to <log directory>/cronjob_skipped.log with the part, the script and the siteaccess, besides the notice in the run's output.
  • The state file is written to a temporary file and renamed over the old one, and the state file and the history get StorageFilePermissions, so the command line or Velocity's workers (root) and the web server (the site user) can each replace what the other wrote.
  • Pauses and resumes are audited: system.cronjob.pause and system.cronjob.resume.
  • The feature page covers several servers (a shared [PauseSettings] Directory) and what a pause does not hold back (the queue workers of expqueue that run as a service).

Upgrading

  • A part an extension paused through such a file (holding 1) is left out from now on; on Exponential those files had been read by nothing. Look at Setup > Cronjobs after the update and resume what should run, or set cronjob.ini [PauseSettings] Pause=disabled.

15 October 2026: a note with a cronjob pause, and the paused parts at a glance

Added

  • Setup > Cronjobs has a field Note for a pause or resume (optional): the next Pause or Resume keeps it with its line in cronjob_pause.log (one line, at most 200 characters). The note shows on the paused part (compact row and card), in the notice of a pause of every part, in a Note column under Pauses, and in the audit record (after.note of system.cronjob.pause and system.cronjob.resume). expCronjobPause::pause() and resume() take it as a third argument; new expCronjobPause::cleanNote() and NOTE_LENGTH.
  • The overview of Setup > Cronjobs has a Paused figure (every part while all are paused).
  • The Cronjobs tile of the Setup overview says 1 part paused, n parts paused or All parts paused while a pause holds: menu.ini [Leftmenu_setup] LinkFigureTemplates[cronjobs] and the fetch fetch( 'setup', 'cronjob_pauses' ) (enabled, all, count).

Updated

  • The notice runcronjobs.php prints for a paused part says since when, by whom and why: ... is paused (Setup > Cronjobs). Paused since 2026-10-15 13:05 CEST by admin: Index rebuild until noon. The same line goes to cronjob_skipped.log. New expCronjobPause::describe().
  • Translations ger-DE and eng-US; Pausing cronjob parts. Tests: expCronjobPauseTest, AdminSetupOverviewTest.
  • The history keeps who paused as the user id only; the console and the notice show the user's current login, looked up by the id. The history is read again in each request, so a persistent Velocity worker shows a pause another process made since. The Setup tile counts only the parts cronjob.ini defines.

Upgrading

  • Nothing to do. History lines written before have no note; a site that set its own LinkFigureTemplates[] for the Setup menu adds LinkFigureTemplates[cronjobs]=design:setup/setupmenu_figure.tpl for the new figure.

Added

  • Setup > Cronjobs shows Docs beside a script that names its documentation, in the compact list, on the part cards and under the available scripts; it opens in a new window. A script names it with the @link tag of its header (or of its class file, #207): a URL, or a path that is put after cronjob.ini [ScriptDocumentation] DocumentationBase (empty: Exponential's documentation on GitHub). An installation gives or replaces it per script with [ScriptDocumentation] Link[<script>], by the file name; none removes it. Only http and https links are shown. New expCronjobRunner::scriptDocumentation() and documentationUrl(); scriptDescription() reads the header as before.
  • The kernel's scripts rssimport.php, session_gc.php, notification.php, workflow.php, mailpreferences.php, mailbounces.php and querycache_cleanup.php link to their guide. Translations ger-DE and eng-US, the console's feature page and the cronjobs guide (section 11). Test: expCronjobScriptDocumentationTest.

Updated

  • With the default DocumentationBase a path is linked only when the installation ships that file, so a typo or an unpublished page never becomes a dead link; the relative path in the header of an extension's script is not put after Exponential's base (it names a file of that extension). A script that names nothing shows no Docs until [ScriptDocumentation] Link[] gives one.

15 October 2026: a compact view of the cronjob parts

Added

  • Setup > Cronjobs lists the cronjob parts in a compact table by default: one row per part - the part with n scripts, which opens them one per line with what each does in a row below it (several at once), its badges, the schedule in words with the next run below it, the last run from here with its result, and Pause/Resume and Run - on one page, wrapping to the window's width. Run and filter is folded, so the page opens on the overview and the list. Detailed next to the heading shows the cards as before, paged. The choice is the user preference admin_cronjobs_view (compact or cards). The search and the Show filter work on the rows. Both administration designs; translations in ger-DE and eng-US. Test: expCronjobsCompactViewTest.

Updated

  • Compact and Detailed are two toggle buttons (the shared segmented control) that write the preference by a POST to user/preferences/set with the page's form token, as the other preference switches of the administration, instead of GET links; the current view is the pressed button.

15 October 2026: the node view's tab box keeps its height

The box of the node view's tabs (View, Details, ...) keeps the height the user drags it to, for every node. Feature ("Height of the node view's tab box").

Added

  • admin4: a grip at the bottom right edge of the tab box (parts/a4_tabs_height.tpl, in node/view/a4_full_body.tpl and node/top_node_full.tpl), a focusable separator for the mouse, touch and the keyboard (Arrow Up and Down 16px, Shift+Arrow, Page Up and Page Down 64px, Home and End, a double-click for the default size: the keys and steps of the left column's and the debug panel's grips), in place of the browser's resize grip, which neither the keyboard nor a touch screen can use. The height is held between 10em and 60em and at most 85% of the window; a phone (below 576px) keeps the default. A "Default size" button (icon arrow-counterclockwise, added to IconList[]) shows while the height is not the default and forgets it.
  • The eZPreference admin_node_preview_height (pixels; one value for all nodes), written on <html> by parts/a4_preference_attributes.tpl outside every cache-block and applied by admin4.js before the page is drawn. It is saved once at the end of a drag or 400ms after the last key, and only when it changed. Test: AdminNodeTabsHeightTest (its behaviour runs in node, tests/tests/design/js/tabs_height.js).

Updated

  • window.a4Pref (page_head_style.tpl): a preference that cannot be saved (no fetch, a network error, an error status, a redirect such as the login page of an expired session) is kept in the browser's localStorage instead, which is read while the preference is empty; set( name, '' ) also removes the browser's copy. The request is sent with keepalive, so a change saved as the page is left still arrives.

15 October 2026: the Setup groups AI and Updates

Links that extensions add to the Setup menu no longer land unsorted under "More": the left menu and the Setup overview have two more groups, AI and Updates, and "More" stays the last group for links that name no group yet. Feature.

Added

  • [Leftmenu_setup] of settings/menu.ini: Groups[]=ai ("AI") and Groups[]=updates ("Updates") after System. AI holds the links of expai (expai_summary, expai_models, expai_budgets), Updates those of ezupdate (ezupdate) and git_manager (git_manager, git_manager_backup). The job queue (expqueue) and the database editor of sevenx_dse (dse) go to System. A key whose extension is not active shows nothing, and a group without a link the user may open is not drawn, so a site without these extensions sees the Setup menu as before.
  • Icons and descriptions for these eight links (LinkIcons[], LinkDescriptions[]): stars, cpu, piggy-bank, list-task, database, cloud-arrow-down, git, archive, eight Bootstrap Icons 1.13.1 SVGs, unchanged (MIT), in share/icons/bootstrap-icons/svg/, with their .a4-bi-* rules in both copies of components.css and in design.ini [AdminDesignSettings] IconList[].
  • New strings in design/admin/parts/setup/menu: the group names "AI" and "Updates" and the eight descriptions, ger-DE and eng-US (the group names also in untranslated).
  • An extension sorts its own Setup link into a group with GroupLinks_<group>[]=<key> in its menu.ini.append.php, e.g. GroupLinks_ai[] or GroupLinks_updates[]; documented in the feature page ("Extending it").
  • Tests: AdminLeftMenuGroupsTest checks the group order (the five core groups, then AI and Updates, "More" built last), that AI and Updates hold only extension links that core does not ship itself, and draws the Setup menu with the template engine and the real groups for links of active extensions: only groups with a link appear, in their order, "More" last with the link of an extension that names no group, the overview before the groups; the same as tiles with the new icons; and with no extension link the AI, Updates and More groups are gone. AdminSetupOverviewTest requires an icon and a description for every grouped link, an extension's too.

Upgrade notes

  • A site that reset Groups[] for its own grouping of Leftmenu_setup keeps it; to have the new groups, add Groups[]=ai and Groups[]=updates there. Clear the INI and template caches after the update.

15 October 2026: the user preferences of admin4 show on and off as tabs

Updated

  • admin4: the User preferences block of the right column (Locations, Re-edit) draws "on" and "off" as the design's tabs (ul.tabs, like the node view's tabs) instead of a segmented button switch with a filled part: the current state in the accent tint with a bar under it, the other state quiet. Each choice is a group named after its preference, the tabs are at least 24px high, and under forced colors an outline marks the current tab of every ul.tabs (the tint and the bar are dropped there). Styling rules; AdminLegacyControlsTest.
  • admin4: the two states are the buttons of a POST form to user/preferences/set, as the left column's width switch, instead of GET links: a preference is written only from the site itself (with the form token), and the current state is the pressed toggle button (aria-pressed="true"), which a screen reader announces as such. The template draws both preferences from one list.

Upgrade notes

  • A site design that overrides toolbar/full/admin_preferences.tpl for admin4 keeps its own markup; to get the tabs, copy the new markup. Clear the template cache after the update.

15 October 2026: "Open the site" among the header's tools

Changed

  • admin4's "Open the site" link (box-arrow-up-right) left the logo for the header's tools: it is the first of them, before light/dark, the user menu and logout, and a round 36 px icon button as they are (.a4-header-btn: border, hover, focus ring, light and dark). The header reads logo | menu | search | site | light/dark | user menu | logout from 1280 px; from 576 px the tools head the first row's end, and on a phone the row is logo | site | light/dark | user menu | logout | menu button. The header keeps its heights (56, 88 and 56 px). The tools stand in the markup in the order they are drawn, so the Tab key goes search, site, light/dark, user menu, logout (the main menu and the phone menu button keep their place before them in the markup, as before). Name, tooltip and address are unchanged ("Open the site", siteaccess_url()), and it opens in the same window; on edit pages it stays a link, as before.
  • The link is #header-site > a.site-preview.a4-header-btn in page_topmenu.tpl, once instead of once per branch of the edit check. admin4l: it moved from the admin_logo block to the admin_theme_switch block, before the light/dark switch. The header's cache blocks keep their keys (siteaccess_url() was already one of them).
  • Removed: the .site-preview rules of admin4's admin4.css and pagelayout.css (its 24 px icon beside the logo).
  • Test: AdminHeaderTest::testTheSiteLinkIsTheFirstOfTheTools (markup order in both designs, the logo without the link, the cache keys, the CSS order and the phone row); AdminLandmarksTest counts the one link.

Upgrade notes

  • A site design that overrides page_topmenu.tpl or admin4l's admin_logo.tpl / admin_theme_switch.tpl keeps the link where its own copy has it; to follow, move the #header-site block before #header-theme. CSS of your own for .header-logo .site-preview no longer matches anything. An admin4l layout without the admin_theme_switch block has no "Open the site" link any more; add the block to show it. Clear the template cache after the update.

15 October 2026: the online editor's engine switch is one control

Updated

  • ezoe's engine switch below a rich text field (EngineSwitch=enabled) is one control instead of one button per other engine: a group named "Editor" with every available engine in the order of Engines[], the engine in use marked as the pressed one (see below), every other engine a button. The buttons post the same custom action as before (CustomActionButton[<attribute id>_switch_engine_<engine>]): the draft is stored with the text typed so far, then the choice is saved as the user preference ezoe_engine; their title still says "Switch to the editor ...". admin4 draws the group as a segmented control at the control height (--a4-control-h), left-aligned under the field and apart from the editor's status bar, under its label on a narrow phone; other designs show the label, the engine in use and the buttons. New: the input handler's template attribute engine_switch_options and expOEEditor::switchOptions() (every engine with a current flag); engine_switch_choices stays. Tests: expOEEngineConfigTest, expOEEditorRegistryTest, AdminContentEditFormsTest.
  • The engine switch is a segmented control of toggle buttons, as admin4's width switch: the engine in use is the pressed button (aria-pressed="true", type="button", so pressing it again posts nothing) instead of a span, so it stays in the Tab order and a screen reader names the group, the engine and its state; the other engines are not pressed. Each button's name is the engine's visible name; the title says that it switches and that the text is kept. Hovering the engine in use no longer draws the hover edge.

Upgrade notes

  • A design of your own that overrides content/datatype/edit/ezxmltext_ezoe_engine_switch.tpl keeps working; to take the new control, drop the override or build it on engine_switch_options. A script or test that looked for the switch as input[type=submit] finds a button with the same name now.

15 October 2026: further findings of an end-to-end test of the administration

Fixed

  • The sub-items table speaks the admin's language: the table options list the columns' groups and descriptions translated like their names (expSubitemsColumn::group() and description(); an attribute column's group is "Attributes: <class>" with the class's own name, its description stays the identifiers), and every column name, group and description of settings/subitemscolumns.ini and the built-in columns, the units of the relative times ("vor 2 Minuten", "gerade eben") and Yes/No are in German and American English (263 texts in the context design/admin/node/view/full). The remove confirmation counts sub items in German ("3 Elemente"). Test: AdminSubitemsTranslationsTest.
  • The language choice of "Create new" has a page title (Content / Language selection, "Sprachauswahl") instead of "- Exponential Admin", and the tab of the place the object is created in. Test: AdminCreateLanguagesTitleTest.
  • admin4's phone menu (below 576px): Escape closes the open menu, sets aria-expanded to false and gives the focus back to the menu button; an open search scope popup in the menu takes the first Escape. The open menu ends under its search field (admin3's 7rem under the list left about 80px of empty panel), and the field takes the panel's width. From 576px the header's search field is 166px wide at 576px (106px before) and 230px from 640px. Test: AdminPhoneMenuTest.
  • TinyMCE 8's toolbar, menus and dialogs follow admin4's dark mode like the editor's content: an editor made on a dark page starts with the skin oxide-dark, and a change of the mode swaps the loaded oxide stylesheets; a skin a site configured is kept, TinyMCE 3 is unchanged. Test: AdminEditorSkinTest.

Updated

  • The top node of the content tree (node 1) is called "Content" ("Inhalte" in German) instead of "Top Level Nodes" ("Knoten der obersten Ebene"): in the content structure menu, the browse dialogs of the online editor (TinyMCE 3 and 8) and the top node's page, whose note now says that the content below it is listed under Sub items. The source text stays Top Level Nodes (context kernel/content); a site without a translation (eng-GB) still shows it. Test: AdminTopNodeNameTest.

15 October 2026: publishing queues index_object again

Fixed

  • Publishing a version no longer queues a reindex of the whole subtree. The step register-search-object of the publish operation had no parameters of its own, so it got all of the operation's parameters by position; since the publish operation takes notify (6 October), notify (true by default) landed in registerSearchObject()'s $isMoved. With site.ini [SearchSettings] DelayedIndexing=enabled (or classbased for a listed class) every publication was queued as index_moved_node instead of index_object, and the indexing cronjob (indexcontent) reindexed the object's entire subtree each time. Moving an object to the trash removes only its index_object entries, so these entries stayed until the cronjob had run them. The step now takes object_id and version, like its neighbours. Without delayed indexing nothing changed: the object is indexed at once, and $isMoved is read only to choose the pending action. Test: eZPublishRegisterSearchObjectTest (the step's parameters, the pending action through the operation's parameter mapping with notify true, false and missing, no other publish step taking more than object and version without parameters of its own, a move still telling the search engine it is one).

Updated

  • A publication that changes the object's parent (a location moved in the edit form) is a move for the search engine, as content/move is: an engine that implements addNodeAssignment() (ezfind) hears of it with $isMoved true, so with delayed indexing it queues one index_moved_node entry and the paths of the moved subtree are reindexed; before, only the object was. With the kernel's ezsearch, whose index holds no paths, nothing changes (one index_object entry). publishNode() marks the version when it moves the node, and the step register-search-object uses the mark once.
  • The entries registerSearchObject() queues in ezpending_actions carry the time they were queued (created), with SQL and MongoDB, so the cronjob indexcontent takes the oldest first and a row tells when it came.
  • An object that goes to the trash loses its queued index_moved_node entries as well as its index_object ones.
  • What each operation queues with delayed indexing, for both kinds of engine, is a table in After installing, Search.
  • Test: eZPendingIndexActionsTest (without the database): the queued row and its time (SQL and MongoDB), the moved publication with both kinds of engine and its mark per version, a real move, a new location, the trash, the cronjob, and every method step of every kernel operation that inherits the operation's parameters filling only the optional method parameters it is meant to (delete, swap, translationavailable, sort, updatepriority, user activation; a loop's children take the loop's child_parameters).

Upgrading

  • An installation with delayed indexing that ran the code of 6 to 15 October 2026 may hold index_moved_node entries made by publications. List them with SELECT id, param FROM ezpending_actions WHERE action = 'index_moved_node' ORDER BY id;. Those entries carry no time (created stayed empty until this fix), so the row alone does not tell a publication from a move. Where the search engine does not implement addNodeAssignment() with $isMoved (the kernel's ezsearch does not), no move queues index_moved_node: every such entry comes from a publication, and UPDATE ezpending_actions SET action = 'index_object' WHERE action = 'index_moved_node'; turns them into the intended reindex of the object alone. Otherwise leave them: the cronjob processes them correctly, at the cost of the subtree.

15 October 2026: the segmented switch is one pill

Changed

  • admin4's segmented switch (.a4-tabs.is-segmented, components.css) is one pill instead of three square boxes with lines between them: one track (a 1px edge in --a4-line-strong, --a4-soft inside, border-radius: 999px) around every choice, each choice a pill inside it. The current choice is tinted in the accent (--a4-accent-soft over --a4-card), with an edge and text in --a4-accent-text (4.8:1 light, 6.1:1 dark) instead of the solid accent fill. The switch stays as high as the other controls (--a4-control-h, 32 px); each choice is 26 px high, round icon buttons (.is-icons) 34 px wide. Every choice has its own focus ring (--a4-ring, 3 px), drawn over its neighbours. Under forced colors the track is drawn in CanvasText, the current choice in Highlight, and the focus ring stays outside the choice in CanvasText.
  • The current choice is found by aria-pressed="true" (buttons) or span.current, a.current, .is-current (links, with aria-current), so a switch of links shows its current state the same way.
  • It is seen at the bottom of every left menu (the width of the left column: narrow, medium, wide, parts/left_width_control.tpl), in the version preview's device switch and in the online editor's engine switch (below). The user preferences of the right column are the design's tabs (ul.tabs) since the same day. The markup of the width switch is unchanged.
  • Every segmented switch answers the arrow keys (admin4.js): Left and Right move the focus to the previous and next choice (round, a disabled one skipped), Home and End to the first and last; each choice keeps its Tab stop and is pressed with Enter or Space, Up and Down keep scrolling the page.
  • ezoe's engine switch below a rich text field is the same pill: the engines' span carries a4-tabs is-segmented, so components.css draws it in every administration design (track, the engine in use tinted with an accent edge, focus ring, forced colors) instead of the joined segments with the engine in use filled in --a4-accent-strong; modules/content.css only places it and keeps the pill's hover. Tests: AdminContentEditFormsTest, AdminLegacyControlsTest::testTheSegmentedSwitchesAnswerTheArrowKeys (a node harness, tests/tests/design/js/segmented_switch.js).
  • design/admin/stylesheets/components.css stays a copy of admin4's.
  • Test: AdminLegacyControlsTest::testTheSegmentedSwitchIsOnePill (track, round choices, height, tint, focus ring, forced colors, both copies identical).

Upgrade notes

  • CSS of your own that relied on the joined boxes (border-radius of :first-child / :last-child, the -1px margin) no longer has anything to adjust. A site design that wants the old filled current choice sets background-color: var(--a4-accent-strong); color: var(--a4-on-accent) on .a4-tabs.is-segmented > [aria-pressed="true"] in its own stylesheet. Clear the stylesheet cache after the update (var/<vardir>/cache/public/stylesheets).

15 October 2026: the version preview in the width of a device

Added

  • admin4's version preview (content/versionview: "Preview" from the edit form, the node view and the version history) shows the version in the width of a device: a segmented pill iPhone (390 × 844 px) | iPad (820 × 1180) | Desktop (1280 × 800) | Large Desktop (1920 × 1080) above the preview, each a button with its icon (phone, tablet, laptop, display), its name and aria-pressed, and a landscape switch (phone-flip) for phone and tablet, disabled for the desktops. The iframe is drawn at the device's size and scaled down when the content column is narrower (never up); beside the switches the size and the scale are shown ("820 × 1180 px, shown at 74 %"), and a change of the column's width redraws it. It still asks content/versionview/.../site_access/<siteaccess>, so it shows the site's own rendering for the siteaccess chosen in the left column.
  • The choice is the signed-in user's: the eZPreferences admin_preview_device (phone, tablet, desktop, large) and admin_preview_landscape (1, 0), saved through window.a4Pref and written on <html> by parts/a4_preference_attributes.tpl like admin4's other remembered choices, so it follows the user to another browser; an anonymous page keeps it in the browser (exp-admin4-preview-device, exp-admin4-preview-landscape), and a choice stored there is taken along once. Without one the preview opens as Desktop, scaled to the column (before, the site was drawn in the column's own width). The script is part of admin4.js (packed with the other admin4 scripts); the template holds no script. The arrow keys move between the devices. A stored landscape choice is kept while a desktop is chosen. In a column narrower than 640 px the switches show their icons only, the names stay for screen readers and in the titles. Light and dark mode come from the tokens; under forced colors the frame has a CanvasText edge.
  • Without JavaScript the switches stay hidden (hidden) and the iframe fills the column at 800 px, as before.
  • New Bootstrap Icons 1.13.1 files tablet.svg, laptop.svg, display.svg and phone-flip.svg (unchanged), with their .a4-bi-* mask rules in components.css (both copies) and IconList[] entries in design.ini. The styles are in modules/content.css (.a4-content-preview-*, both copies). New texts are translated to German.
  • Test: AdminPreviewDevicesTest (the pill and its sizes, the iframe's address and fallback, the script run in node: the saved and restored preferences, landscape, the scale, the anonymous fallback; the stylesheet in both copies, the icons, the translations); AdminPreferencesTest knows the two preferences.

Upgrade notes

  • A site design that overrides content/view/versionview.tpl keeps its own preview. The classic design/admin template is not changed; only its copy of modules/content.css carries the rules. Clear the template, stylesheet and translation caches after the update.

Changed

  • admin4's links to the site show the globe (Bootstrap Icons globe2) instead of the arrow out of a box (box-arrow-up-right), which reads as "open somewhere else" and is the icon of external links: the header's "Open the site" (admin4's page_topmenu.tpl and admin4l's admin_theme_switch block) and the node view's "View on site" (node/view/a4_full_body.tpl, beside Preview and the versions). Names, tooltips, addresses and targets are unchanged.
  • New mask rule .a4-bi-globe2 in components.css (both copies) and IconList[]=globe2 in design.ini [AdminDesignSettings]; globe2.svg was already part of the theme. .a4-bi-box-arrow-up-right stays for other uses.
  • Test: AdminIconsTest::testEveryLinkToTheSiteShowsTheGlobe (the three templates, the mask rule in both copies, the icon list, the unchanged SVG).

Upgrade notes

  • A site design that overrides page_topmenu.tpl, admin4l's admin_theme_switch.tpl or node/view/a4_full_body.tpl keeps the arrow until its copy takes a4-bi-globe2. Clear the template and stylesheet caches after the update.

15 October 2026: SVG images, sanitized on upload

Added

  • The image datatype accepts SVG images (.svg and gzip-compressed .svgz): image/svg+xml is on image.ini [ImageUploadSettings] AllowedMIMETypes[], and the new setting SanitizeSVG=enabled (the default) stores every SVG as the output of the kernel's SVG sanitizer, expSvgSanitizer (lib/ezimage/classes/). It is allow-list based and parses with DOMDocument without network, entity substitution or DTD loading: only SVG elements and attributes known to be safe are kept; <script>, <foreignObject>, event attributes, references other than internal #fragments (except http(s)/mailto links and embedded PNG, JPEG, GIF or WebP images), javascript: and data: URLs, external url(), @import and expression() in CSS, and animations of href, style or event attributes are removed. A DOCTYPE with entities (XXE, billion laughs), UTF-16 input, a file over 5 MB (also after decompression), 50 000 nodes or 128 levels, and a file in which nothing drawable remains are refused. SanitizeSVG=disabled refuses SVG, so an SVG is never stored unsanitized. The edit form, the upload module and multi-upload, the rich text editor's upload, the AJAX uploader, WebDAV and imports all go through it; the package thumbnails stay raster images. Page: SVG images.
  • Every alias of an SVG is the original file, with the width and height from its width/height attributes or its viewBox; an SVG is never handed to GD or ImageMagick.
  • SVG files are served with Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline' and X-Content-Type-Options: nosniff: by .htaccess_root, content/download, the cluster gateway and Exponential Velocity (0.0.4.53). A virtual host configuration needs the lines of the page above.
  • Tests: expSvgSanitizerTest (86 harmful SVGs, 15 real icons and drawings that render the same) and expImageSvgTest.

Fixed

  • expSvgSanitizer checks every piece of a <style> element a browser can read as a declaration, not only the innermost rule blocks: a declaration next to a nested rule, in a last block left open or before the first rule could keep an external url(). CSS functions that load an image from a plain string (image-set(), -webkit-image-set(), image(), cross-fade(), element(), src()) and attr() are removed like an external url(), and a url() is a fragment only when it starts with # as written, not after decoding character references. An <a> link must start with http(s):// or mailto: as written.
  • expSvgSanitizer::isSVGFile() recognises an SVG behind a DOCTYPE with > in its system identifier, a processing instruction with > in its data, and a prolog longer than 64 kB; the prolog is matched without backtracking, and a prolog the pattern cannot finish counts as SVG, so the sanitizer decides.
  • eZImageAliasHandler does not store a file as SVG (by its name or the type the browser sent) that the content check does not recognise as SVG, so it is never stored unsanitized; this closes the package import's path, which has no check before storing. New: eZImageAliasHandler::isUnsanitizedSVG().
  • The serving headers match .svg and .svgz in any case, in .htaccess_root and the cluster gateway.
  • Labels and titles of an SVG (aria-*, title, xlink:title) keep ordinary text such as "Image (2)" or "metadata:": only a script or data URL at the start of a word removes them, since such text is never loaded. The test corpus covers further style sheet nesting, escaped and upper case image functions and character references.

Upgrade notes

  • SVG uploads are accepted after the upgrade. To keep refusing them, set SanitizeSVG=disabled (or remove image/svg+xml from AllowedMIMETypes[]) in an override. Behind a virtual host instead of .htaccess, add the serving headers of the page above.

15 October 2026: the order of the Dashboard menu

Added

  • menu.ini TrailLinks[]: links of a left menu drawn after all its other links, in the order named, also after the links extensions append and, in a grouped menu, after the groups and "More" in no group (.a4-navtrail). Read by every copy of parts/ini_menu.tpl (admin4, admin, adminui); the access checks are unchanged. In the tiles they are the last tiles. Test: AdminLeftMenuGroupsTest.

Updated

  • The Dashboard tab's left menu ([Leftmenu_my]) starts with Dashboard and ends with Change password and Edit profile (TrailLinks[]=change_password, TrailLinks[]=edit_profile); Audit trail is the last link of the kernel, right before the links of extensions such as Exponential Intelligence. Order: Dashboard, Collaboration, My pending items, My drafts, My bookmarks, My e-mail preferences, My notification settings, Audit trail, the extensions' links, Change password, Edit profile.
  • admin4's tiles of a menu without groups showed no tile: the list of lead links had no value when the menu had no LeadLinks[], and every link counted as one.

Added

  • The name at the top of admin4's user menu (display name and login) is a link to the full view of the signed-in user's own object, its main node's URL alias (content/view/full/<main node id> for a node without one), as the right column's "Current user" box links the name. It is drawn only when the user's object has a main node and the user may read it (main_node.can_read); without either, and on edit and browse pages, where the menu's links lead nowhere, the name stays text as before. The link is a plain link of the disclosure (no menu role) and the first stop of its arrow keys; it is named by the name and login it shows, with "Open your user object" as its tooltip (title) and description (aria-describedby). Hover and focus ring as the menu's other links, light and dark; in forced colors the menu's links are underlined on hover. No new icon. The markup is in parts/a4_header_user.tpl, so admin4l (admin_theme_switch.tpl) has it too.
  • The header's cache blocks keep their keys: they are keyed by the page context, the role hash ($user_hash) and the user's object id, so one user's link is never served to another.
  • New string: "Open your user object" (design/admin/pagelayout), ger-DE "Eigenes Benutzerobjekt öffnen" and eng-US.
  • Test: AdminHeaderTest::testTheNameLinksToTheUsersOwnObject renders the template with the real template engine (no database): the link and its target, the text without read access, without a main node and on edit and browse pages, two users with their own nodes, the cache keys of both designs, the CSS and the translations.

Upgrade notes

  • A site design that overrides parts/a4_header_user.tpl keeps its own copy. Clear the template cache after the update. Moving a user's object to another main node, or a change of its section or state that changes who may read it, shows in the header once its cache block expires; a change of roles or their limitations shows at once.

15 October 2026: visibility and account status at a glance

Added

  • admin4 marks a node's visibility with a small status mark whose shape tells the state as well as its color: a green filled disc for visible, a red ring for hidden, a red ring with a bar for hidden by an item above it (is_invisible without is_hidden); a user account that is disabled gets a gray crossed ring and "Disabled", one locked after too many failed logins an amber square and "Locked". The marks are one component, .a4-mark in both copies of components.css, at 3:1 or more in light and dark mode, with their shapes in forced colors. They show in the node view's header (a badge, replacing the "Hidden" and "Hidden by a parent" badges), before every name of the sub-items table (and beside the text of its Visibility column), after hidden nodes and disabled accounts in the content tree, and before hidden nodes of the browse list. Page: admin4, Visibility and account status.
  • expUserAccountStatus reads the account status of a page of objects with at most two queries; the rows of ezjscnode::subtree and expsubitems::rows carry is_hidden, is_invisible and, for user accounts, user_status; the tree menu's answer carries user_status for a disabled account; templates have fetch( 'user', 'account_status', hash( 'user_id', ... ) ). The status is shown to users with role/read, like the sub-items columns "Account enabled" and "Locked out".
  • Tests: AdminVisibilityStatusTest, expUserAccountStatusTest.

Updated

  • Enabling or disabling a user account (user/setting, activation) marks the user's locations as modified, so the content tree, which the browser keeps by modified_subnode, shows the change.
  • admin4's content tree keeps the "(Hidden)" and "(Hidden by superior)" text after a node's name for screen readers (visually hidden instead of display: none); the status mark before it is decorative.
  • German: every "hidden by a parent" text reads "Versteckt durch übergeordneten Eintrag", next to "Versteckt": the node view's badge ("Hidden by a parent", was "Durch übergeordneten Knoten verborgen"), the sub-items table's Visibility column and the tree ("Hidden by superior", was "Vom übergeordneten Element versteckt"), the tree's "(Hidden by parent)", the bookmarks and "Hidden by its parent". Only the translations changed, not the sources.
  • The classic admin design shares the sub-items script, children_detailed.tpl, the tree script and template and components.css with admin4, so it shows the marks too.
  • The mark is a class of its own, .a4-mark: the pull request named it .a4-status, the class of the status with a dot and its text (Setup > Cronjobs, Preload, the workflow processes), which would have drawn those as 10 px rings. AdminVisibilityStatusTest keeps the two apart.

Upgrade notes

  • A design of your own that overrides node/view/a4_full_body.tpl, children_detailed.tpl, contentstructuremenu/show_content_structure.tpl or content/browse_mode_list.tpl keeps its markup; take the include design:parts/visibility_status.tpl or the STATUS labels to follow. CSS of your own for .a4-badge-warn (admin4.css) no longer matches anything, and .a4-subitem-name > a:first-child is now a:first-of-type. Clear the template cache after the update.

15 October 2026: the new Exponential logo in admin4

Updated

  • admin4 and admin4l show the new logo artwork of the project in the header and on the sign-in page, drawn inline from one template, parts/a4_logo.tpl: the letters and outlines take the text color of the theme (dark on light, light on dark, CanvasText under forced colors), the orange square keeps the brand color #f15a28 (the --a4-brand token, light and dark), and the inner square of the back mark takes the surface color. Until now the header painted images/admin4/logo-light.png in light mode and admin3's white sprite in dark mode, and the sign-in page did the same with its own rules.
  • The header logo is 24px high and 152px wide in the 32px box the old image had; the header keeps its heights (56px, 88px from 576px, 56px from 1280px). Below 576px the header shows the mark alone, in a 36px link like the tools: with the word the phone row left its tools 8px apart and narrowed the menu button. From 576px the search field gives the logo its 44px (clamp(6rem, calc(100vw - 454px), 230px), was 410px), so the tools keep one row at 576px.
  • The logo links, their targets and names are unchanged; the drawings are aria-hidden.
  • New files: design/admin4/images/admin4/exponential-logo.svg (word and mark, cut to the drawing, no background) and exponential-mark.svg (the mark, square), the same drawing as the template. The --a4-logo token is gone. logo-light.png stays: the old admin design and the kernel's error page still use it.
  • The word is drawn as paths, as high as the mark: the E and the two squares share top and baseline (viewBox 6 6 380 60). The raster logos (images/admin4/logo-light.png, images/setup/2/Exponential-logo-title.png, images/Exponential-logo.gif in the standard design) are re-rendered from the SVG, sizes adjusted to the word. Each is twice the size it is drawn at, so it stays sharp at 200% zoom and on high density screens: logo-light.png and the setup wizard's Exponential-logo-title.png (the same file) 400 x 100 with the drawing at its own scale in the middle (7 KB, was 4 KB with the old, smaller word), Exponential-logo.gif 420 x 120 for its 210 x 60 on the standard login page (was 210 x 60). AdminUI's white logo (exp_adminui 1.0.0.26) comes from the same drawing. AdminLogoTest LG-07 (the E and the l share the squares' top and baseline) and LG-08 (the sizes, half the size where they are drawn, the setup copy identical).
  • Required packages: exp_adminui ~1.0.0.26 (the white logo at 520 x 82 for its 260 x 41, and the colors of xrowextract's pages in AdminUI from the --a4-muted token).
  • Tests: AdminLogoTest (new: one source, the names, plain SVG without script, style or references, the files equal to the template, the colors, the header heights); AdminPhoneMenuTest PM-03 takes the new search field width; AdminIconsTest lets the logo be the one inline SVG of admin4's templates.

Upgrade notes

  • A site design that overrides page_topmenu.tpl, loginpagelayout.tpl or admin4l's admin_logo.tpl keeps an empty logo link until its copy includes design:parts/a4_logo.tpl inside the link. A stylesheet that set --a4-logo has no effect any more. Clear the template and stylesheet caches after the update.

15 October 2026: the environment badge of the administration

admin4 and admin4l can name the installation's environment (development, test, staging, production) in the header, on the sign-in page and before the page title, so a live site is not taken for a test copy. Off by default. Feature ("Environment badge").

Added

  • site.ini [AdminEnvironment]: Level (development, test, staging, production, or auto for the level of EXP_ENV; empty, the default, shows nothing), Name, ShortName, Release, ShowVersion (disabled) and ShowInTitle (enabled).
  • The template operator exp_environment_badge() (expEnvironmentOperator, beside exp_environment()) and the include parts/a4_environment_badge.tpl: a pill in the level's color beside the logo (admin4's page_topmenu.tpl, admin4l's logo block, loginpagelayout.tpl). From 768px the name, and for signed-in users the release and version; up to 767px, phones included, the short code (never the color alone), the name read out. The header keeps its heights (56, 88, 56px). Color tokens --a4-env-<level>, -ink and -line in admin4.css, light and dark; forced colors keep the text. German and American English for the nine new strings. Test: AdminEnvironmentBadgeTest.

Updated

  • design/standard/templates/page_head.tpl takes an optional title_prefix (empty by default); admin4, admin4l and admin4's sign-in page pass "[NAME] " when ShowInTitle is on.
  • The cache-blocks of admin4's and admin4l's head and header and of the sign-in page's head are keyed by the badge, so a changed setting shows on the next request. The key list changes, so these blocks are rendered anew once after the upgrade.

Upgrade notes

  • Nothing changes without Level. To use it, set [AdminEnvironment] Level per environment, for example in settings/override/site.ini.prod.append.php (EXP_ENV=prod) or with Level=auto. No template cache needs clearing; where EZP_INI_FILEMTIME_CHECK is off, clear the INI cache.
  • A site design that overrides page_head.tpl gets no prefix unless it prints {$title_prefix|wash} itself.

15 October 2026: the environment badge shows on hover

The environment badge of admin4 and admin4l no longer stands in the header at all times: by default it shows only while a mouse or trackpad pointer rests on the logo, or the logo has the keyboard focus. Touch screens, which cannot hover, keep it beside the logo. The page title keeps its prefix. Feature ("Environment badge").

Added

  • site.ini [AdminEnvironment] BadgeDisplay: hover (the default) or always, the badge beside the logo as before. exp_environment_badge() returns it as display, and it is part of the badge's cache key.

Updated

  • parts/a4_environment_badge.tpl: the badge carries id="a4-env-badge" and the class a4-env-hover or a4-env-always; the title attribute only with always. The logo links of admin4's page_topmenu.tpl, admin4l's logo block and loginpagelayout.tpl name it in aria-describedby, so screen readers read the environment with the logo.
  • admin4.css: with hover the badge is out of the flow and clipped to nothing; inside @media (hover: hover) and (pointer: fine) it appears on :hover and :focus-visible of the logo link, beside the logo from 576 to 1279px and on the sign-in page, under it from 1280px and below 576px, and stays while the pointer is on it. A 120ms fade, none with reduced motion. The rules that made room for the badge (the logo mark from 576 to 767px, the search field's width, the short code) apply to always only. The header keeps 56, 88 and 56px. Tests: AdminEnvironmentBadgeTest, AdminLogoTest.
  • The hover badge meets WCAG 1.4.13: Escape hides it until the pointer or the focus comes back (admin4.js, the class a4-env-dismissed; an open menu's Escape comes first), and the pointer can move onto it. Everything that hides it sits inside @media (hover: hover) and (pointer: fine): on a touch screen, a stylus or any pointer that cannot hover finely, the badge stands beside the logo as with always, with its room and the short code up to 767px, so the information never depends on hovering. AdminEnvironmentBadgeTest checks that the touch rules equal those of always and that nothing outside the two pointer media queries names the hover badge.

Upgrade notes

  • To keep the badge in view, set [AdminEnvironment] BadgeDisplay=always. A site design that overrides page_topmenu.tpl, loginpagelayout.tpl or admin4l's admin_logo.tpl keeps working; add aria-describedby="a4-env-badge" to its logo link so screen readers keep the environment.

15 October 2026: a mega menu for the Setup tab (prototype, opt-in)

Added

  • admin4 (prototype, opt-in): a mega menu for the Setup tab. A small button beside the tab (the tab stays a link) opens a card under the header with a short choice of the Setup menu's links in their groups, each with its icon, name and description, up to three groups side by side, and "All Setup functions" for the rest. The choice is menu.ini [Leftmenu_setup] MegaMenuLinks[] (link keys, in order, at most 12 shown; the default names twelve frequent ones, among them the AI and Updates links, shown when their extensions are active), with the menu's groups and access checks. The links a moved tab brings (SetupTabs[], GroupSections[]) are chosen as <group>-<key> (audit-console); a chosen Setup link hidden because its group has the same address takes that link (audit → the Audit group's Settings). The WAI-ARIA disclosure navigation pattern: aria-expanded/aria-controls, Enter, Space and ArrowDown open it, Tab goes through it, Escape closes it and gives the focus back, a click outside closes it. A mouse resting on the tab for 300 ms opens a preview that closes 400 ms after leaving; never on touch. From 1280px only; the two-row header and the phone menu are unchanged. Switched on with menu.ini [TopAdminMenu] MegaMenuTabs[]=setup; empty by default, and then nothing changes. admin4 design, "Mega menu".
  • The view setup/megamenu: the Setup menu's groups and links as an HTML fragment without page layout, drawn from menu.ini [Leftmenu_setup] by parts/ini_menu.tpl (new parameter tiles_layout='mega', template parts/a4_mega_menu.tpl), with the access checks of the menu and the functions of setup/menu. The header loads it once, when the panel is first opened (design/admin4/javascript/a4_mega_menu.js, loaded only while MegaMenuTabs[] names a tab), so the page grows by the button only.
  • Test: AdminMegaMenuTest (with a node harness for the script, tests/tests/design/js/mega_menu.js).

Upgrade notes

  • Nothing to do while MegaMenuTabs[] stays empty. To try it, set MegaMenuTabs[]=setup and clear the INI and template caches. A site design that overrides page_topmenu.tpl or page_topmenuitem.tpl for admin4 draws no button until it takes the new markup. Opening the panel requests a setup view, which the audit trail records as access.view.sensitive.

15 October 2026: the mega menu for the Users tab, one view for every tab (prototype, opt-in)

Added

  • admin4 (prototype, opt-in): a mega menu for the Users tab, switched on with menu.ini [TopAdminMenu] MegaMenuTabs[]=users: Users and user groups, Sessions | Roles and policies, Unactivated users | Edit profile, Change password, My notification settings, My e-mail preferences, in three groups, and "Go to Users". Its links come from menu.ini [Leftmenu_user_mega], a section that gathers the tab's functions without restating a link: GroupSections[] adds [Leftmenu_user] (the tab's left menu, unchanged) and [Leftmenu_my] (the user's own settings) with their addresses and policies; its own links are the user tree and the sessions. The choice is its MegaMenuLinks[]. Icons shield-lock and person-slash (Bootstrap Icons 1.13.1) added to IconList[] and both components.css; LinkIcons[]/LinkDescriptions[] for the four links of [Leftmenu_my] and the two of [Leftmenu_user] (read only where a menu is drawn as tiles or as a mega menu).
  • Any tab can have a mega menu: [Topmenu_<tab>] MegaMenuTemplate names the template that draws its menu (design:parts/setup/mega_menu.tpl, design:parts/user/mega_menu.tpl), and MegaMenuTabs[] switches it on. admin4 design, "Which tabs take part, and how to add one".
  • Tests: AdminMegaMenuTest checks the Users section (its added menus, existing links, icons, names and descriptions in ger-DE and eng-US), which tabs the view serves and that it serves only a tab the user has (hidden, disabled, not shown, not listed), and runs two tabs in the node harness (one panel open at a time).

Changed

  • The fragment view is now user/megamenu/<tab> for every tab, in place of setup/megamenu: policy user/login, since the tabs belong to different modules. It answers only for a tab of MegaMenuTabs[] with a MegaMenuTemplate (else not available) that the topmenu operator shows enabled and open to the user, never for the anonymous user (else denied); each link is checked as in the left menu.
  • The panel's strings name the tab: "Open the %tab menu", "Loading the %tab menu", "The %tab menu could not be loaded.", and "Go to %tab" (the tab's own page) for "All Setup functions". One panel is open at a time; a panel opened by a click or a key is not replaced by the hover preview of another tab.
  • ger-DE: "My notification settings" reads "Meine Benachrichtigungseinstellungen" (was "Meine Benachrichtigungs- Einstellungen").

Changed after the review

  • The mega menu shows from 576px, wherever the main menu is one line (the two-row header from 576 to 1279px too, which is what a browser at 200% zoom shows on a 1920px screen); it was drawn from 1280px only. Below 576px, in the phone menu, the tab stays a plain link.
  • admin4l has the mega menus too: the tabs of both designs are drawn by one template, parts/a4_topmenu_items.tpl, which admin4's page_topmenu.tpl and admin4l's tab menu block include; admin4l's block drew no button before.
  • Settings that cannot be used are named in the debug report (parts/a4_mega_menu_check.tpl): a MegaMenuTabs[] value without MegaMenuTemplate, a MegaMenuLinks[] key that is no link of the section ([Topmenu_<tab>] MegaMenuSection, new: Leftmenu_setup, Leftmenu_user_mega) or of a section it adds, and more than 12 keys. They were left out silently.
  • The button's name is "%tab menu" ("Setup menu", ger-DE "Menü „Setup“") instead of "Open the %tab menu": a screen reader adds "collapsed" or "expanded", which contradicted "open" while the panel was shown.
  • Tests: AdminMegaMenuTest checks the width, the one template of both designs, and the debug messages of the check.

Upgrade notes

  • Nothing to do while MegaMenuTabs[] stays empty. A site that named setup already needs nothing either; to add the Users tab, set MegaMenuTabs[]=users and clear the INI and template caches. A site that gives another tab a mega menu sets MegaMenuSection beside its MegaMenuTemplate to have its MegaMenuLinks[] checked.

15 October 2026: the upload page (ezmultiupload 6.1.0)

ezmultiupload/upload/<node> is rebuilt in admin4, admin4l and adminui, in the frame of the content action pages (box header, box content, control bar with the main action first) with the shared controls; the classic design/admin keeps its lighter page. Root composer.json (and composer.ci-phpunit.json) require se7enxweb/ezmultiupload ~6.1.0 and se7enxweb/exp_adminui ~1.0.0.20. Guide: the extension's doc/upload-page.md.

Added

  • Added: The upload page's target location shows its path and whether it takes files; Change location... opens a location picker (breadcrumbs, a filter, the containers with the reason where one cannot take files), a row of the queue can have a location of its own, and the last location is the eZPreference exp_multiupload_location.
  • Added: Folders (where the browser supports it), drag and drop and pasted images; per file a preview, the class it becomes (upload.ini), an editable title, the alternative text of an image (required where the class asks, L16), a progress bar and the status with the reason; cancel, retry and remove per row and for all; three uploads at a time; a summary toast.
  • Added: Checks before anything is sent (ezmultiupload/service, Action=check): the create permission for the class in the location, the type list, the size limit (MaxFileSize=64M and PHP's limits) and the alternative text; a file with the same name and size already there offers Skip, Rename or Upload anyway.
  • Added: SVG images on the upload page: an SVG becomes an Image (sanitized by the kernel), galleries take *.svg and *.svgz, and the preview is an <img> of the stored file after the upload.
  • Added: expMultiuploadService::canUploadAt(), one check whether the current user can upload at a node, for links and buttons to the upload page.
  • Added: Keep as drafts (the kernel's upload without publishing).
  • Added: After the upload, the list of what was made with View and Edit, and Move to..., Add location... and Remove... (to the trash, after a confirmation) through the kernel's move, addlocation and delete operations and their permission checks.
  • Added: Import compressed zip file(s) contents, off by default (eZPreference exp_multiupload_zip_import; off, a ZIP file is one File item): the archive is read on the server by the new ZIP import backend (expMultiuploadZipImporter) and its entries listed, also those of ZIP files inside it, with the folders kept as folder items or flattened (eZPreference exp_multiupload_zip_mode). Limits in [ZipSettings]: depth 3, 1000 entries, 500 MB uncompressed, a compression ratio of 100 (ZIP bombs); unsafe paths, links and devices are skipped, encrypted archives refused, and the archive is unpacked in a private directory of the request that is removed afterwards.

Updated

  • Updated: The content tree's "Upload multiple files" works on every container: the page used to answer with the kernel's "object is not available" error on any location whose class was not folder or gallery (AvailableClasses[]), which on the media site's installations is nearly every node (Fit & Healthy is an ng_frontpage). New [MultiUploadSettings] AnyContainer=enabled: every container the user may create content in takes uploads; a location that cannot shows the page with the reason and offers the nearest one above. Upgrade note: AnyContainer=disabled keeps the 6.0 rule.
  • Updated: Every request of the page that changes something is a POST with the session's form token, checked again by the extension (constant-time) and never accepted from another site; node ids are positive integers only.
  • Updated: The Admin UI header's search button and user menu have names (exp_adminui 1.0.0.20).
  • Tests: tests/tests/extension/ezmultiupload/ (testsuite ezmultiupload, no database): the input checks, the token, the permission pre-check, the duplicate check (expMultiuploadServiceTest) and ZIP files made by the test: nesting and its limit, traversal, links, a ZIP bomb, the entry and size limits, encryption, names that are not UTF-8, mixed types and both folder modes (expMultiuploadZipTest).

15 October 2026: sub items reordered by drag and drop

Where a node sorts its children by priority and the user may change the priorities, admin4's sub items table can be reordered by dragging a row, with the keyboard as well, and the new order is saved as the children's priorities. Feature.

Added

  • admin4: a column of grips (grip-vertical) in the sub items table (javascript/subitems_priority_drag.js, loaded by children.tpl), only where the node is sorted by priority and $node.can_edit (the condition of the classic priority fields, confObj.priorityDrag in children_detailed.tpl), while the table is sorted as the node sorts and not filtered. Pointer events for the mouse, touch and pen (no HTML5 drag and drop, no library): the row is its own placeholder, a ghost with the name follows the pointer, the window scrolls near its top and bottom edge, Escape cancels. Keyboard: Space or Enter picks a row up, the arrow keys (Home, End) move it, Space or Enter drops it, Escape or leaving the grip cancels; Alt+Up and Alt+Down move it by one place at once. Every step is announced ("Position 3 of 23", "Moved to position 4 of 23"), translated in ger-DE and eng-US.
  • Saving right after the drop through ezjscnode::updatepriority (the inline priority editor's server function, with the page's form token): the page's own priorities are given out again in the new order, in the node's sort direction, so no row moves to another page; equal priorities on the page, or one shared with the neighbouring row of the page before or after, become distinct values between those neighbours (in steps of 10 where there is room); without room, all children are numbered again in steps of 10. A toast says "Order saved" with Undo (it stays 10 seconds), which puts the previous priorities back. A failed save puts the rows back and says why.
  • ezjscnode::updatepriority takes the compact lists PriorityList and ExpectedPriorityList ("node_id:priority,...", one field for a page of any size, far below max_input_vars) and Renumber=1 with PageOrder and PageOffset. With ExpectedPriorityList nothing is written when one of those children has another priority now or is no longer a child (array( 'conflict' => true, 'nodes' => ... )); the table then loads again and says that someone else changed the order. Otherwise the answer lists the changed priorities ('changed' => array( node_id => array( old, new ) )). ExpectedPriorityList is required with the new fields and must name every node they write (a node of PriorityList or PageOrder without its shown priority is refused); they are refused for a node that does not sort its children by priority. Permission as the classic form: the parent's canEdit(); ContentNodeID must be a positive integer. The check and the write run in one transaction, rolled back together on an error, the rows read with FOR UPDATE on MySQL, PostgreSQL and Oracle; the write is the kernel's (the content/updatepriority operation with its triggers, else eZContentOperationCollection::updatePriority()), and a workflow that stops or defers it is answered as an error, not as saved. Posted Priority[]/PriorityID[] work as before.
  • window.a4Toast( message, kind, opener, delay ): the time a toast stays, for a toast with an action button (.a4-toast-action).
  • Tests: AdminSubitemsPriorityDragTest (its behaviour runs in node, tests/tests/design/js/priority_drag.js) and ezjscServerFunctionsNodePriorityTest.

Updated

  • The sub items lists sorted by priority (ezjscnode::subtree, expsubitems::rows through expSubitemsColumnRegistry::sortFor()) order equal priorities by node id, in the same direction, so a page shows the same rows each time and the renumbering keeps the order the list showed.
  • The priority cells stay editable. A drag does not start while a priority is being edited with a changed value (a toast asks to save it with Enter or cancel it with Escape first, and the editor keeps the focus) or while such a change is being saved; no cell can be edited while a new order is being saved.

15 October 2026: language badges show the state of a translation

Added

  • admin4's language badges (DE, EN) show the state of each language for the object by their shape, not by a color per language and without the status colors: the main language filled, a translation outlined, a language of the site the object does not have dashed with pale text, a translation last edited before the main language with a small mark. The code stays the text; the state is in the title and the accessible name ("German – main language", "French – missing", "Spanish – outdated, older than German"), in ger-DE and eng-US. Under forced colors the shapes stay. Shown in the node view's meta line (every language of the site, the missing ones as a "Translate into ..." link, content/edit/<object>/f/<language>, for a user who may create that translation) and in the Translations tab (the state of each translation, the missing ones under the table). The sub-items table's Translations column shows main, present and, on a site with up to six languages, the missing ones (as text); the outdated mark stays on the node. Include design:parts/language_state_badge.tpl, the fetch function fetch( 'content', 'language_states', hash( 'object', $object ) ) and the class expContentLanguageStates.
  • A translation is outdated when the newest published or archived version edited in it is older than the newest edited in the main language; both must exist, so an object whose languages came in one version (an import, a copy) or whose older versions were removed is never marked. One grouped query reads all objects asked for.
  • The node JSON of the sub-items table (ezjscAjaxContent::nodeEncode()) carries initial_language, the locale of the object's main language.
  • Tests: AdminLanguageStatesTest (states, outdated, the list limit, one query for a page of objects, the texts, no color per language, forced colors, the sub-items formatter run in node), a contrast pair in AdminTokenContrastTest.

Updated

  • A plain language badge is outlined (--a4-field-line, 3:1) on no fill, so it reads as "this language exists" next to the filled main language; the badge of the language shown keeps its accent edge, tint and underline.
  • A badge in front of a language's name in a table cell (the Translations tab) keeps a gap to the name.
  • The node view's copy buttons (Node, Object, Remote ID) are 24px targets at every pointer, not only on touch screens: the language badges of the meta line are 24px high and wrap beside them on a phone, where the shorter buttons failed axe's target-size check.
  • A fetch() in a compiled template checks that the function's class and method can be loaded before it calls them, as the interpreted fetch() (eZModuleFunctionInfo::executeClassMethod()) always did. A template compiled by a process that knew a new kernel class could run in a PHP-FPM or Velocity worker whose autoload did not know it yet, and the page ended in a fatal "Class not found" (it happened once on a development installation with expContentLanguageStates, until the workers were reloaded). Such a worker now renders no result and writes an error to the debug output. A new kernel class is still deployed with its autoload (exp:velocity deploy --kernel) before a template uses it.

15 October 2026: an Upload button in the node view

Added

  • The node view's actions have an Upload button after Edit (design/admin and admin4, so also admin4l; first in the "..." menu of exp_adminui). It opens ezmultiupload's upload page for the node (ezmultiupload/upload/<node id>) and shows only where the current user can upload there: the ezmultiupload/upload module is enabled and the new fetch function fetch( 'ezmultiupload', 'can_upload', hash( 'node_id', ... ) ) says yes (the module policy, read and create access at the node, a container or a configured upload class or node; ezmultiupload 6.1). Drafts and the edit pages never show it. An .a4-btn with the Bootstrap Icon .a4-bi-upload (new in components.css), "Upload" / "Hochladen". Guide: doc/eng-us/bc/6.0/admin4-styling.md, "The node view's Upload button".

15 October 2026: unreferenced objects and broken relations

Added

  • The kernel module contentcheck with two content checks for the administration. Page: The content checks.
  • Unreferenced objects (contentcheck/unreferenced/<node id>): the published objects below a node that no other object refers to, filtered by class, sorted by name, class, date or id, and paged, with name, class, file size, last modified and location. A reference is a row of ezcontentobject_link of any type (object relation, relation field, rich text embed and link) from a published, draft, pending or queued version of an object that is not in the trash; relations from trashed objects, old versions and the object itself do not count. One COUNT and one page query with NOT EXISTS and the content/read condition in the SQL; 1 to 1.7 s for 100,000 hits on MariaDB.
  • Ticked unreferenced objects go to the trash after a confirmation that lists what happens to each (POST, the form tokens and a confirmation token bound to the objects shown). Each object is checked again (still unreferenced, content/remove on every location, no sub-items, the class allows the trash, no pending version, no content job) and moved by the kernel's removal, recorded as content.node.remove.trash; at most MaxTrashBatch (50) at a time.
  • Broken relations (contentcheck/brokenrelations/<node id>): visible, published content whose published version relates to, links to or embeds an object or node that is deleted, in the trash, hidden, never published or without location: the relations in ezcontentobject_link and the object_id and node_id of links and embeds in the text of ezxmltext fields (XmlDatatypes[]), which still name a deleted target. Source, field and language, target and its state, and an Edit button where the user may edit; filters for the subtree, excluded subtrees, the states and the kinds of reference. Read only. The sources are checked by object id in chunks, a page goes on after the last object checked (20 to 30 ms for a page of 50 findings).
  • Policies contentcheck/unreferenced, contentcheck/trash and contentcheck/brokenrelations (Administrator only in a new installation); the checks answer on administration siteaccesses only (contentcheck.ini). What a user may not read is left out of every list, count and page in the SQL; a target the user may not read is "not available", without id or state, and a deleted, trashed or hidden target the user may read is shown by its id and state only.
  • Setup menu and overview: Unreferenced objects (icon file-earmark-break) and Broken relations (icon exclamation-diamond) in the group Content model, after the content finder. Context menu of the content tree and the sub-items: Advanced > Unreferenced objects in subtree and Broken relations in subtree, for users with the policy. Translations eng-US and ger-DE.
  • Tests: expContentCheckQueryTest (no database) and expContentCheckSandboxTest (a throwaway installation only), suite kernel-contentcheck.

Updated (review before the merge)

  • Unreferenced objects lists assets only, judged against everything that can use them. A published page, a folder, a user or a settings object is reached through the tree whether or not anything links to it; the first version listed every published object without a relation row, which on a demo installation was 647 of about 800, including landing pages, users and the site's settings object, all offered to the trash. The list now covers the classes of contentcheck.ini [UnreferencedSettings] AssetClassList[] (image, file and the media classes), only when every location's parent is a storage folder (StorageParentClassList[], folder): below a gallery, an article or an event the parent's template shows the asset.
  • The reference index (expContentCheckReferences) adds every source a relation row misses: relation field data (ezobjectrelation, ezobjectrelationlist, ngenhancedlink, which never writes a relation row); ids, schemes, storage paths and internal paths in the text of any field (54 images of the demo installation were embedded in rich text with no relation row, and the first version would have offered them for the trash); the ezurl table, custom URL aliases and wildcards; the layouts (collection items by node or object, dynamic collections by parent node and class, block parameters including exp-remote-location:// links, rule targets); node ids in every settings file; literal ids in every design's templates; role and policy limitations by node or subtree; the shop, collected information, pending actions, bookmarks, subtree notifications, draft placements and the newsletter (configurable table columns, a missing table skipped) and the newsletter's article pools. The index is kept for IndexTTL seconds; the move to the trash always builds it again; a source that cannot be read lists nothing. Published objects without any location are counted.
  • Broken relations also read the data of relation fields (a target named where no relation row was written), show what to do about each finding, and list on the first page the layouts whose collection items, dynamic collection parents, block links or rule targets name a node or object that is deleted, in the trash or hidden.
  • On the command line, report only: ./console exp:content-check --build-index | --unreferenced | --broken | --why=<ids> [--node=<id>] [--json] (bin/php/contentcheck.php), and the cronjob part contentcheck for installations that set BuildOnRequest=false.
  • Ids of imported content are resolved as the site resolves them: contentcheck.ini [ReferenceSettings] ImportedIdRemotePatterns[] and ImportedIdObjectPatterns[] (the media site demo resolves enhanced link and layout component ids as remote id media-o-<id+776>, media-o-<id> or object <id+776>; on the demo installation all 23 enhanced links reported as broken resolve that way).
  • A dynamic collection of the current location counts below the nodes its layout's rules target (children of a node target, everything below a subtree target, anywhere below a parent of a class condition's classes for other rules and for shared layouts), not anywhere: one gallery block had kept every image from ever being listed.
  • Tests: expContentCheckReferencesTest (no database): every reference source's extractor, the judging, the asset classes in the SQL, relation fields and hints.

Upgrade notes

  • Nothing to do: no database change. Give the three policies to the roles of the people who tidy up content.

15 October 2026: removing and hiding check the reverse relations

Added

  • content.ini [RemoveSettings] ReverseRelationCheck=disabled|warn|block (default warn): the confirmation page of content/removeobject lists, per selected item, the objects that point at what the removal takes away: the referring object with a link, its class, the kind of reference (object relation, embed or link in rich text, relation field with the field's name) and its target. It replaces the general hint to look at Reverse related for subtree. With block the OK button is disabled while references exist, and the view refuses a posted confirmation, HideRemoveConfirmation=true and a removal as a content job on its own as well.
  • content.ini [HideSettings] ReverseRelationCheck=disabled|warn (default warn): with warn, hiding a node that other objects point at, or whose object has further locations, takes a confirmation page with the list first: from the context menu, the Locations tab, a link to content/hide and "Hide selected" of the sub items list.
  • Only objects the current user may read are named. References from other objects, and further locations the user may not read, are counted per item and never named ("2 references you cannot see"). block counts them all the same.
  • content.ini [ReverseRelationSettings] ProviderClassList[]: classes that report references kept outside ezcontentobject_link. explayouts adds expLayoutsReverseRelationProvider: blocks of published layouts whose collection names a node inside the removed or hidden subtrees, listed as "Layouts block" with a link to the layout.
  • What counts: rows of ezcontentobject_link from the current version of a published object, of any kind, to an object whose every location lies inside the selected subtrees, from an object that stays. Page: Removing and hiding: the check of reverse relations.
  • Tests: expReverseRelationCheckTest, AdminReverseRelationCheckTest. German and American English for the new strings.

Updated

  • content/hide draws both of its confirmation pages with one method, confirmationPage(); expContentHideReturnTest counts them there.
  • With block, removing the last locations of an object on the Locations tab goes through content/removeobject (listed and refused there), and a WebDAV delete is refused. The standard design's removal page shows the list.
  • The selected nodes are fetched in one query; class and field names once per request.

Upgrade notes

  • Hiding content that others point at, or an object with further locations, shows the list first from now on ([HideSettings] ReverseRelationCheck=warn); set disabled for the hide of earlier releases.
  • The removal page shows the list from now on (warn); selecting every location of an object that others point at now shows the confirmation page instead of removing it at once. Set ReverseRelationCheck=disabled under [RemoveSettings] for the page of earlier releases, or block to refuse removals while references exist.

15 October 2026: language badges show the language, not the region

Updated

  • A language badge (design:parts/language_badge.tpl, admin4 and the classic admin design) always shows the language part of the locale's language tag: eng-GB and eng-US are both EN, also when the installation has both. Before, two content languages of the same language showed the region as well (EN-GB, EN-US), so a row of badges mixed DE with EN-GB. The full name (English (United Kingdom), English (American)) stays the badge's title and accessible name, or is written next to it, so a tooltip and a screen reader still tell the two apart. The include no longer fetches content/translation_list to count the languages: one query less per badge. The sub-items table's Translations column gets its codes from the same include and follows. Guide: the admin4 design: languages.
  • Test: AdminLanguageBadgeTest (the badge is the language part of the tag, the include fetches no translation list; every locale file of share/locale/ gives a two or three letter code, every esl-* locale ES, so a locale added later such as esl-AR follows).
  • Two regions of one language on one object keep EN on both badges: the name tells them apart, written next to the badge in the Translations tab and the edit page's language choice, as each badge's tooltip elsewhere, and in the accessible name together with the state ("English (United Kingdom) – outdated, older than English (American)"). The admin4 design: languages.

15 October 2026: checking user accounts

Added

  • Users > Check user accounts (user/hygiene, also a tile of the Setup overview under Users and messages): every user account, or those below a user group, filtered by never signed in, not seen for N days, disabled, locked (more failed sign-ins than MaxNumberOfFailedLogin), orphaned (no user object, never published, no location) or a shared e-mail address, each filter with its count; a search, the order of the columns and the page size. Each row shows the states, the last visit, the number of sign-ins, the groups and the roles (also those of the groups above). Disable, enable and unlock the ticked accounts after a confirmation with the counts; every account is decided again when it is changed, never the anonymous user, the user creator, the administrator account (id 14, login admin) or the user who acts, and only with content/edit on the account's object. The changes go through the kernel's paths and their audit events (access.user.disable, access.user.enable, access.user.unlock). Export the filtered list as CSV (UTF-8 with a byte order mark, formula cells made text, recorded as data.export.csv). Nothing here removes an account: a user object never goes to the trash. New: the function user/hygiene, expUserHygiene, menu.ini user_hygiene in [Leftmenu_user] and [Leftmenu_setup] (icon person-badge, new in IconList[]), offered in the Users tab's mega menu (MegaMenuLinks[]=access-user_hygiene), Check user accounts. Test: expUserHygieneTest.

Updated

  • Check user accounts has the filter Shared login (any case, not counting accounts in the trash; the duplicates LoveStack L04's exp:user-login-check reports, to resolve before --fix-index), a badge on a shared login, and one line under the tabs that says exactly what the current filter holds. The anonymous user is no longer counted as Never signed in (it cannot sign in). The page counts the published user objects that have no account row (the other side of Orphaned, which no action can touch) and says how many there are. Test: expUserHygieneTest UH-09.

Upgrading

  • The page is only for roles with user/hygiene (Administrator has it through */*); e-mail addresses on it and the filter of shared addresses need role/assign, as the E-mail column of the sub-items table.

15 October 2026: an Export menu in the content tree

Added

  • The Advanced submenu of the content tree's menu and of the sub-items "…" menu has an Export submenu. Each entry opens a form filled in for the item; the export is started there, never from the menu: the item, or its subtree, as a content package (with or without class definitions, with all versions, or the package wizard itself), and, with xrowextract 2.5.11, the children or the subtree as CSV, the subtree of one class (a list of the classes below the item, read when it opens), every attribute or the migration columns. Export is shown only to a user with package/create and package/export (the content object wizard) or xrowextract/csv; an Editor sees no change. Guide: The Export menu of the content tree.
  • Entry parameters of package/create: (creator)/ezcontentobject/(node)/<id>[/(subtree)/1][/(classes)/0|1] [/(versions)/current|all] start the content object wizard with the node or subtree selected and the second step preset; only a node the user can read and a user who may use the wizard and download its package.
  • fetch( 'package', 'content_export_access' ), and admininterface.ini [AdditionalMenuSettings] ExportMenuTemplateArray[] and ExportSubMenuTemplateArray[] for an extension's export entries.
  • ezpopupmenu.js: menuArray[id]['onshow'], for a submenu whose items are filled in when it opens.

Updated

  • ezpopupmenu.js places a nested submenu inside the window: beside its parent on the side with room, moved inside the window when neither side has room (960px at 200 % zoom, phones), up from the bottom edge, scrolling when taller than the window. adminui's copy (exp_adminui 1.0.0.22) is now admin4's, with the keyboard pattern of #165.
  • Required packages: xrowextract ~2.5.11 (the CSV form's entry parameters and the class list of the Export menu); the context menu script of exp_adminui 1.0.0.22 is in the required ~1.0.0.23.

15 October 2026: deactivate and move user accounts instead of removing them

Added

  • Deactivate and move and Reactivate and move back (opt-in): instead of removing a user account (users never go to the trash, so removing one is final), it is disabled and all its locations are brought together in one hidden user group without roles; the locations it had are remembered, and reactivating it enables it and moves it back (a former location that is gone is left out and counted; a gone main location falls back to [UserSettings] DefaultUserPlacement). On a user's page, in the context menu (Advanced) and as bulk actions on Users > Check user accounts, each after a confirmation that lists the accounts, their groups and where they go. The group is [UserSettings] DeactivatedUserGroupNodeID or the user group with the remote id exp_deactivated_users_group, created on first use by a user who may, or beforehand by bin/php/ezcreatedeactivatedusergroup.php -s <siteaccess> [--commit]. Every step goes through the kernel's operations (user/setsettings, content/move, content/removelocation, content/addlocation) in one transaction; recorded as access.user.deactivate_move, access.user.reactivate_move and access.user.deactivated_group.create (the audit catalogue has 148 names). The remembered locations are one ezsite_data row per account (exp_user_deactivation_<id>), so no database update is needed. New: the function and view user/deactivate, fetch( 'user', 'deactivation_offers', ... ), expUserDeactivation, Deactivate and move. Tests: expUserDeactivationTest, expUserDeactivationLiveTest.

Updated

  • The group of deactivated users keeps no roles: Assign on a role (role/view and role/assign) leaves it out of the chosen users and groups and says so on the role's page, and Deactivate and move refuses while a role is assigned to the group some other way. Check user accounts no longer depends on the new class being loaded (a process started before the deploy). Tests UD-07 and UL-08; texts in eng-US and ger-DE.

Upgrading

  • Nothing changes until you switch it on: site.ini [UserSettings] DeactivateAndMove=disabled is the default, and the installer neither switches it on nor creates the group. To use it:
    1. optionally create the group beforehand: php bin/php/ezcreatedeactivatedusergroup.php -s <admin siteaccess> (dry run), then the same with --commit; it prints the node id;
    2. set [UserSettings] DeactivateAndMove=enabled (and, only to use a group of your own, DeactivatedUserGroupNodeID=<node id>) in settings/override/site.ini.append.php or the admin siteaccess, then clear the caches (php bin/php/ezcache.php --clear-all, and var/cache/ini/);
    3. give the function user/deactivate to the roles that retire accounts (Administrator has it through */*); they also need content/edit, content/move and content/remove on the accounts, and content/create in the group (see the feature page).

15 October 2026: the left column's size is a width only

Fixed

  • admin4: the left column's sizes (the small, medium and large switch, and a width dragged by hand) change its width only. The medium and large sizes also made its text larger (16px and 20.9px instead of 13.2px), a rule taken over from admin3, where the sizes were font sizes. The column's text now keeps the body's size (--a4-text-body, 13px up to 14px on very wide windows) at every width, as the right column; headings, the content tree and the group titles keep their sizes relative to it. --left-sidebar-font-size is gone from admin4's pagelayout.css; --left-sidebar-width is unchanged. admin3 keeps its own rule.
  • Test: AdminLeftColumnFontTest (testsuite design): no html[data-a4-left-size] rule of admin4 sets a font size, and .sidebar.left inherits the body's.

Upgrade notes

  • A stylesheet of your own that set --left-sidebar-font-size for admin4 has no effect any more; set font-size on .sidebar.left instead.

15 October 2026: an Import/Export button in the node view

Added

  • The node view's actions have an Import/Export button after Upload and before Move (admin4, admin4l, admin; exp_adminui 1.0.0.24: an entry of the toolbar's "…" menu). Its menu offers the export entries of the context menus' Advanced > Export and, with xrowextract 2.5.12, Import into this item (only on a container below which the user may create content), the export and import jobs, the export history and the schedules, each for its policy. It is shown only for a published object with an entry for the user: an Editor sees no change, a user with xrowextract/csv only sees the CSV entries. A menu button (Enter or Space open it, Escape returns to it) with the Bootstrap icon arrow-down-up, which is now bundled (.a4-bi-arrow-down-up, design.ini IconList[]). Guide: The Export menu of the content tree.
  • popupmenu/popup_export_items.tpl, one definition of the export entries for both menus, and admininterface.ini ImportExportMenuTemplateArray[] for an extension's import and job entries; ezpopupmenu.js's element option shown_if leaves out an entry that does not apply to the item.

Updated

  • Required packages: xrowextract ~2.5.12 (xrowextract/import/(parent)/<node id>, the import and job entries), exp_adminui ~1.0.0.24.

15 October 2026: the phone menu fits the screen and is a grid of tiles

Fixed

  • admin4: the open phone menu (below 576px) was cut off at the bottom in Safari and Chrome on iOS. The panel was at most 100vh high and started at the window's top, with the header's height as padding inside it. On iOS 100vh is the height with the browser's toolbars hidden; with them shown (as when the menu is opened) the panel's end lay under the bottom toolbar, and scrolling the panel could not bring its last items up: with the search scope popup open at a visible height of 664px the panel ended 60px below the window (measured in an emulated 390 x 664 viewport with 100vh resolved to 750px, as iOS does). The panel now starts under the header's row and is at most 100dvh (the visible height, which follows the toolbars; 100vh before it for browsers without dynamic viewport units) less the header. It is the only scroller (admin3 let the list inside scroll as well), its scrolling does not chain to the page (overscroll-behavior: contain), the page does not scroll while it is open, and the safe area insets keep its ends clear of the home indicator where a page covers it (viewport-fit=cover).
  • The closed phone menu was still in the Tab order (max-height: 0 hides, it does not remove): it is visibility: hidden after its closing slide now. A menu left open while the window grows past 575px (a phone turned to landscape) is closed, which also gives the page its scrolling back.

Updated

  • The phone menu is laid out for thumbs: the search first and on a row of its own, then the tabs as a grid of tiles (three to a row at 390px, two at 320px, one at 200% text) instead of a column of small centred links, the current tab filled with the accent; the user's actions (the user's name, leading to their object, Change information, Change password, My e-mail preferences, Logout) as a row of their own; a Close button at the end. Every target is at least 44 x 44px (Apple's Human Interface Guidelines; the search's scope button 40px inside the 44px field), with 16px at the panel's top and 24px at its sides and end. On a phone the header's row keeps the logo, the environment badge, "Open the site", light/dark and the menu button, which shows a cross while the menu is open; the user menu and logout move into the menu (admin4l's header, which has no such row, keeps them).
  • Keyboard: while the phone menu is open, Tab and Shift+Tab stay in the header (its row, the menu button and the panel); Escape and the Close button close it and give the focus back to the button; a tap outside the header closes it. No slide with reduced motion; in forced colors the tiles keep an edge and the current tab is underlined with a heavier edge.
  • New template parts/a4_phone_user.tpl (the user's actions; the same links and conditions as parts/a4_header_user.tpl). In page_topmenu.tpl and admin4l's tab menu block the phone search moved to the menu's start; admin4.js's one-line menu leaves the phone's own items (.a4-phone-only) out of its fitting. From 576px nothing changes.
  • Test AdminPhoneMenuTest (testsuite design): the panel's height (100dvh after a 100vh fallback, no 100vh left in the phone rules), its single scroller and overscroll-behavior, the grid, the 44px targets, the order of the menu's items, the user row's links against the header's, the hidden closed panel, and in node the focus trap, the Close button, the tap outside (pointerdown: iOS sends no click for a tap on the page's background) and the close on a wider window. AdminHeaderTest follows the new order.
  • admin4l's tab menu block (explayouts/block/admin_tab_menu.tpl) ends with the same Close button as admin4's top menu, wired by the same script; AdminPhoneMenuTest checks its order and button too.

15 October 2026: the site menu of the administration

The globe in the admin4 and admin4l header, which opened the site, now also lists the installation's other sites that the signed-in user may log in to, with their addresses, so an administrator can see which end points there are without reading the INI settings. One button in the place of the link: the header takes no more room. On by default. Feature ("Site menu").

Added

  • site.ini [AdminSiteAccessMenu]: Show (enabled; disabled leaves the globe a plain link), List (related: RelatedSiteAccessList[] of the admin siteaccess, else AvailableSiteAccessList[]; available: all of AvailableSiteAccessList[]; an installation with separate databases per site sets RelatedSiteAccessList[] for each administration), HiddenSiteAccessList[], AdminSiteAccessList[] (which siteaccesses count as administrations; empty: those whose SiteDesign begins with admin or that set FrontSiteAccess) and ListAdministrations (disabled: sites only; enabled: the administrations too, as a group of their own; part of the cache key).
  • ezpSiteAccessMenu and the template operators siteaccess_menu( $current_user ) and siteaccess_menu_key() (ezpSiteAccessMenuOperator), and ezpSiteAccessURL::frontSiteAccess(), the name of the siteaccess front() links to. The addresses are ezpSiteAccessURL::root()'s, as siteaccess_url() has them (MatchOrder, URIMatchType, HostMatchMapItems, SiteURL, the scheme, host and port of the request).
  • parts/a4_header_site.tpl, the site link and the menu for admin4's page_topmenu.tpl and admin4l's tools block. For signed-in users, the globe is a disclosure button (aria-expanded, aria-controls) whose list opens the site first, highlighted, then the other sites (with ListAdministrations=enabled the administrations as a second group), each with its SiteName (the identifier without one), identifier, locale and address, in the same window; the siteaccess in use carries aria-current. Keys as the user menu: ArrowDown opens it, ArrowUp/ArrowDown/Home/End move, Escape closes it and returns the focus, a click outside closes it. Without JavaScript, or with the menu off, or with no other siteaccess to list, the globe stays the link it was. On a phone the list opens under the header. Light, dark and forced colors. German and American English for the four new strings. Test: AdminSiteAccessMenuTest (with the node harness tests/tests/design/js/site_menu.js).
  • Besides the site the globe opens, a siteaccess is listed only where the signed-in user may log in: user/login with its SiteAccess limitation, checked by eZUser::canLoginToSiteAccess() as the login checks it. The template passes $current_user to siteaccess_menu(); the header's cache blocks are keyed by the user's role hash already. The entries take --a4-control-h and a spacing token.

Updated

  • The header's cache blocks (admin4 and admin4l pagelayout.tpl) are keyed by siteaccess_menu_key() too: the list, its groups, the siteaccess in use and the scheme, host and port of the request. A changed SiteName, Locale or SiteDesign of a listed siteaccess shows once the template caches of the administration siteaccess are cleared.
  • A mega menu does not open on hover while the site menu is open, as for the user menu.

15 October 2026: admin4's typeface is the Helvetica Neue stack

Updated

  • admin4: --a4-font (components.css, also the copy in design/admin) is now "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" instead of the operating system's interface font (system-ui, -apple-system, "Segoe UI", Roboto, ...). The text looks the same on macOS, Windows and Linux, and emoji in content names and text are drawn in color everywhere. System fonts only; no font file is added.
  • admin4: the stylesheets that still named a font list of their own use the tokens: core.css (body, headings, form fields, buttons; pre and code take --a4-mono instead of "Courier New"), the pager of theme/admin_datatable.css, content.css, packagecompare.css, ezinfo.css, visual-templateview.css. core.css keeps Arial (and monospace) as the fallback of var(), for the standard popup, print and redirect layouts that load it without components.css. The text and the table cells of the rich text editor (editor_content.css) follow --a4-font instead of the editor skin's Arial, its literal blocks --a4-mono.
  • Test: AdminFontStackTest (testsuite design): --a4-font is exactly this stack in both copies of components.css, and every font-family of admin4's stylesheets is var(--a4-font) or var(--a4-mono) (with an optional fallback) or inherit (icon fonts aside); the emoji faces follow the text faces (the generic family last of these, Windows' color emoji before its monochrome symbols); no font is downloaded (the one @font-face is the IcoMoon icon font from the design's folder, no template or stylesheet of admin4 or admin4l loads a web font). admin4l has no stylesheets of its own and takes all of this from admin4.

Upgrade notes

  • The administration's text changes from the system interface font (San Francisco on macOS, Segoe UI on Windows) to Helvetica Neue, Noto Sans, Liberation Sans or Arial, whichever is installed first, with color emoji fallbacks. To keep the old look, redefine --a4-font in a stylesheet of your own (design.ini [StylesheetSettings] BackendCSSFileList[]), e.g. :root { --a4-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }.

LoveStack features (from 2026-10-12)

Features of LoveStack, Mugo Web's distribution of the same kernel, rebuilt in Exponential with thanks to Mugo Web; each line names the LoveStack commit it comes from.

Overview, feature by feature, with who each one is for and how to switch it on: LoveStack features in Exponential 6.

  • Moving a site from LoveStack: ./console exp:lovestack check lists what a LoveStack installation brings along and what to do with each item: the database version row lovestack, LoveStack's db-schema-version row, a unique index on ezuser.login, a data_text NOT NULL DEFAULT '' column, the legacy_2018 extension, LoveStack-only settings in settings/override and the siteaccesses (with the Exponential name where there is one) and the mugoweb/ezpublish-legacy package in composer.json (expLoveStack). It reads only and ends with exit code 0 when nothing needs to be done, 1 otherwise; --json for a machine-readable report. Tested on a LoveStack-shaped SQLite database built from Exponential's schema and LoveStack's schema steps 1 to 4 (master e61e9fdf0c): the same content operations (publishing, new versions, adding, moving and removing locations, copying, a new class attribute on a class with objects, the trash) give the same content as on an Exponential database, and an empty string, never NULL, in data_text, so the column can stay as LoveStack made it. Guide: Coming from LoveStack.
  • Moving a site from LoveStack: ./console exp:lovestack migrate records a LoveStack database as Exponential's. The version row ezpublish-version = lovestack becomes this release's version (6.0.15stable), and LoveStack's schema version (db-schema-version, kept as it is) is recorded as ezsite_data lovestack-schema-version. It then prints the settings and extensions to change by hand, taken from check: LoveStack-only settings with their Exponential names, [Session] SameSite (read as CookieSameSite with a deprecation notice until it is renamed), an active legacy_2018 extension and the mugoweb/ezpublish-legacy package (expLoveStack::migrationPlan(), migrate()). A dry run unless --apply is given; it never changes content or settings, a second run changes nothing, and a database that does not come from LoveStack is refused with a message saying so; --json for a machine-readable report. It follows LoveStack's documented schema steps (master e61e9fdf0c, update/run.php and update/database/mysql/lovestack/1.sql to 4.sql), with thanks to Mugo Web. Guide: Coming from LoveStack.
  • L01 (LoveStack commit aa307a7ff3): the node data calls ezjscnode::load, ::subtree and ::updatepriority can require the policy ezjscore / call with the function list ezjscnode: ezjscore.ini [ezjscServer_ezjscnode] Functions[]=ezjscnode and the new per server function setting RequirePolicy (enabled: checked, a request whose calls are all refused is answered with 403; disabled: not checked; not set: checked as before). New installations switch it on and give the Editor role the policy. Upgrade note: the shipped value is RequirePolicy=disabled, so nothing changes; to switch it on, give ezjscore / call with ezjscnode to the roles that use the administration without full access, then set RequirePolicy=enabled in settings/override/ezjscore.ini.append.php. Guide: Roles and policies, section 8.
  • L02 (LoveStack commit 98854ab935): registering can require the user/register policy. site.ini [RoleSettings] PolicyOmitListExclude[] takes single entries out of PolicyOmitList[] (new: expPolicyOmitList), and the login pages of admin and admin4, the standard design and its login toolbar, the media and the starter designs show the register link or the Sign Up button only to a user who may open user/register (fetch( 'user', 'can_open' )). New installations write PolicyOmitListExclude[]=user/register. Upgrade note: user/register stays in PolicyOmitList[], so registration and its link stay open; to close it, add PolicyOmitListExclude[]=user/register to settings/override/site.ini.append.php (and give the Anonymous role User / Register where registration should stay open). Guide: Roles and policies, section 8.
  • L03 (LoveStack commit 13ace6c9c8): logging out ends the session and its cookie. eZUser::logoutCurrent() removes the session (eZSession::remove()) instead of giving it a new id with its contents kept. The destroy() of every session handler sends the session cookie expired with the path, domain, Secure, HttpOnly and SameSite that eZSession::start() set it with (eZSession::expiredCookie(), eZSession::expireCookie()); eZSession::remove() also keeps a handler that only deletes its stored copy (ezpSessionHandlerDB) from having the session written back, and gives a session started later in the same request a new id, under PHP-FPM and Velocity alike. The logout view reads LastAccessesURI before logging out and no longer opens a new session only to store force_logout. On for every installation. Upgrade: nothing to do; code of your own that reads session variables after eZUser::logoutCurrent() finds the session empty. Guide: Security and audit.
  • L04 (LoveStack commit 826e1d4647): one account per login. ./console exp:user-login-check lists the logins more than one account has, also those that differ only in case, and changes nothing; --fix-index adds the unique index on ezuser.login once the list is empty and refuses otherwise (expUserLoginIntegrity). The kernel schema of every engine (kernel/sql/*, share/db_schema.dba) has the unique index, so new installations get it. A user account of a first version whose form did not validate is not stored, and a draft, a package import, the content API's account string and a first LDAP sign-in no longer give an account a login another account has; two requests creating the same account at once end with one account. Upgrade note: the database update does not add the index (it would fail on duplicates); run ./console exp:user-login-check, resolve what it lists, then ./console exp:user-login-check --fix-index. Guide: one account per login.
  • L05 (LoveStack commit c8e45fced7): uploaded images are checked by their content. PHP's fileinfo reads the type, which must be on image.ini [ImageUploadSettings] AllowedMIMETypes[] (JPEG, PNG, GIF, WebP, AVIF, TIFF, BMP; SVG was added on 15 October 2026, stored sanitized); a refused file is not stored. It applies to the image datatype in the edit form, content/upload, the rich text editor's upload dialog, the AJAX uploader, WebDAV and imports, and eZContentUpload refuses such a file before it creates an object. New: expImageUploadValidator::validate( $path ). On for every site, upgrades included: a file that is not a listed image by content (a renamed file, HEIC, SVG) is refused (SVG is accepted from 15 October 2026, see SVG images). To accept another type, add it to AllowedMIMETypes[] in an override; ValidateMIMEType=disabled keeps only the getimagesize() check. Without fileinfo that check decides and one warning per process says so. See behaviour changes of October 2026.
  • L06 (LoveStack commit 04b3b6985a): the package wizard checks the thumbnail of a new package and the images of a style package by their content, with the allow-list of L05 (eZPackageCreationHandler::httpFileIsImage()), so a file that only looks like an image is refused. On for every installation, upgrades included; nothing to do. Guide: behaviour changes of October 2026.
  • [Session] SameSite (LoveStack commit e56cb305f0): settings written with LoveStack's name for the SameSite attribute of the session cookie keep working. site.ini [Session] SameSite= is read when CookieSameSite is not set (CookieSameSite is used when both are), and noted once per request at the deprecated level of the debug output (var/log/deprecated.log). Upgrade: nothing changes for visitors; rename SameSite to CookieSameSite in settings/override and the siteaccesses to silence the note. Guide: Security and audit.
  • L07 (LoveStack commits fbbd333a85, e20570a7ad, b7832d110e): PHP deprecations have a debug level of their own, eZDebug::LEVEL_DEPRECATED with SHOW_DEPRECATED (part of SHOW_ALL) and eZDebug::writeDeprecated(). With site.ini [DebugSettings] DeprecatedLog=enabled (the default) E_DEPRECATED and E_USER_DEPRECATED are written to var/log/deprecated.log, each distinct message once per request, and only counted: the debug bar shows their number (depr), they stay in the page's warning count, and none is kept in memory or listed with the messages. AlwaysLog[]=deprecated writes them while debug output is off as well. Upgrade note: on after the upgrade; DeprecatedLog=disabled keeps them warnings as before. Guide: PHP deprecations and log file sizes.
  • L08 (LoveStack commit e39dde30ff): the size and rotation of the log files are settings, site.ini [FileSettings] LogFileMaxSize=2097152 (2 MB) and LogFileRotateCount=3, read by eZLog and eZDebug; setMaxLogSize(), setLogrotateFiles() and the constants in config.php still win over them. Upgrade note: log files are rotated at 2 MB instead of 200 KB after the upgrade; LogFileMaxSize=204800 keeps the old size. Guide: PHP deprecations and log file sizes.
  • L09 (LoveStack commit 0bb0bac618): a template operator parameter that the template leaves out and the operator gives no default value for is null, with the warning Operator '<op>' has no default value for parameter '<name>', once per operator and parameter in a request; a compiled template is warned once when it is compiled and its code does not check again. New: eZTemplate::operatorParametersWithoutDefault(). Upgrade note: on for every site, only the warning is new. Guide: an operator parameter without a default value.
  • L10 (LoveStack commit fb20bee0d2): site.ini [DebugSettings] UsedTemplateEditUrl= is the sprintf() format of the "Edit" links in the debug output's list of used templates (%1$s editor address with the file, %2$s siteaccess, %3$s template file). Upgrade note: empty, the shipped value, keeps the link the list always had. Guide: edit links in the list of used templates.
  • L11 (LoveStack commit 3d18f47551): eZDBException::getCode() is the driver's error number of the failed statement on every driver, as errorNumber(): MySQL's number, PostgreSQL's SQLSTATE (a string), SQLite's result code, Oracle's ORA number; MongoDB keeps the server's error code in errorNumber(). MySQL reads the error of the connection the statement ran on (the write server, when there is one), and an error is kept through the transaction's rollback. Upgrade note: PostgreSQL's errorNumber() is the SQLSTATE instead of 1; with the exception handling, MySQL on PHP 8.1 and later throws eZDBException instead of mysqli_sql_exception. Guide: know your database driver. The "database unavailable" page (HTTP 503) also recognises an eZDBException whose error number says the connection went away or could not be made (MySQL 2002/2003/2006/2013 ..., PostgreSQL SQLSTATE class 08, Oracle ORA-03113/03114/12541 ..., SQLite 14/26): eZExecution::isDatabaseUnavailableCode().
  • L12 (LoveStack commit f9691221a3): when a change would clear the view caches of site.ini [ContentSettings] CacheThreshold nodes or more and all view caches are expired instead, eZContentCacheManager writes a warning with the number of nodes and the threshold (it was a debug message). Upgrade note: on for every site. Guide: caches, usual questions.
  • L13 (LoveStack commit 9c931192e0): location changes in the audit trail, channel content, written from eZContentOperationCollection: content.node.add once per added location (the object; the new node and its parent as the target), content.node.remove with whether the removed location was the main one, and content.node.main when an editor chooses another main location or the next location takes the place of a removed main one (cause location-remove). The console shows them as Location add, Location remove and Main location change. The names location-assign, location-remove and main-node-update are mapped in audit.ini [AuditCompatSettings] Map[] and AuditFileNames[], with their attributes, so eZAudit::writeAudit() calls and imported files of those names land on the same events. Upgrade note: content.node.remove is on as before; content.node.add and content.node.main stay off until Enabled[]=content.node.add / Enabled[]=content.node.main is set. Guide: audit reference.
  • L14 (LoveStack commits 55029bd473, a679a13176, 94b7b6c239): fetch( 'content', 'site_data', hash( 'name', ... ) ) reads one row of ezsite_data (the eZSiteData object, or false), read only and only for the names of site.ini [SiteDataSettings] TemplateReadable[] (empty by default, so nothing is readable until a name is listed). Setup > Site data (setup/sitedata, policy setup/system_info) lists the rows with a filter and paging, in admin, admin3 and admin4. New eZSiteData::createNew(), templateReadableNames(), isTemplateReadable(), fetchList() and fetchListCount(). Upgrade note: nothing is readable from templates until a name is listed. Guides: site data in templates, site data.
  • L15 (LoveStack commit 4d56593614): a Copy button on the full view of the administration interface (admin, admin3, admin4), next to Move, active with create access at the item's parent as in the context menu; it opens content/copy. Guide: copy from the full view. On for every installation; nothing to do on an upgrade.
  • L16 (LoveStack commit a28863d9c0): an image attribute can require an alternative text, the class attribute option Alternative text is required (eZImageType::ALT_TEXT_REQUIRED_FIELD, data_int2, off by default). The edit form, the online editor's upload dialog (also used by the Tiptap engine) and the AJAX uploader of relation fields refuse an image without one; the AJAX uploader has an alternative text field for classes with an image attribute and stores it on the new object. Packages carry the option. Upgrade note: off for every class; switch it on per attribute on the class edit page. Guide: required alternative text.
  • L17 (LoveStack commit 6a5c7dd23e): a text line attribute can trim the stored value, the class attribute option Trim the stored value (eZStringType::TRIM_FIELD, data_int2, off by default): spaces at the start and the end are removed in the edit form, information collection, imports and uploads. Upgrade note: off for every class. Guide: trim the stored value.
  • L18 (LoveStack commit 95d86f5990): a PHP class can make object names and URL alias names: {ClassName} at the start of a class's name pattern, for classes listed in content.ini [NamePatternSettings] ResolverClasses[] (empty by default) that implement expNamePatternResolverInterface; any other name is logged and the pattern is resolved from its tokens. New eZNamePatternResolver::resolve(), customResolver() and limitName(); the constructor and resolveNamePattern() are unchanged. Upgrade note: nothing changes until a class is listed. Guide: names made by a PHP class.
  • L19 (LoveStack commit 9c0ff40a78): content.ini [FetchSettings] DefaultClassFilterType= is the class filter type the fetch functions list, tree, list_count, tree_count and calendar use when a fetch gives class_filter_array without class_filter_type (eZContentFunctionCollection::classFilterType()). Upgrade note: empty, the shipped value, keeps today's results (the array is ignored); include filters by the array. Guide: class filter default.
  • L20 (LoveStack commit f1b7f056b4, EZP-26501): in the edit form's Locations window an existing location starts at "Unchanged", not "Visible", so publishing no longer reveals a hidden location; a new location starts as it was given. Fix, for every site. Guide: the Locations window.
  • L21 (LoveStack commits eee7f69fc3, 67eec8f972, 492a946963): the view templates of the selection and matrix datatypes write no stray <br />; several selected options are separated by one <br /> each. Fix, for every site that does not override these templates. Guide: selection and matrix fields.
  • L22 (LoveStack commit e21ef46691): content.ini [CustomTagSettings] IsHidden[] names custom tags the editors do not offer for new tags (TinyMCE 3's ezoe/tags, the TinyMCE 8 custom tag dialog, Tiptap). A hidden tag already in a text is still rendered, shown, editable and saved back unchanged; its own dialog keeps its name. New: eZOEXMLInput::hiddenCustomTags(), eZOEXMLInput::offeredCustomTags(), the key hidden of getCustomTagDefinitions(). None is hidden by default, so nothing changes on an upgrade. Guide: Hidden custom tags.
  • L23 (LoveStack commit 63334f1681, the custom_tags_controls plugin, Copyright 2014, Mugo Web): custom tag controls in the Online Editor with ezoe.ini [EditorSettings] CustomTagControls=enabled. A focused custom tag gets a bar to move it up or down, edit it and remove it (its content stays), also with Alt+Shift+Up, Alt+Shift+Down, Alt+Shift+E and Alt+Shift+Delete; Backspace in an empty block custom tag removes it; every block custom tag shows its name as a label (ezoe_customtags.css); a tag's dialog shows images/editor/custom_tags_thumbs/<name>.png from a design as an example. TinyMCE 3 gets the new plugin exp_customtag_controls (TinyMCE 3's plugin API and ezjscore's jQuery 4, no YUI), TinyMCE 8 the buttons in its custom tag context toolbar, Tiptap the same in exp_oe_tiptap 0.1.2. New installations write CustomTagControls=enabled. Upgrade note: the shipped value is disabled, so the editor stays as it was; set CustomTagControls=enabled in settings/override/ezoe.ini.append.php and clear the INI cache and the ezjscore packer cache. Guide: Custom tag controls.
  • L24 (LoveStack commit 3a8c14bc0c): the sub/sup custom tag dialog of TinyMCE 3 closes on OK. Exponential's tag_custom.tpl already had the fix (from the same author's later upstream commit); tested in the browser on TinyMCE 3 and Tiptap (TinyMCE 8 has its own sub and sup buttons). Nothing changes on an upgrade.
  • L25 (LoveStack commit f2284944b2): eZURI::build( string $url, bool $full = false, bool $htmlEscape = true ) builds a link within the site in one call, the same as the ezurl operator gives (index file and siteaccess in front; with $full scheme and host). $htmlEscape = false for a URL in a header or a JSON answer. Addition only. Guide: URLs, for developers.
  • L26 (LoveStack commit df07cb1ef5): eZMailTransport::send( eZMail $mail, ?string $transportType = null ) sends one message with another transport of site.ini [MailSettings] TransportAlias[] (for example file) without changing the setting; the mail gate applies as before. Addition only. Guide: testing without sending mail.
  • L27 (LoveStack commit 627004e5c4): the template operators rawurlencode and rawurldecode (template.ini [PHP] PHPOperatorList[]), RFC 3986 encoding with %20 for a space. Available in every installation, upgrades included; urlencode and urldecode are unchanged. Guide: encoding a value for a URL.
  • L28 (LoveStack commit f7c382bc2b): [Include <pattern>] inside a settings file reads other files at that place: the pattern is relative to the including file, wildcards are allowed, the files are read in name order, includes nest, a loop is read once with a warning and a pattern without a match is a notice. The included files and the directories their patterns look in are part of the INI cache ('includes', eZINI::includedFiles()), so ezcache.php --clear-tag=ini, the INI step of exp:velocity deploy and expVelocity::refreshIni() pick up their changes; with the file time check on, a changed, added or removed included file rebuilds the cache by itself. The settings page, the classic settings editor and exp:ini keep the line as written and do not expand it. Only active where a file has such a line; parse time of files without one measured unchanged (an A/B in one process over 174 files, within the host's noise). Guide: splitting a large settings file.
  • L29 (LoveStack commit 97690e9c93): error.ini [HTTPError-<code>] HeaderList[<name>]=<value> adds response headers to an error page after the status lines of HTTPName; they are stored with the page in the content view cache and sent again from it (expErrorHeaderList). An invalid header name or a value with a line break is left out with a warning; HeaderList[Status] stands for a missing HTTPName. Empty by default, new installations and upgrades alike, so nothing changes until an entry is added. Guide: response headers of error pages.
  • L30 (LoveStack commits 3d2d337b0d, 3af1ec3aee): the ezjscore search (ezjsc::search, used by the editor's embed and link dialogs and the relation search) sorts eZ Find results by ezfind.ini [SearchSettings] SortBy[]; ezjscore ships SortBy[published]=desc, the order it always used, so nothing changes on an upgrade. Without eZ Find or the setting the order stays newest first. Guide: the order of search results.
  • L31 (LoveStack commit 856a95e240): every node view sets the override key layout ('' outside layout/set, the layout of layout/set kept), through the new eZTemplateDesignResource::setKeyValue( string $key, $value, bool $override = true ). Guide: the layout key.
  • L32 (LoveStack commit f435c2f91e): site.ini [URLTranslator] DecodeOnce decodes the address of a request once instead of twice, so /(tag)/bar%2Bbar gives the view parameter bar+bar instead of bar bar. new eZURI( $uri, $decode ) and setURIString( $uri, $fullInitialize, $decode ) take whether the string is encoded (null: follow the setting); eZURI::decodeOnce(). PHP-FPM and Velocity hand the kernel the same raw address, and the response cache keys are made from it, so they do not change. Upgrade note: off as shipped, on new installations and after an upgrade (DecodeOnce=disabled); to switch it on, set DecodeOnce=enabled in settings/override/site.ini.append.php after checking links that put an encoded + or % in a view parameter. Guide: Decoding and cleaning addresses.
  • L33 (LoveStack commit 7517c0e925): site.ini [URLTranslator] TrailingCleanup makes the double quote a reserved character of the IRI alias cleanup (urlalias_iri), so it becomes a separator and is stripped at the ends like !, dots and separators (eZCharTransform::trailingCleanup()); the other transformations already strip them. Upgrade note: off as shipped (TrailingCleanup=disabled); with TrailingCleanup=enabled in settings/override/site.ini.append.php existing aliases stay until their object is published again, and the old alias then redirects. Guide: Decoding and cleaning addresses.
  • L34 (LoveStack commit 3774a54ead): a number in a text beyond the range of the search index's integer column no longer stops the built-in search engine from indexing the object; it is stored as the limit of the column. Each driver states the range of its integer columns, MAX_INT and MIN_INT (signed 32-bit for MySQL, PostgreSQL and any other driver, signed 64-bit for SQLite and MongoDB), and eZDBInterface::clampInteger( $value ) keeps a value within it, also one written as a float (1e30), which a plain (int) turned into a wrong number. On for every installation; nothing to do on an upgrade. Guide: Numbers in the built-in search index.
  • L35 (LoveStack commit 5c089f6250): the search index leaves out the relation fields of related objects, and the relation datatypes need no recursion guard. Exponential has done the same since 2018 (EZP-27101, eZContentObjectAttribute::metaDataArray( $attributes, true ), eZDataType::isRelationType()), so there is nothing to switch and no setting; tests/tests/kernel/datatypes/eZObjectRelationMetaDataTest.php now documents the behaviour, a cycle of relations included. Upgrade note: none; a site coming from LoveStack gets the same search results.
  • L36 (LoveStack commits 3298b53584, 89a5a73877): the hide cronjob (hide.php) tells the search engine about each node it hides (eZSearch::updateNodeVisibility(), in Exponential since 2018), and the content/cache event gives listeners ( $nodeIDs, $objectIDs ) from every sender in the kernel, now also from the node's URL aliases page; a listener that takes only the node ids keeps working. On for every installation; nothing to do on an upgrade. Guide: Cronjobs.
  • PHP 8 review (phase 7): LoveStack's PHP 7.2 to 8.3 fixes (2017 to 2026) reproduced one by one on PHP 8.5 and 8.1, each as a regression test; most cases were already handled in Exponential and stay as tests that document them. Four still failed and are fixed, on for every installation (plain bug fixes, nothing to do on an upgrade):
    • LoveStack commit e828c54709: the get_class and is_class template operators accept a value that is not an object without template compilation too ('' and false, as the compiled code gives); before, a string, number or null ended the request with a TypeError.
    • LoveStack commit 4065ed16b3: wash(email) of an empty value gives no "Passing null" deprecation.
    • LoveStack commit 75cbb1daa2: eZHTTPTool copes with $_POST set to null.
    • LoveStack commit 1bd250d4c6: update/common/scripts/5.4/cleanupfieldvaluerelations.php skips relation list attributes whose data_text is NULL or empty instead of stopping with a ValueError.
  • PHP 8 review, four more sites from the same LoveStack commits, each reproduced first on PHP 8.5 and 8.1 and fixed; on for every installation (plain bug fixes, nothing to do on an upgrade):
    • LoveStack commit 4065ed16b3: the classic HTML debug report (used when the Exp Debug bar is not there) shows a message whose label or text is null without a "Passing null" deprecation, as the debug bar already did.
    • LoveStack commit 4065ed16b3: the Exp Debug bar shows a message whose text is null or a number without a "Passing null to strncmp()" deprecation when xdebug is loaded (expDebugBarReport::messageRows()).
    • LoveStack commit 4065ed16b3: ezurl, ezroot and eZURI::transformURI() of a number (a node id) give no "Trying to access array offset" warning, and new eZURI( null ) (the command line before a request URI is known) is an empty address. eZURI::build() gives the same links as before.
    • LoveStack commit 1bd250d4c6: eZPostgreSQLDB::escapeString( null ) gives '', as the MySQLi, SQLite3 and MongoDB drivers do, instead of passing null to str_replace() and pg_escape_string().
  • LoveStack commit 5a17c08bfc: a customer's orders are found from the order lists, the archive, the customer list and the shop dashboard when the e-mail address has a "+" (or "?", "#"). The links rawurlencode the address and shop/customerorderview no longer decodes it a third time; older links still work. On for every installation. Upgrade note: a site design of your own that links to shop/customerorderview can add |rawurlencode to the address in the same way.
  • LoveStack commit cb8a3a1c58 (the spell checker part): ezoe/spellcheck_rpc reads the request body from php://input and never from the removed $HTTP_RAW_POST_DATA, GoogleSpell decodes character references without the /e modifier, and the spell checker and JSON classes have __construct(). Exponential already had all three; checked on PHP 8.5, a test keeps them (ezoeSpellcheckRpcRawInputTest).
  • The template operators urlencode, urldecode, rawurlencode and rawurldecode give an empty text for a value that is not set, with and without template compilation, instead of handing null to PHP (a "Passing null" deprecation on PHP 8.1 and later). Found by the L27 tests; a plain fix for every installation, nothing to do on an upgrade (compiled templates take it when the template cache is cleared).
  • Tests (phase 8): every feature above is covered in the PHPUnit suite, each test file running on its own and, apart from the tests marked live, without a database. New: the settings an upgraded site keeps (expShippedFeatureDefaultsTest), the Copy button of the full view (L15, eZAdminFullViewCopyButtonTest), the visibility the Locations window preselects (L20, eZEditLocationsVisibilityTest), the selection and matrix view templates (L21, eZSelectionMatrixViewTemplateTest) and the image check of the AJAX uploader (L05, ezjscServerFunctionsAjaxUploaderImageTest). LoveStack's own test cases for behaviour Exponential has are in the suite as well, with thanks to Mugo Web: the autolink operator (LoveStack commit d894b9e885), sorting by the trash date (e280ff5d64), the name length limit through eZNamePatternResolver::resolve() (95d86f5990), a bare & in a link address of the simplified XML input (5286068d5b), the REST request parser's recorded requests (30c1260b84, 09e49ce1ff), a password change that updates the account details of the user attribute (60a40cdc05, a live test that rolls back what it writes) and eZSiteData::create() and createNew() (55029bd473, 4a27ffecb4; the stored row in a live test that rolls back).

Required packages (root composer.json, 2026-10-10 to 2026-10-12)

The constraints of the root composer.json that changed between 2026-10-10 and the last commit of 2026-10-12.

PackageRequired at the end of 2026-10-12Why it was added or repinned
expai~0.0.1.0new: Exponential Intelligence, the writing assistant of the administration interface
expqueue~0.1.0new: the job queue for Exponential
exponential-velocity (suggested)~0.0.4.51the warm-up's request ends before the pool forks, and a request's $_SESSION ends with it (0.0.4.51); a HEAD over HTTP/2 is its headers only and a stop drains its connections (0.0.4.50)
exp_adminui~1.0.0.9the content finder box in the content structure menu (1.0.0.7), the Open Graph image (1.0.0.8) and the license block (1.0.0.9) of its metadata edit form
xrowmetadata~1.4.10an Open Graph image per object and per class; content licenses at installation, class and object level, from a catalogue of 1,332 licenses
sevenx-themes-media~1.5.54one og:image per page (1.5.51); the Sign Up button of the login page only for a visitor who may register (1.5.54)
cjw_themes_jumper~0.1.3the starter design prints Open Graph and Twitter tags (0.1.2); the register link of its login page only for a visitor who may register (0.1.3)
explayouts / explayouts-core / explayouts-ui / explayouts-ui-api~1.4.24 / ~1.0.7 / ~1.3.14 / ~1.3.15a block copy takes its collections, query and child blocks along; the layouts lists show the minutes of the modification time; the layouts pages belong to the Layouts tab and the template editor works again, on .tpl files in its allowed roots only (1.4.24)
ezoracle~2.3.6long multibyte texts load; the reserved column names comment and mode
xrowextract~2.5.9passes its CI check on PHP 8.1 to 8.5
sevenx_authentication_2fa~1.0.6allows PHP 8.0 like the kernel

15 October 2026: 89 new locales and one page for every language

Content can now be written in the major languages of the world: 89 locale files were added to share/locale (138 in all), and every language Exponential supports, at which level, and how to add more is described in one place, Languages.

Added

  • 89 locales, generated at build time from Unicode CLDR 44 (ICU 74.2; Unicode License v3, named in each file): South Asia ben-BD, ben-IN, urd-PK, urd-IN, mar-IN, tel-IN, tam-IN, pan-IN, pnb-PK, snd-PK, guj-IN, kan-IN, mal-IN, ori-IN, asm-IN, bho-IN, nep-NP, sin-LK, pus-AF, eng-IN; Southeast and East Asia vie-VN, tha-TH, msa-MY, fil-PH, mya-MM, khm-KH, lao-LA, jav-ID, chi-SG, eng-SG, mon-MN, uig-CN; Middle East and Central Asia fas-IR, ara-EG, ara-AE, kur-TR, aze-AZ, kaz-KZ, uzb-UZ, kat-GE, hye-AM, tgk-TJ; Africa swa-KE, swa-TZ, amh-ET, hau-NG, yor-NG, ibo-NG, pcm-NG, zul-ZA, xho-ZA, afr-ZA, som-SO, orm-ET, tir-ET, kin-RW, mlg-MG, sna-ZW, eng-ZA, por-AO; Europe ron-RO, bul-BG, slv-SI, lit-LT, lav-LV, est-EE, isl-IS, gle-IE, cym-GB, eus-ES, glg-ES, sqi-AL, mkd-MK, bos-BA, bel-BY, mlt-MT, ltz-LU, ger-AT, ger-CH, dut-BE, fre-CH, ita-CH, eng-IE; Americas esl-AR, esl-CO, esl-CL, esl-PE, esl-US, que-PE. Existing language codes are reused for regional variants (ger, esl, fre, dut, ita, ara, eng, chi, por); a new language gets its ISO 639-2/T code (ben, urd, vie, fas, ron ...), Western Punjabi in Shahmukhi script pnb and Nigerian Pidgin pcm (ISO 639-3). Each file is UTF-8 with names in English and in the language, the BCP 47 tag, date and time formats, day and month names, the first day of the week, number and currency formats and country names. Where CLDR's 12-hour clock uses a marker other than am/pm, the file uses the 24-hour clock, since eZLocale only prints am/pm. The right-to-left languages (fas, urd, pus, pnb, snd, uig, ara) carry a comment; eZLocale has no direction setting.
  • Languages: locales against administration translations, content languages and siteaccesses; the support levels (full interface and content, partial interface, content only); every locale with its English and native name, region, direction and release; every administration translation with its completeness measured two ways (its own file, and the 13,620 strings of today: 100% eng-US, 99% ger-DE, 20 to 48% for the others); the 38 languages with 50 million or more speakers and their locales (all 38 now, 18 before) and translations; the common languages still missing; how to add a locale (every key, the code convention, the generation, the test) and an administration translation; the right-to-left status; the limit of 62 content languages. Linked from Translations and languages, Content languages and the README.
  • Test expLocaleFilesTest (no database, no PHP extension): every share/locale/*.ini is valid UTF-8, parses with eZINI, loads in eZLocale with every key it reads, formats a date, time, number and amount and reads the number back; the generated files also follow the code, charset, HTTP tag, one-byte separator, license and right-to-left conventions.

Updated

  • Setup > Languages > Add a language offers 132 locales instead of 43. An entry no longer repeats the country when the name already gives it: German (Austria) · Deutsch (Österreich) · ger-AT instead of ... · Austria · ger-AT; the search still finds the country (Translations::localeChoices()).
  • Setup > Languages says what the limit counts: the add form reads "%used of at most %max content languages are in use. Only the languages added here count, not the locales in the list below; removing a language no content uses frees a place.", the overview "More can be added (at most %max content languages)", and the refusal at the limit says to remove a language that no object or class uses (admin and admin4; eng-US and ger-DE). Why the limit is 62 (one bit of the 64-bit language_mask per language, bit 0 for "always available") and why it is not raised (a schema change for SQL that reads the masks) is in Languages.
  • ser-SR.ini: Thursday was stored in ISO-8859-2 inside a UTF-8 file (an invalid byte instead of Č) and misspelt Četvrak; it is Čet and Četvrtak now.

No PHP extension is needed at run time: locales are plain INI files, intl was used only to generate them. Nothing changes on an installation until a language is added under Setup > Languages.

15 October 2026: references on every removal page, imported ids of the media theme

Updated

  • The removal confirmation of the exp_adminui, ezwebin and ezdemo designs lists the objects, Layouts blocks and further references that use the items, like the admin, admin4 and standard designs (the reverse relation check, content.ini [RemoveSettings] ReverseRelationCheck); with block their Confirm/OK button stays disabled.
  • explayouts 1.4.27 brings the provider that lists Layouts blocks whose collections name a removed or hidden node.
  • sevenx_themes_media 1.5.58 ships its contentcheck.ini [ReferenceSettings] ImportedIdRemotePatterns[] and ImportedIdObjectPatterns[], so the content checks of a fresh installation resolve the ids its enhanced links and layout components keep from the media site demo and report no false broken links.
  • Required packages: explayouts ~1.4.27, exp_adminui ~1.0.0.25, ezwebin ~6.0.18 (also the password change page of 6.0.15), ezdemo ~6.0.10 (likewise), sevenx-themes-media ~1.5.58.
  • admin4 and admin4l: under forced colors the left menu's group titles (#leftmenu-title-*, the groups' summaries) and the content tree's title name CanvasText, so a contrast check measures what is painted (axe found 1.35:1 on them with a dark canvas: -webkit-text-fill-color kept the light ink).

15 October 2026: each translation once in the German catalogue

Updated

  • share/translations/ger-DE/translation.ts: 88 messages that appeared twice (same context, source and comment, left by merges) are now there once, and eng-US loses its one duplicate. Where the copies differed (46 messages, almost all in the setup wizard and its system checks) the copy kept is the one the translator already showed, the later one, with the corrected spelling and German text instead of English; the earlier copy is kept for "Extensions" in design/admin/setup/info and design/standard/setup ("Erweiterungen"), and the registration notice names 7x, as its English source does. Both catalogues still validate against schemas/translation/ts.rng.
  • The German administration's header (search, user menu, site menu, color mode, side bar buttons, phone menu) is German again: an installation whose copy of the catalogue did not validate compiled its templates with the English texts, which stayed after the catalogue was corrected until the template cache was cleared.

Added

  • Test eZTSTranslatorTest::testMaintainedTranslationHasNoDuplicateMessage: the eng-US, ger-DE and untranslated catalogues hold no message twice.

15 October 2026: no "already defined" warnings from the admin4 menus and translations

Updated

  • admin4's left menus (parts/ini_menu.tpl) no longer write "Variable 'section' is already defined" and "Variable 'groups' is already defined" into the warning log on every page. The pagelayout shares its namespace with the variables a module's view sets for the page (section/view sets section, state/groups groups), so the menu's own section, groups, group_id and group_list now carry the prefix ini_menu_; its closing undef also no longer removes the view's variables.
  • The node view's Translations tab (translations.tpl) keeps the main language's name in a variable of its own, a4_tr_main_language_name: the node view (node/view/a4_full_body.tpl) still has a4_main_language_name when it draws the tabs, and each node view wrote "Variable 'a4_main_language_name' is already defined".
  • Require ezupdate 1.1.18, whose page header takes an empty error or message without the warnings "Variable 'error' is already defined" and "Variable 'message' is already defined" on each of its pages.

15 October 2026: the media design's registration completes again

Updated

  • The registration form of the media design (user/register) goes on to the success page again, with the activation mail where [UserSettings] VerifyUserType=email: the form disabled its Register and Discard buttons inside the submit event, and a submit button disabled while that event runs is left out of the posted data, so PublishButton never arrived and the view showed the empty form again without a message. sevenx_themes_media 1.5.60 disables them on the next tick, as its shop forms already do.
  • Required packages: sevenx-themes-media ~1.5.60.

Added

  • Test FormSubmitButtonDisableTest: no template of the kernel designs or of an installed extension disables its submit buttons inside the submit event (as the submit listener itself, in a submit listener or in onsubmit).

15 October 2026: the German administration in German below the header

Updated

  • lib/ezi18n/classes/eztstranslator.php: a message with an empty translation (the placeholder of a message nobody translated yet) no longer replaces a translation read before it. Extension catalogues are read after the kernel's, and some carry copies of kernel contexts with empty placeholders: ezstarrating's copy of kernel/content turned "Top Level Nodes" at the top of the content tree back into English. A later message with a translation still wins.
  • The German and English catalogues have the messages the administration asked for and did not find: the content job pages (section assignment, durations), the template view's override messages, the RAD page's content policy limitation and cronjob part filter, the workflow event statuses, the SVG upload error, the content finder and package messages, the navigation parts AdminAid and eZFind, and the audit console's labels for the content finder, sessions, deactivated users, API keys, paused cronjob parts and removed packages. The setup wizard's editor siteaccess texts and package download errors are German too.
  • German translations that were English or unfinished: "Erweiterungen", "Sitzungen" and "PDF-Export" in the Setup menu, "Ablagen" on the package list, the user and status IDs, "Global (Überschreibung)", the RAD descriptions of datatypes and template operators, and the AJAX uploader, wishlist, OAuth admin and search messages that were marked unfinished. The active extensions cache is "Cache der aktiven Erweiterungen" (it read "Erweitungen").
  • Setup > Cache management: the caches an extension declares in site.ini ([Cache_<name>] name=) are translated in kernel/cache like the kernel's own; the REST and ezjscore packer caches have German names.
  • Setup > RAD: the labels of the survey counts are translated once, in PHP (expRADSurvey::groupCounts()); the page translated them a second time, and each one wrote a missing translation to the debug output.
  • Additional node view tabs (admininterface.ini [AdditionalTab_<tab>]) can name the context of their Title and Description with TranslationContext=; without it they are shown as written, as before.
  • The maintenance block of the dashboard reads "for more information" (it read "infomation"), in the template and in every catalogue's source.
  • Require explayouts 1.4.28, whose Layouts and Authors tabs name their translation context ("Autoren" in German), and xrowextract 2.5.14, whose preset summaries read in the language of the page.
  • Require explayouts 1.4.29: in 1.4.28 a comment in settings/admininterface.ini.append.php named translations/*/translation.ts, and its */ closed the PHP comment that wraps the file, so the settings parsers read a broken file (expIniRoundTripTest failed). 1.4.29 rewords the comment.

15 October 2026: the media design's header has a user menu, and its search fits a phone

Added

  • The header of the media design has a user button beside the search button, on phones and desktops. Its menu lists Sign in, Register, the basket, the sitemap and the tag cloud for visitors, and My profile, Edit profile, Change password, the basket, the administration (for users who may log in there), the sitemap, the tag cloud and Sign out for signed-in users, each only where the user's policies open that page. Escape closes it and returns the focus to its button, the arrow keys move between the links, and only one header menu is open at a time. Settings in menu.ini [HeaderUserMenu]; the basket entry (ShowBasket=auto) appears only with the shop/buy policy, so it stays off until a shop is installed.

Updated

  • The media design's header search is a button, and on phones and tablets its open form spans the header below the bar instead of running off the left edge of the screen.
  • Required packages: sevenx-themes-media ~1.5.61.

15 October 2026: the classic administration's Setup overview and pages back on its model

Fixed

  • The classic administration design (design/admin) draws the Setup tab's first page (setup/menu) as the Setup overview of admin4: every function of the Setup menu as a tile with its icon, name and description, in the groups of the Setup menu, from the design's own menu template (menu_layout='tiles'). It drew the setup toolbar of toolbar.ini instead, a bare list whose images the design does not have. The left menu names its first link Overview instead of the key "overview".
  • A check box with its text inside a field (.a4-field > .a4-check) is no longer drawn small and bold as the field's label, in every administration design.
  • The trash and the URL alias page of the classic design close their frames once, so the footer stays on the dark page instead of below it on white.
  • The classic design reaches 4.5:1 for text: the current left menu item and tree node, the version block of the dashboard, the disabled page links, the default button, the header row of expui's tables and the upload page's Select files button and hint (expui's colors mapped to the design's tokens).
  • The classic design's language select of the node view, the search field, the template list's filter and the digest settings of notifications have accessible names.

15 October 2026: the registration result page names the account's state and offers the next steps

Updated

  • user/success knows the state of the account the registration made: user/register keeps its id in the session (RegisteredUserID, read for an hour) and the view passes $registration_state (verify_email, pending_approval, active, signed_in, error) and $registered_user to the template. Before, the page only knew whether e-mail verification was on and said the same thing to everyone.
  • The result page of the standard, ezwebin, ezdemo and media designs says what happened and what to do, as a live region, and then offers the next steps in a navigation: Sign in, My profile, Edit profile, Register after an error, Home, the sitemap and the tag cloud; the media design also Contact and, for users who may log in there, the administration, and draws the activation page (user/activate) the same way.
  • Required packages: sevenx-themes-media ~1.5.63, ezwebin ~6.0.19, ezdemo ~6.0.11.

15 October 2026: every shipped catalogue holds each message once

Updated

  • The Chinese (chi-CN, chi-HK, chi-TW), Mexican Spanish (esl-MX) and Serbian (srp-RS) catalogues hold each message once (37, 81, 81, 85 and 68 duplicates removed). The copy the translator used is kept, so no page changes, except where the earlier copy was the better one: in chi-HK and chi-TW two setup texts lose a stray character and two keep their %1 placeholder, in esl-MX a setup hint reads correctly, and in srp-RS the upgrade warning names Exponential.
  • The duplicate-message test covers every catalogue under share/translations, and a change to a catalogue runs it.

15 October 2026: the newsletter pages log no warnings, and the layouts editor belongs to the Layouts tab

Updated

  • Required packages: cjw_newsletter ~4.2.7, whose edition list, report and A/B test pages no longer log that status or i18n is already defined; explayouts-ui-api ~1.3.16, whose layouts editor and JSON views belong to the Layouts tab instead of Setup.

15 October 2026: the interface language, and a UI switch framework for interface choices

Added

  • Interface language (#506): a signed-in user of the administration chooses the language the interface is shown in, in the right toolbar right after Bookmarks (box Interface, choice Interface language). It sets site.ini [RegionalSettings] Locale in memory for that user's requests: translations, date and number formats, the page's lang and Content-Language. Content languages are never changed. The choice is kept in the session for that siteaccess and that user and ends at logout; RememberPreference=enabled also keeps it in the user's preferences. Keyboard: arrow keys, Home, End and Escape in the list; without JavaScript the list is shown open. Interface language.
  • The UI switch framework behind it, open to extensions: expUISwitchProviderInterface, the base class expUISwitchProvider, the registry expUISwitch (called from ezpKernelWeb::requestInit() after the session start, before the locale and any template) and the first provider expUISwitchLanguageProvider. The provider API.
  • The module uiswitch: uiswitch/set (POST only, form token, refuses Sec-Fetch-Site: cross-site, strict allow-list, redirects only to a local path) and uiswitch/state (JSON); one policy function per provider, built from ProviderList[], so uiswitch/language and any extension's uiswitch/<identifier>. Listed in settings/module.ini.
  • fetch( 'uiswitch', 'toolbar' ), the toolbar tool admin_ui_switch (design/admin4/templates/toolbar/full/admin_ui_switch.tpl, uiswitch/choices.tpl), the page of a refused change (design/standard/templates/uiswitch/refused.tpl).
  • settings/uiswitch.ini: [UISwitchSettings] Enabled, SiteAccessList[] (empty: every administration siteaccess), ProviderList[]; [UISwitch_language] Class, Enabled, AllowedValues[] (eng-GB, eng-US, ger-DE; a locale is offered only with its locale file and, except English, its translation), RememberPreference. UI switch settings.
  • AdminUI: the side bar plugin uiswitch, the dropdown Interface after the bookmarks (exp_adminui.ini [MenuPlugins] Enabled[]=uiswitch).
  • admin4l: the block Admin: interface choices (language) (admin_ui_switch) for admin layouts, and the block in the right zone after Bookmarks in the admin layouts fragment of explayouts.
  • The audit event access.uiswitch.change (channel access, severity info), with the value and siteaccess before and after.
  • Translations of the new texts in eng-US and ger-DE (contexts kernel/uiswitch, design/admin/toolbar/uiswitch, design/standard/uiswitch).
  • Tests: tests/tests/kernel/classes/uiswitch/expUISwitchTest.php (18 tests, DB-less).

Updated

  • The cache variant key of a choice ($GLOBALS['expUISwitchVariantKey'], for example language-ger-DE) keeps caches apart: {cache-block} keys, the content view cache hash (key uiswitch) and compiled template names; the role-aware HTTP page cache does not store a page rendered with a choice (ui switch). Without a choice the key is empty and no cache name changes.
  • New installations: the editor siteaccess's right toolbar is Bookmarks, the interface choices, the current user and the preferences; the admin and adminui toolbars list admin_ui_switch after Bookmarks; the Editor role gets uiswitch/language in the clean data (policy 332) and in the multi-site installers.

Upgrading

  • The choice appears on an existing installation only after AvailableToolArray[]=admin_ui_switch and Tool[]=admin_ui_switch (after Tool[]=admin_bookmarks in [Toolbar_admin_right]) are added to the admin siteaccesses' toolbar.ini.append.php, or the block to the admin layout's right zone on admin4l, followed by a clear of the INI and template caches. Roles other than Administrator need uiswitch/language. The extension bcswitchadminlanguage is replaced and must not be installed alongside. See UI switch settings.

Fixed

  • About and Copyright (every administration design), the trash and the URL aliases (admin4 and admin4l) and the package uninstall error page closed one frame too many or too few, so the rest of the page and the footer fell outside the page's frame, onto white. A test now checks that every template of the administration designs closes the frames it opens.
  • The muted text of About in the classic design reaches 4.5:1.

15 October 2026: the job queue's admin pages on the shared components

Updated

  • Require expqueue ~0.1.1: Setup > Job queue no longer carries a <style> block or style="" attributes. Its pages are drawn with the administration's shared components and --a4-* tokens, so they follow admin4's dark mode; what the components cannot do is in the extension's expqueue.css, loaded through design.ini [StylesheetSettings] BackendCSSFileList. Bar heights and progress are .a4-pct-<n> classes. A DB-less test in the extension (tests/unit/StylingRulesTest.php) keeps inline CSS and colour literals out.

Added

  • The administrator's "New user registered" mail (user/registrationfeedback.tpl) is an HTML mail with a plain-text part (multipart/alternative, with every mail transport). It lists the account's name, login, e-mail address, registration time and state (waiting for the e-mail confirmation, for an administrator or for an approval item, pending, active), and carries signed links into the administration: View account, Activate account (Approve when an approval item holds it), Reject and remove, the approval item and the list of unactivated users. A link is an HMAC-SHA-256 token under a key derived from the site secret, names one account and one action, expires after [UserSettings] RegistrationActionTTL (7 days); approve and reject links work once (expRegistrationAdminAction). Settings: RegistrationAdminLinks, RegistrationAdminBaseURL, RegistrationAdminSiteAccess, RegistrationActionTTL. Guide: Signed links in the "New user registered" mail.
  • The page user/registration/<token> in the administration: needs a signed-in user with user/activation (or the approver of the waiting approval item; a view link also with content/edit on the account), shows the account and its state, and approves or rejects only after a confirmation posted with the form token; opening a link changes nothing. Approve activates the account as Users > Unactivated users does, or accepts the approval item and publishes the account at once; reject denies the approval item and removes the account; an active account is never changed. An invalid, changed, used or wrong link is refused with 403, an expired one with 410. Audited as access.registration.approve, access.registration.reject and access.registration.refused.
  • One layout for HTML mails, design:mail/layout.tpl, with its plain-text twin design:mail/layout_text.tpl: content templates pass text and links, the layout holds the styles. A design overrides it for its own colors and logo; the media design does.

Updated

  • The registration mail no longer links to http://<host>/content/view/full/<main node>: the account is not published when the mail goes out, so the node did not exist yet, and the link used http.

  • The mails a new user gets are HTML mails with a plain-text part through the same layout: the activation mail (user/registrationinfo.tpl, with an Activate account button), the activation mail sent again from Users > Unactivated users (user/unactivated_resend_mail.tpl) and the "registration approved" mail (user/registrationapproved.tpl, with a Sign in button). Their links use https; the activation mail's http://<host>/content/view/full/ link without a node is gone. The senders set the body through expMailTemplateBody::apply(), which makes the multipart mail when a template sets content_type text/html and body_text.

  • Required packages: sevenx-themes-media ~1.5.65 (the mail layout of the media design, with the site logo).

15 October 2026: the public media site fits phones, tablets and a 200% zoom

Added

  • The media design draws the advanced search page itself: the form in the search page's markup, labels tied to their fields, 44px controls and 16px text on phones, results in the search view. It used ezwebin's unstyled form, whose inputs scrolled every phone sideways.

Updated

  • A crawl of every public page group at 320, 375, 414, 768, 960 and 1440px in Chromium and WebKit, on both servers, found squeezed pictures, text on the screen edges of the generic full view, a narrow search input, slider arrows, links and buttons under the touch sizes, a card title covering its author link, low contrast in the search results, the basket steps and the 404 code, an empty picture link in the zigzag features block and an empty title link in the file view. All are fixed in the theme; the same crawl finds none of them now.
  • The style blocks and style attributes of the media design's page templates moved to a stylesheet that packs with the theme; the sitemap and tag cloud links are 44px rows on phones; empty consent labels fall back to the checkbox's name.
  • Required packages: sevenx-themes-media ~1.5.66.

15 October 2026: the country lists start with the most used countries, and autofill picks the right one

Added

  • Grouped country list (#517): every country select shows Most used countries first, ranked (the United States, Canada and Mexico on top, then South America, Europe, the United Kingdom, Asia, New Zealand, Australia, Africa), then All countries A-Z by the translated name in the reader's collation (intl's Collator, or accents and case folded without it), as native option groups. Guide: The grouped country list.
  • settings/countrygroups.ini [CountrySettings]: GroupList[], GroupName_<group>, GroupCountries_<group>[] (ISO alpha-2 codes, ranked), RepeatInAllCountries. 37 countries ship in the group common.
  • fetch( 'content', 'country_groups' ) (class expCountryGroups) and the shared template design:country/options.tpl, which every country select includes; group labels in eng-US and ger-DE (context kernel/country).

Updated

  • Autofill with a United States address picks the United States, not the United States Minor Outlying Islands: the United States is the first option, the selects carry autocomplete="country" (the country attribute, which posts codes) or autocomplete="country-name" (the shop, which posts names), and names are no longer shortened to 20 characters. Checked in Chromium and WebKit.
  • The country attribute's edit, information collection and class edit templates and the shop's shop/country/edit.tpl (account form of every design, VAT rules, user country toolbar) use the shared options. A single select repeats the ranked countries in the A-Z group and selects a stored value once; a multiple select lists each country once. The shop's select starts with an empty Choose a country option when no country is known, and takes an optional select_class and select_autocomplete.
  • Stored values are unchanged (codes for the attribute, names in the orders), so no migration is needed.
  • Required packages: sevenx-themes-media ~1.5.67 (the account form's grouped country list and autocomplete fields), exp_adminui ~1.0.0.28 (the country attribute's selects in AdminUI).

16 October 2026: the job queue pages are dense again and fit a narrow column

Updated

  • Require expqueue ~0.1.2: the cards, facts and tables of Setup > Job queue are as dense as in 0.1.0 again (spacing tokens only), and each table hides its secondary columns when its own box is narrower than 760px, so the classic admin at 200% zoom shows the Priority classes and job tables without sideways scrolling.

15 October 2026: every language in the interface language list

Added

  • The interface language list offers every language (#516): the Interface language choice lists the 33 most needed locales in three groups (English, Major world languages, Regional variants, ranked by speakers and share of web content), then every locale of share/locale A–Z (about 130). Each entry gives the language's own name, its name in the current interface language and how complete the kernel's translation is ("Deutsch (Deutschland) — German (Germany) (99%)"); a language without a translation is offered with English texts and its own formats, marked "no translation yet". The A–Z list follows the collation of the current interface language (Intl), with a fallback without Intl. See The interface language list.
  • A list box with headings for long choices (design/standard/templates/uiswitch/grouped.tpl): a native select with type-ahead and an Apply button, the same in admin4, admin4l, AdminUI and the classic admin design.
  • expUISwitchLanguageCatalogue: the names, HTTP codes and translation coverage of every locale, cached in memory and under the cache directory (uiswitch/), built again by itself when a locale or translation file changes.
  • uiswitch.ini [UISwitch_language]: GroupList[], GroupLabel[], GroupValues_<group>[], AllLanguages, ListUntranslated. See UI switch settings.

Updated

  • The allow-list of the language choice is the siteaccess's Locale, the groups, AllowedValues[] and, with AllLanguages=enabled, every locale file; AllowedValues[] is empty by default. Without the new settings the choice behaves as before (only translated languages, a list of buttons).
  • uiswitch/state and fetch( 'uiswitch', 'toolbar' ) return the groups of a provider that has them.

15 October 2026: sorting and readable filter counts on the extensions page

Updated

  • Setup > Extensions sorts by name both ways (#519): the Sort choice offers Loading order, Name (A to Z) and Name (Z to A), with the arrow keys like the other chip groups. The sort and the filter are kept in the address (/setup/extensions/(sort)/name/(dir)/desc/(show)/active), so a reload or a bookmark shows the same list. AdminUI's Order and Name headings now sort descending too: the view used to ignore (dir).
  • The filter choices read All (87), Active (62), Inactive (25) and Problems (0): the count was a separate element in the chip, whose space was lost, so the chips showed and copied as "All87". German: Alle (87) and so on. Same in admin4, admin4l and the classic admin design. See The extensions page.

15 October 2026: accessible names in the classic admin page frame

Updated

  • The classic admin design names its search field, siteaccess list and user link (#522): the search field has a visually hidden label (.a4-sr-only) besides its title, the Siteaccess: label of the quick settings is bound to its list, the search scope's close link has a name, and the icon beside the current user's name is no longer a second link without text. axe reports no serious finding in the page frame on 10 classic pages at 1440 px and at 960 px with 200 % zoom, on both web servers. The same label fix is in the standard design's quick settings, and admin3's disabled search field gets the name its enabled one has.

15 October 2026: the test language Lorem Ipsum

Added

  • A test language for content translations (#523): share/locale/qlo-ZZ.ini, Lorem Ipsum, a code from the ISO 639-2 range reserved for local use, English formats and lorem-style month and day names, marked [LanguageSettings] TestLanguage=true. It is shipped but added nowhere: Setup > Languages > Add a language offers it like any locale, for a test, to be removed again afterwards. The interface language choice leaves test languages out of its ranked groups and its A-Z list unless AllowedValues[] names them.
  • exp:loremipsum (bin/php/exploremipsumfill.php) fills the translation of objects or a subtree in a test language with lorem ipsum text per datatype (text line, text block, rich text, keywords), with --dry-run. It refuses a real language and never adds one. See The test language Lorem Ipsum and Languages, section 12.

15 October 2026: the package wizard is a step wizard

Updated

  • The package creation wizard is drawn again (#520): Setup > Packages > Create new package and every step of the content class, content object, extension and site style wizards, in admin4, admin4l and the classic administration. Each step shows the wizard's steps (the current one marked with aria-current="step"), "Step n of m", what it asks for, its fields in cards with labels and help, and one primary action (Continue, on the last step Create package) beside Back and Cancel, in a bar that stays in view. An error of the last Continue is listed at the top with a link to its field and shown under the field (aria-invalid, aria-describedby). The last step reviews the whole package (name, version, format, license, maintainer and what it carries) before it is written. The classes and extensions are tables of checkboxes with a filter and a count, the chosen nodes a table with their class and URL, the siteaccesses and languages of the content export chips; extensions that may not be exported are folded away with the reason once. The posted names, what the creators read and the packages they write are unchanged (the same packages, byte for byte apart from their dates). See The package wizard.
  • Remove selected on the content step with no row ticked removes nothing; it stopped with an error before.

16 October 2026: right-to-left languages, Hebrew, Latin, Klingon and 81 more locales

Added

  • Text direction of locales: a locale file can say [LanguageSettings] TextDirection=rtl; eZLocale::textDirection(), isRightToLeft() and textDirectionOf() read it (template attributes text_direction, is_right_to_left), and $site.text_direction carries the direction of the interface language. See Right-to-left languages.
  • Right-to-left administration: with a right-to-left interface language (UI switch) the pagelayouts of admin4, admin4l, the classic admin design and AdminUI print dir="rtl", and the layout skeleton (header, side columns, toolbar, tables, forms) is mirrored through logical CSS properties; left-to-right pages are unchanged.
  • Right-to-left content: text in a right-to-left content language gets its language tag and dir="rtl" in the edit forms, the admin full view and the rich text editors (TinyMCE 3 and 8, Tiptap); the Add language list marks right-to-left languages.
  • 81 more locales (219 in all): every right-to-left language with CLDR data (28, among them Yiddish, Central Kurdish, Syriac, Dari, Kashmiri, N'Ko and Dhivehi, and nine more Arabic countries), 62 world languages and regional variants from CLDR 44, Latin (lat-VA) and Klingon (tlh-QO, private-use region QO). See Languages.
  • Interface translations (first drafts, to be reviewed by native speakers): Hebrew, Latin and Klingon. Hebrew is in the Major world languages group of the interface language list.
  • Complete administration translations for Spanish (Spain and Mexico), Hebrew, Latin and Klingon, machine-assisted, with a quality test for placeholders, markup and whitespace.
  • Font fallbacks for CJK, Thai, Devanagari, Arabic and Hebrew script after the Latin families of the admin font stack, and line-breaking rules for Japanese, Korean and Thai.

Updated

  • Hebrew (heb-IL), Hindi (hin-IN) and Korean (kor-KR) are generated from CLDR again (double-encoded month names, English names, an invalid Korean date pattern); Chinese, Greek, Georgian and Ukrainian get small fixes.
  • eZLocale::localeList() no longer returns whatever the first call of a request asked for, and sees a locale file added while a worker runs; the interface language list does the same (no restart needed for a new language).
  • The HTTP cache figures of Setup > System information are translatable.
  • Right to left inside the pages: the module stylesheets of admin4 and the classic design use logical properties, the data table pagers' arrows turn with the direction, and AdminUI 1.0.0.32 mirrors its pages too: its own stylesheets use logical properties, a right-to-left copy of its reference stylesheet (style-rtl.css, built from style.css) is loaded in place of it, and the icons that point along the line and the content tree column's resize handle follow the direction. Left-to-right pages are unchanged.
  • Names keep their own direction: node names, the path, page headings, the user name, author lines, relation lists, dashboard blocks and the content tree isolate their text (<bdi>, dir="auto", and Unicode isolates in the page title of a right-to-left page), so an English name in a Hebrew page, or a Hebrew name in an English one, keeps its punctuation in place. Left-to-right content shown in a right-to-left interface carries dir="ltr" and its lang (parts/exp_content_direction.tpl, exp_dir_ltr). AdminUI 1.0.0.33 and 1.0.0.35.
  • Accessible names in the edit form and AdminUI: the classic design's and AdminUI's attribute labels point at their fields, the online editor's frame has a title (TinyMCE 3 and 8), the relation search field, the tags field (eztags 2.4.13), the image upload field (ezie 6.0.11), the preview toggle (expui 1.0.0.7) and AdminUI's sort, section and class list controls have names; every page checked reaches 0 serious or critical accessibility findings, light and dark. AdminUI 1.0.0.34, expui 1.0.0.8.
  • The accent text color (--a4-accent-text, light) is darker, so an accent badge reaches 4.5:1 on a hovered or striped table row.
  • The package wizard's creator choices wrap inside their cards at narrow widths, with their descriptions in normal weight; the node view's action buttons wrap instead of running past the card with long words (Latin, German).

15 October 2026: the classic menus in the Design menu, Site and URLs reordered

Updated

  • Menus (classic) (visual/menuconfig) left the Setup menu and the Setup overview (#528): it is a link of the Design tab's left menu now, after Toolbar management, in admin4, admin4l, the classic administration and AdminUI, and its page marks the Design tab and draws the Design menu. Its address, its access (visual/*), its icon and its description are unchanged; with the Design tab left out it is a tile of the Design group of the overview.
  • Site and URLs in the Setup menu and the Setup overview opens with Preload Sites, RSS and Packages and ends with RAD; the other links keep their order (menu.ini [Leftmenu_setup] GroupLinks_site[] and Links[]).

16 October 2026: the upgrade check names the tables of extensions that are not active

Updated

  • The database part of the upgrade check (Setup > Upgrade check, expSchemaConsistencyReport) also reads the schema files of the extensions that are installed and not active (share/db_schema.dba, and the CREATE TABLE statements of sql/<engine>/*.sql). A table in the database that only such an extension names, for example ezfind_elevate_configuration of ezfind, is no longer a "Table not in the schema" with a DROP statement: it is listed as "Table of an extension that is not active", "belongs to ezfind (not active)", with the advice to activate the extension or leave the table, since dropping it removes its data. No SQL is written for it, on its card, in All statements or in the SQL download, and it does not fail the check. A table that no schema names keeps its DROP statement as before. Guide: upgrade check.
  • Tests: the upgrade check reads the tables of extensions that are not active and writes no DROP for them; a fresh installation creates ezuser.login UNIQUE on SQLite (from share/db_schema.dba, read back and refusing a second account with the same login), MySQL and PostgreSQL. A database installed before the index became unique (12 October 2026) still reports it, with the two statements that change it; ./console exp:user-login-check --fix-index runs them once no two accounts share a login.

16 October 2026: admin4 review, account actions, the site menu, the badge and Broken relations

Fixed

  • Check user accounts (user/hygiene) carries out a bulk Disable, Enable or Unlock only from its own confirmation page: Confirm needs the token of that page, kept in the session (HygieneToken), as Deactivate and move does, so the actions no longer rest on ezformtoken alone.
  • Check user accounts and Deactivate and move count an account as disabled or enabled only when the user/setsettings operation continued. A workflow that cancelled or halted it left the account enabled, while the result said it was disabled and Deactivate and move moved it.
  • An administration without RelatedSiteAccessList[] lists only itself beside the site in the site menu (site.ini [AdminSiteAccessMenu] List=related), not every siteaccess of AvailableSiteAccessList[].
  • The environment badge takes BadgeDisplay, ShowInTitle and ShowVersion in any case and with surrounding spaces, as it already took Level; BadgeDisplay=Always or ShowInTitle=Disabled were ignored.
  • The links of Broken relations without a filter no longer end in a bare question mark. Tests: expUserHygieneTest, expUserDeactivationTest, AdminSiteAccessMenuTest, AdminEnvironmentBadgeTest, expContentCheckQueryTest.

Upgrade notes

  • An installation that relied on the site menu listing every siteaccess without RelatedSiteAccessList[] sets RelatedSiteAccessList[] for its administration, or [AdminSiteAccessMenu] List=available.

16 October 2026: admin4 review, template warnings and small template fixes

Fixed

  • The admin4 left menu (parts/ini_menu.tpl) and the relations window (relations.tpl) no longer log "Variable 'check'/'offset' is already defined": they are $access_check and $relation_offset.
  • admininterface.ini [AdditionalMenuSettings] ImportExportMenuTemplateArray[] is declared, so the Import/Export entry of the content menu no longer logs a missing variable where no extension adds an item.
  • Cancel on a browse page opened with the parameters of an old edit session no longer leads to a "not available" error: content/browse drops BrowseParameters whose return page is the edit page of a missing object or version (new eZContentBrowse::editReturnTarget()).
  • The admin4 dashboard says "Nothing was published in the last 14 days." instead of showing an empty chart box.
  • The toolbar editor draws a preview image only for tools that have one (no broken images for the admin_* tools).
  • Seven admin4 templates close their {default} block; the class view of admin and admin4 shows the background process monitor link only where the scriptmonitor module exists; the relation list editor passes fetch( 'content', 'object' ) only parameters it knows.

Updated

  • Seven German strings of the admin and ezoe's file table headings (Name, Size, Type) are no longer marked unfinished. Tests: AdminReviewTemplateWarningsTest, eZContentBrowseEditReturnTargetTest.

16 October 2026: admin4 review, the search page

Fixed

  • A new search from the admin4 search page keeps the section (a choice on the page now), the locations and the page size (SearchPageLimit, 1 to 5 as in the advanced search, at most site.ini [SearchSettings] MaximumSearchLimit); the pages of the advanced search keep their locations.
  • An empty admin search shows "Type the words to search for." without running the search, also with an engine that allows an empty search.
  • The header's section can be chosen on pages without a location; only an edit page has no search.
  • The search forms are named landmarks ("Search", "Advanced search", "Quick search"), the header's scope button announces a dialog, each result checkbox names its item, the location choice is a labelled group, and the class attribute label of the advanced search points at a field only when there is one. Tests: AdminSearchTest.

16 October 2026: admin4 review, the stylesheets

Fixed

  • The checkbox and radio rows of the settings, class, RSS, history and other admin pages no longer inherit the sign-in page's extra top margin and larger text (.a4-check is scoped to the sign-in page).
  • The grey edge on neutral badges is limited to the node view's title row; badges elsewhere in the admin are one flat tint.
  • The primary button of expui dialogs, the upload button and a picked date use an orange that carries white text (5.2:1, was 3.1:1, and 2.3:1 in dark mode); focus rings of these widgets and of the language badge are the 3 px admin ring (the badge's was 2.8:1 on a current badge in dark mode).
  • The node view's action row (language choice, Edit, Move, Remove, icon buttons) wraps on tablet widths instead of being cut off at the card's edge.
  • The tree's open/close arrows and "Actions for ..." buttons, and the sub-items row menu button, have a 24 x 24 px click target without changing how they look, left to right and right to left.
  • The sub-items row menu button (three dots) and the image icon stay visible in forced colours.
  • Long pop-up menus (user menu, mega menu, context menu) fit the visible screen on iOS and Android (100dvh after 100vh).
  • The password rules' check mark is readable in dark mode and in high contrast.
  • The in-place confirmation's summary (.a4-confirm > summary) has one rule of its own in components.css (both copies).

Updated

  • The IcoMoon font of the pager loads as woff only with font-display: swap, and its glyphs are no longer read out; small helper text keeps at least 12 px.
  • Removed the dead legacy modal-window rule that could fix and shift any ancestor of such a window, and a duplicated block of the sub-items table stylesheet. Tests: AdminCssReviewFixesTest, AdminResponsiveTest, AdminTokenContrastTest.

16 October 2026: a Content Security Policy, report-only on the administration

Added

  • site.ini [ContentSecurityPolicy]: Mode (disabled, report-only, enforce, or auto: report-only on administration siteaccesses, whose SiteDesign begins with admin, disabled elsewhere; the default), the policy in Directives[] (shipped: default-src, script-src and style-src 'self', object-src 'none', base-uri, form-action and frame-ancestors 'self', images and media with data:/blob:) and ReportURI. A report-only policy is the header Content-Security-Policy-Report-Only; an enforced one is sent as a second policy of Content-Security-Policy after SecurityHeaders[Content-Security-Policy]; frame-ancestors and sandbox only when enforced (browsers ignore them in report-only). The policy is sent also when SecurityHeaders[] is not set. Values that would split the header are ignored. object-src 'none' blocks <object>/<embed> (the legacy plugin previews, inline PDF) once enforced. See Content Security Policy.

16 October 2026: the administration runs without inline scripts, handlers and styles

Updated

  • admin4 and admin4l (and the admin3, admin and standard templates they render) carry no inline <script>, no on...= attribute, no javascript: link, no style="" attribute and no <style> element any more, so they run under the shipped Content Security Policy (script-src 'self', style-src 'self'). Page scripts are files under design/admin4/javascript/page/, loaded with ezscript where the inline script stood, and take their values from data-* attributes; the head script is a4_head.js. See Content Security Policy, "The administration without inline code".
  • Context menus are declared in data-menu-* attributes (menu, items, openers) instead of menuArray scripts and onmouseover/onclick attributes; menus and items of extensions written the old way keep working, also when they add items to a core menu (under report-only; once enforced their inline scripts and handlers do not run - convert them before enforcing). A script that assigns menuArray anew replaces the declared core menus: add to it instead. A menu item links and a redirect entry (data-menu-redirect, ezpopup_SubstituteAndRedirect()) goes only to an address of the site (http or https, the page's origin); a javascript:, data: or foreign address is refused with an error in the console, and the values put into a redirect address are URL-encoded.
  • The content tree is drawn from data-tree-* attributes (JSON) instead of an inline script with document.writeln(); its open/close buttons have no onclick/onkeydown attributes.
  • ezjscore: generated script and style content (server functions with packing off) is a file in the packer's cache directory instead of an inline <script>/<style> (ezjscPacker::generatedContentURL()).
  • The sub-items status badges use the admin's a4-badge (token colours, dark mode) instead of fixed inline colours.
  • ezoe, TinyMCE 8: the edit template writes the editor settings as JSON data blocks (<script type="application/json" id="ezoe8-settings">, id="ezoe8-button-map") and each textarea's toolbar in data-ezoe8-* attributes; javascript/tinymce8_ez/ezoe8_start.js starts the editors, so a rich text edit page with TinyMCE 8 carries no inline script. eZOe8Init(), eZOe8Toolbar() and eZOe8StickyOffset() stay global; new eZOe8Start() starts editors added later. TinyMCE 3 and its dialogs are unchanged and still inline (the guide says what is left). The data blocks are printed with < and > as \u003c and \u003e: json_encode leaves <!-- as it is, and <!--<script> in a setting would have made the browser read past the block and skip the scripts after it, so the editor did not start. Without tinymce.min.js the console says why the fields stay plain text.
  • The test of the administration's templates (AdminContentSecurityPolicyTest) follows the chain the kernel builds: admin2 is part of it, and the design directories of the active extensions come before the core directory of each design. What extensions still have inline is listed per extension (EXTENSION_ALLOWLIST) and only shrinks.

Added

  • design/admin4/javascript/a4_actions.js: confirmations, select all, dismiss, submit or go on change and focus, declared with data-a4-* attributes.

Fixed

  • Package > Compare and import: All/None and the class cascade work (their inline script had a syntax error and never ran).

16 October 2026: oauth/authorize redirects only to a client's end point, the publishing status needs read access

Fixed

  • oauth/authorize sent an error for a missing client ID, an unknown client or a mismatching redirect_uri to the redirect_uri of the request, so any address could be reached through the view (an open redirect). Until the redirect_uri is the end point of a registered client the view now answers 400 Bad Request without a Location (RFC 6749, section 4.1.2.1); a redirect_uri or client_id given as an array is refused the same way. Errors after that check (a missing response_type, access_denied) still go to the client's end point.
  • The ezjscore server function ezpublishingqueue::status told any caller, also an anonymous one, whether a version of an object exists and its publishing status, and the address of the node once it was published. It answers now a user who can read the object, or the registered user who created the version and waits for it; the address of the node only a user who can read the object. For anyone else the answer is the one of a version that does not exist ("Object version not found"), and arguments that are not positive integers get the same answer.
  • The asynchronous publishing page (content/queued) hides its link "View the published item" when the status comes without the node's address, instead of leaving it pointing at #.

Fixed

  • ezurl and ezroot gave a link with a scheme (https:, mailto:, javascript:, data:) or a protocol-relative //host one back unescaped, also when they put it in quotes for an HTML attribute; a stored link with a " closed the attribute (<a href={$url|ezurl}>). In quotes (no quote parameter, 'double', 'single') such a link is now escaped as the links the operators build already are (an &amp; already in it stays as it is). Relative links and quote 'no' are unchanged; eZURI::transformURI() itself is unchanged. Compiled templates take the change after the template cache is cleared. In a <script> block, JSON or a CSS url() such a link in quotes now carries &amp; for &, as relative links already did; there use quote 'no' with |wash( 'javascript' ) or |json_encode.
  • The filter redirects of package/list, package/compare and package/view send their address without HTML escaping, so an & in it no longer reaches the Location header as &amp;.

16 October 2026: online editor hardening

Updated

  • The custom tag dialog (ezoe/tags/<id>/<version>/custom/<name>) opens only for a name of content.ini [CustomTagSettings] AvailableCustomTags[] (or no name, for a new tag); any other name gets the "Invalid parameter" message. tag_custom.tpl escapes the name in its script string and its class attribute as well: a link with a crafted name ran script on the administration for the editor who opened it. The "Invalid parameter" messages of the view escape the value they repeat.
  • ezoe/embed_view shows an embed whose EmbedID has no _, whose node or object no longer exists, as the unknown embed; it ended in a fatal error (HTTP 500) before, which broke the editor on an old draft. The EmbedID is read with Relations::parseEmbedId() like ezoe/relations and ezoe/load, the view parameter must be the tag's own view (embed, or embed-inline for an inline embed) or one of content.ini AvailableViewModes[] of that tag's sections only ([embed] and [embed_<class>], or [embed-inline] and [embed-inline_<class>]), otherwise the tag's own view is used, and class, size, align and view are read as strings.
  • The upload of the online editor refuses executable types for every engine: a file name with php, phtml, phar, sh, cgi, .htaccess and the like anywhere in it is refused in every ezoe.ini [EditorSettings] UploadExtensionCheck mode, disabled included. Every dot-separated part of the name is checked, not only the last one, so report.pl.pdf or shell.php.jpg is refused too; rename such a file before uploading. TinyMCE 3 users skipped the check in the default mode engine, so x.php was accepted, which is dangerous on a web server without the .htaccess rules (nginx). UploadExtensionCheck still decides about the list UploadFileExtensions[].

Removed

  • The After the Deadline spell checker of the TinyMCE 3 editor: the view ezoe/atd_rpc, which sent the editor's text over plain HTTP to service.afterthedeadline.com (a service that no longer answers) and appended the debug report to the answer, the plugin javascript/plugins/AtD, the commented Plugins[]=AtD and Buttons[]=AtD and the [AtD] block of ezoe.ini, and the atd_ options of the editor template. A siteaccess that still lists Plugins[]=AtD or Buttons[]=AtD in an override should drop them; TinyMCE 3 skips a plugin it cannot find. The spell checker of TinyMCE (ezoe/spellcheck_rpc) stays.

Added

  • ezoe.ini [EditorSettings] ForceEngine=<engine> (per siteaccess, not set by default) gives every editor of a siteaccess one engine of the online editor, whatever their own choice on ezoe/engine says; the engine page then shows a note instead of its form. An engine that cannot be used (its files or its extension missing) is ignored, so editors keep the usual choice.

Fixed

  • eZURL::registerURL() and eZURL::urlByURL() match an address byte for byte. The url column compares with its collation (MySQL utf8mb4_general_ci: case and accents ignored), and registerURL() rewrote the row it found to the new spelling, so saving a text that links .../Present_perfect re-pointed every link stored as .../Present_Perfect (another page) to it. A different spelling gets a row of its own now (eZURL::fetchExactByUrl(), eZURL::pickExact()), and urlByURL() finds it, where it returned false for two collation-equal rows and the rich text datatype stored another row on every save.
  • Online editor: a custom tag whose text is 0 keeps it (it became a space), and line breaks inside a custom tag (a LaTeX formula) become a space instead of gluing the lines together.
  • eZContentObject::versionLanguageName() with version 0 or a negative version returns false without a PHP warning and without a database query.
  • oauth/authorize refuses an empty redirect_uri also for a client stored without an end point (400 Bad Request).
  • The online editor's upload refuses an executable type followed by white space, NUL or a dot (x.php , x.php.) and also phtm, php6 and shtml; the engine page escapes the engine names it shows.

Renamed

  • ezpContentSecurityPolicy (new in this release) is expContentSecurityPolicy (kernel/private/classes/expcontentsecuritypolicy.php). The security headers are sent without the policy while a long-running process does not know the class yet.

Updated

  • AdminContentSecurityPolicyTest checks the core designs and the extensions this repository ships (expservices, ezformtoken, ezjscore, ezoe). Extensions installed from their own repositories are checked with CSP_ALL_EXTENSIONS=1, an audit of an installation before the policy is enforced (see Content Security Policy, "Before enforcing").

16 October 2026: the SQLite driver counts its statements without SQL output

Fixed

  • The SQLite driver counts every statement in the accumulator sqlite3_query, as the MySQL and PostgreSQL drivers do, so the debug output and the debug bar's summary give the number of queries and their time also with site.ini [DatabaseSettings] SQLOutput=disabled; they said "0 SQL / 0 ms" while the SQL query cache counted its misses. With SQLOutput on, a statement faster than SlowSQLTimeout left the accumulator running and was not counted either. Which statements are listed is unchanged. Test: eZSQLite3DBQueryCountTest.

16 October 2026: the debug bar's Velocity tab is called Server

Updated

  • The Exp Debug bar's tab that says where the request entered and which process rendered the page is called Server instead of Velocity: it shows the same for PHP-FPM, mod_php or the command line, where a tab named Velocity read as if Velocity were running. Velocity, when it renders the page, is named in the tab itself. The tab's ids (data-tab="velocity", exp-debug-panel-velocity) are unchanged. German and American English. See the debug bar.

16 October 2026: panels of extensions in the debug bar

Added

  • An extension can add a tab to the Exp Debug bar, after Memory, with a chip in the summary and data in the page's JSON: debugbar.ini [DebugBarSettings] Panels[]=<id> and [Panel_<id>] Class=<class>, the class implementing the new expDebugBarPanelInterface (visible(), title(), chip(), html(), data()). The panel decides who may see it; an entry without a usable class and a panel that fails are left out with a warning. expDebugBarRegistry::panels(), expDebugBarReport::extensionPanels(). In the extension point catalogue (Setup > RAD). See Panels of extensions. Test: expDebugBarPanelsTest.

16 October 2026: OPcache, APCu and the versions in the debug bar's summary

Added

  • The summary of the Exp Debug bar shows the memory of OPcache and APCu in use while they are on for the process (OPc 66 %, APCu 25 %, the details in the title), and after them the versions of PHP and Exponential (PHP 8.4.19 | Exp 6.0.15), all three at the right end; each opens the Server tab, which lists APCu below OPcache. Only for users with setup/setup or setup/managecache, since the bar also shows to anonymous visitors by IP. New thresholds debugbar.ini [DebugBarSettings] Thresholds[opcache_memory]=80;95 and Thresholds[apcu_memory]=75;90; the server summary (expDebugBarSummary) carries opcache and apcu with their level. German and American English. See the debug bar. Test: expDebugBarReportRuntimeChipsTest.

16 October 2026: the debug bar names the PHP version only to administrators

Fixed

  • The Exp Debug bar gave the version of PHP to everybody who sees it, which includes anonymous visitors when site.ini [DebugSettings] DebugByIP matches: in its server summary (expDebugBarSummary::collect(), the JSON of every page with the report) and in the PHP row of the Server tab. The summary has null now unless the user may use setup/setup, the rule for its other lists; the Server tab shows the version to the users who see its privileged rows (setup/setup or setup/managecache) and only the server API to the others. Tests: expDebugBarSummaryPhpVersionTest, expDebugBarReportEngineTest.

16 October 2026: the debug bar's Rendering tab

Added

  • Rendering tab of the Exp Debug bar, for users with setup/setup: the module views that ran with their view class and script, the siteaccess, the navigation part and the design order; what the content view cache did (read from the cache with the file's age and size, generated, generated without storing, or not used, each with its reason) and its key part by part, with the keys of content/view/cachekeys listeners marked; the template settings and the templates compiled in this request. Two chips, View: hit/new/off and Compiled N, open it. See the Exp Debug bar.
  • eZNodeviewfunctions::generateViewCacheFile() also returns cache_hash, cache_keys and cache_keys_from_listeners; the three keys it returned before are unchanged.
  • Cache blocks in the Rendering tab: every {cache-block} of the page with its template and line, hit or generated, the time to look it up and to generate it, its size, expiry and keys, and the chip CB hits/count. The compiled code of a cache-block passes its placement to eZTemplateCacheBlock::handle() and stores through the new eZTemplateCacheBlock::store(); clear the template cache once so compiled templates name their blocks.
  • Template tree in the Rendering tab: every template the page rendered, nested under the template that included it, with the file the overrides chose and the time it took (at most 2000, every template counted). debugbar.ini [DebugBarSettings] RenderTree=disabled leaves it out.

16 October 2026: admin4 review, admin4l

Fixed

  • admin4l keys its cache blocks by the admin generation, so an edit of an admin layout or rule shows at once: the page read the key with .result.0, which the list the fetch operator returns does not have (a template error on every page, and a layout id without the generation); the key list is now imploded.
  • The GET parameter SectionID in admin4l's page cache keys is a number, so odd values no longer create cache files.
  • admin4l's tab menu block (admin_tab_menu) draws only the list items, so the page has one menu panel instead of two nested ones.

Added

  • Block templates admin_toolbar_right and admin_toolbar_developer: the toolbars admin_right and admin_developer of toolbar.ini, as admin4 draws them. Their block definitions belong to explayouts.ini of the explayouts extension. Tests: Admin4lReviewTest.

16 October 2026: admin4 review, the design tests

Updated

  • The design tests that run admin4 JavaScript in node look node up through tests/tests/design/AdminNodeBinary.php: without node they are skipped on a developer machine and fail where CI is set (GitHub Actions), so a runner without node no longer passes the suite with those checks gone.
  • The design tests that assert something is absent read their files through tests/tests/design/AdminFiles.php: a missing file or an empty file list fails instead of passing without looking at anything.
  • AdminMegaMenuTest runs the mega menu view as the anonymous user and checks that it is refused.

16 October 2026: the left column's tab stays on the window's edge on a phone

Fixed

  • admin4 and admin4l: the open left column below 1024px (a drawer over the page) moved its tab to its far edge, by the column's width. With a width dragged or chosen on a wide window that put the tab and the width grip in the middle of a phone's screen, over the page, and right to left the tab left the screen. The tab now stays fixed on the window's edge, open or closed, in the 24 px the drawer's card leaves there, as the right column's tab does.
  • On a phone (below 576px) the left column has no width grip, and below 1024px the drawer leaves 48 px of the page visible at any saved width (it could be wider than the screen).
  • On a touch screen both sidebar tabs are 48 px high (40 px before; their width stays 24 px, the room the cards leave at the window's edge).

16 October 2026: the Tab order of admin4l's header is admin4's

Updated

  • admin4l: the Tab key goes through the header as it does in admin4: logo, menu button, menu, search, site link, light/dark switch, user menu, sidebar controls. The menu button and the sidebar controls were one block (admin_sidebar_toggles), drawn after the tools; they are two now, admin_menu_button and admin_sidebar_controls, and the page draws the logo and the menu button before the menu panel and the rest of the header zone after it. A layout stored with admin_sidebar_toggles keeps working and keeps its old order (the block is made of the two new ones). To give such a layout admin4's order, replace that block in the layout editor with admin_menu_button right after the logo and admin_sidebar_controls at the end of the header zone; nothing migrates stored layouts.

16 October 2026: the side columns are there on the first paint

Fixed

  • No layout shift when an admin4 page loads: the side columns opened only once the document was parsed (ezadmin_menubar.js adds .show), so a reload first drew the content across the whole window and then moved it aside for the columns; the dashboard at 1440 px with the left column open had a cumulative layout shift of 0.87 (0.73 with the default width and both columns). The page layouts of admin4 and admin4l now write html.a4-preload, and until the script has applied its state admin4.css draws the columns from the user's eZPreferences on <html> (admin_left_sidebar, admin_right_sidebar, admin_right_drawer) with the script's rules: open beside the content unless saved closed (left from 1024 px, right from 1280 px), a right drawer saved open is open on a tablet-sized window, and phones start closed. No transition runs before the script has run; it removes the class two frames after it has applied the state. Measured on the dashboard: 0.003 to 0.009 at 1440 px, light and dark, right-to-left, custom and default left width; no shift at 1180, 800 and 390 px. An anonymous page keeps its choice in the browser: it starts with both columns open, and a column it keeps closed closes when the script runs.

16 October 2026: the sub items say why they are missing without Exponential UI

Fixed

  • The node view's sub-items table is drawn by Exponential UI (extension expui). On a siteaccess where it is not active the table's place stayed empty below "Sub items (3)", without a word. It now says that the sub items cannot be listed because Exponential UI is not active or not loaded (admin4: a4-notice is-warn, admin: message-warning), as text and with role status. With Exponential UI nothing changes. German and American English. Test: AdminSubitemsWithoutExpUITest.

16 October 2026: explayouts 1.4.32 and 1.4.33: admin layouts with admin4's header and toolbars, the Exp Layouts debug bar panel

Updated

  • Require explayouts 1.4.32. Its stock admin layouts (the admin layouts fragment of new installations) put the tab menu in the topmenu zone, order the header as admin4 does (logo, menu button, search, theme switch, sidebar controls: the new blocks admin_menu_button and admin_sidebar_controls), and draw the right column of admin_3col from the toolbars admin_right and admin_developer (the new blocks admin_toolbar_right and admin_toolbar_developer; the interface choices come through admin_right). The single tool blocks, among them admin_ui_switch, and the combined admin_sidebar_toggles stay available, so admin layouts already stored keep working unchanged. [AdminLayoutSettings] SiteAccessMatch[] also matches siteaccesses named <site>_admin.
  • The template editor of explayouts edits only the templates of the layout types and blocks: explayouts.ini [TemplateEditorSettings] AllowedTemplateRoots[] ships the three folders below extension/explayouts/design/standard/templates/explayouts/ (it shipped design and extension, and an empty list fell back to them), and an empty list allows nothing. A site whose design overrides layout templates adds its own folder. AllowNewTemplates=disabled (new): a template that does not exist yet is created only with enabled. Symbolic links are never read or written, and the previous version of a saved template is kept in <var directory>/explayouts/template_backup/ instead of a .tpl.bak beside it.
  • The Layouts tab of the node view in admin4 has no inline script (the page to return to is in data-explayouts-return-to).
  • Require explayouts 1.4.33, which adds the panel Exp Layouts to the Exp Debug bar through the kernel's panel API (debugbar.ini [DebugBarSettings] Panels[]=explayouts): the layout of the page, the rule or default layout that chose it, its zones and blocks with links to edit them, and the available blocks. It shows only on public siteaccesses and only to users with explayouts_ui/edit, so visitors matched by DebugByIP do not see layout internals. explayouts.ini [DebugBarSettings] AdminURL sets the address of an administration on its own host.

16 October 2026: the classic administration design on phones and narrow windows

Updated

  • The right column of the classic administration design keeps its tools inside the column: they no longer ran about 5px past it and past the window's edge at 1440px and at 200% zoom. The interface choices (the design switch and the grouped language list) are one full-width button a row, a long name wrapping inside its button.
  • Below 760px the classic design works on a phone (design/admin/stylesheets/pagelayout.css): the page's content comes first, then the left menu, then the right column's tools; the header wraps (logo and logout, the search, the tabs); wide tables with links or fields scroll sideways inside their own box, tables of text wrap; fields and pictures keep to the width of the column; buttons, tabs and menu links are 44px high. Both page layouts carry the viewport meta, and the left menu's width control leaves the content's margin alone while the columns are stacked. Wider windows look as before, left to right and right to left.
  • Tests: tests/tests/design/ClassicAdminNarrowWindowTest.php.

16 October 2026: admin4 review, the admin scripts

Fixed

  • The admin frame saves a remembered choice only when it changes: window.a4Pref.set() skips a value already on <html>, and a page view no longer posts one preference per left menu group that was stored closed.
  • Changing the color mode for a signed-in user also changes it in the other open admin tabs.
  • A click on the left column's grip that does not move it saves nothing, and a width the server did not take is kept in the browser.
  • The sign-in button shows its own label again after the browser's back button.
  • admin4 always loads its rounded theme stylesheet, also when an old admin_theme preference names another one.
  • A mega menu whose links do not load within 10 seconds says so and loads again on the next opening.
  • A relation search result is added once and can be chosen with Tab, Enter and Space; a failed search shows "The search failed. Try again."
  • Sub-items table options (presets, rows per page, columns) that could not be saved show "Not saved: ..." and are not shown as saved.
  • The upload dialog shows an error for an empty server answer instead of stopping.
  • The sub-items list without its scripts (Exponential UI) keeps the one note of the same day's fix ("The sub items cannot be listed ..."); the merge dropped a second note of its own.
  • Context menus fill every placeholder, disable only what the opened menu asks for, and a click on a disabled item no longer jumps to the top of the page.
  • In the content tree a node on the path to the current page is marked also when it shows no sub items.
  • A broken tab cookie no longer breaks the node view's script.

Updated

  • The content tree's open/close buttons have no inline handlers; the tree, tab and sub-items column cookies use encodeURIComponent and SameSite=Lax (old values are still read).
  • The RSS export and RAD pages read event.key instead of keyCode. Tests: AdminScriptsBehaviourTest (node harnesses in tests/tests/design/js/), AdminScriptFailureTextsTest, AdminPreferencesTest, AdminMegaMenuTest, AdminKeyboardTest, AdminScriptsTest.

16 October 2026: admin4 templates for the copy step, the publishing queue and the locked class

Added

  • admin4 has its own content/copy.tpl and content/copy_subtree.tpl (the choice of versions, creator and times after the target was chosen), content/queued.tpl (the page shown while asynchronous publishing works) and class/edit_locked.tpl (a class whose changes wait for a cronjob), built from its components (.a4-radios, .a4-notice, .a4-facts, .a4-toolbar), so they follow the theme and dark mode instead of design/standard's unstyled pages. The form names, the variables and the ids the publishing scripts look for are unchanged; no inline code.
  • The image editor (ezie) in admin4: admin4's content/datatype/edit/ezimage.tpl hid the copy of the template that ezie ships in design/standard, so the editor's Edit button was missing. It now includes content/datatype/edit/ezimage_editor.tpl beside a stored image, which draws the editor's scripts and its Edit button only when ezmodule( 'ezie/prepare' ) allows it, and nothing otherwise.

16 October 2026: the content job page stops polling when it cannot go on; the relation search keeps its text

Fixed

  • Content job progress (content/job/<id>, page/content_job.js): the page stopped asking for the status only when the job finished. A job that was removed or that the user may not see (404, 403) and an ended session (the request redirected to the login page, or an HTML page in place of the JSON) made it ask every four seconds for as long as the tab stayed open. It now stops with a message (This job no longer exists or you may not see it ..., You are no longer signed in ...; data-text-gone, data-text-signed-out of the template), and so does a script error while it shows the status (data-text-error). After a network or server error the next request waits 4, 8, 16 ... up to 60 seconds and is back at two seconds after an answer, and a hidden tab makes no request until it is shown again.
  • "How to run it" on the confirmation pages of the content jobs (page/content_job_mode_choice.js): the remembered choice was saved with a GET of user/preferences/set/..., which answered with a redirect that rendered a whole page, carried no form token and did not check the answer. It is saved through window.a4Pref, as the other remembered choices of the frame: a POST to user/preferences/set_and_exit with the form token. The fieldset's data-prefs attribute is gone.
  • Relation search of the edit form (object relation and object relations attributes, ezajaxrelations_jquery.js of admin4 and admin): the search text was part of the ezjscore call (ezjsc::search::<text>), where strip_tags() removed what looked like a tag and :: cut it into arguments, so a<b or c::d searched for something else. It is sent as the POST parameter SearchStr, which ezjscServerFunctionsJs::search() reads.

Removed

  • data-a4-print and data-a4-disable-on-submit in a4_actions.js: no template used them, and disabling the buttons on a click would have left a form unusable after a cancelled submit or a page restored from the back/forward cache.

16 October 2026: content checks: the start location and the storage files

Page: The content checks, "Where a check starts", "Storage files" and "Quarantine".

Added

  • The start location of a content check is any node: the presets of contentcheck.ini [ScopeSettings] PresetList[] (the whole tree, the content, Media, Media/Images, Users) or any node through the shared node picker (contentcheck/scope/<view>/<node id>, browse.ini [ContentCheckScope]). It is kept as the preference admin_contentcheck_scope of the signed-in user and in the address. Shared selector contentcheck/scope.tpl, class expContentCheckScope; the views that take it are listed in ViewList[].
  • Storage files (contentcheck/storage/<node id>, Setup menu "Storage files"): a batched scan of var/<site>/storage (images, images-versioned, original) with progress, kept as the last scan. Orphan files: no ezimagefile row, no image XML of the attribute their directory names, no ezbinaryfile or ezmedia row, and no path found by the reference index in fields, layouts, settings or templates; every version and object state counts; files younger than MinAge (a day) never count. Size, age and totals. File problems of used files below the start location: another owner than the storage directory's, missing mode bits of StorageFilePermissions, empty files, a name that differs only in case, an image url that is not its dirpath and filename, a file in another MIME type's directory. With a DFS or database cluster the files come from its table, report only.
  • Quarantine, never a delete in the storage: orphans are moved to var/<site>/storage-quarantine/<date>/ with their path, restored from there (never over an existing file), and deleted from there only after a confirmation with its own box. Fix permissions sets only the expected owner (as root) and mode. Every action is a dry run first, then a POST with the form tokens and a confirmation token bound to its files; each file is checked again when it is done. Policies contentcheck/storage, contentcheck/quarantine, contentcheck/permissions. Audit events content.file.orphan.quarantine, .restore, .delete, .refused and content.file.permissions.fix.
  • On the command line: ./console exp:content-check --storage [--quarantine | --fix-permissions] [--dry-run] [--json].
  • One layout for every check (unreferenced objects, broken relations, storage files): the start location, the filters with Select all / Select none per group (buttons after Show, read on the server), the results header ("X to Y of N"), the table with a master box that ticks every row of the page (mixed state, "n of m selected"; without JavaScript "Select all on this page" / "Select none"), the bulk actions with "all N results" (one batch, always the plan first), and the pager. Shared parts contentcheck/results_head.tpl, bulkbar.tpl, pager.tpl, filter_choice.tpl, select_all.tpl, select_count.tpl, scope.tpl and contentcheck_select.js.
  • Tests: expContentCheckStorageTest (no database): the start location and the presets, the node picker view, paths, file problems, image XML and file paths, fixing owner and mode, the action view's decisions, restore and delete in a throwaway quarantine, the list parameters and the scan state.

Updated

  • The filter form of Broken relations took its node with a redirect that the kernel answered with the same query again, endlessly: "Check" ended in "too many redirects". It reads the node of the form directly.
  • The reference index (format 3) keeps every storage path it found, for the storage files.

Upgrade notes

  • New settings with safe defaults: contentcheck.ini [ScopeSettings], [StorageSettings], browse.ini [ContentCheckScope]. The new policies are held by Administrator only. Nothing is scanned or moved until someone asks for it. On hosts where two users write the storage (an application server as root, PHP-FPM as the site user), owner fixes need root: run the command line as root.

16 October 2026: admin4 review, values from the database and from packages are escaped

Fixed

  • A package whose package.xml carries markup or spaces in its version, release or type is no longer read (eZPackage::parseDOMTree() refuses it like a package without a name, with an eZDebug error), and the package pages of admin and admin4 wash every value taken from a package, including the error message of a JSON document. Before, a <number> with markup ran as script on package/view/files and in the file browser of package/view/full.
  • The admin and admin4 pages escape the hidden fields of the browse page, the template name of the template creator, workflow group names, product options of an order, wait-until class names, toolbar parameters, currencies and the preferred currency of the shop errors.
  • A class name ending in a backslash no longer breaks the sub-items table's script.
  • The content tree menu shows each user the classes they may create below the root node, not those of the user who drew it first: the root block's cache key carries the roles and limitations.
  • The page cache of the administration no longer writes a new copy for every distinct SectionID in the address: the parameter enters the cache keys as a number.
  • White space around the version, release or type of a hand-written package.xml (<number>\n 1.0\n</number>) is trimmed instead of getting the package refused by the new check. All 905 package definitions of the installed and downloadable packages (every content class, site and extension package) are still read.
  • design/standard, the fallback of every design without its own package pages, escapes the same package values, the action IDs of the install and uninstall errors and the package description, which it printed raw.
  • The content tree menu keeps its data attributes (no inline script, Content Security Policy) with the roles and limitations in its root block's key; the admin4 sub-items table keeps its JSON options, which escape the class names for their context already. Tests: eZPackageDefinitionTest, AdminStoredValueEscapingTest.

16 October 2026: missing files and image aliases in the broken relations

Added

  • Missing files, a target of Broken relations (contentcheck/brokenrelations, chip Missing files in var/<site>/storage, Kind[]=missing_file): the file-backed fields of the content below the node whose file is not in the storage (through the cluster file handler, so a DFS counts), in the published version and in drafts, and the images whose aliases are missing, out of date (another image.ini key, expired, or older than the original) or, when asked, never made. Each row: the object and its node, the field and language, the version, the expected path, the last modified time and the problem. Ticked alone, no relation is checked. With a total and pages ((file_offset), the findings kept ListCacheTTL seconds). Datatypes through contentcheck.ini [MissingFileSettings] Handlers[] (ezimage, ezbinaryfile, ezmedia; an extension adds its own expContentCheckFileHandler). Command line: ./console exp:content-check --missing-files [--problems=...] [--versions=...]; opt-in count in the cronjob part contentcheck (CronjobReport=enabled). Read only. Guide: Missing files and image aliases. Strings in eng-US and German; tests expContentCheckFilesTest (no database).
  • Resolving missing files (contentcheck/fixfiles, POST only, policy contentcheck/fixfiles with brokenrelations, and content/edit on each object): regenerate the aliases from the original, restore the file from another version that still has it, re-upload or replace it (same extension; an image must be an image), remove the dead reference (the database only), or mark for review; per row or for the ticked rows, with select all on the page (contentcheck_select.js, and links without JavaScript), select all and none in the filter groups, and a preview for all results. Always a preview first and a confirmation token bound to the rows shown; done in batches of FixBatchSize (10) with progress; nothing is ever deleted or overwritten. Audited as content.file.alias.regenerate, content.file.restore, content.file.replace, content.file.reference.remove and content.file.review.mark. Command line: --fix=<action> [--items=...] [--replace-with=...] [--dry-run]. Tests expContentCheckFileFixTest (no database).

17 October 2026: the log viewer

Page: The log viewer; guide: Finding an error by its reference.

Added

  • Download all logs on Setup > Manage logs: one .zip (or .tar.gz) of every log, its rotations optional, with a MANIFEST.txt of sizes, last writes and sha256; masked as the viewer shows them unless "Unredacted" is ticked (only with setup/unmasklogs); POST with the form token, audited as system.log.download (after.bundle), built as a stream under var/tmp/logbundle and removed after it is sent; capped by logviewer.ini [ActionSettings] MaxBundleBytes (default 256 MB). The row of buttons of the log viewer's pages has room above, below and between its lines.
  • Managing logs (Setup > Log files > Manage logs, setup/logsmanage, policy setup/managelogs): every log with its size, estimated lines, last write, growth, archives and limits; rotate now, empty (archive first), compress the archives, remove archives older than N days, rotate all and prune all, each with a preview of every step before it is applied under a lock and audited. The limits come from logviewer.ini [RotationSettings] per log, else site.ini [FileSettings]; new switch [ActionSettings] Compress (default disabled). The work is done by the log manager, or by a built-in fallback with the same API.
  • Log health (setup/logshealth): disk use against [HealthSettings] DiskBudget (256 MB), the fastest growing logs, logs over their limit, files the web server cannot write (mixed ownership), the newest error per log linked to its line, and the rotation settings in effect with the file and section each comes from.
  • Error digest (setup/logsdigest): the distinct errors and warnings of the last 24 hours per log, masked, normalized, grouped and counted, linked to their filtered view; "Mark as known" per user (preference admin_logviewer_known). Settings [DigestSettings].
  • Archives open read only under /setup/logs/view/<key>/(archive)/N ((rotation)/N still read).
  • Copy messages: copies the lines shown as plain text (following: every line kept; paused: the lines on screen), with a message saying how many.
  • Tests without a database: expLogArchiveViewTest, expLogHealthTest, expLogDigestTest, expLogManagerFallbackTest, LogViewerCopyMessagesTest.
  • Wrapping long lines is on by default and kept per viewer: the Wrap lines switch stores the choice as the user preference admin_logviewer_wrap, the same for every log, permalink and visit, drawn on first paint; without a choice logviewer.ini [TailSettings] WrapLines (default enabled) decides. The Open and Copy permalink buttons are on a line of their own at the start, and Open, Copy permalink, Download and Rotate are primary buttons.
  • Setup > Log files (setup/logs, Setup menu group System, admin4's mega menu): the log files of the installation (var/log, every site's var/<site>/log, the logs of extensions) and their rotations .1 .. .N and .gz (read only), in a navigator grouped Kernel, Site, Extensions and Rotated archives with size, time and line rate.
  • Following a file live with short polls (setup/logsfeed), never a held request: a stat() first, only the bytes after the reader's offset, at most MaxBytesPerPoll per poll, whole lines only, a server-side cache of the last chunk per file, no file locks, an interval that doubles while nothing arrives; pause and resume; rotation and emptying detected by inode and size.
  • Search of a file, every kernel log or every file, with their rotations: literal or regular expression (with pcre.backtrack_limit and a time limit), levels, time range (a binary search to its start), siteaccess, request, client address, error reference; bounded per request, continued with "More results".
  • Cross-references: the line format [ time ] [client] [siteaccess] [request] message read into entries; the lines of the same request across the kernel logs; the error reference of an error page (ERR-XXXXXXXXXX) found with its request; the audit console of the same minute; links to nodes, objects, users, classes, requests and templates.
  • Masking by default: e-mail addresses, IP addresses, passwords, tokens, keys, session ids and cookies; the values of secret settings always. The per-viewer switch "Show unmasked" for setup/unmasklogs.
  • Actions, POST with the form token and audited: download a range (masked, gzip; on), rotate now, empty a file, remove an archived rotation (each off until switched on, with a confirmation page).
  • Policies setup/logs, setup/managelogs, setup/unmasklogs (Administrator only in a new installation). Audit events system.log.read, system.log.download, system.log.rotate, system.log.truncate, system.log.delete (channel system, on).
  • A rate limit per session for the feed ([RateLimitSettings], 429 with Retry-After), kept outside the session.
  • On the command line: ./console exp:logs (bin/php/explogs.php): --list, --tail [--follow], --search with the same filters, --reference, --related, --json, --unmask, level colors.
  • Settings settings/logviewer.ini: [LogViewerSettings], [TailSettings], [SearchSettings], [RedactionSettings], [ActionSettings], [RateLimitSettings], [ViewerAuditSettings], every one documented with its default on the page. Classes in kernel/classes/logviewer/; template fetch fetch( 'setup', 'log_files' ).
  • Tests (no database) in tests/tests/kernel/logviewer/: the parser, offset tailing and the chunk cache, masking, path safety, the regular expression limit, the search, the rate limit and the actions.
  • Permalinks without a query string, by the log's key: /setup/logs/view/error, /setup/logs/view/debug, /setup/logs/view/site-storage, /setup/logs/view/webserver-access, with the whole view as view parameters ((rotation), (level), (search), (regex), (case), (from), (to), (siteaccess), (uri), (ip), (reference), (scope), (rotations), (cursor), (offset), (follow), (wrap)), e.g. /setup/logs/view/error/(level)/error/(search)/timeout/(follow)/0. A value is written as is when it is letters, digits and _ . , -, any other byte as ~XX. The page keeps its address in step with the view, and Copy permalink copies it (never with the unmasked switch). The navigator, the menus and the actions link to permalinks. A key names only a listed log, never a path; an unknown key answers 404. expLogPermalink writes and reads them.
  • Old links /setup/logs?file=var/log/error.log (with the search fields of that form) answer 301 with the permalink of the same file and search; the search form without JavaScript is answered 303 with its permalink.
  • The web server's logs, configured by key: logviewer.ini [LogViewerSettings] LogList[], LogPath[<key>], LogLabel[<key>], LogGroup[<key>] (webserver-access and webserver-error are listed with no path, so they are off until a path is set: never guessed). Shown in the navigator group Web server with their rotations (<name>.N, <name>.N.gz), read and downloaded only: rotate, empty and remove are refused for a configured log. The parser reads access logs (common and combined; the status gives the level) and Apache 2.4 error logs.
  • [LogViewerSettings] DefaultKey (error): the log /setup/logs opens.
  • Tests: expLogPermalinkTest (encoding, view parameters, round trip), expLogKeyTest (keys, configured logs, the old links), expLogWebServerFormatsTest (no database).

Updated

  • The Setup overview (setup/menu) also opens with setup/logs alone.
  • The audit catalogue has 167 names (five system.log.* added).

Fixed

  • A search finds only what the viewer is shown. The request and client address filters compared the values as written while the results showed them masked, so a viewer without setup/unmasklogs could find out a masked address one search at a time (203.0.1 finds it, 203.0.2 does not, ...) and likewise a token or e-mail address in a request. With masking on, both filters now also compare the masked value (203.0. still finds 203.0.113.7, shown as 203.0.x.x; 203.0.113 finds nothing); the text, the request and the client address are checked against the redactor for every viewer, so the values of secret settings and the secrets in an address, hidden even with Show unmasked, are not found either. Unmasked, the address and the request are compared as written, as before. expLogSearch::run() takes the viewer's expLogRedactor (a callable still works, for the text only); expLogSearch::entryMatches() takes it as an optional third argument. Tests: expLogSearchTest (LV-48), expLogViewerTest (LV-65).
  • The filters compare only what is shown, so a value copied from the page finds its entries. With a viewer's redactor the request and client address filters are compared with the shown value alone (no longer also with the written one), and the client address filter accepts an x: 203.0.x.x, 2001:db8:x:x, 2001::x, ::x, token=***. Before, the masked addresses of 404.log and security.log (written 203.0.x.x) could not be filtered beyond their first two parts, and a masked value copied from the page was refused or found nothing. A filter with a mark of the masks also finds, for an unmasked viewer, what a masked viewer would find. The same holds for the archives, the page's script, the command line and the error digest. New: expLogSearch::shownContains(), shownStartsWith(), expLogRedactor::masked(), masksIps(), ipFilter(), ipFilterOf().
  • A shared permalink never carries an unmasked address. Copy permalink gives the link with the request and client address filters as the masks of the settings show them ((ip)/203.0.113.7 becomes (ip)/203.0.x.x, an e-mail address j***@***.com), new expLogPermalink::shareable() and the template variable $log_share_permalink (both designs; an override without it falls back to $log_permalink). The address bar keeps the filters as typed. With MaskIPs=disabled the address stays.
  • A compressed IPv6 address with one group before the :: is masked. 2001::7334 and fe80::1 were shown in full by the log viewer; they are shown as 2001::x and fe80::x, and 404.log and security.log write that form instead of x. "Class::method" in a message is left alone (only the address field is affected).
  • The error digest links a log grouped by uri with the request filter (GET /blog/# links with (uri)/~2Fblog~2F), masked as the group is; expLogDigest::link() takes the grouping as an optional third argument, expLogDigest::uriFilterOf() is new. Tests: expLogMaskedFilterTest (LV-220 to LV-227: IPv4, IPv6 and its compressed forms, the request logs, the archives, the digest, the permalinks, without a redactor).

Upgrade notes

  • Nothing changes for a site until someone opens the page: no new table, no cronjob. The new policies are held by Administrator only; rotate, empty and remove are switched off ([ActionSettings]). A site that keeps logs in another directory adds it to [LogViewerSettings] Directories[] (it must be inside the installation root).
  • To show the web server's logs, set their paths, e.g. on Plesk LogPath[webserver-access]=/var/www/vhosts/<domain>/logs/access_ssl_log and LogPath[webserver-error]=/var/www/vhosts/<domain>/logs/error_log; the user that runs PHP must be able to read them.
  • Bookmarks of the first form (?file=) keep working: they are redirected to the permalink.

17 October 2026: content checks: quick scope buttons and a row of sites and sections

  • Every content check (unreferenced objects, broken relations with the missing files, storage files) starts with the quick scope buttons Whole tree (node 1), Websites (the content root, content.ini [NodeSettings] RootNode) and Media (MediaRootNode), then Choose with the node picker for any other node, Users among them.
  • Beneath them a second row lists the sites and sections under the content root, read live from the content tree: the visible children you may read, in the content root's sort order, names escaped. At most 12; with more, More… opens the node picker at that level (contentcheck/scope/<view>/<node id>/<start node id>; a cancel goes back to the check where it was). The site the start location lies in is outlined.
  • All of them are plain links to contentcheck/<view>/<node id> (no JavaScript needed), wrap on phones and mark the current one with aria-current. The storage files keep the list shown (?Show=) when the scope changes.
  • One shared part, design:contentcheck/scope.tpl (admin and admin4), from expContentCheckScope::forTemplate().
  • Settings (contentcheck.ini [ScopeSettings]): PresetList[] now defaults to tree, content, media (was also media/images and users; add them back there if wanted); new SiteRow=enabled, SiteRowParent=content (a preset key, node id or URL alias path) and SiteRowLimit=12 (1 to 50). SiteRow=disabled hides the second row.
  • Tests: expContentCheckStorageTest CS-15 (no database). Translations: eng-US, ger-DE.
  • Page: The content checks.

17 October 2026: the extensions' left menus end with admin4's width switch

Updated

  • The left menus of bccie, eztags, syndication and cjw_newsletter draw admin4's own width switch in admin4 and admin4l (parts/left_width_control.tpl: three icon buttons in a POST form, the current width pressed, stored per user), the same markup, script and behavior as every kernel module. bccie, eztags and syndication carried a copy of the classic Small, Medium and Large links; the newsletter pages had no switch in admin4. Each extension ships a design/admin4/templates/parts/<name>/menu.tpl that includes the kernel's part; the classic admin designs keep their links. Required versions: bccie ~1.1.14, eztags ~2.4.15, syndication ~1.3.7, cjw_newsletter ~4.2.9.
  • eztags 2.4.15 names the fold links of its tag tree ("Fold/Unfold") for screen readers.
  • The fold icons the classic tree menu script adds (ezjslib_createHTMLChildImageNode()) carry an empty alt text: they are decorative, the fold link carries the name.
  • Tests: AdminLegacyControlsTest::testExtensionLeftMenusUseTheKernelWidthSwitchInAdmin4 (no database): an extension whose classic menu copies the width links ships an admin4 menu, and every extension's admin4 menu includes the kernel's switch instead of writing its own.

16 October 2026: admin4 review, templates

Fixed

  • The admin4 sign-in page offers "Forgot your password?" only to a visitor who may open user/forgotpassword (fetch( 'user', 'can_open' ), as the register link); on an administration where anonymous has no user/password policy the link led to the access denied page.
  • content/upload.tpl (admin4, admin, standard) prints the reasons an upload failed as text (|wash); they carry the file's name.
  • A page of the admin4 search past the last one says that it has no matches and links to the first page of the same search, instead of the number of matches above nothing.
  • The Setup tab (menu.ini [Topmenu_setup]) has no PolicyList[]=setup/managecache any more: it is shown to every user who may open the Setup overview (setup/menu, any one of its six functions, checked by CheckViewAccess). A user with setup/system_info only lost the tabs of [TopAdminMenu] SetupTabs[] and had no Setup tab to find them in. An installation with [MenuAccessSettings] CheckViewAccess=disabled that wants the old rule sets the policy again in an override menu.ini.append.php.
  • "Active", "Activate", "Apply changes" (setup/extensions), "Sessions" (setup/session), "Repository" (package/list) and "Rejected" (content/versions) are current messages of the German catalogue, no longer marked obsolete.

Removed

  • setup/datatype_{basic,describe,code}.tpl and setup/templateoperator_{basic,describe,code}.tpl of admin4 and the classic admin design: the step templates of the old wizards, fetched by nothing.

Tests: eZTopMenuOperatorSetupTabAccessTest, eZLoginTemplatesRegisterLinkTest (RL-05), AdminTreeMenuEscapingTest, AdminSearchTest (AS-13), AdminSetupWizardTemplatesTest, AdminUsedTranslationsTest.

17 October 2026: the siteaccesses on Setup > System information

Added

  • Setup > System information shows the siteaccesses (#552). The Exponential card has a short line, "Siteaccesses: 17 (default: site; match: uri, host)", and a folded part Siteaccesses lists every siteaccess of AvailableSiteAccessList and RelatedSiteAccessList with its kind, designs, locale, languages, access extensions, hidden nodes and login, the entries that reach it, an Open link (the code of "Open the site", so host-matched siteaccesses get their host and URI-matched ones their prefix, on the port of the page) and a link to its site.ini settings. A resolution map shows the rules in MatchOrder order, the default siteaccess and the rule that chose this request. Read from the settings without changing the current siteaccess; nothing secret is shown. Class expSiteAccessOverview, tests expSiteAccessOverviewTest (no database). Guide: doc/eng-us/guides/system-information.md, section 5a.

Updated

  • Fixed: the "Open the site" link of a URI-matched siteaccess (ezpSiteAccessURL::root()) went to the host that the host map gives it whenever this page's host was mapped to another siteaccess, even with MatchOrder=uri;host, where the URI wins on any host (the mapped host may not even resolve). It keeps this host unless the host rule comes before the URI.

Upgrade notes

  • New optional setting site.ini [SiteAccessSettings] SiteAccessType names the kind of a siteaccess on that page; it is not set by default, and the kind is then worked out from the designs and RequireUserLogin.

17 October 2026: clean logs and one log rotation

A crawl of every site siteaccess, the administration and the default cronjobs now leaves nothing in error.log, warning.log, deprecated.log and strict.log but the requests for pages that do not exist (#555).

Updated

  • One log rotation for the kernel (expLogRotator, lib/ezfile/classes/explogrotator.php), used by eZDebug and eZLog: an exclusive lock on the log while it is rotated, the size checked again under the lock (two requests that both found a log too big rotated it twice, and the archives raced: "rename(storage.log.N): No such file"), and the new empty log created at once with the owner, group and mode of the old one, so a log written by Velocity (root) and PHP-FPM (the site's user) stays writable for both.
  • The last timing point is written before the first message of a request into debug.log only; it was an entry of its own ahead of every first error, warning and notice.
  • The error page's log line names the error and its HTTP status ("(kernel error 2, HTTP 404)"). A login request (401), a refused request (403) and a moved page (301) are notices, not errors; the policies a refused request did not match are debug output.
  • On an eng-US siteaccess a text the eng-US translation does not reword is shown as written and not logged as a missing translation (one debug line per text per page before).
  • Routine traces are debug output: the online editor's fallback editor, the layout resolver's cache hits and matched rules, a version without a name in a language, the upload location match, a skipped preference write, the two-factor sign-in copy of auth.log.
  • runcronjobs.php -q no longer prints "No scripts found for execution" for a part without scripts.
  • A translation file that does not validate raises one warning naming the file and its first errors (one PHP warning per schema complaint before).

Fixed

  • PHP 8.5 deprecations: null passed to trim(), rawurldecode(), explode(), strlen() and createTextNode(), and null as an array offset, in content views, the data map, the module dispatch, advanced search, keywords, the class package export and the string template operators (which now take null as an empty string).
  • A missing file row of the content checks redefined cc_action_names (a warning per row); the classic administration's left menu read LinkNames[] of sections that have none; a refused form raised an http_response_code() warning; content/translations logged its optional fields as missing; the parent of a draft's virtual node logged an empty fetch; a cache callback that returns nothing was reported as an error.
  • The translations carry the upgrade check's texts on inactive extensions, the image editor's button, the sub-items table's Editorial preset, and six administration texts the untranslated catalogue lacked. A new test (TranslationFilesValidTest) validates the three kernel translation files against their schema and checks every template text is in them.
  • Extensions released with their log fixes: exp_oe_tiptap 0.1.9, expai 0.0.1.7, explayouts 1.4.35, eztags 2.4.16, git_manager 2.0.19, sevenx_authentication_2fa 1.0.7, ezwebin 6.0.21, exp_adminui 1.0.0.37 (composer.json requires them).

Upgrade notes

  • New settings with safe defaults in site.ini [FileSettings]: LogFileCompress=disabled (compress the archives from .2 on) and LogFileMaxAge=0 (the age in days after which a prune removes an archive; 0: none).
  • eZDebug::rotateLog() and eZLog::rotateLog() take an optional second parameter, the size a log must still exceed under the lock; without it they rotate as before.
  • A log line of the error page ends with "(<type> error <number>, HTTP <status>)"; a filter that matched the whole line "Error ocurred using URI: <uri>" must allow the suffix.

17 October 2026: rich text keeps characters above U+FFFF, TeX comments and line breaks in custom attributes

Fixed

  • eZXMLInputHandler and eZXMLInputParser::process() allow the characters XML 1.0 allows above U+FFFF (#x10000-#x10FFFF). Before, opening a stored text in an editor removed each of them without a replacement (<mi>𝑥</mi> in a MathML attribute became <mi></mi>, 𝔸 = \mathbb{A} in a formula = \mathbb{A}), and saving made each of them a space (a 😀 b became a b). Control characters, U+FFFE and U+FFFF are still removed.
  • Online Editor (eZOEXMLInput::inputXML()): a line break in a custom attribute value becomes a space instead of vanishing; multi-line MathML in an attribute (</mi>&#10;<mo>) lost the break and glued the tags together.

Added

  • content.ini [CustomTagSettings] TeXSource[<tag>]=true names custom tags whose text is TeX source. When the Online Editor opens such a text it reads it as TeX does: an unescaped % comment is dropped with the rest of its line, the line break and the indentation of the next line (a run of comment lines counts as one); after a control word a space stays (\alpha%⏎beta gives \alpha beta). Before, the line break became a space and a % comment⏎+ b turned into a % comment + b, with the rest of the formula commented out. Leading white space of such a tag is dropped instead of being stored as a no-break space, and AllowMultipleSpaces adds no &nbsp; there. None is set by default; other custom tags keep the line-break-to-space rule.

  • ezxml.ini [InputSettings] SupplementaryCharacters=auto (enabled, disabled) decides where those characters are kept. auto keeps them unless the database is MySQL or MariaDB on a connection charset other than utf8mb4: Charset=utf-8 connects as utf8, three bytes at most, where they would be stored as ???? or fail the save in strict SQL mode. There they are replaced as before.

  • Rich text that is not valid UTF-8 no longer empties the whole text in the input handler and parser (preg_replace() returned null); each invalid byte sequence becomes U+FFFD.

  • eZOEXMLInput::texWithoutLineComments() finds a control word before a comment by walking back over the letters, not with a regular expression over the whole text before it for every comment, and takes ASCII letters only.

Tests: eZXMLInputSupplementaryCharactersTest (kernel-datatypes), expOEFormulaTextTest (ezoe), both without a database. Documentation: Rich text: characters above U+FFFF and TeX source.

17 October 2026: the side columns stay where they are while an admin4 page loads

Fixed

  • The content column no longer narrows after the first paint: the first paint's rules for the side columns (html.a4-preload) found the columns with body:has(.sidebar.right), and the right column stands after the content in the source, so a paint of the half-parsed page drew the content 232 px wider and then wrapped it (node views: 0.12 to 0.14 of layout shift at 1440 px). The page layouts of admin4 and admin4l now name the page's columns on <body data-a4-columns="left right"> (left: the frame's column or the one an edit-type page draws itself); :has() stays for a page layout without the attribute.
  • The right column's tools no longer move up: the Interface tool's design list was drawn open and closed by admin4.js once the document was parsed, and every tool below it moved up 96 px. a4_head.js sets html.a4-js before the first paint, and admin4.css draws the disclosure button and the closed list from the start.
  • Trash, the content finder and the width buttons no longer move down while the content tree is built: the left card with the content tree fills the column, the tree scrolls in it (at least 8 rem high), and the parts after it stand at the card's foot (they moved 20 to 600 px on every page with the tree). Test: AdminFirstPaintColumnsTest (FP-05 to FP-07).
  • The newsletter menu's tree no longer covers its Administer and Settings lists (newsletter/*, admin4 and admin4l): the rules for the tree card above also reached a tree inside a box of its own (#newsletter-menu > #content-tree), where the inner boxes with flex: 1 1 0 had no height, the card ended under its heading and the tree was drawn over the lists after it. The rules now start at the column's own card (#leftmenu-design > #content-tree), so a wrapped tree keeps the ordinary flow. Test: AdminFirstPaintColumnsTest (FP-08).

17 October 2026: the admin4 header's menu row stays in place while the page loads

Fixed

  • The main menu row no longer moves down 4 px from 576 to 1279 px: the header's tools (search, site, light/dark, user menu) stand after the main menu in the markup, so a paint of the half-parsed header drew the first row with the logo alone, 32 px high, and the menu row moved down when the tools arrived. The logo's box is now as high as the tools (36 px). Applies to admin4 and admin4l, which share the header. Test: AdminHeaderTest.

17 October 2026: the version preview and the edit form stay where they are while they load

Fixed

  • The version preview no longer jumps when its device switch appears (content/versionview, admin4 and admin4l): the bar with the devices was shown and the frame drawn at the device's size by admin4.js once the page was parsed, so the preview moved down 44 px and shrank (0.07 of layout shift at 1440 px). With the frame's scripts (html.a4-js) modules/content.css now draws the bar and the frame from the first paint, in the saved device's size and proportions (the preferences admin_preview_device and admin_preview_landscape on <html>), narrowed to the column as the script draws it. Without JavaScript nothing changes. Test: AdminPreviewDevicesTest.
  • The fields of the edit form no longer move down when an extension adds its tools to them: expai drew its bar of field actions into every field's block with JavaScript after the page was shown, and every field below moved down 26 px per bar (0.047 of layout shift at 1440 px). New setting content.ini [EditSettings] AttributeTemplates[] (empty by default): content/edit_attribute.tpl of the admin designs includes these templates in every field, after its label (first in the block for a field with its own fieldset or being translated), so an extension draws its tools with the page; expai uses it for its bar from its next release. Test: AdminEditAttributeTemplatesTest.

Upgrade notes

  • New optional setting content.ini [EditSettings] AttributeTemplates[], empty by default; nothing changes until an extension adds a template to it. A design with its own copy of content/edit_attribute.tpl includes the list the same way to get the tools drawn with the page (otherwise the extension's script adds them as before).

17 October 2026: the admin4 content edit form, striped role lists, Bootstrap Icons 1.13.2

Added

  • admin4 content/edit (also while translating), multi-edit and user/register draw each attribute as a card with an edge, and its corner names the kind of input with an icon and a word (.a4-content-attr-kind, hidden from screen readers, which hear the control's own kind; on a phone above the name). Two short inputs next to each other share a row from 64em on (not while translating). From six attributes on a row of links above the form names every field with its kind's icon, the required mark and an error state; a link to a field in a collapsed attribute group opens the group (content_edit_attribute.js, also for the error summary). An attribute group is a heading line over its cards. The mapping is admininterface.ini [ContentEditAttributeSettings]: DatatypeKind[], KindName[], KindIcon[], DefaultKind, CompactDatatypes[], FieldNavigationFrom; the kernel's datatypes and those of the extensions composer.json requires are mapped, an extension adds its own in admininterface.ini.append.php (The content edit form).
  • Bootstrap Icons 123, input-cursor-text, tag, tags, text-paragraph, ui-radios (1.13.2, unchanged); .a4-bi-* for them and for file-earmark, film, geo-alt, star in both copies of components.css, all ten in design.ini IconList[].

Fixed

  • Every second policy of a role (role/view, role/edit) and every second role card of role/list is striped (--a4-zebra), counting visible entries only; the tints of a moved or selected policy still win.
  • A one-line select without a class in an .a4-field leaves room at its end for the chevron admin4 draws there ("Subtree" beside "Assign" in role/view ran under it).
  • A field of the admin4 edit form that its datatype template draws without a name of its own (the name and email of an author, the selects of an enhanced selection) is named by its attribute's name and its column header (content_edit_attribute.js).
  • The kinds of input and the field list of the edit form are translated in the completed catalogues (Spanish of Spain and Mexico, Hebrew, Latin, Klingon).

Updated

  • admin4 names Bootstrap Icons 1.13.2; every shipped SVG is byte-identical to the one in the 1.13.2 package.

Tests: AdminContentEditAttributeKindsTest, AdminRoleListZebraTest (no database).

17 October 2026: map addresses escaped for HTML before JavaScript

Updated

  • A map location's address is stored as typed and escaped where it is printed, in the same order everywhere: |wash in HTML, |wash|wash( 'javascript' ) where a map script hands it to setContent(), which reads HTML. Markup in an address (or in the title of cjw_themes_jumper's popup) ran in the map popup before. Required versions: ezgmaplocation ~6.0.9, ezflow ~6.1.6, cjw_themes_jumper ~0.1.4.
  • ezgmaplocation 6.0.8 stores the address without HTML entities. Upgrade step, once per database, before editors work with it: php extension/ezgmaplocation/bin/php/decodeaddressentities.php -s <siteaccess> (dry run), then again with --apply; rows saved by 6.0.7 or earlier would show their entities otherwise. Its fromString() keeps an address with |# whole and stores only numeric coordinates, and its settings file no longer prints itself.
  • ezgmaplocation 6.0.9: the edit form opens on a database that returns the coordinates as numbers (SQLite, PDO), and its address, latitude and longitude fields have accessible names.
  • ezflow 6.1.6: the map block leaves out a location without a map value instead of stopping the whole map.
  • Tests: expMapAddressEscapingTest (suite maps, no database): the escaping order in every map template, the rendered pipeline, the coordinates made strings before explode(), and fromString().

17 October 2026: job queue, autosave and star rating without inline script or GET writes

Updated

  • expqueue 0.1.3: a job queue control that asks before it is sent declares its question in data-a4-confirm instead of an inline onsubmit handler. admin4 and admin4l ask it with a4_actions.js, the other administration designs with the extension's expqueue_confirm.js (design.ini [JavaScriptSettings] BackendJavaScriptList); it is asked once in every design.
  • ezautosave 6.0.10: the admin edit form reads the autosave settings from a JSON block (< and > escaped) and starts the autosave from the deferred javascript/ezautosave/autosave_admin.js, so the edit page carries no inline script.

Fixed

  • ezstarrating 6.0.11: ezstarrating::rate answers POST requests only, so an image or link to its GET address on another page can no longer rate in the visitor's name. The shipped script already posts with the form token. A theme of its own that still calls the GET address can be allowed with the new site.ini [eZStarRating] AllowGetRating (disabled by default), at the cost of that protection.
  • Required versions: expqueue ~0.1.3, ezautosave ~6.0.10, ezstarrating ~6.0.11. Tests: each extension's DB-less tests/unit (run phpunit in the extension).

17 October 2026: the icon theme follows the design

Updated

  • The class, file type, class group and other icons of admin4 and admin4l (class_icon, mimetype_icon, classgroup_icon, icon, the content tree's icon_info) are Bootstrap Icons without a setting of the siteaccess. New icon.ini [IconSettings] DesignTheme[<design>], set to bootstrap-icons for admin4 and admin4l: while that design is the SiteDesign, its theme replaces Theme, and Theme is searched right after it. Before, the theme was opt-in per admin siteaccess, and an admin siteaccess whose icon.ini.append.php says Theme=crystal-admin (as the setup writes it) drew the crystal PNGs in the content tree, the node header and the sub items. A user who switches to admin3 gets the siteaccess's Theme again; a type's own Theme still wins.
  • The Settings extension wizard and the RAD health check know the themes of DesignTheme[] as well.

Fixed

  • class_icon and the other icon operators no longer pass null to strlen() when eZSys::wwwDir() is not set (command line).
  • icon_info keeps its answer per type and theme list instead of per type only, so a persistent worker or a switch of the design in the same session never hands one design the icons of another.
  • icon_info leaves out a ClassMap[] (or other map) entry whose file the theme does not have: an extension that maps its classes to crystal paths (cjw_newsletter) no longer gives the admin4 content tree addresses under bootstrap-icons/svg/ that answer 404; the theme's own entry or its Default is used.
  • The two tests of eZSiteAccess::getIni() and eZSiteAccess::findPathToSiteAccess() live once, in eZSiteAccessGetIniAndFindPathTest (kernel-classes, no database), instead of twice under file names that differ only in case.

Upgrade notes

  • Nothing to do. To keep the crystal PNGs in admin4, set DesignTheme[admin4]= (empty) in the admin siteaccess's icon.ini.append.php. A design of your own built on admin4 needs its own DesignTheme[] entry. The opt-in setting (Theme=bootstrap-icons) still works and can stay.

Tests: eZWordToImageOperatorDesignThemeTest (kernel-classes), without a database.

17 October 2026: safer defaults in owsimpleoperator, no inline tracker in bcwebsitestatistics, full sitemap URLs in bcgooglesitemaps

Updated

  • owsimpleoperator 1.2.7 no longer permits file_get_contents and ereg_replace as template operators by default: the first let every template read any file the web server can read, the second no longer exists since PHP 7. Its DebugOutput is disabled by default. A site whose templates use one of them lists it again in its own owsimpleoperator.ini.append.php ([PHPFunctions] PermittedFunctionList[]); no template shipped with or installed on this installation uses either.
  • bcwebsitestatistics 1.0.11 drops the inline urchinTracker() onClick handler from the download links of its binary file view: the retired Urchin tracker no longer exists, and the handler was blocked under a Content Security Policy without 'unsafe-inline'.
  • bcgooglesitemaps 1.1.6.5 builds each <loc> of the dynamic sitemap with ezurl( 'no', 'full' ): the addresses carry the scheme (https too), host, port and siteaccess path the site is served on, also on servers that do not pass HTTP_HOST in the environment (they were http:/// there).
  • Required versions: owsimpleoperator ~1.2.7, bcgooglesitemaps ~1.1.6.5, bcwebsitestatistics ~1.0.11.

17 October 2026: formulas, tables and embeds survive the assistant and the Tiptap editor

Fixed

  • expai 0.0.1.8: formulas, tables and embeds are no longer lost when Vector or a tool writes a rich text field. What the model cannot express in an ezxmltext field is sent as a placeholder (⟦C1⟧ and the like) and restored from the stored value; a write that drops, repeats or invents one is refused, or staged for review in a Vector thread. New expai.ini [RichTextSettings] (Placeholders=enabled). The bars of field actions are drawn with the edit form, so its fields no longer move down; above a rich text field whose editor has an assistant of its own (Tiptap with its AI on) the field actions are hidden ([EditViewSettings] HideActionsWithEditorAI=false shows them), and the length of a field the model reads is [EditViewSettings] MaxFieldLength (default 20000).
  • exp_oe_tiptap 0.1.10 (not a requirement of this repository; sites that install it get it through their own constraint): formulas (exp_oe_tiptap.ini [EditorSettings] AtomicInlineCustomTags[], default math_latex and math_mathml) are inline atoms, so typing, deleting or formatting around a formula no longer changes it; their text is edited in the custom tag dialog's new field Content. Unedited content is stored byte for byte as before.

17 October 2026: configuration managed by the deployment

Added

  • site.ini [ConfigurationSettings] ManagedByDeployment (default disabled). Enabled for a siteaccess, the admin interface does not change its configuration on the server: the views that write settings, templates, generated code or packages (settings/edit, the toolbar and menu editors, the template editor, the setup wizard, extension activation, setup/rad and the code generators, package/upload|install|uninstall|create|export) answer like a disabled view and are hidden from the menus, and neither eZINI::save() nor expIniEditor::save() writes a settings file in a web request, under Velocity too. Read-only views and command line scripts are not affected; generated audit and mail preference keys are still written. See doc/eng-us/features/6.0/managed-by-deployment.md.

17 October 2026: log management on the command line and as a cronjob part

Page: Log rotation and pruning.

Added

  • expLogManager (kernel/classes/logviewer/), the kernel's implementation of expLogManagerInterface, which the log viewer's management pages take through expLogManagerFactory:
    • every log with its archives, sizes, growth, limits (with where they come from) and owner;
    • plans (dry runs) for rotate, prune, compress and empty, carried out step by step, each step resolved through the allow-list again under the rotation lock.
  • On the command line: ./console exp:logs --archives, --rotate=<keys>, --rotate-all [--force], --prune[=<keys>] [--days=N], --compress[=<keys>], --truncate=<keys> [--in-place], each with --dry-run and --json; changes are audited as system.log.rotate, system.log.delete and system.log.truncate.
  • The cronjob part logs (cronjobs/explogrotate.php, [CronjobPart-logs], in no default part): rotates the logs over their MaxSize, prunes and compresses.
  • logviewer.ini [RotationSettings]: MaxSize[], Keep[], Compress[] and MaxAgeDays[] per log key (empty by default; a log without an entry takes site.ini [FileSettings]).
  • Tests (no database): expLogManagerTest.

17 October 2026: the expai test suite runs on any site

Updated

  • expai 0.0.1.9 (tests only, no change to what the extension does): its unit suite no longer depends on the language, region rule and operating system of the site it runs on. A German siteaccess with [DataPolicy] DefaultRegions[]=eu;local gives the same result as an English one, and a run without a database skips the tests that need it instead of ending in errors.

17 October 2026: authorizing an OAuth client needs the policy oauth/authorize

Page: Upgrade note: authorizing an OAuth client needs the policy oauth/authorize.

Fixed

  • oauth/authorize never denied a signed in user: its check !$access['accessWord'] == 'yes' reads as (!$access['accessWord']) == 'yes', which is always false, and the oauth module had no policy function to grant. The check is now expOAuthAuthorizePolicy::userMayAuthorize(); a user without the policy is sent to the client's end point with error=access_denied (RFC 6749, section 4.1.2.1). A search of the kernel and every extension for the same !$x == ... pattern found no other access check with it.

Added

  • The policy function oauth/authorize, with an optional SiteAccess limitation.
  • rest.ini [OAuthSettings] AuthorizeRequiresPolicy (default enabled; disabled gives back the old behavior and is meant only for the time of an upgrade).
  • bin/php/addoauthauthorizepolicy.php (--dry-run, --role=<id or name>): grants oauth/authorize to every role with user/login, for the same siteaccesses; roles that have it already and roles of only the anonymous user are skipped.
  • New installations give the Editor role oauth/authorize.
  • Tests (no database): expOAuthAuthorizePolicyTest, expOAuthAuthorizePolicyGrantTest, and the view's access_denied redirect in ezpOauthAuthorizeRedirectTest.

17 October 2026: a toolbar theme for the debug bar

Added

  • debugbar.ini [DebugBarSettings] Theme=toolbar draws the pinned bar of the Exp Debug bar as a dark, compact toolbar in the manner of the Symfony web debug toolbar: an icon in each block, the request's HTTP status and module/view in the first block, amber and red blocks filled with their colour, and the details of a block (page and query time, queries, memory and its limit, the message counts, the request) in a popup above it on hover or focus. The panel follows the Symfony profiler: the tabs a dark menu down the left side with icons, a large title, and metrics at the head of Messages, Timing, SQL, Templates, Memory and Server. New stylesheet design/standard/stylesheets/expdebugbar-toolbar.css, linked only in this theme. The setting is also in the bar's Settings tab ("Bar theme").

Upgrade notes

  • Nothing to do: Theme=default keeps the bar as it was. Guide: doc/eng-us/bc/6.0/debug-bar.md, "Themes".

Tests: expDebugBarReportToolbarThemeTest (lib), without a database.

Fixed

  • The debug bar no longer grows or moves after the page has loaded (#571): the minimise symbol, "Keep open on reload" and the minimised tab are in the report's markup instead of being added by its scripts, the bar keeps at least the height of one line, and its sides are logical, so its link stays at the end of the line in a right-to-left interface. Both themes. The report has no inline script any more: what must run before the first paint is design/standard/javascript/expdebugbar-head.js, linked without defer, so a Content-Security-Policy with script-src 'self' holds.

17 October 2026: Tiptap in the layout editor, and Tiptap's admin4 look without a setting

Updated

  • explayouts-ui-api ~1.3.19: the layout editor's rich text and code editors are Tiptap instead of CKEditor 4 and Ace. CKEditor 4 is end of life without free security updates; the two editors were 23 MB of design/standard/vendor/, which is removed. The editor bundle is unchanged: an adapter provides the calls it makes. Rich text keeps CKEditor's toolbar and writes its HTML as CKEditor did, and unchanged content comes back byte for byte. HTML snippet and Markdown blocks keep their text byte for byte. No longer available: anchors, bidi buttons and paste-from-Word cleanup; syntax checking, themes, search and code folding. Nothing has to change in an installation.
  • exp_oe_tiptap 0.1.11 (not required by this package): exp_oe_tiptap.ini [EditorSettings] DesignSkin[] names the Tiptap skin per admin design, used when Skin is not set. admin4 and admin4l get admin4's look (a4) without a setting of their own; Skin=o2k7 keeps ezoe's look. The edit template no longer writes "Operator 'has_attribute' is not registered" to warning.log.

17 October 2026: the admin4 mega menu loads reliably

Page: admin4 design, "Mega menu".

Fixed

  • The mega menu of a top tab no longer says "The <tab> menu could not be loaded." when nothing failed: a load the browser stops because another page is being opened (Firefox reports it as a network error) shows no error, and the next opening loads again.
  • A passing failure (no connection, 5xx, 408, 429, no answer after 10 s, an answer without the menu) is tried once more after 600 ms. A failure for good offers "Try again" and keeps the link to the tab's page; a signed-out user is offered a link to sign in instead of the error.
  • One request per tab and page, shared by every opening while it runs; the script binds each tab once.
  • user/megamenu answers its script with a bare status and an X-Exp-Mega header (ok, unknown, signed-out, denied), never the sign-in page or an error page in the page layout; it releases the session as soon as the user is known, so the page's other requests do not wait for it, and sends the fragment without the templates' indentation (Setup about 5 KB instead of 75 KB).

Updated

  • site.ini [SiteAccessSettings] AnonymousAccessList[] and [RoleSettings] PolicyOmitList[] name user/megamenu: the view checks the signed-in user, user/login for the siteaccess and the tab itself. A site that removes either entry gets the sign-in or error page back, which the script also handles.
  • The fragment's root carries data-a4-mega-fragment="<tab>"; the script takes the element with that marker (or a design's own data-a4-mega-groups).
  • New strings (eng-US, ger-DE): "Try again", "You are signed out. Sign in again to see the %tab menu.", "Sign in".
  • Tests (no database): AdminMegaMenuTest MM-09, the load scenarios run in node against mini_dom.js.

17 October 2026: git_manager, ezupdate and the database editor follow the admin design

Updated

  • The pages of git_manager (dashboard, commit details, Backup), ezupdate (update/dashboard and the header, footer, status and method parts every update view shares) and sevenx_dse (dse/dashboard, its gate page and the embedded database editor) are drawn with the shared admin components (.a4-card, .a4-table, .a4-btn, .a4-toolbar, .a4-tag/.a4-badge, the notices and empty states) and the --a4-* tokens, so they follow admin4's light and dark modes and admin4l, and the classic admin gets the shared classes it has. No template carries a <style> block, a style="" attribute or an inline script any more: each extension's own rules are in a stylesheet loaded through its design.ini [StylesheetSettings] (bundled by the packer), and the scripts are files. Every button, form, confirmation and status poll works as before. Required versions: git_manager ~2.0.20, ezupdate ~1.1.20, sevenx_dse ~1.1.10.
  • git_manager's dashboard and commit details get their own left menu (Git Manager, Backup) that ends with admin4's width switch; the Backup page and the update and database editor pages keep the Setup menu, which has it already. Sites that override git_manager's templates with the old .gm-* classes lose that styling.
  • The database editor's stylesheets are scoped to the editor, so they no longer restyle the admin frame and its left column, and its login fields, logo, menu button and language select carry accessible names.
  • New DB-less tests under tests/tests/extension/{git_manager,ezupdate,sevenx_dse} check that these extensions' admin templates carry no inline CSS or scripts, that their stylesheets use only the tokens, and that their admin4 menus include the width switch. Each skips when its extension is not installed.

17 October 2026: copying a layout makes each zone once

Fixed

  • explayouts-core ~1.0.8: copying a layout gave every zone of the copy two rows, the layout type's and the source's. The layout editor added new blocks to one row and showed the other, so a block added to a copy disappeared from the editor and the page. A copy now has each zone once, with the source zone's position, its link to a shared layout zone and its blocks, collections and parameters. Draft and publish are unchanged. Layouts copied before can carry duplicate zone rows; an installation that copied layouts can list them by grouping explayouts_zone on layout_id, status, identifier.

17 October 2026: 404.log and security.log, error.log keeps the real faults

Page: Request logs: 404.log and security.log.

Added

  • var/log/404.log (expRequestLog, kernel/classes/requestlog/): one line per 404 or 410 answer with its reason (module:, view:, disabled:, node:, object:, rule:, file:, kernel:), the siteaccess, the node when known, the referrer and a short user agent; client addresses masked, every query value ***. site.ini [RequestLogSettings]: NotFoundLog=enabled, NotFoundLogFile=404.log, MaskIP=enabled, MaxURILength=300.
  • The request classifier and var/log/security.log (expRequestClassifier, kernel/classes/requestclassifier/, settings/requestclassifier.ini): a request that is a probe, a path traversal, an injection, comes from a known attack tool or is one of too many not-found answers from one client is written as one line <category>/<rule> confidence=... <status> ..., whatever its status, instead of its 404, notice or error line. Classification only, nothing is blocked. Rule sets by INI (RuleSets[], [RuleSet-<id>] with Patterns[], Disabled[], Excludes[]), extensions add their own (expRequestRuleSetInterface, expRequestPatternRuleSet), and Listeners[] (expRequestVerdictListenerInterface) is the hook for a later ban list or rate limit.
  • The log viewer: 404.log and security.log are kernel logs (/setup/logs/view/404, /setup/logs/view/security) with the labels "Not found requests (404)" and "Security (request classifier)", the levels notice and warning, and rotation defaults ([RotationSettings]: 10 MB, 5 archives, compressed, pruned after 30 and 90 days).
  • The error digest groups per log (logviewer.ini [DigestSettings] GroupBy[<key>]: message, category-rule, reason, reason-kind, uri) and lists other levels per log (LogLevels[<key>]): security.log by <category>/<rule>, 404.log by the reason of the answer, with its notices.
  • LogLabel[<key>] names any log of the log directories by its key, translated in the context kernel/logviewer.
  • Tests (no database): expRequestLogTest, the classifier's tests in tests/tests/kernel/requestclassifier/ and expLogRequestLogsTest.

Updated

  • error.log keeps the real faults: "Undefined module", "Module/View ... is disabled", "Undefined view", "Error ocurred using URI ... HTTP 404" and node not found go to 404.log (or to security.log for a classified probe) while NotFoundLog=enabled. With NotFoundLog=disabled they are written to error.log as before. 401 and 403 stay notices in notice.log.
  • The log parser reads the lines of both logs: the request, the siteaccess and the status become fields, the source is the status or <category>/<rule>.
  • logviewer.ini [DigestSettings] and [HealthSettings] are read from the settings: before, the defaults were used whatever the file said.

Upgrade notes

  • Nothing to do. To keep the old behavior, set site.ini [RequestLogSettings] NotFoundLog=disabled and requestclassifier.ini [ClassifierSettings] Classification=disabled.

17 October 2026: the file manifest check is a warning on pull requests

Every Quality failure of a pull request since 15 October stopped at "File manifest (share/filelist.md5)". The check runs on the merge of the branch into the current main, so when main changed the same files a contributor could not produce the merged checksums, and the person merging refreshes the manifest anyway.

  • On a pull_request event a mismatch is a ::warning:: annotation plus the diff and a note in the job summary; the job does not fail. On a push to main the check stays blocking, exactly as before.
  • Test: CiWorkflowPullRequestTest::testManifestWarnsOnPullRequestsAndBlocksOnPush (suite quality).
  • Guide: Pull requests, CI.

17 October 2026: a merged contributor commit no longer fails the commit message check

Every pull request is merged and its commits are kept as written, so a contributor's subject such as Changed: ... reached main and the Quality step "Commit messages" failed the push (and the pull request) for a commit nobody could reword any more.

  • The check is .githooks/check-commit-range <base> <head>, used by CI and by the local gates alike. A commit by a project author, an address listed in .githooks/commit-msg-authors, must still start with Added: , Updated: , Fixed: , Removed: or Renamed: and fails otherwise. A commit by anyone else gets a warning annotation and never fails. Merge commits are not checked. EXP_COMMIT_MSG_STRICT_ALL=1 makes every author strict.
  • The commit-msg hook on a developer machine is unchanged.
  • Tests: CheckCommitRangeTest (suite quality) builds throwaway repositories and covers project and contributor commits, a merged contributor branch, the strict switch and the annotations.
  • Guide: The commit messages.

17 October 2026: publishing under Velocity after a registration

Fixed

  • content/edit and user/register each give the edit form an action check of their own name (expContentEditCheckContentActions, expUserRegisterCheckContentActions) instead of sharing checkContentActions. Velocity's persistent workers keep every function a worker defined, so after one user/register in a worker, Publish in content/edit there ran the registration operation on the edited object: the publish answered 500 and the draft stayed unpublished. Apache and PHP-FPM were not affected. A custom content/edit or user/register view that hooks checkContentActions keeps working, but under Velocity it should give its function a name of its own too.
  • The text part of the mail layout (design:mail/layout_text.tpl) writes a row value that is not a string (a number, or nothing) as text instead of stopping the mail with an error.

17 October 2026: module views define global functions of their own name

Fixed

  • Under Velocity's persistent workers a worker keeps every global function it ever defined, so two views that defined the same name behind function_exists() served each other's function. shop/productcategories and shop/vattype both defined applyChanges (with different bodies: saving the categories of one view could run the VAT type code of the other), content/copy and content/copysubtree both defined browse, content/search and content/advancedsearch both defined pageLimit. Apache and PHP-FPM were not affected.
  • Every global function of a kernel module view now has a name of its own, expModuleView<Module><View><Name> (for example expModuleViewShopVatTypeApplyChanges, expModuleViewContentCopyBrowse). oauth/authorize no longer defines the generic error(), response() and getHTTPVariable() (now expModuleViewOauthAuthorizeError, expModuleViewOauthAuthorizeResponse, expModuleViewOauthAuthorizeGetHTTPVariable), which an extension defining the same name would have replaced. kernel/settings/validation.php defines expSettingsValidate() instead of validate(), and the unused testPHPIni() of the setup tests is expSetupTestPHPIni().
  • The bundled ezjscore (1.8.3) and ezoe views do the same: expModuleViewEzjscoreRunExitWithInternalError, expModuleViewEzjscoreRunHasAccessToBySetting, expModuleViewEzjscoreCallMultipleServerCalls, expModuleViewEzoeSpellcheckRpcFormatParam, expModuleViewEzoeSpellcheckRpcGetRequestParam.
  • The helpers of kernel/content/node_edit.php, relation_edit.php, section_edit.php and state_edit.php (initializeNodeEdit() and the rest) keep their names: custom content/edit views call them.

Tests

  • expGlobalFunctionCollisionTest (no database) scans kernel/, lib/, design/ and the bundled extensions by token and fails when two request-time files define the same global function, or when a module view defines a generic or unprefixed one.

Upgrade notes

  • Code that called one of the old names of a view's function (only possible after that view had run in the same request) calls the new name. A custom view should give its own global functions a prefixed name too.

17 October 2026: extensions without inline scripts in their templates

Updated

  • The installed extensions move their inline scripts, event handler attributes and javascript: links into script files with data-* attributes, so the administration's Content Security Policy (script-src 'self') no longer reports them, and they keep working once it is enforced. composer.json requires these releases: cjw_newsletter 4.2.12, eztags 2.4.17, xrowextract 2.5.17, ezownerchange 1.4.2, ezgmaplocation 6.0.10, ezwt 6.0.11, ezie 6.0.13, recaptcha 1.4.8, hcaptcha 1.4, syndication 1.3.8, bccie 1.1.16, ezautosave 6.0.12, ezmbpaex 6.0.7, explayouts 1.4.37, explayouts-ui 1.3.20, explayouts-ui-api 1.3.24 (the layout editor), explayouts-content-browser-ui 1.0.8, xrowmetadata 1.4.13, ezstarrating 6.0.12, bcwebsitestatistics 1.0.12, enhancedezbinaryfile 4.4.8, ezmultiupload 6.1.7, expui 1.0.0.10, expai 0.0.1.12, expchangeclass 1.0.9 and sevenx_authentication_2fa 1.0.9 (and AdminAid 1.1.4, which is installed from its repository).
  • Their context menu entries are added to menuArray by the extension's script from data attributes, and the entries call the same ezpopmenu_* functions as before, so they work with this release's data-menu-* menus and with the menus of earlier releases. Every administration page had 9 policy reports from the menu entries of eztags, xrowextract and ezownerchange; it has none now.
  • Exponential UI (exp_adminui 1.0.0.40) runs its own administration design without inline scripts, except the TinyMCE 3 start script it shares with ezoe; the site designs follow: ezwebin 6.0.22 (except the footer script an editor enters as content), ezdemo 6.0.12, ezflow 6.1.9, cjw_themes_jumper 0.1.6, sevenx_themes_simple 1.0.24 and sevenx-themes-media 1.5.70 (its service worker registration included), as do ezfind 3.0.6, ezpm 0.10.1 and ezauthorize 1.4.3 where they are installed.
  • The expservices portal designs (expportal_jquery, expportal_reactive) set their saved color mode from javascript/portal/theme_head.js instead of an inline script.

Tests

  • ExtensionInlineScriptsTest (no database) fails when a template of one of these extensions, at its listed version or later, carries an inline script, an event handler attribute or a javascript: link. What an extension still has is listed per file with the reason; the list only shrinks.

17 October 2026: the design switcher marks the older administration designs

Changed

  • The administration design switch (uiswitch.ini [UISwitch_design]) lists the current designs first (Admin 4 (layouts), Admin 4, Admin 3) and the older ones after them, labelled as such: "AdminUI (older)", "Classic (older)" and "Classic, server-rendered (older)". The labels are translated in eng-US, ger-DE, esl-ES, esl-MX, heb-IL, lat-VA and tlh-QO (context kernel/uiswitch); the former labels stay in the catalogues for an installation that sets them in its own settings.

Tests

  • expUISwitchDesignProviderTest::testShippedOrderPutsTheCurrentDesignsFirstAndMarksTheOlderOnes (no database) checks the shipped order, the labels and their eng-US and ger-DE translations.

17 October 2026: the update Overview no longer starts Composer on every visit

Fixed

  • ezupdate's Overview (update/dashboard) ran composer --version each time it was opened, and each run wrote a notice to var/log/notice.log. ezupdate 1.1.21 keeps the answer in <cache dir>/ezupdate/composer-version.json for ezupdate.ini [ComposerSettings] VersionCacheTime seconds (default 86400, 0 asks every time), keyed on the binary's path, size and time, so a new composer.phar is asked again at once; a failure is never kept. The cache is a cache item of its own (ezupdate, "Update extension cache"), cleared by "Clear all" and bin/php/ezcache.php --clear-id=ezupdate. A Composer run that succeeds is logged at debug level, one that fails still writes a notice. Required version: ezupdate ~1.1.21. Tests: ezUpdateComposerVersionCacheTest (DB-less, skips when ezupdate 1.1.21 is not installed).

17 October 2026: sitemap XML, inline CSS and accessibility follow-ups in five extensions

Updated

  • bcgooglesitemaps 1.1.6.6: the dynamic sitemap (googlesitemapdynamic/sitemap) is the whole response, sent as application/xml; charset=utf-8 and ended with eZExecution::cleanExit() outside any try/catch. With DebugOutput=enabled the debug output no longer follows </urlset>, so the XML stays valid, under PHP-FPM and the Velocity engine.
  • explayouts 1.4.36: the admin templates carry no inline CSS. The Authors tab's dates and names use the shared a4-nowrap class instead of style="white-space: nowrap", which a Content-Security-Policy without 'unsafe-inline' for style-src-attr reports and drops; the Layouts tab and the legacy layout and rule lists take their styles from explayouts_admin.css (design.ini BackendCSSFileList), on the admin's colour tokens.
  • cjw_newsletter 4.2.11: the check-all boxes of the blacklist and the editorial article pool have an aria-label, the edition filter's status icons an alt, the pager's current page 5.2:1 contrast, and the admin2 templates no style="" attributes.
  • bccie 1.1.15: the overview's check-all box has an aria-label.
  • cjw_themes_jumper 0.1.5: the starter design passes the contrast, target size and link name checks: the logo link has a name, the v2 colours below 4.5:1 are darkened in the same hue in starter.css, the footer menu links are 24px high, and linked images fall back to the object's name for their alt text.
  • Required versions: bcgooglesitemaps ~1.1.6.6, explayouts ~1.4.36, cjw_newsletter ~4.2.11, bccie ~1.1.15, cjw_themes_jumper ~0.1.5. Each extension carries a test without a database for its change.

17 October 2026: the layout editor page meets WCAG 2 AA

Fixed

  • explayouts-ui-api ~1.3.22: the layout editor page (explayouts_ui_api/app) had serious accessibility issues outside the editors, older than Tiptap. The sidebar's Content and Design tabs are WAI-ARIA tabs (a named tablist, aria-selected, a roving tabindex, tab panels labelled by their tab) and move with the arrow keys, Home and End; only one tab shows as selected. The block palette's button and the left toolbar's icon buttons have accessible names, the palette's button says whether the palette is open, and its list can be reached by keyboard. Text colors reach at least 4.5:1 through the editor's own tokens (--exp-le-accent, --exp-le-muted, --exp-le-on-dark-muted). The editor's CSS moved from an inline style block in app.tpl into stylesheets/explayouts-editor.css, and the app and mobile scripts are linked without a version query. The new texts are translated in every language of the extension. Nothing has to change in an installation. Tests: EditorAccessibilityTest (no database).

17 October 2026: every request is classified once, also one that ends through a clean exit

Page: Request logs: 404.log and security.log.

Fixed

  • A request that ended through eZExecution::cleanExit() (a file download, an ezjscore call, a redirect, a view that sends its own output) was not classified, so a probe such as /content/download/../../etc/passwd or an ezjscore call with an injection in its query had no line in security.log unless it failed through the error module. ezpKernelWeb::run() now starts the request log with expRequestLog::begin(), which registers expRequestLog::finish() as a clean-up handler of eZExecution; the clean-up runs on a clean exit, at the normal end and after a fatal error. finish() runs once per request, however many of these reach it, so a request has at most one line, in security.log or 404.log.
  • Under Velocity's persistent workers the handler belongs to its request (eZExecution keys its handlers on REQUEST_TIME_FLOAT) and is added once: nothing is carried into the next request and nothing accumulates.
  • expRequestClassifier::setInstance(): a test hook for the classifier instance() returns.

Tests

  • expRequestLogRequestEndTest (no database): a download probe and an ezjscore injection that end through the clean-up have one line each in security.log, a normal download and ezjscore call none; the normal end, the error module and a clean exit together still write one line; a real cleanExit() in a process of its own; eight requests in a row in one process as a persistent worker serves them, each probe once, one handler per request.
  • phpunit.xml has the testsuite kernel-requestlog (tests/tests/kernel/requestlog, tests/tests/kernel/requestclassifier): the request log and classifier tests were in no suite, so CI did not run them.

17 October 2026: the layout editor page can be zoomed

Fixed

  • explayouts-ui-api ~1.3.23: the layout editor page's meta viewport carried user-scalable=no, which stopped phones from zooming the editor (WCAG 1.4.4, axe meta-viewport). It now reads width=device-width, initial-scale=1, like the other editor pages. The "No content" placeholder of an empty block takes its color from the editor's muted token (--exp-le-muted) instead of an inline #888, which was 3.3:1 on a selected block. Nothing has to change in an installation. Tests: EditorAccessibilityTest (no database).

17 October 2026: the relation list's rows are labelled in the classic design

Fixed

  • The classic design's relation list edit template (design/standard, ezobjectrelationlist.tpl) gave the controls of a relation row no accessible name: axe counted two serious label findings per relation list on the edit form, from the empty row the browse script copies when a relation is added. As admin4 already did, the remove checkbox now carries the related object's name (else "Remove selected") as its aria-label, and the order field and the translation base select are labelled by their column heading (aria-labelledby). The strings were already translated in every catalogue. Nothing has to change in an installation. Tests: AdminRelationListLabelsTest (no database).

17 October 2026: a repeated probe reaches PHP under Velocity too

Page: Request logs: 404.log and security.log.

Fixed

  • Under Velocity the response cache answered a repeated identical anonymous request itself (X-Cache: HIT), also when the request sent Cache-Control: no-cache, so a probe sent five times reached the request classifier once and security.log had one line where Apache writes five. A request the classifier flags is now answered Cache-Control: private, no-store (expRequestLog), whatever its status, so no shared cache keeps it.
  • [CacheSettings] NotFoundSeconds ships as 0: every 404 reaches PHP, as under Apache, so 404.log and security.log see each one. A 404 without Cache-Control (a REST answer) was answered from the cache for a minute.
  • [CacheSettings] HonorRequestNoCache=enabled: a request with Cache-Control: no-cache (or Pragma: no-cache) reaches PHP and its answer is stored again; a browser's ordinary reload (max-age=0) is still answered from the cache. Needs exponential-velocity with Q.web.cache.honorRequestNoCache (main 8a1321e); older versions ignore it.
  • Normal anonymous pages are cached as before: measured on the :8080, 12 of 15 requests for three cacheable pages answered from the cache before and after. A probe sent five times now writes five lines in security.log (one before, four answered from the cache).

Tests

  • expRequestLogTest::testVerdictKeepsTheAnswerOutOfSharedCaches (no database).

17 October 2026: no inline CSS in the extensions' admin designs

Updated

  • Eleven extensions' admin templates carry no <style> block and no style="" attribute any more, which a Content-Security-Policy without 'unsafe-inline' for style-src reports and drops. Their styles are in each extension's admin stylesheet, loaded through design.ini [StylesheetSettings] and packed by ezjscore, on the shared components (a4-flag, a4-badge, a4-pct-*, a4-sr-only ...) and the admin's --a4-* tokens, so they follow the dark mode. Values that are data (bar widths, tag cloud sizes, the details panel width) became classes in steps or data-* attributes. Each extension carries a test without a database that fails on inline CSS in its admin templates.
    • exp_adminui 1.0.0.39: 120 uses in design/adminui, now in adminui-pages.css (design.ini AdminUICSSFileList, loaded after style.css); the workflow process list takes the Admin UI accent.
    • explayouts_ui 1.3.19 (39, design/admin Layouts pages, explayouts_ui_admin.css), eztags 2.4.18 (10 in admin2 and 3 in the standard design; the tag cloud's sizes are classes), ezflow 6.1.8 (5), sevenx_authentication_2fa 1.0.8 (5), expai 0.0.1.11 (3), expchangeclass 1.0.8 (3; the result page also escapes the parameter file it prints), ezautosave 6.0.11 (2; the preview panel follows the dark mode), AdminAid 1.1.3 (2), ezauthorize 1.4.2 (1), ezmbpaex 6.0.6 (1).
  • Required versions: exp_adminui ~1.0.0.39, eztags ~2.4.18, ezflow ~6.1.8, sevenx_authentication_2fa ~1.0.8, expai ~0.0.1.11, expchangeclass ~1.0.8, ezautosave ~6.0.11, ezmbpaex ~6.0.6. Nothing has to change in an installation.

17 October 2026: a restart or deploy that is cut off no longer leaves Velocity down

Fixed

  • exp:velocity restart, and the restart step of exp:velocity deploy, run the stop and the start as one detached unit (own session, and a transient systemd scope vc-restart-<date>-<token> as root). On 17 October a deploy's caller was killed between the stop and the start and :8080 refused connections for 28 minutes; now the restart finishes on its own. The caller still waits and prints the outcome with the same exit code. [ControlSettings] DetachedRestart=disabled restores the old behavior.
  • The server runs at [ServerSettings] Nice (default 5) whatever the caller's priority; a deploy run under nice -n 19 left every worker at 19. exp:velocity status reports a parent at another value. Nice=inherit keeps the caller's.

Added

  • restart-in-progress.json and stopped.json next to the pid file (expVelocityMarkers): an explicit stop or kill marks the server as stopped on purpose, start, restart and graceful lift it. deploy starts a server that is down without that mark and leaves one that was stopped on purpose alone.
  • exp:velocity watchdog install|status|run|remove: an optional systemd timer (vc-watchdog, every 45 s) that starts the default engine when no process of it runs, unless stopped.json says it was stopped on purpose or a restart is in progress (restart-in-progress.json younger than [WatchdogSettings] RestartTimeout with its process alive). It never touches a running server, backs off after three failed starts, logs to var/log/velocity-watchdog.log and is paused by exp:velocity conf disable watchdog. Off by default.

Tests

  • expVelocityMarkersTest, expVelocityNiceTest and expVelocityWatchdogTest (no database): markers, the start decision, the detached command, the priority prefix and its status check, the hand-over of the result.

17 October 2026: git_manager's commit log has pages and filters

Added

  • The commit log of git_manager/dashboard browses the whole history one page at a time: first, previous, the shared admin pager, next and last, "Showing x–y of z commits", and 10, 25, 50 or 100 commits per page (kept as the user preference admin_git_manager_log_limit, 50 until one is chosen). A page is git log --skip --max-count and the total git rev-list --count, so a long history is never read as a whole.
  • The log filters by branch or tag, author and message text (fixed text, any case) besides the dates. The page, its size and the filters are view parameters (/(offset)/50/(limit)/25/(ref)/main/(author)/jane/(grep)/fix), so a link reproduces the view. Every value reaches git as an argument of its own, without a shell, and a branch or tag must be one of the repository's refs. The filter no longer lives in the session. Required version: git_manager ~2.0.21.
  • Tests (no database): tests/tests/extension/git_manager/GitManagerLogPagingTest.php in the admin-extensions suite (the paging maths, the view parameters and the git arguments, hostile values included).

17 October 2026: the remaining serious accessibility findings of the admin pages

Updated

  • The classic design's Layouts pages: the primary buttons and the table headings have white text (4.8:1 and 5.1:1 instead of 4.3:1 and 4.4:1), the links of the Layouts menu are at least 24px high, and the dates, counts, row links and the import hint that the bundle drew in light grey or its own blue take the admin tokens (4.5:1 or more). The mapping list's export checkboxes and the layout list's sort direction have labels. In admin4 a disabled mapping is set apart by its dashed border on the soft surface instead of being dimmed, which took its priority badge to 2.5:1 (explayouts-ui 1.3.21).
  • The fold links of the tags tree are 24 x 24px targets in every admin design and are named with their tag ("Fold/Unfold Sports"); the tree's lines are at least 24px high (eztags 2.4.19).
  • The Admin UI's package list and cache page label every field; the sign-in footer is readable on its dark frame (exp_adminui 1.0.0.41).
  • The two-factor pages (second step at sign-in, setup, social login answers, the 2FA field of a user object) have no <style> block any more: their rules are stylesheets/sevenx_authentication_2fa.css, loaded through design.ini [StylesheetSettings] for the public and the administration designs. In a dark admin4 their card, fields and status colors follow the tokens, so the setup page keeps its text readable (sevenx_authentication_2fa 1.0.10).
  • Required versions: explayouts-ui ~1.3.21, eztags ~2.4.19, exp_adminui ~1.0.0.41, sevenx_authentication_2fa ~1.0.10. Nothing has to change in an installation.

17 October 2026: the last inline styles of the edit forms and the Admin UI's sign-in button

Updated

  • The classic design's relation list edit template (design/standard, ezobjectrelationlist.tpl) hides its "Create new object" field with the hidden attribute instead of style="display:none", and labels the name field. No datatype edit template of the standard design carries a style attribute any more (StandardDatatypeEditNoStyleAttributeTest).
  • The profile's link to the two-step sign-in page and the 2FA field of the public edit form take their layout from the extension's stylesheet instead of style attributes (sevenx_authentication_2fa 1.0.11).
  • The Admin UI's sign-in button has its filled accent with white text above 5.5:1 instead of 2.6:1 (exp_adminui 1.0.0.42).
  • Required versions: exp_adminui ~1.0.0.42, sevenx_authentication_2fa ~1.0.11. Nothing has to change in an installation.

17 October 2026: every Updates and packages view follows the admin design

Updated

  • ezupdate's other views (Installed packages, Find packages, a package's page, Package servers, a server's packages, Funding and a run's page) are drawn with the shared admin components and tokens, like the Overview since ezupdate 1.1.20, so they follow admin4's light and dark modes and admin4l (no serious accessibility findings, no light cards on the dark page). Each page draws its title, intro and actions in the shared page header. The older stylesheet ezupdate.css is removed; the module's own rules are .a4-update-* in its modules/update.css. Composer's coloured output uses classes instead of style="" attributes and the script sets no inline styles, so the pages work under style-src 'self'. Every form field and button name is unchanged; sites that override an ezupdate template with the old .ezx-* or .ezupdate-* classes lose that look. Required version: ezupdate ~1.1.23.
  • Tests (no database): tests/tests/extension/ezupdate/ezUpdateAdminStylingTest.php now checks every view and part: no inline CSS or script, no stylesheet of a template's own, the title through the shared header, the form names, ANSI colours as classes, and every string translated in the seven completed catalogues. It skips when ezupdate is not installed.

17 October 2026: eZ Find's search suggestions without YUI

Fixed

  • eZ Find's search suggestions work again (ezfind 3.0.7). They needed YUI 3, which ezjscore no longer ships, so no search field showed any. The suggestions are now plain JavaScript against the same server function (ezjscore/call/ezfind::autocomplete), as an accessible combobox: a named listbox of options under the field, arrow keys, Enter and Escape, a click, and a status region that announces the number of suggestions. Lookups wait for a pause in typing, and only the answer to the latest one is shown. The ezflow search block's AJAX search runs without YUI as well and escapes the values the server returns.
  • The list takes the administration tokens (with their dark values) in the admin designs and the site design's look on the site. A site that styled the old yui3-aclist-* classes needs the new ezfind-ac-* names.
  • The composer.json suggestion names ezfind ~3.0.7. Nothing has to change in an installation without eZ Find.

17 October 2026: the public edit form names every control, and the last style attributes of the extensions

Updated

  • The standard design's content edit form (the public edit form of starter and every design without its own) names every control: the sort field, the sort order and main location radios, the translation radios, the related object checkboxes and the class list have an aria-label, the move, remove and find image buttons an alt text, all translated in the design/standard/content/edit context, and an attribute's label points to its field. axe found 13 serious findings (missing labels and alt text) on that form and finds none now (StandardContentEditAccessibleNamesTest).
  • The media design's two-factor pages take their rules from sevenx_authentication_2fa_media.css instead of a <style> block and style attributes (sevenx_authentication_2fa 1.0.12). The metadata edit forms and the license picker take theirs from xrowmetadata.css (xrowmetadata 1.4.14), and the location map its size from ezgmaplocation.css (ezgmaplocation 6.0.11), both loaded through design.ini [StylesheetSettings]. The language flags of the tags field have an empty alt text (eztags 2.4.20).
  • Required versions: sevenx_authentication_2fa ~1.0.12, xrowmetadata ~1.4.14, ezgmaplocation ~6.0.11, eztags ~2.4.20. Nothing has to change in an installation.

17 October 2026: the REST administration in light and dark

Updated

  • The REST administration (Setup > oAuth admin: the application list, one application, its edit form, the removal confirmation, the API keys and the revocation confirmation) is drawn with the shared admin components (.a4-card, .a4-figures, .a4-tabs, .a4-table, .a4-filterbar, .a4-toolbar, .a4-notice) and the --a4-* tokens, in design/admin and design/admin4, instead of its own stylesheet with fixed light colors. In a dark admin4 its cards, tabs, figures, fields and confirmations follow the theme with readable contrast. The few rules the components do not have (the framed client identifier and secret, the key list as one block per key on a narrow screen) are .a4-setup-oauth-* rules in stylesheets/modules/setup.css. Every form field, button and action is the same (AdminOauthadminPagesTest); design/standard keeps its templates for other designs. Nothing has to change in an installation.

17 October 2026: export and import of layouts

Added

  • Layouts can be exported and imported between installations (explayouts_core 1.0.9, explayouts_ui 1.3.22). The admin page Layouts > Export chooses layouts (one, several, all), shared layouts, the mappings and drafts, or everything, and downloads a versioned ZIP file (JSON documents and a manifest.json with the format version, the source site, the counts and a sha256 per file). The shared layouts a chosen layout links a zone to always come along.
  • Layouts > Import validates such a file and shows a dry run first: each layout and mapping is new, identical, changed or conflicting, with the differences. A strategy can be chosen for all or per item: skip, overwrite (a new draft, published only when asked) or create a copy. Node and object ids are mapped by remote id, and references without a match are listed. The import writes in one transaction (rolled back on any error), makes shared-layout links by identifier, writes audit events (data.explayouts_core.import) and removes only the resolver cache entries of the layouts and mappings it touched. The format is described in extension/explayouts_core/doc/TRANSFER.md.
  • ./console exp:layouts list|export|import does the same from the console, with --dry-run, --json, --strategy, --item, --only/--all, --publish and --unmapped.

Updated

  • The console resolves the @alias tags of extension commands as well (exp:layouts runs ext:explayouts_ui:layouts). A full name is used as written; a bare alias stays in the extension's namespace (ext:<extension>:<alias>). The aliases the extensions already declared now work too (ConsoleExtensionAliasTest).
  • Export and import have policy functions of their own, explayouts_ui/transfer_export and explayouts_ui/transfer_import. The import page asked for explayouts_ui/edit before: a role that should keep importing needs explayouts_ui/transfer_import (roles with explayouts_ui/* have it).
  • Required versions: explayouts_core ~1.0.9, explayouts_ui ~1.3.23 (1.3.22 sent no valid ZIP file: under the persistent server no file arrived, under Apache a stray newline came first; 1.3.23 fixes the download).

17 October 2026: the Velocity watchdog records an audit event only when it acts

Updated

  • exp:velocity watchdog run (the vc-watchdog timer, every 45 s) no longer records system.command.run for every run, about 1,900 events a day. It records one specific event when it does something: system.velocity.watchdog.start (it started the engine, or a start failed), system.velocity.watchdog.backoff (it waited after three failed starts) or system.velocity.watchdog.skip (it left a down engine alone: stopped on purpose, a restart in progress, the lock held, an overlapping run, an error). A run that finds the engine running, or a paused watchdog, records nothing. Every other command still records system.command.run.

Added

  • eZScript::setAuditCommandRun( false ): a command that records its own, more specific events can leave out the generic system.command.run of its run (only that run; there is no setting). eZScript::auditCommandRun() records it, and shutdown() calls it as before. The three new names are on by default (settings/audit.ini can switch them off like any other name).

Tests

  • expVelocityWatchdogTest (no database): a run that finds the engine running writes no audit event, a start, a failed start, a backoff and a skip write exactly one specific event each, and another command still writes system.command.run. expAuditRecordTest: the catalogue has 170 names.

17 October 2026: the Velocity watchdog records a skip only when its reason changes

Fixed

  • While the server stayed down on purpose (or a restart was in progress, or the lock was held), every 45-second check still wrote system.velocity.watchdog.skip, some 1,900 events a day. A skip is now recorded only for the first skip of a period or a new reason. The last reason is kept in watchdog-last.json beside the markers and removed when a check finds the engine running or starts it. start and backoff events are unchanged; velocity-watchdog.log still has one line per skip.

Tests

  • expVelocityWatchdogTest (no database): three skips with the same reason write one event, a new reason writes a new one, the engine running again resets it, and a start ends the period.

17 October 2026: enforcing the Content Security Policy

Added

  • site.ini [ContentSecurityPolicy] gains what an enforced policy needs; the shipped policy and Mode=auto stay as they were:
    • AdditionalSources[]=<directive> <sources> adds hosts to a directive, as a list that extensions and siteaccesses can each extend.
    • ReportOnlyDirectives[] sends a stricter Content-Security-Policy-Report-Only beside an enforced policy, for example style-src 'self' while 'unsafe-inline' styles are still allowed.
    • ReportOnlyDesigns[] (shipped: admin, admin2, admin_classic) keeps requests in the classic administration report-only, including when a user picks it with the UI switch.
    • ReportOnlyViews[] (module/view, empty by default) keeps a view that sends a policy of its own report-only under the kernel's policy, for a server that does not let the view's header replace the kernel's (Velocity).
    • Nonce=enabled and NonceDirectives[]=script-src add a nonce per request. The template operator csp_nonce() prints it. It is never added next to 'unsafe-inline', and it is renewed per request in Velocity's persistent workers.
    • ReportEndpoint sends the reports to the siteaccess's own csp/report. An absolute report address is also announced with Reporting-Endpoints and report-to.
  • expContentSecurityPolicy::allowsInlineScripts() tells code whether inline scripts run under the current policy. The online editor uses it: it does not offer TinyMCE 3 where the policy is enforced without 'unsafe-inline' and falls back to TinyMCE 8.
  • The shipped policy names worker-src 'self' (the same as the fallback before), so AdditionalSources[] can extend it.

Updated

  • Mode=auto also counts a siteaccess whose AdditionalSiteDesignList[] has an administration design (an editor siteaccess on admin4l). It is report-only there, as on the other administration siteaccesses.
  • admin3 runs its pre-paint column width from admin3_head.js; no administration pagelayout has an inline script.
  • Rolling back is one setting: Mode=report-only, then clear the INI cache. See Content Security Policy, "Enforcing: nonce, more sources, a stricter companion, rollback". Tests: expContentSecurityPolicyTest, AdminPrePaintGuardsTest, expOEContentSecurityPolicyEngineTest.

17 October 2026: the reports of the Content Security Policy in a log of their own

Added

  • The kernel view csp/report takes the reports of the Content Security Policy, in both formats (application/csp-report of report-uri, application/reports+json of the Reporting API), and writes one line per violation to var/log/csp.log: directive, what was blocked, enforce or report, page, file, line, column and the first characters of the code. It is open to every visitor (AnonymousAccessList[], PolicyOmitList[]), answers a status without a body, and protects itself: content types, size (MaxReportBytes), rate limits per client and in total (ReportsPerMinute, ReportsPerMinuteTotal), masked and bounded lines (MaxViolationsPerReport). A flood is written to security.log once a minute as abuse/csp-report-flood. Settings: site.ini [ContentSecurityPolicy]; guide: Content Security Policy, "Reports".
  • csp.log is a kernel log of the log viewer ("Content Security Policy reports", key csp), rotated like the request logs (5 archives, compressed, 10 MB, pruned after 30 days). The error digest groups it by directive and blocked origin (the new grouping directive-blocked), and the parser reads its fields.

Updated

  • ezformtoken leaves a POST to csp/report with a report content type alone: a signed-in browser sends its session cookie with a report, but no form token. Every other POST is checked as before.

Tests

  • expContentSecurityPolicyReportTest and expLogCspReportsTest (no database): both formats, masked and bounded lines, every status, the rate limits and the one flood line, the settings and registrations, the form token exception and the log viewer's key, levels, rotation, parser and digest.

17 October 2026: the setup wizard no longer takes an installed site offline

Fixed

  • A GET of setup/init (a crawler of a signed-in administrator) started the web setup wizard on an installed site, and its maintenance mode answered every page with 503 "The site is being set up". Opening the wizard now changes nothing: maintenance starts only with the first posted step of a fresh installation.
  • On an installed site setup/init shows "This site is already installed" with the way to reinstall (the kickstarter or a fresh installation) and writes nothing: no maintenance marker, no setup log run. Installed means var/site_installed.json exists (the wizard's last page writes it), the site is served normally, or the database holds content.
  • The warning "Undefined variable $partName" of the setup view.

Added

  • settings/site.ini [SetupSettings] AllowWizardOnInstalledSite (default disabled, development only): when enabled, the wizard runs on an installed site after a confirm page posted back with a one-time token.
  • expSetupWizardGuard (kernel/classes/expsetupwizardguard.php) decides what a wizard request may do.

Updated

  • The wizard's lease is 15 minutes (expMaintenance::WIZARD_LEASE = 900, was 1,800), renewed by posted steps only and at the end of a long step; the browser's cookie is renewed with it.

Tests

  • SetupWizardGuardTest (no database): an installed site refuses the wizard whatever is posted, a GET never starts maintenance, it starts only from a posted step or a confirmed POST, the confirm token is used once and only with POST, the switch is off by default, and an abandoned wizard's lease runs out.

18 October 2026: the help system in the administration

Added

  • The kernel module exphelp (kernel/exphelp/): the documentation of the repository inside the administration. exphelp/dashboard (search, the help per audience, categories, featured pages, recent changes, how to report an error), exphelp/viewer/<page> (a page of doc/, rendered with its navigation, contents, previous and next page, and an "Edit on GitHub" link), exphelp/search (results page, and JSON for the instant search), exphelp/asset (images of doc/) and exphelp/report (which tracker, a prefilled issue, safe diagnostics, the private address for security issues). Guide: doc/eng-us/features/6.0/help-system.md.
  • The policy function exphelp/read. Administrators have it; Anonymous does not; give it to the Editor role to let editors read the help.
  • The help shows only git-tracked files below doc/ (exphelp.ini [IndexSettings]); everything else, untracked files, .., symbolic links, generated HTML and SVG, is a 404. Markdown is rendered safely by expHelpMarkdownRenderer (raw HTML escaped, unsafe links dropped, images only from doc/ or https://) with server-side highlighting (expHelpHighlighter) and the code colour tokens --a4-code-* (light and dark).
  • Context help: a Help link in the header of admin4, admin4l and the classic admin opens the page of exphelp.ini [ContextHelp] Map[] for the current module and view (expHelpContextRegistry, fetch exphelp/context; extensions add entries in their own exphelp.ini.append.php or with an expHelpContextProviderInterface). The link checks its page in a small cached list of served pages, never in the help index, so it adds no measurable time to an administration page.
  • ./console exp:help index|search|check: rebuild the index and the search index, search from the command line, and report broken links and images in doc/.
  • New settings file settings/exphelp.ini; module stylesheet stylesheets/modules/help.css (design/admin and design/admin4) and script javascript/exphelp.js (instant search, / to focus, contents that follow the reading position, copy buttons, collapsible sections; no inline script, CSP-clean).
  • Translations of every new string (context kernel/exphelp) in the complete catalogues.
  • Tests: tests/tests/kernel/classes/help/* (rendering and XSS, served paths, index, search, context, error report without secrets, module policy), AdminHelpScriptTest (the script under the mini DOM), no database.
  • exphelp/contents: every help page as one tree with a filter field; the viewer's side navigation lists the complete tree too (expHelpNavigation, cached per index, siteaccess and language; folder names in exphelp.ini [NavigationSettings] FolderNames[]).
  • Fixed: the help pages styled .a4-help as their page frame, which is also the shared help text under form fields; as help.css loads on every admin page, field help texts across the administration were squeezed to one word per line. The help frame is .a4-help-frame now, and a test keeps help.css off .a4-help.
  • exphelp/contents opens with every folder closed (title and page count); Expand all and Collapse all in a panel fixed near the bottom of the window, above the debug bar or its minimised tab (--exp-debug-clearance, set by expdebugbar.css), mirrored in right-to-left languages; the filter opens the folders of its matches and puts them back when emptied; #exphelp-folder-<path> opens a folder. Strings in the 11 complete catalogues.
  • The help dashboard shows the Exponential logo above "Help center" (the admin4 header's drawing, following the dark mode; the same drawing's file in the classic design), named "Exponential", at a fixed size.
  • The help navigation (at the side of every help page and on exphelp/contents) keeps the filter text and the folders the reader opened or closed (by their path; Expand all / Collapse all too) from page to page in the browser's localStorage (exphelp-nav); the folders of the page in view are always open; a Clear filter button and Esc empty the filter; without storage the page still works. Expand all / Collapse all now sit sticky at the end of the help column, so the Tools sidebar can no longer cover them.
  • The help navigation's stored folders and filter are applied before the page is first drawn (the script opens each stored folder as the page is read; a stored filter keeps the tree hidden, with its room, until it is applied), and help page images carry their width and height, so neither moves the page (layout shift). In the classic design the page frame clips with overflow: clip instead of hidden, so sticky controls such as Expand all / Collapse all stay in the window.
  • Search and browse addresses with view parameters (/exphelp/search/(q)/<words>/(category)/<id>/(audience)/<id>, (type), (offset), (limit)); without (q) the page lists every page the filters allow. Old ?q= addresses and the search form without scripts are redirected once to the canonical address; the JSON of the instant search accepts both forms.
  • The header's "?" opens a Help menu in admin4, admin4l, the classic admin and AdminUI (search, the Help center right below it, help for this page, related topics, quick links; exphelp.ini [ContextHelp] MenuTopics, MenuLinks[], fetch exphelp/context_menu); the Help center is linked from the user menu, the footer, the dashboard and the Setup overview. In the classic admin and AdminUI the menu is a native disclosure; Escape closes it and gives the focus back to its button. The root requires exp_adminui 1.0.0.48 and ezwt 6.0.12, whose website toolbar "?" opens the Help center for a signed-in editor.
  • Print and PDF: a help page prints without the administration around it, and "Download as PDF" (exphelp/pdf/<page>, ./console exp:help pdf) draws it with the kernel's own PDF library (exphelp.ini [PdfSettings]).
  • The help states its license, the GNU Free Documentation License 1.3 or later: doc/eng-us/LICENSE-GFDL-1.3.txt, doc/eng-us/README-LICENSE.md, a notice on every help page, its print view and PDF, and a Free documentation block on the dashboard (exphelp.ini [License]).
  • The release history (3.x to 6.x) and the branch history are shown as tables per series with a filter and sortable columns (expHelpRecordRenderer).
  • Help pages: developing with Git and growing from user to developer (with a dashboard band), changing the administrator password, the default administrator password per install method, and security hardening.
  • Help pages on the supported databases (SQLite 3, the default, then PostgreSQL, MongoDB, MariaDB, Oracle and MySQL; Microsoft SQL Server is possible with additional work) and on the supported operating systems; the installation guides state the same order. composer.json suggests ext-sqlite3 as the default database and lists the others in that order.
  • php bin/php/addexphelpreadpolicy.php --apply (./console exp:help-grant --apply) gives the Editor role exphelp/read on an existing installation.
  • Examples (doc/eng-us/examples/, category "Examples", featured on the dashboard): web server configurations for Apache (.htaccess, virtual host), nginx, Caddy, FrankenPHP, LiteSpeed, IIS, PHP-FPM and Velocity, each with the routing of .htaccess_root and checked (syntax, and test servers for Apache and nginx); a cookbook of fourteen worked tasks; and about 2 400 reference pages generated from the code by ./console exp:help:examples (template operators and functions, fetch functions, module views, policies, INI settings per block, datatypes, workflow events and triggers, cronjobs, console commands, extension points). --check reports pages that are not current. doc/eng-us/examples/htaccess and doc/eng-us/examples/ezpublish.conf were rewritten and are free documentation now.
  • exphelp.ini: the category examples, the page type generated (TypeBoost[generated]=0.5, left off the audience cards) and [NavigationSettings] FoldedFolders[]: a folded folder shows its overview and page count and lists its pages only while a page inside is open, so the generated pages add no links to the other pages.
  • Two more audiences of the help, Project managers (project-manager) and Sales & marketing (sales-marketing): their dashboard cards link a start page, the search filters, the page pills and the categories Project leadership (doc/eng-us/help/leadership/) and Sales and marketing (doc/eng-us/help/business/) know them. Help pages for both: planning, scoping, hosting choices, checklists, quality gates, releases, risks, Git teams, templates and measures for project managers; positioning, feature highlights, factual comparisons, demo scripts, objections, licensing, brand rules and the community for sales and marketing; and shared pages on working together, becoming a community leader and a reading path. exphelp.ini [AudienceSettings] and [CategorySettings] list them; installations with their own audience list in an override add the two ids there.
  • Eight community audiences of the help: Partners (partner), Advocates (advocate), Designers (designer), Hosting companies (hosting), Application creators (app-creator), Website creators (website-creator), Supporters (supporter) and Sponsors (sponsor), each with a dashboard card, a search filter and a page pill, and their folders under doc/eng-us/help/ (partners/, advocates/, designers/, hosting/, creators/app-creators/, creators/website-creators/, supporters/, sponsors/; pages in extension-development/ are for developers and designers). New categories with an introduction each: Partnership, Advocacy and community, Theme design, Templates, Extension development, Theme extensions, Publishing and distribution, Selling themes and services, Maintenance life cycle, Hosting, Building applications, Building websites, Supporting Exponential and Sponsorship. Installations with their own audience list in an override add the new ids there.
  • Landing pages for every audience of the help, exphelp/audience/<audience>, opened from the dashboard's audience cards. They are built from blocks set in exphelp.ini [LandingSettings] and [Landing_<audience>]: a hero with a summary, a search box and the start page, the reading path, info boxes, a search button per category, key pages, recently updated pages and related audiences. The developers' page has a paged log of the latest commits to Exponential, read by the server from the GitHub feed with a timeout and a 10-minute cache and kept when GitHub cannot be reached ([CommitFeedSettings]). A Support Exponential block at the bottom of the dashboard and on the supporters' and sponsors' pages asks for monthly sponsorships of 7x and of the Exponential Foundation through GitHub Sponsors ([SupportSettings]).
  • Two more audiences of the help, each with a dashboard card, a search filter, a page pill and a landing page: Trainers (trainer, category Training, doc/eng-us/help/trainers/) and Press, media and influencers (press, category Press and media, doc/eng-us/help/press/). The dashboard's "Grow from user to developer" band has a fifth step, Contribute extensions back, shown once its page doc/eng-us/help/contribute-extensions-back.md is in the help.
  • The help cache no longer grows with every change of the documentation: rebuilding the index removes the older indexes and the navigation trees and rendered pages that belonged to them (one stale index is about 5 MB). A new category, The Exponential project (project, doc/eng-us/help/project/), holds the pages about the project itself.
  • Two more audiences of the help with dashboard cards, search filters, pills and landing pages: Businesses (businesses, category Exponential for businesses business-solutions, doc/eng-us/help/businesses/) and Governments (government, category Public sector public-sector, doc/eng-us/help/government/); and the category Two ways to build (build-two-ways, doc/eng-us/help/build-two-ways/). The help search weighs the trainers' lessons below the editor and administrator pages they teach ([SearchSettings] AudienceBoost[trainer]=0.6), except while the search is filtered to trainers.
  • One more audience of the help with a dashboard card, a search filter, a pill and a landing page: Customers (customer, category Customer success customer-success, doc/eng-us/help/customers/), for the organizations and people who run their own site on Exponential and their teams; the aliases customers, site-owner and site-owners select it too.
  • The help search keeps to a time budget: the words of each title are stored in the search index (format 4) instead of being split and stemmed again for every hit of every query, and a test (expHelpSearchBudgetTest, no database) fails when a query over 7 000 generated pages takes more than 300 ms of CPU time at the 95th percentile.

Fixed

  • The search form sent without scripts redirected to itself until the browser gave up; the view now sends its redirect without the query string.
  • exp:help check reported a backslash-escaped \[x\](y) as a broken link; it reads it as text, as the renderer does.

18 October 2026: quick location buttons on the upload page

Added

  • expNodeQuickPick (kernel/classes/expnodequickpick.php): the quick location buttons of a page that asks for a place in the content tree. It finds the well-known places from a short key and content.ini (Websites, Media, Users, a node id, remote:<node remote id>, a URL alias path), never from fixed node ids, and builds a row of the places right below one of them with the current one and the one it lies in marked, filtered by a check the page gives. Guide: doc/eng-us/features/6.0/quick-location-buttons.md; tests: expNodeQuickPickTest (QP-01 to QP-05), no database.
  • The shared component .a4-quickpick in components.css (design/admin and design/admin4): labelled rows of small buttons or links, the current one filled (aria-pressed, aria-current or .a4-btn-primary), the one the current place lies in outlined (.is-within), in light, dark and forced colors.
  • The upload page of ezmultiupload 6.1.8 uses both: under its target location, Websites, Media, Images and Files, then the sites and the other folders of Media, only where the user may create the classes uploaded files become; one click makes a place the target. Required version: ezmultiupload ~6.1.8.

Updated

  • The dashboard's Upload files button (admin4 and admin4l) opens the upload page at Media > Images where the user can upload there, else at Media (ezmultiupload 6.1.8, ezmultiupload.ini [DashboardSettings] UploadTargetList[], fetch function dashboard_target), so uploads land in the Images folder first. The Admin UI's dashboard does the same from exp_adminui 1.0.0.43.
  • The content checks' start location takes its presets and its row of sites from expNodeQuickPick (expContentCheckScope::presetSource(), presetNodeID(), presetLabel() and siteRow() keep their names and answers, and a preset can now also be remote:<node remote id>). Its styles moved from .a4-setup-scope in stylesheets/modules/setup.css to the shared .a4-quickpick; a site that styled .a4-setup-scope renames it.
  • Required versions: ezmultiupload ~6.1.8 (exp_adminui ~1.0.0.44 includes 1.0.0.43). The quick location buttons' scripts are tested under the mini DOM (expMultiuploadQuickLocationsScriptTest, QJ-01 to QJ-04, node, no browser).

17 October 2026: Manage logs names every log again, 404.log included

Setup > Manage logs (/setup/logsmanage) showed every log as a link without a name, marked "Web server, read only", without its actions: the kernel's expLogManager::logs() left out the fields the page reads (label, lines, managed), which only expLogManagerFallback gave.

Fixed

  • expLogManager::logs() gives each log its label (the LogLabel[<key>] of settings/logviewer.ini, else the file name), its lines (about) and managed, as expLogManagerInterface now documents for every implementation.
  • A key of digits only (404 of var/log/404.log) is an integer as soon as PHP uses it as an array key. The log viewer keeps it the string it is everywhere: logs() of both managers, select(), plan() (also for every log) and apply() name it "404", expLogFileLocator::resolveKey() and labelOf() also take the integer, and Log health asks for its settings and builds its link with the string. The key is not renamed: /setup/logs/view/404 stays.
  • Log health showed "Array" for the largest size and the archives kept of every log: expLogHealth::settings() now reads the {value, source} entries of expLogManager::effectiveSettings() as well as the flat limits.

Tests

  • expLogNumericKeyTest (no database): the key 404 through the settings, the locator (also a rotation), both managers (fields, label, plan and apply), the permalink, Log health and the digest settings.

18 October 2026: the upload page in Ukrainian

Updated

  • Required version: ezmultiupload ~6.1.9, which adds a complete Ukrainian translation of the upload page (its queue, the location picker and the quick location buttons, the actions on uploaded items, the ZIP import and the server's messages). Its terms follow the kernel's Ukrainian administration translation (share/translations/ukr-UA), which also translates the named quick location buttons (Websites, Media, Users ...).

18 October 2026: every country of a translated language gets its translation; fallback chains

Merged pull request #283 (Germany's, France's, Spain's and the Arab world's other countries; 48 new locales from CLDR 48) and built on it. Docs: Translation fallbacks, Writing a fallback rule.

Added

  • i18n.ini [TranslationSettings] FallbackLanguages[] for every country of a language with a translation: the German-, French- and Spanish-speaking countries fall back to ger-DE, fre-FR and esl-ES, the Arabic ones to ara-SA once it exists, and chi-SG, dut-BE, ita-CH, por-AO, por-CV to theirs. With esl-AR the administration was English, it is Spanish now.
  • 48 locales: Spanish of Paraguay, Costa Rica, Panama, Honduras, El Salvador, Nicaragua, Puerto Rico and Equatorial Guinea; French of Luxembourg, Monaco, Haiti, Vanuatu, Mauritius and 20 African countries; Arabic of Bahrain, Oman, Libya, Palestine, Yemen, Sudan, Mauritania, Djibouti, Comoros, Somalia, Chad and Eritrea (right to left); German of Luxembourg, Liechtenstein and Belgium.
  • Fallback chains: a fallback can have a fallback of its own (esl-AR -> esl-MX -> esl-ES), tried in order for each translation root, with cycles stopped and FallbackMaxDepth (default 4).
  • expI18nFallbackResolver, the one source of truth for the translator, the interface language choice, exp:i18n and Setup > Interface languages, with rules: FallbackLanguages[] first, then the classes of FallbackRules[] (expI18nFallbackRuleInterface, for extensions), then the automatic same-language fallback.
  • AutomaticLanguageFallback=enabled: a locale with no translation of its own and no configured fallback takes the most complete translation of its language (fre-NC to fre-FR); AutomaticFallbackExclude[]=eng. It changes nothing for the shipped locales, which all have their line.
  • FallbackMode=merge (opt-in; default first, as before): every translation of the chain is loaded, the most specific winning, so esl-MX takes the messages it lacks from esl-ES.
  • ./console exp:i18n fallbacks [--locale=] [--only=] [--format=table|json]: per locale its chain and the kernel's and every extension's coverage; ./console exp:i18n check [--strict] exits 1 on a cycle, a chain too long, a missing or invalid target or a rule that is none (for CI).
  • Setup > Interface languages (setup/languages, policy setup/system_info): the same list in the administration, with one locale's chain step by step and every extension; read only.

Updated

  • expUISwitchLanguageCatalogue counts a locale without a translation with its whole chain (cache format 5); its entries carry chain.
  • The translation cache's directory includes the fallback settings, and with TranslationCheckMTime a changed file of a fallback renews the cache of the locales that fall back to it.

Tests

  • expI18nFallbackResolverTest, eZTSTranslatorFallbackChainTest, expI18nFallbackReportTest (no database): chains, cycles, depth, the automatic fallback and its exclusions, rule registration and order, problems, first and merge in the translator per root, the report, the command's output and exit codes, the page's rows. expUISwitchLanguageProviderTest and expLocaleFilesTest from the pull request.

17 October 2026: RSS and Collected information in the Setup mega menu

Updated

  • admin4 and admin4l: the Setup mega menu lists Collected information (infocollector/overview, group "Content model") and RSS (rss/list, group "Site and URLs") too. menu.ini [Leftmenu_setup] MegaMenuLinks[] names them after Sections (collected, rss) and keeps its twelve other links, so the default is fourteen; each keeps its access check (infocollector/read, rss/edit).
  • The mega menu shows at most 14 chosen links (was 12), in parts/a4_mega_menu.tpl; the debug report (parts/a4_mega_menu_check.tpl) names a list of more than 14 keys. A site that restates a shorter list is unchanged.

Tests

  • AdminMegaMenuTest: the default choice names collected and rss, has at most 14 keys, the panel shows the first fourteen chosen links, and the debug report names 15 keys.

18 October 2026: "New draft" on the edit-conflict page opens the new draft

Updated

  • The edit-conflict page (content/edit of an object that already has a draft in the language) of the admin designs admin, admin3 and admin4 (and admin4l, which uses admin4's): "New draft" creates the draft and opens its editor. The page posted the hidden field DoNotEditAfterNewDraft (since 2004), so content/edit created the draft and redirected back to the same page, which looked as if the button did nothing. Not caused by the Content Security Policy: the page has no script, and it behaved the same under the report-only policy. content/edit still honors the field, so a design that wants the old behavior can post it again.

Tests

  • AdminEditConflictNewDraftTest (no database): no admin design posts DoNotEditAfterNewDraft beside "New draft"; the kernel still honors it.

18 October 2026: expui's test page under an enforced Content Security Policy

Updated

  • Required version: expui ~1.0.0.12. exp::sticky's scrollToStart focuses the first text field without scrolling again (preventScroll): on admin4 and admin4l, whose fixed header sets scroll-padding-top, the focus scrolled the field to the middle of the window and undid the scroll to the start (its expui/test case failed there, with or without a policy).
  • Its test page (/expui/test) reports the three tests that need inline scripts (a dialog with scripts: true, the classic onclick calendar icon by click and by keyboard) as "N/A under enforced CSP" with the reason, instead of failing, where the policy's script-src has no 'unsafe-inline' (admin4 and admin4l on the admin siteaccesses); they still run where the policy allows inline scripts (the classic design's report-only policy). The test view asks expContentSecurityPolicy::allowsInlineScripts().
  • Required version: exp_adminui ~1.0.0.45. Its edit-conflict page opens the new draft on "New draft", as the kernel's admin designs do (see above).

Updated

  • admin4 and admin4l: the Setup mega menu lists Preload sites (setup/preload) and Packages (package/list) in the group "Site and URLs" after RSS, and EI: Models (expai/models) and EI: Budgets (expai/budgets) next to EI: Summary in the group "AI", as the Setup menu does. menu.ini [Leftmenu_setup] MegaMenuLinks[] names preload, packages, expai_models and expai_budgets and keeps every other link: the default is eighteen. Each keeps its access check (setup/preload, package/list, expai/model_manage, expai/log); the EI links are left out where expai is not active.
  • The mega menu shows at most 18 chosen links (was 14); the debug report names a list of more than 18 keys.

Tests

  • AdminMegaMenuTest: the default choice names the new links in their places, has at most 18 keys, the panel shows the first eighteen chosen links, and the debug report names 19 keys.

18 October 2026: AdminNeo keeps its page and its policy in a persistent Velocity worker

On Exponential Velocity with persistent workers, /dse/adminneo lost AdminNeo's document head and its own Content-Security-Policy whenever the worker had served /dse/dashboard before, so the enforced administration policy blocked AdminNeo's inline scripts. The dashboard defined NEO_BASE_URL (AdminNeo embedded in the dashboard) and ended on its warning gate or a redirect before it asked for the worker to be replaced. PHP-FPM was not affected.

Fixed

  • Required version: sevenx_dse ~1.1.13. The dashboard asks for the worker to be replaced first, on every path; dse/adminneo does too, and a worker that already ran AdminNeo answers 503 with a page that reloads itself (no script). dse/adminneo removes the kernel's report-only policy header, so the page carries AdminNeo's policy only. The extension ships [ContentSecurityPolicy] ReportOnlyViews[]=dse/adminneo, so the kernel's policy cannot block AdminNeo on a server that keeps both headers.

Tests

  • SevenxDseAdminNeoWorkerStateTest (no database): a process prepared by the dashboard or one that ran AdminNeo is recognised, both views ask for the worker to be replaced before they define a constant or end the request, and the reload page has no script.

Added

  • admin4 and admin4l (prototype, opt-in): a mega menu for the Dashboard tab, switched on with menu.ini [TopAdminMenu] MegaMenuTabs[]=dashboard ([Topmenu_dashboard] MegaMenuTemplate=design:parts/my/mega_menu.tpl, MegaMenuSection=Leftmenu_my_mega). It offers seventeen links in five groups: Overview (the Dashboard), Your work (drafts, pending items, collaboration, bookmarks), Activity and audit (the audit trail, the AI events), Exponential Intelligence (its dashboard, Vector, Chains, Missing alt text, Log, Help) and My account (notification settings, e-mail preferences, Change password and Edit profile last). Every function of the Dashboard menu is there but the shop's preferred currency.
  • menu.ini [Leftmenu_my_mega]: a section that only sorts the tab's functions into groups. Its groups add [Leftmenu_my], [Leftmenu_audit] and [Leftmenu_design] (GroupSections[]), so each link keeps the address, policies, icon and description of the menu that defines it, and the links of an extension that is not active (expai, the help) are not shown. Icons and descriptions for the Dashboard menu's links, expai's among them, for expai's AI events ([Leftmenu_audit]) and Missing alt text ([Leftmenu_design]); read only where a menu is drawn as tiles or as a mega menu, so the left menus are unchanged.
  • GroupSections[<group>] may name several sections, separated by ;, added in that order (parts/ini_menu.tpl); the check of the settings (parts/a4_mega_menu_check.tpl) reads every one of them. A single section works as before.
  • [Topmenu_<tab>] MegaMenuLayout=wide: the panel sets its groups in up to four balanced columns of 15rem (four at 1440px, three at 960px, the 200% zoom of a 1920px screen, two below), so a long choice fits the window's height instead of scrolling inside the panel. Set for the Dashboard; the Setup and Users panels are unchanged.
  • The group names and descriptions in English, German, French, Spanish, Mexican Spanish, Ukrainian, Hebrew, Latin, Klingon and Arabic, and in the untranslated catalogue.

Tests

  • AdminMegaMenuTest: the Dashboard section and its added menus, the choice (at most 18, every function of the tab, every EI link, the audit before Exponential Intelligence, Change password and Edit profile last), icons, names and descriptions in every complete catalogue, links without access, disabled or of an inactive extension left out, the wide panel only for the Dashboard, and the check of a group that adds two sections.

18 October 2026: the administration in Arabic, and Hebrew for the newest strings

Added

  • Arabic administration translation ara-SA (Modern Standard Arabic, right to left): the whole kernel catalogue (17,307 messages) and the ezoe and ezjscore catalogues. Choosing Arabic as the interface language shows admin4l, admin4 and the classic admin in Arabic and right to left; every Arabic country falls back to it. Machine-assisted, to be reviewed by a native speaker. Contributed by ekked (pull request #284).
  • expTranslationQualityTest checks ara-SA too, with the reasons for every text that may stay identical to the English source in tests/tests/lib/ezi18n/translation-identical-allowlist.json.

Updated

  • Hebrew (heb-IL) translates the eight newest kernel messages of the setup wizard (pull request #285).
  • Arabic wording: the shop tab "Store" is the shop (it said "save"), "Login" in the user, session, audit and package lists and forms is the user name, "empty" is the adjective where a value is empty, and "State" in an address is the state or region.
  • doc/eng-us/languages.md lists Arabic among the complete translations and describes both right-to-left translations.
  • settings/i18n.ini no longer gives ara-SA and heb-IL the fallback untranslated: both have a complete translation of their own, and the Arabic countries fall back to ara-SA. A site that set either fallback itself keeps its setting.

Tests

  • expAdminTranslationsRegisteredTest (no database) counts Arabic among the complete translations (locale, switch, ranked group, documentation, shop tab) and checks the ezoe and ezjscore catalogues of Arabic and Hebrew for completeness. New: both right-to-left translations have a right-to-left locale named in their script, a fallback setting, and catalogues written in their script; "Login" takes the user-name sense in lists and forms.

18 October 2026: Spanish "tú", right-to-left admin4 and the header's tab icons

Merged pull requests #282, #286 and #287 and built on them.

Updated

  • Spanish (#282): the remaining "usted" messages use "tú", like the rest of the catalogue; five more found by the new check are changed too ("Vuelve a cargar el formulario", "Cambia tu contraseña", three "Selecciona" in esl-MX).
  • Right to left (#286): admin4's dashboard facts, the version and the debug bar's figures keep their order in an Arabic or Hebrew interface, the greeting is one translated message with the name isolated (Arabic writes "،"), and ara-SA's locale is regenerated from CLDR 48.
  • The header's tabs (#287) show an icon before their name (menu.ini [Topmenu_<tab>] Icon), and the header and dashboard use one set of symbols. design.ini [AdminDesignSettings] TopMenuIcons=disabled switches the tab icons off (default enabled).

Tests

  • expSpanishTuFormTest (no database): no finished esl-ES or esl-MX translation of the kernel, ezoe or ezjscore uses "usted", "Ud." or a formal imperative at a sentence's start; the conventions and forms are lists in the test.
  • AdminRightToLeftTest (no database): every administration pagelayout sets dir="rtl", every right-to-left catalogue translates the greeting (Arabic with its comma), the isolation rules stay, the Interface languages chain points the other way, every TextDirection=rtl locale names itself in a right-to-left script.
  • AdminIconsTest::testTheTabIconsCanBeSwitchedOff.

18 October 2026: oAuth admin in the Setup mega menu

Updated

  • admin4 and admin4l: the Setup mega menu lists oAuth admin (oauthadmin/list) in the group "Users and messages", after Audit, as the Setup menu does. menu.ini [Leftmenu_setup] MegaMenuLinks[] names oauth_list and keeps every other link: the default is nineteen.
  • The mega menu shows at most 19 chosen links (was 18); the debug report names a list of more than 19 keys.

Tests

  • AdminMegaMenuTest: the default choice names oauth_list after audit, has at most 19 keys, the panel shows the first nineteen chosen links, and the debug report names 20 keys.

18 October 2026: the current page's tab stays in the admin4 header row

Updated

  • admin4 and admin4l: when the header's main menu is too narrow for every tab, the tab of the current page is the last to move into "More": the other tabs go first, so the page's tab shows selected in the row as on every other page, and "More" is drawn as selected only when the row has no room even for that tab. On setup/menu at 1440px with many extension tabs, "Setup" showed as an orange "More" before.

Tests

  • AdminHeaderTest: the script keeps the current tab in the row while another tab can go.

18 October 2026: every admin4 mega menu opens under its own tab, and the header's row keeps its tabs

Updated

  • admin4 and admin4l: each mega menu panel (Setup, Users, Dashboard) opens under its own tab instead of in the middle of the window: its start edge at the tab's start edge (right to left, the right edges), moved back inside the window with 24px to spare when the tab is near its end. a4_mega_menu.js sets the panel's --a4-mega-start through the CSSOM when it opens, when its links have loaded and when the window changes size; without the script the panel stays centred.
  • The mega menu's button is a caret inside the tab's pill, in its end padding: a tab with a mega menu is 12px wider than one without (26px before), and a selected tab keeps its pill with the caret in it. The tab is still a link.
  • The tabs of the header's one-line row show their icons from 1600px; between 576 and 1599px their names alone, so at 1440px the row keeps Setup with the Help link and three mega menus (in "More" and the phone menu the icons stay).
  • The Dashboard's wide panel has a fixed width (four columns of 15rem, at most the window less 48px), so it is placed as reliably as the others.

Tests

  • AdminMegaMenuTest: the anchored panel (its custom property, the clamp, right to left, no style attribute), the caret in the pill and the row's icons from 1600px.

18 October 2026: help maintained as content (phase 1 of the help system plan)

Help topics, manuals, figures and the glossary as content of the site, after the plan help-system.md and the help style guide. Guide: help maintained as content.

Added

  • ./console exp:help:content (bin/php/exphelpcontent.php) with the actions install (classes, tree, roles), check [--fix], seed-glossary, seed-topics [--release], report and uninstall [--with-content]. -s <siteaccess> is required, --dry-run changes nothing, every step is idempotent (a second run prints "No change."). Nothing runs on its own: an installation creates the model by running install once.
  • The classes help_manual, help_section, help_concept, help_task, help_reference, help_troubleshooting, help_screenshot, help_glossary_entry (class group Help; text fields are Markdown in eztext), the sections help_public and help_internal, the object state group help_status (Draft, Review, Released), the folder tree below the Media root (exphelp.ini [ContentSettings] ParentNode), the roles "Help editor" and "Help release", and one content/read policy of Anonymous limited to help_public and Released.
  • The validator of the style guide (expHelpContentValidator): required fields per topic type, help_id form and uniqueness, one sentence of short description, exactly one numbered list of steps, alt text, glossary words, relations of manuals and chapters, review dates, and the section that the audience requires (--fix sets it on the object only).
  • share/help/seed-topics.json (the five templates of the style guide and a manual for editors) and share/help/seed-glossary.json.
  • exphelp/topic/<help_id>, exphelp/manual[/<remote_id>] (policy exphelp/read) and the context help JSON exphelp/context/<help_id>, exphelp/context/manual/<remote_id> with ETag (new policy function exphelp/context). Released topics for who may read them; Draft and Review only for who may edit them, as a preview. Anonymous gets exphelp/context only with [ContentSettings] AnonymousContextHelp=enabled (default disabled).
  • expHelpContextPageInterface: a context help provider implementing it may answer content/<help_id>, and the Help control of a page opens that topic (expHelpContentProvider with [ContentSettings] ContextHelpIds[]).

Tests

  • expHelpContentTest (no database, 17 cases): the model and roles, help_id parsing, the style guide's templates pass, each validator rule, sections from audiences, maps, the topic's Markdown and document, the registry accepts content/... only from a page provider and only with an address below /exphelp/, one Anonymous policy with both limitations, ETag handling, policy signatures, and the shipped seed files.

18 October 2026: e-books of help manuals (EPUB and PDF)

Added

  • ./console exp:help:content export -s <siteaccess> --manual=<remote_id> --format=epub|pdf [--output=] [--dry-run] and Download EPUB / Download PDF on a manual's page in the help (exphelp/manual/<remote_id>?format=). Only Released chapters and topics the user may read go into a book. EPUB 3 with a navigation document, the figures with alt text, xml:lang and EPUB Accessibility 1.1 metadata; PDF through the help's PDF writer where it is installed. Guide: help maintained as content.

Tests

  • expHelpContentExportTest (no database): headings below the topic title, well-formed XHTML without buttons, scripts or heading anchors, xml:lang, and no file outside the storage put into a book.

Added

  • expWeakPasswordList (kernel/classes/expweakpasswordlist.php): one list of well-known and common passwords and the check, with simple variants (case, digits and symbols at the ends, separators, letter swaps, common endings, a word typed twice) and patterns (repeats, keyboard, alphabet and digit runs). More words with site.ini [UserSettings] WeakPasswordList[] and WeakPasswordFile; WeakPasswordCheck=disabled switches it off for the forms (default enabled). See the upgrade note.
  • site.ini [UserSettings] ForgotPasswordKeyLifetime (default 3600; 0: a link works until it is used, as before).

Updated

  • The setup wizard refuses a well-known or common administrator password, also when it is long enough.
  • The kickstarter replaces a kickstart.ini administrator password that is shorter than MinPasswordLength (as it already did for an empty or well-known one) with a random one, printed once and written to var/log/initial-admin-password (mode 0600); exp:install uses the same list.
  • user/password, registration, the user edit form, exp:resetuserpassword and the web services (expaccount, expuser) refuse a well-known or common new password (the rule not_common of expPasswordPolicy). Existing passwords keep working; nobody is refused at sign-in.
  • "Forgot your password?" links expire after ForgotPasswordKeyLifetime: an expired link is refused with "This link has expired." and removed, the mail says how long the link works, and expired keys are removed on every visit of the page. The web services use the same lifetime (they had a fixed 24 hours). No database change: the table ezforgot_password already stores the time.

Tests

  • expWeakPasswordListTest and eZForgotPasswordExpiryTest (no database); eZSetupStepsTest, InstallPasswordTest and expPasswordPolicyTest cover the wizard, the installers, the account form and the console.

18 October 2026: clear help titles, a Standards section and the design pattern catalogue

Added

  • exphelp.ini [TitleSettings]: Titles[<path>], Summaries[<path>] and TitleRules[]=<pattern>;<template> (placeholders {title}, {name}, {fileversion}, {dirversion}) give a clear title and summary to help pages whose own is unclear; a front matter title or summary always wins. 47 inherited pages are retitled (doc/eng-us/standards/header.txt showed as "//", two README pages as "5.90", six changelogs as "CHANGELOG"), and the help has no duplicate titles any more. Help system.
  • A Standards section in the help (doc/eng-us/standards/, category standards, featured on the dashboard): which standards Exponential follows and its compliance with each, with evidence and known gaps (accessibility, security, privacy, the web platform, internationalization, PHP and Composer, coding standards, testing and CI, source control and releases, documentation and publishing, APIs and protocols, databases and SQL), and its architectural rules (kernel architecture, layering and naming, compatibility, releases and deployment). Standards.
  • php bin/php/exppatterncatalogue.php [--write|--check]: generates the design pattern catalogue (doc/eng-us/standards/patterns/) from the source code with PHP's tokenizer, no database: every class, interface, module view, policy function, fetch function, template operator, cronjob part, event hook and INI plugin point of the kernel, lib/ and the se7enxweb extensions, in 29 pattern categories, with file, description and extension point, one page per pattern and per area. The output is reproducible (sorted, no dates). The pages have the new help page type catalogue ([SearchSettings] TypeBoost[catalogue]=0.5, left out of the dashboard's audience cards), so the written pages rank first. Design pattern catalogue.

Tests

  • expHelpPageTitlesTest (no database): exact titles and summaries, title rules and their placeholders, front matter wins, pages without an entry keep their title, and every Titles[] entry of the shipped settings names a file.

18 October 2026: ten more complete administration translations

Added

  • Translations: complete administrations in Turkish (tur-TR, new), Polish (pol-PL), Italian (ita-IT), Portuguese (Brazil) (por-BR), Dutch (dut-NL), Swedish (swe-SE), Norwegian Bokmål (nor-NO), Danish (dan-DK), Czech (cze-CZ) and Croatian (cro-HR): all 17,763 strings of the kernel, and the catalogues of ezoe and ezjscore (ezjscore 1.8.5). Machine-assisted with a glossary per language; the older hand-made translations of these languages keep every message, and those with broken placeholders, tags, line breaks or English leftovers were redone. A review by a native speaker is welcome. Languages.
  • Translations: the Layouts tab and its pages in the ten languages (explayouts_ui 1.3.26).

Fixed

  • Translations: the header's Store tab names the shop in the ten languages (it said "save"), Turkish save buttons say "Kaydet" (they said "shop"), and Polish buttons and actions use the imperative ("Zapisz", "Otwórz") instead of the infinitive.

Tests

  • expTranslationQualityTest checks the ten languages too (placeholders, tags, entities, whitespace, line breaks, English left in), each identical text with its reason in translation-identical-allowlist.json.

18 October 2026: the MySQL export for MongoDB needs a password from the environment

Updated

  • bin/mongodb/export_mysql.sh has no default database password any more: MYSQL_PASSWORD must be set (from a secret store), or the script asks for it in a terminal; without it the script stops with a clear message and writes nothing. The password reaches mysql2ndjson.py in its environment instead of on the command line, so it is not in the process list; mysql2ndjson.py reads MYSQL_PASSWORD when --password is not given.

18 October 2026: the role and policy template operators are registered

Updated

  • has_role, has_role_id, has_any_role, has_role_by_user, has_role_by_user_id, has_policy and has_policy_by_user (role and policy template operators) are registered in lib/eztemplate/classes/eztemplateautoload.php; before, a template that used them got "unknown operator".

Tests

  • eZTemplateRoleOperatorRegistrationTest (no database): the autoload entry names the class's seven operators, the engine knows them, and templates using them render, interpreted and compiled, without an error.

19 October 2026: package fixes: the index address, escaped index data, version comparison, the wizard's tiles

Updated

  • The setup wizard's fallback for an empty [RepositorySettings] RemotePackagesIndexURL (package.ini) builds RemotePackagesIndexURLBase plus 6.0/6.0.15/, the folder the package server has, through the new ExponentialSDK::packageServerPath(). Before, it appended the release state (6.0/6.0.15stable/index.xml) and got 404. A release that is not stable keeps its state (6.0/6.0.16alpha1). Package server settings.
  • Security: the Site package step (setup/init/site_types.tpl) escapes every field of a remote index.xml entry it prints: description (plain text, line breaks kept; before printed as HTML), summary, version, url, the requirement names and versions and the repository address. eZStepSiteTypes::sanitizeRemotePackageAttributes() keeps a url or thumbnail_url only when it is an http:// or https:// address (no javascript:, data: or file://, so neither an image nor a download can use one) and skips an entry whose name is not letters, digits, ., - and _. The standard package view escapes the maintainer and changelog e-mail addresses in its mailto: links. The index.xml format.
  • eZPackageVersion::compareFull() reads a release with a state word, the form the 7x site packages carry: 6.0-15-stable is version 6.0, release 15, and meets min-version="6.0". Before, it was read as the prerelease 15-stable of 6.0.0, lower than 6.0. A stable word (stable, final, release, ga) is no prerelease, any other (rc1) sorts before the plain release; releases compare as numbers (9-stable < 15-stable). New: eZPackageVersion::splitReleaseState(), isStableWord(). Version numbers.
  • Package creation wizard (package/create, admin4 and classic): each wizard's description fills its tile again; it showed one character per line. The cause was the help module's frame rule for the bare .a4-help class (a grid with container-type: inline-size), which also matched the shared hint text under form fields and made it 0px wide in a flex row. The help module's rules name the help pages' frame, .a4-help-frame (the same fix also landed separately).
  • doc/eng-us/install/04-choosing-an-install-method.md: twelve site packages in the 6.0.15 index (with cjw_multisite), and the type element written as package.xml has it, <type value="site" />.

Tests

  • eZPackageVersionTest (releases with a state word, stable words after a legacy number), ExponentialSDKPackageServerPathTest, SetupRemoteIndexSanitizingTest and AdminHelpFrameClassScopeTest, all without a database.

19 October 2026: packages keep their install scripts on export, dependencies down the whole chain, more package servers, uninstalling files

Updated

  • eZPackage::domStructure() (used when a package is stored or exported) writes <settings> (the install scripts of a site package) and every attribute of a dependency item (min-version, max-version ...). Before, a site package re-exported from the repository lost both. exportToArchive() also takes every other file and folder at the top of the package (eZPackage::extraTopLevelEntries(), such as the cjw-*.json side files of cjw_multisite_democontent), not hidden ones or symbolic links. package.xml reference.
  • The setup wizard, the kickstarter and exp:install download a site package's requirements down the whole chain, each package once (eZStepSiteTypes::downloadDependantPackages()); before only the direct ones. The Create sites step reads the content languages of the whole chain (eZStepCreateSites::requirementsInInstallOrder()). The SitePackages step stays out of StepTable, now with the reason in place: it depends on eZSetupFunctionality(), which no longer exists. Dependencies and install order.
  • More package servers: the three setup tools read, after RemotePackagesIndexURL, the servers of the new package.ini [RepositorySettings] AdditionalRemotePackagesIndexURLs[] (empty) and of the lists named by RemotePackagesIndexSources[], by default ezupdate.ini;PackageServerSettings;Servers, the servers added on ezupdate's Package servers page. A package an earlier index lists is kept as it is; only http(s) addresses are read; a server that cannot be read is skipped. Empty RemotePackagesIndexSources[] to read only package.ini. Package server settings.
  • extension is a package type of package.ini [PackageSettings] TypeList, so the Type limitation of the package policies can name extension packages.
  • Uninstalling a package removes the files its ezfile items created, when they are unchanged, inside the installation and one of the package's own files (eZFilePackageHandler::uninstall(), install data installed_files); a file the install replaced or the site changed stays. ezsql and eziniaddon items say in the debug output why they undo nothing. Install items and handlers.

Tests

  • eZPackageSitePackageExportTest, eZFilePackageHandlerUninstallTest and more cases in SetupRemoteIndexSanitizingTest, all without a database.

19 October 2026: ezupdate 1.1.24

Updated

  • Required packages: se7enxweb/ezupdate ~1.1.24. Its Package servers page built the built-in server's fallback address (an empty RemotePackagesIndexURL) as RemotePackagesIndexURLBase plus 6.0/6.0.15stable, a folder the package server does not have; it now builds 6.0/6.0.15 through ExponentialSDK::packageServerPath() (eZUpdatePackageServers::releasePath(), the same rule on an older kernel). Package servers in ezupdate.

Tests

  • ezUpdatePackageServersReleasePathTest (no database; skipped without ezupdate 1.1.24).

19 October 2026: the 7x site packages on the package server corrected

Updated

  • Package server, 6.0.15 index: the four 7x site packages and cjw_multisite are listed with the version their package.xml states, 6.0-15-stable (the text eZPackage::getVersion() returns); they were listed as 1.0.0-0 and 6.0.15-0. The setup wizard compares the index version with an imported copy, so an imported copy now compares equal and is kept.
  • sevenx_multisite, sevenx_multisite_clean: the md5sum of logo.png in package.xml matches the file.
  • sevenx_multisite_clean, sevenx_multisite_democontent, sevenx_multisite_democontent_clean: documents/LICENCE names the package itself (it named the package it was copied from). The license is unchanged, GPL 2.0 or later.
  • The source repositories sevenx-ezpackage and cjw_multisite-ezpackage have a README and a LICENSE with the full GPL version 2 text; cjw_multisite-ezpackage has the packages/build.sh of the 7x repository; both build scripts leave backup copies (*.bak, *.bak.*) out of every package.
  • The package server repository has a CONTRIBUTING.md on submitting a site package by pull request (Contribute your site package).

Removed

  • sevenx_multisite_democontent: 113 unused backup copies of object files (object-media-o-<n>.xml.bak.*). Nothing read them; the archive is 355 KB smaller.

Tests

  • The rebuilt archives were imported into a throwaway installation (every md5sum checked, no backup copies, LICENCE names the package) and installed on SQLite with exp:install from a local copy of the package server with the new index: sevenx_multisite, sevenx_multisite_clean and cjw_multisite (with ger-DE) each INSTALLED with 0 errors and 0 warnings.

19 October 2026: the design extension wizard writes a theme that installs and registers its operators

Updated

  • The composer.json of every Setup > RAD extension wizard carries extra.ezpublish-legacy-extension-name (next to installer-name), the key the legacy extension installer reads. Before, a theme named my_theme installed into extension/my-theme, after the dashed package name.
  • The design extension wizard's Template operators part writes autoloads/eztemplateautoload.php, so the example operator is registered once the extension is active. Before, only the class and the ExtensionAutoloadPath[] line were written, and a template using the operator got "unknown operator".
  • The design extension wizard writes FrontendCSSFileList[] and FrontendJavaScriptList[] instead of the deprecated CSSFileList[] and JavaScriptList[]. The generated page_head.tpl and pagelayout.tpl load both lists, as the shipped designs do. The page head, title and script loading were corrected earlier the same week (the generated templates load their stylesheets and scripts with ezcss_load and ezscript_load).
  • The designer help (The design wizard's parts) says what is fixed from 6.0.15 on and keeps the hand corrections for themes generated by older wizards.

Tests

  • expDesignExtensionWizardTest (no database): the generated composer.json installs into a folder named after the extension, by the installer's own rule; the operator autoload file is written, and with the extension's autoloads directory handed to the template engine the operator is registered and works; design.ini.append.php has no deprecated keys. expExtensionWizardBaseTest checks the new extra key.

19 October 2026: the RAD template wizard's field help is text again, and wizards open without errors

Updated

  • Setup > RAD > Template extension (/setup/templateoperator) printed template code as the help of the Operators, Functions, Fetch functions and Fetch aliases fields and of the "Takes input" switch, in admin and admin4: a } inside the quoted text of an i18n tag closed the tag early. Inside a quoted string the closing brace is written \}, as the URL wildcard page already does; the translations keep their source texts.
  • The RAD wizards (datatype, workflow event, template, design, content, handler, module, settings extension and kernel override) show their problems once the form has been sent, not on a first visit. Writing and the archive still check every problem.

Tests

  • AdminI18nStringTagsTest (no database): every {'text'|i18n( 'context' )} tag of the setup pages of every admin design renders, interpreted and compiled, as its text, with no |i18n or quote left over and its braces kept.
  • expRadWizardFirstVisitProblemsTest (no database): the ten wizard views hide the problems of a first visit, and every wizard form posts Submitted.

19 October 2026: more corrections in what the RAD wizards write, a view count without a warning, and no blank autoload entry

Updated

  • Template extension wizard: the README and doc/examples.php call an operator with its values by position ({$value|op( 3, 'text' )}); name=value in an operator's brackets is refused by the template parser. Template functions keep name=value. The generated operatorTemplateHints() no longer lists input-as-parameter twice.
  • The READMEs of the template, datatype and settings extension wizards give php bin/php/ezpgenerateautoloads.php --extension; --extension takes no value, and --extension=<name> was refused.
  • Datatype wizard: the templates go to design/standard/templates/, where DesignExtensions[] finds them, not to templates/ in the extension root. The edit and collect templates post _<type>_<column>_ for the storage column chosen (the class carries it as VALUE_VARIABLE, and objectAttributeContent() reads that column), not always _data_text_. DataTypeGroup[], which nothing reads, is no longer written, and the form no longer asks for a group.
  • Module wizard: the templates go to design/standard/templates/<module>/, not design/<extension>/, which is in no design chain. The view script casts only number parameters (ID, NodeID, Offset...) to int; text parameters stay text and are washed by the template.
  • Design extension wizard: the README and design.ini.append.php say that a siteaccess has to name the design in SiteDesign or AdditionalSiteDesignList[]; DesignExtensions[] alone does not make a site use it.
  • Handler wizard: the mail transport stub logs an error and returns false, with a TODO, instead of returning true and sending nothing.
  • eZContentObjectTreeNode::viewCount() returns 0 for a node without a row in ezview_counter, without the warning "Trying to access array offset on null".
  • The autoload generator no longer takes a method called class or enum (function class(), $o->class()) for a declaration. expObjectServices::class() in extension expservices gave an entry with the blank class name ' ' in var/autoload/ezp_extension.php.
  • RAD tools lists the corrections to make by hand in output from an older wizard.

Tests

  • expRadWizardGeneratedCodeFaultsTest (no database): one test per fault above, on the generated files.
  • eZContentObjectTreeNodeViewCountTest (no database): 0 and no warning without a counter row, the count with one.
  • eZAutoloadGeneratorTest: a class with methods called class and enum gives one entry.
  • expRadWizardListsTest and expModuleWizardTest follow the operator call and the template path.

19 October 2026: the help index looks at its files once every few seconds

Updated

  • The help index looks at the size and time of every listed help file at most once every [IndexSettings] StatFilesInterval seconds (exphelp.ini, default 5), for every request and process: the answer is kept in signature.php in the help cache directory. Before, each help request looked at all of them (6,195 files on a full installation), about 25 ms behind PHP-FPM and half a second under Velocity, whose file layer answers each look. An edited page now shows within the interval; a page added or removed (the git index) and ./console exp:help index are seen at once. StatFilesInterval=0 keeps the old behavior.
  • Together with Exponential Velocity's change of the same day (a file that cannot need the include transform is not tokenized: the help index, a 14 MB data file, cost each worker 3 to 5 seconds on its first help request after it changed), this removes the two costs that made the help views on Velocity take 1 to 5 seconds where PHP-FPM took about half a second.

Tests

  • expHelpDocumentIndexTest (no database): HX-08 checks that a look at the files is reused within the interval and never after it, that StatFilesInterval=0 sees an edit at once, that a rebuild always looks again and leaves its look for the others, and that a commit is seen at once.

19 October 2026: visitors can register on a new installation, and the top folder carries the current welcome text

Updated

  • The base data (share/db_data.dba, which the setup wizard and the kickstarter load, and the clean data kernel/sql/common/cleandata.sql and kernel/sql/sqlite/cleandata.sql) gives the Anonymous role the policy User / Register (policy 336, no limitation). A new installation takes user/register out of PolicyOmitList[] (PolicyOmitListExclude[]=user/register), and without the policy its visitors could not register and the login pages showed no register link. To close registration, remove the policy from the Anonymous role. An upgraded site is not changed. user/activate, user/success and user/forgotpassword stay in PolicyOmitList[].
  • The top folder (node 2) of a new installation carries the current Welcome to Exponential text in its short description and description, with the links written as addresses (href), since a new installation has none of the link rows of the site the text was written on.
  • share/db_data.dba gives the Editor role the four policies the clean data files already gave it: uiswitch / language, uiswitch / design, oauth / authorize and exphelp / read (policies 332 to 335, no limitations). An installation through the setup wizard or the kickstarter, which load the .dba, gave Editors none of them: no interface language or design switch, no OAuth authorization and no help pages. The roles, policies, limitations, their values and the role assignments are now the same rows in all three files.
  • The search index of the base data (ezsearch_word, ezsearch_object_word_link, in all three files) indexes the top folder's current text, so a new installation finds it without a reindex. The rows were still those of the first eZ plain site text. They were generated by the kernel's own eZSearchEngine (remove, then add the object) in a throwaway SQLite database loaded from the .dba, and are word for word the index that a development installation's live database holds for the same text: 1,531 rows for the top folder, 625 words, each word's object_count the number of objects linking it. The rows of the other objects are unchanged; words used only by the old text are gone.
  • Every table now holds the same rows in share/db_data.dba and both clean data files:
    • share/db_data.dba seeds the content language eng-US "English (American)" (id 2), as the clean data files did. The setup wizard added it itself when the table was empty; it looks the language up first (eZContentLanguage::fetchByLocale(), and addLanguage() does too), so a seeded row is used and never doubled, and id 2 is the id every language mask of the base data is built on.
    • The site meta data (object 54, ezinisetting) in both clean data files names the Exponential Foundation and the Exponential keywords, as the .dba did, instead of eZ Systems.
    • Both clean data files say ezpublish-version 6.0.15stable (they said 6.0.10stable and 6.0.6stable), the value the .dba already had, the 6.0.15 database updates write and the upgrade checks (ExponentialSDK::version(), the LoveStack check) expect for this release.

Tests

  • expBaseDataSeedTest (no database): the Anonymous role has user/register without a limitation in all three files, policy ids are unique, and the top folder's two texts are the same in all three, well-formed, with every link an address. BD-03: every table of the .dba holds the same rows in both clean data files, which have no table the .dba lacks; the Editor role has the four policies in the .dba; the base data says 6.0.15stable and has the one language eng-US, id 2. BD-04: every word's object_count matches its links, the top folder's placements run without a gap with the previous and next words of each slice of 1,000 that eZSearchEngine writes, and each indexed word comes from its text.

19 October 2026: demo mode for public demos (EXP_DEMO_MODE=1)

Added

  • Demo mode: the environment variable EXP_DEMO_MODE=1 (only the exact value 1; never an INI setting or a request parameter) marks an installation as a public demo whose administrator sign-in is published. expDemoMode (kernel/classes/expdemomode.php) reads it on every call, so persistent Velocity and FrankenPHP workers follow the environment they have. See the upgrade note.
  • The template operator exp_demo_mode() (expDemoModeOperator) and the banner design/{admin,admin4}/templates/parts/exp_demo_mode_banner.tpl, shown on every page of admin4, admin4l and the classic admin while demo mode is on (the shared component .a4-notice.is-warn, outside the cache-blocks).
  • The audit event system.demo.mode (warning, always on) and a demo/mode line in var/log/security.log, once per process that starts with demo mode on.
  • Help pages: Demo mode, Demo mode for hosts and Demo mode for developers.

Updated

  • In demo mode the password of an administrator (unlimited access to every module and function) cannot be changed or reset by any path: user/password, "Forgot your password?" (no key and no mail; the same answer for every address; an old link is refused), the user account in content/edit, exp:resetuserpassword (also with --allow-root-user), the web services (expaccount.reset, expaccount.changePassword, expaccount.forgotRequest, expuser.setPassword: HTTP 403), the operations user/password and user/forgotpassword, and as the last line eZUser::store(), which keeps an administrator's stored password. Each refusal shows "This installation is a public demo: the passwords of administrators cannot be changed or reset." and is recorded with the reason demo_mode. Other accounts keep every rule.
  • In demo mode the installers (setup wizard, kickstarter, exp:install and the demo installers built on them) accept a well-known administrator password of any length (publish, publishing), so a demo installs the password it publishes; an empty one is still refused, and every other account keeps MinPasswordLength. The setup wizard's administrator page says that demo mode is on. The installation sets the administrator password inside expDemoMode::installing() and expDemoMode::settingInstallationAdministratorPassword(), the only place eZUser::validatePassword() accepts a short one.
  • Installation, security hardening (13.6 "Demo mode must be off" and the go-live checklist), the default administrator password and the demo pages of the help describe demo mode.

Tests

  • expDemoModeTest (no database): the value rules, the order getenv then $_SERVER and never the request, no caching, who is an administrator, the store guard's decision, the installing() scope, the installers' relaxation and its limits, the once-per-process record, the operator, the banner in the three pagelayouts, every password path, the audit catalogue and the catalogues. expAuditRecordTest counts the new event.

20 October 2026: demo mode across the documentation, and a test for the password page overrides

Added

Updated

  • Demo mode is described where readers need it: the installation overview and chapters 2, 4 and 8 to 12, upgrading, cronjobs, roles, the audit and logs, the REST API and the services, the password and account pages, the log viewer, the environment settings, the glossaries, and the hosting pages for Plesk, cPanel, LiteSpeed, Apache, nginx, PHP-FPM pools, Velocity, FrankenPHP, containers, SQLite, cron, provisioning, the checklists and the security pages, plus the start pages for administrators, hosts, developers, press, advocates, trainers, partners and sales. Each links to the upgrade note, which lists them all.
  • config.php-RECOMMENDED and config.env.php.example say that demo mode is not a constant of theirs.

20 October 2026: the documentation moves to doc/eng-us/, and the help is read in the reader's language

Renamed

  • The documentation moves from doc/ to doc/eng-us/, every file with its relative path and its history (git mv), so each language of the documentation has a directory of its own (doc/<language>/). doc/changelogs/ stays: it is not translated. A short doc/README.md lists the language directories for readers of the repository.

Updated

  • Every reference to the moved files follows them: the root files' links, the templates' guide and GitHub links, the settings (exphelp.ini path rules, titles and license rules, the Doc= entries of the other INI files), code comments, command line scripts, tests, the CI workflows, composer.json, the translations whose source names a guide, and the file manifest. The links between the guides and the changelogs are rewritten for their new places. The released changelogs and the history pages keep the paths of their time.
  • The help center reads the interface language (site.ini [RegionalSettings] Locale, set by the language switch) and shows each page in it: exphelp.ini [DocLanguages] Map[] maps eng-US and eng-GB to en/us and ger-DE to de, and a locale without an entry uses its language's (ger-AT reads German). A page that is not translated yet is shown in English with a translated note. Page ids are the paths below doc/eng-us/ in every language, so every help address of before the move keeps working and shows the same page in each language; an address that names a file (/exphelp/viewer/en/us/<id>, a translated path) is redirected to the id. Translated file names are listed in doc/<language>/.paths.json. Each language has its own index, search, navigation and rendered pages (lang-<language>/ in the help cache); the search finds the reader's language and the English pages not translated yet. Links inside a page are resolved against the file it was read from, as on GitHub.
  • ./console exp:help index builds every language's index and search and reports how much of each is translated; --language=<directory> limits it, and makes search, show, check and pdf read that language.

Added

  • expHelpLanguage, the languages of the documentation and the path maps; exphelp.ini [DocLanguages] (Default, Directories[], Map[], Names[]).
  • Translated documentation: what moved, what to change in your own links, settings and scripts, and how a translator adds a language.
  • Reading the help in your language, a help page.

Tests

  • expHelpTranslatedDocumentationTest (no database): TD-01 no tracked file below doc/ outside a language directory, doc/changelogs/ and doc/README.md; TD-02 no tracked text file names a path of before the move (the history excepted); TD-03 to TD-05 the locale map, the shipped settings and the path map; TD-06 to TD-09 the index per language with its fallback, one id per page in every language, links, the search per language and the classification of a translation. The help tests follow the new paths.

Operator note

  • Links or scripts of your own that name a documentation file need en/us/ after doc/ (doc/eng-us/INSTALL.md); settings overrides that name one in a path rule need it too. Help addresses and page ids do not change. Run ./console exp:help index --rebuild once after the deploy.

20 October 2026: the project files (README, security policy, contributing) in the help

Added

  • The project files of the repository are help pages: the tracked root *.md (README.md, SECURITY.md, CONTRIBUTING.md, COPYRIGHT.md, LICENSE.md) and the README.md of bin/, design/<design>/ and kernel/. Their ids are project/<path in the repository> (/exphelp/viewer/project/README.md; /exphelp/viewer/README.md redirects there). They have the category Project files and the type Project file (dashboard, search filters), a place at the top of the page tree of the contents page and every help page (no folder), audiences by [ProjectFiles] AudienceRules[] (developers and administrators at least), and the title of their first heading.
  • expHelpProjectFiles and exphelp.ini [ProjectFiles] (Enabled, Include[], Exclude[], Category, Type, Audiences[], AudienceRules[], LicenseName, LicensePage). Test fixtures and the copies of other projects (a bundled JavaScript library's README) are excluded by default.
  • Project files, a help page; the section "Project files" of The help system.

Updated

  • Links of the documentation to a project file (../../../../README.md) open it in the help instead of leaving the link out, and the project files' links into doc/eng-us/ open the help pages; both are resolved against the file, as on GitHub.
  • The project files are English only, like the changelogs: a reader of another language sees them with the note that the page is not translated. The license notice under them names the license of the software (GPL 2.0 or later), not the documentation license.

Security

  • Only files git tracks can be project files: the list comes from the git index, git ls-files or the release manifest, never from the directory, so the private notes an installation keeps in its root without committing them are never shown, searched, linked or redirected to, whatever a setting asks for. A tracked file in a place .gitignore or .git/info/exclude ignores is refused, and so are anything but Markdown and every path below doc/, extension/, a tests/ directory, var/, vendor/, settings/override/, settings/siteaccess/ and node_modules/, and every hidden file or directory (.github/ included). These rules are not settings.

Tests

  • expHelpProjectFilesTest (no database; fixtures are real git repositories): PF-01 the patterns; PF-02 the names and the paths that are never project files; PF-03 untracked root notes are never listed, indexed, searched or linked, from the git index, git ls-files and the manifest alike, even when Include[] names them; PF-04 ignored locations; PF-05 to PF-09 ids, classification, links both ways, the language fallback and the navigation; PF-10 this installation's tracked root files and the shipped settings; PF-11 the license; PF-12 Enabled=disabled; PF-13 the translations.

Operator note

  • Run ./console exp:help index --rebuild once after the deploy.

Updated

  • Contributing guidelines state the copyright assignment (CLA): CONTRIBUTING.md has a section Copyright assignment (CLA). 7x and the Exponential Foundation require a signed Contributor Licensing Agreement for every code contribution to the core kernel, whether it is sent as a pull request, a patch, an issue or by mail, before it is merged; users' own custom extensions are not affected. The README, the pull request template (new, .github/PULL_REQUEST_TEMPLATE.md, with a CLA checkbox), the contribution and pull request pages of the help, the glossaries and the FAQs link to it.
  • The contributing guidelines state plainly that a signed CLA gives control of a contribution to 7x: the section is now called "Copyright and control of your contribution (CLA)" and opens with what a contributor gives up (a perpetual, worldwide, irrevocable, royalty-free license, patents included, under which 7x may use, change, sublicense, relicense and distribute the contribution without asking or paying, and which cannot be withdrawn), with only a short note that formally the CLA is a license (section 4 of the agreement). The FAQ answers "Do I give up control of my code?" with yes. Every page that mentions the CLA says the same.
  • The "Code Reviews" section of CONTRIBUTING.md warns that 7x reviews in many more rounds than usual, sometimes hundreds of revisions, and says how to get through faster (small focused pull requests, the coding standards, tests, answers in the pull request, a branch rebased on main). The pull request template and the pages that walk through a pull request link to it. Its link to the missing CODE_OF_CONDUCT.md now points at the project's conduct page.
  • Contributing guidelines state that kernel contributions are a copyright assignment to 7x: by signing the CLA and submitting a contribution to the core kernel, the contributor assigns (transfers) its copyright to 7x, which becomes the copyright owner and may use, change, relicense and distribute it in any way; the contribution cannot be withdrawn and no conditions can be set. Users' own custom extensions are not affected. The section is called "Copyright assignment (CLA)" again (#copyright-assignment-cla; the anchor of the previous name still works), the FAQ answers "Do I give away my copyright?" with yes, and every page that mentions the CLA says the same. The agreement form on the CLA page is being updated to the assignment.
  • Contributors squash and rebase their pull requests: 7x requires a pull request to be squashed into logical commits (usually one) and rebased onto main while it is open (git fetch origin, git rebase -i origin/main, git push --force-with-lease to the pull request branch only), so the merge is clean. CONTRIBUTING.md ("Code Reviews") states it with the commands, the pull request template has a checkbox for it, and the pages that said a contributor's commits are kept as written with fixes on top now say that main's history is never rewritten after the merge and later fixes come as new pull requests (Continuous integration, the source control standard, the pull request pages and the press, partner, advocate and leadership pages).
  • CONTRIBUTING.md is titled "Exponential Contributors Guidelines".

20 October 2026: help notices and client help_ids checked, the help standards, the help classes as a package

The help system plan and the help style guide (pull request #288) compared with what the help does, item by item: help maintained as content, compared with the specifications.

Added

  • ./console exp:help:content check reports the notice levels of the style guide (IEC/IEEE 82079-1): an Attention or Data protection notice outside the step of a task (notice.position), after the action in its step (notice.before-action), a second notice in one step (notice.per-step), a notice of more than three sentences (notice.too-long).
  • The same command reports the help_ids that clients call and no topic has (client.help-id-unknown), or whose topic is not Released (client.help-id-not-released), from exphelp.ini [ContentSettings] ContextHelpIds[] and the new ClientHelpIds[].
  • ./console exp:help:content package -s <siteaccess> [--output=<dir>] [--version=]: the eight help classes as the content class package exp_help_classes (directory and .ezpkg); verify-package --file=<.ezpkg or directory> compares a package with the classes of the release without a database (expHelpContentPackage). Build and handoff: the help classes as a package.
  • Help standards: what the help takes from DITA 1.3, IEC/IEEE 82079-1:2019, ISO/IEC/IEEE 26514:2022, ISO 3864-2:2016, EPUB Accessibility 1.1 and WCAG 2.2 AA, what it does differently and how far it goes (no conformance claimed). Pull request #288 announced the page; it was not in the merge.

Updated

  • The help viewer shows > **Attention:**, > **Data protection:** and :::data-protection as callouts with that title; Data protection has its own icon.

Tests

  • expHelpContentTest (notices, client help_ids), expHelpMarkdownRendererTest (the notice callouts), expHelpContentPackageTest (the package's definition, a package and an .ezpkg compared with the schema), no database.

20 October 2026: the documentation of the active extensions in the help center

Added

  • The help center shows the documentation of every active extension, found when it looks (exphelp.ini [IndexSettings] ActiveExtensionDocs=enabled): the files of extension/<name>/doc/ that the extension's repository tracks (or its share/filelist.md5 lists) and its README, CHANGELOG, LICENSE, INSTALL, USAGE and COPYING at the top of the extension (ExtensionTopFiles[]), nothing else: no PHP or other source file, no settings, no untracked file, nothing of an inactive extension, and a text file without a suffix that starts like a program is left out. The pages have the ids ext/<name>/<path>, the type "Extension documentation" and the category and navigation folder Extensions, and are read, searched and printed like every help page.
  • exphelp/extensions: the extensions with documentation, and exphelp/extensions/<name>, one extension's index with its pages by folder and a search limited to them. The search takes (extension)/<name> (view parameter, form and JSON). The contents page links to the index; the breadcrumb of an extension's page goes through it.
  • expHelpDocumentIndex::extensions(), expHelpAllowlist::extensions() and isExtensionTopFile().

Updated

  • ExtensionDocDirs[] now adds extensions whose documentation is shown whether they are active or not. An extension's page has no "Edit on GitHub" link (EditBaseURL names the installation's repository).
  • exphelp.ini [ContentSettings] carries a commented ClientHelpIds[] example.

Tests

  • expHelpExtensionDocsTest (no database): a fixture installation with extensions in their own repositories and in the installation's, active and inactive; the pages shown, the files refused (source, settings, scripts without a suffix, untracked, inactive, traversal), ids both ways, the overview, the per-extension list, the search filter, the viewer's breadcrumb and edit link. expHelpSearchViewParametersTest covers (extension).

Operator note

  • After the deploy, run ./console exp:help index --rebuild once. An extension's documentation shows only while it is active and only when its files are tracked by its repository or listed in its share/filelist.md5. expai 0.0.1.14 moves its own help pages here (expai/help redirects); release it after this.

20 October 2026: the user manual, one question per page

The user manual of the administration with the Admin 4 design: more than fifty pages, each answering one question with who may do it (the role or policy), numbered steps, what can go wrong and the deeper pages to read next.

Added

  • The admin tabs: the Store dashboard, order statuses, archiving orders, VAT, discounts, currencies and the products overview; what the Design tab holds; the Setup overview and why a tile is missing; who changed what in the Audit console; My account, drafts, pending items and notification settings; switching the administration design; turning on two-step sign-in, and what an administrator does when a user lost their phone.
  • The daily tasks: translating and finding what is not translated, sending for approval and approving, following content, finding content, the trash, the order of sub items, changing the class and the owner, tags and the tag tree, images, links, tables and embeds in the Online Editor, the SEO title and description, the sitemap, form submissions, object states, a page with Layouts, a newsletter and the AI assistant (which points to the extension's own manual).
  • The tasks of administrators and site managers: edit rights on one section, disabling and unlocking accounts, which cache to clear, broken links, URL aliases and wildcards, adding a content language, the log viewer, the content checks, extensions and system information, RSS export and import, and API access for another application.
  • Twenty-five Admin 4 screenshots (light mode, WebP), taken with a throwaway account; names, addresses and keys are replaced by examples.
  • The help category User manual (manual, exphelp.ini [CategorySettings], translated), first after Getting started; the folder's audience is editors, administrators and users.
  • Pages that already answered a question of the list are linked from the manual's index instead of written again (adding a product, changing your own and another user's password).

20 October 2026: help maintained as content leaves Exponential as DITA 1.3

Guide: help maintained as content.

Added

  • ./console exp:help:content dita -s <siteaccess> [--manual=<remote_id>] [--output=<dir>] [--dry-run] [--json] (also export --format=dita, the plan's name): the Released manuals as DITA 1.3 bookmaps with a DITAVAL file of their audiences, the Released topics as concept, task, reference and troubleshooting topics named by their help_id, the chapters, the figures, the glossary as glossentry topics, and a map of the whole topic pool. Only changed files are written and files of an earlier export that are gone are removed, so the directory can be a git repository of the help's history; a second run prints "No change.". The command checks the result: well-formed XML, the DITA 1.3 DOCTYPE of each root element, unique topic ids, and every reference resolves (exit 1 otherwise).
  • Download DITA on a manual's page in the help and Download all as DITA on the list of manuals (exphelp/manual[/<remote_id>]?format=dita): the same export of what the user may read, as a zip file, checked before it is sent.
  • expHelpContentDita (the mapping, the check, writing and zip) and expHelpDitaMarkup (the help's Markdown HTML as DITA elements: bold UI labels as uicontrol, callouts as typed notes, tables as simpletable, one numbered list of steps as steps with cmd and info).

Tests

  • expHelpContentDitaTest (no database, HCD-01 to HCD-11): a fixed set of topics, a chapter, a manual, a figure and glossary entries through the export; the files, DOCTYPEs and references, each topic type, ids and filter attributes, Released only, the bookmap and DITAVAL, informal steps, figures, the glossary and pool map, one manual, the check finding broken files, an idempotent write that keeps a .git directory, and the markup. The export of the test set and of the shipped seed topics is valid against the OASIS DITA 1.3 DTDs (xmllint --valid with the DITA-OT catalog).

20 October 2026: the e-books of help manuals are stored as content and rebuilt on release

Guide: help maintained as content.

Added

  • ./console exp:help:content ebooks -s <siteaccess> [--manual=<remote_id>] [--dry-run] [--json]: the EPUB and PDF of every Released manual stored as an object of the class file below E-books (help-ebooks), remote_id help-ebook-<manual remote_id>-<format>, state Released, section help_public for a public manual and help_internal otherwise (on the object only), so the help's policies decide who may download it. A new build is a new version of the object; the same book changes nothing ("No change." on a second run).
  • A release rebuilds the books: setting an object of the help to Released builds the stored e-books of every Released manual that contains it again (expHelpContentEbooks::onStateAssign(), called from eZContentOperationCollection::updateObjectState(); exphelp.ini [ContentSettings] EbooksOnRelease, default enabled). A book that cannot be made is logged; the release stands.
  • The book of a public manual carries only public objects, whoever releases (expHelpContentEditionProvider).
  • The manual's page in the help lists its stored e-books (file, version, size, date) for who may read them.

Updated

  • The entries of an EPUB are dated by the book's content (the newest manual or topic change), and so is its dcterms:modified: the same content gives the same file.

Tests

  • expHelpContentEbooksTest (no database, HCB-01 to HCB-05): the public and internal editions, the manuals a release rebuilds, remote_ids, the EPUB dated by its content, and the release hook. In a sandbox copy of a site: a topic set to Review leaves the books, set to Released stores a new version of each.

20 October 2026: help topics overdue for review on the help's dashboard

Guide: help maintained as content.

Added

  • Help topics overdue for review on the help's start page (exphelp/dashboard): the topics whose review date has passed, or that have been in Review longer than exphelp.ini [ContentSettings] ReviewMaxDays (default 14) days without a change, the longest overdue first, with their state, the reason and links to the topic and its object. Shown only to who can act on a topic (edit it, or assign it another state of help_status): help editors see the topics in Review, release managers also the Released ones; nothing is shown while no topic is overdue. At most ReviewListLimit (default 10) are listed. Shared admin components only, light and dark mode.
  • ./console exp:help:content review -s <siteaccess> [--json]: every overdue topic.
  • expHelpContentReview (the rule, without a database in overdue()).

Tests

  • expHelpContentReviewTest (no database, HCR-01 to HCR-05): a past review date in every state, too long in Review and the setting, a topic with both reasons once, the settings, and the dashboard part (shared components, no style, identical in admin and admin4). In a sandbox copy of a site, in a browser (light 1440 at scale 1, dark 960 at scale 2): an administrator sees both overdue topics in a dark card in dark mode, a help editor only the one in Review, a help editor with Help release both; no horizontal overflow, no serious accessibility finding.

20 October 2026: help maintained as content in every language of the help

Updated

  • The interface strings of the DITA download, the stored e-books, the overdue-review list and the headings of task and troubleshooting topics (Before you begin, Steps, Result, What to do next, Condition, Cause, Remedy) are in the context kernel/exphelp of all 24 translation catalogues that have it: translated in 22 languages, the source in eng-US, unfinished in untranslated.
  • The overdue-review list names a topic's state with the name of its help_status state (in the interface language where the site wrote one), not with an interface string: "Released" is already a release date in several of the help's translations.

Tests

  • expHelpContentTranslationsTest (no database, HCL-01 and HCL-02): every string in each catalogue with the context, translated, placeholders kept; the templates and topic headings use exactly these strings.

20 October 2026: the help's project files without .github, tests and extensions, at the top of the page tree

Updated

  • The help's project files leave out .github/ (the pull request template), every test README and everything below extension/ (the extensions' documentation is the category Extensions). [ProjectFiles] Include[] no longer names .github/*.md, .github/**/*.md, tests/README.md, tests/**/README.md and extension/*/tests/**/README.md, and hidden files and directories, tests/ directories anywhere and extension/ are now rules that no setting can change.
  • The project files are pages at the top of the page tree of /exphelp/contents and of every help page, next to the main folders, instead of a "Project files" folder; a project file's breadcrumb is Help > its title, and a title that is only "README" gets its directory in front. The category Project files stays for the search and the dashboard.

Tests

  • expHelpProjectFilesTest: PF-02, PF-05 and the new PF-14 (.github/, tests and extension/ are never listed, even when Include[] names them), PF-09 (no folder of the project files, titles, breadcrumb), PF-10 (this installation lists none of them).

20 October 2026: the order of the top-level pages of the help's page tree

Added

  • exphelp.ini [TreeSettings] PageOrder[<page id>]=<integer> sets the place of a page in the help's page tree, the folder lists and the category lists, and wins over the page's front matter order; the project files and plain text pages, which cannot carry front matter, can be placed too. The key is the page id, the same in every language. Exponential ships the order of the top level: the README, installing, languages, branch history, release history, then contributing, copyright, the documentation license, the GFDL, the GPL, the security policy, and the glossary last. branch-overview.md and README-LICENSE.md no longer carry an order of their own. Guide: help system, "Order of the pages".

Tests

  • expHelpPageOrderTest (no database, HO-01 to HO-04): an entry wins over the front matter order and places a page without front matter, other pages keep theirs, invalid entries are left out, and the shipped exphelp.ini lists the same order as the built-in default, for pages that exist.

20 October 2026: the documentation's language directories are named by the locale codes

A language directory of the documentation is now the Exponential locale code of its language in lower case, so the interface, the content languages and the documentation name a language the same way and a new translation needs no mapping. Upgrade notes: Translated documentation, "Upgrading a site that links the old directories".

Renamed

  • en/us/ to eng-us/ (the original, eng-US) and de/ to ger-de/ (German, ger-DE) below doc/, with .paths.json and .dirnames.json, as pure renames (git mv) in a commit of their own; git log --follow reaches the history before it. doc/changelogs/ and doc/README.md stay where they are. Page ids do not change, so help addresses, [TreeSettings] PageOrder[], context help and bookmarks keep working.
  • The per-language help caches: var/<site>/cache/exphelp/lang-<directory>/ (lang-ger-de/, was lang-de/); the formats were raised (the index to 4, the search index to 5, the viewer's rendered pages to 5), so the caches are built again on their own.

Added

  • exphelp.ini [DocLanguages] Legacy[] (Legacy[en/us]=eng-us, Legacy[de]=ger-de, also built in): the old directory names of 6.0.14 and before, for old addresses only; no file is read from an old directory (expHelpLanguage::resolveDirectory(), splitLegacy()).

Updated

  • expHelpLanguage and exphelp.ini [DocLanguages]: a locale reads an alias of Map[] (now aliases only: eng-GB to eng-us, ger-AT and ger-CH to ger-de), else the directory of its own lowercased code when Directories[] lists it (ger-DE reads ger-de), both tried with and without the variant, else the first directory of its language (any other ger-* reads ger-de), else Default=eng-us; Directories[] lists eng-us and ger-de, Names[] is keyed by the new names.
  • The help keeps every old address working: /exphelp/viewer/en/us/<id>, /exphelp/viewer/doc/en/us/<id> and /exphelp/viewer/de/<translated path> are redirected to the page id, like the new /exphelp/viewer/doc/eng-us/<id> and /exphelp/viewer/ger-de/<translated path>; ?lang= takes the directory name (eng-us, ger-de) or a locale, and still accepts en-us and de.
  • Every reference to the old directories in the repository: kernel code, templates, settings (exphelp.ini path rules and the Doc= entries of the other INI files), translations, tests and fixtures, command line scripts, CI, the root files and the links of the documentation itself, including GitHub links into doc/eng-us/. The released changelogs and the history pages keep the paths of their time.
  • Translated documentation: the naming rule, the alias settings and an upgrade table for sites, overrides and scripts written for the old en/us/ and de/ directories; help system and Reading the help in your language follow it.

Tests

  • expHelpTranslatedDocumentationTest: TD-01 the tracked files below doc/ are in doc/eng-us/, doc/ger-de/, doc/changelogs/ or doc/README.md; TD-02 no tracked text file names the old directories below doc/ outside the history; TD-03 and TD-04 the lowercased locale, the aliases and the fallbacks; TD-11 ?lang= with the new and the old keys. The redirects of the old addresses are tested with the viewer.

Operator note

  • After the deploy, remove the old copies (en/us/ and de/ below doc/) if the deploy copied files without deleting (the help does not read them), deploy with --kernel, and run ./console exp:help index --rebuild so every language's index and search are built at once; the old lang-de/ cache directory can be removed.

20 October 2026: the system card: exp:expfetch, exp:info and exp:about

Guide: The system card. For hosts: The system card for hosts; for extension developers: Facts, themes and logos for the system card; for partners: Verifying a customer installation; for demos: The system card in demos; for trainers: Lesson 5.5. Reference: exp:expfetch, syscard.ini.

Added

  • ./console exp:expfetch: the system card. The orange Exponential "e" in block art (three sizes, and ASCII drawings) next to the facts of the installation, grouped in sections: Exponential (version, line, git branch and commit of a checkout), System (operating system, kernel, host, uptime, load, CPU, memory, disk, shell, terminal), PHP (version and SAPI, OPcache, JIT, the extensions that matter, memory_limit), Engine (Exponential Velocity: version, running, live and configured workers, ports and TLS, envvars, demo mode; Apache, nginx, Caddy, FrankenPHP, PHP-FPM masters and pool workers), Database (type, version, host only) and Site (siteaccesses, extensions, demo mode, debug, the mail transport's type, caches, help, cron, Composer). A Hints block closes it, worst first, and the color bars (the terminal's 16 colors and a gradient bar).
  • ./console exp:info: the card plus an inventory: every active extension with its version (extension.xml, ezinfo.php, composer.json), each siteaccess with its design, language and SiteURL host and the URL mode, the settings of an allowlist (syscard.ini [Inventory] Settings[]), the cronjob parts and their scripts, the expqueue service and workers, the size of the cache directory and of its largest caches, the help coverage per language, every local disk, the Velocity instances and worker model, and the PHP-FPM pools.
  • ./console exp:about: the card plus the project: what Exponential is, the Exponential Foundation and 7x, Exponential Velocity as part of 6.0.15, the license (GPL 2.0 or later, documentation GFDL 1.3 or later), links, how to contribute (CONTRIBUTING.md, the CLA), "Become an Exponential Partner" and the credits. The text is share/syscard/about.txt, written from the documentation, each block naming its source.
  • Options of all three: --json (sections, hints, how each provider ran), --plain (no logo, no colors, for pasting), --no-logo, --section=, --no-color, --color=auto|truecolor|256|16|none, --theme=, --width=, --ascii, --no-hints, --no-bars, --watch[=seconds] with --count= (refreshes in place like top: load, memory, disk and the Velocity workers; Ctrl-C stops), --animate (a reveal of the logo and its gradient in under a second, only on a color terminal), --mode=card|info|about, --list-providers, --list-themes, --completion=bash|zsh.
  • The layout follows the terminal: the big "e" with the rainbow wordmark EXPONENTIAL from 82 columns, then the big, small and mini "e", and stacked below 41 columns. Colors only on a terminal and never with NO_COLOR; the depth is detected (truecolor from COLORTERM, 256 from TERM, else 16) with the nearest color at each depth. Themes: rainbow (default), orange, mono, high-contrast and light (for a light background), each with <Role>16 fallbacks for 16-color terminals.
  • Health hints: load above twice the cores, memory or a disk nearly full, a PHP branch near or past the end of its security support (syscard.ini [Php] SecuritySupportEnds[]), OPcache off, a recommended PHP extension missing, memory_limit below 256M, no database connection, demo mode on, debug output for everyone, the view cache off, a stale help index, no recent cronjob run, Composer packages that disagree (ezupdate's inventory, local files only, cached), a queue without workers.
  • The extension API: providers implement expSysCardProvider (most extend expSysCardBaseProvider), read through an expSysCardContext (a fake one for tests) and return expSysCardFact rows with a severity and an optional hint. settings/syscard.ini [Providers] ProviderClasses[] lists them in card order; an extension adds its own in its syscard.ini.append.php, and Enabled[<id>], Priority[<id>] and Budget[<id>] switch them off and tune them. Themes are [Theme-<name>] blocks; logos are text files in share/syscard/logos/ and in the directories of [Logo] Directories[].
  • Time budgets: each provider has one, and all together [Runtime] TotalBudget; with [Runtime] Isolation=enabled each provider runs in a child process that is killed when its time is up, so a hanging database or disk shows n/a (timeout) and never hangs the card.
  • Classes: expSysCardFact, expSysCardContext, expSysCardProvider, expSysCardBaseProvider, expSysCardRegistry, expSysCardColor, expSysCardTheme, expSysCardLogo, expSysCardRenderer and sixteen providers in kernel/classes/syscard/; the command in kernel/private/classes/commands/syscard.php, its entry point bin/php/expfetch.php; settings/syscard.ini; share/syscard/.
  • No secrets: the card never prints a password, key, token, database user, database name or full DSN, nor the values of Velocity's envvars. Every fact passes expSysCardRegistry::safe(): a fact named like a secret is dropped and a password, token or key inside a value is masked, whatever a provider (also an extension's) returns.
  • Naming: only the command is called "expfetch", after the tools it is modeled on. Its code is the "system card" (expSysCard*, syscard.ini), because "fetch" already names the template fetch functions, the fetch definitions of modules, function collections and the fetch methods of persistent objects.

Updated

  • The console (bin/php/console) passes the name it was called by, an alias too, to the command in the environment variable EXP_CONSOLE_COMMAND, so a command can show more under an alias (exp:info, exp:about).
  • The help: the canonical page, pages for hosts, extension developers, trainers, advocates and press, supporters and partners, the installation and upgrade chapters (run exp:expfetch after installing or upgrading), the glossary and the FAQs of supporters, partners and advocates.

Tests

  • tests/tests/kernel/classes/syscard/ (no database): each provider with a fake context; the registry (the order of ProviderClasses[], a provider added by an extension, Enabled[]=disabled, priorities and budgets, a provider that hangs shown as a timeout); the renderer at 40, 80 and 120 columns, at each color depth and with NO_COLOR; --json is valid JSON and carries no secret; the alias set of the command; and a check that the new code has no "fetch" outside the command name.

20 October 2026: Exponential Velocity is required again and runs on PHP 8.0

Updated

  • Updated: composer.json requires Exponential Velocity (se7enxweb/exponential-velocity) at ~0.0.4.54 again, instead of only suggesting it. From release v0.0.4.54 (2026-10-20) the engine declares "php": ">=8.0" (it was >=8.1) and runs on PHP 8.0 to 8.5 with nothing extra, so a composer install on PHP 8.0, the stock PHP of Red Hat Enterprise Linux 9, installs the engine too. The el9 rpm depends on php-cli >= 8.0 and installs without enabling a module stream; on RHEL 8, enable php:8.0 and use the phar or Composer. On PHP 8.0, AVIF image output (needs 8.1) and the dashboard's per-request memory peak (needs 8.2) are not available. Engine changelog: CHANGELOG.md; details: PHP 8.0 support and Velocity engines.
  • The README, the installation guide (chapters 1, 2, 3, 8, 11, 12, 14 and 15), the PHP 8.0 and PHP 8 pages, the Velocity feature pages and the help pages for hosts, businesses, governments, partners, the press and trainers state PHP 8.0 as Velocity's minimum, with the RHEL notes and what differs on 8.0.

20 October 2026: the administration views take their options as view parameters only

As the help center does, every administration view and endpoint below takes its options as view parameters, /(name)/value, and never as GET query parameters: a ?name= in the address is not read, there is no fallback and no redirect of a GET. Filter forms without scripts are sent with POST (with the form token) and answered with a 303 to the canonical /(name)/value address. Each module.php declares the parameters as unordered_params.

Added

  • expViewParameters (kernel/classes/expviewparameters.php): reads a view parameter off $Params, builds the /(name)/value part of an address, and answers a posted filter form with a 303 to it.

Updated

  • Updated: content/job ((json)/1, (log)/1), content/jobs ((state), (type), (user), (sort), (order), (offset)), content/subitemsexport ((columns), (sort), (order)), the (q) search of content/bookmark, content/draft, content/urltranslator and content/urlwildcards (the Try form of the wildcards posts its address); contentcheck storage, storageaction, brokenrelations, unreferenced and fixfiles; contentfinder/search ((text), (type)); settings/view ((q), (scope), (changed), (compare)); role/list, user/unactivated, url/list, search/stats, oauthadmin/keys, pdf/list, mailpreferences/admin ((export)/csv|json), shop/orderreceipt ((download)/1); setup cronjobsstream, staticcachestream, logsfeed, languages, sitedata, cronjobs (the Ajax flag is posted), logs (the old ?file=, ?key= and archive= links are no longer read); visual/templatelist ((filter)); package list, compare and view (their filter forms are posted). Links, pagers, the scripts' fetch and stream addresses and the templates of every admin design build the /(name)/value form.
  • The guides that showed a ?name= option (bookmarks, drafts, URL aliases, content jobs, subitems table options, PDF exports, roles, mail preferences, the log viewer, content checks, the content finder, the settings page).

Not changed: the classic content/search and content/advancedsearch (a long-standing public API), the call options of ezjscore and the passthrough of switchlanguage.

Tests

  • AdminViewParametersOnlyTest (no database): VP-01 the converted views read no $_GET, getVariable() or $http->variable(); VP-02 their module.php declares the options; VP-03 and VP-04 their templates and scripts build no ?name= option and have no GET form. expViewParametersTest for the helper; the bookmark, draft, content check, content finder, PDF export and package tests follow the new addresses.

Operator note

  • Deploy with --kernel (a new kernel class). Bookmarked addresses with ?q= and the like open the unfiltered page.

20 October 2026: one reversible encoding for the values of view parameters

A view parameter, /(name)/value, cannot carry a / as it is: Apache refuses %2F, and the kernel decodes the address (twice unless DecodeOnce is enabled) before it splits it. The views handled that in three ways: most of them turned /, ( and ) into spaces (a link list or URL alias search for example.com/a lost its slash, and a + became a space), the content finder and the settings page wrote ~ and a base64url, the template list and the log viewer ~XX. They now share one encoding. Guide: View parameters: how a value travels in an address.

Updated

  • Updated: expViewParameters (kernel/classes/expviewparameters.php) writes a value with encode(): /, (, ), ~, %, +, \, ?, # and control characters as ~ and two hexadecimal digits (~2F for /), a value of only . or .. as ~2E each, everything else readable (letters, digits, , : @ = ! * as they are, a space %20). decode() is the inverse; get() and text() return the value decoded, raw() as the kernel handed it over, escape() what the kernel hands over for a value. suffix() encodes, and clean() no longer turns /, ( and ) into spaces.
  • Updated: every converted view writes and reads its values that way: the searches of url/list, content/urltranslator, content/urlwildcards, search/stats, content/bookmark, content/draft, role/list, user/unactivated (its search keeps / and brackets now), oauthadmin/keys, mailpreferences/admin and pdf/list; contentfinder/search (text) and settings/view (q); visual/templatelist (filter); the log viewer's permalinks and feed (expLogPermalink::encode() and decode() call it); the search of package/list, package/compare and package/view (written once instead of twice); the content check options.
  • Updated: templates write a value with the new template operator view_parameter (expViewParameterOperator, kernel/common/expviewparameteroperator.php): the setting links of the settings page and the link back to the content finder. Scripts write it with window.a4ViewParameter.encode() (and read it with decode()), added to a4_url.js of the admin and admin4 designs: the log viewer (page/setup_logs.js, its permalinks, feed, content finder and audit links), the subitems export and the static cache stream.
  • Updated: links written before keep working: contentfinder/search and settings/view still read the ~ + base64url form (Contentfinder\Search::textFromParameter(), when the value is not one the new encoding writes), the ~XX form of the log viewer and the template list (a space ~20) is read by the same decode(), and the package views still read their search encoded twice (demo%2520content).
  • Updated: the guides of URLs and aliases (section 9, English and German), the settings page, the content finder, the log viewer (English and German) and finding an error by its reference; the kernel operators page and the operator index list view_parameter; the generated module view and operator pages.

Tests

  • expViewParametersTest: the encoding character by character, its inverse and the ~XX links of before, round trips through the kernel's decoding once and twice for /, (, ), ~, spaces, %, +, unicode, emoji, dots, control characters and quotes, how the kernel splits an encoded address, and the template operator.
  • AdminViewParametersOnlyTest VP-05 (no hand-written encoding in the converted views, templates and scripts: no base64url, no double rawurlencode(), no |urlencode or bare encodeURIComponent() after /(name)/) and VP-06 (a4_url.js the same in admin and admin4; in node, a4ViewParameter.encode() writes exactly what expViewParameters::encode() writes and decode() gives the value back).
  • The content finder (CF-08, both forms), log viewer, package, PDF export, bookmark and unactivated users tests follow the new addresses.

Operator note

  • Deploy with --kernel (a new kernel class, expViewParameterOperator, and a new template operator) and --packer (a4_url.js and the admin scripts changed). Bookmarked links keep working.

20 October 2026: the help shows external images from a local copy

Updated

  • Updated: the help shows the external images of a page (README.md's screenshots on github.com/user-attachments) as images again, without widening the administration's Content Security Policy: ./console exp:help index stores a copy of every https:// image a page references in var/<site>/cache/exphelp/images/<sha256 of the address>.<suffix> (expHelpRemoteImages), and the new view exphelp/image/(id)/<sha256> (policy exphelp/read, the id a view parameter, never GET) serves it with its content type, nosniff and a long private cache lifetime, so img-src 'self' allows it. Fetching follows redirects over https only, never to a private or reserved address, stops at 5 MB and 15 seconds, and keeps only PNG, JPEG, GIF and WebP whose bytes match the type (no SVG). An image without a copy keeps the labeled link of before. The width, height and alt of an <img> tag are kept as attributes. exp:help index --no-images skips the fetching; the rendered pages are cached again (RENDER_VERSION 6, and the key follows the image directory). Guide: help system.

Tests

  • expHelpRemoteImagesTest (no database, a fake fetcher): redirects followed and the copy stored readable by the other server user; plain http, a redirect to http or file:, another port, too many redirects, a status other than 200, a body over the limit, SVG and bytes of another type refused; the references of a page; the renderer's <img> with the local address and the size and alt of the tag; the view refuses unknown ids, upper case, path tricks, a further path and a symbolic link.

Operator note

  • After the deploy (--kernel: a new class and a new view), run ./console exp:help index as the user the help cache belongs to, so the copies are made; the server needs to reach github.com over https once for it.

20 October 2026: Exponential Velocity is part of Exponential

As of 6.0.15 the Velocity Internet / Web Application Server is part of Exponential. This marks a turning point for the project, a cornerstone and a capstone at once: a new level of application system integration, which provides even greater features out of the box in the core product, by default. Exponential is now a whole working unit, not just a framework: a rapid application development (RAD) stack for Internet applications that is a content management system, a digital experience platform and an e-commerce system in one installation, from the server to the rendered page.

The story, for every reader: Velocity is part of Exponential 6.0.15. Every feature page: Exponential 6.0 features.

Updated

  • composer.json requires se7enxweb/exponential-velocity ~0.0.4.54; it moved from suggest to require. A composer install on any supported PHP installs the server with the rest of the product.
  • Velocity 0.0.4.54 runs on PHP 8.0 and later (it needed 8.1 before), so the kernel and the server now share one minimum: PHP 8.0, the stock PHP of Red Hat Enterprise Linux 9 included. It needs ext-sockets; pcntl and openssl are recommended for the worker pool and HTTPS.
  • Velocity runs next to Apache with PHP-FPM, nginx and FrankenPHP. Its own server ([ServerSettings] Engine=qbix) is the recommended engine for every stage; FrankenPHP is the production-ready alternative; Apache and nginx keep working as before. The shipped settings/velocity.ini still starts PHP's built-in server (Engine=php) until a site chooses.
  • The commands: ./console exp:velocity deploy (autoloads, settings, caches, PHP-FPM and Velocity in one ordered run, stopping at the first failure), start, restart and cache clear.
  • The documentation tells it throughout: the README, the installation chapters (requirements, getting the code, serving the site, upgrading, troubleshooting), the Velocity feature pages, the BC notes on the engines and on PHP 8.0, the help start page, the project FAQ, the press kit and the Velocity stories, and the start pages for hosting companies, extension developers and decision makers. Every page that said Velocity needs PHP 8.1, or that it is only suggested, now says it runs on 8.0 and is required.

Added

Upgrading

  • Run composer install as usual; it installs se7enxweb/exponential-velocity. Nothing switches engines on its own: a site served by Apache, nginx or FrankenPHP is served the same way after the upgrade.

20 October 2026: Velocity's opcode cache has room for the help center

Updated

  • Updated: settings/velocity.ini gives the Velocity server process 512 MB of opcode cache (opcache.memory_consumption, was 256) and 64 MB for interned strings (opcache.interned_strings_buffer, was 32). The help center's index files are 18 to 24 MB per language, and with the old values the interned strings buffer was full after a few help requests. The memory is shared by the workers of an instance and reserved, not used, until it fills. The PHPSettings reference, the FrankenPHP and opcode cache pages and the hosting page on PHP settings show the new values.

20 October 2026: the help center takes its options as view parameters only

Updated

  • Updated: The help center takes every option as a view parameter (/(name)/value), never as a GET query parameter, with no fallback and no redirect: /(lang)/<key> instead of ?lang= (the original of a translated page), /(format)/json instead of ?format=json (the instant search), /(format)/epub|pdf|dita instead of ?format= (the manuals' downloads) and /(tracker)/<key> instead of ?tracker= (the error report); a page id is no longer read from ?p=. kernel/exphelp/module.php declares them as unordered_params, the templates and the help script build only view parameter addresses, and the help search forms (the dashboard, the pages, the header menus, the audience pages, the extension report) are sent with POST, which the search view redirects (303) to its canonical address. expHelp::url() appends its options as view parameters.

Tests

  • AdminHelpViewParametersOnlyTest: no help template, header help template or help script builds ?lang=, ?format=, ?q=, ?p= or ?tracker=, the help views read no $_GET, module.php declares the parameters and no help form is a GET; AdminHelpScriptTest, expHelpLinksTest and expHelpSearchViewParametersTest follow the view parameter addresses.

20 October 2026: admin4 serving, style sheets, address checks and the admin4l zones

Merged pull requests #291, #292, #293 and #294.

Added

  • The rewrite templates .htaccess_root and .htaccess_root_static serve the icon themes of extensions (extension/<name>/icons/, icon.ini [ExtensionSettings] IconExtensions[]) directly, images with a single extension only (#291). The same rule is in every web server example (doc/eng-us/examples/htaccess, ezpublish.conf, Apache, LiteSpeed, nginx, Caddy, FrankenPHP, IIS), the install and hosting chapters that list the rules, and the static path list of Velocity, FrankenPHP and the PHP built-in server (expVelocity::STATIC_PATHS).
  • admin4 frames the API access page (apikey/list) like its other pages (#291), and copying several items at once shows its browse page description (content/browse_copy_subtrees.tpl, in design/admin and design/admin4).
  • javascript/a4_url.js (design/admin and design/admin4): window.a4SafeURL(), the one check the admin scripts make before they follow an address that came from data; the page heads of admin, admin3 and admin4 load it before BackendJavaScriptList, and the log and content job pages load it themselves (#293).

Updated

  • The sorting controls and the VAT type removal title escape their text (#291).
  • The context menu, the select boxes that navigate on change, the log viewer and the content job page follow only addresses of this site; the CSRF token of the legacy preference posts is URL-encoded, and an error text in the content tree is shown as text, never parsed as markup (#293).
  • The admin4l zone template draws only admin_* block definitions (others are skipped and logged), and the popup menu, debug area and overlay only from the footer zone, so they appear once; the page reads the safety switch through the fetch function explayouts/admin_layouts_enabled (#294).

Removed

  • The view scripts kernel/class/delete.php, kernel/content/error.php and kernel/workflow/list.php, for which no module defines a view, and design/admin's unused page_menuhead*.tpl (#291).
  • admin4 style rules that nothing uses or that a later rule overrides completely, and the old YUI data table and paginator rules of theme/admin_datatable.css (#292).

Upgrading

  • An installed .htaccess or a server configuration of your own is not rewritten: copy the rule into it, before the front controller rule, if an extension ships icons. .htaccess: RewriteRule ^extension/[^/]+/icons/[A-Za-z0-9_/-]+\.(svg|png|gif)$ - [L]; a virtual host and the other servers: Web server rules. Velocity and the generated FrankenPHP configuration take it with their next restart (exp:velocity deploy).
  • A design that overrides page_head_script.tpl and loads BackendJavaScriptList must load a4_url.js before it ({ezscript_load( array( 'a4_url.js' ) )}); without it the context menu follows no links and says so in the browser console.

Tests

  • AdminExtensionIconsRewriteTest, expVelocityEnginesTest (the static list serves extension icons, never a.php.svg), AdminKernelDesignTemplatesTest (every literal design: template of the kernel views resolves in an admin design), AdminTemplateFramesTest (#291); AdminBaseStylesheetsTest (#292); AdminContextMenuTest and the script tests of the context menu, node tabs and content job (#293); Admin4lReviewTest AL-R7 to AL-R9 (#294). All without a database.

21 October 2026: CORS for the REST API, PKCE and state for OAuth

Added

  • CORS for everything index_rest.php serves (the content API v1 and v2, the OAuth token endpoint and the routes of every REST provider), off by default: rest.ini [CorsSettings] with Cors, an allowlist of exact origins (AllowedOrigins[]; * only without credentials, null never), AllowedMethods[], AllowedHeaders[], ExposedHeaders[], MaxAge and AllowCredentials. A preflight is answered by the REST kernel without running the request (204, or 403 when the origin, method or a header is not allowed); every response carries Vary: Origin while CORS is on, and only an allowed origin gets Access-Control-* headers (expRestCors, CORS for the REST API).
  • PKCE (RFC 7636) for the OAuth authorization code flow: oauth/authorize takes code_challenge and code_challenge_method and stores them with the code, the token endpoint checks code_verifier and uses the code up on a mismatch. S256 is accepted, plain only with AllowPlainPkce=enabled. A client stored without a secret is a public client: it sends no client_secret and must use PKCE (RequirePkceForPublicClients, enabled by default); RequirePkceForConfidentialClients (disabled by default) requires it for every client (expOAuthPkce, PKCE and state).

Updated

  • oauth/authorize sends the state of the request back unchanged with the code, the token and every error that goes to the client's end point, and carries state and the PKCE parameters through the authorization form.
  • An authorization code or refresh token is redeemed only by the client it was issued to.
  • An unknown response_type is answered with error=unsupported_response_type; an end point with a query string of its own keeps it when the parameters are added.
  • The REST and OAuth guides, the app creator and business help pages, the API standards page and the procurement requirements describe CORS, PKCE and state, in English and German.

Upgrading

  • Run the ezprest_authcode statements of update/database/<engine>/6.0/dbupdate-6.0.0-6.0.15.sql (two nullable columns). Kernel schemas and share/db_schema.dba have them for new installations.

Tests

  • expRestCorsTest, expOAuthPkceTest, expOAuthPkceSchemaTest, and new cases in ezpOauthAuthorizeRedirectTest (state returned, challenge stored, plain and malformed challenges refused before the login, PKCE required for a public client). All without a database.

Updated

  • Fixed: the administration's site menu and "Open the site" link (siteaccess_url(), siteaccess_menu(), ezpSiteAccessURL::root()) gave a host-matched siteaccess its URI as well when the page was on another host: with MatchOrder=host_uri;host;uri, the administration on its own host linked the site to https://example.com/site/, where the host map picks the site and /site/ is read as a module, so the link answered 404. HostUriMatchMapItems were not taken into account at all. The address is now the first one the matching rules (host_uri, host, uri in MatchOrder order) lead to the siteaccess with nothing of the path left over: this host with its host and URI entry or its URI, its own [SiteSettings] SiteURL (host, port and path), a host the host map gives it, then one of its host and URI entries; the URI on this host remains the fallback. Siteaccesses matched by URI on the host the administration runs on keep the address they had. Documented in the siteaccess_url operator reference.

Tests

  • ezpSiteAccessURLTest: host-matched, host and URI-matched and URI-matched siteaccesses seen from the administration's own host, SiteURL used only when the rules lead there, and the address check itself. No database.

21 October 2026: the audit syslog sink under open_basedir

Updated

  • Fixed: the audit syslog sink (expAuditSyslogSink) called file_exists() on /run/systemd/journal/socket and /dev/log, so on a host with open_basedir (PHP limited to the site folder and /tmp) every request wrote an open_basedir warning into the error log. The sink now reads open_basedir first (parsed as PHP does, decided once per process, expAuditSyslogSink::pathAllowed()), never touches a socket outside it and sends through PHP's syslog() instead. Without open_basedir nothing changes.

Tests

  • expAuditSinksTest SK-08: the open_basedir parsing, the fallback with an injected setting, and a PHP process started with a real open_basedir that raises no warning. No database.

21 October 2026: layout mappings by route and time, imports and helpers

Fixed

  • Fixed: A layout mapping with a Route target matches on the site. The value is a module view (content/search, user/*, content/view for every content page, *); the media design lays out its pages that are not content pages through such a mapping only (explayouts 1.4.42, sevenx_themes_media 1.5.75).
  • Fixed: A Time condition switches the layout at its start and end. The resolver kept the answer for [ResolverSettings] CacheTTL (an hour) and the page cache kept the page as long again; the answer now expires at the condition's next start or end, and the page is kept out of the page caches when that comes within [HttpCacheSettings] MaxAge. A Query parameter condition is evaluated on every request, so /page and /page?variant=b can show different layouts (explayouts 1.4.42).
  • Fixed: exp:layouts import --publish (and an import that writes rules) purges the page cache itself; the pages kept the old layout until the cache was cleared by hand (explayouts_core 1.0.10).
  • Fixed: ./console ext:explayouts:block_list, layout_info, collection_list, layout_copy, layout_list, rule_list, share_create and the other helpers of explayouts ended with Class "..." not found; they run on their own now (explayouts 1.4.42).
  • Fixed: A block an extension adds with AvailableBlocks[] is offered wherever the extension is listed in ActiveExtensions[]: explayouts' lists no longer start with a reset line (explayouts 1.4.42). The media design's Product highlight block appears in the block list for the first time.

Updated

  • Updated: The "Two ways to build" help pages no longer describe these as limitations or workarounds (English and German).

Upgrading

  • Update se7enxweb/explayouts to 1.4.42, se7enxweb/explayouts-core to 1.0.10 and se7enxweb/sevenx-themes-media to 1.5.75. An installation that relied on explayouts' reset line to drop a block an extension adds writes the reset in settings/override/explayouts.ini.append.php instead.

Tests

  • explayouts: tests/explayoutsresolvertest.php (RS-04, RS-08 to RS-10), tests/explayoutsbinhelperstest.php, tests/explayoutssettingslistresettest.php; explayouts_core: tests/TransferPurgeTest.php. All without a database.

21 October 2026: REST children lists without ezprestapiprovider

Fixed

  • Fixed: GET /api/ezp/v1/content/node/<id>/list/... and the same route at v2 answered 500 (Class "ezpRestContentController" not found) on every installation that activates ezprestapi alone, which is what a new installation and the public demos do. The content model looked up the Fields response group on the controller of the deprecated ezprestapiprovider; ezprestapi 1.2.7 reads it from its own controller. Node reads (/content/node/<id>) were not affected. Installations that also activate ezprestapiprovider never saw it.

Upgrading

  • Update se7enxweb/ezprestapi to 1.2.7 (~1.2.6 in composer.json already allows it). The REST interface needs authentication: an unauthenticated request answers 401 with WWW-Authenticate: OAuth; send a personal API key (Authorization: Bearer expk_..., Personal API keys) or an OAuth token.

Tests

  • ezpRestContentServiceModelTest::testGetChildrenListWithAndWithoutFields in ezprestapi (with a database).

21 October 2026: a payment gateway made in Setup > RAD is found

Updated

  • The handler wizards for payment gateways (/setup/handlerextension/paymentgateway and paymentgatewaydirect, kernel/setup/exphandlerwizard.php) write the class to extension/<name>/paymentgateways/<alias>/classes/<alias>gateway.php, which is where eZPaymentGatewayType looks (<GatewaysDirectories>/<gateway>/classes/<gateway>gateway.php for each name in AvailableGateways[]). They wrote paymentgateways/<alias>gateway.php before, so a generated gateway was never loaded and never offered in the Payment Gateway workflow event.
  • The generated registerGateway() call names the class itself, not <alias>gateway, which was a different name whenever the extension name had an underscore, so createGateway() could not build it.
  • The RAD catalogue (expRADCatalogue), Extension points and the help page on payment gateways for website creators give the new path; the services API's gateway list (expPaymentServices) reports the files the kernel would load.

Upgrading

  • A gateway generated by an earlier version is not found as it stands. Move its class file to extension/<name>/paymentgateways/<alias>/classes/<alias>gateway.php and change the second argument of its eZPaymentGatewayType::registerGateway() call to the class name. The settings it came with need no change.

Tests

  • expRadWizardPaymentGatewayIsFoundTest: both gateway kinds are generated into a scratch directory, registered with the settings the wizard writes, loaded by eZPaymentGatewayType and built with createGateway(). Without a database.

21 October 2026: downloads on Velocity carry their type again, and an EPUB is application/epub+zip

Updated

  • Updated: The HTTP cache contract (kernel/private/classes/httpcache/ezphttpcachecontract.php) no longer loads eZFile and eZDir. Velocity's parent process loads the contract before it installs its source transform, and every worker inherits the classes compiled there: an eZFile compiled that way kept PHP's own header(), which does nothing under the CLI SAPI. Every content/download on Velocity (:8080) went out as text/html; charset=utf-8, without Content-Disposition, and a range request was answered 200 instead of 206; Apache was not affected. The contract limits the modes of the files it writes itself (ezpHttpCacheContract::fileMode(), dirMode()), with eZFile and eZDir when they are loaded and by the same rule (EZP_FILE_MODE_MAX, EZP_DIR_MODE_MAX) otherwise.
  • Updated: settings/mime.ini knows .epub as application/epub+zip, so the help e-books and every stored EPUB are downloaded with that type instead of application/octet-stream.

Tests

  • expFileModeLimitsTest (FM-08): loading the contract (with the repair queue, before the autoloader) declares neither eZFile nor eZDir, and the contract limits modes exactly as they do for ten ways of setting the constants. eZUtilsHelpersTest looks up an .epub file. Without a database.

21 October 2026: the VAT rules page says when no handler applies the rules

Updated

  • VAT charging rules are applied only by a VAT handler (shop.ini [VATSettings] Handler, ezdefault for the shipped eZDefaultVATHandler), and the shipped settings/shop.ini sets none. The VAT rules page (shop/vatrules, design/admin and design/admin4) now says so when rules exist and no handler applies them, and when the handler that is set cannot be loaded (Exponential\View\Kernel\Shop\Vatrules::handlerNotice()).
  • The shipped file keeps Handler commented out on purpose. With a handler set, eZVATManager reports dynamic VAT charging as enabled, and an existing store would change at checkout: eZShopOperationCollection::handleUserCountry() stops an order whose customer has no known country (with RequireUserCountry=true, the shipped value), the "Determined by VAT charging rules" VAT type appears on price fields, and that type is charged 0 percent instead of "unknown" where no rule matches. Fixed VAT types are charged the same with or without a handler.
  • The help pages on VAT rules and VAT types and on products, prices and VAT (English and German) explain the setting.

Upgrading

  • Nothing to do unless the new notice appears. To apply your VAT rules, set Handler=ezdefault in [VATSettings] of settings/override/shop.ini.append.php, check that the customer's country is known at checkout (UserCountryAttribute, or the country of the shop account handler), and test an order before going live.

Tests

  • eZShopPricesTest: the shipped shop.ini sets no VAT handler, a fixed VAT type is charged the same with and without one, and the VAT rules page's notice (no rules, no handler, a handler, a handler that cannot be loaded). Without a database.

21 October 2026: the media design loads the stylesheets of design.ini

Updated

  • sevenx_themes_media 1.5.74: the media design (Fit & Healthy) loads the stylesheets extensions add to design.ini [StylesheetSettings] CSSFileList[] and FrontendCSSFileList[], after its own files and in the same packed bundle. Before, its page layout listed its stylesheets in pagelayout/head/style.tpl only, and a file an extension added (cjw_newsletter.css, eztags.css, ezgmaplocation.css ...) never reached a media page. The entries the theme replaces are named in its new MediaReplacedCSSFileList[] and stay off the page, as before.
  • The help pages Styling your blocks and the examples Move inline styles into the stylesheet and Restyle blocks without touching markup (English and German) add a stylesheet with FrontendCSSFileList[] instead of a copy of style.tpl.

Upgrading

  • Repin se7enxweb/sevenx-themes-media to 1.5.74, then clear the INI, packer and template-block caches. A site that copied pagelayout/head/style.tpl into its own extension keeps working; it can drop the copy and list its file in FrontendCSSFileList[].

20 October 2026: twenty-two more audiences of the help, in groups

Added

  • Added: Twenty-two more audiences of the help, each with a dashboard card, a search filter, a pill, a category, a landing page ([Landing_<audience>], start page help/<folder>/<folder>-start-here.md) and aliases: Community (community), Ecosystem (ecosystem), PHP Developers (php-developer), Theme designers (theme-designer, category Theme design), Resellers (reseller), Bundlers (bundler), AI, agents and agentic (ai-agents), Software Engineers (software-engineer), Adopters (adopter), Mobile App Developers (mobile-app-developer), Web Framework Developers (web-framework-developer), CMS Developers (cms-developer), DXP Developers (dxp-developer), E-commerce (ecommerce, folder e-commerce), Integrators (integrator), Database Administrators (dba, folder database-administrators), Podcasters, radio and streaming (podcaster), Non-profits (non-profit), Local businesses (local-business), Individuals (individual), Non-technical people (non-technical, folder non-technical-people) and Students and learners (student, folder students). Overlapping audiences name each other as related on their landing pages, both ways.
  • Added: The "Who makes Exponential" band of the help dashboard lists Partners, Community and Ecosystem after "Who is 7x?" and "Who is the Exponential Foundation?", each opening its audience's landing page.

Updated

  • Updated: With forty-one audiences, the dashboard shows their cards in five groups (Use and run Exponential, Organizations, Build with Exponential, Host, sell and partner, Community and learning), each a folding section with its count: the first is open, the others folded. The search hero shows the filters of the first group and the others behind More audiences. No inline CSS: stylesheets/modules/help.css of admin and admin4, shared tokens only; the icons book, chat-left-dots, chat-square-text and collection-play (Bootstrap Icons, MIT, already in share/icons) get their .a4-bi-* classes.
  • Updated: The new strings are translated in the twenty-three complete catalogues that have the kernel/exphelp context and added to untranslated.

Tests

  • expHelpCommunityAudiencesTest covers the twenty-two audiences and their categories, and CA-07 the dashboard groups (every audience in exactly one group), the "Who makes Exponential" links and each audience's start page (checked once the page exists); expHelpLandingPagesTest checks their landing groups and that overlapping audiences relate both ways. All without a database.

21 October 2026: the Become an Exponential Partner block of the help pages is a card

Updated

  • Updated: The Become an Exponential Partner paragraph that ends almost every help page, in every language (Werden Sie Exponential-Partner in German), is shown in the help viewer as a card like the footer blocks of the help dashboard ("Grow from user to developer", "Found an error?"): the partner icon on a tile, the bold lead as its heading, the text, and the two links (Partners: start here, How to apply) as buttons, the first one primary. expHelpMarkdownRenderer recognizes the block by the markers every page already carries (<!-- exp-partner-block:start --> and <!-- exp-partner-block:end -->), with option partner_card, which the viewer sets; the Markdown files are unchanged and read on GitHub as before. A marked block that is not one paragraph with a bold lead, or has no end marker, is shown as written. Both admin designs (admin and admin4), light and dark: the CSS is in stylesheets/modules/help.css (.a4-help-partner*), shared tokens only, no inline CSS.
  • Updated: The PDF of a help page prints the card as a box with its title, its text and its links on one line. The search, the DITA and content exports and the generated example pages (which carry no such block) are unchanged. Cached pages render again (render version 7, PDF writer version 2).
  • Updated: A page shown in another language than the reader's (/(lang)/ger-de for an English reader) resolves its links in its own language: they were resolved against the English original's file, so the relative links of a German page (../partner/partner-hier-beginnen.md) named no page and were shown as text, and the German partner card had no buttons. The viewer renders the page with its language's index (expHelpDocumentIndex::setInstance() for the render, restored after; fixedInstance() is new), render version 8. A partner block whose links lead nowhere keeps its last sentence as text instead of showing it as buttons.

Upgrading

  • Nothing to do: the cached help pages and PDFs are made again on their next view.

Tests

  • expHelpPartnerCardTest: the English and the German block become the card (heading, text, buttons with the links kept, the partner icon), other bold paragraphs and broken blocks stay paragraphs, the buttons follow the link policy, two real pages render one card each, the CSS of both designs is the same and uses tokens only, and the PDF prints the card. PC-08: a German page rendered with the German index has its two buttons to the pages they name, with the English reader's index none (the sentence stays text). Without a database.

21 October 2026: the layout editor saves a view type change alone and gives added items positions of their own

Updated

  • explayouts_ui_api 1.3.29: on the Design tab, changing only the View type of a list is saved also when the new view type leaves no item view type to choose (list_zigzag, list_accordion, one of your own); + Add items gives every new item a position of its own (the new items first, in the order chosen) instead of storing all of them at the collection's offset, and moving an item moves the ones in between.
  • The pages of Two ways to build that described both as known editor behaviors (doc/eng-us/help/build-two-ways/known-editor-behaviors.md, the view type and list examples, and their German versions) say they are fixed in explayouts_ui_api 1.3.29 and keep the workaround for older versions.

Upgrading

  • Collection items that already share a position are sorted out on the next change to their collection. No such items were left on the test installation.

Tests

  • explayouts_ui_api: CollectionItemPositionsTest (without a database) and tests/js/view-type-save.test.mjs (the bundle's own modules with jQuery 4).

21 October 2026: the kernel service container (first slice)

Added

  • The kernel service container: shared objects by name (expKernelServices::get( 'db' ), has(), tagged()), defined in settings/kernelservices.ini ([Service_<id>]: class or factory, arguments, worker, request or prototype scope, tags), made on first use; extensions add theirs in kernelservices.ini.append.php. Classes expKernelServices, expKernelServiceContainer, expKernelServiceScope, expKernelServiceCompiler, expKernelExtensionOverrideList; console exp:services:compile, exp:services:list, exp:services:show and exp:services:check (kernel services).
  • The compiled container in var/cache/kernelservices/ (an index and one file per siteaccess): a recording of the web kernel's two extension activation passes, made by booting the kernel once per siteaccess in a separate process. A request applies it only when it starts from exactly the recorded state, and otherwise runs the passes as before; with AutoRebuild=enabled a siteaccess without a current file records itself on its next web request. The deploy and ezcache.php --clear-tag=ini rebuild it (cache item kernelservices, tag ini).
  • The worker scope of the settings files (WorkerScope=auto: when EZP_INI_FILEMTIME_CHECK is false): eZINI::instance() hands out copies of settings objects built once per worker and siteaccess (eZINI::attachSharedStore()), kept on Exponential Velocity through expVelocity::DEFAULT_KEEP_GLOBALS (expKernelServicesWorker); any change of the override directories, a reset, a save or an INI cache clear drops them.

Updated

  • The seven per-request resets of the web kernel's constructor are the request scope's reset hooks (expKernelServiceScope::DEFAULT_RESET_HOOKS, same order, same conditions), and code can add its own (expKernelServiceScope::onRequestReset()).
  • Measured on the test installation (62 active extensions, 18 siteaccesses): the start of a request up to the module run 38 to 50 ms -> 27 to 36 ms PHP-FPM-like (-15 to -35%), 21 to 31 ms -> 7 to 14 ms with Velocity's pool emulated (-51 to -72%); the constructor alone -40 to -54% and -74 to -78%.

Upgrading

  • Deploy with exp:velocity deploy --kernel --allow-root-user (new kernel classes). Nothing else: the container is compiled by the deploy, and [ContainerSettings] Mode=disabled in settings/override/kernelservices.ini.append.php switches it off.

Tests

  • expKernelServicesTest (without a database): definitions and their validation (the shipped kernelservices.ini has no error), scopes, arguments, the request reset hooks, eZINI's worker store, compiled files, a recording applied only from the recorded state, the kernel wiring, and no change without a booted container.

21 October 2026: the classic administration carries no inline handlers or javascript: URLs

  • Fixed: The context menus of the classic administration (the class icon of a node and of a version preview, the icon of a sub item in the line and thumbnail views) open through the data-menu-* attributes of ezpopupmenu.js, as in admin4, instead of onclick="ezpopmenu_showTopLevel( ... )".
  • Fixed: The calendar of the date and date and time fields opens from a button with data-a4-datepicker (page/content_datepicker.js), the "No wrap" switch of the package file viewer works through page/package_view_full.js, and the registration form focuses its first field and disables its buttons after a click through page/user_register.js. The classic administration loads the same files as admin4.
  • Fixed: The search scope pane's close link and the attribute group headings of the edit form use no javascript: URL any more.
  • Updated: The content security policy test (CSP-10) reads every template of every administration design (classic, admin3, admin4, admin4l and the admin designs of the shipped extensions) and fails on any on...= attribute or javascript: URL, also one whose code holds a <; its allowlist is gone.

2026-10-21: help images in code examples, eZTrustedProxy before the Velocity warm-up

Updated

  • The help's image store skips image syntax inside code blocks and code spans (expHelpRemoteImages::references()): an example such as ![alt](https://...) in a page about Markdown is no longer fetched and reported as a failed download. The help system pages write the example address as URL.
  • Velocity's early-load allow list (appCache) names eZTrustedProxy, which sends no header, cookie or exit, so the early-load warning no longer reports it. Takes effect after exp:velocity restart (a deploy restarts it).

Tests

  • expHelpRemoteImagesTest HRI-03 also checks fenced blocks (backticks and tildes) and inline code spans.

21 October 2026: design extensions of another siteaccess, admin template errors, ezprestapi tests

Merged pull requests #296 and #297 (contributed by ekked).

Fixed

  • eZTemplateDesignResource::allDesignBases( $siteAccess ) read the design list from the site.ini of that siteaccess but its design extensions from the current one, so a design extension only the other siteaccess activates was left out (visual/menuconfig found its page layout in design/standard). designExtensions() takes an optional siteaccess and reads its design.ini (#297). With DesignLocationCache=enabled, clear the cache once.
  • setup/info sets every PHP setting it shows (an empty open_basedir logged "No such attribute"), and its OPcache and APCu boxes no longer read $cache.enabled of a cache that is not installed (#297).
  • class/view (admin, admin4) falls back to the name and description of the class's prioritized languages when the class or an attribute has none in the language shown (#297).

Added

  • design/admin/templates/notification/status.tpl: the notification status page in the admin design (and admin3, which uses it), without javascript; it had a template in admin4 only (#297).

Tests

  • Testsuite ezprestapi (without a database): the extension names no class of the deprecated ezprestapiprovider, and its routes, constants and rest.ini providers are declared (#296). It skips when extension/ezprestapi is not installed.
  • SiteAccessDesignAndAdminViewWarningsTest (without a database) (#297).

21 October 2026: the help's manuals are made from the Markdown help

The help as content had one manual, one chapter and five topics; the real help is the Markdown in doc/eng-us/ and doc/ger-de/. The manuals are now made from it, and the Markdown stays the source: they are rebuilt on each release, and an edit made only in the administration is overwritten by the next build (Manuals from the Markdown help).

Added

  • Added: ./console exp:help:content build-manuals -s <admin siteaccess> [--manual=<key>] [--language=ger-DE,...] [--release] [--dry-run] [--json]: one manual per line of exphelp.ini [ManualSettings] Manuals[] (52: the 45 folders of doc/eng-us/help/, Getting started from the pages directly in help/, and install, guides, features, bc as Contributor notes, standards and specifications; not history and examples), each subfolder a chapter, each page a topic (concept, task, reference or troubleshooting: front matter topic_type, else from the page), in the help's order, with the page's audiences, the section from them, and the German pages (doc/ger-de/) as the ger-DE translation when the installation has that language (never created; reported). Links between pages that are topics become links to the topic, other links and images point to the help. Everything it makes has a remote_id starting with help-gen-: a rebuild updates those objects in place, removes those whose page is gone and never touches any other object. A second run prints No change..
  • Added: exphelp.ini [ManualSettings]: Manuals[] (the scope is a list edit), Languages[], TranslatedTitles[], License, BuildOnInstall, BatchSize. [ContentSettings] gets the other audiences of the help as audience options with their AudienceMap[], and the area doc of the generated help_ids (doc.help.editors.publishing).
  • Added: The kickstarter and ./console exp:install --apply queue the build of the manuals at the end of an installation (exp:help:content build-manuals --queue -s admin) and do not wait for it: the content job helpmanuals (expContentJobHelpManuals) installs the help as content and builds the manuals in the background over the next 20 to 30 minutes, at the lowest CPU priority, ten objects per transaction with a pause after each, one such job at a time, resumed by the cronjob part contentjobs when its worker dies. In a fresh SQLite sandbox the installation took 36 seconds (28 seconds without the help step; the build in the foreground had made it 22 minutes). The help's manual pages and exp:help:content report say "Manuals are being built (n of 52)" while it runs (translated in the 24 catalogues with the context kernel/exphelp). BuildOnInstall=disabled leaves it out; after the setup wizard, run build-manuals --queue once.
  • Fixed: A content job started by root on an installation that belongs to root (a container, a sandbox) never ran: its worker refused to run as root. The worker command gets --allow-root-user when root starts it; on an installation with a site user the worker still becomes that user first.
  • Added: exp:help:content ebooks and export take --language=ger-DE: the German edition of a manual's e-books, stored as help-ebook-<manual>-ger-de-<format> next to the default language's and listed on the manual's page.

Updated

  • Updated: A first build makes about 5,700 objects; it publishes each new object once with its translation, writes in transactions of 50, publishes without notification, leaves the search index to the indexcontent cronjob and does not clear the view cache per object. On a copy of the installation's SQLite database (52 manuals, 342 chapters, 5,315 topics, 4,614 with German): 28 minutes for the first build on a busy server, 34 seconds for a run without changes (No change.); without the single publish per object the first build was on course for about 45 minutes.

Upgrading

  • Run after the deploy (and on each later release): ./console exp:help:content install --apply -s admin, then ./console exp:help:content build-manuals --apply -s admin --release, then ./console exp:help:content ebooks --apply -s admin and ./console exp:help:content ebooks --apply -s admin --language=ger-DE. The install step appends the new audience options to the help classes.

Tests

  • expHelpManualBuilderTest (without a database): the manual list (the shipped 52, every help folder, every audience mapped), remote_id and help_id stability, links, topic types, short descriptions, the plan (chapters, order, parents first, audiences, sections, the validator finds no ERROR), German pairing, idempotence, what a rebuild removes and the background job (registered, "n of 52", the installer only queues it); expHelpContentTranslationsTest checks the new string in every catalogue.

2026-10-22: admin4's header tabs show their icons at every width

Updated

  • The tabs of admin4's header row draw their icons (menu.ini [Topmenu_<tab>] Icon) at every width. Since 2026-10-18 the row hid them between 576 and 1599px to fit more tabs, so on most screens no tab showed an icon. A tab that does not fit the row moves into "More", as before. The extensions of the se7enxweb repositories give their tabs icons in their next releases.

Tests

  • AdminMegaMenuTest checks that no rule hides the row's tab icons.

2026-10-22: extensions with header tab icons and modern release files

Updated

  • Required extension versions, each with an icon for its admin header tab and a rewritten README, composer.json, ezinfo.php and extension.xml: bccie 1.1.19 (arrow-down-up), cjw_newsletter 4.2.15 (envelope), exp_adminui 1.0.0.50 (images, box-seam, people), eztags 2.4.24 (tags), git_manager 2.0.23 (git), sevenx_dse 1.1.15 (brush), syndication 1.3.11 (rss), xrowextract 2.5.19 (download).

2026-10-22: admin4 escaping, multi-edit autosave, page scripts on plain JavaScript, no jQuery 4-removed APIs

Merged pull requests #298, #299 and #300 (contributed by ekked).

Fixed

  • The reverse related list heading (admin, admin4, standard) washes the object name, and the existing URL alias warning (admin, admin4) washes the alias it links (#298).
  • The option editor (admin4, standard) gives the additional price field an id of its own instead of the one of the option text (#298).
  • The multi-edit autosave takes the form's state when it sends it, so changes typed while a save is in flight go out with the next save, and a failed save is tried again (#298).
  • The content edit page labels its fields without jQuery.trim(), which jQuery 4 removed (#299).
  • The content edit attribute groups set aria-expanded on their toggle link, and their names are washed (admin, admin4).
  • The online editor's link dialog focuses its first field with .first() instead of the jQuery-only :first selector; TinyMCE's jQuery adapter binds with .on() and .off().

Updated

  • admin4's small page scripts run on plain JavaScript: content edit (attributes, languages, relation uploads), URL alias, node details, locations and ordering, translations, template look edit, user register (admin and admin4) (#300), and class edit.

Tests

  • AdminPageScriptsWithoutJQueryTest (without a database) (#300), extended to class edit and multi-edit.
  • AdminJQuery4RemovedApisTest (without a database): no script of the admin designs, design/standard or the extensions the kernel ships uses an API jQuery 4 removed (jQuery.trim, isArray, type, isFunction ...), .size(), .andSelf(), .live(), the .load(fn) shorthand, the deprecated event binders or a jQuery-only positional selector.

2026-10-22: the transfer registry, import and export tools that know each other

Every import and export tool declares itself in kernelservices.ini, and every link to a tool reads that list, so a user finds the right tool from where they are and never a link to a tool that is off or not theirs. Guides: doc/eng-us/features/6.0/transfer-registry.md, for users doc/eng-us/help/import-export-tools.md (both in German too).

Added

  • The transfer registry: expKernelTransferRegistry (kernel service transfer.registry) collects the services tagged transfer.tool, each an expKernelTransferTool with its name, description, direction, scopes, formats, entry URL, permission, help page, round-trip partners, task words and context entries. It lists a tool only when its module exists and the user has its policy.
  • The kernel's own tools: Packages, RSS import, RSS export, Collected information, Audit export, PDF export.
  • The template operators transfer_tools, transfer_actions, transfer_related, transfer_tool, and the shared template parts transfer/looking_for.tpl, transfer/left_menu.tpl, transfer/actions.tpl, transfer/search_suggestions.tpl (shared admin components, no CSS of their own) for the kernel and the extensions.
  • The help page "Which import or export tool do I need?" (English and German): from the task to the tool, the round trips, and which one when two seem to fit.

Updated

  • The Import/Export menu of an item and the context menus' Advanced > Export list the tools' item entries (for example "Import into this folder…" on a container the user may create in) and end with "Which import or export tool do I need?".
  • The class page lists the tools that work on the class's objects; the Setup menu has an "Import and export" list.
  • The pages of Packages, RSS, Collected information, Audit export and PDF export show a "Looking for another import or export?" box with the other tools, the ones that read their files first.
  • The administration's search shows up to three import and export tools that match the search words ("import csv", "auf eine andere Website kopieren") above the results.
  • The ? help menu of a tool's pages lists the "Which tool" page and the tool's own help.

Tests

  • expKernelTransferRegistryTest (without a database): the shipped blocks validate and their help pages exist, parsing, availability by module and policy, filters, context entries and placeholders, round-trip order, search, the help menu topics, the operators, and that the help page names every kernel tool in English and German.

2026-10-22: transfer runs, one engine and one run history for every import and export tool

The tools of the transfer registry run their background work the same way, on whichever engine the installation has, and every run of every tool is recorded in one table, which the run list and the Statistics of Imports read with a tool filter. Guide: doc/eng-us/features/6.0/transfer-runs.md (in German too).

Added

  • expKernelTransferRunner: a run goes to the job queue (expqueue) when it is active, the tool declares its job type and a worker is alive, else to the kernel's content jobs, else the tool runs it its own way as before. transfer.ini [TransferSettings] Engine (auto), ToolEngine[<tool>], QueueNeedsWorker; per tool Engine=, QueueJobType= and HistoryAllPermission= in its registry block.
  • expKernelTransferTask (the work of one run), expKernelTransferContext (progress, heartbeat, cancel, log, the same on every engine), expKernelTransferCancelledException, the expqueue handler expKernelTransferQueueHandler (declared only where expqueue is active) and the content job type transfer (expContentJobTransfer, which runs the task outside its batch transaction).
  • The run history: the table exp_transfer_run (tool, direction, kind, title, scope, user, state, counts, bytes, duration, format, file reference, engine and its job, the tool's own job, the link to its page, errors) on every engine, expKernelTransferRun, and expKernelTransferHistory to write a run (start, progress, finish, record) and to read runs with the user's rights (a user sees every user's runs of a tool only with its HistoryAllPermission), as rows in the shape of a run of Imports for its Statistics.
  • update/common/scripts/6.0/createtransferruntable.php creates the table on any engine; the 6.0.0 to 6.0.15 update files of MySQL, PostgreSQL and SQLite create it too (only when missing).

Updated

  • The cronjob part contentjobs also removes runs of the history older than [TransferSettings] KeepDays (365).
  • The help page "Which import or export tool do I need?" says where the runs are listed; the transfer registry guide and the database update guide link to the new guide.

Tests

  • expKernelTransferRunsTest (without a database): the table is the same in share/db_schema.dba, the persistent object, the three kernel schemas and the update files, within Oracle's identifier limits; the SQLite update runs twice; the values a row can hold, the statistics row, the filters, the engine choice and its fallback, the payload check, a task's run with progress, cancel and failure, the new registry keys and who sees which runs, the settings, and the content job type.

2026-10-22: data tables and the secret store

The tables of the persistent classes become part of the system: a catalogue of every class with its table and its relations to content, a description of each table as its database has it, named database connections, and classes that read and write on their own connection. Their passwords, and those of every other tool, live in one encrypted secret store. Guides: doc/eng-us/features/6.0/data-tables.md and doc/eng-us/features/6.0/secret-store.md (both in German too).

Added

  • The data table catalogue (expDataTableCatalogue): every eZPersistentObject class of the kernel and the active extensions with its table, fields, keys, sort, function attributes, connection and relations (from the definition's relations key, a field's foreign class, datatable.ini [Relation_<class>_<column>], or inferred from the column's name), kept in the cache until the autoload arrays change.
  • expDataTableDescriber: a table as its database has it (columns, rows, from the engine's own catalogue, a sample of documents on MongoDB) and where it differs from the definition (missing table or column, extra column, type).
  • Named database connections (expDataConnections, table exp_data_connection, datatable.ini [Connection_<id>]): SQLite files in var/datatable, MySQL/MariaDB, PostgreSQL, Oracle, SQL Server and MongoDB, here or remote, each an eZDB instance of its own per request; Test connection with the server's version, tables and time.
  • expPDODB: a generic database driver over PDO for the engines the kernel has no driver for (SQL Server, Oracle without ezoracle).
  • Persistent classes on a connection: 'connection' => '<id>' in definition() or datatable.ini [ClassSettings] Connection[<class>]; eZPersistentObject::dbFor(), with conditions and values escaped for that connection.
  • The secret store (expSecrets, expSecretKey, table exp_secret): values encrypted with XChaCha20-Poly1305, the key from EXP_SECRET_KEY or a key file made on first use in var/secrets/, key rotation, names resolved from env:, the store and secrets.ini.
  • The administration pages Setup > Data tables (module datatable: the catalogue, a class's page, Connections, one connection) and Setup > Secrets (module secrets), a Setup menu group "Data tables", and the policies datatable/read, datatable/manage, datatable/connections, datatable/sync and secrets/manage.
  • expKernelTableSchema: whether a table exists on any engine (from its catalogue), and creating missing tables of a .dba schema; the Data tables and Secrets pages create their tables with it, no command needed. The 6.0.0 to 6.0.15 update files of MySQL, PostgreSQL and SQLite create both tables too (only when missing).
  • expDataTableSchema and the page Setup > Data tables > Schema (datatable/schema): a table made from a class's definition or an extension's share/db_schema.dba, created or upgraded on any connection, the SQL shown before it runs; dropping and changing columns or indexes only when ticked; the install SQL of MySQL, PostgreSQL, SQLite, Oracle (sequence and trigger) and SQL Server (IDENTITY).
  • The rows of a data table (datatable/rows/<class>), page by page, filtered by one field, relation values linked to what they point at, columns that look like secrets left out.
  • The tab Data tables of the content view: the rows of extensions' tables that point at the node or its object through a declared relation (datatable.ini [DataTableSettings] ContentTabInferred adds the inferred ones).
  • expDataTableWriter: the one way to write rows into a data table (insert, update, upsert, replace, dry run; matched by unique keys; references @object:, @node:, @path:; a refused row fails alone).
  • The fetch functions datatable/list, count, row, pointing_at and can for templates.
  • The data tables in the transfer registry ([Service_transfer.datatable]), with 7x SQLI Import as round-trip partner.
  • The module table wizard (Setup > RAD > Module table, setup/moduletable): a new data table from a list of fields, a content class, an existing table or a field source of an extension (expDataTableFieldSource, tag datatable.field_source), written as a new extension or added to an existing one: the persistent class, the schema and the install SQL of every engine, a module with list, edit, remove and a JSON endpoint and the policies read and edit, the admin templates, fetch functions and a Setup menu entry.
  • The wizard installs from the browser: it creates the table on its connection, activates the extension through the kernel's activation (with a copy of the settings file kept first), regenerates the autoload arrays and clears the caches.
  • The synchronisation between data tables and content (expDataSync, Setup > Data tables > Synchronisations, datatable/syncs and datatable/sync/<id>, policy datatable/sync): a data table, a content class and a node, a map of columns to attributes and @ sources (object and node ids, remote ids, name, times, class, section, owner, URL alias) with a proposed map, key columns, a direction (content to table, table to content, both ways), and a run mode (once, scheduled, live); every run goes through the transfer runner and the run history.
  • The tables exp_data_sync, exp_data_sync_link (the pairs of a row and an object with the hashes of both sides) and exp_data_sync_conflict, in the kernel schemas and the 6.0.0 to 6.0.15 update files of MySQL, PostgreSQL and SQLite (only when missing), or created from the Synchronisations page.
  • The live mirror (expDataSyncListener): the kernel notes published, moved, removed, purged and trashed objects and writes them to the tables of the live synchronisations of their class at the end of the request; the events content/publish, content/move and content/remove.
  • The workflow event Mirror to data tables (expdatatablemirror) for the mirror as a step of a workflow.
  • The cronjob part datatable (cronjobs/datatablesync.php, also in frequent) for the scheduled synchronisations.
  • Conflict rules for two-way runs (the newer change, the content, the table, or ask) and the page of open conflicts (datatable/conflicts) with Take the content and Take the table.

Tests

  • The two tables are the same in share/db_schema.dba, the persistent classes, the three kernel schemas and the update files; the catalogue, the description, the connections, persistent classes on a SQLite connection, the secret store's encryption, key and rotation, and expPDODB (all without a database).
  • The schema (definitions, the SQL of every engine, plan and apply on SQLite), the writer, the relations, the fetch functions and the registry entry, on a SQLite connection (without an installation).
  • The module table wizard: the field list and sources, the settings and their problems, every generated file (PHP syntax, no inline styles), adding a table to an existing extension, and the generated class on a SQLite connection.
  • The synchronisation: its values, map and problems, hashes, keys, conflict rules and typed values; definitions, pairs and conflicts on SQLite; the live mirror's listener, the cronjob part, the workflow event type and the kernel's calls of the listener; its three tables in every schema and update file.

2026-10-22: image variations follow StorageFilePermissions

New images and their variations took their mode from image.ini [FileSettings] ImagePermissions, shipped as 0666, not from site.ini [FileSettings] StorageFilePermissions. An installation that narrowed only the latter (for example to 0644) still wrote every variation world-writable: measured on a production site, 215 variations were -rw-rw-rw- while every other stored file was 0644.

Updated

  • image.ini [FileSettings] ImagePermissions ships empty. Empty means the mode of every other stored file, site.ini [FileSettings] StorageFilePermissions; a value of its own still wins. With the shipped settings nothing changes (both are 0666). eZImageHandler::imagePermissions() returns the mode that applies.
  • Upgrading: an installation that sets StorageFilePermissions and wants images to follow it needs no change; one that relies on images being wider than its other files sets ImagePermissions itself. Variations written before keep their mode: find var -type f -perm -o+w lists them.
  • The documentation of the setting, the file modes page and the hosting page on writable directories say so (English and German); the hosting page also says that image.ini DirPermissions is not read (image directories are made with StorageDirPermissions).

Tests

  • expFileModeLimitsTest FM-09: an empty ImagePermissions takes StorageFilePermissions, a value of its own wins.

2026-10-22: expimageeditor, an image editor in the browser under the Content Security Policy

A new extension, expimageeditor, edits the image of an image attribute in admin4 in the browser; the server only stores the result. It works without jQuery and under the administration's Content Security Policy (no inline script or style). It is shipped with the kernel but not active: activate it with ActiveExtensions[]=expimageeditor and switch ezie off. Guide: extension/expimageeditor/README.md.

Added

  • Raster images (JPEG, PNG, WebP) with Cropper.js 2.3.1 and canvas tools: crop (free, the original shape, the fixed shapes of the image aliases and AspectRatios[]), rotate by 90 degrees or an angle with a background color, flip, resize, brightness, contrast, black and white, sepia, pixelate or blur a region, a watermark (a text or an image of Watermarks[]), undo and redo, zoom.
  • SVG images with an admin4 toolbar on the drawing engine @svgedit/svgcanvas 7.4.2: select, rectangle, ellipse, line, path, freehand, text, delete, duplicate, bring to front and send to back, fill, stroke, stroke width, opacity, font size, undo and redo, zoom. The full SVG-Edit interface is not used: it adds <style> elements and style attributes the policy refuses.
  • Fonts of the site (Fonts[], loaded with the FontFace API) for the watermark and the SVG text; in an SVG, text in such a font can be saved as paths (opentype.js, loaded on the first save that needs it), so the image looks the same where the font is not installed.
  • The view expimageeditor/save/<attribute id>/<version> (policy function expimageeditor/edit): it takes the result only for the user's own draft with edit access in the attribute's language and the form token in the X-CSRF-Token header, checks the file by its content (eZImageType::imageFileError()), keeps an SVG an SVG and a raster image a raster image, allows the types of OutputMIMETypes[] and the class attribute's maximum size, and stores it through eZImageAliasHandler, an SVG only as the output of expSvgSanitizer. Cancel leaves the draft untouched; the previous version keeps its image.
  • Keyboard use (the toolbar is one tab stop with the arrow keys, shortcuts, the focus returns to the button), light and dark color modes, English and German texts. The dialog uses admin4's own controls (.a4-btn, .a4-btn-primary, .a4-icon-btn, .a4-field, .a4-input).
  • New SVG drawing beside an image attribute without an image, where SVG is accepted: the SVG editor starts with an empty drawing of NewDrawingSize (800x600) and the save view stores it as drawing.svg. admin4's content/datatype/edit/ezimage.tpl includes the new ezimage_new.tpl there; admin4's own copy draws nothing.
  • Black and white and sepia are shown on the image at once (selection or whole image) and become a step with Apply.
  • Sample fonts under the SIL Open Font License 1.1, not configured by default: Lora, and Noto Serif and Noto Sans 2.015 (Latin, Greek, Cyrillic, Vietnamese).

Tests

  • expImageEditorStoreTest (without a database): the stored name, broken uploads, the kind of image, the output types, a hostile SVG, the maximum file size, and that only the user's own draft is editable.
  • expImageEditorDesignTest (files only): the template's guard and data, every text translated and used, every icon with its file and class, the scripts it names, and no inline code or style in the scripts.
  • AdminContentSecurityPolicyTest checks the templates of expimageeditor as a shipped extension. Suite expimageeditor.

2026-10-22: a section is assigned to the whole subtree, the subtree without the branches in another section, or the object only

Every place that assigns a section (section/assign with the content browser, the Details tab of a node, the edit form, the content job section and ./console expcontentjob section --scope=...) offers which objects below the chosen item get the section: all of them (subtree, the default and the behavior before), those in the section the item had (inherited: an object below in another section keeps its section, with everything below it), or the item alone (object). Guide: Sections, section 8.5.

Added

  • eZSection::SCOPE_SUBTREE, SCOPE_INHERITED, SCOPE_OBJECT, scopes(), isValidScope(), scopeFromHTTP() (the POST variable SectionScope); eZSection::applyTo(), eZContentObjectTreeNode::assignSectionToSubTree(), eZContentOperationCollection::updateSection() and the operation content/updatesection take the scope as an optional parameter whose default is the old behavior. sectionAssignmentObjectIDs(), sectionBoundaryPaths() and pathIsInAny() find the objects; the content job builds its work list once, before anything changes.
  • The choice in admin and admin4: above the content browser of section/assign, beside the section of the Details tab and in the edit form's section menu (page/section_browse_assign.js, no inline script or style).
  • The audit event content.node.section names the scope (after.scope).
  • eZSection::applyToOrStartJob(): from the Details tab and the edit form, a subtree with more nodes than content.ini [ContentJobSettings] NowLimit (1000) is assigned by the content job section, in batches; the Details tab opens its progress page. The object alone is always done at once.
  • eZContentObjectTreeNode::sectionAssignableObjectIDs(): the objects the user may assign a section to.

Updated

  • section/assign (done at once) and eZSection::applyTo() change only the objects the user's section/assign limitations (Class, Section, Owner, Subtree) allow, as the content job already did. assignSectionToSubTree() and content/updatesection check it only when asked (check_access, default false), so the kernel's own changes (a new main location takes its parent's section) are not narrowed.
  • applyTo() updates the search index once, after the commit, with the objects that changed.

Tests

  • eZSectionScopeTest SC-01 to SC-13: the scopes, the operation's parameters, each mode on a small tree of its own (subtree, inherited, object, an unknown scope), the tree function and the operation, the content job, the access check with and without limitations, the audit event, and the content job above NowLimit. AdminSectionScopeTest: the choice in the templates of admin and admin4.

2026-10-22: refinements of the image editor, the section scopes and the image modes

Updated

  • expimageeditor re-encodes a raster image on the server before it is stored (GD decodes it and writes it anew, expimageeditor.ini ReEncodeRaster, enabled): nothing but the pixels reaches the storage. The copy lives under a random name in <cache directory>/expimageeditor/ and is removed when the request ends; stale copies after an hour.
  • expimageeditor has limits of its own, checked before anything is decoded: MaxPixels (25 megapixels) and MaxFileSize (20 MB).
  • The save view of expimageeditor compares X-CSRF-Token with the session's form token while ezformtoken is active, and clears the stat cache before it looks for the stored file (a Velocity worker remembers file answers within a request).
  • expimageeditor carries the version it ships with (6.0.15) in extension.xml and ezinfo.php, and is on exphelp.ini [IssueTrackers] BundledExtensions[]. Documentation in English and German: expimageeditor. The dialog says "color" (American English).
  • An image variation without a usable mode (ImagePermissions and StorageFilePermissions both empty, or a value that is not an octal mode) gets the shipped 0666, limited as every other mode, never mode 0.

Tests

  • expImageEditorStoreTest IE-09 to IE-12: the re-encoding, the limits, the sanitizer, the form token and the temporary files. expFileModeLimitsTest FM-10: the fallback mode of an image variation.

2026-10-22: admin4's frame, tree, node view, edit pages and dashboard run on plain JavaScript

Merged pull requests #301, #303, #305, #306 and #307 (contributed by ekked).

Updated

  • admin4's menu bar, sidebars and column grip, and the content structure tree (admin and admin4) run without jQuery; the tree loads its sub items with fetch() (#301).
  • admin4's node view: the tabs, the sub items table on Exp.datatable.create() and the drag and drop of the priorities run without jQuery (#303).
  • The relation upload (expajaxuploader.js, expAjaxUploader(), with the jQuery plugin kept where a page has jQuery) and the relation search (admin and admin4), and queued publishing run without jQuery; admin4's class edit binds its move buttons and keeps the focus on the button pressed (#305).
  • The Online Editor requires jQuery only for the built-in TinyMCE 3 (#306).
  • The dashboard's community activity block runs without jQuery (#307).
  • a4_dom.js, loaded by admin4's page head right after a4_url.js: the helpers the scripts on plain JavaScript share (a4Dom.ready, each, a delegated on, formToken, headers, a form post with the form token, the CSRF header and X-Requested-With, and getJSON). Class edit and the node tabs use it; class edit listens once on the attribute list and marks it aria-busy while a move is saved, and still puts back a move the server did not store.
  • admin4's relation search template requires Exponential UI (exp::core::shared, exp::io), which its script runs on, instead of jQuery.

Fixed

  • The community activity block writes the forum posts as text (title, author, date), links only http and https addresses, and shows "The latest forum posts could not be loaded." instead of loading for ever when the feed fails.
  • Queued publishing shows the server's error text as text, not as markup.
  • The relation search escapes the box id of the answer before it uses it in a selector, and an answer without content no longer throws.

Tests

  • AdminPageScriptsWithoutJQueryTest (without a database), extended to the sub items page script and the two relation upload page scripts, and to every other script converted here (frame, tree, node tabs, sub items, drag and drop, relation search and upload, queued publishing, dashboard, a4_dom.js): no jQuery, no eval or string timers, no inline handlers; the head loads a4_dom.js in both lists; the queued publishing and relation search templates require no jQuery; the Online Editor asks for jQuery only for TinyMCE 3; answers go in as text.
  • AdminClassEditMoveTest (#305) checks the move is posted through a4Dom.post(); the node tabs harness runs a4_dom.js before node_tabs.js.

2026-10-23: the node view's Tabs switch shows the new state at once

Fixed

  • Switching the node view's "Tabs" on or off left an empty view (no panel, or no blocks) until the page was reloaded by hand: the server draws "with tabs" and "without tabs" with different markup, and since the node view's scripts run on plain JavaScript the switch only changed classes. It now saves the preference and loads the page again once the save is done (the switch's own link, a GET, if the save fails), in admin4 and in the classic admin.

Tests

  • AdminKeyboardTest checks that both switches load the page again after saving.

2026-10-23: URL aliases for titles in scripts without a transliteration table

Under the shipped site.ini [URLTranslator] TransformationGroup=urlalias a title in a script without a transliteration table lost every letter: Arabic دليل المستخدم, Persian, Pashto, Chinese 用户手册, Japanese, Korean, Thai, Devanagari, Bengali, Georgian and Armenian titles gave an empty alias, so the node was called node_<node id> (a custom alias noname1, any other element _1). Latin, Cyrillic, Greek and Hebrew are transliterated and were not affected.

Added

  • site.ini [URLTranslator] ScriptFallbackTransformationGroup, shipped as urlalias_iri: when the configured transformation leaves no letter of a title that has letters, that title alone is converted with this group (دليل المستخدم becomes دليل-المستخدم). A title that keeps a letter, also a mixed one (Exponential دليل stays exponential), gets exactly the alias it got before; a title without letters still gets node_<node id>, noname1 or _1. Empty turns it off. eZURLAliasML::convertToAlias() applies it, so it covers node aliases, custom and global aliases, updateniceurls.php and image file names; convertToAliasCompat() (path_identification_string) is unchanged.
  • Upgrading: existing aliases stay as they are. A node with such a title gets the new alias when it is published again or the aliases are rebuilt, and the old node_<node id> alias stays as a redirect to it.
  • Titles in scripts without a transliteration table (English and German), the URL alias settings of the help and the right-to-left section of the languages page say so.

Tests

  • eZURLAliasMLScriptFallbackTest (SF-01 to SF-06, no database): Arabic, Persian, Chinese and Thai get the IRI alias; Latin, Cyrillic, Greek and Hebrew keep today's aliases; the mixed title rule; titles without letters; the setting off; urlalias_compat and urlalias_iri as configured group, convertToAliasCompat() and identifier unaffected. eZURLAliasMLTest (database group) checks the plain groups with the fallback off.

2026-10-23: month names on their own and inside a date

A locale file had one list of month names, used inside a date and on its own. Languages that inflect the month name need two: the calendar of month_overview read октября 2025 in Russian instead of октябрь 2025.

Added

  • Locale files: the optional groups [LongMonthNamesStandalone] and [ShortMonthNamesStandalone] (jan ... dec), the month names as they read on their own (CLDR's stand-alone form, LLLL/LLL). [LongMonthNames] and [ShortMonthNames] stay the form used inside a date (MMMM/MMM).
  • eZLocale::longMonthNameStandalone(), shortMonthNameStandalone() and monthsNamesStandalone(), the template attribute month_name_standalone_list; each falls back, month by month, to the normal name where a locale has no stand-alone name.
  • The stand-alone groups, from CLDR, in 51 locale files whose stand-alone names differ from the format names, among them pol-PL, cze-CZ, slk-SK, cro-HR, rus-RU, ukr-UA, bel-BY, lit-LT, fin-FI, ell-GR, hye-AM and cat-ES.

Updated

  • month_overview (the month and the previous, current and next months) and the month list of shop/statistics show the stand-alone name.

Fixed

  • pol-PL, cze-CZ, cro-HR, slk-SK, fin-FI and cat-ES print the month inside a date in the form CLDR uses there, with CLDR's day names and long date formats: środa, 15 października 2025, dimecres, 15 d’octubre del 2025. ukr-UA prints 15 жовтня instead of 15 Жовтень, and its short month names are CLDR's (жовт.). The short November of ell-GR is written with a Greek Ν again (Νοε, it had a Latin N).

Tests

  • expLocaleStandaloneMonthNamesTest (suite lib, no database): the format form in dates and the stand-alone form on its own for twelve inflecting locales, the fallback, and that every stand-alone group has twelve names and differs from its format group.
  • eZKernelCommonOperatorsTest: month_overview names the months in the stand-alone form (rus-RU, pol-PL).

2026-10-23: admin4 and admin4l load jQuery only on the pages that request it

Updated

  • admin4's page_head_script.tpl no longer prepends ezjsc::jquery and ezjsc::jqueryio on every page: its first ezscript_load starts with a4_url.js and a4_dom.js, then design.ini [JavaScriptSettings] BackendJavaScriptList[], then the scripts the page's templates requested. The kernel's admin4 pages request no jQuery; a template whose script needs it, or an Exponential UI module, requests it with ezscript_require, outside every cache-block.
  • The page layouts of admin4 and admin4l include page_head_script.tpl outside the head's cache-block, so the head prints what the page requested on every request, and a request made later in the page prints its scripts in place.
  • The templates that run on Exponential UI request the modules they use instead of relying on expui's BackendJavaScriptList[]: the sub-items table (exp::io, exp::datatable), the toolbars of content edit and class edit (exp::sticky), the date fields (exp::datepicker) and the relation upload (exp::io, exp::dialog, exp::upload).
  • Reduced motion also switches jQuery's effects off when a page loads jQuery after admin4.js.

Added

  • design.ini [JavaScriptSettings] ClassicBackendJavaScriptList[], empty: the older admin designs (admin, admin2, admin3) load it on every page after jQuery and BackendJavaScriptList[]; admin4 and admin4l do not read it. An extension moves its jQuery plugins and exp:: modules there from BackendJavaScriptList[].

Upgrading

  • An extension or override that puts a script needing jQuery or an exp:: module into BackendJavaScriptList[] has to request it from its admin4 templates (and list it in ClassicBackendJavaScriptList[] for the older designs).
  • Several packages of the root composer.json still put jQuery or Exponential UI on every page and follow in their own releases: eztags 2.4.24 (which keeps jQuery on every admin4 page while it is active), expui 1.0.0.15, xrowmetadata 1.4.16 and expai 0.0.1.17; ezautosave, ezmultiupload, ezstarrating and the layout editor of explayouts_ui_api run on jQuery until theirs. See admin4 and admin4l without jQuery.

Tests

  • AdminJQueryOnDemandTest (JQ-01 to JQ-13, without a database): admin4's head loads no jQuery, the kernel's BackendJavaScriptList[] names nothing that needs jQuery or Exponential UI, every admin template that loads a script calling jQuery requests it, no such request sits in a cache-block, the page layouts print the head's scripts outside the cache-blocks, the older designs read ClassicBackendJavaScriptList[], and the node view and edit pages request only the modules they use. JQ-13 checks the installed packages; it is skipped while a package still puts jQuery on every page, and names what still relies on it.
  • AdminPlainScriptsExpUIPacksTest counts ClassicBackendJavaScriptList[] for the admin design; AdminContextMenuTest, AdminHeaderTest, AdminPageScriptsWithoutJQueryTest, AdminSiteAccessMenuTest and AdminSubitemsPriorityDragTest follow the new head.

2026-10-23: the layout editor runs without jQuery

Needs explayouts_ui_api 1.3.30 (se7enxweb/explayouts_ui_api pull request #12); composer.json requires se7enxweb/explayouts-ui-api ~1.3.30.

Updated

  • The layout editor of Layouts loads no jQuery, no jQuery UI, no Bootstrap JavaScript and no Moment.js: app.tpl no longer requests ezjsc::jquery and ezjsc::jqueryUI, and its app bundle runs on a small DOM layer of its own. The page loads 529 KB less.
  • Blocks, block types and collection items are dragged with the editor's own drag and drop on Pointer Events: mouse, touch and pen at every screen width, and the canvas keeps scrolling while the pointer is held at its edge.
  • Dialogs of the editor say what they are, keep the focus inside while open and give it back on close; the date field is the browser's own and sends the same value as before.

Added

  • Blocks, block types and collection items move with the keyboard: Space or Enter picks up, the arrow keys move (into the zone before or after), Home and End, Space or Enter drops, Escape cancels; every step is announced.

Fixed

  • The Options, Items and Advanced panels of the editor's sidebar fold again: under jQuery 4 Bootstrap's collapse stopped with an error on the first click.

Tests

  • AdminLayoutEditorWithoutJQueryTest (without a database): the editor page requests no jQuery, loads only the checked scripts, none of them uses jQuery, the bundle carries neither jQuery nor jQuery UI, and no setting adds jQuery to the page. Skipped, with the reason, while explayouts_ui_api is missing or older than 1.3.30.

2026-10-23: demo mode stays with its installation under Velocity, and admin3 shows the demo banner

Updated

  • Exponential Velocity: EXP_DEMO_MODE=1 in the shared /etc/vc/envvars no longer switches demo mode on. Every installation's Velocity server on a host reads that file, so a demo set up there switched demo mode on for every other site of the host too, and their administrators could no longer change or reset a password. A Velocity worker now refuses a demo mode value the engine took from an envvars file of a configuration directory outside the installation, keeps demo mode off and records demo/refused ... once per process in var/log/security.log; exp:velocity start no longer passes the shared file's EXP_DEMO_MODE. A tree inside the installation (var/vc/qbix/etc) still counts as the installation's own. PHP-FPM, Apache, LiteSpeed, FrankenPHP and the console are unchanged. Demo mode.
  • Demo mode is recorded once per PHP-FPM, mod_php, LiteSpeed or built-in server process (an empty marker file per process in the cache directory, <cache>/demo-mode/<pid>-<start time>), no longer on every request: such a process forgets PHP's statics between requests, so a demo wrote a demo/mode line to security.log and a system.demo.mode audit event for every page.
  • The system card (exp:info, Setup > System information) reads Velocity's demo mode from the installation's site envvars, and shows refused (shared envvars) with a warning when the shared file still has it.
  • The demo mode pages, the Velocity pages, the environment variable reference, the hosting runbook and recipe and the demo mode examples, in English and German, name the site envvars as the Velocity place for demo mode.

Added

  • The site envvars of an installation, /etc/vc/sites-available/<site>.envvars beside its site file: exp:velocity start reads it for that installation's server only (after the shared file, so it wins; the same rules as envvars), and exp:velocity layout lists it on the line site envvars. The engine loads only *.conf there.
  • admin3 run as its own design shows the demo mode banner, at the top of its own main area (design/admin3/templates/page_mainarea.tpl, outside every cache-block); with admin4 first, admin4's main area draws it, so it shows once whatever the order of the designs.

Upgrading

  • A public demo served by Velocity with EXP_DEMO_MODE=1 in /etc/vc/envvars has demo mode off after the restart that brings this update. Move the line to the demo's /etc/vc/sites-available/<site>.envvars (<site>: ./console exp:velocity layout) and remove it from the shared file before restarting. Updating by copying files: clear the template-override cache for the new admin3 template.

Tests

  • expDemoModeTest DM-13: a Velocity value from a shared envvars file is not taken; one from a tree inside the installation, the start environment or the site envvars is; outside Velocity nothing changes. DM-09 covers admin3.
  • expVelocityConfigLayoutTest: the shared envvars never passes EXP_DEMO_MODE, the site envvars does and wins, and another installation's site file is never read. expSysCardProvidersTest: shared file refused, site file on.

2026-10-23: the Anonymous role of a new installation lets visitors register

Fixed

  • A new installation takes user/register out of PolicyOmitList[] (since 12 October), but until the base data carried User / Register (19 October) no site package granted it, so an installation made in between refused every registration. The setup wizard and the kickstarter now check, after the site package's roles, that the Anonymous role has user/register and add it when it is missing (expAnonymousPublicPolicies::ensure() in kernel/setup/steps/ezstep_create_sites.php), for every site package, base data and database engine.

Added

  • bin/php/addanonymouspublicpolicies.php (--dry-run, --only, --skip, --role): gives the Anonymous role the public policies it lacks, user/register, rss/feed, and where the module is installed tags/view and the newsletter's subscribe, configure, unsubscribe and archive; a policy the role has in any form is left alone, a second run adds nothing, the role cache is expired and the result checked (PASS/FAIL).
  • kernel/classes/expanonymouspublicpolicies.php (expAnonymousPublicPolicies): the list, the plan and the grant.
  • Upgrade note with the policies a public site gives the Anonymous role and where each comes from.

Tests

  • expAnonymousPublicPoliciesTest (without a database): the plan for the old and the current base data, policies of extensions only where their module exists, existing policies in any form left alone, --only and --skip.

2026-10-23: the role and policy template operators answer for the Administrator, and every documented example works

Updated

  • has_policy and has_policy_by_user (role and policy template operators) count a policy for every module (*, the Administrator role's) or for every function of a module (content/*), as the kernel's access check does. Before, eZRole::hasPolicy() was asked, which compares names only, so has_policy( 'content', 'edit' ) was false for the admin user and the matching code meant for * was never reached.
  • has_role_by_user answers false when it gets something that is no user object (a number, an empty variable), as has_policy_by_user already did; before, the template stopped with "Call to a member function attribute() on int".
  • A user_id passed as a string ('0', $node.object.owner_id) is read as a number, so '0' also means the current user.
  • The class comment and the documentation show only forms that work: has_role_by_user_id( 42, 1 ) instead of 42|has_role_by_user_id( 1 ) (the piped value is ignored, so that form asked about user 1 and role 0), and the match_all argument by position instead of a named argument the template language does not have. The English and German feature, help and designer pages say how * policies count and drop the note that the registration was still to come.

Tests

  • eZTemplateRoleOperatorLiveTest (installation's database; skipped without one): a throwaway user in a throwaway group with two throwaway roles, the admin user and the anonymous user; every operator and every example of the feature page, rendered interpreted and compiled, with the same output both ways. Everything it creates is removed again.
  • eZTemplateRoleOperatorRegistrationTest (no database): has_role_by_user( 42, 1 ) and has_policy_by_user( 42, 'content', 'read' ) render false without an error.

2026-10-23: every new installation requires two-step sign-in of administrators and editors, and has imports

Updated

  • The setup wizard, the kickstarter and exp:install give every new installation, whatever its site package, what the maintained packages (sevenx_multisite, sevenx_site, cjw_multisite and their _clean copies) set up themselves (expInstallDefaultExtensions), each part only when the extension is in the installation and the package left it out:
    • two-step sign-in (sevenx_authentication_2fa): switched on globally, its login handler before standard, and settings/override/sevenxauthentication2fa.ini.append.php with Enforce2FA=enabled for the groups Administrator users and Editors (EnforcedGroups[], by remote id), DefaultMethod=totp, RecoveryCodes=10, no exemptions, no grace period and a SecretKey made for the installation; the user class gets the "Two-step sign-in" field, and the roles Member, Editor and Partner the policies user2fa/setup and user2fa/verify. Members of other groups choose for themselves.
    • imports (sqliimport 2.0): switched on in the administration siteaccess, after expui and expqueue when they are not global, with its tables and those of expqueue, and its Imports tab after Export where the package wrote a tab list. The editor siteaccess is made without it. Its schedules need the cron parts expqueue and sqliimport every minute with the admin siteaccess (php runcronjobs.php -q -s admin expqueue, and sqliimport).
    • ezodf stays in the wizard's own list (7.0: no tables; its converter only where LibreOffice is installed).
  • composer.json requires se7enxweb/sqliimport ~2.0.0 and se7enxweb/ezodf ~7.0.0.
  • The demo rebuild scripts (doc/eng-us/examples/demo-mode/) switch two-step sign-in off after the installation, because a public demo's visitors share one administrator account (DEMO_ENFORCE_2FA=1 keeps it on); crontab-demo runs the queue and the imports every minute.

Tests

  • expInstallDefaultExtensionsTest (without a database): the policy, a package with and without its own two-factor settings, the admin's access extensions with and without global dependencies, the editor without imports, the position of the Imports tab, and which policies grant the second step.

2026-10-23: editors of a new installation can hide, restore from the trash and multi-upload

Fixed

  • The site packages replace the Editor role's content/* with single functions and left out content/hide, content/restore and content/reverserelatedlist, and none granted ezmultiupload: an editor could remove an object to the trash but not get it back, could not hide or reveal anything, could not open the list of what uses an object, and the media library's multi-upload was refused. The setup wizard and the kickstarter now check, after the site package's roles, that the Editor role has them and add what is missing (expEditorPolicies::ensure() in kernel/setup/steps/ezstep_create_sites.php); also user/password, user/selfedit and notification/use when no other role of the Editors group has them (plain_site, which assigns no Member role).
  • The installers of sevenx_multisite, sevenx_multisite_clean, sevenx_site, sevenx_site_clean and cjw_multisite grant the four policies themselves and let editors create geo_article and video below a folder.

Added

  • bin/php/addeditorpolicies.php (--dry-run, --only, --skip, --role): gives the Editor role of an existing installation the everyday policies it lacks; a policy the role, or another role of the same groups, has in any form is left alone, a second run adds nothing, the role cache is expired and the result checked (PASS/FAIL). WARN lines name what the role holds that an editor role should not (emptying everybody's trash, the installation's languages, URL aliases and wildcards, everybody's content jobs, the administration modules); nothing is removed.
  • kernel/classes/expeditorpolicies.php (expEditorPolicies): the list, the plan, the grant and the over-grant report.
  • Upgrade note with what an editor could not do, what each package's Editor role holds, and what stays with administrators.

Tests

  • expEditorPoliciesTest (without a database): the plan for a package's Editor role with and without the Member role alongside, the base data's content/*, extension policies only where the module exists, existing policies in any form left alone, --only and --skip, and the over-grant report.

2026-10-23: editors get the image editor

Fixed

  • The image editor answered "module not found" (kernel error 20) on a development installation for every user: the installation had switched ezie off in favour of expimageeditor (root pull request #302), so ezie/* addresses no longer exist; the edit form offers expimageeditor's Edit image in its place. Editors saw no image editor at all, since the button is drawn only for a user with expimageeditor/edit and no role gave it to them. expEditorPolicies now plans expimageeditor/edit for the Editor role where the module exists: the setup wizard and the kickstarter add it, and php bin/php/addeditorpolicies.php --apply --only=expimageeditor/edit adds it on an installation that exists. The save view still checks that the draft is the user's own and that the user may edit the object.

Tests

  • expEditorPoliciesTest::testImageEditorWhereTheModuleExists (no database).

2026-10-23: editors manage the site's members, with every action in the audit log

Added

  • Member management (settings/membermanagement.ini, kernel/classes/expmembermanagement.php): rules say which user groups manage the accounts of which others, with which policy set, in which role; protected groups are never managed through a rule. The default gives the Editors group the role "Member management" over Members: create, edit (name, e-mail, login, password), enable and disable, move and remove members, with the Users tab; never the accounts of Administrator users or Editors, never roles, sections or setup. The setup wizard and the kickstarter apply the rules after the site package's roles (kernel/setup/steps/ezstep_create_sites.php).
  • bin/php/addmembermanagement.php (--dry-run, --rule): applies the rules on an installation that exists; creates the role when missing, adds only what the role lacks, assigns it to the manager groups, checks the result (PASS/FAIL).
  • Audit events access.user.change (the fields of an account that changed, before and after, never the account attribute; login, e-mail and password keep their own events) and access.user.move (an account moved to another group). Refusals (access.permission.refused) name the account a refused view was about (id and login).
  • Member management: the default, the settings, the guard, the audit events, variations, the upgrade.

Changed

  • The access check of an account with a location below a protected group: an allowed edit, move, remove, location change, hide, translate or version removal holds only when each of those locations allows it on its own (eZContentObject::checkAccess(), eZContentObjectTreeNode::checkAccess()); a Subtree policy on Members no longer reaches an administrator who also has a location there. A managed group itself (Members) needs the function at its parent, so a manager cannot rename, move or remove the whole group.
  • access.permission.refused names the object of a refused content/action (a move, a removal) from the form.
  • user/setting (enable, disable, concurrent logins) also asks for content/edit of the account ([MemberGuardSettings] SettingRequiresEdit=enabled): user/preferences alone, a function without limitations, reached every account of the installation.

Tests

  • expMemberManagementTest (no database): the policy line format, the default plan, what a manager must not get (wildcards, administration modules, unknown classes, own Subtree/Node), rules that reach a protected group or the manager's own group, a second run that adds nothing, and the shipped settings.

2026-10-23: the legacy admin of a Platform Legacy project without script, toolbar and icon errors

Added

  • site.ini [ExtensionSettings] RequiredExtensions[] (shipped: expui) and eZExtension::requiredExtensions(): the extensions the kernel's own designs need. Each one that is installed is active in every siteaccess, ahead of ActiveExtensions[], whatever an override sets there; one that is not installed is left out without an error. The admin designs' sub-items table, relation search, uploads and date fields request the exp:: script packs of Exponential UI (exp::core::shared, exp::io, exp::datatable ...), which the kernel package installs. A Platform Legacy project resets ActiveExtensions[] in its own override without expui, and ezjscore logged "Not a valid ezjscServer function" for each pack on every legacy admin page. Nothing is to be changed in a project: the kernel activates what it requires, and a project on 6.0.14 (which has neither) gets no "does not exist" error from an entry it does not need. eZExtension::extensionLists() counts them as active in every siteaccess.

Updated

  • {tool_bar name=...} draws no tools, without an error, for a toolbar the siteaccess does not configure. A siteaccess that only falls back to the admin design (the front siteaccess of a Platform Legacy project rendering a legacy 404) logged "Undefined group: 'Toolbar_admin_right' in toolbar.ini" and the same for admin_developer on each page.
  • The icon operators (class_icon, classgroup_icon, mimetype_icon, icon, flag_icon, icon_info) look up share/icons under the legacy root when eZSys::siteDir() is no directory. Under a Symfony front controller (the Legacy Bridge's web/app.php or public/index.php) it is <web dir>/app.php/: every theme was skipped, each icon was logged as "Missing icon file" and pointed at share/icons/crystal/<size name>/mimetypes/empty.png, a 404.

Tests

  • eZExtensionRequiredExtensionsTest (no database): an installed required extension is active ahead of a reset ActiveExtensions[], once, and one that is not installed is left out without an error.
  • eZWordToImageOperatorIconRootDirectoryTest (no database): with the site directory of a Symfony front controller the classic admin's icons resolve to their crystal-admin files, without an error.
  • eZTemplateToolbarUnconfiguredTest (no database): a toolbar without a [Toolbar_<name>] group renders empty and logs no error, interpreted and compiled.

2026-10-23: change password at next sign-in

An administrator who creates an account with a temporary password can make the user replace it: after the next sign-in the user is sent to user/password before any other page, on every siteaccess, until a new password is set (feature page, upgrade note).

Added

  • The checkbox Change password at next sign-in on the user account edit form (design/standard and design/admin4), ticked by default when an administrator creates an account for someone else, never shown on one's own account or on self-registration.
  • expPasswordChangeRequired (kernel/classes/user/exppasswordchangerequired.php): the mark is the user preference exp_password_change_required (ezpreferences), so no schema change and no update script on any engine. The check runs before every view in ezpKernelWeb::dispatchLoop(); site.ini [PasswordChangeRequiredSettings] ExemptViews[] keeps user/password, user/logout and the two-step sign-in (user2fa) open. Two-step sign-in comes first.
  • The REST interface answers 403 {"error":"password_change_required"} for a marked account (expRestPasswordChangeRequiredStatus); WebDAV and SOAP refuse its sign-in.
  • Console command exp:user-password-change-required status|set|clear --login=<login>|--user-id=<id> [--json].
  • Audit events access.user.password.change_required.set, .clear and .redirect.
  • The texts in the complete kernel catalogues (German, Spanish for Spain and Mexico, French, Hebrew, Arabic, Ukrainian, Latin, Klingon) and the source catalogue.

Updated

  • user/password clears the mark after a change that went through and shows why the user is there ($password_change_required; design/standard, design/admin, design/admin4).
  • user/preferences refuses to write the preference exp_password_change_required.
  • Demo mode (EXP_DEMO_MODE=1): the mark is ignored for administrators, whose passwords cannot change there, so it never locks them out.

Tests

  • expPasswordChangeRequiredTest (without a database): the mark's values, the decision (demo mode never locks an administrator out, a non-administrator still is sent), the views that stay open, the checkbox's default and who may choose it, the posted form and the draft, the reserved preference, the REST answer and its wiring, the kernel hook and its place, the audit names, the templates and the catalogues.

2026-10-23: Remember me is on by default and lasts the whole sign-in

Updated

  • iOS Safari and Chrome drop cookies that end with the browser session on their own, and signed users out with them. site.ini [Session] RememberMeTimeout is now 31104000 (360 days, before: empty, which hid the box) and the Remember me box of the login forms (design/standard, design/admin, design/admin4) is checked by default. SessionTimeout is 31104000 as well (before: 259200, three days), so the server keeps an idle session as long as the cookie can bring it back. A site that wants the old behaviour sets both in settings/override/site.ini.append.php.
  • Remember me works when a session already runs at sign-in, which is the usual case: eZSession::setCookieLifetime() cannot change the cookie of a running session, so the box had no effect there. The login view now calls eZSession::rememberCookieLifetime(), which keeps the lifetime in the session and sends the session cookie again with it, replacing the one of the response. eZSession::regenerate() sends every later new session id with the same lifetime, also on a later request: the code step of a two-factor sign-in no longer turns the cookie back into one that ends with the browser. The cookie sent with every response (CookieAlwaysAddToHttpResponse) and the is_logged_in cookie last as long. The same code runs under Apache (PHP-FPM) and Velocity. A sign-in with the box cleared forgets a lifetime remembered before.
  • With PHP's file sessions (the default handler) the session directory holds about 360 days of sessions instead of three; each file is a few kilobytes. The Sessions guide says what the defaults are.

Tests

  • eZSessionRememberMeTest (no database): the lifetime kept in and forgotten from the session, the cookies that last as long, a persistent worker's next request not inheriting it, the session cookie replaced rather than sent twice, the new id of regenerate() sent with it, the defaults and the checked box of every kernel login form.

2026-10-24: the installers write the cron job of the queue and the imports

Added

  • New installations activate expqueue and sqliimport (and expai in the administration), but nothing ran runcronjobs.php for them or for the kernel's parts: queued jobs, scheduled imports, notifications and the search index waited until somebody wrote a crontab by hand. The setup wizard, the kickstarter and exp:install now write the cron job in their last step (eZStepFinal, kernel/classes/expcrontabsetup.php): one line per cronjob part of cronjob.ini [CrontabSettings] Parts[] that the installation defines (global, frequent, infrequent, and expqueue, sqliimport, expai where their extension is active), with the PHP binary, the admin siteaccess for AdminParts[] and the schedules of [AdminSettings] CrontabSchedule_<part> (the queue and the imports every minute). The lines go into the crontab of the system user that owns the installation (its own crontab, or crontab -u <owner> from root), never into root's crontab for a site, between # BEGIN Exponential cronjobs: <root> and # END Exponential cronjobs: <root>: a second run replaces the block or writes nothing, every other line stays as it was, and a part that a line outside the block runs already is left out. Without a crontab (a container, no crontab command, no permission, another user) the installation finishes and the installer prints the lines to add; the wizard shows them on its last page.
  • [CrontabSettings] Install=disabled (or exp:install --no-crontab, EXP_INSTALL_CRONTAB=0) switches it off: the lines are printed instead.
  • bin/php/installcrontab.php (--show, --add, --remove, --dry-run, --admin-siteaccess): the same on an installation that exists, with the lines that would go and come, and PASS/FAIL.
  • runcronjobs.php records each part it starts and finishes in var/log/cronjob-lastrun.json. The dashboard has the block Cronjobs (dashboard.ini [DashboardBlock_cronjobs], for setup/managecronjobs, fetch fetch( 'setup', 'cronjob_status' )): each part, its schedule, its last start, and a warning when no part has run yet or one is late (no start for twice its interval and five minutes, at least an hour). exp:syscard reads the record too. Upgrade note.

Tests

  • expCrontabSetupTest (no database, no crontab: the crontab command is a callable): the lines and how Setup > Cronjobs reads them back, the block appended, replaced in place and taken out with every other line kept, other installations' blocks, hand-written lines found and their parts left out, whose crontab (owner, root with -u, never root's or a third user's), the fallback without a crontab command and on a refused one, dry runs that write nothing, the parts of an installation's files (active extensions only, admin siteaccess, schedules), the switch, and the run record and its on time, late and never states.

2026-10-24: plain_site's Editor role holds single content functions instead of content/*

Changed

  • plain_site kept the base data's content/* in its Editor role, which also let editors empty everybody's trash, change the installation's languages, its URL aliases and wildcards, and see everybody's content jobs (bin/php/addeditorpolicies.php reported them as WARN lines). The setup step now replaces a content/* without limitations by the content functions the other site packages give editors (create, edit, read, manage_locations, diff, versionread, versionremove, remove, translate, bookmark, pendinglist, dashboard, view_embed, hide, restore, reverserelatedlist), without limitations, so the role's assignment still limits them to Content and Media (expEditorPolicies::narrowPlan(), narrow()). The base data is unchanged; the other packages remove content/* themselves and are not touched; a limited content/* is kept.
  • bin/php/addeditorpolicies.php does the same on an installation that exists: --dry-run lists content/* to be removed and each function to be added, the run checks the stored role, a second run changes nothing. --keep-content-wildcard keeps content/*; with --only it is kept as well. Upgrade note.

Tests

  • expEditorPoliciesTest (no database): the base data's content/* narrowed with no over-grant left, the narrowed list covering every content function of the packages and of the everyday policies and no over-grant, the packages' role untouched, a limited content/* kept, functions held already and skipped ones not added.

2026-10-24: the second step on the other sign-in paths

Two-step sign-in (sevenx_authentication_2fa) asks for its second step in the web login form only. The REST interface with basic authentication, WebDAV, SOAP and the expservices login check a password themselves, so an account the site requires a second step of (Enforce2FA, e.g. Administrator users and Editors) signs in there with its password alone. The kernel now asks the two-step extension on those paths too (feature page), and a site can opt each path in. By default no path is guarded: their clients cannot answer a second step, so every path signs in as before. The extension's side is in sevenx_authentication_2fa 1.0.17: [OtherSignInPaths] Guard[] (empty by default; one line per path: expservices, webdav, rest, soap), and app passwords ([AppPasswords]) for the opted-in ones of REST, WebDAV and SOAP.

Added

  • expSignInSecondFactor (kernel/classes/user/expsigninsecondfactor.php) and the interface expSignInSecondFactorHandler: after a right password the kernel asks the classes of site.ini [SignInSecondFactorSettings] Handlers[] whether a password alone is enough, and before it whether the secret is an app password of the account (which signs in for that request only, without a session). Without a handler, or with the extension's handler and no path in its Guard[] (the default), nothing changes. CodeHeader (X-Exp-OTP) names the header of an authenticator code.
  • The answers of an opted-in path, in each protocol's own format: REST 401 with WWW-Authenticate and {"error":"second_factor_required"|"second_factor_invalid","error_description":...}, 403 second_factor_setup_required (expRestSecondFactorRequiredStatus); WebDAV 401 with the reason in X-Exp-Sign-In-Refused; SOAP 401/403 with WWW-Authenticate and a SOAP fault; the expservices login {"ok":false,"error":{"code":401,...,"reason":...}}.
  • Audit events access.user.second_factor.refused, access.user.app_password.create, .revoke and .use, with their labels in the complete kernel catalogues.

Updated

  • expRestPasswordAuthFilter tries an app password before the password, so it never counts as a failed sign-in; ezpRestAuthConfiguration checks a password alone before the request is signed in, never a personal API key, an OAuth token or an app password. The OAuth token end point still has no password grant.
  • WebDAV (eZWebDAVContentBackendAuth) and SOAP (soap.php) take an app password first and check the password sign-in after it.
  • expservices: expsession::login takes the field otp (or the header X-Exp-OTP) and, once a site guards it, refuses a password alone for such an account; app passwords are never taken there, because the login makes a session the website accepts. It also refuses an account marked "change password at next sign-in" (403, reason password_change_required), like the other paths. expServiceException and the error envelope carry an optional reason.

Tests

  • expSignInSecondFactorTest (no database): the refusal and its status, the decision with handlers of the test, the kept refusal of a request, app passwords never for a disabled account, the code of a request, the answers of every protocol (REST status, WWW-Authenticate, SOAP fault), the wiring of every path and its order, the audit names, verbs, labels and documentation, the settings and the autoload.
  • expAuditRecordTest: the catalogue has 180 names (130 on by default).

2026-10-24: a new installation after a failed one leaves maintenance mode

Fixed

  • A kickstarter run that fails leaves maintenance mode on on purpose, so the half-installed site is not shown. The next run counted that window as someone else's ("Maintenance mode was already on: kept as it is") and left it on after a successful installation, so the new site kept answering with the maintenance page until php bin/php/maintenance.php off. expMaintenance::beginRun() now takes over a window that an earlier setup run left (reason setup, a run id, no wizard token) and the successful run switches it off. A window opened by hand, and the setup wizard's own window, are still kept as they are.

Tests

  • MaintenanceRunTest::testAFailedRunsWindowIsTakenOverAndEndedByTheNextRun and testAWizardSessionsWindowIsNotTakenOver (no database).

2026-10-24: the help dashboard answers in about a second

Fixed

  • The help dashboard (exphelp/dashboard) took 16 seconds on Apache and ran into Velocity's 30 second limit (504) on a site with 5,700 help objects: every request loaded the whole help tree with its data maps (expHelpContentRepository::load(), about 10 seconds) and then each object again to ask canRead() and canEdit() (another 6), only to show six topics, a count and the manuals. The search for help topics, the topic view and the manual list paid the same, and the context help's list of released topics paid it every five minutes on whatever admin page came first. On the same data the dashboard's part now takes 0.5 to 2 seconds instead of 16 to 35, with the same content for every user.

Added

  • The rows of help maintained as content are kept in the cache (var/<site>/cache/exphelp/content/repository-*.cache) and checked against a stamp of the help subtree on every request (a few sums over the locations, objects and states below the help root); after a publish, translation, move, removal, state or section change only the objects that changed are read again. expHelpContentRepository::index() gives the rows without the topics' texts, load() with them, accessData() what the permissions are decided with. A cache item exphelpcontent (site.ini [Cache_exphelpcontent], tag exphelp) clears it.
  • expHelpContentAccess: who may see a help object, decided from the cached rows against the user's content/read and content/edit policies as eZContentObject::checkAccess() does (Class, ParentClass, ParentDepth, Section, User_Section, Language, Owner, ParentOwner, State, StateGroup_*, Node, Subtree, User_Subtree); for a Group limitation, an extension's limitation, the anonymous "own session" owner or the member management guard the object is asked, as before.
  • expHelpContentProvider::newestTopics() (the dashboard's list and count), topics( false ) (without the texts), searchWordSets(); expHelpContentRepository::searchWords() keeps the search words of every topic in search-*.cache, computed again only for the topics whose texts changed (a search for help topics took 25 seconds).
  • ./console exp:help:content cache -s <siteaccess>: fills the cache after a deploy or after clearing it.

Updated

  • Reading every help object again (an empty cache) asks for the attributes one version and language at a time with the attribute table first: the kernel's single query made SQLite go through every attribute of the help classes for each chunk (30 seconds for the help tree; about 1 second this way). The state of each object comes from one query for all of them.
  • The list of released help_ids of the context help is kept in a static property, so a persistent Velocity worker no longer keeps it for its whole life.

Operator note

  • After the deploy, run ./console exp:help:content cache -s <siteaccess> once for each siteaccess that shows the help (the cache is per language list), so the first visitor does not read the help tree.

Tests

  • expHelpContentAccessTest (no database): access words; Section, Class, State, StateGroup_, Subtree and User_Subtree, Node with Subtree, Owner, Language and ParentDepth as checkAccess() compares them; what only the object can tell; decide(); newestTopics() without loading an object; the cache file; the search words.

2026-10-24: a site package keeps the newer installed extensions

Fixed

  • Installing a site package replaced every extension it carries, without asking, whatever was installed: exp:install --package=ezwebin_site deleted the Composer copies of ezwebin, ezstarrating, ezwt and ezgmaplocation (6.x) and put the copies of 2012 from the package server in their place, and the old ezstarrating then stopped the admin's full view with a fatal error. eZExtensionPackageHandler::install() now compares the two copies (extension.xml <metadata><version>, else ezinfo.php 'Version') and keeps the installed one unless the package's is newer; an installed copy without a release number stays when Composer or git manages it. A package without a release number (dev-master, //autogentag//) never replaces a numbered copy. The kept extension is activated as before and named in var/log/setup.log with both versions. Doc: doc/eng-us/bc/6.0/package-install-keeps-newer-extensions.md.

Tests

  • ExtensionKeepNewerTest (no database): release numbers and what is not one, the version read from extension.xml then ezinfo.php, the keep-newer rule, Composer (installed.json format 1 and 2) and git detection, and the published 6.0.15 extension packages against the Composer copies.

2026-10-24: the legacy site packages get the tables of the extensions they switch on

Fixed

  • ezwebin_site, ezflow_site and ezdemo_site switch on extensions such as eztags and xrowextract without creating their tables, so the setup log's checks ended in "6 of 144 declared tables missing" and "The site is installed but not usable yet". After the site package's post-install, the setup now creates the tables that every active extension's share/db_schema.dba declares and the database lacks (expInstallDefaultExtensions::ensureExtensionTables(); nothing is dropped or changed, an installation that has them all is left as it is).

2026-10-24: WebDAV on PHP 8

Fixed

  • PROPFIND after sign-in failed with a TypeError: the WebDAV component's lock plugin counted a list that is null when every property is found (count(null) since PHP 8.0). The component comes from the se7enxweb fork from 1.2.1 on (se7enxweb/zetacomponents-webdav, replacing zetacomponents/webdav 1.2), which also fixes the lock discovery of allprop responses, a LOCK of a missing resource that recursed without end, display names cut at "&amp;", an empty PROPFIND body (now allprop, as RFC 4918 says) and the deprecations of PHP 8.1 to 8.5.
  • WebDAV stores locks (LOCK, UNLOCK, a write without the lock token refused with 423) and reads back the files it wrote on a siteaccess with a PathPrefix; names with spaces can be locked (expWebDAVPathFactory).

Tests

  • WebDAVPathsTest (no database); the fork's tests/php8_regression_test.php.

2026-10-24: an emptied help cache is filled in the background, never by a visitor

Updated

  • A web request no longer reads every help object when the help content cache is missing or cannot be updated by reading only what changed (ezcache --clear-all, --clear-id=exphelpcontent, --clear-tag=exphelp, a changed help class or section, more than 300 changed objects): that took about 35 seconds, more than Velocity's 30 second limit. It starts php bin/php/exphelpcontent.php cache -s <siteaccess> detached in the background, once (a marker file next to the cache), and answers at once without help content: the dashboard leaves out its help content part, the search finds no help topics, a topic or manual is not found, the context help keeps its last list. The next request after the command finished has it all again. The same for a search that would compute the search words of every topic. expHelpContentRepository::isBuilding() and expHelpContentProvider::isBuilding() tell a request that it is in that state.
  • exp:velocity deploy fills the help content cache after the cache clears, as one PASS/FAIL step per siteaccess that shows the help (exphelp.ini [ContentSettings] CacheSiteAccessList[] and the siteaccesses that have used the cache, recorded in var/<site>/cache/exphelp/content/siteaccesses.json); SKIP where help content is not installed.
  • ./console exp:help:content cache answers SKIP (exit 0) where help content is not installed or switched off.
  • New settings exphelp.ini [ContentSettings] CachePhpBinary (the command line PHP for the background command; empty: found next to the running PHP) and CacheSiteAccessList[].
  • The generated pages of the command (exp:help:content, English and German) and of [ContentSettings] list the action cache and the new settings.

Tests

  • expHelpContentAccessTest: the background command line, the command line PHP, the siteaccesses the deploy fills, the marker that keeps a second build from starting.

2026-10-25: passkeys and "Verify it's you"

Guides: Passkeys, Verify it's you. Upgrade note: Passkeys and "Verify it's you".

Added

  • Added: Passkeys (WebAuthn) for user accounts: added, named, renamed and removed on My account > Passkeys (user/passkeys), used on the web sign-in form of design/standard, design/admin and admin4 (a button and the browser's passkey autofill). The kernel checks WebAuthn itself (kernel/classes/webauthn: challenge per request kept in the session, origin, relying party id, user presence and verification, ES256/ES384/ES512/RS256/RS384/RS512/Ed25519 signatures, the signature counter; attestation none). New table exppasskey (share/db_schema.dba, the kernel schemas, the 6.0.0 to 6.0.15 update files, update/common/scripts/6.0/createpasskeytable.php), new settings site.ini [PasskeySettings]. A passkey sign-in is the whole sign-in; REST, SOAP, WebDAV and the expservices login are unchanged.
  • Added: "Verify it's you": before the actions of five groups (own account security, users and roles, system settings and setup actions, data exports: on; removing and publishing content: off) the signed-in user confirms with the password, a passkey or an authenticator code, valid for [ReauthSettings] Minutes (10). Only the web pages ask. Groups are switched on Setup > Verify it's you (setup/reauth), written to settings/override/site.ini.append.php. On Your devices (user/sessions), switching off one's own "New sign-in" mail asks (account group) and signing another user out asks (users group); signing out one's own other devices does not.
  • Added: audit events access.passkey.add, .rename, .remove, .use, .use.failed and access.session.reauth.ask; access.session.reauth and .failed are raised by "Verify it's you" as well.

Updated

  • Updated: "Verify it's you" counts only a file actually chosen as an upload (expReauth::hasUploads(): an entry of $_FILES, nested arrays included, whose error is not UPLOAD_ERR_NO_FILE). A form that merely has file inputs, such as the edit form of a user account with its image, counted as one: an editor creating a member account was told that the chosen files had to be chosen again, though none were. Such a form is now replayed whole without the notice, and the account is published after the answer as it was filled in. The rule content/edit other (users group) stays: creating or editing another user's account is user management and asks once.
  • Updated: after a real upload the form now opens again after the answer, as the guide always said. The check looked at the answer's own request, which never carries files, so the POST was replayed without the file and, unless the autosave had stored the file in the draft first, published the content without it; the page now sends the mark ExpReauthReplayFiles with the replayed fields.

Tests

  • expWebAuthnTest (recorded vectors of real authenticators and of Chromium's virtual authenticator, each check refused field by field), expPasskeyTest, PasskeySchemaSqlTest, expReauthRulesTest; no database. expReauthRulesTest RA-12: file inputs left empty are no upload, a chosen file (or one that did not arrive) is one, also for inputs named as arrays.

2026-10-25: Your devices, the signed-in sessions of each user

Added

  • Your devices (user/sessions): every sign-in is a session of its user, listed with the browser, system and kind of device (from the User-Agent header), the approximate address (IPv4 /24, IPv6 /48), the sign-in method (password, password and two-step code, REST, SOAP, WebDAV, single sign-on), Remember me, when it started and was last used; this device first and marked. Sign out this session and Sign out all other sessions end sessions for good: the session is signed out at its next request on every session handler, also when it stays signed in with Remember me (360 days, unchanged). Recently ended sessions are listed with how they ended.
  • user/sessions/<user id> for administrators with the new policy function user/sessions: a user's sessions with the full address, one signed out or all ("Sign out everywhere"). The account page (user/edit) has the card Your devices (own) or Sessions (another user); the profile page of the public site links to the page.
  • New sign-in to your account: an e-mail (category security) when an account signs in from a browser, system and device kind it has not used within RetentionDays; on by default, each user turns it off on Your devices (the E-mail preferences page points there). Optionally a note to the administrators (NewDeviceAdminMail).
  • site.ini [UserSessionSettings]: Tracking, TouchInterval, RetentionDays, NewDeviceMail, NewDeviceAdminMail, AdminMailReceivers[], FirstSignInMail.
  • Table expuser_session in share/db_schema.dba, the MySQL, PostgreSQL and SQLite kernel schemas and the 6.0.0 to 6.0.15 update files (created only when missing); update/common/scripts/6.0/createusersessiontable.php creates it on any engine. Only the SHA-256 of a random token kept in the session is stored, never the session id.
  • Cronjob usersessions.php (part infrequent, and part usersessions): ends sessions not used for [Session] SessionTimeout, removes ended ones after RetentionDays and those of removed users.
  • Audit event access.session.new_device; access.session.revoke also for sessions signed out from the page (scope, by user or admin, the rows); access.session.login and access.session.logout carry the session's row id. The catalogue has 181 names.
  • Documentation: Your devices: signed-in sessions per user, the guide Your devices and sessions, in English and German.

Updated

  • A password change with [PasswordSettings] EndOtherSessions also ends the user's other session rows (reason password_change).

Tests

  • expUserSessionTest: User-Agent parsing, approximate addresses, the new-device rule, the sign-in method, the table in every schema and update file (the SQLite update twice), revoke one, others and all on SQLite, retention and the page's list, the wiring. expUserAccountOverviewTest: the Sessions card. expAuditRecordTest: 181 names.

2026-10-25: administrators approve new editor accounts

Added

  • The workflow event Approve user accounts (event_expuserapprove, on content/publish before), modelled on the approval event: a user account whose new version places it in a group that requires approval waits until an approver accepts it in the collaboration system (an item of the handler ezapprove), and cannot sign in before. Its settings are the groups that require approval, the approving users and groups, the groups without approval and the e-mail to the approvers. Approvers never wait.
  • The approvers get the mail "New user account waiting for approval" with the one link to the approval item in the administration ([UserSettings] RegistrationAdminBaseURL); it carries no key and decides nothing.
  • After Approve or Deny the workflow continues at once: the account is published and signs in, or its version is a draft of its author again.
  • Moving an account or a user group into a group that requires approval, or adding a location there, is refused for someone who would wait for the approval.
  • Audit events access.user.approval.request, .approve, .deny and .refused (on, channel access).
  • A new installation gets the workflow "Approve new editor accounts" (Editors wait for the Administrator users) and the role "Editor account requests" (read the Editors group, create user accounts directly in it), assigned to Editors; bin/php/adduserapproval.php (--dry-run, --required, --approvers, --requesters, --no-role) sets them up on an installation that exists and changes nothing on a second run.
  • Docs: technical note, feature page, guide, and their German counterparts.

Tests

  • expUserApprovalTest (no database); expAuditRecordTest counts the four new events (184 names, 134 on by default).

2026-10-24: four kernel calls that did not work as written

Fixed

  • fetch( 'collaboration', 'tree_count' ) named eZContentFunctionCollection::fetchObjectTreeCount() (13 parameters) with 4 and ended in an ArgumentCountError. It calls eZCollaborationFunctionCollection::fetchObjectTreeCount() now, which answers a missing parent node with "not found" instead of a call on null.
  • ezpContentPublishingQueue::initHooks() guarded itself with isset( $init ) before static $init = true;, which never held: every add() connected the INI hooks once more (three calls, each hook ran three times per signal). A static property guards it now.
  • content/download announced its event with an array with string keys, which call_user_func_array() passes as named arguments under PHP 8: a listener with other parameter names failed with "Unknown named parameter". The arguments are positional now (object ID, attribute ID), as ezpEvent::notify() documents.
  • The object relation datatype (ezobjectrelation) did not export its allowed classes to a package, so a class imported from a package allowed every class. They are written as <class-constraints>, the format of ezobjectrelationlist, and read back on import; a package without them keeps what the attribute had.

Tests

  • expKernelCallFixesTest (no database): tree_count reaches a method with one parameter per declared one and answers a missing node, the queue hooks run once after three init(), the download event's arguments.
  • eZObjectRelationClassConstraintsTest (no database): export, import, and an older package.

2026-10-25: event arguments always reach listeners by position

Fixed

  • ezpEvent::notify() passes its arguments by position (array_values()): an extension that still announces an event with a hash, as content/download did and the content/download view of nxc_powercontent still does, no longer stops a listener with other parameter names ("Unknown named parameter" under PHP 8).

Tests

  • expKernelCallFixesTest KC-03: a hash given to notify() reaches the listener by position.

2026-10-24: an account without its ezuser_setting row is not enabled anywhere

Fixed

  • Sign-in already refused an account without an ezuser_setting row (F-06 of the security hardening of August 2026), but eZUser::isEnabled() still answered true for it, so a session, API key or OAuth token of such an account kept working. isEnabled() now agrees with the login: no row, not enabled.
  • The user hygiene and user deactivation pages (expUserHygiene, expUserDeactivation) count such an account as disabled; the hygiene filter "disabled" lists it.
  • eZUserOperationCollection::setSettings() creates the missing row, so enabling such an account in the administration repairs it; it did nothing before.

Upgrade notes

  • Count the accounts concerned before updating; an empty result means nothing changes: SELECT COUNT(*) FROM ezuser u LEFT JOIN ezuser_setting s ON s.user_id = u.contentobject_id WHERE s.user_id IS NULL.

Tests

  • eZUserIsEnabledWithoutSettingLiveTest (live installation, skipped without one): no row is not enabled, with a row is_enabled decides, setSettings() creates the row.
  • expUserHygieneTest: the account without a row is disabled. The live tests that create users (eZContentAccessForUserLiveTest, eZContentPermissionSQLOptimizationLiveTest, eZContentHistoryAccessLiveTest, eZContentObjectDraftAccessLiveTest, expOEDialogAccessTest) give them an enabled row; eZContentAccessForAnotherUserTest marks its stand-in users enabled.

2026-10-25: an upgrade script creates the missing ezuser_setting rows

Added

  • update/common/scripts/6.0/createmissingusersettings.php (expUserSettingRepair): an account without its ezuser_setting row is not enabled anywhere since eZUser::isEnabled() agrees with the sign-in, so its open session, API key or OAuth token stops working with the update. The script creates the rows of all such accounts on any engine: enabled, as these accounts counted everywhere but at the sign-in before, disabled for one waiting for its activation mail, or every one disabled with --disabled; an account whose content object is not published is left alone, rows that exist are never changed. --dry-run lists the ids first. Fresh installations and every way the kernel creates an account write the row, so only installations upgraded from old releases or filled by SQL are concerned.

Tests

  • expUserSettingRepairTest (no database): what each account gets, --disabled, unpublished accounts, the dry run.

2026-10-25: WebDAV checks what a request may do

Fixed

  • eZWebDAVContentBackendAuth::authorize() allowed every read before it looked at a policy; only the root (/, the list of sites) is read without a check now, everything below it needs user/login for the siteaccess named by the first path element. A check compared with = instead of === ($access = ACCESS_WRITE).
  • PUT, MKCOL, DELETE, COPY and MOVE answered 201/204 when the content layer refused them (no content/edit, content/create or content/remove), so a user without the right lost changes silently. They answer 403 now. COPY or MOVE between two sites answers 403 instead of "no errors"; performDelete() returns array() instead of null for the first step of a MOVE (count(null) is a TypeError since PHP 8).
  • The site was matched by name prefix: /site_admin/... ran as /site. It is matched as a whole path element.
  • webdav.php sets the authorizer for the root branch too.

Tests

  • expWebdavAuthorizeTest, expWebdavBackendRefusalTest (no database).

2026-10-25: WebDAV reads of a missing or unreadable file, and the permissions for administrators

Fixed

  • eZWebDAVContentBackend::getContentNodeData() read the node information before it checked the node: a GET of a path without a node ended in a call on false, and the details of a node the user may not read were fetched before it was refused. The node is checked first, content/read second, and getResourceContents() answers a refused or missing file with false without reading an offset of false.

Documentation

  • WebDAV permissions for administrators (English, German): user/login per siteaccess, content/read per node, 403 for refused writes, never "Verify it's you", a second sign-in step only with Guard[]=webdav. Proven on a SQLite installation: an unreadable folder 404, a readable one 207, a refused PUT, MKCOL and DELETE 403 with nothing changed, a refused COPY and MOVE 403 inside the 207 multistatus answer with nothing copied or moved, the administrator's MKCOL 201 and DELETE 204, an account without its ezuser_setting row 401.

Tests

  • expWebdavReadRefusalTest (no database): a path without a node, a node the user may not read, the contents of both.

2026-10-24: the content job log line does not follow the locale

Fixed

  • expContentJobWorker::run() wrote its line per batch with %f, which follows LC_NUMERIC: with a German locale set in config.php the job log read "in 0,01 s, peak 30,0 MB". It uses %F now.

Tests

  • ContentJobEngineTest: testWorkerRunsToDoneEachItemOnce (CJ-12) failed with a German locale and passes. Every forked child now ends by SIGKILL in a finally and in a shutdown function, and the parent waits at most 60 seconds: before, an exception in a child skipped its kill, the copy of the PHPUnit process ran the rest of the suite next to the parent, and its later kill ended runs at random places (exit 137).
  • ContentJobEngineTest::testWorkerLogLineUsesADotUnderACommaLocale (CJ-12b) switches LC_NUMERIC to a German locale, runs the worker and checks every batch line carries a dot; it fails with %f and passes with %F. Skipped where no locale with a decimal comma is installed (CI's runners), so it guards the fix wherever one is.

2026-10-25: the database test group runs on PHPUnit 13, and the DFS cache generation works on PHP 8.1+

Fixed

  • eZDFSFileHandlerMySQLiBackend::_query() (dfsbackends/mysqli.php): with $reportError = false a failed query returns false again instead of throwing mysqli_sql_exception (mysqli reports errors as exceptions by default since PHP 8.1). _startCacheGeneration() relies on the failed INSERT (duplicate key means "being generated"), so startCacheGeneration() never returned the remaining generation time and a timed-out generation was never taken over. _endCacheGeneration() rolls back and returns false when its SELECT ... FOR UPDATE fails, instead of a TypeError from mysqli_num_rows( false ) with the transaction left open.

Tests

  • The tests of the database group, which had not been migrated to PHPUnit 13 (assertFileNotExists(), @dataProvider annotations, setMethods(), returnValue()), run again: 30 errors and 8 failures before.
  • Tests no longer expect the demo content or a fresh database per test: they create what they read, remove what they created (also when they fail) and restore the INI settings and languages they change in tearDown() (eZContentClassTest, eZURLAliasMLTest, eZRSSExportTest and others). The RSS fixtures name the locale and the site address through placeholders.
  • ezpMobileDeviceDetectTest is declared once: the duplicate under tests/tests/kernel/private/ is removed and its isEnabled() cases are in the kernel/classes test.

2026-10-25: tests of the group database run on throwaway databases

Fixed

  • ezpLiveInstallation started the siteaccess admin, which this distribution does not ship, so every live test was skipped ("Unable to connect to the database server") and the group database never ran. The siteaccess is now EXP_TEST_SITEACCESS, else DefaultAccess.

Added

  • ezpThrowawayDatabase (tests/toolkit/ezpthrowawaydatabase.php): MySQL and MariaDB databases exp_test_<pid>_<hex> for the tests. One seed per run gets the kernel schema and the clean data; each test class gets a copy made on the server and dropped when the next class starts, at the end of the run, and on a fatal error, SIGINT or SIGTERM. Only databases the process made itself are written to or dropped; connections are closed before DROP and lock_wait_timeout is set; forked children never inherit the admin connection.
  • Live tests write only to a database with test as a word of its name; otherwise to a throwaway database, and without one they are skipped with the siteaccess, the database and the reason. Other databases than MySQL and MariaDB skip with the reason; tests/runtests.php --db-per-test --dsn=… works as before.
  • Settings: EXP_TEST_DB_HOST, EXP_TEST_DB_PORT, EXP_TEST_DB_SOCKET, EXP_TEST_DB_USER, EXP_TEST_DB_PASSWORD (default: the database settings of the siteaccess) and EXP_TEST_KEEP_DATABASES=1. The account needs CREATE and DROP on exp\_test\_% only, for example GRANT ALL ON exp_test_%.* TO 'exp_test'@'localhost'. See TESTING.md. Guard test: ezpThrowawayDatabaseGuardTest.
  • ezpTestDatabaseHelper::create() and clean(), which drop every table of the database of tests/runtests.php --db-per-test --dsn=…, accept only a name with test, tests or testdb (optionally numbered) as an underscore-separated word (testdb, exp_test_*), not any name containing "test": a demo site's expdemolatest or a reinstall target …pkgtest was accepted before. For Oracle, where create() empties the schema of the account, the account is checked (ezpTestDatabaseHelper::isSetUpDatabaseName(), TD-06).

2026-10-24: three unused Zeta Components are no longer required

Removed

  • zetacomponents/authentication-database-tiein and zetacomponents/mvc-authentication-tiein from require, zetacomponents/php-generator from require-dev (composer.json, and the same entries in composer.json.dist). No code of the kernel, of its designs or of the packages it requires uses a class of them; the two comments in kernel/private/rest/ that name MvcAuthenticationTiein say where the kernel's own copy of that code came from.

Kept

  • zetacomponents/configuration stays in require for now. 7x SQLI Import (se7enxweb/sqliimport ~2.0.0, required by composer.json) throws its ezcConfigurationNoConfigException from SQLIImportFactory::runImport() for a handler without its configuration section, in its releases up to 2.0.1; without the component that error path would end an import with "Class not found" instead of marking the item failed. The component goes once a release of 7x SQLI Import that throws its own SQLIImportConfigException is pinned. Its INI reader, which turns a quoted dimension key (key["..."]) into PHP code and runs it with eval() (src/ini/ini_parser.php), stays unused: the kernel reads its settings with eZINI, and nothing loads ezcConfigurationIniReader.
  • tests/toolkit/extras/scripts/create-test-from-class.php, the only user of zetacomponents/php-generator: it wrote a test skeleton whose constructor calls parent::__construct() without a name and then setName(), which the PHPUnit versions the test suite runs on (10 and later) do not accept. No documentation, script or test calls it.

Upgrading

  • Code of your own that uses one of the three removed components (ezcAuthenticationDatabase*, ezcMvcAuthentication*, ezcPhpGenerator*) requires it in the project's own composer.json, e.g. composer require zetacomponents/php-generator. composer update removes them from vendor/ otherwise.

Tests

  • expComposerKernelRequirementsTest (no database): the three components are not required, no PHP file of the kernel, its libraries, designs, scripts or shipped extensions names one of their classes, and zetacomponents/configuration stays required as long as a required 7x SQLI Import still uses it.

2026-10-24: the setup wizard activates expimageeditor; ezie stays installed

expimageeditor (root pull request #302) replaces the image editor ezie in admin4.

Updated

  • The setup wizard and the kickstarter activate expimageeditor for a new installation where they activated ezie (kernel/setup/steps/ezstep_create_sites.php; as before, only an extension that is present is activated, and only one of the two: both replace the same include of admin4's image edit template).
  • The admin4 hook for ezie (content/datatype/edit/ezimage_editor.tpl, drawn only when ezmodule( 'ezie/prepare' ) allows it) stays: a site that activates ezie keeps its Edit button in admin4.
  • The pages on ezie and expimageeditor say how ezie is installed and activated (English and German).

Kept

  • se7enxweb/ezie and zetacomponents/image-conversion (with its dependency zetacomponents/image-analysis) stay in the require list of composer.json (and ezsystems/ezie-ls, zetacomponents/image-conversion in composer.json.dist). Dropping them was proposed together with the wizard change, but the sevenx_multisite site package activates ezie for the classic admin designs, which have no other image editor, and the released ezie versions do not declare the image conversion themselves: a new installation would have activated an extension composer install no longer put there. The ImageMagick handler of image-conversion builds the file name of a composited image (the watermark) into its shell command without escapeshellarg() (ezcImageImagemagickBaseHandler::save()); ezie, its only user, composites only the watermark images of its own folder, resolved with realpath(), so that name never comes from a request. The requirement can go once ezie declares the image conversion itself and the site packages activate expimageeditor.

Upgrading

  • Nothing to do: ezie stays installed. To use expimageeditor in admin4: ActiveExtensions[]=expimageeditor in place of ezie, then php bin/php/addeditorpolicies.php --apply --only=expimageeditor/edit for the Editor role (expimageeditor).
  • A project that builds its own composer.json instead of the root's requires ezie with the image conversion, since ezie does not declare it: composer require se7enxweb/ezie zetacomponents/image-conversion.

Tests

  • expComposerKernelRequirementsTest: composer.json requires ezie and the image conversion while a site package of the distribution activates ezie, and the setup wizard activates expimageeditor, not ezie.

2026-10-24: OAuth tokens stop working for disabled users, refresh tokens expire, no token in the address

(upgrade note)

Fixed

  • A user disabled in the administration or locked by failed logins kept full REST access with an OAuth token issued before, and kept getting new access tokens with the refresh token. ezpOauthFilter::run() and both grants of the token endpoint now check the user (ezpOauthUtility::userMayUseToken(), the rule of personal API keys and password sign-in) and answer 401 or invalid_grant. The grants refuse such a user with the description they give for a code or refresh token of another client, so the answer does not tell the token holder the account's state (ezpOauthUtility::checkGrantUser(); the reason goes to the debug output).
  • The token endpoint refused only a missing last parameter: a request without client_id went on to ezpRestClient::fetchByClientId( null ) with "Undefined array key" warnings. Every missing parameter is named now (ezpRestOauthTokenController::missingParams()).
  • A token endpoint parameter posted as a list (client_id[]=..., refresh_token[]=..., code[]=...) reached the client and token lookups as an array and ended in an error page; it is refused as invalid_request ("Invalid client_id parameter.", ezpRestOauthTokenController::invalidParams()). An oauth_token[]=... is no token, and a list code_verifier counts as none.
  • A client still sending ?oauth_token= while AllowTokenInQuery is off is named in the debug output, so the 401 can be traced from the site's side.

Updated

  • The client a code or refresh token was issued to is compared in constant time, and an empty client id never matches (ezpOauthUtility::clientMatches()).
  • Refresh tokens expire: rest.ini [OAuthSettings] RefreshTokenTTL=2592000 (30 days from the refresh that issued them, 0 = never). The issue time is derived from the access token's expiry, so no schema change.
  • The oauth_token query parameter is read only with rest.ini [OAuthSettings] AllowTokenInQuery=enabled (default disabled); the form body parameter stays on (AllowTokenInBody=enabled). The Authorization header is always read.

Tests

  • ezpOauthGrantChecksTest (no database): disabled, locked and missing users and the description they are refused with, the client binding, the refresh token lifetime, every missing parameter named before any lookup. ezpOauthUtilityTokenTest: the token sources of the shipped settings, the query only with AllowTokenInQuery=enabled, header only.

2026-10-24: sendmail envelope sender, tip-a-friend sender, SMTP failures and random mail identifiers

(upgrade note)

Fixed

  • eZSendmailTransport passed the sender as -f'<address>', and mail() undoes that quoting with escapeshellcmd(): a sender with a quoted local part such as "a' -X/tmp/x.php -OQueueDirectory=/tmp '"@example.com (valid for eZMail::validate()) gave sendmail options of its own. -f is now given only for a plain address (isShellSafeAddress()), without quoting; any other sender is left out with a debug warning.
  • content/tipafriend without [TipAFriend] FromEmail sent with the visitor's address as sender and envelope sender; it now sends with EmailSender or AdminEmail and puts the visitor into Reply-To; the display name is then [SiteSettings] SiteName, not the visitor's free-text name (which made "Bank support <site address>").
  • An SMTP server that offers only AUTH NTLM made ezcMailSmtpTransport throw ezcBaseExtensionNotFoundException('mcrypt') into the request; eZSMTPTransport::sendMail() now catches every Throwable, logs it and returns false.
  • eZMail::senderText() of a mail without a sender raised "Trying to access array offset on false" and a PHP 8.1 deprecation in mb_encode_mimeheader(); it returns ''.

Added

  • site.ini [MailSettings] TransportAuthMethod (empty by default: the library's choice).
  • A warning in the debug log when SMTP credentials go unencrypted to a host other than loopback.

Updated

  • The multipart/alternative boundary of template mail and the generated Message-Id are random; the library's forms carried the PHP process id.

Tests

  • eZSendmailEnvelopeSenderTest, eZSMTPTransportTest (fake SMTP server on 127.0.0.1), eZMailTest, expMailTemplateBodyTest, ezpMailMessageIdTest, expTipafriendSenderTest (no database).

2026-10-24: package archives are scanned before they are read

Fixed

  • A package archive in the PAX tar format (tar types x/g; Python's tarfile writes it by default since 3.8, bsdtar for names that are not ASCII) kept package/upload, ezpm import and the setup wizard busy until max_execution_time: the GNU header reader of zetacomponents/archive 1.5.1 moves on only after L and K headers and read the same block for ever. The same held for GNU D/M/N/S/V headers and for a regular file typed \0 in an archive with the ustar magic. A long name header was read into memory in the size it declared (up to 8 GiB) before a single entry was checked.
  • A file packed as 04755 came out of the archive set-uid, a directory packed as 0777 world writable: ezcArchive applied the archive's modes.

Added

  • expPackageArchive (kernel/classes/exppackagearchive.php): scan() reads the tar headers 512 bytes at a time, skips the data by its size and stops at MaxEntries/MaxUnpackedSize; open() opens the archive only when the scan finds nothing, with the extract callback expPackageArchiveModes (files 0644, directories 0755, within the installation's file mode limits). Used by eZPackage::importUnaudited(), eZPackageUploadInspector::inspect() and expPackageJson::extractArchive().
  • The refusal codes tar_pax, tar_entry_type and tar_long_name, with their messages in the source catalogue and the complete kernel catalogues (The .ezpkg file). A long name header whose size is a multiple of 512 bytes is refused, because the reader then skips the entry's real header and parses its data as the next one; GNU tar stores the name with a closing NUL, so a name of 511, 1023, ... or 4095 bytes cannot be imported. The tar_pax message names the GNU format in the spelling of both tars: tar --format=gnu for GNU tar, tar --format=gnutar for bsdtar (macOS, the BSDs), which answers --format=gnu with "No such format".
  • A size field of 12 octal digits without terminator, as old writers fill it, is read like ezcArchive reads it.

Tests

  • expPackageArchiveTest (no database): a GNU archive with a long name and a V7 archive pass and extract, a PAX archive is refused quickly (in a child process with a deadline), unreadable header types, a huge long name, a long name of a block multiple and damaged headers are refused, a size of 12 octal digits is read while a base-256 or not octal size is refused, the limits are kept, and the extracted modes are masked. An archive written as eZPackage::exportToArchive() writes one (ezcArchive's ustar writer, a path over 100 bytes in the prefix field) passes the scan, the inspection and the extraction, so every package the kernel exports stays importable.

2026-10-24: package XML is read without network access, and the form token is compared in constant time

Fixed

  • eZPackage::fetchDOMFromFile() and the three XML readers of eZPackageFileBrowser (documentText(), prettyPrintXML(), objectItemSummary()) loaded the XML of an uploaded package without LIBXML_NONET, so a DTD or an external entity named in it was fetched from any host. They pass LIBXML_NONET now, as expPackageJson and eZPackageUploadInspector already did.
  • ezxFormToken::input() compared the posted token with !==, which stops at the first differing byte. It uses hash_equals() now; a token posted as an array (ezxform_token[]=) is refused as wrong instead of a TypeError.

Tests

  • eZPackageXMLNoNetworkTest: every load()/loadXML() of both files passes LIBXML_NONET, a package.xml with a DOCTYPE naming a DTD on another host is still read, the file browser still pretty prints, shows and summarises.
  • ezxFormTokenCompareTest (no database): the right token from the field and from the X-CSRF-Token header passes; wrong, truncated, longer and array tokens are refused.

2026-10-24: getDataByURL() fetches only http and https, and an error status is no data

Fixed

  • eZHTTPTool::getDataByURL() handed any address to curl or fopen(), both of which read file://, php://, ftp:// and more, so a configured or posted address (RSS import, static cache) could return local files; curl also followed a redirect to file:// or gopher://. Only absolute http and https addresses with a host are fetched now (eZHTTPTool::isHTTPURL()), and curl is limited to both protocols, also for redirects. An address without a scheme (www.example.com/feed, as RSS sources are often entered) is read as http (eZHTTPTool::withDefaultScheme()), as curl did before.
  • A response with a status of 400 or more returned the error page's body as the data. It is false now, with curl and with fopen() (the status of the last response in the stream's headers, eZHTTPTool::responseStatus()); $justCheckURL is false for it as well.
  • The static cache fetches several pages at once with curl_multi (eZStaticCache::fetchPages()), outside getDataByURL(); its handles follow redirects and are limited to http and https the same way now (eZHTTPTool::limitCurlToHTTP(), used by both).

Tests

  • eZHTTPToolGetDataByURLTest: refused schemes, paths, addresses without host, control characters and non-strings; 200, 404, 500 and redirects against PHP's built-in server on 127.0.0.1 (fixtures/http_status_router.php); a redirect to file:// not followed; the status of a real stream; addresses without a scheme; a curl handle limited by limitCurlToHTTP() refuses file://; eZStaticCache::fetchPages() answers 200 and redirects to 200 with the body and 404 and a redirect to file:// with false.

2026-10-24: views that send the user back to the page viewed last redirect again after the front page

Fixed

  • The front page stores an empty LastAccessesURI in the session (its requested URI is empty). eZRedirectManager refused '' as unsafe ("empty"), so redirectTo( $module, false ) had no target and views relying on it (shop/setusercountry, content/hide, user/preferences) did not redirect at all after the front page was the page viewed last. An empty page viewed last is now the site root /; an unsafe page or no page at all stays no target, and unsafeReason() itself is unchanged.

Tests

  • eZRedirectManagerSafeURITest: RED-01, redirectURI() with an empty or blank page viewed last returns /, a normal page is returned as before, an unsafe page and an empty session return false.

2026-10-25: the front page is recorded as the site root, for every reader of the page viewed last

Updated

  • The web kernel records the front page in LastAccessesURI and LastAccessedModifyingURI as /, not as an empty page (eZRedirectManager::pageURI()). Every other reader of the page viewed last (returnURI() behind Cancel and Discard, user/login, user/logout, shop/basket, the "Verify it's you" step) got '' before and fell back to its default or to nothing. returnURI() reads an empty page left in an older session as / too, as redirectURI() does.

Tests

  • eZRedirectManagerSafeURITest: RED-02 pageURI(), RED-03 returnURI() with an empty page viewed last returns / instead of the default, RED-04 the web kernel records the page through pageURI().

2026-10-24: a copied object keeps the names of its translations, and the default language creates no language

Fixed

  • content/copy names each translation of the copy with eZContentObjectVersion::name( false, $languageCode ), but the method had a single parameter and cached its first answer for every language, so every translation of the copy got the name of the main language. name() takes the language from either position now and caches the name per language.
  • eZContentObject::defaultLanguage() without a prioritized language (a CLI script without siteaccess) created [RegionalSettings] ContentObjectLocale as a new content language, even in an installation that already had others. It uses that locale when it exists, else the existing language with the lowest ID, and creates it only when there is no language at all.

Tests

  • eZContentObjectVersionNamePerLanguageTest (no database): the name per language from both parameter positions, cached per language, kept apart from the name without language, with the object's name as fallback.
  • eZContentObjectDefaultLanguageTest (no database): an existing ContentObjectLocale is used, a missing one falls back to the lowest existing ID and adds no language.

2026-10-24: the currency code and class attribute names are escaped in six templates

Fixed

  • shop/editcurrency.tpl (design/admin, design/admin4) printed $original_currency_code raw in the heading and in the hidden OriginalCurrencyCode field. The code comes from the URL and reaches the template for an unknown currency as well. Both places use |wash now.
  • Class attribute names were printed raw in design/standard (user/register.tpl, content/collectedinfo/poll.tpl, content/history.tpl) and design/base (override/templates/edit/comment.tpl). The name is editable by anyone who may edit classes and is shown to visitors; all seven places use |wash now.

Tests

  • AdminStoredValueEscapingTest: the case "edited currency code".
  • StandardClassAttributeNameEscapingTest (source text): each of the four templates prints the class attribute name only escaped.

2026-10-25: more names escaped in the standard and base designs

Updated

  • The "wait until date" workflow event of the standard design prints class names and class attribute names escaped, in its edit form (the lists and the entries) and in its view. The admin designs already did.
  • The poll of the base design (embed/poll.tpl, collectedinfo/poll_result.tpl) prints the question and the answers escaped, as the option datatype's own templates do; editors enter them and visitors see them.

Tests

  • StandardClassAttributeNameEscapingTest: the four templates above, each expression only in its escaped form.

2026-10-24: ezjscore never answers with an empty JSON body

Fixed

  • ezjscAjaxContent::autoEncode() and nodeEncode() called json_encode() without flags, which returns false for a string that is not valid UTF-8 (a truncated multibyte character in a title, a Latin-1 value from an old import): ezjscore/call answered 200 with an empty body, which the client only saw as a JSON SyntaxError. The new ezjscAjaxContent::jsonEncode() replaces invalid bytes with U+FFFD (JSON_INVALID_UTF8_SUBSTITUTE); a value that still cannot be encoded (INF, NAN) is logged and answered with the error object ezjscore/call uses for every other failure. Valid values are encoded exactly as before.

Tests

  • ezjscAjaxContentJsonTest (no kernel, no database): invalid UTF-8 through both encoders and through ezjscore/call, values that cannot be encoded, valid values unchanged.

2026-10-25: the json_encode template operator and the ajax uploader answer with valid JSON too

Updated

  • The json_encode template operator of ezjscore and the ajax uploader's own JSON answers (the error carrying the exception message, the location form) encode through ezjscAjaxContent::jsonEncode(). A value that is not valid UTF-8 (a file name of an old import) gave false there as well: an empty value in the page, an empty answer to the upload.

Tests

  • ezjscAjaxContentJsonTest: JSON-05 the template operator with invalid UTF-8, JSON-06 the uploader's answers.

2026-10-24: three leftovers around removing content

Fixed

  • content/action with ActionRemove and a node ID that no longer exists (the form sent again after the removal) ended in "Call to a member function attribute() on null". It is answered with "not available" now.
  • content/removeeditversion left DiscardConfirm in the session, so any later GET of the view discarded the version named in DiscardObjectID/DiscardObjectVersion without asking (a new draft can get the number of a discarded one). The value counts for the request that follows the Discard action only.
  • eZContentObject::purge() asked only the workflow event types already loaded in the request to clean up once any type had been created; the others kept their rows for the purged object. It now asks every type of workflow.ini (eZWorkflowType::fetchRegisteredTypes()).

Tests

  • expContentRemoveLeftoversTest (no database): the node check of ActionRemove, the one-time DiscardConfirm, and a purge that lets a type loaded before and event_ezapprove (not loaded before) both clean up.

2026-10-25: the approval of user accounts after a purge and in persistent workers, Preview of a removed object

Updated

  • The configurations of the "Approve user accounts" events (expUserApproval::activeConfigs()) are read again in every request: a reset hook of the request scope (userapproval, expUserApproval::resetRequest()). A persistent worker kept the ones of its first request, so a changed workflow counted there only after a restart. A purge that takes a group or a user out of an event's lists forgets them at once too.
  • content/action with ActionPreview for an object that no longer exists answers "not available", as ActionRemove does now, instead of ending in "Call to a member function attribute() on null".

Tests

  • expContentRemoveLeftoversTest: RL-01 for ActionPreview, RL-03 the purge lets event_expuserapprove clean up, RL-04 resetRequest() forgets the request's configurations and keeps a test override. expKernelServicesTest lists the new reset hook.

2026-10-25: "Verify it's you" before approving or denying a new user account

Updated

  • Updated: Approve and Deny of a new user account waiting for approval (an item of the workflow event Approve user accounts) ask "Verify it's you" in the users group: the rule collaboration/action other CollaborationAction_Accept CollaborationAction_Deny CollaborationAction_Defer in site.ini [ReauthGroup_users], and expReauth::addressedUser() reads the item of a POST to collaboration/action (the pending account). The decision is held until the approver confirms and is then carried out as pressed. Ordinary content approvals address no account and never ask; comments and opening the item do not ask either.
  • Updated: The users group's description on Setup > Verify it's you names approving and denying new user accounts (eng-US, ger-DE, untranslated).
  • Docs: Verify it's you, Approving new editor accounts, the upgrade notes Passkeys and "Verify it's you" and Approval of new user accounts, and their German counterparts.

Tests

  • expReauthRulesTest RA-11: Approve, Deny and Defer of an account approval ask in the users group; a content approval, a comment and opening the item do not, also with the content group on; the item id of the POST without a database.

2026-10-26: every mail as HTML with a plain-text part in the shared mail layout

Updated

  • Updated: The mails of the forgotten password (the reset link, then the new password), "Your password was changed", "New sign-in to your account" and its copy to the administrators, the e-mail preference mails (confirm a subscription, confirm a new address, the link to the preferences, the note to the old address), tip a friend, the mails of collected information and the order confirmation are HTML mails with a plain-text part in design:mail/layout.tpl and design:mail/layout_text.tpl, as the registration mails already were: a heading, a short text, one button with its address as text below it, the security notes, the site's name. Clumsy wording was rewritten; links use https.
  • Updated: Notifications, audit alerts and the reports of extensions, written as text, are sent as that text and as HTML in the same layout (expMailTemplateBody::fromText()); the first address on a line of its own is the button. notification.ini [MailSettings] HTMLLayout=disabled keeps notifications text only.
  • Updated: The layout shows a code to type in (mail_code) large and easy to copy, has a hidden preview text (mail_preheader), more paragraphs and small print (mail_paragraphs, mail_outro), and dark colors for the mail programs that ask for them.
  • Updated: The mail gate puts the footer of an optional mail (unsubscribe, manage the preferences) into both parts of a multipart/alternative mail, inside the card of the HTML part; before, it was appended after the last part, where no mail program shows it (no optional mail was multipart until now).
  • New strings in the contexts of the mails and design/standard/mail, translated in every administration catalogue.
  • Docs: The mail layout.

Tests

  • expMailLayoutMailsTest ML-01 to ML-12: every mail template builds the layout with a text part, escapes typed values, carries no styles or markup of its own, uses https links given as text too; fromText(), applyRendered(), the gate's footer in both parts, the audit alert, a notification sent as text and HTML.

2026-10-26: the packages of every generation import

Fixed

  • Packages made by eZ Publish 3.x, 4.x and 5.x and packages repacked by hand were refused on import. Measured against 1,429 distinct archives on disk: the 30 ezwebin 1.0 packages (V7 archives of the 3.x Archive_Tar port, files typed \0) were refused as unsafe_type, because ezcArchive's V7 reader takes such a file for no file under PHP 8, and 5 uncompressed .ezpkg files of the 4.x era as not_gzip. Archives built the way each tool writes them were refused too: a 3.x archive with a long name (L without magic, tar_entry_type), every PAX archive (bsdtar on macOS, GNU tar --format=posix; tar_pax), a GNU tar repack (./package.xml was not found at the top, no_definition; with links, unsafe_type), a GNU volume label or a PAX global header (tar_entry_type, tar_pax).
  • expPackageArchive::index() now reads V7, ustar, GNU and PAX headers (PAX path, linkpath, size, mtime, bounded to 64 KiB), long names L/K of any size up to 4096 bytes, files typed 0, \0 and 7, directories 5 and D, and normalises names (./, //). An archive ezcArchive cannot read as it is is rewritten into a plain GNU tar archive below the cache directory and opened from there (expPackageArchive::rewrite()); the copy is removed as soon as it is open. A hard or symbolic link to a file of the package is extracted as a copy of that file.
  • What a package cannot hold no longer refuses it: a link out of the package (link_outside), to a directory (link_directory) or to nothing (link_missing), a device or fifo (special_file), a sparse file or an unknown type (unsupported_entry) is passed over with a notice, written to the debug log by eZPackage::importUnaudited(), returned by eZPackageUploadInspector::inspect() (notices) and expPackageJson::extractArchive(), and listed among the validation warnings of package/upload. An entry whose own path leaves the package (.., an absolute path) still refuses it, as do the size and entry limits; files still get 0644 and directories 0755.
  • A plain (uncompressed) tar archive passes the upload inspection again, as eZ Publish read it through compress.zlib://.
  • A file or directory name longer than 255 bytes is refused as tar_name_segment before extraction: ezcArchive ended the request with a fatal error when it could not create the file.
  • Package names with upper case letters or a hyphen import: eZPackage::isValidName() accepted only what the identifier transformation leaves unchanged (lower case letters, digits, _), which refused 4 packages of the 4.x era on disk as invalid_name (RSSFeed_content_class, Classi_helper_classi, infoProf 1.0 and 1.1). A valid name is now letters of either case, digits, _ and -, not starting with -, at most 200 characters: still no /, back slash, dot, space or control character. The name stays the package's directory as it is; the creation wizards still offer lower case names.

Removed

  • The refusal codes tar_pax and tar_entry_type and their messages; every administration catalogue (71) that had translated them carries the six new messages translated instead, so the complete ones stay complete.

Tests

  • expPackageArchiveLegacyFormatsTest (no database): twelve fixtures in tests/tests/kernel/classes/packages/fixtures/legacy/, built by build-legacy-package-fixtures.php the way each tool writes (3.x Archive_Tar V7, the 4.0 to 4.2 handler, ezcArchive ustar, PEAR Archive_Tar, GNU tar gnu, posix, ustar and v7, bsdtar on macOS, GNU and ustar oddities), each inspected, extracted and imported: content, copies of links, modes, notices, and nothing but plain files and directories inside the target.
  • expPackageArchiveTest and eZPackageUploadInspectorTest follow: PAX archives, unusual types, long names of a block multiple and links are read; paths out of the package are refused.

2026-10-28: orders record the siteaccess they were placed in

Added

  • Every order records the siteaccess (store or site) it was placed in, in the new column ezorder.siteaccess (varchar(255) NOT NULL default ''), set by eZBasket::createOrder() and, for an order that has none yet, by eZOrder::activate(). The order view, the order list, the archive list and the customer order view of admin and admin4 show it; the order lists filter by it ((siteaccess)/<name>, - for unknown) and the order list sorts by it. The customer's order view shows the store's name. Orders placed before keep '' and show as unknown; bin/php/expbackfillordersiteaccess.php infers their siteaccess from the content tree each siteaccess serves (a dry run unless --apply). Run the 6.0.0 to 6.0.15 database update (update/database/<engine>/6.0/dbupdate-6.0.0-6.0.15.sql) to add the column; until then the shop works as before. Feature.

Tests

  • expOrderSiteaccessTest (no database): the definition with and without the column, the recording, the filter parameter and the SQL and document conditions built from it, the schemas, the update SQL and the templates.
  • expOrderSiteaccessBackfillTest (no database): the inference of the backfill from path strings and siteaccess roots (one, none, several, a removed product, the deepest root).

2026-10-28: site packages keep Composer's extensions and find the content root

Fixed

  • An extension that Composer installed (an install path in vendor/composer/installed.json) or that is a git checkout is now always kept when a site package carries a copy of it, also when the package's copy states a higher release number: Composer updates it, and a package that replaced it left installed.json and composer.lock describing files that were no longer there. The 6.0.15 ezwt_extension package (6.0.17) replaced a Composer ezwt 6.0.16 before. Uninstalling an extension package no longer deletes such a directory either; only its activation is undone. Both are named in var/log/setup.log. A plain copy is still replaced by a newer package, as before.
  • The legacy site packages (ezwebin_site, ezflow_site, ezdemo_site and their _clean variants, in every release before the rebuilt 6.0.15 ones) swap the content root with their "Home" page and name the root "eZ Publish", a name the clean data of Exponential 6 no longer uses: the installation stopped at post-install step 8 with "Can't fetch node ''". eZSiteInstaller::contentObjectByName() now takes a remote_id first, then the name (with the class_id the step gives, which was dropped before), and for a name the content root has carried ("eZ Publish", "Exponential", "Websites", "eZ Platform") the object with the root's remote id (eZSiteInstaller::CONTENT_ROOT_REMOTE_ID). removeContentObject() never purges an object whose main node is a top-level node, so a removal of the old root cannot remove the content root itself when the swap did not happen. swapNodes() names the object it could not find instead of an empty node id. The ezwebininstall command names the root by its remote id. Doc: doc/eng-us/bc/6.0/package-install-keeps-newer-extensions.md.

Tests

  • ExtensionKeepNewerTest (no database): a Composer or git copy is kept against a newer package.
  • SiteInstallerContentRootLookupTest (no database): the former root names, the lookup order, the messages, the top-level guard and the command's remote id.

2026-10-28: the user audit log

Added

  • The user audit log: the table expuser_audit keeps one row per security event about a user account or a user group: who acted, when (to the millisecond), where (siteaccess, host, module and view, the address as audit.ini [AuditPrivacySettings] Field[actor.ip] leaves it), whom (the account or group, id and login), what (the event name, result and reason, refused and failed attempts included) and the fields before and after. Passwords, hashes and other secrets are never stored. The rows are copied from the audit's access channel by the new sink useraudit (expAuditUserAuditSink, [AuditChannel_access] Sinks[]), so every path the audit records is covered: user/edit and content edits of user objects, registration and its approval, enable, disable, move, removal, member management, password changes and resets, role assignments, sign-ins. Settings: audit.ini [UserAuditSettings] (UserAudit, Events[], RecordIP, RetentionDays, MaxValueLength).
  • Users > User audit log (user/audit, new policy function user/audit): the events newest first, filtered by user (id or login, the account or the user who acted), action, result and days, paged, and exported as CSV (a POST, recorded as data.export.csv). The same template in admin and admin4, built from the shared components only.
  • The cronjob part useraudit (also in infrequent) removes the rows older than RetentionDays.
  • The table is in share/db_schema.dba (MySQL and MariaDB, PostgreSQL, SQLite, Oracle through ezoracle), the kernel schemas and the 6.0.0 to 6.0.15 update files; update/common/scripts/6.0/createuseraudittable.php (--dry-run) creates it on any engine. Not on MongoDB. Feature.
  • The member management page in German (doc/ger-de/bc/6.0/mitgliederverwaltung.md).

Tests

  • expUserAuditTest (no database): which records are kept, the row, no secrets, the filters and their SQL, the CSV cells, the schema in every file and the SQLite update run twice, the wiring (sink, view, policy, cronjob, menu, help).

2026-10-28: addeditorpolicies and addmembermanagement only show what they would change unless --apply is given

Updated

  • bin/php/addeditorpolicies.php and bin/php/addmembermanagement.php are a dry run by default and write only with --apply; they wrote by default and took --dry-run. Both change roles and assignments of an installation that exists, and a run without options now lists the changes instead of making them, as bin/php/expbackfillordersiteaccess.php does. --dry-run is still accepted and means the same as no option, so scripts and notes that pass it keep working; --apply together with --dry-run is a usage error (exit 2). The dry run ends with PASS: ... (dry run, nothing changed; --apply writes). The setup step of a new installation calls expEditorPolicies::ensure() and expMemberManagement::ensure() directly and is not affected. Exponential\Runnable\Command::applyRequested() reads the two options for any command that works this way. Behavior change.

Tests

  • expEditorPoliciesTest and expMemberManagementTest (no database): no option and --dry-run do not write, --apply writes, every write call of both commands sits behind the dry-run check, and both options together end the command with exit code 2.

2026-10-29: exp:upgrade, the upgrade from the console

Added

  • ./console exp:upgrade upgrades an installation in one call: a dry run by default that lists, step by step, what would change; with --apply a backup, then every pending step in order, each recorded, stopping at the first failure, then exp:velocity deploy --kernel and a request to the front page. The stages run in a fixed order: code, libraries, database, scripts, finish. Every stage is its own command: exp:upgrade:database, exp:upgrade:scripts, exp:upgrade:finish; with them exp:upgrade:status, exp:upgrade:steps, exp:upgrade:verify (drift: a step recorded as done whose effect is missing) and exp:upgrade:backup. Machine output --format=json (exp-upgrade/1); exit codes 0 to 4. The names, options, exit codes and the output are kept for good. Guide.
  • 32 steps for 6.0.15 (exp:upgrade:steps): the 6.0.0 to 6.0.15 database update in 21 steps written from the kernel schema on every engine (MySQL and MariaDB, PostgreSQL, SQLite, Oracle through ezoracle; MongoDB gets the collections), the update scripts of update/common/scripts/6.0/ and the setup commands through their classes, and the finish. Each step looks at the installation itself, so what was done by hand counts as not-needed.
  • The record of what exp:upgrade applied: since 31 October the table exp_migration; the first pre-release kept it in a table expupgrade_step of its own, which no release ships (dropped).
  • Extensions add their own steps with upgrade.ini [UpgradeSettings] ExtensionStepList[]. Guide.

Fixed

  • update/database/mysql/6.0/dbupdate-6.0.0-6.0.15.sql stopped with a syntax error after the table exppasskey (its closing line was missing), so the help content tables, the user audit table and ezorder.siteaccess were never created by it; the PKCE columns are now added only when missing, so the file can run again.

Tests

  • expUpgradeRegistryTest and expUpgradeCommandContractTest (no database): the order of the steps, dependencies and their problems, the states, the options, every step registered and its tables, columns and indexes in the kernel schema, the record table in every schema file, and the contract snapshot of the commands.
  • On throwaway SQLite, MariaDB and PostgreSQL databases: a new installation is not-needed; a 6.0.14 database is pending, the dry run changes nothing, --apply makes a backup and applies every step, a second run changes nothing, the schema then equals a new installation's, a dropped table is reported as drift, a half-applied database is completed, and after the SQL update file run by hand every database step is not-needed.

2026-10-29: exp:upgrade moves the code and the libraries, and rolls back

Added

  • exp:upgrade:code (stage 1): the installation's git checkout, and each --repos checkout, moved to --to (a tag, a branch or a commit) or to its upstream through git_manager, fast-forward only, the submodules following. Refused over local changes, over commits the target lacks and for a process that is not the owner of the checkout. git_manager's backup comes first on MySQL and MariaDB; the commits before the move are kept in code.json.
  • exp:upgrade:libraries (stage 2): composer install from composer.lock, or --update of named packages first, with composer audit before and after. Refused over local changes in vendor/, over a package kept as a git checkout of its own (an extension updated by git: Composer would replace it), and for a process that is not the owner (--allow-vendor-changes for the first two).
  • exp:upgrade runs the stages after the code stage in a new PHP process when the code moved, so they use the new code; the reports of both processes are one.
  • exp:upgrade:rollback: the backups, and with a name and --apply the database, settings/override, the libraries and the checkouts restored, part by part (--parts=). Never automatic.
  • --no-backup is refused on production ([AdminEnvironment] Level, or auto with EXP_ENV) without --force-no-backup.
  • Audit events system.upgrade.step, system.upgrade.backup, system.upgrade.rollback; system.upgrade.run also for the end of exp:upgrade --apply.
  • The backup also keeps composer.json, composer.lock, installed.json and the list of tables.

Fixed

  • The upgrade backup of MySQL and MariaDB wrote mysqldump's warnings into the dump; PostgreSQL's dump now cleans the objects it restores (pg_dump --clean --if-exists).

Tests

  • expUpgradeStagesTest (no database): the lock against installed.json, git checkouts found, merged reports, the options of the new process, production, the rollback's usage errors, the audit events.
  • On throwaway git repositories, a throwaway Composer project and throwaway SQLite, MariaDB and PostgreSQL databases: the code stage (dry run fetches only, --to, upstream, refusals, --repos), the libraries stage (install, a second run, --update, refusals), the rollback of each part, the production rule; on a throwaway installation cloned from the branch, exp:upgrade --apply --stages=code,finish moves the code and runs the finish stage with the new code in a new process.

Added

  • Added: The section based approval of new staff accounts, an optional alternative to the event Approve user accounts (which stays the default): a section Staff for Administrator users and Editors and the plain approval event (ezapprove) on content/publish before, with Administrator users approving and needing no approval, for new objects only. bin/php/adduserapproval.php --mode=section (a dry run unless --apply; --staff, --approvers, --exempt) sets it up and refuses while the event Approve user accounts runs on that trigger, as both together would ask twice for one account; site.ini [UserSettings] AccountApprovalMode=group|section|none chooses what the setup step creates (default group). expUserApprovalSection.

Updated

  • Updated: The collaboration notification mails (notification/handler/ezcollaboration/view/plain.tpl, ezapprove/approve.tpl, ezapprove/author.tpl) link to the item in the administration ({$collaboration_item.admin_url}, eZCollaborationItem::adminURL(): [UserSettings] RegistrationAdminBaseURL, else the administration siteaccess), not to the SiteURL of the siteaccess that ran the notification cronjob.
  • Updated: The plain approval event, for a user account or user group: every approver gets the mail with the link to the item, the request, approval and denial are recorded (access.user.approval.* with mode: section, also in the user audit log), "Verify it's you" asks before Approve and Deny, and the decision is carried out at once.
  • Docs: Approval of new user accounts ("Two ways", examples), Approving new editor accounts (step 9, screenshots), the feature page, and their German counterparts.

Tests

  • expUserApprovalSectionTest (no database): the default groups, the approval event's fields as eZApproveType reads them, the policy warnings, the modes, the command's dry run, the collaboration mail links, the plain event's hooks.

2026-10-28: sign-in sessions with their place, host and second step

Added

  • Each signed-in session keeps the host it was signed in on, its second step (an authenticator app, a code by e-mail, a recovery code, a passkey) and, with a local GeoIP database, its country and approximate place: the new table expuser_session_info (expUserSessionInfo), created by the 6.0.0 to 6.0.15 update files and update/common/scripts/6.0/createusersessiontable.php. Your devices shows them.
  • [UserSessionSettings] GeoIPDatabase and GeoIPLanguage: a MaxMind DB file (.mmdb) on the server, read with MaxMind\Db\Reader when it is installed; nothing is asked of another server and private addresses are not looked up (expUserGeoIP). A sign-in from a country the account has not used within RetentionDays is access.session.new_location and goes into the "New sign-in" e-mail, which now also links to the security activity.
  • expUserSessionTracker::exportForUser() and eraseForUser() for a data export and erasure.

Updated

  • The addresses of sessions are stored as [UserSessionSettings] RecordIP says: audit (default, as the audit keeps them: the network by default), full, truncated or disabled.
  • A session's last use is written at most once a minute (TouchInterval=60, was 300).
  • A session kept by Remember me is no longer ended as expired before its Remember me date, also when [Session] SessionTimeout is shorter.
  • The method hint of a sign-in and the row of the current session are kept for their own request only, so a persistent Velocity worker never hands them to the next request.

Tests

  • expUserSessionInfoTest (no database): the address modes, the GeoIP record and lookup, the new-location rule, Remember me and the timeout, the method hint per request, the schema in every file, and on SQLite in memory the details with the list, the retention, the export and the erasure.

2026-10-28: security activity per account

Added

  • Security activity (user/security, user/security/<user id> with the policy user/sessions): the account's recent security events as a timeline, newest first, filtered (all, sign-ins and sessions, account changes, needs attention) and paged, with the sign-ins, failed attempts and changes of the last 30 days on top (expUserSecurityActivity, read from the user audit log expuser_audit; nothing is recorded twice). Templates in admin (also admin3), admin4 (also admin4l) and standard (the public site), shared components only.
  • Linked from Your devices, the account page (a card with the failed sign-ins of the last 30 days), the user menu of the header (also on the phone) and the "Current user" box, with "Your devices" next to it.
  • Audit events of two-step sign-in: access.user.two_factor.enroll, .remove, .recovery_used, .recovery_issued, .verify.failed, recorded by the two-step extension through expSignInSecondFactor::audit().
  • The help of user/sessions and user/security.

Tests

  • expUserSecurityActivityTest (no database): every shown event is in the taxonomy with an icon of the administration and reaches the user audit log, the filters, the description of a row (who acted, no secrets), the counts, and on SQLite in memory one account's events newest first, paged, counted and exported; the wiring.
  • expUserAccountOverviewTest: the card of the own account and of another account with and without the policy.

2026-10-28: the sign-in report and signing a user out everywhere

Added

  • Users > Sign-in report (user/sessionreport, new policy function user/sessions_manage): the sessions running and the users they belong to, today's sign-ins and failed attempts, the last 14 days with bars (sign-ins and failures from the user audit log, new sessions from expuser_session), the running sessions of every user with a button to sign one out, and Sign a user out everywhere by user ID, username or e-mail address (expUserSessionReport). It reads expuser_session, so it works with every session handler; Setup > Sessions links to it when its handler keeps no table. Left menu and mega menu of the Users tab; the help of the view.

Updated

  • "Verify it's you" now asks before Sign out all other sessions on Your devices ([ReauthGroup_account] user/sessions self SaveMailButton RevokeOthersButton) and before signing sessions out on the sign-in report ([ReauthGroup_users]); with two-step sign-in it takes the code of the authenticator app.
  • The generated example pages of the settings, module views and cronjobs are current again.

Tests

  • expUserSessionReportTest (no database, and on SQLite in memory): counts per day, bar widths, the running sessions of every user (Remember me kept, timed out and ended left out), the wiring; expReauthRulesTest, AdminMegaMenuTest.

2026-10-28: security alerts, data export and erasure of sign-in data

Added

  • Security alert e-mails to the owner of an account (expUserSecurityAlerts, template design:user/security_alert_mail.tpl in the shared mail layout, category security, eng-US and ger-DE): two-step sign-in turned on or off, a recovery code used, FailedSignInAlertThreshold (5) failed sign-ins in a row, the account locked; each with "Secure your account" (the security activity) and Your devices. Sent from the rows the user audit log stores for the first time, so no event alerts twice and a late spool alerts nobody. A user cannot turn them off; [UserSessionSettings] SecurityAlertMail=disabled does for the site.
  • Download my sign-in data on the security activity: the account's sessions with their details and its security events as JSON ("Verify it's you" first, [ReauthGroup_export]), recorded as the new event data.export.json.

Updated

  • Removing a user account erases its sessions and their details (expUserSessionTracker::eraseForUser()).
  • "Verify it's you" also asks before the CSV export of the user audit log ([ReauthGroup_export] user/audit ExportButton).

Tests

  • expUserSecurityAlertsTest (no database, no mail): which rows alert, when none does, the wiring; expMailLayoutMailsTest renders the alerts and the "new place" sign-in mail in the shared layout.

2026-10-28: commands only change things with --apply

Updated

  • Updated: The setup and policy commands only show what they would change unless --apply is given: addanonymouspublicpolicies, addexphelpreadpolicy, addoauthauthorizepolicy, adduserapproval (both modes), cleanuppolicies and adddefaultstates, like addeditorpolicies and addmembermanagement before them. Each dry run ends with PASS: ... (dry run, nothing changed; --apply writes); --apply writes and checks the result; --dry-run is still accepted; --apply with --dry-run ends with exit code 2 before the database is read. addexphelpreadpolicy and addoauthauthorizepolicy now end with PASS or FAIL. cleanuppolicies lists each policy it would remove and reads them all before removing any; its paging no longer rereads pages. adddefaultstates has a dry run, per state group.
  • Updated: The maintenance commands work the same way: the writing actions of audit, explogs, contentcheck and exphelpcontent, and exploremipsumfill, collaborationsampledata, cleanuprss, clusterpurge, ezsubtreecopy, ezsubtreeremove, trashpurge and cleanupversions (whose removal loop skipped every second batch and could fail to end).
  • Updated: The mail and notification commands: mailbounces, mailconsent cleanup, notificationevents cleanup, notificationsubscriptions remove-missing and notificationrun (a dry run never sends mail); installcrontab --add and --remove show the block and the lines that would change. The button Run now on the notification status page passes --apply.
  • Updated: The installers: exp:install and exp:kickstarter run install only with --apply; the dry run checks kickstart.ini, the database and the remote packages and shows the plan (also when DatabaseAction=remove would drop the tables). --force alone is a usage error; it is accepted together with --apply. The demo rebuild examples (doc/eng-us/examples/demo-mode/) pass --apply and stop when nothing was installed.
  • Updated: preload, the writing subcommands of velocity (config set and unset, cache clear, layout migrate, site, conf and mod enable and disable, ssl renew, watchdog install and remove, the FrankenPHP install), webserver configure and frankenphp install. Process control (start, stop, restart ...) and exp:velocity deploy are as before; the maintenance mode clears Velocity's cache with --apply.
  • Updated: ezpgenerateautoloads, exp:cache and ezcache.php stay as they are, on purpose: they regenerate what can always be regenerated, and composer, the deploy and CI run them.
  • Updated: The administration's hints that name such a command show --apply (notification status, content check storage, audit dashboard, cronjob dashboard, the setup page of an installed site), in English and German.
  • Docs: Behavior change with every command and the ones left as they are, and why; the generated command pages; every guide, upgrade note and example that runs one of the commands, in English and German.

Tests

  • expApplyFlagCommandsTest, expApplyFlagMaintenanceCommandsTest, expApplyFlagCronCommandsTest, expApplyFlagInstallerCommandsTest and expApplyFlagDeployCommandsTest (no database): every write call of a command sits behind the dry-run check, the dry run ends with the PASS line, --apply with --dry-run ends each bin/php/<command>.php with exit code 2, and the callers (the notification job, the maintenance mode, the preload job, the demo scripts) pass --apply; ezpgenerateautoloads and cache still write without it.

2026-10-29: the SOAP entry script is index_soap.php

Renamed

  • The SOAP server soap.php is now index_soap.php, named like the other front controllers (index.php, index_rest.php, index_treemenu.php), so one rule pattern describes every script the web server may run. Settings, extensions (soap.ini [ExtensionSettings] SOAPExtensions[]), sign-in and answers are unchanged; a second sign-in step still never applies to SOAP (an app password is used). eZSys::init() gets the name the request used, so URIs and the siteaccess match are read relative to it.

Updated

  • soap.php stays as a shim that only includes index_soap.php. Once a day a request through it writes a notice to var/log/warning.log (the day is kept as the date of var/log/soap-php-deprecated.stamp); without a writable var/log/ it writes nothing and still answers. It will be removed in a later major version.
  • expUserSessionTracker::detectMethod() counts a sign-in through either name as soap.
  • .htaccess_root, .htaccess_root_static and the server examples (doc/eng-us/examples/htaccess, ezpublish.conf, web-servers/nginx.conf, web-servers/openlitespeed-vhost.conf) carry the rule for index_soap.php, commented out as SOAP is off by default.
  • Documentation, English and German: the behavior-change page The SOAP entry script is now index_soap.php (why, the lines to change for Apache .htaccess and the HTTPS virtual host, Plesk, nginx, LiteSpeed, Caddy, IIS and Velocity, the SOAP clients, a curl test of a GET and of a SOAP envelope, the log check for clients that still call soap.php), and every page that named soap.php: WebDAV and SOAP, syndication between installations, integration surfaces, integrators FAQ, the source tree tour, getting the code, API standards, extension directories, multi-site log paths, the second step on other sign-in paths, the syndication feature page, the Apache, virtual host, nginx and Velocity examples, the hosting server overview, hardening for hosts, Plesk and the storage layout.
  • syndication 1.3.14 shows index_soap.php as the SOAP address when the kernel has it and uses it as the example in its import wizard.

Tests

  • expSoapEntryScriptTest (no database): index_soap.php is the server, the shim only forwards and declares nothing, run in a PHP process of its own it forwards, writes one notice a day and still answers without var/log/, and a sign-in through either name is a SOAP sign-in. expSignInSecondFactorTest, expPasswordChangeRequiredTest and the file-mode scan read index_soap.php.

2026-10-29: the WebDAV entry script is index_webdav.php

Renamed

  • The WebDAV entry script webdav.php is index_webdav.php, named like index_rest.php, index_treemenu.php and index_cluster.php, so one pattern (index(_[a-z]+)?\.php) covers every web entry point at the root. Administrators who use WebDAV must change their virtual host (HTTP and HTTPS) and rewrite rules; the rules for Apache, Plesk, nginx and IIS and a test procedure are on The WebDAV entry script is now index_webdav.php.

Updated

  • webdav.php stays as a forwarding shim: it defines EXP_WEBDAV_ENTRY_SCRIPT and includes index_webdav.php, so existing rules and mounted drives keep working. The first request through it on a day writes a warning to warning.log (stamp file webdav-entry-script-deprecated.stamp in the log directory). It will be removed in a later major version.
  • index_webdav.php passes the name of the script actually run to eZSys::init() and treats /index_webdav.php[/] and /webdav.php[/] as the server root (new class expWebDAVEntryScript).
  • A sign-in through index_webdav.php or webdav.php is recorded as a WebDAV session (expUserSessionTracker::detectMethod()).
  • .htaccess_root and .htaccess_root_static carry a commented rule #RewriteRule ^index_webdav\.php - [L] with an explanation; the shipped rules still send WebDAV to index.php until it is uncommented.
  • The documentation names index_webdav.php everywhere, in English and German: the WebDAV help, the integration pages, the installation chapters 3 and 8, the web server examples (Apache .htaccess and virtual host, nginx, IIS, Velocity), the hosting pages, the upgrading guide and the behaviour changes of October 2026.

Tests

  • expWebDAVEntryScriptTest (no database): index_webdav.php is the server, the shim only names itself and includes it, both names are the entry script and the root, the warning is written once a day, a sign-in through either name is a WebDAV session, the shipped rewrite rules name index_webdav.php.

2026-10-29: PHP 7.4 is usable again, without support

Supported: PHP 8.0 to 8.5. Usable without support: PHP 7.4 (Exponential 6.0.15 and later). Exponential 6.0.15 installs and runs on PHP 7.4 again, without support (no bug-fix or security commitment specific to PHP 7.4), for sites that cannot change the PHP of their servers yet. Velocity, Exponential Layouts and a few extensions that need PHP 8 are left out on 7.4. The reasons, a table per feature, the security posture and the end-of-life stance: Why Exponential supports PHP 7.4 (Deutsch).

Added

  • PHP 8.0 installs Exponential Layouts (explayouts and its packages), expquery-translator, the expsite packages and the media site theme again: the metapackage for PHP 8.0 (se7enxweb/exponential-php-floor 1.1.4) requires them, as every one of them runs on PHP 8.0. 1.1.0 to 1.1.3 left them out; the root composer.json accepts 1.1.4 and later on PHP 8.0.
  • The metapackage se7enxweb/exponential-php-floor (1.0.3 for PHP 7.4, 1.1.4 for 8.0, 1.2.3 for 8.1 to 8.3, 1.3.3 for 8.4 and later), required by the root composer.json at ~1.0.3 || ~1.1.4 || ~1.2.3 || ~1.3.3: Composer installs the version for the PHP it runs with, and with it the extension releases that run there. 1.1.x, 1.2.x and 1.3.x require at least the extension releases the root composer.json required itself before (Exponential Velocity ~0.0.4.57 and the releases of 29 October 2026). 1.0.x adds ezodf ~7.0.4 and eztags ~2.4.31 on PHP 7.4, so the ezodf extension that Exponential activates is installed there too. The x.y.3 versions also bring Twig 3 for the Twig template engine: se7enxweb/twig ~3.11.4 on PHP 7.4 and ~3.19.1 on 8.0, twig/twig ^3.27 on 8.1 and later. The root composer.json does not require twig/twig: upstream Twig up to 3.26 is blocked by Composer's security advisories and 3.27 needs PHP 8.1, so a twig/twig requirement in the kernel made installs on PHP 7.4 and 8.0 fail. The earlier floor versions (1.0.0 to 1.0.2, 1.1.0 to 1.1.2, 1.2.0 to 1.2.2, 1.3.0 to 1.3.2) allowed older extension releases or install no Twig and are left out. symfony/polyfill-php80 and symfony/polyfill-php81.
  • make lint74 and make lint74-all, and the Quality workflow's job php74: PHP 7.4 syntax of every file, a production install with Composer on PHP 7.4 and the boot smoke test.
  • expVelocity::phpRefusal(): exp:velocity start refuses the Velocity engine on PHP 7.4 with a message that names what does run there (Apache or nginx with PHP-FPM, or --engine=php).
  • The help page "Why Exponential supports PHP 7.4" (EN and DE), and a question in the project FAQ.

Updated

  • composer.json: "php": "^7.4 || ^8.0 || ... || ^8.8"; the 38 packages whose current releases need PHP 8 are required through the metapackage instead of directly (on PHP 8.4 and later the installed set is the same as before).
  • The kernel parses on PHP 7.4: nullable parameters are written ?Type, three nullsafe operators became plain checks, a test passes arguments by position.
  • The kernel runs on PHP 7.4: the query cache's session map falls back from WeakMap to SplObjectStorage, eZPersistentObject's magic methods have no mixed type, the GD handler accepts an image resource, eZHTTPTool keeps its cURL handle open until its last use, the log viewer's ZIP download sets file times only where ZipArchive::setMtimeName() exists, the sign-in integrity check calls setAccessible() before PHP 8.1, host matching uses str_starts_with()/str_contains() (PHP 8 semantics for an empty host on every version), the HTTP benchmark keys cURL handles that are resources, and the template edit link falls back when sprintf() returns false.
  • settings/setup.ini [phpversion] MinimumVersion=7.4.0, also the system report's default.
  • The tests run on PHPUnit 9.6 as well (the PHPUnit for PHP 7.4 and 8.0).
  • expPhpSupport and settings/setup.ini [phpversion] SupportedVersion=8.0.0: the setup check, the kickstarter and exp:install accept PHP 7.4 with an "unsupported" notice and refuse older PHP; the system report shows both versions.
  • The documentation (EN and DE) states "Supported: PHP 8.0 to 8.5. Usable without support: PHP 7.4 (Exponential 6.0.15 and later)." wherever it states a PHP requirement of Exponential: the requirements and install chapters, the hosting and panel pages, the upgrade guides, the business, press, training and developer pages, the README; Velocity and Layouts keep their own minimum (8.0 and 8.1).

Renamed

  • The namespace of the class module's views Exponential\View\Kernel\Class is Exponential\View\Kernel\ContentClass ("class" is a reserved word before PHP 8.0). The old names keep working: autoload.php loads the new class for them, and each class registers the old name with class_alias.

Tests

  • expVelocityPhpRefusalTest, expRenamedClassViewNamespaceTest. The DB-less suites were run on PHP 7.4 and PHP 8.0 with the same PHPUnit (9.6); every test that failed only on 7.4 was traced to a PHP difference and fixed.

2026-10-29: exp:upgrade captions, the verification after an upgrade, rollback to any point in time

Added

  • Before an upgrade writes anything, exp:upgrade --apply takes a caption, git_manager's full backup (the database dump and the var/ storage) labelled upgrade <from>→<to> <time> pre. It adds the upgrade's own part: settings overrides, composer.json, composer.lock, installed.json, the git commits of every checkout, and the SHA-256 of every file. The caption is recorded in the report and in exp_migration. Without git_manager, or with one that cannot dump the engine, exp:upgrade makes the caption itself and says why. --no-caption (the same as --no-backup) is refused on production without --force-no-caption. Guide.

  • exp:upgrade:verify --after, run in a new process at the end of exp:upgrade --apply (--no-verify skips it):

    • the steps: no drift, none failed;
    • the smoke addresses ([UpgradeSettings] SmokeURL[], else velocity.ini WarmUpURL[] for Apache and Velocity, else the front page and the sign-in page);
    • the new PHPUnit test suite upgrade-smoke;
    • no new fatal line in the error logs;
    • the autoloads.

    A failure ends the run with exit 1 and lists the captions with their rollback commands. --rollback-on-fail rolls back to the pre caption; --post-caption takes a caption of the verified state.

  • exp:upgrade:captions: every caption, git_manager's own included. --verify=<id> checks the checksums and restores the dump into a throwaway database. --prune [--apply] applies CaptionKeep, CaptionKeepDays and CaptionKeepUpgrades of upgrade.ini [UpgradeSettings].

  • exp:upgrade:rollback <caption> works with any caption, and has a new part, storage (var/). With --apply it first takes a safety caption of the current state, so a rollback can be undone, and it verifies afterwards.

  • Audit events system.upgrade.caption and system.upgrade.verify.

  • git_manager: captions dump PostgreSQL, SQLite and MongoDB as well as MySQL and MariaDB, and describe their dump in dump.json (git_manager, next release).

Tests

  • expUpgradeCaptionTest (no database): prune by count, age and the last upgrades, checksums, caption ids never paths, the fatal lines of an error log since a moment, the smoke addresses.
  • On throwaway SQLite, MariaDB and PostgreSQL databases with git_manager's every-engine dump: the pre caption and its record, the list, --verify (checksums and a restore into a throwaway database) and a changed file found, --no-caption refused on production, rollback with its safety caption and back again, the storage part, prune. On SQLite also without git_manager, and with a git_manager that dumps MySQL only.
  • On a throwaway installation through the console: a failed verification ends exp:upgrade --apply with exit 1, lists the captions and, with --rollback-on-fail, restores the pre caption. A passed one (with the test suite) takes the post caption. exp:upgrade:captions, --verify and the rollback dry run work on the result.

2026-10-29: "Verify it's you" with the password and a passkey, a passkey policy, a passkey as the second step

Guides: Passkeys, Verify it's you. Upgrade note: Passkeys and "Verify it's you".

Updated

  • Updated: "Verify it's you" asks for the password AND a passkey, without a passkey for the password and the code of the authenticator app ([ReauthSettings] Factors[]); a way of the same strength is offered beside it, never a weaker one. A web sign-in that asked for as much counts as a confirmation.
  • Updated: a group delete (on): emptying the trash, removing from it, removing several items at once (rule token bulk, BulkCount). Another user's devices in full and the sign-in report ask before the page opens. The optional content group also asks when a draft is stored. Groups can name UserGroups[]; Setup > Verify it's you edits every siteaccess or one (setup/reauth/<siteaccess>) and picks the user groups of the content group.

Added

  • Added: a passkey policy, [PasskeySettings] Policy (optional, preferred, required) and PasswordSignIn, per site, user group and account, with expPasskeyPolicyHandler classes for extensions. The last passkey cannot be removed while the account could not sign in without it. Web pages only.
  • Added: administrators with the policy user/passkeys_manage see and revoke another account's passkeys (user/passkeys/<user id>). The list names each passkey's authenticator by its model id (AAGUID); Attestation, RelyingPartyIDMap[], AuthenticatorNames[] and ConsoleSlots[] are new settings.
  • Added: security mails when a passkey is added, removed or revoked; audit events access.passkey.revoke, .password_refused, .required, .suggested.
  • Added: expReauthTriggerHandler ([ReauthSettings] TriggerHandlers[]) and expReauth::requireFresh() for extensions.
  • Added (sevenx_authentication_2fa): the second step of signing in offers a passkey beside the app's code; an account that must have a second step and has a passkey needs no app.

Tests

  • expReauthFactorsTest, expReauthGroupsTest, expPasskeyPolicyTest (no database): the combinations and that none weaker is offered, the delete and content groups, bulk, UserGroups[], trigger handlers, the settings page's scope, the policy decision and gate, the last passkey, authenticator names, the relying party per host, attestation, the passkey alerts, a sign-in that counts as a confirmation.

2026-10-29: console commands copy .htaccess and config.php into place and read and set config.php values

Added

  • ./console exp:setup:htaccess [--static] copies .htaccess_root (or .htaccess_root_static) to .htaccess, ./console exp:setup:config copies config.php-RECOMMENDED to config.php, and ./console exp:setup:status says which template each file comes from (bin/php/setupfiles.php). They replace the cp -va of the installation chapters. Nothing is written without --apply; an existing file that differs is shown as a diff and kept unless --force; the old file is backed up to var/backup/setup/<time>/ first (mode and owner kept, directories 0700), and the new one is written to a temporary file and renamed into place. A new file gets the template's mode and, run as root, the template's owner, as cp -a does; --mode, --owner=user[:group] and -v (prints each copy like cp -v) are there too.
  • ./console exp:config:list, exp:config:get <NAME>, exp:config:set <NAME> <value> and exp:config:unset <NAME> read and change the define() settings of config.php (bin/php/configphp.php). The file is read as PHP tokens and never run (expConfigPhpEditor): only the value of one define() changes, a documented //define( ... ); line of config.php-RECOMMENDED is uncommented in place, any other name goes into a marked block; comments, order and formatting stay. Values are typed (bool, int, octal, float, string, null) from --type, the current or the documented value. config.php-RECOMMENDED is the catalogue of known settings, with their sections and descriptions (expConfigPhpCatalogue); other names need --allow-unknown. Every write is a dry run without --apply, backed up to var/backup/config/<time>/, checked with php -l and read back before it is renamed into place; a config.php that does not compile is refused. Values of names with PASS, SECRET, KEY, TOKEN, CREDENTIAL or PWD are masked unless --show-secrets. --format=json for list and get.
  • Guide: Setting up .htaccess and config.php from the console; feature page, generated command pages, and the installation, upgrade, hosting, web server, security and developer pages name the commands, in English and German.

Updated

  • autoload.php reads a config.php that does not compile as before (the parse error stops the request), except for the scripts that define EXP_CONFIG_PHP_TOLERATE first: bin/php/console, bin/php/setupfiles.php and bin/php/configphp.php. For them the file is checked with the PHP parser and left unread when it does not compile, so ./console exp:setup:config --force --apply can replace a broken config.php; the console prints a warning.
  • config.php-RECOMMENDED: the file permission settings have a heading of their own, FILE PERMISSIONS.

Tests

  • expSetupFilesAndConfigPhpCommandsTest (no database): the editor finds set, commented and conditional define()s, reads every value type, changes only the value, uncomments the documented line, adds to and removes from the block (an addition and its removal give the file back byte for byte), refuses conditions and duplicates; values are typed; strings with quotes and backslashes read back; every setting of the shipped config.php-RECOMMENDED can be set, read back and unset; copies, modes, backups, a failed check that changes nothing; the commands run in a child process against a throwaway root (both flags exit 2, dry run writes nothing, refusal without --force, masked secrets, a broken config.php refused).

A historical shift. On 29 October 2026 the copyright of Exponential moved from 7x alone to the Exponential Foundation and 7x together, and the project replaced every notice of its own work with one notice in two forms:

  • shown to users: Exponential Copyright © 1998-2026 Exponential Foundation and 7x
  • in files and metadata: Copyright (C) 1998 - 2026 Exponential Foundation and 7x. All rights reserved.

The Foundation is named first on purpose: the nonprofit that operates the project is the steward meant to carry the software's rights, its GPL license and its history through the twenty-year support window of the 6.x line, independent of the fate of any one company; 7x, which leads the development, is the second, full holder. Until this date the project's own notice was written as "7x and others", "7x / Exponential Foundation", "7x & Exponential Foundation", "7x and the Exponential Foundation" or "7x" alone, depending on the file. The license did not change: Exponential stays under the GNU General Public License, version 2 or any later version, and its text is untouched. The full story: Copyright and copyleft (German), and COPYRIGHT.md.

Updated

  • Every place a user sees the notice shows the visible form, never translated: the footers of the admin and admin4 designs (and of admin3, admin4l and the editor, which include them) and their sign-in pages, the standard design footer (plain, print, popup, full screen, login and OAuth layouts), the base design footers, the setup wizard, the page of an undefined module, the fatal error page, ./console --version, the /ezinfo/copyright page, the help's documentation notice (also in its PDFs) and the system card.
  • Catalogues: in all 156 translation.ts catalogues the footer message (context design/admin/page_copyright) is the new notice, and its translation is the notice itself; the three dead variants of the old "7x and others" notice that no template used any more are removed. translation-identical-allowlist.json lists the notice as kept in every language.
  • settings/exphelp.ini [License]: CopyrightYears=1998-2026, CopyrightHolder=Exponential Foundation and 7x; the documentation notice (doc/eng-us/README-LICENSE.md) names the same two holders and no others. There is no separate "Exponential contributors" copyright: every contributor assigns the copyright in a contribution to the Exponential Foundation and 7x through the CLA; contributors are credited (changelogs, commit authorship, the recognition pages), and credit is not a copyright claim. ./console --version says "Written by the Exponential Foundation and 7x (se7enx.com)."; the about page says that its list of contributors is credit.
  • File headers: about 3,630 files carry "Copyright (C) 1998 - 2026 Exponential Foundation and 7x. All rights reserved." in their @copyright, // COPYRIGHT NOTICE: or comment header line. Only that line changed in each file; php -l passes on every changed PHP file.
  • LICENSE and LICENSE.md name the new holders above the license text, which is unchanged; COPYRIGHT.md records the notice, the holders, this history, the contributors and the third-party notices; README.md has a Copyright section; composer.json lists the Exponential Foundation and 7x as authors.
  • The metadata of the extensions shipped in the kernel (ezinfo.php and extension.xml of exphelpsite, expservices, ezformtoken, ezjscore and ezoe) and the files ./console exp:ini:move --create-extension writes use the header form.
  • Documentation, English and German: a new page, Copyright and copyleft, listed in the help index; every page that quoted or described the notice (licensing, legal, about, contributing, partner, press, trainer, procurement and FAQ pages, the extension developer guide, the coding standards, the extension metadata specification) states the new holders, and about 35 of them link to the new page.
  • The contributor agreement (CLA) assigns the copyright in a kernel contribution to the Exponential Foundation and 7x together, which become its owners, the Foundation named first as in the notice: the Exponential Foundation and 7x Contributor Licensing Agreement, version 1.1.0, live on the CLA page since the same day, is the first version that assigns the copyright (section 4.1; section 4.3 keeps a license as a fallback). Version 1.0.1 had granted 7x a non-exclusive copyright license (its section 4, from the Apache ICLA 2.0), not an assignment; contributions under a signed 1.0.1 stay licensed to 7x under it (section 12 of 1.1.0). CONTRIBUTING.md ("Copyright assignment (CLA)"), the checklist of .github/PULL_REQUEST_TEMPLATE.md, README.md, CODE_OF_CONDUCT.md and every page that describes the CLA (the CLA explained, the CLA for students, the FAQs, the glossaries, the contributing, partner, supporter, press, trainer and procurement pages, in English and German) say so and point to version 1.1.0 (Exponential Foundation and 7x CLA v1.1.0.pdf).

Kept unchanged

  • Every notice of another holder, as the GPL requires: eZ Systems AS (the original developer of the discontinued eZ Publish, "Copyright (C) 1999 - 2014 eZ Systems AS" and about 1,390 @copyright lines, below the project's line), the joint notice "7x / Brookins Consulting / eZ Systems AS" of the role operator, CJW Network, Mugo Web (LoveStack), Moxiecode and Tiny Technologies (TinyMCE), the OpenJS Foundation, Unicode (CLDR), Nokia and Trolltech (Qt Linguist sources), the KDE Artists, the Tango icons, the Free Software Foundation's notice in the license text, and the notices of the books. The list with the reason for each: tests/tests/bin/copyright-notice-allowlist.json.
  • History: the changelogs, the history pages and the release tables keep the notices as they read on their date.

Tests

  • CopyrightNoticeTest (testsuite quality, no database) reads every file of the repository and fails on any notice that names 7x or the Exponential Foundation in a form other than the two approved ones, unless the allowlist gives a reason; its own cases cover the approved forms, every old spelling and third-party notices.
  • CopyrightFooterNoticeTest (testsuite design) checks the footers, sign-in pages, setup wizard, undefined module and error pages, the console, the copyright page, the help notice and the system card, and that no catalogue translates the notice.
  • expHelpLicenseTest checks the help's notice; IniMoveTest the header the extension generator writes.

Operator note

Nothing to run. The extensions in repositories of their own (the se7enxweb extensions and their -ezpackage twins, Exponential Velocity, the installers and site packages) follow in their own releases.

2026-10-29: the site packages no longer carry ezpaypal or bcwebshop

Removed

  • The sevenx_extension package no longer carries bcwebshop: its files, file list and install items are gone (sevenx-ezpackage). The kernel's shop covers what bcwebshop added; its shop module and kernel override conflicted with it. bcwebshop 0.8.2 stays available on GitHub and Packagist for sites that keep it.

Updated

  • ezwebin_site, ezflow_site, ezdemo_site, sevenx_site and their _clean variants no longer require the ezpaypal_extension package: Composer installs ezpaypal, and the package server's copy was written over it. The installers still activate ezpaypal and no longer activate bcwebshop. ezpaypal_extension stays on the package server (and in its index) for older installations. The multisite installers (sevenx_multisite, cjw_multisite) never installed either and still activate ezpaypal.
  • Upgrading: item 23 of the list above. Existing installations are not changed.

2026-10-29: the documentation recommends rewrite rules in the virtual host over .htaccess

Updated

  • Every page of the documentation that mentions .htaccess (155 in English, 156 in German; not the generated reference pages and not the history) opens with the same warning: .htaccess files, once enabled with AllowOverride, are looked for, read and evaluated on every request, and the fix is AllowOverride None in the HTTPS virtual host with the rules in its configuration. Each box links to the measurement, the example virtual host for Apache, the configurations for nginx, LiteSpeed, Caddy, IIS and Velocity, and the control panel pages for Plesk, cPanel, DirectAdmin and ISPConfig, and says how to switch over.
  • Setting up .htaccess and config.php from the console has the measurement: two throwaway Apache 2.4 instances with the rules of .htaccess_root, without PHP, 9 rounds, medians. A static image four directories deep: 988 requests/s with .htaccess and AllowOverride All, 1,419 with the rules in the configuration and AllowOverride None (44% more). A page address rewritten to index.php: 1,079 against 1,303 (21% more).
  • exp:setup:htaccess prints one line naming the faster alternative and the example virtual host.

2026-10-29: debug comments no longer break a start tag

Fixed

  • With [TemplateSettings] Debug=enabled or ShowTemplatePathComments=enabled, the administration showed a stray > before the title of the full view, before every attribute of the preview and the edit form, and <Name> in the title of the edit form (admin and admin4). parts/exp_content_direction.tpl prints into a start tag (<h1 class="context-title"{include ...}>), and the engine wrapped it in its debug comments: the browser ended the tag at the first > of the comment and showed the rest as text. The templates that test its output with set-block for '' got the comment, never an empty string, and added their right-to-left wrapper always. Clear the template and content caches after the update: templates cached with the comments keep them.

Added

  • site.ini [TemplateSettings] TemplatesWithoutDebugComments[]: templates that neither Debug nor ShowTemplatePathComments wrap in comments, by their path below templates/ (a file of that path in any design or extension). The default lists parts/exp_content_direction.tpl. Add a template of your own that prints into a start tag. eZTemplate::templateAllowsDebugComments() answers for a template file; the interpreter and compiled templates ask it.

Tests

  • eZTemplateDebugCommentsTest (no database): a part printed into a start tag or captured with set-block gets no comments with Debug or ShowTemplatePathComments, interpreted and compiled; other templates keep theirs; the setting matches the path below templates/ in any design.

Merged pull request #342 (contributed by ekked).

2026-10-29: debug comments are left out wherever they break the output, system wide

Updated

  • Every rendered output (eZTemplate::fetch()) is checked once while Debug or ShowTemplatePathComments is on: a debug comment inside a start or end tag, an attribute value, <script>, <style>, <title>, <textarea> or another comment is taken out, and output that is not HTML (XML, where a comment before <?xml is an error, JSON, plain-text mail, CSV, iCal) gets none (eZTemplate::removeMisplacedDebugComments()). No list is needed for those places; production, with both keys off, never runs the check.
  • A set-block or append-block that captures a value rather than HTML (a name, a URL, attributes, plain text, nothing) gets no debug comments (eZTemplate::capturedText(), interpreted and compiled); captured HTML keeps them. With the comments on, the admin4l left menu asked for the template parts/<!-- START ... (the navigation part, captured from parts/a4_navigation_part.tpl), the Import/Export button and the menu zones were drawn although empty (their |trim|ne( '' ) tests saw the comments), the plain-text part of the HTML mails carried them, and exp_adminui's avatar src was a comment. parts/a4_preference_attributes.tpl (in the <html> tag of admin4 and admin4l) and parts/language_badge.tpl (in a script string) are now covered by the rule as well, so TemplatesWithoutDebugComments[] keeps parts/exp_content_direction.tpl as its only default.
  • eZTemplate::templateAllowsDebugComments() reads the list once into a lookup array and keeps each template's verdict: the check on every include is a key lookup, however long the list.
  • Templates compiled with Debug=enabled (which compiles its comments into the template) get compiled files of their own, named with the state of Debug, ShowXHTMLCode and the list: switching Debug or changing the list no longer needs a template cache clear, and production's compiled file names do not change.

Tests

  • eZTemplateDebugCommentsTest: a comment in a tag, an attribute value, a script, a style, a title, a textarea or another comment is taken out, comments between elements stay, XML, JSON, plain text and iCal get none, an unlisted part in a start tag loses its comments in the rendered page (Debug and path comments, interpreted and compiled), the verdict is a key lookup, the site.ini default, the compiled file name of Debug, a captured value (a name, nothing) has no comments and captured HTML keeps them.
  • Checked in a browser with path comments on (a sandbox, throwaway administrator): node views, the preview and the edit form in admin4, admin4l, the classic admin design, admin2 and exp_adminui, light and dark, without a comment in a tag, an attribute, a script, a style, the title or a textarea and without a stray >.

2026-10-29: admin4 node view tabs switch and package compare marks (pull request #341, follow-up)

Updated

  • The node view's tabs switch stands at the end of the meta line after the languages (parts/a4_tabs_switch.tpl; window_controls.tpl leaves its own out with tabs_switch_elsewhere), the dark mode has more contrast and the side columns' headings clear the rounded corner.
  • Package compare's insert and delete marks take a quarter of the radius token, so a mark on one character stays a box.

Merged the follow-up commits of pull request #341 (contributed by ekked).

2026-10-29: page caching with LiteSpeed and OpenLiteSpeed, without a plugin

Added

  • .htaccess_root carries a commented block, ## >>> LiteSpeed page cache to ## <<< LiteSpeed page cache, directly before the front controller rule. Uncommented (one sed line, given in the block), LiteSpeed Web Server (Enterprise) and OpenLiteSpeed keep a page for max-age (120) seconds and answer it without PHP, for a GET or HEAD request without a query string, without a session cookie (eZSESSID... or PHPSESSID, after [Session] SessionNameHandler) or is_logged_in cookie, and outside the administration, user/, shop/, notification/, layout/, ezjscore/, setup/ and every content/ view other than content/view/. It sits in <IfModule LiteSpeed>, so Apache and nginx never read it. The verbatim copies of the file (doc/eng-us/examples/htaccess, the Apache .htaccess example pages) have it too.
  • litespeed-page-cache.conf: the same block for a LiteSpeed Enterprise virtual host, Plesk's "Additional directives for HTTPS", cPanel's include files and DirectAdmin's custom HTTPD configuration. The OpenLiteSpeed virtual host example has a commented module cache block and the commented cache rules before its last rewrite rule.
  • The guide Page caching with LiteSpeed and OpenLiteSpeed (German: Seiten-Caching mit LiteSpeed und OpenLiteSpeed): when it helps, what the kernel does that the cache must know (when a session starts, the two session cookie names, is_logged_in, the default no-cache headers), every line of the block, LiteSpeed Enterprise, Plesk (and why a hand-made vhost_ssl.conf is lost at the next regeneration), cPanel, DirectAdmin, OpenLiteSpeed and CyberPanel, checks with X-LiteSpeed-Cache, how it sits with the kernel's caches, choosing max-age, purging (what exists today, and an example listener on content/cache that sends X-LiteSpeed-Purge, not part of the kernel), what must never be cached, troubleshooting, security, measuring and crawlers.

Updated

  • Linked from the help index, the guides' table of contents and the operators' road, the caches guide, the hosting pages for LiteSpeed, performance, caching layers and the slow site runbook (a new section on slowness under crawlers), the Plesk, cPanel and DirectAdmin pages, the web server examples, and every .htaccess speed box (156 English, 155 German pages), in English and German.

Tests

  • The address condition was checked with PCRE against sample addresses (content pages and content/view/ cached; the administration, user/, shop/, content/edit, content/search, content/treemenu, layout/, ezjscore/, setup/ and notification/ excluded, also behind a siteaccess name), and the sed line was run on a copy of .htaccess_root. Not run on a LiteSpeed or OpenLiteSpeed server: there is none on the machine the documentation is checked on.

2026-10-29: Twig documentation: native templates, recipes, debugging, editors, compatibility and speed

Added

  • Writing Twig-native templates for Exponential (German: Twig-Templates für Exponential nativ schreiben): the two ways a .twig is found (the design resource with override rules, Twig's own loader with the design fallback), template inheritance together with override rules, include, embed and expinclude, macros, filters, functions and tests, wash, autoescape and |raw (and why wash under autoescape escapes twice), whitespace control, what the sandbox allows and refuses, the variables each kind of template sees, the design keys through expvar().
  • Twig templates: recipes (German: Rezepte): a page layout, a line view, a block of Exponential Layouts, a mail template whose subject the module reads back, cache blocks per role and per user, a form with the form token, texts with a count, images in an image alias.
  • Debugging Twig templates (German: Fehlersuche): exp:template:check with every Twig rule, the message in the page read part by part, the messages and their fixes, template path comments and the debug bar, the twig profile notice row by row, Twig's debug mode, a change that does not show.
  • Twig templates in your editor (German: im Editor): highlighting and file endings, what an editor's Twig checks cannot know, the checker in Visual Studio Code and JetBrains IDEs, the ignore comment, formatting, .editorconfig, the conversion marker, snippets.
  • Twig templates: versions, upgrades and compatibility (German: Versionen, Upgrades und Kompatibilität): the Twig package the PHP floor metapackage installs on each PHP line (twig/twig ^3.27 on PHP 8.1 and later, se7enxweb/twig on 8.0 and 7.4), why twig/twig cannot be in the kernel's own composer.json (advisories up to 3.26, 3.27 and later need PHP 8.1) and why an installation on PHP 7.4 or 8.0 must not add it, what happens when Twig is missing, upgrading Twig, PHP and Exponential, Velocity (one environment per worker, class names, the cache permissions of compiled templates).
  • Speed of the three template engines (German: Geschwindigkeit der drei Template-Engines): Twig, Smarty and Exponential templates measured on 29 October 2026, cached (600 requests per page, engine and server), uncached, side by side in the same rounds and as the debug render, each with its date, load and data file; why cached pages are engine-independent and why .tpl is slower in a full render; a profile of where a request spends its time; what is in progress and not shipped; how to repeat the measurements with exp:benchmark.
  • Templates side by side: Exponential templates, Twig and Smarty (German: Templates nebeneinander): the same template in the three engines, every row converted by the kernel's converters and rendered with identical output, and how every operator and template function is written in Twig, the operators of the fast path included.
  • Glossary entries for Smarty templates, template engine and Twig templates (English and German).

Updated

  • The Twig performance guide (English and German): the direct static calls for every bridged operator and function, the quieter cached run of the afternoon, links to the comparison of the three engines.
  • The three template engines compared, the Twig feature page, the guides index, the features index, the template language index, the Smarty guides and side-by-side page, the moving-to-Twig chapter and the benchmarking guide link the new pages; German indexes and .paths.json likewise.

2026-10-29: the GraphQL API, phase 1: reading content

Added

  • Added: The GraphQL API of the kernel at /api/graphql in every siteaccess where it is switched on (settings/graphql.ini [GraphQLSettings] Enabled=enabled, optionally SiteAccessList[]; off by default). The schema is generated from the content classes: a type per class implementing the interface Content, a field per attribute, the root queries node (by node id, remote id, path or URL alias), content, children, currentUser, siteaccess and translations, lists with limit/offset and with first/after cursors, images in any image alias with variations, rich text as html, text, xml and excerpt, relations, a language argument. The datatypes of the kernel have types of their own; every other datatype is a String. Every query runs with the rights of its user: an OAuth token, a personal API key with the new scope graphql:read, the session, or the anonymous user. Guide: The GraphQL API (German: Die GraphQL-API).
  • Added: Naming styles for the schema (NamingStyle=identifier, pascal or map with [GraphQLNaming]), the suffix _field for an attribute named like a field of Content, the suffix Content for a class named like a type of the schema, and a schema hash in every answer (extensions.schemaHash, the header X-GraphQL-Schema).
  • Added: Limits checked before a query runs (MaxDepth, MaxComplexity, MaxListLimit, DefaultListLimit), introspection for signed-in requests only by default (Introspection), classes, attributes and datatypes kept out of the schema (ClassList[], ExcludedClassList[], HiddenFieldList[], HiddenDatatypeList[]: user accounts, user groups, settings classes, ezuser and ezpassword by default), errors with extensions.code and extensions.requestId, and Debug for a development siteaccess.
  • Added: Setup > GraphQL (module graphql, policy graphql/playground): the end points, the library, the schema and a playground that runs queries with the rights of the signed-in user through the session end point graphql/query (policy graphql/query, POST with the form token in X-CSRF-Token).
  • Added: The console commands exp:graphql:status, exp:graphql:schema (sdl, json, summary) and exp:graphql:query (a query, a .graphql file or standard input, --variables, --as, --format=table), with names, options, exit codes and machine output kept as a stable contract.
  • Added: webonyx/graphql-php ^15 in the root composer.json (installs on PHP 7.4 through 8.5), and [GraphQLSettings] LibraryPath or the environment variable GRAPHQL_LIBRARY_PATH for an installation that keeps the library in a directory of its own.
  • Added: The generated reference pages of graphql.ini, the three commands, the views and policies of the module graphql, the new rest.ini settings and the extension points of graphql.ini (English and German), with their .paths.json entries; glossary entries for GraphQL (English and German).

Updated

  • Updated: rest.ini: ProviderClass[graphql]=expGraphQLRestProvider, the scope graphql:read with its route, [RouteSettings] OptionalAuthentication[] (a route authenticated only when the request presents credentials; a wrong or expired token is still refused) and [expGraphQLRestController_CacheSettings] (no application cache for an answer).
  • Updated: The REST prefix filter takes the address of a provider without a slash after it (/api/graphql) when ProviderClass[] registers a provider of that name; any other single element stays the default provider's route.
  • Updated: Personal API key scopes may carry a namespace (graphql:read).
  • Updated: The features index (English and German) links the GraphQL page.

2026-10-30: the GraphQL API, phases 2 and 3: speed, caching, limits; search, tags, layouts, more datatypes

Added

  • Added: Persisted queries for the GraphQL API: queries stored on the server (var/<site>/graphql/persisted/) and called by name (GET /api/graphql?id=articles) or by SHA-256, Apollo's automatic persisted queries (extensions.persistedQuery.sha256Hash, stored for signed-in requests by default: AutomaticPersistedQueries, MaxPersistedQueries), queries shipped in directories (PersistedQueryDirectoryList[]), and a mode that runs persisted queries only, for anonymous requests or all (PersistedQueriesOnly). The new command exp:graphql:persist checks .graphql files against the schema and stores them (--apply), lists (--list) and removes them (--remove); exp:graphql:query --id runs one. New error codes PERSISTED_QUERY_NOT_FOUND, PERSISTED_QUERY_NOT_SUPPORTED, PERSISTED_QUERY_REQUIRED; the longest query is now a setting (MaxQueryLength).
  • Added: Rate limits for the GraphQL API: RateLimit requests per signed-in user or personal API key (600) and RateLimitAnonymous per anonymous IP address (120) in RateLimitWindow seconds (60), counted in files under var/cache/graphql/rate/ by every web server worker alike; over the limit the answer is 429 with RATE_LIMITED and Retry-After, and every answer carries X-RateLimit-* headers. exp:graphql:status shows the limits and the persisted queries.
  • Added: A response cache for the GraphQL API (ResponseCache, ResponseCacheTtl): the answers of persisted queries, kept per siteaccess, schema, user rights, variables and address in var/cache/graphql/responses/. An anonymous GET of a persisted query is answered at the start of index_rest.php before the kernel starts, still counted against the anonymous rate limit. The cache expires whenever eZContentCacheManager clears view caches (publishing), when a class changes, when persisted queries change, and with the new cache item graphql (ezcache --clear-id=graphql, tags content and ini). Every answer has an ETag and If-None-Match gets 304; X-GraphQL-Cache says HIT, MISS or BYPASS. A signed-in GET is answered early as well once the kernel has checked its token or key: the rights of the credentials are remembered CredentialCacheTtl seconds (60) and forgotten when an API key is revoked or removed, a role changes or the user caches are cleaned up; a CORS GET keeps the Access-Control-* headers of its origin. Measured with PHP-FPM 8.5 and OPcache and alpha's extensions (50 articles with image, owner and address, 3 x 250 requests): cached GET answers median 1.1-1.7 ms, p95 2.1-3.2 ms, at most 6.6 ms; a cached answer through the kernel (POST) median 33.0 ms; uncached median 93.0 ms.
  • Added: The GraphQL API's error messages in the language the request asks for with Accept-Language (a catalogue of the administration, by full code or language), else in the siteaccess's language (expGraphQLMessage, context kernel/graphql); the depth, complexity and introspection refusals of the library in the kernel's own words. extensions.code stays the same in every language.
  • Added: GraphQL types for the datatypes of extensions and the shop: ezgmaplocation (GeoLocation), ezsrrating (Rating), xrowmetadata (Metadata), ngenhancedlink (EnhancedLink, with the linked content), sckenhancedselection and ngclasslist (lists of identifiers), ezoption (OptionList), ezmultioption (OptionGroup), ezrangeoption (Range), ezmultiprice (CurrencyPrice), ezenum (EnumValue), ezsubtreesubscription, ezproductcategory.
  • Added: Schema extensions for the GraphQL API ([GraphQLSettings] SchemaExtensionList[], expGraphQLSchemaExtension): an extension adds root queries, fields of Location and the types of its datatypes where it is available; its name is part of the schema hash, and a batch loader of its own is one call away ($ctx->loader( kind, language, batch )).
  • Added: The root query search of the GraphQL API: the search engine of the installation (the kernel's, or Solr through ezfind) by text, classes, subtree, sections, publishedAfter and publishedBefore, with the pagination of every list, totalCount, the engine that answered and, from Solr, facets with their values (type SearchResult).
  • Added: Tags in the GraphQL API while eztags is active (the schema extension expGraphQLTagsExtension, in SchemaExtensionList[] by default): the root queries tag (by id, remote id or path of keywords) and tags (below a tag, or by keyword), the type Tag with its keyword in a translation, parent, path, children, synonyms, main tag, address and the content tagged with it (eztags' attribute filter, as the user may read it), and the eztags fields of content as [Tag!]!. The root queries need the policy tags/read.
  • Added: Layouts of the page builder in the GraphQL API while explayouts is active (the schema extension expGraphQLLayoutsExtension, in SchemaExtensionList[] by default): the root query layout (by id, identifier, or the URL alias of a page through the rules of explayouts) and Location.layout, with Layout, LayoutZone (a linked zone shows the shared zone's blocks), LayoutBlock (definition, view types, parameters, collection type, the content placed in a manual collection as the user may read it, the blocks of a container) and LayoutBlockParameter. Only published layouts of the site; parameters and collections of a level in one batch each.

Updated

  • Updated: The GraphQL API reads a page in groups (the DataLoader pattern, expGraphQLBatchLoader): the owners, the parents and the related objects of every item of a level are read in one query each, the URL aliases of a page in two (eZURLAliasML::primePathsForNodes()), and a value once read is answered from memory for the rest of the request. 50 articles with their image, owner and address cost 11 SQL statements instead of 249. The content classes of the schema come from a cache file per siteaccess (var/<site>/cache/graphql/), renewed when a class is stored, and the name of an item is read from its own row instead of one query per item.
  • Updated: The GraphQL API's caches, rate counters and persisted queries are written with directories 0777 and files 0666, so PHP-FPM and Velocity, which run as different users, can both renew and count them.

2026-10-30: the GraphQL API, phase 4: mutations

Added

  • Added: Mutations of the GraphQL API, on together with the end point ([GraphQLSettings] MutationsEnabled=enabled, the default; disabled per siteaccess for a read-only API): createContent, updateContent (a new version, published or left a draft with publish: false), translateContent, publishDraft, deleteContent (one location or every location; to the trash by default), moveContent, hideContent, revealContent, addLocation, uploadImage, uploadFromUrl and createPreviewToken, each answering a ContentPayload (status, contentId, nodeId, version, content). Each checks the policies of the user on the content it changes (NOT_FOUND for content the user may not read, FORBIDDEN for a change it may not make), runs the kernel's operations so their workflows run (PENDING for a publish an approval holds, CANCELLED for one a workflow cancels), and never writes a class the schema does not show, a hidden field or datatype, or a top-level location. Guide: Mutations.
  • Added: Who may send a mutation: POST only; signed in (AnonymousMutations=enabled lets anonymous requests through to the Anonymous role's policies); a personal API key with the new scope graphql:write (rest.ini Scopes[graphql:write]=content/edit); the session end point with the form token in X-CSRF-Token.
  • Added: The audit names content.graphql.mutation (the mutation, how it was signed in, the request id, the key, the field identifiers, never their values; the kernel's own events of the change are its children) and content.graphql.mutation.failed (refusals and errors, with the reason), both on by default.
  • Added: Files in mutations by the GraphQL multipart request specification (the scalar Upload, also inside a FieldInput): MaxUploadSize, the checks of UploadCheckList[] (expGraphQLUploadCheck, a virus scanner's hook; a missing check refuses every file), and FileDatatypeList[]; a file field never takes a path as its value. uploadFromUrl fetches over https from the hosts of UploadFromUrlHostList[] only (empty by default: off).
  • Added: FieldInput with format: TEXT and format: HTML for rich text, checked against the class before anything is written (unknown, hidden or doubled fields, required fields of new content).
  • Added: Drafts and preview tokens: content(id:, version:) for a draft of one's own or with content/versionread; createPreviewToken and the query preview(token:), a token signed with the installation's secret key, valid PreviewTokenTtl seconds (at most MaxPreviewTokenTtl), readable without signing in.

Updated

  • Updated: A mutation that changed something expires the GraphQL response cache, and the answer of a mutation is never kept. The error codes FORBIDDEN and NOT_FOUND, reserved until now, are sent by mutations and preview tokens.
  • Updated: The GraphQL API no longer reads an object in the trash as content (content(id:) and relations).
  • Updated: The schema version of the GraphQL API is 3; the schema hash changes when mutations are switched on.

2026-10-30: the GraphQL API, phase 5: switching it on in one command, keys, Setup > GraphQL

Added

  • Added: exp:graphql:enable: the GraphQL API switched on in one command. It checks that the schema builds, sets Enabled=enabled and SiteAccessList[] (--siteaccess) in settings/override/graphql.ini.append.php (the mutations come on with it; --mutations switches them on again where they were switched off and gives a key graphql:write), lets a front end's origin call it from a browser (--origin, rest.ini [CorsSettings]), makes a personal API key for a user (--key-for, --key-name, --scope, --days; the token is shown once), and prints the end point and a curl line. A dry run (the diff of every file) unless --apply; the files are written by the kernel's INI editor (a backup first, the audit's system.setting.write), and the INI cache is cleared. Guide: Quick start.
  • Added: exp:graphql:disable: the API, or only its mutations (--mutations), switched off everywhere or in the siteaccesses of --siteaccess (taken out of SiteAccessList[], since settings/override wins over a siteaccess's file); a dry run unless --apply.
  • Added: exp:graphql:keys: the personal API keys with a GraphQL scope, listed (--format=json, schema exp-graphql-keys/1), made for a user (--create --for=, checked against the user's policies and the Scope limitation of apikey/create; recorded as access.apikey.create) or revoked (--revoke=, access.apikey.revoke); a dry run unless --apply.
  • Added: Setup > GraphQL shows whether the API and its mutations answer in every siteaccess (with switches), the limits, the response cache (with its answers, bytes and last expiry, and a button to clear it), the persisted queries (with removal of a stored one) and the API keys with a GraphQL scope. The switches need the new policy graphql/administrate, which also shows every user's keys. admin4 components and tokens only, light and dark.

Updated

  • Updated: exp:graphql:status tells whether the mutations are on; its status comes from expGraphQLSetup::status(), shared with Setup > GraphQL.

2026-10-30: the GraphQL API, phase 6: starter kits, recipes, a walkthrough

Added

  • Added: exp:graphql:starter: a starter front end generated into a new directory (--kit=html|next|nuxt|astro|expo, --to=, --endpoint=, --list) from templates shipped in share/graphql/starters/ and the live schema: the kit's files with the end point filled in, and the queries Menu, Node (a page by its URL alias) and one per content class with every field of the class (images in the alias large, rich text as HTML, relations by name and URL alias), as a module of the kit and as .graphql files for exp:graphql:persist. Every generated query is checked against the schema and its limits before anything is written; nothing is installed or deployed. A dry run unless --apply.
  • Added: The kits: plain HTML and JavaScript (no build step), Next.js (App Router, server components; the key stays on the server), Nuxt 3 (its own server route adds the key), Astro (a static build that walks the content tree) and React Native with Expo.
  • Added: The guides A front end on the GraphQL API, step by step and GraphQL recipes (a blog, a mobile app, a static build, an AI agent; the queries run against the demo content), in English and German, with the generated page of the command.

2026-10-30: content jobs wait out a busy database

Updated

  • Updated: The content job worker retries a batch that meets a busy or locked database (SQLite busy or locked, the transaction start that waited SQLiteTransactionWait in vain; MySQL lock wait timeout and deadlock): the batch is rolled back, the job read again from its last checkpoint, the type's prepare() run again, and the batch tried again after a pause of 1, 2, 4 ... seconds while the heartbeat is written. New settings content.ini [ContentJobSettings] BusyRetries=8 and BusyBackoffMax=60. Before, the start of the batch's transaction was outside the error handling: on a busy SQLite database the worker process ended and left the job running without a worker, which is how the help manual builds on the demonstration servers stopped at 77% and 5%.
  • Updated: A content job whose busy retries ran out fails with a temporary error, and the cronjob part contentjobs resumes it by itself, at most MaxAttempts times in a row (counted again from 0 after every batch done). Any other error of a worker fails the job with "the worker stopped: <reason>" instead of ending the process unannounced.
  • Updated: The help manuals' content job builds its manual sync again after a rolled-back try (expHelpManualSync::abandon()), so a retried batch makes nothing twice.
  • Updated: bin/php/expcontentjob.php list shows the age of each job's heartbeat and "stalled" for a job that waits without a worker; list --resume resumes every stalled or failed job in the background.

2026-10-30: the GraphQL API's mutations come on together with the end point

Updated

  • Updated: settings/graphql.ini [GraphQLSettings] MutationsEnabled ships enabled: the mutations come on together with the end point, and every fallback in the code agrees. A read-only API switches them off per siteaccess (./console exp:graphql:disable --siteaccess=<name> --mutations --apply, the button Mutations off of Setup > GraphQL, or MutationsEnabled=disabled in the siteaccess's graphql.ini.append.php); exp:graphql:enable --mutations switches them on again and gives a key graphql:write. Every guard stays: POST only, a signed-in user (AnonymousMutations=disabled), a personal API key with graphql:write or the session's form token, the user's policies on the content changed, the workflows and the audit. The GraphQL pages, the walkthrough and the generated pages of the setting and the commands say so, in English and German.
  • Updated: Setup > GraphQL names the siteaccess whose settings it shows: the facts of the end point say "For the siteaccess admin, which shows this page", and where the API is off there but answers in another siteaccess the notice says so and points to the table, instead of reading as if the API were off everywhere. The Mutations fact shows on only where the API answers too. Two new texts in all 155 catalogues.
  • Updated: The Remove button of a persisted query on Setup > GraphQL no longer has an empty bordered box beside it: the in-place confirmation (.a4-confirm) in a row of actions (.a4-actions) is as wide as its summary until it opens, as it already was in a toolbar.
  • Updated: The Spanish texts of Setup > GraphQL (esl-ES, esl-MX) address the user with tú, as their catalogues' guides require.

2026-10-30: the SQL query cache no longer misses tables of a statement

Fixed

  • eZDBQueryCache::readTables() stored a result for too few tables, so a write to one it missed left the result stale until MaxAge: a derived table in FROM (FROM ( SELECT ... FROM a ) x, b JOIN c was read as c only), an index hint or PARTITION (p) before a comma list, LATERAL and ONLY, a parenthesised join, a comma list after a join (ON ..., USING (i), an alias), STRAIGHT_JOIN, a quoted name with a space, the second SELECT of Oracle's MINUS and of INTERSECT and EXCEPT, any comment, Oracle's q'' and PostgreSQL's E'' literals, MySQL's double-quoted strings, and a backslash at the end of a literal on Oracle, PostgreSQL and SQLite, where it does not escape ('C:\'). Such statements are now read correctly or not cached, and a regular expression that fails (PCRE limits) makes the statement not cacheable instead of reading only part of it. writtenTables() uses the same literal and comment rules (unreadable: everything stale). New eZDBQueryCache::backslashEscapes( $db ) tells from the driver's databaseName() how its literals end; readTables() and writtenTables() take it as an optional second argument. Oracle's SAMPLE (n) is volatile.

Tests

  • eZDBQueryCacheTest QC-38.

2026-10-30: the SQL query cache's state file and key, robust

Fixed

  • A state file (var/<site>/cache/querycache/state.ser) that was deleted, with an rm -r of the cache directory, or could not be read made every result still in APCu or in the result files current again, although the writes the file knew were lost. The state now carries an id, set when a file is written where there was none; results are answered only under the id they were stored under. The first write of a new installation therefore makes the results stored before it stale, once; a miss now reads the state.
  • A write that could not open or take the state lock went on without it, so another process's change could be lost. It now makes everything stale, which narrows that window without closing it, and logs it once per request; the lock must work (writable state.lock, NFSv4 or lockd on NFS). A failed chmod() of the new state file no longer counts as a failed write (the check was always true), and a temporary file that could not be renamed is removed.
  • The key of a statement carries the database user, port and socket: two sites on one server with one APCu and the same database name (two Oracle schemas on one service) answered each other's rows. Results stored before the update are not answered again (new keys).
  • The request's memo keeps at most eZDBQueryCache::MEMO_MAX (1,000) results, the least recently used going first; a long script (an import, a cronjob) kept every result it read, and every write walked all of them.

Tests

  • eZDBQueryCacheTest QC-39 to QC-42; QC-04 and QC-22 start from an existing state file.

2026-10-30: the SQL query cache on PostgreSQL and SQLite, in a persistent worker, and its lock failures

Builds on pull requests #343 and #344 (contributed by fwoldt).

Updated

  • Updated: eZDBQueryCache::readTables() and writtenTables() read the statements of PostgreSQL and SQLite explicitly: a backslash does not escape there, so 'C:\' is a whole literal and the statement stays cacheable; "..." quotes a name, which is kept ("public"."ezuser" is ezuser), and # is no comment (PostgreSQL: an operator). Not cached any more on any database: a quote left unpaired (a literal that does not end) and a quoted name that holds a quote ("it's", `it's`, SQLite's [it's]), whose apostrophe paired with the next literal's quote and hid the tables in between; PostgreSQL's dollar quoting ($$...$$, $tag$...$tag$, not $1). A PostgreSQL connection with standard_conforming_strings off escapes with a backslash as MySQL does: new eZPostgreSQLDB::standardConformingStrings() reads the setting from the connection (no statement), and eZDBQueryCache::backslashEscapes() asks it.
  • Updated: Writes on PostgreSQL and SQLite: UPDATE ONLY a, DELETE FROM ONLY a and ALTER TABLE ONLY a marked a table called only and left a current; they mark a now. TRUNCATE a, b and DROP TABLE a, b mark every table of the list (only the first before), ALTER TABLE a RENAME TO b both names; TRUNCATE ... CASCADE and DROP ... CASCADE, which reach tables the statement does not name, make everything stale. A name in a write's table list that cannot be read makes the whole write unreadable (everything stale): before, it was left out and its table never became stale.
  • Updated: Not cached either, as not a function of their tables: PostgreSQL's clock_timestamp(), statement_timestamp(), transaction_timestamp(), timeofday(), txid_current(), setval(), gen_random_uuid(), current_setting(), every pg_*() function, FOR NO KEY UPDATE and FOR KEY SHARE; SQLite's changes(), total_changes(), last_insert_rowid(), randomblob() and its date functions without an argument; the literal 'now' (and 'today', 'tomorrow', 'yesterday', 'localtime').
  • Updated: The state lock is taken on the file at the path: a process that waited for state.lock while the cache directory was deleted got the lock on the deleted file while the next process locked a new one, and both wrote the state. The file held is now compared with the one at the path (inode and device) and opened again when they differ.
  • Updated: A write that cannot take the state lock is shown to the admin, not only logged: the time of the last failure is kept in the state file (lockFailed), so Setup > System information shows it in red for any process's failure (admin4 and admin, .a4-notice.is-bad, no inline CSS), with the lock file to check; the debug output's query cache report has a row State lock, and the Exp Debug bar's QC chip turns red. Clearing the query cache forgets it. One new text in all 155 catalogues.
  • Updated: The persistent worker (Velocity) was checked: the per-request reset drops the worker's copy of the state, the memo, the settings and the "lock failed" note, so every request reads the state file again and another process's write or a replaced state file counts from the next request on. The SQL query cache pages (English and German, BC note and help page) say so, and the German pages carry everything #343 and #344 added.

Tests

  • eZDBQueryCacheTest QC-43 to QC-47 (QC-45 forks a second process and needs pcntl); expDebugBarQueryCacheTest DQ-01 checks the red chip.

2026-10-30: "Was this you?", Secure your account and suspicious activity

  • Added: The security activity (user/security) asks Was this you? about every event of the last 30 days that needs attention on the own account: Yes, it was me remembers the answer (user preference expsecurity_confirmed, recorded as access.user.activity.confirm), No, secure my account opens the new card Secure your account.
  • Added: Secure your account on the security activity: after "Verify it's you" ([ReauthGroup_account] Rules[]=user/security self SecureButton) every other session of the account is signed out, also those kept by Remember me, and the page leads on to Change password. The link Secure your account of the New sign-in mail and the security alerts is user/security/(secure)/1, which opens the page with that card first. In admin, admin4 (and so admin4l and editor) and the standard design of the public sites.
  • Added: Suspicious activity in the last 24 hours on the sign-in report (user/sessionreport): one network failing on three or more accounts, accounts locked, accounts with FailedSignInAlertThreshold failed sign-ins, and accounts signed in from several countries at once, each linking to its security activity and sessions.
  • Updated: The texts of these pages in all 155 catalogues; the guides and feature pages in English and German.

2026-10-30: the Editor role gets the interface choices and the media site's classes, without duplicates

Updated

  • Updated: The setup step, bin/php/addeditorpolicies.php and the upgrade step 6.0.15-editor-policies also add uiswitch/language and uiswitch/design to the Editor role where they are missing (each while its provider is in uiswitch.ini ProviderList[]).
  • Updated: The class sets of the new settings/editorpolicies.ini: for the classes of a set that the Editor role cannot create, one content/create limited by Class and ParentClass and one websitetoolbar/use limited by Class are added. The shipped set media_site covers the media site's ng_* classes below a folder, the front page and the set's own containers; an installation without them is not touched. --skip-class-set=<name> leaves a set out.
  • Updated: A policy the Editor role holds twice with the same limitations (such as ezoe/*) is removed once, the oldest kept ([EditorPolicySettings] RemoveDuplicates, --keep-duplicates). Upgrade note: The everyday policies of the Editor role.

2026-10-30: the GraphQL API, phase 7: real time, signed webhooks and subscriptions

Added

  • Added: The GraphQL event journal (var/<site>/graphql/events/, graphql.ini [GraphQLEvents]): the kernel notes publish, update, trash, delete, move, hide, reveal and successful GraphQL mutations wherever content changes, and writes them at the end of the request, after its transaction, while the API is on and somebody listens.
  • Added: Signed outgoing webhooks: end points in graphql.ini [GraphQLWebhooks] EndpointList[] and [GraphQLWebhook_<name>] (events, siteaccesses, classes, subtrees, Payload=event or the answer of a persisted query), POSTed by the new cronjob part graphqlwebhooks (in the frequent group) with X-Exponential-Signature: sha256=<HMAC-SHA-256(secret, timestamp + "." + body)> and X-Exponential-Timestamp against replays, retried with a doubling back-off, given up after MaxAttempts, every attempt in a delivery log. The signing secrets live in the secret store, never in a settings file or a log.
  • Added: The console command exp:graphql:webhooks (list, --add, --remove, --test, --deliver, --log; a dry run unless --apply) and a Webhooks section in Setup > GraphQL (add, test, remove, the recent deliveries).
  • Added: GraphQL subscriptions (Subscription.contentEvents, contentPublished, contentUpdated, contentDeleted, contentMoved, contentHidden, the types ContentEvent and ContentEventKind) at /api/graphql/subscribe: an event stream in the protocol of graphql-sse with PHP-FPM and Velocity, and a WebSocket with graphql-transport-ws on Velocity (velocity.ini [ServerSettings] GraphQLWebSocketPaths[], Velocity after v0.0.4.58). Every event is checked against the subscriber's rights when it happens; a stream holds no database connection while it waits; limits per installation, user and address, a heartbeat, a maximum length and an idle timeout (Subscriptions, AnonymousSubscriptions, MaxSubscriptions*, Subscription*), and the error code SUBSCRIPTION_LIMIT.
  • Added: The documentation of the real-time API, its security notes and a recipe (a webhook for a shop and a live list in the browser), in English and German; the generated pages of the command and the new settings.

Updated

  • Updated: /api/graphql refuses a subscription and names /api/graphql/subscribe; the schema hash changes with the subscriptions; ContentEvent and ContentEventKind are reserved type names. The REST response writer sends nothing more for a response that was streamed.

2026-10-30: the Editor role gets ezie/* where ezie is active, and only active extensions count in the setup step

Updated

  • Updated: expEditorPolicies knows ezie's module: where a site keeps ezie active, the Editor role gets ezie/* (ezie's module.php has no policy functions, so every view asks for it; each view still checks the draft), and bin/php/addeditorpolicies.php --apply --only=ezie/* adds it on an installation that exists. Where ezie is not active it is skipped ("no module ezie on this installation").
  • Updated: The setup step (expEditorPolicies::ensure(), new setupModules()) counts an extension's module only when the new installation activates the extension, not when it is merely in extension/: ezie is installed with every installation, so ezie/* is not granted where expimageeditor takes its place.
  • Updated: The Editor role note explains why ezie/... answers "module not found" (kernel error 20) for every user on a site that activates expimageeditor in place of ezie: an inactive extension's module.ini is not read, so the module is not on the module path. No page of such a site asks for ezie (Editor role).

2026-10-31: Your devices and Security activity follow a design's own light and dark switch

Updated

  • Updated: design/standard/stylesheets/user_sessions.css (Your devices, Security activity and the link to them on the profile) follows the mode a site design marks on <html> -- data-theme, data-bs-theme (Bootstrap 5.3) or data-exp-theme (the media design) -- and the system's prefers-color-scheme only where there is no such mark. A site with its own switch set to light on a dark device showed these pages dark. Documented as the integration point for theme developers (Dark mode and design tokens in themes, German too) and in the problems of Your devices.

2026-10-31: a stored GraphQL query with variables is checked for its worst call

Updated

  • Updated: exp:graphql:persist and exp:graphql:starter check a query's complexity with the values of its variables that make it highest (expGraphQLServer::worstCaseVariables(): an Int at MaxListLimit, an @include that runs, an @skip that does not skip), as no request gives them values yet. Every query with a required variable was refused with "Variable "$path" of required type "String!" was not provided": exp:graphql:starter wrote no kit (its Node and Content_* queries), and step 8 of the walkthrough could not store a kit's graphql/. A query that one call could take past MaxComplexity is still refused, and a request still checks the values it sends.
  • Updated: /api/graphql/subscribe computes a subscription's complexity with the variables of the request.

2026-10-31: the GraphQL library is optional again, so the platforms install

Removed

  • Removed: webonyx/graphql-php from the require of the kernel's composer.json (added on 2026-10-29). Installations that pull the kernel's dev-main through a platform could not be resolved any more: Exponential Platform Legacy 3.x (ibexa/graphql-php 0.13, which replaces webonyx/graphql-php, on PHP 8.0 and later) and Exponential Platform Legacy 4.6.x on PHP 7.4 (se7enxweb/graphql-php 14.11 through exponential-platform-dxp-php-floor). The library is now listed under suggest and require-dev (^15.32.3, the releases no security advisory affects): the kernel's own Composer set-up keeps installing it, a platform keeps its own.

Updated

  • Updated: expGraphQL::loadLibrary() accepts only webonyx/graphql-php 15 (it checks for GraphQL\Validator\QueryValidationContext): with a platform's 0.13 or 14 loaded, the API reports the library as incompatible instead of running on it. New: expGraphQL::libraryState() (available, missing, incompatible), libraryProblem() and foundLibrary(); exp:graphql:status and its JSON (libraryState, libraryProblem, added fields) say why the API cannot run (FAIL GraphQL API unavailable: ..., exit code 3), /api/graphql answers 503.
  • Updated: The boot smoke test (tests/bin/bootsmoke.php, run by CI with PHP 7.4 and 8.0 after a --no-dev install, so without the library) checks that the GraphQL status degrades; the new test expGraphQLLibraryMissingTest checks the missing and the incompatible library, the status and the end point's answer.
  • Updated: The GraphQL API (German: Die GraphQL-API), the requirements (Requirements, German: Voraussetzungen) and the GraphQL walkthrough say which installations have the library and why the kernel does not require it.
  • Updated: Twig templates: versions, upgrades and compatibility (German: Versionen und Kompatibilität) names exponential-php-floor 1.2.5 and 1.3.5, which accept twig/twig ^3.27 || ~3.19.1, so a platform that keeps se7enxweb/twig 3.19.1 on PHP 8.1 and later (Exponential Platform Legacy 3.x, 4.6.x) installs the kernel's current code. The kernel's own constraint on the floor (~1.0.3 || ~1.1.4 || ~1.2.3 || ~1.3.3) is unchanged.

2026-10-31: the move from ezwt to expwt grants each expwt policy once and leaves one expwt active

Updated

  • Updated: exp:wt:migrate-from-ezwt and the upgrade step 6.0.15-expwt-enable plan one expwt policy per function for a role with several websitetoolbar/use policies: identical limitations give one grant, limitations that differ in one limitation's values only are joined (two Class lists become one), and sets that cannot be joined without granting more stay one grant each. A policy the role holds already, the same or broader, is not granted again, so a second run plans nothing. Each line names the limitation values ("limited to Class 1, 2, 5") and the source policies, in place of the limitation's name and the source function (the website sidebar).
  • Updated: Where a settings file activates expwt already, before or after ezwt, the ezwt line is removed instead of replaced ("remove ezwt (expwt already active)"), and an ezwt listed twice gives one expwt; the file is still kept as <file>.before-expwt.

2026-10-31: migrations in the kernel

A site's changes to its database, content, classes, roles and settings can be written once as migrations, files under version control that every installation of the site applies in the same order, with a record of what ran where, a caption before every run, an automatic restore after a failure and a commit, tag and push of every run's record. Guide: Migrations (German: Migrationen), starting with the tutorial; the feature: Migrations in the kernel.

Upgrading: ./console exp:upgrade --apply runs the new step 6.0.15-migration-tables, which creates exp_migration and exp_migration_run (after it, 6.0.15-upgrade-record-table retires the pre-release table expupgrade_step); from then on exp:upgrade records its steps in exp_migration. Commits, tags and pushes of migration runs need git_manager; without it a run commits nothing and says so.

The parts of migrations in the kernel, each with a section of its own: the engine, the record, captions, commands and the administration, YAML migrations, content and locations, the upgrade steps of exp:upgrade, one record table, exp:migration:check, classes, users, roles, sections, languages, object states, tags, URL aliases, the trash, files, images, caches, search index, autoloads, layouts, HTTP, mail, console commands, SQL and INI migrations, GraphQL and the APIs, the engine matrix and performance.

Added

  • Added: Migration files YYYYMMDDHHMMSS_name.<type> in the site's migrations/, in extension/<name>/migrations/ of active extensions and in installed packages, with a header of key: value comment lines (description, requires, environments, engines, maintenance, author), ordered by id and requires, with a SHA-256 checksum (expMigrationDefinition, expMigrationLocator, expMigrationGraph; the file format).

  • Added: PHP migrations: a namespaced class that extends expMigrationPhp with plan(), up() and optionally down() and detect() (PHP migrations).

  • Added: The record: tables exp_migration (one row per migration; also the record of exp:upgrade, which replaces expupgrade_step) and exp_migration_run, mirrored into migrations/status/<environment>.json and migrations/runs/<run-id>.log (expMigrationRecord, expMigrationStatusMirror).

  • Added: The runner: one installation lock with exp:upgrade (expMigrationLock; expUpgradeLock is now this lock), a transaction per migration where the engine has one, the run's user ([RunSettings] RunAsUser), maintenance mode per migration, a guard against changed files (--force --reason), the events migration/run/before|after and migration/step/before|after, and the audit events system.migration.run, .apply, .fail, .rollback, .caption, .push.

  • Added: Captions of migration runs (kinds migration-pre, migration-post, migration-failure of exp:upgrade's captions), reused while nothing has changed, and the restore of the pre caption after a failure; a run, one migration (down()) or any caption taken back by hand (Rollback and captions).

  • Added: The migrations repository: the record of every run committed, tagged migrations/<environment>/<run-id> and pushed; failed pushes retried by the new cronjob part migrationpush (The migrations repository).

  • Added: The commands exp:migration:status, :migrate, :dry-run (with --simulate --diff on PostgreSQL and SQLite), :rollback, :resume, :caption, :push, :check and :generate --skeleton, with the machine output exp-migration/1 and the exit codes of exp:upgrade (the commands).

  • Added: settings/migration.ini with [MigrationSettings], [RunSettings], [CaptionSettings] and [RepositorySettings] (the settings).

  • Added: The documentation in English and German: the guide, a tutorial, PHP migrations, rollback and captions, the migrations repository, troubleshooting, the feature page and the file format.

  • Added: Setup > Migrations, the module migration: the status of every migration, the history of the runs (filtered by state), the page of a run with its record, its migrations and the end of its run log, the dry run with a simulated run, the run form (the migrations chosen one by one, the switches of the caption flow, changed files accepted with a reason), resume, and the rollback of a run, a migration or (expert) an earlier caption. Policies migration/read, run, rollback and edit (Administrator only in a new installation); a run, a resume, a simulation and a rollback ask "Verify it's you" (site.ini [ReauthGroup_system]). Every string is in the 155 administration catalogues (Migrations in the administration).

  • Added: Runs started in the administration run in the background: a console process of its own as the signed-in user (expMigrationJob, files in var/<site>/migration/jobs/), followed live by the run's page through migration/stream, and going on when the page is closed. exp:migration:migrate and :resume take --run-id and --progress, :rollback takes --progress; migration.ini [AdminSettings] has PhpCliPath, JobHistory and LogBytes (migration.ini [AdminSettings]).

  • Added: The dashboard block migrations (pending, failed, orphans, the run going on, the last run) and the fetch function migration/summary; the website sidebar (expwt) shows the same read-only in its Migrations panel.

  • Added: The help pages of Setup > Migrations in English and German: the status and the history, run, dry run and rollback, and the website sidebar's panel, opened by the ? button of each view; the generated pages of the module's views, its policies, its fetch function and the new settings.

  • Added: Recording changes as migrations: while a recording runs, what a user changes (a session recording) or what every user changes (a site-wide window) is kept at the kernel's operation level, through the audit's hook points, as the steps of a YAML migration with remote ids and identifiers instead of numeric ids. Users' accounts and personal fields are never recorded. Tables exp_migration_recording and exp_migration_recording_event (upgrade step 6.0.15-migration-recording-tables, every engine), the commands exp:migration:record:start, :stop, :status, :list, :show and :discard, exp:migration:generate --from-recording (it lists the steps and waits for the YAML step types), Setup > Migrations > Recording with a Recording bar on every page of the administration (policy migration/edit; the site-wide window asks "Verify it's you"), migration.ini [RecordingSettings] and the audit event system.migration.record (Generators and recording changes, Recording changes in the administration).

    Upgrading: ./console exp:upgrade --apply runs the new step 6.0.15-migration-recording-tables.

Updated

2026-10-31: fixes from the Opencontent and Mugo Web forks, and the items their review left open

Merges pull request #345, reported and contributed by Ekkehard Dörre (CJW): the fixes that Opencontent (Opencontent/ezpublish-legacy) and Mugo Web (mugoweb/ezpublish-legacy), the two active forks of the kernel Exponential grew from, made and Exponential still lacked, each written anew for this tree. With thanks to Ekke, to Opencontent and to Mugo Web. Feature page: Fixes from the Opencontent and Mugo Web forks (Deutsch); upgrade notes: What to check (Deutsch).

Added

  • Added: site.ini [URLTranslator] MaxPathDepth (default 30): eZURLAliasML::translate() and fetchByPath() look up at most that many path elements, each one a self-join of ezurlalias_ml; a deeper URL is not found without a query. A crawler requesting endless paths kept the database busy (Opencontent). New eZURLAliasML::maxPathDepth().
  • Added: eZLog::oneLine(), which replaces every line break of a text with a space, for code that writes log lines.
  • Added: eZURLAliasML::stripURLOrigin(), the path of an absolute URL.
  • Added: database tests of eZContentOperationCollection::publishNode() for a new object, a new version, a new location and a copied node assignment (eZContentOperationCollectionPublishNodeTest).

Updated

  • Updated: An eZLog::write() entry stays on one line: a line break in a logged value (a user name, a URL) could forge further entries (Opencontent). eZLog::writeStorageLog() writes storage.log the same way.
  • Updated: The REST kernel registers the listeners of site.ini [Event] Listeners[], as the web kernel does; none of them ran for REST requests (Opencontent).
  • Updated: Iterating the fields of a content object in a language it has no translation in gives no fields instead of a warning and a TypeError (ezpContentFieldSet, Opencontent).
  • Updated: eZPersistentObject reads a limit with an offset and no length without an undefined array key (Mugo Web).
  • Updated: clusterize.php skips binary file, media and image rows without a file name instead of storing a directory (Mugo Web).
  • Updated: index_cluster.php without a cluster configuration answers with its error page instead of an Error about the undefined CLUSTER_ENABLE_DEBUG (Mugo Web).
  • Updated: The Online Editor's browse answers an empty folder with a count of 0 instead of a TypeError from count( false ) (Mugo Web).
  • Updated: texttoimage takes a null or numeric input as a string (Mugo Web).
  • Updated: eZINI::parameterSet() without a root directory looks in settings/; it set the file name to settings instead (Mugo Web).
  • Updated: eZSession does not try to rename a session that is already active (Mugo Web).
  • Updated: The PDF of an embedded object the reader may not view no longer shows the object's name (Mugo Web).
  • Updated: The benchmark CLI runner loads again; display() was declared incompatibly with its parent (Mugo Web).
  • Updated: publishNode() takes the node found by a node assignment's remote ID only when it is the object's node below the assignment's parent. The code read if ( !$existingNode ); with an empty statement, so findNode() always decided; removing only the semicolon, as Mugo Web did, would have let a copied object's first publication take over the original's node, because cloneNodeAssignment() keeps the remote ID. The same empty if in the search engine's handling of www. addresses is fixed, and a test finds any if with an empty body in kernel/ and lib/.
  • Updated: eZURLAliasML::storePath() stores an absolute URL given as an alias (https://www.example.com/news) as its path (news) instead of nested placeholder aliases; Opencontent refuses it instead, which the alias form would answer without a message.
  • Updated: ezjscore.ini [ezjscServer_ezjscnode] HardLimit is 500 instead of false, so ezjscnode::subtree and expsubitems::rows return at most 500 items per request, the largest page the sub-items list offers (Mugo Web ships 100). ezjscore's changelog lists it under 1.8.8 (unreleased).
  • Updated: file.ini [FileSettings] FileExtensionBlackList[] also refuses php4, php5, php7, php8, phps, shtml, cgi, pl, asp, aspx, jsp and htaccess (Mugo Web's list is the shorter one Exponential shipped).
  • Updated: The features index, Security hardening and the security engineering overview for software engineers name the changes, in English and German.

Removed

  • Removed: moxieplayer.swf, the Flash video player of the TinyMCE 3 media plugin bundled with ezoe; the plugin's flash_video_player_url defaults to false, so it adds no Flash fallback (Mugo Web removed Flash files for the same reason).

Not adopted, and proposals

  • Not adopted: skipping a re-parenting UPDATE of ezurlalias_ml that changes no row, and a trim() of the CSS packer's output; neither fixes a defect.
  • Proposals, not in 6.0.15: INI values from environment variables, i18n.ini OverrideDefault for eng-GB, a Greek flag ell-GR.gif and the locales moc-IT and lad-IT (Opencontent); see the feature page.

Tests

  • expLegacyForkFixesTest (DB-less, 17 tests): each fix and follow-up fails without its change. eZContentOperationCollectionPublishNodeTest (database).

2026-10-31: four problems a downstream installation reported (Redmine #16709)

Reported by Ekke (CJW) after merging the kernel's main branch into a downstream fork, on PHP 8.5 and macOS. Thank you.

Updated

  • Updated: A zstd audit archive is read to its end with every version of the PHP extension zstd. With zstd 0.18.0 a compress.zstd:// stream never reported its end, so reading an archive back (the read-back after archiving, verifyArchives(), restore()) ran at 100 % CPU until it was killed, and expAuditArchiveTest::testEachFormat hung. expAuditZstdFormat::open() no longer reads through the stream wrapper: it decompresses the archive in chunks (zstd_uncompress_init()/zstd_uncompress_add(), else zstd_uncompress()) into a temporary stream. The zstd binary is used as before where the extension is missing. expAuditFormatBase::digestStream() also stops after 1,000 reads in a row that return nothing, so a stream that never ends makes the archive fail verification instead of hanging. The test file guards its long tests with a time limit (pcntl) and checks both cases.
  • Updated: expPackageArchiveTest::testPaxArchiveDoesNotHang runs its child process with -d display_errors=stderr: on PHP 8.4 and later zetacomponents/archive 1.5.1 prints deprecations, which landed on stdout in front of the child's answer, so the test failed where the PHP configuration displays errors. The child's stderr is read while it runs, so many notices can never block it. expLogActionsTest::testDownload gets the same option, since a notice on stdout would break the gzip data it reads.
  • Updated: expPackageArchiveLegacyFormatsTest::testPackageWithUpperCaseNameIsImportedListedAndRemoved no longer assumes a case-sensitive file system. It creates a probe file in the package repository and looks for it under its lower-case name: on a case-sensitive file system (Linux) the lower-case package name must still not be found, on a case-insensitive one (macOS, Windows) the test checks that both spellings reach the same directory instead of failing.
  • Updated: The first administration page without the interface language caches no longer runs into max_execution_time. The language choice in the toolbar, on every page, showed each language's translation coverage and built the masks of all 156 translations for it at once, about 30 seconds. Its catalogue now has a time budget of 2 seconds per request (expUISwitchLanguageCatalogue::setTimeBudget(), expUISwitchLanguageProvider::COVERAGE_TIME_BUDGET): what is not built in that time follows in the next requests, those languages are listed without a percentage meanwhile (pending in their entries), and the complete catalogue is cached as before once every mask is there. Measured on a copy of an installation with 156 translations: the sign-in after the caches were cleared took 30.1 seconds before (the limit), 11 to 13 seconds after (the other cold caches), each following page about 2.5 seconds until the masks were complete after about ten pages, then under one second. Setup > Interface languages and ./console exp:i18n fallbacks still build everything at once (The interface language list, German: Die Liste der Oberflächensprachen).

2026-11-01: YAML migrations

A migration can be written as YAML: a list of steps, each of a step type, with references between them, conditions, loops and the id map of the environment, checked against each step type's schema before anything runs. Guide: Migrations (German: Migrationen); the rules: the file format.

Upgrading: YAML migrations need symfony/yaml (^5.4 || ^6.4 || ^7.0), which composer.json now requires; run composer update symfony/yaml (or keep a copy elsewhere and name it in migration.ini [YamlSettings] LibraryPath). Without it a YAML migration is refused with that hint, and nothing else changes.

Added

  • Added: YAML migrations (YYYYMMDDHHMMSS_name.yml) run, check and roll back like PHP migrations, and the two kinds require each other and share references in a run (expMigrationYaml, read by expMigrationYamlParser through symfony/yaml: no PHP tags, duplicate keys refused). The document is the list of steps, or a map with steps, down (the steps that undo it) and transactional.
  • Added: The step model: type, mode, if, environments, engines, references and the step type's own keys, checked against its schema (expMigrationStepSchema); loops foreach (as, key, ${loop.index}) and while with a maximum ([StepSettings] MaxLoopIterations); conditions all, any, not, ref/set, environment, engine and value with equals, not_equals, in, not_in, gt, gte, lt, lte, matches, empty (expMigrationCondition); placeholders ${ref.*}, ${loop.*}, ${idmap.*.*}, ${run.*} (expMigrationReferences).
  • Added: The id map of an environment, migrations/idmap/<environment>.json (schema exp-migration-idmap/1, expMigrationIdMap), and identifiers of nodes and objects by id, remote:, object-remote: and path: (expMigrationIdentifier).
  • Added: The step plugin API: expMigrationStepType (with expMigrationStepTypeBase and expMigrationRevertibleStepType), registered in migration.ini [StepSettings] StepTypeList[<name>]=<class> by the kernel and by extensions (expMigrationStepRegistry); the kernel's step types reference and assert.
  • Added: exp:migration:generate --skeleton <name> --type=yml writes an empty YAML migration; exp:migration:check checks YAML migrations (the YAML, every step, its keys, conditions, loops, references and placeholders) instead of refusing them; the dry run plans loops and conditions as far as they can be told before the run.
  • Added: migration.ini [YamlSettings] LibraryPath and [StepSettings] (the settings).
  • Added: Tests of the YAML engine without a database, and runs of YAML migrations through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationYaml*Test).

Updated

  • Updated: The file format, the migrations guide and the tutorial (a YAML migration, section 12), in English and German.

2026-11-01: YAML migration steps for content and locations

YAML migrations can now create and change content: the step types content, location and content_version, each running the kernel's own API and operations the way the administration does. Guide: YAML migration steps (German: Schritte von YAML-Migrationen); every key of every step type: the step types (German: die Schritttypen).

Added

  • Added: The step type content with the modes create, update, ensure (create when missing by remote_id, else change only what differs) and delete; fields: in one language and translations: with one block per further language, each published as a version of its own; in_place: true changes the published version without a new one; section, always_available, missing: skip, trash.
  • Added: The step type location with the modes create (a further location), update (priority, sorting, main location), delete, move, copy, hide and unhide, through the content operations of the administration.
  • Added: The step type content_version with the modes restore (publish a copy of an earlier version) and cleanup (remove archived versions but keep:, and drafts with drafts: true).
  • Added: Workflows of the operations' triggers run only with workflows: true, notification events only with notify: true; a workflow that cancels or defers an operation fails the step.
  • Added: expMigrationPartlyRevertibleStepType: a step type that can undo only some of its steps says which with revertible(). A content create with a remote_id, a location create, hide and unhide undo themselves; a migration with any other content step needs down:.
  • Added: Identifiers of classes, sections, languages, users, roles and object states (expMigrationIdentifier): by id, by name (identifier, locale, login, role name, <group>/<state>), classes and users also by remote:.
  • Added: Generated reference pages of the step types, examples/migration-steps/ (German beispiele/migrationsschritte/), made from each type's schema by ./console exp:help:examples --area=migration-steps.
  • Added: Tests of the step types without a database, and runs through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationContentSteps*Test).

Updated

  • Updated: On MongoDB, a URL alias entry that was made the original of its path stored no action type (eZURLAliasML::storePath() used it before it was set).
  • Updated: The migrations guide and the tutorial name the new step types.

2026-11-01: static publishing, the build engine

A front siteaccess can be built as a folder of static files that GitHub Pages or any other static host serves: the pages crawled as the anonymous user, cleaned of debug output, form tokens, session ids and nonces, their links rewritten (relative, or below a base path such as /my-site/), the files they need copied, and .nojekyll, 404.html, robots.txt, sitemap.xml and a manifest written. This is the first part of static publishing; pushing builds to git, the administration screens and the GitHub setup follow. Guide: Host static sites on GitHub Pages with Exponential 6.0.15+ (German: Statische Websites auf GitHub Pages hosten); the feature: Static publishing; the format: The static publishing build format.

Upgrading: nothing to do. Static publishing is switched off ([StaticPublishSettings] Enabled=disabled) until you switch it on and name a target.

Added

  • Added: Targets of static publishing in staticcache.ini: [StaticPublishSettings] (Enabled, TargetList[], WorkDir, MaxSiteBytes, MaxFileBytes) and one [Target_<id>] block per target, with the template [Target_example]; read and checked by expStaticTarget (front siteaccesses only, base path, addresses, no secret in Remote, credential names).
  • Added: The build (expStaticBuild): a crawl as the anonymous user, the pages stripped (expStaticPostProcessor) and refused when a session cookie, debug output or a form token remains, links rewritten (expStaticLinkRewriter), the files collected from the installation (var/ through the cluster file handler) or from the site (expStaticAssetCollector), the storage images of content the anonymous user may read, the extras (expStaticExtras), the manifest .static-publish/manifest.json (expStaticManifest), the diff against the last build, a link check (expStaticLinkChecker) and an update of the target's folder that touches only the files a manifest lists. Everything is built in a staging folder first.
  • Added: The commands exp:static:targets, exp:static:build <target> (--dry-run, --paths, --nodes, --full, --report, --crawl-url, --max-pages, --keep-staging), exp:static:diff <target> (--verify, --check-links, --mount) and exp:static:serve <target> (a preview below the base path with PHP's built-in web server, through expStaticServe), with the machine output exp-static/1 and the exit codes of exp:upgrade (the commands).

Updated

  • Updated: expStaticCacheRunner has an export mode: a callable receives every page with its path in the site instead of the static cache handler, and the crawl can be pointed at another address. expPreloadRunner hands the store callback the response's status, type and the names of the cookies it set, takes a request function for tests (fetcher), and its request is public as expPreloadRunner::request().

2026-11-01: the upgrade steps among the migrations

exp:upgrade and migrations are one engine now: every upgrade step (the kernel's and those of extensions) is also a migration of the source kernel. Guide: The steps among the migrations (German: Die Schritte unter den Migrationen).

Added

  • Added: expMigrationUpgradeSource and expMigrationUpgradeStep present each step of expUpgradeRegistry as a migration of the source kernel, with the state exp:upgrade gives it (detect() and the record). exp:migration:status, Setup > Migrations, its dashboard block and the expwt Migrations panel list the steps first, before the site's and the extensions' migrations.
  • Added: exp:migration:migrate --apply (and Run in Setup > Migrations) applies the pending steps of the stages database and scripts before the migrations, in the same run, under the same lock and caption, and records them in exp_migration with their stage and version, where exp:upgrade reads them. A migration may name a step in requires.

Updated

  • Updated: A pending step of the stages code, libraries and finish, or an optional step, is listed by a migration run as deferred (a new action of exp-migration/1) with a warning that names the exp:upgrade command that runs it; a migration that requires it waits, and the run form of the administration leaves it out. After a run that applied steps a warning names ./console exp:upgrade:finish --apply.
  • Updated: The entries of exp-migration/1 have a stage (migration for a migration file); a migration whose state is detected (an upgrade step) has no checksum guard and no transaction around it.
  • Updated: exp:upgrade keeps its commands, stages and options; its record is exp_migration, made by 6.0.15-migration-tables (the pre-release table expupgrade_step is copied over and dropped by 6.0.15-upgrade-record-table, see the next section).
  • Updated: Upgrading with the console, Upgrade steps for extensions, Migrations, Migrations in the kernel and Schema changes and migrations, with their German pages.

2026-11-01: the table expupgrade_step is dropped

The record of exp:upgrade is exp_migration (see the two sections above). The table expupgrade_step, which kept that record in a pre-release of 6.0.15, is no longer created anywhere: no released version had it, so a new installation and an upgrade from 6.0.14 or older never see it. An installation that ran the pre-release still has it, and the step 6.0.15-upgrade-record-table retires it. Guide: The older table expupgrade_step (German: Die ältere Tabelle expupgrade_step).

Upgrading: on an installation with expupgrade_step, run ./console exp:upgrade:database first (the dry run lists each row it copies and the DROP TABLE), then ./console exp:upgrade:database --apply. Nothing is lost: the rows exp_migration does not have are copied, a newer row of exp_migration is kept, and the table is dropped only when every row of it is in exp_migration. A second run does nothing. Oracle: ezoracle carries the same change for its schema and its 6.0.15 update file.

Removed

  • Removed: The table expupgrade_step from share/db_schema.dba, kernel/sql/mysql/kernel_schema.sql, kernel/sql/postgresql/kernel_schema.sql and kernel/sql/sqlite/schema.sql (and so from MongoDB, whose collections come from share/db_schema.dba).
  • Removed: expUpgradeRecord no longer reads or writes expupgrade_step, and expUpgradeRecord::LEGACY_TABLE is gone. Until exp_migration exists (the first steps of a first run) nothing is recorded and a write returns false.
  • Removed: expupgrade_step from migration.ini [CaptionSettings] ReuseIgnoreTableList[] and the defaults of expMigrationCaption.

Updated

  • Updated: 6.0.15-migration-tables is step 0 of the database stage and only makes exp_migration and exp_migration_run.
  • Updated: 6.0.15-upgrade-record-table (now after 6.0.15-migration-tables) is pending only while expupgrade_step exists; it copies the rows exp_migration does not have (expMigrationRecord::copyLegacy()), checks that none is left out, and drops the table (MongoDB: the collection; Oracle: DROP TABLE ... PURGE). It refuses to drop while exp_migration is missing or a row is not copied.
  • Updated: The 6.0.0 to 6.0.15 update files of MySQL, PostgreSQL and SQLite create expupgrade_step empty only so the copy can read it, copy its rows and drop it (DROP TABLE IF EXISTS), so they run on every installation and twice.
  • Updated: Upgrading with the console, Migrations and CaptionSettings, with their German pages.

2026-11-01: static publishing, publishing to git

A static build is published to any git host from the console: exp:static:publish <target> commits only the build's files in the target's own repository (var/<site>/static-publish/<target>/, with Folder=docs the build in its docs/), tags the commit static/<target>/<n> and pushes the branch and the tags, never forced. A push that fails waits and is sent again by exp:static:push or the cronjob part staticpublish; a rollback is a new commit with the files of an earlier tag. The push signs in with a token from the secret store or the environment (through GIT_ASKPASS), an SSH deploy key made per target, or the host's own credentials; a credential is never on a command line, in a remote address or in a log. Guides: Repositories and credentials, History and rollback, SSH deploy keys (German: Repositorys und Zugangsdaten, Verlauf und Zurücknehmen, SSH-Deploy-Keys).

Upgrading: ./console exp:upgrade --apply runs the step 6.0.15-static-publish-tables (tables exp_static_run and exp_static_page, every engine). Publishing works without them, but nothing is recorded.

Added

  • Added: expStaticRepository: the target's folder as a git repository on its branch (an existing remote branch is taken over, its own files kept), commits of the build's files only, annotated tags static/<target>/<n> that are never moved, pushes that are never forced (a guard refuses --force, + refspecs and deletions), the push states pushed, pending, disabled, no-remote, none, and the rollback as a new commit; git through expUpgradeGit::git() (git_manager) where no credential is needed, without a shell everywhere.
  • Added: expStaticCredentials: token:<secret>, token:env:<VARIABLE>, sshkey:<secret> and none, with a hook for app:<installation> (expStaticCredentials::provider()); the askpass helper bin/shell/expstaticaskpass.sh; every git output redacted.
  • Added: expStaticPublisher (publish, rollback, retry), expStaticLock (one build, publish, rollback or push of a target at a time; exp:static:build takes it too) and expStaticStatus (tables exp_static_run and exp_static_page, upgrade step 6.0.15-static-publish-tables, kernel schemas and 6.0.15 update files of MySQL, PostgreSQL and SQLite).
  • Added: The commands exp:static:publish <target> (--build, --message, --no-push), exp:static:rollback <target> <n|tag> (--apply, --message, --no-push), exp:static:push [<target>], exp:static:keys <target> (--generate, --replace) and exp:static:status <target> (--limit); the cronjob part staticpublish.
  • Added: [StaticPublishSettings] Push, CommitName, CommitEmail; the audit events system.staticpublish.publish, system.staticpublish.rollback and system.staticpublish.credential.

Updated

2026-11-01: SQLite tables keyed on more than one column

The hand-written SQLite schema kernel/sql/sqlite/schema.sql keyed ezcontentobject_attribute on id alone, where share/db_schema.dba keys it on id and version. A SQLite installation made from that file could not store a second version of an object: the new rows replaced the old ones. Eleven more tables had the same fault (ezcontentclass, ezcontentclass_attribute, ezworkflow, ezworkflow_event, ezpdf_export, ezrss_export, ezrss_export_item, ezrss_export_opml_item, ezrss_import, ezoperation_memento, ezwaituntildatevalue), and ezenumvalue and ezenumobjectvalue had a column text in place of enumelement and enumvalue. An installation made by the setup wizard, the kickstarter or the installers reads share/db_schema.dba and was never affected.

Upgrading: SQLite only. ./console exp:upgrade:database lists every table whose key differs from the .dba under the step 6.0.15-sqlite-composite-keys; --apply rebuilds each in one transaction (rows, columns and indexes kept). On a database with the right keys the step is done and does nothing.

Updated

  • Updated: kernel/sql/sqlite/schema.sql keys every table as share/db_schema.dba does, and ezpdf_export has show_footer and footer_text.

Added

  • Added: The upgrade step 6.0.15-sqlite-composite-keys (expUpgradeStepSqliteCompositeKeys, SQLite only): a table keyed differently from share/db_schema.dba is rebuilt with the key of the .dba (new table, rows copied, old table dropped, new one renamed, indexes created again, in one transaction). Pending only while a key is wrong.

2026-11-01: exp:migration:check reports a method PHP would refuse

A PHP migration with a helper method named definition() or file(), or with a hook such as plan() declared with another signature, made exp:migration:check (and a run) stop with a PHP fatal error while the file loaded, without naming the migration. The class is now read before the file is loaded, and the method is reported as an error of that migration; the other migrations are checked as usual.

Updated

  • Updated: expMigrationPhp::load() refuses, before loading the file, a class that extends expMigrationPhp and declares a method of the base's own (now final: load(), classesIn(), definition(), setDefinition(), id(), source(), environments(), requires(), maintenance(), engines(), checksum(), file(), state()), a hook (plan(), up(), down(), detect(), description(), transactional(), reversible()) that is not public, static, takes fewer parameters, requires more or types $context, or a class without up() (expMigrationPhp::methodClashes(), which only tokenizes the code). exp:migration:check lists the message.
  • Updated: PHP migrations, with its German page.

2026-11-01: YAML migration steps for classes, users, roles, sections, languages and object states

YAML migrations can now change the structure of an installation: content classes and class groups, users and user groups, roles with their policies, limitations and assignments, sections, languages and object state groups. Each step type goes through the kernel's own helpers and operations, as the setup wizard, the site packages and the administration do. Guide: YAML migration steps (German: Schritte von YAML-Migrationen); every key: the step types (German: die Schritttypen).

Added

  • Added: The step type content_class (create, update, ensure, delete): a class with its names and descriptions in several languages, name pattern, URL alias pattern, container, sorting and class groups, and its attributes with their flags, category, placement and datatype settings (settings:, the columns data_int1 ... data_text5). update adds attributes (add_attributes:, through eZSiteInstaller::addClassAttributes(), so every object gets them), changes them (update_attributes:) and removes them with their data (remove_attributes:, eZSiteInstaller::removeClassAttribute()); a class with objects is deleted only with with_objects: true.
  • Added: The step type content_class_group (create, update, ensure, delete); a group with classes is not deleted.
  • Added: The step types user (create, update, ensure, delete: login, e-mail, password hashed as the kernel hashes it or a given hash, enabled, groups as locations, fields) and user_group (the content step with the keys of a group). No plan, log or record shows a password.
  • Added: The step type role (create, update, ensure, delete, assign, unassign): policies with limitations whose values may be identifiers (class and section identifiers, nodes and subtrees, siteaccess names, states), written through eZSiteInstaller::addPoliciesForRole(); assignments to users and groups, limited to a subtree or a section.
  • Added: The step types section (create, update, ensure, delete, assign with the scopes subtree, inherited and object), language (create, update, ensure, delete) and object_state_group (create, update, ensure, delete, with the states and their translations).
  • Added: Creates undo themselves when their migration is rolled back without down: (a class only while it has no objects), and so do role assignments.
  • Added: The identifier kinds class_group (by name) and state_group (by identifier).
  • Added: Generated reference pages of the eight step types, English and German.
  • Added: Tests without a database (expMigrationStructureStepsTest) and runs through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationStructureSteps*Test).

Updated

  • Updated: A content delete checks that the object is gone and fails when the delete operation left it (it does so without a word when the user the migration runs as may not remove it), and a delete that removes a user account keeps the user the migration runs as signed in.
  • Updated: The migrations guide, the file format and the YAML steps guide, with their German pages: the step types that come later are those of tags, URL aliases, the trash, files, caches, layouts, http, mail and command.

2026-11-01: YAML migration steps for tags, URL aliases, the trash, files and images

YAML migrations can now change the tags of eztags, the custom URL aliases and wildcards, the trash, and the files and images of content: a file or image is read from the folder of the migration, so it travels with the migration in its repository. Guide: YAML migration steps (German: Schritte von YAML-Migrationen); every key: the step types.

Added

  • Added: The step type tag (create, update, ensure, delete, move, merge): a tag of eztags with its keyword in several languages, its main language, always available and synonyms, made as the tags pages of eztags make it. Without eztags the step fails and says so; missing: skip lets it pass.
  • Added: The step type url_alias (create, ensure, delete): a custom URL alias of a node (redirecting or not, from the root or below the parent's address), a global URL alias to a module URL or another address, and URL wildcards; a delete removes one address, every custom address of a node, or a wildcard, never the address of a node's name.
  • Added: The step type trash (move, restore, purge): an object with all its locations to the trash, back below the parent it had or another, and purged for good or the whole trash emptied, each checked afterwards.
  • Added: The step types file (an ezbinaryfile or ezmedia attribute) and image (an ezimage attribute with its alternative text, focal point and crop rectangle), each a new version or in place. The file is a path below the folder of the migration file; .., absolute paths, URLs and links out of the folder are refused.
  • Added: Creates of tag and url_alias, and trash move and restore, undo themselves when their migration is rolled back without down:.
  • Added: The identifier kinds tag (by id, remote: or path:<keyword path>) and url_alias (by its address).
  • Added: Generated reference pages of the five step types, English and German (with the German descriptions).
  • Added: Tests without a database (expMigrationTagAliasTrashFileStepsTest) and runs through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationTagAliasTrashFileSteps*Test; the tags part with eztags where it can be loaded).

Updated

  • Updated: On MongoDB a URL alias that does not redirect is stored as an alias (it was stored as a history entry of the node's name, so it neither showed in the administration nor could be removed), an alias removed in its last language is deleted (the filter of eZURLAliasML::removeSingleEntry() used a negative bitmask, which matched nothing), and eZURLAliasQuery without a limit reads every item.
  • Updated: The migrations guide, the file format and the YAML steps guide and the migrations help hub, with their German pages: the step types that come later are those of caches, the search index, layouts, http, mail and command.

2026-11-01: Caches, the search index and autoloads after a migration run

A migration run now clears exactly the caches its steps touched and reindexes the objects they changed, and three step types cover what that does not: cache, search and autoload. The report of a run names the caches with the deploy that clears exactly them on the server too. Guide: YAML migration steps (German: Schritte von YAML-Migrationen).

Added

  • Added: After a run that applied migrations, or a rollback that undid some, the caches the steps of those migrations declare are cleared once through ezcache, and only those (migration.ini [RunSettings] ClearCaches). The report lists them (caches) with the deploy that clears them on a server with PHP-FPM and Velocity (deploy: ./console exp:velocity deploy --caches=<list> --apply).
  • Added: The objects the steps created or changed are reindexed after the run: queued for the cronjob indexcontent (the default), indexed at once, or left alone ([RunSettings] Reindex=queue|now|disabled); the report says how many (reindex).
  • Added: The step type cache: caches of ezcache by id (caches:) or tag (tags:), and the view caches of objects; cleared with the run's list after the run (when: end) or at once (when: now).
  • Added: The step type search (reindex, full): named objects or those the run changed so far, or every published object (of some classes), queued or indexed at once.
  • Added: The step type autoload: the autoload array of the extensions or of the kernel overrides, regenerated by the generator in a process of its own and checked with php -l; never the kernel's own array.
  • Added: exp:velocity deploy --caches=<list> clears the caches named (ids or tags of ezcache, tag:<name> for a tag) whatever files changed.
  • Added: Generated reference pages of the three step types, English and German (with the German descriptions); a step type whose caches are the ones a step names (cache) shows those of its example.
  • Added: Tests without a database (expMigrationCacheSearchAutoloadStepsTest) and runs through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationCacheSearchAutoloadSteps*Test).

Updated

  • Updated: The migrations guide, the file format, the YAML steps guide, the migrations help hub and the Velocity deploy page, with their German pages: the step types that come later are those of layouts, http, mail and command.

2026-11-01: YAML migration steps for layouts, HTTP requests, mails and console commands

YAML migrations can now build the layouts of Exponential Layouts (layouts, their zones and links to shared layouts, blocks and rules) and reach out of the installation: one HTTP request, one e-mail or one allowlisted console command per step. With these the YAML steps of the plan for 6.0.15 are complete. Guide: YAML migration steps (German: Schritte von YAML-Migrationen).

Added

  • Added: The step types layout, layout_block and layout_rule (create, update, ensure, delete) for Exponential Layouts (the extensions explayouts and explayouts_core), made through its services as the layout editor makes them: a change in the layout's draft, published at the end of the step (publish: false keeps the draft). A layout is named by its identifier, shared or not, its zones linked to a zone of a shared layout or not; a block by its name in a zone, with its definition, view types, place and parameters; a rule by its layout and targets (a node target takes any identifier of a node), with its conditions, priority and switch. Without the extensions the steps fail and say so; missing: skip lets them pass. A create of each undoes itself on a rollback without down:.
  • Added: The step type http: one request (GET, POST, PUT, PATCH, DELETE, HEAD) to an http or https URL without a user or password, headers, secrets by name (bearer:, secret_headers:), a text or JSON body, a signature as webhooks of Exponential carry it (sign:), the status codes that pass, and the answer kept for references (status, body, json). No redirect is followed.
  • Added: The step type mail: one e-mail through the mail transport of the installation (the mail gate applies), to, cc and bcc, plain or HTML, from and reply-to, and a transport for this mail only.
  • Added: The step type command: one exp:* console command of migration.ini [StepSettings] CommandAllowList[] (a name, or a group ending in *), never exp:migration:* or exp:upgrade*, handed to PHP as a list (no shell), with a time limit and the exit code that passes. The default list: exp:services:compile, exp:graphql:persist, exp:help:content, exp:static:build.
  • Added: The audit events system.migration.http, system.migration.mail and system.migration.command (on by default): method, host and path and status of a request, the number of recipients of a mail, the name and exit code of a command; never a header, an address, a text, an argument or an output.
  • Added: Generated reference pages of the six step types, English and German (with the German descriptions).
  • Added: Tests without a database (expMigrationLayoutHttpMailCommandStepsTest; requests, mails and processes go to a transport, a folder and a runner of the test) and runs through the runner on SQLite, PostgreSQL, MySQL/MariaDB and MongoDB (expMigrationLayoutHttpMailCommandSteps*Test; the layouts part with Exponential Layouts where it can be loaded).

Updated

  • Updated: The migrations guide, the file format, the YAML steps guide, the migrations help hub and the help page of the migration policies and events, with their German pages: every YAML step type of the plan is in 6.0.15; SQL and INI migrations come later.

2026-11-01: SQL migrations, INI migrations, and the schema, data, sql and settings steps

Migrations can now change the database schema and its rows in a portable way, run SQL written per engine, and change settings. Guide: Migrations: SQL and settings (German: Migrationen: SQL und Einstellungen).

Added

  • Added: The step type schema (create_table, drop_table, add_column, drop_column, add_index, drop_index): a table, a column or an index written once in the .dba format and made on MySQL/MariaDB, PostgreSQL, SQLite, Oracle and MongoDB by the kernel's schema handlers. Each mode looks in the engine's catalogue first and does nothing when its work is done; create_table, add_column and add_index undo themselves on a rollback. SQLite before 3.35 drops a column by rebuilding the table.
  • Added: The step type data (insert, ensure, update, delete): rows of a table, the values quoted by the connection on the SQL engines and stored as they are on MongoDB.
  • Added: The step type sql: statements for every SQL engine or per engine, MongoDB commands, or a file next to the migration (the engine's own file wins); missing: skip for an engine it has nothing for.
  • Added: SQL migrations: .sql, .<engine>.sql (pgsql for PostgreSQL too) and .mongodb.json files of one id, the engine's own file run, a -- down part that undoes them. A portable .sql file runs on the SQL engines only.
  • Added: The step type settings and INI migrations (.ini, each block naming its file and siteaccess): changes to settings/override and settings/siteaccess/<name> only, through the INI editor of exp:ini, with a backup first, owner, group and mode kept, and each change in the audit log (system.setting.write).
  • Added: The header key transactional: no for a migration without a transaction.
  • Added: Generated reference pages of the four step types, English and German, and the guide Migrations: SQL and settings with its German page.
  • Added: Tests without a database (expMigrationSqlSettingsStepsTest) and through the runner on SQLite, MySQL/MariaDB, PostgreSQL and MongoDB (expMigrationSqlSettingsSteps*Test), with schema changes that are and are not undone by a failure.

Updated

  • Updated: The runner and exp:migration:check run and check SQL and INI migrations instead of refusing them.
  • Updated: The migrations guide, the file format, the YAML steps guide and the guide index, with their German pages.

2026-11-01: static publishing, the parts that need a server

A static copy cannot search, take a form or sign anybody in. Each build now finds those parts and gives them a fallback: the search gets a page and index of its own, information collectors post to the live site through a signed endpoint (or show a short text), comments, the basket and the login are left out, and the report lists all of it. Old addresses get redirect pages, the files can be minified, precompressed and fingerprinted, and a target can publish itself after content publishes or on a schedule, with an e-mail and a webhook afterwards.

Upgrading: nothing to do. Every new behavior is off until a target asks for it; the form endpoint answers only targets with Forms=endpoint. To publish on content publish or on a schedule, run the cronjob part staticpublish every few minutes.

Added

  • Added: expStaticDegrader, the report degraded of every build, and the fallbacks for search, information collectors, comments, the basket, the login and server calls; [StaticPublishSettings] FormFallbackText and FormFallbackLink.
  • Added: expStaticSearchIndex: search.html, static-search/search.js and an index per language (exp-static-search/1) with Search=enabled; [StaticPublishSettings] SearchMaxBytes.
  • Added: The form endpoint staticpublish/form (expStaticFormEndpoint, expStaticFormToken): origin, signed token, rate limit, then the normal information collection; [StaticPublishSettings] FormEndpoint, FormTokenDays, FormRateLimit, FormRateWindow; PolicyOmitList[]=staticpublish/form.
  • Added: expStaticRedirects: redirect pages for the history and custom aliases of the pages and for URL wildcards without * (Extras[]=redirects); README.md and LICENSE (Extras[]=readme, license, target setting License).
  • Added: PostProcess[]=minify, precompress (.gz, .br with the brotli extension) and fingerprint (content hash in the file name, never a query).
  • Added: expStaticPublishQueue (pages marked stale by eZContentCacheManager, Triggers[]=publish, [StaticPublishSettings] PublishDebounce) and expStaticSchedule (Triggers[]=schedule, Schedule), both run by the cronjob part staticpublish.
  • Added: expStaticNotifier: the e-mail of Notify[] ([StaticPublishSettings] NotifyEmail) and the webhook (exp-static-webhook/1, signed with the secret WebhookSecret names).
  • Added: The audit events system.staticpublish.build and system.staticpublish.form.
  • Added: The guide Static publishing: dynamic features, the specification The form endpoint and the help pages on search and forms, publishing automatically, notifications and webhooks, SEO and languages, with their German pages.

Updated

  • Updated: exp_static_page keeps a page stale when its content changed after the build that wrote it began.
  • Updated: exp:static:targets --export=<ids|all> prints targets as an INI migration (also for a site package), with credentials as their secret names only (expStaticTarget::migrationIni()).
  • Updated: A package carries static publishing targets (ezpm.php add <package> expstatictarget <ids|all>, install item type expstatictarget): the wizard's settings with credentials as their secret names only, no user or password in the git address and no webhook; installing adds a target that is not there yet and keeps one that is.
  • Updated: exp:static:github status, the "On GitHub" box and "Set up on GitHub" warn when Delivery=actions deploys from a branch that is not the repository's default one, which GitHub's github-pages environment refuses unless the branch is added to it.
  • Updated: The setup wizard describes the redirect pages, README, LICENSE, the workflow, minify, precompress, fingerprint and the triggers on publish and on a schedule, and has a field for the license of the content; the report of a build shows what does not work statically, the search index and the redirect pages.

2026-11-01: GraphQL in migrations, tagged content events, and the migrations through the APIs

A migration can run queries and mutations of the GraphQL API, its content changes reach webhooks and subscriptions tagged with it, and the state of the migrations can be read through GraphQL and REST. Guides: YAML migration steps, the GraphQL API, REST API v1 and v2.

Added

  • Added: The step type graphql: a persisted query (by name or SHA-256), a query or mutation text, or a .graphql file next to the migration, run in process (no HTTP, no token) as the run's user, with variables from references; its answer is kept as data, result and errors. An error fails the step unless allow_errors: true and data.
  • Added: The header key events: each|summary|none: each content event of a migration carries the field migration (each, the default), or the migration notes one mutation event migration with the objectIds and nodeIds it changed (summary), or none (none). A migration that fails and is rolled back leaves no events.
  • Added: The fields migration and objectIds of the event journal, in webhook payloads and in ContentEvent of the subscriptions.
  • Added: The GraphQL queries migrationStatus, migrations, migrationRuns and migrationRun (expMigrationGraphQLExtension, in graphql.ini SchemaExtensionList[]), read only, for users with migration/read.
  • Added: The REST routes GET /api/migration/v1/status, /runs and /runs/<id> (rest.ini ProviderClass[migration]) and the API key scope migration:read. Nothing runs or is undone through an API.
  • Added: expMigrationService, the read API both answer from.
  • Added: The generated reference page of the step type, English and German; tests without a database (expMigrationGraphqlIntegrationTest), of the schema extension with a snapshot of its types (expMigrationGraphQLExtensionTest), and through the runner on SQLite with the real GraphQL server: a persisted query and a mutation from a migration, their events tagged (expMigrationGraphqlSqliteTest).

Updated

  • Updated: The GraphQL feature page, the GraphQL recipes, the REST API guide, the YAML steps guide and the file format, with their German pages.

2026-11-02: migrations on every engine, and what a run costs

Every family of the migration engine is checked the same way on SQLite, MySQL/MariaDB, PostgreSQL and MongoDB, in CI as well, and three performance checks measure what a run costs on each. Guide: Migrations: engines and performance (German: Migrationen: Engines und Leistung).

Added

  • Added: The step type graphql is checked on MySQL/MariaDB, PostgreSQL and MongoDB too (expMigrationGraphqlDatabaseTest, expMigrationGraphqlMongoDBTest), with the checks shared with SQLite in expMigrationGraphqlEngineChecks: on MongoDB a failed migration keeps the content it made (no transactions), and so its events, tagged with the migration.
  • Added: Performance checks on every engine (expMigrationPerformanceEngineChecks, with a SQLite, a database and a MongoDB class): 1,000 YAML migrations (discovery, ordering, checksums, the run, a second run, the status), a data step of 5,000 rows inserted, updated and deleted, and a PHP migration of 5,000 rows in batches with checkpoints, stopped and resumed. EXP_MIGRATION_PERF_REPORT names a file the measurements are written to; EXP_MIGRATION_PERF_COUNT and EXP_MIGRATION_PERF_ROWS change their size.
  • Added: The database job of the PHPUnit workflow runs the migration classes of the testsuite mongodb-live on a MongoDB 7 service, next to MySQL 8.0 and PostgreSQL 15.
  • Added: The guide "Migrations: engines and performance" in English and German: the matrix, how to run it, how CI does, the measured times and memory per engine and how to measure your own.

Updated

  • Updated: On MongoDB, the conditions of eZPersistentObject::fetchObjectList() that are lists (IN), ranges (BETWEEN) or operators (>, <=, like ...) match as they do on the SQL engines; they matched nothing before, so a migration's full reindex of a class queued no object there.

2026-11-02: the Exp Debug bar shows the user's cache profile

Added

  • The debug bar has a User tab and a chip VC <profile>: the user's cache profile, a fingerprint of the parts of the content view cache key that depend on the user (role ids, limitation values, discounts, an interface switch, the preferences of CachedViewPreferences of every view mode, the user id with pr_user). Users with the same profile share the view cache files under the same ViewCacheTweaks; a second fingerprint covers the keys listeners of content/view/cachekeys added for the page's view. The tab lists every part as the key takes it, the preferences per view mode, the role assignments with their limitation and whether they come from the user or a group, the profile of anonymous visitors for comparison, and copies the profile as text. For setup/setup, and for an anonymous visitor who gets the debug output. See the Debug bar.
  • eZNodeviewfunctions::viewCacheTweak( $nodeID ), userCacheKeys( $user, $viewCacheTweak ) and userPreferencesKey( $preferenceList, $values ): the parts of the view cache key that depend on the user, used by generateViewCacheFile() itself, so the debug bar shows exactly what the key takes. The cache file names do not change.
  • Translations ger-DE and eng-US; the toolbar theme has icons for the chip and the tab.
  • Added: The User tab's words in every administration catalogue, not only ger-DE and eng-US.
  • Updated: The German guide, feature page and help page of the debug bar describe the User tab.

Tests

  • expDebugBarUserTest (UC-01 to UC-09).

2026-11-02: static publishing, the documentation completed

The static publishing documentation is complete in English and German. The help manual gains the 15-minute tutorial from an existing site to a live github.io address, plain-language pages on what static publishing is and whether a site suits it, a GitHub account walkthrough, links and base path, images and downloads, private content, other git hosts, troubleshooting by symptom and a FAQ. Every audience of the help center has a page of its own, the guides cover CI and GitHub Actions line by line, custom domains, limits, multisite, Windows and clusters and the way over from the static cache, and four tutorials show a blog, a documentation site, developing in public and a static HTML site brought into Exponential and back. A "New in 6.0.15" page announces it.

Upgrading: nothing to do.

Added

  • Added: The help pages Make a free website in 15 minutes, What static publishing is, Is static right for my site?, Create a GitHub account and repository, Links and base path, Images, files and downloads, Keep private content private, Other git hosts, When a publish fails and the FAQ.
  • Added: New in 6.0.15: static publishing, a static publishing page for each help center audience, and a chapter of the Building a Site book.
  • Added: The guides for administrators, custom domains and DNS, CI and GitHub Actions, troubleshooting, limits and GitHub's terms, multisite installations, Windows and clusters, and from the static cache to static publishing; the tutorials on developing in public, a static blog, a static documentation site and a static HTML site.
  • Added: The specification of the manifest, the compatibility page Static publishing and its item in the upgrade checklist.

Updated

  • Updated: The help hub, the GitHub Pages limits page and step 8 of the setup wizard page (publishing on content publish and on a schedule works now), with their German pages.

2026-11-02: the Exp Debug bar's User tab lists the policies of the user's roles

Added

  • The User tab ends with a folded section Policies (for setup/setup): the policies of the user's roles with their limitations in readable form (classes, sections, siteaccesses and nodes by name) and the role, searchable by module/function, role, limitation and names, filterable by role, 200 rows at a time. A search for a module and a function (content/read) also finds the policies that grant it with a wildcard (content/*, */*). Loaded only when opened, by the new server function expdebugbar::policies::<role>::<offset>::<search>, everything in the path (the search as base64url) and anything else refused; the policies, limitations and values of all roles are read through their persistent objects, three queries per 500 ids, so hundreds of roles work on every database, MongoDB included. See the Debug bar.
  • The role ids in the Roles table and in the policies link to role/view/<id>.
  • expDebugBarPolicies (kernel/classes/debugbar/expdebugbarpolicies.php); the section's words in every administration catalogue.

Updated

  • Updated: An earlier version of the section, filtered by module and function and also shown to anonymous visitors with debug output, gave way to this one; the policies are for setup/setup only.
  • Updated: The guide, feature page and help page of the debug bar describe the section, in English and German.

Tests

  • expDebugBarPoliciesTest (UP-01 to UP-04, UP-06 to UP-08), expDebugBarUserPoliciesTest (UP-05, UP-09, UP-10); expDebugBarUserTest checks the role link.

2026-11-02: suggestions under the header search while typing

The header search field of the administration suggests while the user types, as Ibexa's admin does: content, the pages of PDF documents, user accounts, content classes and the administration's own pages, in groups, each only as far as the user may see it, and "View all results" with the number of matches at the end. In admin and admin4 alike, in both copies of admin4's header search (header and narrow screen menu); not in the browse mode.

Upgrading: nothing to do; the suggestions are on. Roles that use the administration need content/read (the view content/suggest asks for it, as content/search does). Clear the caches so the templates, the packed scripts and stylesheets and the INI settings are rebuilt. Switch it off with admininterface.ini [SearchSuggestSettings] Enabled=false.

Added

  • Added: The view content/suggest/(text)/<text> (policy function content/read, read only, GET, private JSON: { query, total, groups: [ { id, title, count, items } ] }, 403 for the anonymous user, 404 when switched off), built by expSearchSuggest (kernel/classes/expsearchsuggest.php). The groups and their sources:
    • content: eZSearch::search() with the search engine of the site, which applies the read permissions; with eZ Find (the class eZSolr and the extension ezfind active) eZ Find, else the kernel's eZSearchEngine, also when site.ini [SearchSettings] SearchEngine names an engine that cannot be loaded. Only the API the kernel and the se7enxweb/ezfind package share is used (SearchLimit, SearchOffset, SearchResult, SearchCount). Each hit is checked again with canRead(), an object is listed once.
    • pages: the PDF pages that hold the words, only with eZ Find, the extension ezfindpdfdeepsearch active and its fetch function pdfdeepsearch/page_hits (which checks the document's read permission); without it a document is suggested as a whole.
    • users: accounts by login, e-mail address or name, only those canRead() allows.
    • classes: by name or identifier, only for a user who may open class/grouplist.
    • admin: the top tabs and the links of each tab's left menu, drawn by the design for the user (parts/<part>/menu.tpl, with all of its access checks), as "Setup › Cache management"; kept per user, rights and address for AdminPagesCacheTTL seconds in the cache item searchsuggest (cleared with the template and user caches).
  • Added: admininterface.ini [SearchSuggestSettings]: Enabled, MinLength=3, Groups[] (order, on/off), Limits[] per group, PagesPerDocument, PageHitClasses[], Wildcard (auto: a * after the last word only with site.ini [SearchSettings] EnableWildcard=true), AdminPagesCacheTTL, AdminMenuSkipParts[].
  • Added: javascript/page/search_suggest.js and templates/parts/search_suggest.tpl, the same files in admin and admin4: the field is an ARIA combobox with a listbox (aria-activedescendant), asked after 250 ms from MinLength characters, a running request cancelled when the text changes (AbortController), ↓/↑, Enter, Esc, a click outside; the server's texts set as text, the typed words marked with <mark> through the DOM; no jQuery, no inline script or style. Ctrl+/ and Cmd+/ focus the field from anywhere (/ alone stays admin4's, outside fields).
  • Added: The "Suggestions" block of stylesheets/modules/search.css (the same in both designs): the .a4-suggest* classes on the --a4-* tokens (dark mode), in the flow under the field in the narrow screen menu, touch-sized entries, an outline in forced colours.
  • Added: German texts (context design/admin/search/suggest), and the sources in eng-US and untranslated.
  • Added: tests/tests/kernel/classes/expSearchSuggestTest.php (DB-less: settings, fallbacks, wildcard, matching, permission filter, menu links, answer) and tests/tests/design/AdminSearchSuggestTest.php with the node harness tests/tests/design/js/search_suggest.js. Guide: Searching, "Suggestions while typing".

2026-11-02: the search page with a filter bar, the advanced search in it

The administration's search page (content/search) works as Ibexa's: the search field on top, a filter bar beside the results (above them, folded under "Show filters", on a narrow page), the active filters as chips, a sort order and a result table. The former advanced search is part of it. In admin and admin4 alike.

Upgrading: nothing to do. Clear the caches so the templates, the translations, the packed scripts and stylesheets are rebuilt. An old address of content/advancedsearch leads to the search page; its query string is not read (see the view parameters below), a posted advanced search form is taken in with the same search. Links and bookmarks of the form content/search?SearchText=... now open an empty search: Search links with view parameters shows how to rewrite them. A siteaccess that runs the classic admin design with eZ Find active keeps eZ Find's own design/admin/templates/content/search.tpl, which comes before the kernel's design in the design chain.

Added

  • Added: The fetch function fetch( 'content', 'filtered_search', hash( 'offset', ... ) ) and the class expContentSearchFilters (kernel/classes/expcontentsearchfilters.php). It reads the filters from the request (SearchText, SearchMode all/any/phrase, ClassID[], ClassAttributeID, SectionID[], Modified, ModifiedFrom, ModifiedTo, Published, PublishedFrom, PublishedTo with the periods day, week, month, year and custom, Owner as user ID, login or e-mail address, Language, SubTreeArray[], SortBy, SearchPageLimit and the header's BrowsePageLimit), checks every value against the installation (an unknown class, section, language or user is dropped, an invalid date reported) and searches:
    • with eZ Find (site.ini [SearchSettings] SearchEngine=ezsolr and the ezfind fetch functions present): fetch( 'ezfind', 'search' ) with query, offset, limit, class_id, class_attribute_id, section_id, subtree_array, filter (modified, published, owner_id, language_code), facet (class_identifier, section_id, translation, for the counts), sort_by and spell_check, all parameters of the package se7enxweb/ezfind 3.0. The extended attribute filters ezfindallwords (all words) and ezfindcollapse (the parts of a document folded into it, where an installation has it) are asked for only when ezfind.ini [ExtendedAttributeFilters] FiltersList[] registers them and their class exists. "Any word" joins the words with OR;
    • with the built-in engine: fetch( 'content', 'search' ) with text, class_id, class_attribute_id, section_id, subtree_array, publish_timestamp and sort_by. It cannot filter by modification, owner or language, find any of the words, count or sort by relevance: those filters are not shown. Without words it searches only with AllowEmptySearch=enabled. The parameters of the advanced search are mapped: SearchContentClassID to ClassID[], SearchContentClassAttributeID to ClassAttributeID, SearchSectionID to SectionID[], SearchDate 1, 2, 3, 5 to Published=day|week|month|year and 4 to a custom range of three months, SearchTimestamp to PublishedFrom, PhraseSearchText to a quoted phrase in front of the words (alone: SearchMode=phrase).
  • Added: javascript/page/content_search_filters.js (both designs): Apply is disabled until a filter changed, the from/to dates show for a custom range only (a typed date chooses it), the sort order searches on change, empty fields are not sent, the bar folds on a narrow page. No jQuery, no inline script; the page works without it.
  • Added: The "Results" block of stylesheets/modules/search.css: the two columns from 820 px of page width (a container query), the compact filter bar with counts, chips, the result table; tokens only, dark mode, forced colours.
  • Added: German texts (context design/admin/content/search), the sources in eng-US and untranslated.
  • Added: tests/tests/kernel/classes/expContentSearchFiltersTest.php (DB-less: checking, mapping, the parameters per back end, dates, query strings, chips, facets, the search run) and the node harness tests/tests/design/js/content_search_filters.js, run by AdminSearchTest.

Changed

  • Changed: content/search.tpl, content/searchresult.tpl and content/advancedsearch.tpl of admin and admin4 are the same files. The result table has the columns type, name (with the path, the words found and a PDF's pages with ezfindpdfdeepsearch), modified, content type, translations (each a link to the item in that language) and Edit; "Edit selected" stays. The header reads "Results for “words” (count)", an empty page and a search without matches say what to do, "Did you mean" keeps the filters, the pages keep every filter and the sort order.
  • Changed: the page is content/search_page.tpl; content/search.tpl and content/advancedsearch.tpl include it, the advanced search's own form is gone. eZ Find brings its own content/search.tpl for the design admin, read before design/admin's: through search_page.tpl an old advanced search address still shows this page there. The kernel view content/advancedsearch is unchanged, so other designs keep their template.
  • Changed: the search statistics (LogSearchStats) count the filtered search the page showed, also with SearchViewHandling=default: the views content/search and content/advancedsearch take the search_data the template leaves. With that page the views leave their own search out (Search::templateSearchesItself(): the design's content/search.tpl includes content/search_page.tpl), so a search runs once, with or without eZ Find.
  • Changed: a subtree or an owner in the filters shows its name only when the user may read it; without a subtree the siteaccess's PathPrefix tree is searched, as content/search does; filters alone search only where AllowEmptySearch allows it; "All words" passes the text on as typed, "Any word" keeps NOT and -word.
  • Changed: design/admin/templates/content/searchresult.tpl has no inline script any more (javascript/page/content_searchresult.js, as in admin4).
  • Changed: the search page takes its search as view parameters only, never as a query string: content/search/(text)/easter/(class)/2,16/(section)/1/(sort)/name_asc (expContentSearchFilters::VIEW_PARAMETERS: text, mode, class, attribute, section, modified, modified_from, modified_to, published, published_from, published_to, owner, language, subtree, sort, limit; a list once, its values joined by commas). The filter bar and the header search field of admin, admin3 and admin4 are posted (method="post") and the view answers with a 303 to that address (expViewParameters); the pager, the chips, "Reset", "Did you mean" and "Go to the first page" link to it (expContentSearchFilters::viewSuffix()). The view content/advancedsearch answers in the same way with content/search. A search of the browse window (Mode=browse) keeps its own handling. Tests: expContentSearchFiltersTest (SF-14), AdminSearchTest.
  • Changed: every administration design has the suggestions and the search page, from one copy of each file in design/standard, so admin4 still needs no other admin design: content/search_page.tpl, parts/search_suggest.tpl, parts/header_search.tpl (the header search with suggestions, included by the page_search.tpl of admin3 and admin4), the scripts search_suggest.js, page_search.js, content_search_filters.js, content_searchresult.js and modules/search.css. Only content/search.tpl, advancedsearch.tpl and searchresult.tpl stay in both admin and admin4, because design/standard has front-end templates of those names. admin3 now suggests too and its header has no inline script. The suggestions list is no longer tied to .searchblock, and Ctrl+/ finds any field with suggestions. The AdminUI design (exp_adminui 1.0.0.58) drops its own search pages and posts its header search with suggestions. Tests: AdminSearchTest AS-11, AdminSearchSuggestTest SG-01.

2026-11-02: overview and detail pages as dense rows

The overview pages of the administration (roles, sections, states, classes and class groups, PDF exports, RSS, URLs, packages, drafts, workflow groups) drew one card per item, so a screen showed three or four items. They read as table rows again, in admin and admin4 alike: one row per item with fixed columns, the facts on one line and the row actions as icons. The detail pages (class/view, section/view and others) show the facts of a card on one line and the attributes of a class as rows. One density, no switch: the decision of 15 October stands.

Upgrading: nothing to do. Clear the design cache so the packed stylesheets are rebuilt.

Changed

  • Updated: Inside .a4-page, ul.a4-cards reads as a table: rows with a rule, zebra and hover, the status marks as an edge at the row's start, the facts of a row on one line, and from 701 px the rows are subgrids of the list (name, buttons, facts in columns). More than three facts take a line of their own below the name: four in equal columns, five and more as running text that wraps between the facts. In url/list the address takes the whole first line of its row, the facts and buttons the second.
  • Updated: The row actions View, Edit, Open, Assign and Copy of card rows and tables are frameless icon buttons (Bootstrap Icons), their label kept for screen readers; other actions, external links and buttons with an icon of their own keep their text. Each such button has a title (its label, or its aria-label where it has one) as tooltip, in design/admin and design/admin4: class, class group, role, section, state, workflow group, URL, bookmark, pending, session, collected information, broken relation and settings lists.
  • Updated: From 601 px, .a4-table cells inside .a4-page take 4 px above and below; an action cell does not wrap, and a date and its modifier keep a space between them. On touch screens (pointer: coarse) the row buttons keep their full size.
  • Updated: class/grouplist is a table: select, name, ID, classes, objects, modified and actions in columns. The class chips and the "Removing it" fact are hidden visually only (screen readers still read them); a group whose removal removes objects takes the red edge, and class/removegroup lists the details before it asks. The facts carry classes of their own (.a4-classgroup-last, -modified, -removing, -classes), so the columns do not depend on their order.
  • Updated: The facts of every card that is not a list row (.a4-card > dl.a4-facts, also inside its .a4-card-body: detail pages such as class/view and section/view, settings and status pages) are one line of "Label: value"; a long value wraps. The one-line empty state of a section (.a4-section > p.a4-empty) is slim, and sections are 16 px apart. The attributes of class/view are rows with their datatype settings on one line.
  • Added: tests/tests/design/AdminListRowsTest.php. Guide: Styling the administration, Components.

2026-11-02: an image field takes a copy of an existing image

The edit form of an image field (ezimage) offers, besides the upload, Choose an existing image: the editor browses the content tree (Browse for an image, starting in the media library) or searches by name (Search images by name, thumbnails of the objects found) for an object that has an image, and its image file is copied into the field being edited. The copy is a new file under the edited object's own storage path, stored through the same code as an upload, so the field behaves as if the file had been uploaded: the aliases are made from it, it can be removed or replaced, nothing refers to the other object, and removing the other object later leaves the copy. The other object is only read. In admin, admin4 and every design that uses the standard edit template; works without JavaScript (both the browse and the search are posts of the form).

Upgrading: nothing to do. Regenerate the kernel autoloads (a new kernel class) and clear the caches so the templates, the packed stylesheets and scripts and the INI settings are rebuilt.

Added

  • Added: expImageCopy (kernel/classes/datatypes/ezimage/expimagecopy.php): copyFromObject() checks that the selected object exists and that the current user may read it (content/read; refused with the same message as an object that does not exist), takes its image field (the field image, else the first image field with a file), reads the file through the cluster file handler (DFS safe) into a private working copy and checks it as an upload is checked (the size limit of the field being edited, the file name blacklist, an image by name and by content, SVG only when accepted and then sanitized), then stores it with eZImageAliasHandler::initializeFromFile(). An empty alternative text takes the source image's. search() lists at most 12 objects of the classes with an image field whose name contains the text, through the tree fetch, which applies the read policies.
  • Added: The custom actions of the image datatype <attribute id>_browse_for_image (content browser, browse.ini [ExpImageFromObjectToDataType], start node media, selectable: the classes with an image field), _set_image_from_browse (the browser's answer), _search_existing_image (the search field <base>_data_imagesearch_<attribute id>) and _use_existing_image (the button's value is the object id). A refused copy is the field's validation error.
  • Added: design/standard/javascript/expimagepick.js: Enter in the search field searches instead of sending the form with its default button.
  • Added: The UI and error strings in every administration catalogue.
  • Added: tests/tests/kernel/datatypes/ezimage/expImageCopyFromObjectTest.php (uses the installation's database, skipped without one): the copy is a file of its own with the source's bytes, the source is unchanged and its removal leaves the copy, the alternative text, refusal without read access, of an object without an image and of input that is no object id, and the search.

Updated

  • Updated: design/standard and design/admin4 content/datatype/edit/ezimage.tpl have the "Choose an existing image" section; the upload is unchanged. Its styles are .a4-content-imagepick-* in stylesheets/modules/content.css of admin4 and admin (tokens only).
  • Updated: eZImageAliasHandler has the attribute existing_image_pick (the search and copy state of the current request, kept on the handler, never in a static); eZImageType::isAcceptedImageFileName() is public.
  • Guide: Images, media and uploads.

2026-11-02: migration remotes and deploys in the administration, staged deploys and rollback

Upgrading: run the upgrade (./console exp:upgrade:status, then --apply) or the 6.0.0 to 6.0.15 database update of your engine: the step 6.0.15-migration-deploy-tables creates the tables exp_migration_deploy and exp_migration_deploy_host. Give the policies migration/remotes and migration/deploy to the roles that may manage remotes and deploy.

Added

  • Added: Setup > Migrations > Remotes lists the installations this one deploys its migrations to, tests a connection (the remote's migration status read once, or for FTP one file read back) and opens a wizard that adds or changes a remote: environment, groups, transport, address, PHP command line, a credential by secret name or environment variable, and the host key. Review shows the lines written to settings/override/migration.ini.append.php through the INI editor; a secret is never written.
  • Added: Setup > Migrations > Deploys starts a preflight or a deploy of a selection of remotes or groups as a background job, follows every remote live over the migration stream, keeps the history from the stored reports, and on a deploy's page approves the gate of a staged deploy or rolls the deploy back. Saving or removing a remote, deploying, approving and rolling back ask "Verify it's you".
  • Added: exp:migration:deploy --staged (stages by environment in the order of [RemoteSettings] StageOrder[], all or nothing within a stage, a gate before each next stage), --continue=<deploy id> (approve the gate) and --rollback=<deploy id> (take back every remote that applied it), and --login, --run-id, --progress for the administration's job.
  • Added: The tables exp_migration_deploy and exp_migration_deploy_host (kernel schema, the update files of MySQL, PostgreSQL and SQLite, upgrade step 6.0.15-migration-deploy-tables). A deploy that fails or waits at a gate sends a plain-text notice in the kernel's admin mail model ([RemoteSettings] Notify[], NotifyEmail); the audit name system.migration.remote records changes to remotes.
  • Guide: Remotes and deploys, Remote deploy, in English and German.

Tests

  • expMigrationRemoteDeployStagedTest (no database: stages, gates, rollback, the records and the notices); expMigrationJobTest, expMigrationCommandContractTest, expAuditRecordTest and AdminContentSecurityPolicyTest cover the new options, the audit name and the pages.

2026-11-02: jQuery 4 without jQuery Migrate

Upgrading: nothing to do. A design or extension of your own that still needs a removed jQuery API switches Migrate back on with ezjscore.ini [eZJSCore] LocalScripts[jqueryMigrate]=jquery-migrate-4.0.2.min.js (and ExternalScripts[jqueryMigrate] for the CDN), in an override.

Updated

  • Updated: ezjsc::jquery loads jQuery 4 without jQuery Migrate: LocalScripts[jqueryMigrate] and ExternalScripts[jqueryMigrate] of ezjscore.ini are empty. Every admin siteaccess (admin, adminui, admin2, admin4, admin4l, editor) was checked page by page, including the edit forms, the online editor's dialogs, the multi-object edit, the priority drag and drop and the layouts editor: no Migrate message, and a static scan of the admin designs and the extensions they load found no removed API. Migrate 4.0.2 is still shipped, and the comment in ezjscore.ini names the settings that switch it on again.
  • Guide: jQuery in designs, in English and German.

Tests

  • ezjscoreVendoredLibrariesTest accepts an empty jqueryMigrate as long as both settings are empty and the comment names the shipped release.

2026-11-02: the DS Editor's Change database opens the database chooser

Upgrading: nothing to do.

Updated

  • Updated: Once the warning was confirmed in the session, the DS Editor's (sevenx_dse) Change database link leads to the database chooser instead of the warning again: the site's database with one click, the logins remembered in the session with the current connection marked, and the login form with every driver for any other database. Feature, in English and German.

Tests

  • SevenxDseDatabaseChooserTest (no database): the redirect, the chooser template, the login list without passwords and the translations of every catalogue.

2026-11-03: exp:checkdbfiles and the database check scripts compare with a git tag, not svn

Upgrading: nothing to do. On an installation that is not a git checkout, run exp:checkdbfiles --no-verify-branches.

Updated

  • Updated: exp:checkdbfiles compared the update files of the released branches with an export from svn.ez.no, which is gone, so every run without --no-verify-branches ended with a C line. It now compares them with a release of the kernel's own git repository (git archive): --compare-with=<tag or commit>, by default the newest tag v<major>.<minor>.<patch>. Without a git checkout or a tag it says so and stops. Compared with v6.0.14 it reports, as C, the MySQL update files of older branches changed since then on purpose (the storage engine is set with default_storage_engine).
  • Updated: bin/shell/checkdbupdate.sh takes the schema of the release it starts from from the git tag v<version> instead of an svn export; bin/shell/checkdbschema.sh drops its unused svn addresses.

Tests

  • CheckDbFilesVersionListTest: no svn left in the command and the two scripts, and exportGitRelease() extracts update/database of a commit and refuses an unknown one.

2026-11-02: eZSection::fetchByIdentifier() returns the section of the identifier

Upgrading: nothing to do.

Updated

  • Updated: eZSection::fetchByIdentifier() returns the section of the identifier, not an older one kept under its id. The sections read in a request are cached by id and by identifier; a section read by its identifier was only kept when nothing was cached under its id yet, so a section that had changed its identifier, or an id given again to a new section after another was removed (SQLite hands out the next free id), returned the other section. The migration section step then refused with "another section has the identifier ...". The row read now replaces a cached one whose identifier differs.

Tests

  • The migration tests forget the cached sections when they switch the database (expMigrationRunnerEngineChecks), as they already did for the languages; the structure step tests pass on SQLite again.

2026-11-02: user management from the console

The exp:user commands manage accounts from a shell, a deploy script or a CI job the way the administration does: exp:user:list, show, create, edit, disable, enable, delete, reset-password, pending and approve. A new account is published with the content/publish operation, so its workflows and the approval of new accounts run; disabling is the setsettings operation and ends the account's sessions; a registration is approved or rejected as Users > Unactivated and the approval item do. Every change reaches the audit and the user audit as in the administration. Every changing command is a dry run unless --apply; a password is never taken on the command line (--password-stdin, or generated and shown once); the anonymous user, the administrator accounts, the user who acts and the last administrator are never disabled or removed. --format=json prints exp-user/1.

Upgrading: nothing to do.

Added

  • Added: The commands exp:user:list, exp:user:show, exp:user:create, exp:user:edit, exp:user:delete, exp:user:disable, exp:user:enable, exp:user:reset-password, exp:user:approve and exp:user:pending, with their entry points in bin/php and the generated reference pages in English and German.
  • Added: expUserManager (kernel/classes/user/expusermanager.php), the work behind the commands.
  • Added: The guide User management from the console, in English and German.

Updated

  • Updated: The services expuser and expaccount check a new login, e-mail address and password and send the password reset link through expUserManager, so the console and the services apply the same rules.
  • Updated: The user hygiene list takes the console's filters enabled and unactivated and a list of account ids; the page itself is unchanged.

Tests

  • expUserCommandContractTest (UC-01 to UC-10, no installation needed), expUserManagerSqliteTest and expUserManagerDatabaseTest (UE-1 to UE-7 on SQLite, MySQL/MariaDB and PostgreSQL).

2026-11-03: user management from the console, the second part and the help manual

Fourteen more exp:user commands complete the family: groups (the group tree; create, rename, move, remove an empty group), roles (list, assign and unassign roles of users and groups, with a subtree or section limitation), sessions (list and end tracked sessions), unlock, 2fa (show, reset the second step for a lost phone, remove passkeys), require-password-change, resend-activation, export (CSV or JSON, never a password or hash), import (each account created as exp:user:create does; a password column is refused), bulk (disable, enable, delete, approve, reject or unlock over a filter; --apply needs --confirm with the count the dry run showed), audit, hygiene, whoami and check-login. Each does what the administration does, through the same kernel paths and the same audit events. The help center has a manual for them in English and German.

Upgrading: nothing to do.

Added

  • Added: The commands exp:user:groups, exp:user:roles, exp:user:sessions, exp:user:unlock, exp:user:2fa, exp:user:require-password-change, exp:user:resend-activation, exp:user:export, exp:user:import, exp:user:bulk, exp:user:audit, exp:user:hygiene, exp:user:whoami and exp:user:check-login, with their generated reference pages in English and German.
  • Added: expUserGroupManager and expUserBulk (kernel/classes/user/).
  • Added: The help manual User management from the console (command groups, FAQ, troubleshooting, tasks such as onboarding and offboarding, audience pages), the feature page User management from the console, the specification exp:user JSON output and exit codes and glossary terms, each in English and German.

Updated

  • Updated: The guide User management from the console and the help pages on members, editor and administrator accounts, the user audit log, accounts and sign-in and user hygiene point to the console commands.

Tests

  • expUserCommandContractTest UC-11; UE-8 to UE-14 in expUserManagerSqliteTest and expUserManagerDatabaseTest.

2026-11-03: the website sidebar's Export buttons work while "Verify it's you" waits (expwt)

Upgrading: nothing to do; update the expwt extension.

Updated

  • Updated: In the website sidebar's Export panel, the "Fields as a file" buttons (Markdown, Word), Create link and Withdraw show a busy state instead of turning grey, always come back and say a failure. While "Verify it's you" waits in its window, a row under the buttons brings the window back or cancels it, and a second action waits for the same window instead of being left hanging (expwt bb5ba8d).

Tests

  • tests/js/export.test.js of expwt: the busy state, the waiting row, Cancel and one window for two actions.

2026-11-03: the header search takes the section from the search's view parameter

Upgrading: nothing to do.

Updated

  • Updated: The scope popup of the header search (every administration design) preselects the section of the current search, /content/search/(text)/<text>/(section)/<id>, from the view (search_section_id), and the page layouts key their cache on it. No template reads SectionID from the query string any more.

Tests

  • AdminSearchTest::testTheHeaderSearchTakesTheSectionFromTheViewParameter; Admin4lReviewTest AL-R1 and AdminStoredValueEscapingTest check the new cache key.

2026-11-03: fixes from the release audit

Upgrading: a monitoring check that matches the text of /ezinfo/is_alive must accept Exponential is alive (ezinfo/is_alive answers "Exponential is alive"). A bookmark of the user audit log or the account check with filters in a query string (?user=..., ?filter=...) opens the unfiltered page.

Updated

  • Updated: /ezinfo/is_alive answers Exponential is alive (was eZ Publish is alive); without a database it still answers No connection.
  • Updated: User audit log (user/audit), Check user accounts (user/hygiene), the security activity (user/security) and "Verify it's you" (setup/reauth) take their filters as view parameters, /user/audit/(user)/<login>/(from)/<date>, never as a query string. The filter forms are posted and answered with a 303 to that address; the scope of "Verify it's you" is chosen by a link per siteaccess.
  • Updated: "Verify it's you" asks on user/hygiene for the bulk actions and the CSV only (Rules[]=user/hygiene DisableButton EnableButton UnlockButton ConfirmButton ExportButton), so filtering the list does not ask.
  • Updated: The JSON endpoint the module code generator writes (Setup > Database tables) reads /(offset)/, /(limit)/ and /(<field>)/ instead of a query string.
  • Updated: Four calls that threw ArgumentCountError on PHP 8 when reached: eZMySQLiDB::escapeString() before a connection, the MySQLi cluster backend's error report without a connection, adding a product with option sets to the basket, and the eZCodePageCodec constructor.
  • Updated: The community activity block of the dashboard tags its links with the campaign "Exponential Community Project".
  • Updated: CI fails when the autoload generation fails; its GitHub actions run on Node 24 (actions/checkout@v7, actions/cache@v6, actions/setup-node@v7, actions/upload-artifact@v7).
  • Updated: PHPStan reports baseline entries that match nothing any more (reportUnmatchedIgnoredErrors: true).

Removed

  • Removed: 109 entries of phpstan-baseline.neon the code no longer needs.

Tests

  • ViewsTakeViewParametersTest, eZMySQLiDBEscapeStringWithoutConnectionTest; expReauthRulesTest checks the hygiene rule. eZExtensionWithOrderingTest, expRequestLogRequestEndTest and eZExecutionPerRequestRegistrationTest leave PHP's exception handlers as they found them (no risky tests); eZImageCopyOwnFilesTest keeps the object's name for its second version; five tests raise no deprecations on PHP 8.5.

2026-11-03: the archive library is Exponential's own fork

Upgrading: nothing to do; composer update installs se7enxweb/expcomponents-archive, which replaces zetacomponents/archive.

Updated

  • Updated: Package import, export and upload use se7enxweb/expcomponents-archive 1.5.2, Exponential's fork of zetacomponents/archive 1.5.1 (the same ezcArchive* classes), in place of the upstream package, which has had no release since 2023. It prints no deprecations on PHP 8.4 and 8.5 (implicitly nullable parameters, missing tentative return types, substr() given null, octdec() given padding), extracts GNU tar entries with long names or long links again, and its own test suite runs on PHP 7.4 to 8.5.

Upgrading: Velocity no longer sends Strict-Transport-Security unless [HTTPSSettings] HSTSMaxAge is set in settings/override/velocity.ini.append.php; an installation that relied on the 300 seconds shipped before sets it there.

Updated

  • Updated: Setup, RAD survey (/setup/rad) no longer fails with an error page when a directory below extension/ cannot be read by the web server user: the survey leaves it out. The settings and template walks of the content check and the help examples generator skip such a directory the same way.
  • Updated: expContentPreviewLink::tokenFor() gives the token of a share link only to the user who made the link or who may share its version, as its description always said; anybody else gets null.
  • Updated: On SQLite, a table whose extra unique key is the one the shipped schema gives it (the session's token_hash, exp_secret.name, ezcontentobject.remote_id, ezuser.login, the primary key of ezcontentobject_attribute) is still written without REPLACE, but no longer logs a warning that asks to rebuild the table. A key that the shipped schema does not have is still reported.
  • Updated: A request whose browser went away (a closed server-sent event stream) no longer writes "Fatal error" without a cause to the error log and renders no error page; a notice says the client closed the connection.
  • Updated: The administration sign-in page (design/admin) no longer redefines the view's passkey_options variable, which logged "Variable 'passkey_options' is already defined" on every sign-in page.
  • Updated: [HTTPSSettings] HSTSMaxAge in velocity.ini ships as 0 (no header) with a generic comment, since a browser keeps HSTS after it is lowered; the documentation says to start at 300 in settings/override.

Tests

  • expVelocityEnginesTest::testTheDefaultResponseHeadersAndHstsReachTheServer expects no HSTS by default.
  • Checked by hand: the RAD survey's source walk as the web server user with an unreadable extension directory; the share link token refused to a user who did not make the link.

2026-11-03: view counts for multi-site installations

Upgrading: nothing to do for a single site; logfile.ini [AccessLogFileSettings] is read as before, and the first run takes over the old updateview.log. To count several siteaccesses in one run, set [ViewCountSettings] SiteAccessList[] or AllSiteAccesses=enabled (View count statistics). Counts are lower for the same traffic on purpose: robots, errors, redirects, files and module pages are no longer counted (View counter 6.0.15).

Why: the cronjob kept one resume file for whatever log it read, found its place by the text of the last line, counted the front page for the tree root and every line of a shared log for every siteaccess, so installations with several sites, several logs or one log for several sites counted twice, never or for the wrong node, and rotation lost lines.

Added

  • Added: ./console exp:viewcount:update counts the page views of the access logs into ezview_counter, with --dry-run, --top (the most viewed nodes and the most requested paths that are no node), --all-siteaccesses, --siteaccess-list, --log-file, --state-dir, --from-start (dry run only), --skip-to-end, --reset-state and --format=json (exp-viewcount/1); exit codes 0, 1, 2 and 3 (a log was locked by another run).
  • Added: Multi-site counting: one run counts every siteaccess of [ViewCountSettings] SiteAccessList[] (or every non-administration siteaccess with AllSiteAccesses=enabled), each from the log its own logfile.ini names; siteaccesses sharing a log are read once and each line goes to the siteaccess the kernel's siteaccess matching finds for its host and path (MatchSiteAccess, [AccessLogFileSettings] Host, Port, Hosts[]).
  • Added: A resume state per log and per siteaccess (var/<site>/viewcount/*.json, StateDir), by inode and byte offset with a hash of the first line, written atomically after the database commit, with a lock per log (LockTimeout); rotated copies (RotatedSuffixes[], also compressed) are read to their end before the new log.
  • Added: logfile.ini [ViewCountSettings] decides what is a page view: Methods[], Statuses[], BotUserAgents[], SkipEmptyUserAgent, SkipExtensions[], SkipPathPrefixes[], ViewModes[], MaxLineLength, LineRegexp.
  • Added: eZViewCounter::addCounts() adds the views of many nodes in one transaction; eZSiteAccess::matchAddress() finds the siteaccess of an address without the side effects of a web request.
  • Added: The guide View count statistics (German), the behavior note View counter 6.0.15 (German) and the generated pages of the command and of [ViewCountSettings] in both languages.

Updated

  • Updated: The cronjob updateviewcount.php is the same work as the command: "/" counts for the siteaccess's IndexPage node instead of the tree root; a path is looked up with its PathPrefix first and then without one (PathPrefixExclude[] respected, the siteaccess's own PathPrefix when logfile.ini names none); SitePrefix[] removes a whole first path element only; /content/view/<any view mode>/<node>, URL encoded UTF-8 paths and view parameters reach their node; aliases are looked up in each siteaccess's languages; lines are read by a regular expression for the common, combined and vhost_combined formats (IPv6, escaped quotes, fields after the user agent, no client address) instead of being split at quotes, and are no longer cut at 1023 bytes; all views of a run are written in one transaction and nodes are checked 500 at a time.
  • Updated: The cronjob's description no longer speaks of an "in-memory view count buffer", which never existed; it says that it counts the page views of the access logs.
  • Updated: The state of the earlier cronjob (updateview.log, or the per-log updateview-<log>.log of the multi-site prototype this work builds on) is taken over on the first run: after its last line, or by its time when the line is no longer in the log.

Tests

  • tests/tests/kernel/classes/viewcount/: the parser (formats, IPv6, quotes, malformed lines), the filter (methods, statuses, robots, files, module paths, the shipped settings), the path resolver (IndexPage, view modes, PathPrefix and its fallback, PathPrefixExclude, UTF-8, view parameters, SitePrefix, languages), the reader and state (resume, a line being written, rotation by rename, copytruncate and gzip, truncation, long lines, two logs, two siteaccesses in one log, atomic state, the lock), the updater without a database (single site, dry run, shared log, hosts, a failed write, the old state taken over, the maintenance options, the report), the siteaccess matching, the configuration, and expViewCountSqliteTest counting into ezview_counter on SQLite.
  • Checked by hand on the demonstration server: dry runs of every siteaccess over an Apache and a Velocity log, and one real run into a copy of the database (616 views added, none on a second run, the live table unchanged).

Upgrading: nothing to do. [linkCheckSettings] MaxRunTime (cronjob.ini) is new, 900 seconds by default.

Updated

  • Updated: The help center lists the documentation of the extensions that are active only in a siteaccess (ActiveAccessExtensions[], such as expai and sqliimport) again: their siteaccess settings were read from a path the settings reader does not accept, so the list was always empty and exphelp/extensions/<name> answered 404.
  • Updated: The link check (linkcheck.php, part infrequent) stops checking after [linkCheckSettings] MaxRunTime seconds (900 by default; 0 checks until all are done). The links left are the ones checked most recently, and the next run starts with them. A run with many links to one host took most of an hour in HostDelay alone.
  • Updated: The audit dashboard keeps the figures of the six days before today for an hour (only today's are counted afresh), keeps the index's record count for ten minutes, and counts today's records by the time index on SQLite. On alpha (800 000 records) a page without cache went from 9 to 12 seconds to about half a second, and once an hour the past days are counted again.

Tests

  • expLinkCheckTest::testFileSettingsAndRecheck covers MaxRunTime and Linkcheck::overTime().

2026-11-03: the Exp Debug bar's User and Rendering tabs with sub-tabs, the session; the request and the user in the bar

Upgrading: whoever gets the debug output now reads the bar's settings, lists and caches without the policies; a site that gives the debug output to visitors it does not trust with its configuration sets debugbar.ini [DebugBarSettings] ReadAccess=privileged.

Added

  • The User tab shows the user's own PHP session in its sub-tab Session, loaded only when it is first seen (bar open, User tab and sub-tab shown; the answer Cache-Control: private, no-store), by the new server function expdebugbar::session: the keys of $_SESSION as a table per group (the kernel and others first, then each active extension whose name starts its keys), with type, size as stored and value; nested arrays and objects open into indented rows, short ones read on one line, a search finds keys and values. Values of keys that look like secrets are masked, with everything below them (debugbar.ini [DebugBarSettings] SessionMask[]: token, csrf, nonce, secret, passw, hash, key$, credential, auth, jwt, bearer, otp, saml, assertion, ticket, sid$). Below, the frame: started, cookie name, the session id shortened to four characters, handler, lifetimes from PHP and site.ini [Session], the cookie's parameters; the save path and the handler class for setup/setup only. At most 8 levels, 200 children and 3000 entries, keys not listed counted; keys and texts that are not UTF-8 as hex. The masks hide secrets, not personal data. Only ever the session of the request that asks, for whoever sees the User tab. See the Debug bar.
  • expDebugBarSession (kernel/classes/debugbar/expdebugbarsession.php).
  • Added: Session values are also masked by what they look like, whatever their key is called: a JSON Web Token, the credentials of an Authorization header, a PEM private key, well-known API key prefixes, 32 or more hex digits, a long random text; a key that looks like a token is shortened and masked with everything below it. The badge says why. More patterns: debugbar.ini [DebugBarSettings] SessionValueMask[].
  • Added: "Copy as text" copies the session as path = value lines, built on the server from the masked entries.
  • Added: debugbar.ini [DebugBarSettings] SessionWarnBytes (65536): a larger session is marked large in the overview and the sub-tab with the reason; a hint names PHP's locking file handler when session.save_handler=files.
  • Updated: expdebugbar::session is asked for with POST (with the form token when signed in), refuses a request the browser marks as sent by another site (Sec-Fetch-Site), and every answer, a refusal included, carries Cache-Control: private, no-store, max-age=0, Pragma: no-cache and Expires: 0, so no browser, proxy or Velocity response cache keeps it. The sub-tab panels take the focus (tabindex="0").
  • All 156 catalogues have the new words of the debug bar's session and sub-tabs.

Updated

  • Updated: The User tab opens with a compact overview (user, logged in, siteaccess, the session's number of keys and size, the cache profile against anonymous visitors, the number of role assignments) and has four sub-tabs: Cache profile, Preferences, Roles and policies (the policies as before, for setup/setup), Session. The sub-tab shown last is kept, the arrow keys, Home and End move between them, the filter box counts the matches of each; the explanations are folded behind "?".
  • Updated: The Rendering tab opens with a one-line overview (the module view, the content view cache in a word, the cache blocks read from the cache, the templates rendered and compiled) and shows its parts as sub-tabs like the User tab: Controller, Template tree, Content view cache, Cache blocks, Templates; the explanations folded behind "?". expdebugbar.js handles the sub-tabs of every tab (data-scope; localStorage exp-debug-<tab>-subtab and exp-debug-<tab>-help).
  • Updated: "Keep open on reload" reads "Keep open" in the bar of the default theme, the whole words as its tooltip, so the bar wraps later.
  • Updated: The bar starts with the request in every theme, not only in the toolbar theme: the HTTP status and module/view, green, amber for a redirect (3xx but 304 Not Modified), red from status 400 (before: amber from 400, red from 500); a click opens the Rendering tab (before: the Server tab), the Server tab for users who do not see it.
  • Updated: Whoever gets the debug output (DebugByIP, DebugByUser), logged in or not, reads everything the bar shows: the settings with their values and file paths, the IP and user lists, the change log and the IP test, the cache list, the policies of their roles, the session's save path and handler class, the User and the Rendering tab with their chips, the versions of PHP and Exponential, OPcache and APCu, the process and the host. Changing a setting still needs setup/setup, clearing a cache setup/managecache, with the form token; a reader sees the controls disabled and the cache list without its buttons. The new debugbar.ini [DebugBarSettings] ReadAccess decides it: debug, the default, or privileged for the rule before (expDebugBarRegistry::readAccess(), readsAll()): the settings, the log and the policies for setup/setup, the cache list for setup/managecache, the versions, OPcache and APCu for either, the User and the Rendering tab for setup/setup and an anonymous visitor with debug output. See the Debug bar.
  • Fixed: A reader by the debug output (ReadAccess=debug, no setup/setup) gets nobody else's data: the change log comes without the user id, login and address of whoever made each change, the IP and the user list are masked as secrets, the IP test answers for the reader's own address only without the lines of the list, and the summary's matched IP line stays with setup/setup (expDebugBarServerFunctions::readsWithoutPolicy(), forReader(), listsMasked(), ownIpAnswer()). The guide warns that debug output on a public address shows every visitor their own session and recommends ReadAccess=privileged in production.
  • Fixed: Class icons an extension maps to a file the icon theme does not have (CJW Newsletter's crystal paths under bootstrap-icons in admin4) fall back to the theme's own mapping of that class instead of losing the icon (eZWordToImageOperator::themeOwnIcon(), themeOwnMap()); bootstrap-icons maps the six CJW Newsletter classes; a figure row that wraps keeps its height instead of overlapping the text below it.
  • Updated: The user chip names the login after a person icon (admin · VC 3f9ac21e).
  • Updated: The guide, feature page and server functions of the debug bar describe it, in English and German; ger-DE and eng-US have the new words.

Tests

  • expDebugBarSessionTest (US-01 to US-06), expDebugBarUserTest::testTheChip, expDebugBarUserPoliciesTest::testWhoMaySeeTheTab (UP-05, both values of ReadAccess) and testReadAccess (UP-11: the setting, readsAll(), the cache list without its form for a reader), testAReaderByTheDebugOutputGetsNobodyElsesData (UP-12), eZWordToImageOperatorDesignThemeTest, expDebugBarReportToolbarThemeTest (the request in every theme, the new colours, 304 and 1xx), expRenderStatsTest::testTabEscapesCutsAndMarks (the overview and the sub-tabs of the Rendering tab), expDebugBarReportPanelHeightTest (the shorter label).

2026-11-03: the tables of active extensions on upgrade, site packages without extension packages

Upgrading: run ./console exp:upgrade. The new database step 6.0.15-active-extension-tables creates the missing tables of every active extension that ships a share/db_schema.dba, and does nothing where they are there.

Added

  • Added: Upgrade step 6.0.15-active-extension-tables (expUpgradeStepActiveExtensionTables, database stage, every engine). For each extension that is active globally (ActiveExtensions[]) or in a siteaccess (ActiveAccessExtensions[]) and ships a share/db_schema.dba, the step creates the tables of that file that the database lacks, through the engine's schema handler, as a new installation does after its site package. Nothing is dropped or changed and no share/db_data.dba is loaded. The dry run shows the SQL. On alpha the step reports every table of the 14 active extensions with a .dba as present.
  • Added: Extensions of a new installation (German too), the one list of the extensions of 6.0.15. For each extension it gives where it comes from (kernel tree, root composer.json, php-floor), who activates it on a new installation (the kernel, the administration, each site package), whether it has tables, and where those come from. The extension pages, the distribution page and the multisite page link to it.

Updated

  • Updated: The site packages no longer require the extension packages ezwt_extension, ezstarrating_extension and ezgmaplocation_extension (sevenx_site, ezwebin_site, ezflow_site, ezdemo_site and their _clean copies). Composer installs ezstarrating and ezgmaplocation, and the installers still activate them. Their tables come from each extension's own share/db_schema.dba. The installers of every site package activate expwt, the website sidebar, in place of ezwt. The rebuilt packages are published on the package server with the first expwt release. See Site packages no longer install extension packages.

Tests

  • expUpgradeActiveExtensionTablesTest (SQLite, fixture extensions under var/tmp) covers detection, the planned SQL, creation with the columns and keys of the .dba, a half-applied state, a second run that does nothing, no applicable extension, and the installer's ensureExtensionTables() making the same tables. With EXP_TEST_EXTENSION_ROOT it also checks the Composer copies of ezstarrating and ezgmaplocation.
  • Fresh installs in a throwaway sandbox (SQLite, exp:install, the rebuilt packages, no extension package in the repository) of sevenx_site, sevenx_multisite, cjw_multisite, ezwebin_site and ezflow_site completed with 0 setup errors. Each install has expwt, ezstarrating and ezgmaplocation active and ezwt inactive, and has the tables ezstarrating, ezstarrating_data and ezgmaplocation. The Composer copies of the three extensions were left unchanged.

2026-11-03: compiled {foreach} and {for} go on after a template includes itself

Upgrading: clear the template cache (php bin/php/ezcache.php --clear-tag=template) so that compiled templates are generated again; templates compiled before keep the fault until then.

Fixed

  • Fixed: A {foreach} or {for} whose body includes the same template again (a tree or a menu that renders its children with {include}) stopped after the first item that recursed when templates were compiled (TemplateCompile=enabled); every other item of the outer loop was dropped. Interpreted templates were right. The compiled code keeps a loop's state in variables named after the loop, and a template that includes itself runs the same code in the same scope; the state was set to empty values before it was saved, so the outer loop came back empty. The state is now saved first, and only while a loop of the same code runs, so compact() never names an undefined variable (eZTemplateForeachFunction, eZTemplateForFunction). The variables of a sequence are saved with it: with a sequence the recursive loop ended in a fatal error (next(): Argument #1 ($array) must be of type array, null given). The workaround until then was {section}, which was not affected.
  • Fixed: A compiled {for} removes its loop variable after the loop, as an interpreted one does; the code for it was never reached before. Its loop variable keeps the last value of the loop, as interpreted, not the one past it (seen after an {include} of the same template, whose loop shares the template variables). The template variable of a sequence stays until the outermost loop of a template that includes itself ends, as interpreted.

Tests

  • eZTemplateCompiledLoopRecursionTest LR-01 to LR-04: {foreach} and {for} with a recursive {include}, deeper trees, reverse, offset, a loop after the recursive one, sequence, {delimiter}, {break} and the loop variables read after the include, interpreted and compiled, without template errors and PHP notices.
  • Fixed: A compiled {while} or {do} with a sequence whose body includes the same template again ended in a fatal error (next(): Argument #1 ($array) must be of type array, null given): the include set and then removed the sequence's variables of the loop outside. They are saved and restored around the loop now, as for {foreach} (eZTemplateCompiledLoop::saveSequenceState(), restoreSequenceState()).
  • Fixed: A compiled {include} (and {tool_bar}) in a template that includes itself restored the parameters of the wrong level after the third level: the list of variables to restore was kept in one variable that the included copy overwrote, so the outermost level got the values of the one below it. It is a stack now.
  • Fixed: A compiled {cache-block} whose body includes the same template again (a tree that caches each node with keys of its own) stopped with Call to a member function storeCache() on null and was never stored: the inner block set and removed the variable that held the outer block's cache handler. The handler is kept on a stack until the block's text is stored.
  • eZTemplateCompiledLoopRecursionTest LR-05 to LR-07: {while} and {do} with a sequence, {section} with a sequence and a delimiter, {skip} and {continue}, a {for} inside a {foreach}, and include parameters at four levels; eZTemplateCacheBlockRecursionTest CB-01: a cache-block tree rendered and read back from the cache. The tests set a siteaccess name when none is set, so they also pass run on their own.

2026-11-03: the datatypes of the installation, the companion of the class groups

Added

  • Added: Setup > Datatypes, /class/datatype/list, read only, beside the class groups (a Datatypes button on /class/grouplist, an entry in the group "Content model" of the Setup menu). Every datatype that content.ini [DataTypeSettings] AvailableDataTypes[] registers, grouped by provider (the kernel, then each extension), with its name, PHP class and file, the number of classes, class attributes, published objects and their object attributes that use it, and its flags (translatable, searchable, collects information, sortable with its sort key type, relations, from text, from file, packages). The datatypes the database uses but nothing registers any more are listed apart in red with a notice (their content cannot be shown, edited or published); so are listed datatypes whose file is missing. Filters as view parameters (/(filter)/used|unused|orphans, /(group)/<provider>), a search and sortable columns (page/class_datatypes.js, CSP safe), the figures above the list. On alpha: 50 datatypes, 36 in use, 12 extensions providing datatypes, 920 class attributes, 196,224 object attributes.
  • Added: /class/datatype/view/<identifier>: one datatype with its provider, class, file and flags, the columns of its value and of its class attribute settings and the tables its code names (read from its code), its view, edit, collect and class templates resolved for the administration and the default siteaccess, the classes and class attributes that use it with their set settings and their objects, the ten last modified nodes of those classes and its help page.
  • Added: expDataTypeInventory (kernel/classes/expdatatypeinventory.php). The counts are two grouped queries over ezcontentclass_attribute and ezcontentobject_attribute (current version of the published objects) on SQLite, MySQL, PostgreSQL and Oracle, aggregation pipelines on MongoDB, kept five minutes in the cache directory (the second query takes 2.5 s on alpha's 3 GB SQLite database); Count again counts at once.
  • Added: The guide Datatypes (German too), the context help of class/datatype, the strings in every catalogue.

Tests

  • expDataTypeInventoryTest (no database): the list built from a registry and counts of its own (groups, states, orphans, files missing, figures, filters), the view parameters, the location and provider of a datatype file, the flags of a datatype object and the columns and tables read from its code.
  • expDataTypeInventorySqliteTest (SQLite in memory): the grouped counts of defined classes only and of the current version of published objects only, per class attribute, an unregistered datatype as an orphan, upper case column names as Oracle returns them.

2026-11-03: compiled {do} with {continue}, transactions, buffers and handlers on error paths

Fixed

  • Fixed: {continue} or {skip} in a compiled {do} loop jumped back to the top of the body without testing the {/do while} condition, so a loop whose last iteration ran {continue} went on (or never ended). The compiled loop tests the condition at the top of every iteration but the first now, as the interpreter does (eZTemplateDoFunction::templateNodeTransformation()).
  • Fixed: A transaction that an exception left open between begin() and commit() stayed open: the counter stayed raised, every later commit() of the request only lowered it, and eZDB::checkTransactionCounter() committed the half done work at the end; in a persistent worker of Exponential Velocity the next request on the same connection ran inside it. The web kernel now rolls it back when a module throws (before the cleanup handlers write the session), a persistent worker rolls back what the request before left open at the start of each request (expKernelServiceScope::resetRequest()), and eZDB::transaction( callable ) runs work in a transaction that is rolled back when the work throws. New: eZDB::rollbackOpenTransaction().
  • Fixed: 445 begin() ... commit() pairs of the kernel and lib (content publishing, object, node and version store and removal, relations, translations, sections, user login attempts and logout, workflows, roles, states, URL aliases, packages, migrations and the rest) roll the transaction back and throw the exception on when the code between them throws, instead of leaving it open for an outer commit(). Only the exception path changed; pairs whose commit depends on a condition (48, such as the SQLite serial id transaction of eZPersistentObject::storeObject()) are covered by the request level rollback above.
  • Fixed: In the 51 namespaced files among them (the console commands and the module views) the rollback caught Throwable of the file's own namespace, a class that does not exist, so it never ran: it catches \Throwable now.

Tests

  • eZTemplateCompiledDoContinueTest: {continue} and {skip} inside an {if} and as a direct child, {break}, a false condition, a sequence and nested {do} loops, interpreted and compiled.
  • eZDBTransactionExceptionTest TX-01 to TX-05: eZDB::transaction() commits and rolls back, a nested one rolls the outer transaction back, eZDB::rollbackOpenTransaction(), two requests of a persistent worker through expKernelServiceScope::resetRequest(), and the web kernel's rollback before its cleanup handlers.

2026-11-03: search suggestions and the filtered search page on the sites

Added

  • Added: The search field of a site suggests content while the visitor types, as the administration's header search does: content/sitesuggest/(text)/<text> (expSearchSuggest::siteSuggest()). Only content objects the user may read (the anonymous user: what is public), of each only its name, address, class name and a small image; never user accounts, content classes, administration pages or settings; on a siteaccess with a PathPrefix only its own tree. Rate-limited per address (429 with Retry-After). An anonymous answer is kept PublicCacheTTL seconds, keyed by text, siteaccess, languages and user, and sent Cache-Control: public; a signed-in user's answer is private, no-store. Policy: content/read. Settings: site.ini [SiteSearchSuggestSettings], on by default for every siteaccess.
  • Added: design:parts/front_search_suggest.tpl, the include a site design puts inside its header search form: the field becomes an ARIA combobox (Up, Down, Enter, Escape, Ctrl+/), drawn by stylesheets/front_search.css with --exps-* tokens and their dark values. javascript/page/search_suggest.js serves the administration and the sites (the class prefix and the wiring come from the list's box).
  • Added: design:content/front_search_page.tpl, the filtered search page for a site design: a design's content/search.tpl that includes it gets the administration's filtered search (view parameters, the form posted and answered with a 303, content type and sort filters, chips, "Did you mean", pages), with the results as the design's line views or its own result template.
  • Added: The help page "Searching" (English and German): suggestions and the search page on the site, and their settings.

Tests

  • expSiteSearchSuggestTest SSS-01 to SSS-06: the settings and their bounds, an item carries nothing of the account and no user or excluded class, a short text asks nothing, the view's declaration and cache key, a site's search template is the filtered page, the includes have no inline CSS or script and reuse the administration's strings.

2026-11-03: clean urls on every new installation

Fixed

  • Fixed: An installation from the setup wizard, the kickstarter or exp:install writes [SiteAccessSettings] ForceVirtualHost=true to settings/override/site.ini.append.php when the web server rewrites addresses (always on the command line, otherwise when the wizard was reached without index.php or the login page answers without it). ezwebin_site, ezflow_site, ezdemo_site and their _clean variants had no such setting, so every link of a new site came out as /index.php/<siteaccess>/...; sevenx and cjw_multisite already wrote it themselves.
  • Fixed: The siteaccess urls a site package installer builds (eZSiteInstaller::createSiteaccessUrls() without an index_file) leave index.php out when the installation runs with ForceVirtualHost=true.

2026-11-03: all the content of a datatype, a page at a time

Updated

  • Updated: /class/datatype/view/<identifier> lists all the content using the datatype instead of ten nodes: every main node of the classes using it that the user may read (the tree fetch with its read check, an object once), with name, class, section, languages, owner, modified and published. Sortable column heads (aria-sort) for modified (the default, newest first), published, name and class, both ways; 10, 25, 50 or 100 a page with the shared pager and the total; a class filter posted and answered with a 303. Everything is a view parameter (/(sort)/name/(order)/asc/(limit)/50/(class)/5/(offset)/50), never the query string; the count is kept five minutes per classes and read policy (expDataTypeInventory::nodePage()). Guide Datatypes (English, German).

Tests

  • expDataTypeInventoryTest: the view parameters of the list, their fallbacks, the addresses and sort links and the fetch parameters, without a database; expDataTypeInventoryNodesSqliteTest: paging, every sort both ways, the class filter, main nodes only and an offset past the end on SQLite with the kernel schema and clean data.

2026-11-03: site packages on PostgreSQL, captions without an active git_manager

Updated

  • Updated: The PostgreSQL schema reader (eZPgsqlSchema) leaves out columns of PostgreSQL's own types (tsvector, tsquery) and the indexes over them, instead of stopping with "Unhandled column type: tsvector". The audit log's full-text column (expaudit_event.search_tsv, added by engine-specific SQL) had made every site package install on PostgreSQL fail.
  • Updated: Captions (expUpgradeCaption, used by exp:upgrade and migration runs) use git_manager only when it is an active extension. An installed but inactive git_manager has no settings loaded, so it took the installation root as its backup folder and wrote its .htaccess ("Require all denied") and an empty index.html there, which left a new site answering with an empty page. git_manager itself no longer takes the root as its backup folder either.

2026-11-03: site packages on MongoDB

Updated

  • Updated: The MongoDB driver answers SELECT column AS name with the column under that name (and sorts by an alias as by its column). The projection used to ask for a field called "column AS name", so the row came back without it: the installer's check reported every class and attribute name as "stored without a language".
  • Updated: A MongoDB schema reader made from a connection (expMongoSchema without a .dba schema) lists the database's collections as tables. It answered none, so the installer reported every declared table missing and created every extension's tables again on each pass.
  • Updated: subTreeByNodeID() and subTreeCountByNodeID() apply the AttributeFilter on MongoDB (node and object fields, state, visibility and class attributes by their sort key; every comparison; "and" and "or"). The MongoDB path left it out, so a fetch for one group by name, or the events of a month, returned the whole subtree. A filter that cannot be applied there (class_name, an unknown operator) gives an empty result, never the whole subtree.

2026-11-03: locales for 22 Indigenous languages of the Americas

Added

  • Added: Locales for 22 Indigenous languages of the Americas, so each can be a content language: Central Yup'ik (esu-US), Inupiaq (ipk-US), Aleut (ale-US), Alutiiq (ems-US), Tlingit (tli-US), Haida (hai-US), Gwich'in (gwi-US) and Tsimshian (tsi-US) of Alaska; Navajo (nav-US), Cherokee (chr-US), Lakota (lkt-US), Ojibwe (oji-US, oji-CA), Cree (cre-CA), Inuktitut (iku-CA) and Mohawk (moh-CA); Nahuatl (nah-MX), Yucatec Maya (yua-MX), K'iche' (quc-GT), Guarani (grn-PY), Aymara (aym-BO) and Mapudungun (arn-CL). Cherokee and Lakota are generated from Unicode CLDR 44; the others are written by hand, with the formats of their country and, where no established names were found, English or Spanish month and day names, as each file's header says.
  • Added: The help page "Indigenous languages of the Americas" (help/government/languages/), in English and German: adding the language, translating content, addresses, the interface language, and fonts for Canadian syllabics and the Cherokee syllabary. "Languages in Exponential" lists the new locales and has a section on them.

2026-11-03: expwt in place of ezwt in the requirements

Updated

  • Updated: composer.json no longer requires se7enxweb/ezwt, the website toolbar. expwt, the website sidebar that replaces it, comes with exponential-php-floor 1.0.6, 1.1.8, 1.2.8 and 1.3.8 (each line, PHP 7.4 included); the floor requirement asks for those versions. The site designs follow: ezwebin 6.0.32, ezflow 6.1.17 and ezdemo 6.0.20, rebuilt as modern, responsive and accessible designs that use expwt's edit actions and page toolbar. Existing installations move with ./console exp:wt:migrate-from-ezwt --apply (or the upgrade step 6.0.15-expwt-enable).

2026-11-04: a shorter Dashboard menu

Added

  • Added: menu.ini CollapseAfter=<n> for a left menu drawn as one list: its first <n> links show, in the order drawn, and the rest follow in a "More (count)" disclosure, closed at first and open when it holds the current page. A native <details>, so it needs no script and works with the keyboard; every copy of parts/ini_menu.tpl (admin4, admin) reads it. Test: AdminLeftMenuGroupsTest.

Updated

  • Updated: the Dashboard tab's left menu shows its first 10 links (CollapseAfter=10), and Audit trail moves to the end, third to last, after the links extensions append and right before Change password and Edit profile (TrailLinks[]=audit).

2026-11-04: the guide Configuring Exponential 6.0

Added

  • Added: The guide "Configuring Exponential 6.0" (doc/eng-us/CONFIGURING.md, in German too), right after "Installing Exponential 6.0" in the help center: the settings layers and which file wins, the settings page and exp:ini, siteaccesses and their matching, languages, the database, mail, storage and clusters, sessions, caches, HTTPS and the security headers, the Content Security Policy, trusted proxies, two-step sign-in, "Verify it's you", secrets, roles, extensions and their tables, cronjobs, when a change takes effect under PHP-FPM and Velocity, debugging and logs, the help center and the website toolbar, with a troubleshooting table. "Installing" and "Updating" link to it.

2026-11-04: the guide Extending Exponential 6.0

Added

  • Added: The guide "Extending Exponential 6.0" (doc/eng-us/EXTENDING.md, in German too), right after "Updating Exponential 6.0" in the help center: the ways to extend and the parts of an extension, the RAD wizards, activation, autoloads and extension tables, modules, views with view parameters, policies and the left menus, templates and the rules of the administration designs, datatypes, workflow events, cronjob parts, console commands, GraphQL schema extensions, audit branches, secrets, translations, help pages, website toolbar panels, Exponential Layouts, tests and releases, with a troubleshooting table. "Installing" and "Updating" link to it.

2026-11-04: the guide Documenting an extension

Added

  • Added: The guide "Documenting an extension" (doc/eng-us/guides/documenting-an-extension.md, in German too): the convention for an extension's documentation, README.md and INSTALL.md on top of extension/<name>/ and every other page as Markdown (.md) in extension/<name>/doc/, in English; how documentation evolved from plain text files to Markdown, why Markdown, that plain-text pages still show, and an old plain-text document converted step by step; how the help center shows the pages, front matter, context help, what is never shown and how to check it.

Updated

  • Updated: Pages that mention extension documentation name its place, README and INSTALL on top and the rest in doc/, and link the guide. The extension development course, its reference and tutorial, and the extension anatomy page no longer say the help center needs ExtensionDocDirs[] to show an extension's pages: it shows every active extension on its own, and ExtensionDocDirs[] is only for an inactive one.

2026-11-04: the help index reads the help settings as they are on disk

Updated

  • Updated: exp:help index first removes a cached copy of exphelp.ini that is older than a file it was built from. With the INI file time check off (as config.php sets it), a landing that changed exphelp.ini and built the index before the INI caches were cleared stored the old settings in the index: the new help books "Configuring" and "Extending" went to the end of the help tree instead of their PageOrder[] place. The deploy, the landing and the background build all run this command, so none of them needs a manual rebuild after a settings change.

2026-11-04: the guide Integrating With Exponential 6.0

Added

  • Added: The guide "Integrating With Exponential 6.0" (doc/eng-us/INTEGRATING.md, in German too), after "Extending Exponential 6.0" in the help center: choosing a way in, the REST content API and OAuth 2.0, personal API keys and tokens, GraphQL, remote services, SOAP, WebDAV and syndication, packages, migrations, imports, exports, feeds and sitemaps, static publishing, GraphQL webhooks and the audit trail's signed webhook, mail and the job queue, LDAP and single sign-on, headless front ends with CORS and the HTTP cache, search, AI services, the console in CI, and security for integrations, with a troubleshooting table. "Installing" links to it; "Uninstalling" moves to order 16.

2026-11-04: the guide Distributions of Exponential

Added

  • Added: The guide "Distributions of Exponential" (doc/eng-us/DISTRIBUTIONS.md, in German too) in the help center, after "Integrating With Exponential 6.0": which distribution to choose, a comparison table, and for Exponential 6, Exponential Platform Legacy, Nexus (with its CJW and starter flavors), DXP, Platform 3.2.9 and Exponential Basic what each is and who it is for, its PHP versions and databases, kernel and administration, what it ships, how to install it, its newest release and the way forward; then the site packages of Exponential 6, the PHP floor packages, moving between distributions, demos and a troubleshooting table. Every fact was read from the distributions' own repositories. "Installing" links to the guide.

2026-11-04: the guide History of Exponential (1999 - 2026)

Added

  • Added: The guide "History of Exponential (1999 - 2026)" (doc/eng-us/HISTORY.md, in German too) in the help center, before "Branch history": a timeline, the family tree of the code, the takeover of the legacy kernel in 2023, every 6.0 release with its date, the rename to Exponential, the 6.0.15 line, the Exponential Foundation and 7x, the distributions and the community; then the history of the code before Exponential, release by release, from eZ Systems and eZ publish 1.x in 1999 through 3.x, 4.x, the Community Project, eZ Publish 5, eZ Platform and Ibexa, how the legacy kernel lived on until 2023, and the licenses, version numbers and names over time, with its sources. The history chronicle and the branch history link to it.

2026-11-04: a tab moved into Setup can keep its own left menu

Added

  • Added: menu.ini [Leftmenu_setup] GroupTabsLeftMenu (pull request #355). With tab, the pages of a tab that is moved into Setup (GroupTabs[] with the tab left out of [TopAdminMenu] Tabs[], in HiddenTabs[] or in SetupTabs[]) still mark the Setup tab but draw the tab's own left menu, the view's left_menu or parts/<part>/menu.tpl, in admin4 and in admin4l's left menu block. The default setup keeps the Setup menu with the tab's group open. design/admin and exp_adminui never move a tab into Setup and always draw the tab's own menu. The design tests render both templates for each case, and the admin4 design page explains the setting in English and German.

Updated

  • Updated: The generated help examples match their sources again (exp:help:examples --check), among them the page of [Leftmenu_setup].

2026-11-04: the help has its own left menu

Added

  • Added: menu.ini [Leftmenu_help] and design:exphelp/menu.tpl (pull request #356). The help views draw the help's own left menu (Help Start, Contents, Search the help, Extensions, Report an error, each for users with exphelp/read) instead of the Setup menu, while the Setup tab stays marked. A page the menu does not name marks the link it belongs to: a page of the viewer, a topic, a manual and the page tree mark Contents, an extension's page marks Extensions, an audience page marks Help Start. The help viewer's own page tree ("All help pages", with the books in their order) stays beside the page as before.
  • Added: the header's help control is marked on the help's own pages in admin4 (the "?"), design/admin (the Help link) and exp_adminui 1.0.0.62.

2026-11-04: the guide Getting Support

Added

  • Added: The guide "Getting Support" (doc/eng-us/SUPPORT.md, in German too) in the help center, after "Distributions of Exponential": the fastest path to an answer, helping yourself first (the help, the logs, the system card, the upgrade check, the facts a helper needs), turning a problem into one of PHP, Composer, the database, the web server, the operating system or the browser and asking in that layer's own communities, the volunteers of the Exponential community, the wider PHP ecosystem, the Exponential Foundation, and 7x for the fastest result with total product responsibility for the whole stack; security reports stay private. "Installing" and "Configuring" link to it from their troubleshooting; "Uninstalling" moves to order 18.

2026-11-04: the guide Exponential Community

Added

  • Added: The guide "Exponential Community" (doc/eng-us/COMMUNITY.md, in German too) in the help center, after "Security Policy": where to start, who the community is, every public website of the project with what it is and what runs it, the forums and chats, how to report a bug and a vulnerability, contributing code, translations, documentation and extensions, a start page for each role, releases, the roadmap and decisions, recognition and copyright, supporting the project, and Exponential elsewhere on the internet (the social media accounts and the sites by 7x that feature Exponential). CONTRIBUTING.md, README.md and the history book link to it.

2026-11-04: the guide Exponential Competition

Added

  • Added: The guide "Exponential Competition" (doc/eng-us/competition/, in German doc/ger-de/wettbewerb/) in the help center: Exponential compared with proprietary DXPs and SaaS, the PHP content management systems and frameworks, and the systems in every other language, for a site without a budget that must keep working for many years. It states the criteria (cost at zero funding, license freedom, exit, metering, hosting, upgrade stability, extensibility, multi-site and languages, security, data ownership), the values of free software, a summary matrix, and for each product its license and business model, what it does well, where it fails that reader and what Exponential does instead, each with its sources. Distributions of Exponential and Exponential Community link to it.

2026-11-04: a front siteaccess switches designs

Added

  • Added: A front siteaccess can let every visitor switch between designs with one click (site.ini [DesignSettings] SwitchableDesigns[], with SwitchableDesignLabel[], SwitchableDesignChain[] and DesignSwitchCookie). The view parameter /(design)/<name> on any address chooses a design and keeps it in a cookie; the siteaccess's own SiteDesign stays the default. Off by default and never on an administration siteaccess. A chosen design gets its own cache variant (design bases, compiled templates, {cache-block}, the content view cache); responses send Vary: Cookie, and a page in a chosen design is private. fetch( 'uiswitch', 'front_designs' ) gives templates the choices. Documented in doc/eng-us/features/6.0/front-design-switch.md, in German too.

2026-11-04: a site names the classes its search offers

Added

  • Added: site.ini [SiteSearchSuggestSettings] IncludeClasses[]: when it lists classes, only those are suggested by the site's search suggestions and offered in the content type filter of the search page (content/front_search_page.tpl). Empty, as shipped, keeps every class that ExcludeClasses[] does not exclude. For a site whose content tree also holds other sites' classes, such as a multisite package. Documented in "Searching on the site", in German too.

2026-11-04: the REST and GraphQL API of an installation served below a path

Updated

  • Updated: An installation served below a path (an Apache Alias such as /doc) answers its REST and GraphQL API at /doc/api/...: the REST request parser removes the installation's path in front of ApiPrefix, as it already removed index_rest.php in non-vhost mode. Before, every API address there answered "Not Found".

Updated

  • Updated: The links a REST answer writes (the fields of a node, its children) use https when the request came over HTTPS, also behind a proxy, and carry the path the installation is served below (an Alias such as /doc, or index_rest.php in non-vhost mode). Before, every link was http://<host>/api/....

2026-11-05: SQLite work waits for a busy database instead of failing

Updated

  • Updated: On SQLite a write outside a transaction (an INSERT of a link, an UPDATE of a cronjob) queues on the driver's writer lock like a transaction and waits up to [DatabaseSettings] SQLiteBusyTimeout ms in a web request (default 5000) or SQLiteCLIBusyTimeout ms on the command line (default 30000). Before, it lost every race against a loop of short transactions (an import's link pass) and failed with "database is locked", so an external link saved in the online editor could lose its URL id.
  • Updated: Cronjobs and scripts wait SQLiteCLITransactionWait seconds (default 300) for a transaction to start, not the 25 s of a web request; runcronjobs.php no longer stops with "A database transaction in Exponential failed" behind a long import. A lock SQLite reports at once (WAL recovery, a COMMIT waiting for readers) is tried again within the wait, and an error says the database was busy, how long the work waited and which setting sets the limit. Upgrade notes.

2026-11-05: generated passwords, keys and file names from the secure random source

Updated

  • Updated: eZUser::createPassword() draws every character from the password character table with random_int(). Before, it built the password from md5() of the time and mt_rand(). Length and table are unchanged; the $seed parameter is accepted and no longer used, so a password can never be reproduced from it. This is the password of the user/forgotpassword mail, of a user account created with a generated password, of bin/php/resetuserpassword.php and of the expservices generatePassword call.
  • Updated: The key of a forgot-password link is bin2hex( random_bytes( 16 ) ) (32 characters, as before). The openssl_random_pseudo_bytes() call and its mt_rand() fallback are gone.
  • Updated: ezpRestTokenManager::generateToken() returns a token of ezpRestToken::generateToken() (40 hex characters). Before, it returned the same value on every call. The kernel does not call it; an extension may.
  • Updated: Stored files of the file and media datatypes, files stored by eZHTTPFile::store() and the temporary file of the Word text extractor are named from random_bytes() instead of md5() of the time and mt_rand(). The names keep their form (32 hex characters and the suffix).
  • Updated: A user account the site installer creates for an ezuser attribute gets a random login, address and password. Before, its password was publish.
  • Added: Dependabot (.github/dependabot.yml: Composer, npm and GitHub Actions, weekly) and a CodeQL workflow (.github/workflows/codeql.yml) for the JavaScript of the repository and its workflows. CodeQL has no PHP analysis; PHPStan and the tests check the PHP code.
  • Updated: The MongoDB adapter's class and its limits page say that an empty result of untranslated SQL must be read as "no" by code that decides access, and why escapeString() returns the value unchanged.
  • Added: Tests tests/tests/kernel/classes/security/expRandomSourcesTest.php (no database).

Updated

  • Updated: When a subtree is copied, the links and embeds in the XML text of the copies that point inside the copied subtree are changed to point at the copies. The scan that did this skipped every link and embed that came before a literal (code) block in the same text, and an embed or object that came before a later link. A copy of 2,714 nodes kept 730 node ids in 189 XML texts pointing at the originals. The XML text is now parsed as XML: node_id and object_id of link, embed, embed-inline and a, id of object, and href values eznode://<node id> and ezobject://<object id> are re-pointed wherever they are; nothing inside a literal block is changed, ids outside the copied subtree stay, and a text with nothing to change is stored byte for byte as it was. This applies to the content job "copy" and to the direct copies (content/copysubtree, eZContentObjectTreeNodeOperations::copySubtree(), bin/php/ezsubtreecopy.php), which share expContentJobCopySubtree::remapXMLText(). Copies made before keep their links to the originals; copy again to get them right.
  • Added: Tests in tests/tests/kernel/classes/contentjobs/expContentJobCopyLinksTest.php (no database) with an XML fixture: links before, between and after literal blocks, several literal blocks, embeds, eznode:// and ezobject:// hrefs, ids outside the copy.

2026-11-05: cond gives its empty default without template compilation

Updated

  • Updated: The admin4 left menu and the Setup overview keep their groups when templates run without compilation (TemplateCompile=disabled): a group without GroupLinks_<group>[] starts from an empty list. Before, every link not yet placed went into the first such group ("Audit"). Contributed by Ekkehard Dörre (pull request #357).
  • Updated: cond takes its last parameter, the value when no condition holds, as given without template compilation too. The processing interpreter took it only when it was true, so cond( ..., array() ), hash(), 0, '' and false() gave no value there, while a compiled template gave the empty value. That missing value contains everything ($list|contains( 'x' ) was true), which is how the menu lost its groups; the same form is used in about 45 places of the admin, admin4 and standard designs (the help's search, report, viewer and dashboard, the top menu tabs, maintenance mode, the audit dashboard), all of which now get the empty value in both modes. The template language page describes cond's last parameter.
  • Added: Cases for cond in tests/tests/lib/eztemplate/eZTemplateCompiledInterpretedParityTest.php (no database): empty array, empty hash, 0, false, empty string and later clauses, each processed and compiled, with the values written in the template and passed in as variables.

2026-11-05: URL map files for the URL translator and wildcards

The first part of importing and exporting the URL translator (global URL aliases) and the URL wildcards: the files. Importing them into an installation, the admin screens and the console commands follow.

Added

  • Added: expUrlMapRow (kernel/classes/urlmap/), one mapping of an old address to a new one with the columns old_url, new_url, kind, siteaccess, language and note. The kinds are redirect, alias, wildcard-forward and wildcard-direct, or empty for automatic (a * makes a wildcard). Old and new addresses are normalized when they are read: scheme and host split off and in lower case, default ports dropped, slashes and dot segments resolved, a trailing slash dropped, percent-encoded UTF-8 decoded (but %2F, %3F, %23, %25 and spaces kept encoded), eznode://123 written node:123. A row knows what keeps it from being imported, and the line it came from.
  • Added: Readers and writers for CSV (delimiter found from the content, byte order mark, UTF-16 and Windows-1252 input, quoted fields over several lines, English and German column names), JSON (exp-url-map/1), Excel .xlsx (read and write), OpenDocument .ods (write), Apache (Redirect, RedirectMatch, RewriteRule with R and L, host and query conditions) and nginx (rewrite, return in location = and regular expression locations, map). Simple regular expressions become wildcards with {1} placeholders; a rule a mapping cannot express is read as a row that is not importable, with the reason. The rule writers write aliases, direct wildcards and node targets, which only Exponential can serve, as #exp: comments that they read back.
  • Added: expSpreadsheetReader and expSpreadsheetWriter (kernel/classes/spreadsheet/): the table of the first sheet of an .xlsx file, and a minimal valid .xlsx or .ods of one sheet, with the PHP extension zip and no other library. Without the extension, reading and writing spreadsheets stops with a message that names it.
  • Added: expUrlMapFormats, which chooses the reader and writer by format name, file extension or content.
  • Added: The specification The URL map file formats, also in German.
  • Added: Tests in tests/tests/kernel/classes/urlmap/ (no database) with fixtures for every format: normalization and validation, CSV and JSON edge cases (empty files, byte order marks, CRLF, Unicode paths, malformed rows with their line), workbooks from LibreOffice and hand-made ones with every kind of cell, the structure of the written .xlsx and .ods, a round trip from every readable format through every writer and back, 10,000 rows through every format, and, where LibreOffice is installed, the written .xlsx and .ods converted to CSV by LibreOffice.

2026-11-05: URL map files: .ods import, temporary redirects, Apache Redirect as a prefix

Follow-ups of the URL map files, as decided after their first part.

Updated

  • Updated: A redirect keeps its status. The new kinds redirect-temporary and wildcard-forward-temporary answer with 302, and the new, optional seventh column status gives another status of the same kind: 308 (permanent), 303 or 307 (temporary). A file without the column is read as before. The Apache and nginx readers no longer turn 302, 303 and 307 into 301 with a note: they read the temporary kinds with their status, and the writers write each row's status (R=307, return 307, rewrite ... redirect for 302).
  • Updated: Apache's Redirect /old /new (and RedirectPermanent, RedirectTemp) is read as Apache answers it, as a prefix: the path itself as a redirect and a wildcard /old/* -> /new/{1} for everything below it.
  • Updated: OpenDocument spreadsheets (.ods, LibreOffice Calc) are read too: expSpreadsheetReader::readOdsFile() and the reader expUrlMapOdsReader, with repeated rows and columns, spaces, tabs and line breaks in cells; a ZIP file is recognized as .ods by its mimetype.
  • Updated: The temporary files of the spreadsheet classes go to the installation's cache directory (var/<site>/cache/spreadsheet, else var/tmp), not the system's temporary directory, so they work under open_basedir.
  • Updated: The URL map file formats, also in German.
  • Updated: The tests of tests/tests/kernel/classes/urlmap/: an .ods saved by LibreOffice, .ods cells of every kind, files that are not .ods, every redirect status through every format and back.

2026-11-05: URL map import and export, redirects checked on the full request

The second part of importing and exporting the URL translator and the URL wildcards: planning, importing, undoing and exporting, and a table of redirects for what a URL alias cannot hold. The administration's screens follow.

Added

  • Added: exp_url_redirect and expUrlRedirect: redirects checked once per request, on the address asked for, before the URL aliases and wildcards (also under Velocity): an old address with a query (/index.php?id=12, the order of the parameters ignored), a temporary redirect (302, 303, 307) or 308, a target with a query or on another site; for one siteaccess or every one; the target node:<id> follows the node. The request's query is not carried over. Read from a cache file beside the wildcard cache.
  • Added: expUrlMapPlanner and expUrlMapPlan: each row of a URL map planned as new, unchanged, changed, duplicate, conflict (a page's own address, a module address, the front page), broken-target, unknown-host or not-importable, with where it goes (global URL alias, wildcard or redirect), the siteaccess from a full address's host (the siteaccess matching, or an old host mapped to one), the language, and a report as JSON or CSV.
  • Added: expUrlMapImporter: imports a plan as one batch in one transaction, with the conflict policy skip or overwrite and per-row decisions; records each row it writes before and after in exp_urlmap_batch and exp_urlmap_batch_item, so undo() puts everything back exactly (a row changed since is kept unless forced); clears the wildcard and redirect caches; audit events content.urlalias.import and content.urlalias.import_undo.
  • Added: expUrlMapExporter: the global URL aliases (optionally the custom aliases of nodes), the wildcards and the redirects as URL map rows for every writer; importing the export finds every row unchanged.
  • Added: The console commands exp:url:import, exp:url:export, exp:url:batches and exp:url:undo, and the upgrade step 6.0.15-url-map with the three tables in the kernel schemas and the 6.0.15 update files.
  • Added: A temporary forward wildcard, eZURLWildcard::TYPE_FORWARD_TEMPORARY (302; the wildcard pages show it with the import screens); error.ini defines 302, 303, 307 and 308, and a moved page answers with its redirect's status.
  • Added: URL map import and export, the BC note, also in German; the generated pages of the commands and settings.
  • Added: Tests: the planner's classification without a database, the commands' contract, and import, resolve, export, overwrite and undo on SQLite, MySQL and PostgreSQL.
  • Updated: A redirect of exp_url_redirect runs the error module as a moved URL alias does, so an old address that is no module of the site redirects instead of answering 404; a URL alias imported for a siteaccess with a PathPrefix is stored below the prefix (unless PathPrefixExclude[] names its first element), where the siteaccess looks it up.

2026-11-06: URL map screens in the administration

The third part of importing and exporting the URL translator and the URL wildcards: the screens of the administration, in the admin4 and admin designs (admin4l, admin3 and admin2 inherit them).

Added

  • Added: Import a URL map (content/urlimport): a file uploaded or pasted (CSV, JSON, .xlsx, .ods, Apache or nginx rules; the format found or chosen), the siteaccess and language of bare paths, the conflict policy and the old hosts; a preview of every row with its state as a filter (new, changed, unchanged, duplicate, conflict, broken target, unknown host, not importable), a tick per row that can be imported, hosts mapped and the file planned again, a dry run, the import as one batch, and the plan as CSV or JSON. The file and the plan stay on the server for the user who uploaded it, at most a day (expUrlMapImportSession); the forms post only the decisions of the rows shown.
  • Added: The import history (content/urlimporthistory): every import with who, when, the file, the counts and the state; one import's entries a page at a time and Undo, which lists the entries changed since and keeps them unless ticked (expUrlMapImporter::changedSince()).
  • Added: The export (content/urlexport) as a download in every format, and the list of redirects with a query or a status (content/urlredirects), with removing one or the ticked ones.
  • Added: The policy function content/urltranslator_import for importing and undoing (the export and the redirects stay under content/urltranslator); importing and undoing ask "Verify it's you" ([ReauthGroup_system]).
  • Added: The buttons Import, Export, Import history and Redirects on the URL translator and the wildcards pages, shown by policy; the context help and the guide URL maps: import, export and undo, also in German; the generated pages of the views; the strings in every administration catalogue.
  • Updated: The wildcard pages show a temporary forward wildcard as "Temporary redirect (302)" instead of "Undefined", and the filter Redirecting lists both forward types (eZURLWildcard::fetchFilteredList() takes a list of types).
  • Updated: URL map import and export and Verify it's you, also in German.
  • Added: Tests: the screens' helpers and the store of an import without a database, the reauthentication rules, and the history's checks on SQLite, MySQL and PostgreSQL.

2026-11-06: URL map documentation

Added

  • Added: The feature page Importing and exporting URL maps: the formats, the kinds and statuses, query-string redirects, the preview and its states, undo, export, the administration screens, the policies and the console commands.
  • Added: The help-center tutorial Migrating URLs: collect the old addresses from the old sitemap or the web server's 404s, build a mapping file, import it with a preview and a dry run, test the old addresses, undo an import, and export for the web server or a backup.
  • Added: URL map examples: small mapping files in CSV, JSON, Apache and nginx format, one with query-string and temporary redirects and one with wildcards, each checked with exp:url:import --dry-run.
  • Updated: The glossary has the terms URL map, query-string redirect and temporary redirect; the URL translator and wildcards pages of the help, the import and export tools table, the specification and the BC note link to the new pages. All of it in German too.
  • Updated: The tutorial Migrating URLs, the feature page and the examples use the helpers: exp:url:from-sitemap and exp:url:from-404 (tabs From the old sitemap, From the 404 log) start the map, exp:url:suggest (Suggest targets) fills in the targets, exp:url:test (Test the old addresses after an import) checks them; the 404 log gives paths only, as it hides query values. In German too.

2026-11-06: every Zeta Component is Exponential's own fork, expcomponents

Exponential officially forks the Zeta Components under their Apache License 2.0 as expcomponents and modernizes them internally. All 21 forks, se7enxweb/expcomponents-<name> (repositories github.com/se7enxweb/expcomponents-<name>), were released on 2026-11-06 and are on Packagist; they are maintained by 7x and the Exponential Foundation, each one replaces its zetacomponents/<name> package, ships a NOTICE file with the upstream notices, and keeps the ezc* class names and APIs.

Upgrading: item 30 of the list above. composer install from the release's lock file (or, in a Composer project, composer update se7enxweb/exponential --with-all-dependencies) removes the zetacomponents/* packages and installs the forks; nothing that requires an upstream name breaks (Upgrade notes: the switch to expcomponents).

Added

  • Added: The feature page Exponential Components (expcomponents): what the libraries are, their history from eZ Components (eZ Systems, New BSD) through Apache Zeta Components and Zeta Components (Apache 2.0) to expcomponents, why they are forked, what stays the same, every fork with its repository, release and kernel constraint, Composer's replace, the license and how it combines with the kernel's GPL version 2 or later, where the repositories and packages are, releases, issues and the intent of the modernization.
  • Added: The upgrade notes The switch to expcomponents: who is affected, the composer.json before and after, the upgrade by console and by hand, projects and extensions that required zetacomponents/* themselves, the replace semantics, the mail fork's refusal of unsafe addresses, autoloading, verifying with composer show | grep expcomponents, troubleshooting and rollback.
  • Added: The specification The expcomponents packages: for each library its purpose, the kernel classes, files and features that use it, the replaced package, the repository and the release; the forks the kernel does not require and the upstream packages that are not forked.
  • Added: The guide Contributing to expcomponents: clone, test on every supported PHP version, the rules a change keeps, license headers and notices, commit messages, the ChangeLog, pull requests, releases and versions, syncing with upstream and security reports.
  • Added: The glossary terms expcomponents, eZ Components and Zeta Components, and FAQ entries for PHP developers, the ecosystem, licensing and bundlers.
  • Added: All of it in German too: Exponential Components, Upgrade-Hinweise, Die expcomponents-Pakete, Zu expcomponents beitragen.

Updated

  • Updated: composer.json and composer.json.dist require the forks in place of the upstream packages: se7enxweb/expcomponents- authentication ~1.4.2, base ^1.9.6, cache ~1.6.4, configuration ~1.4.1, console-tools ^1.7.6, database ~1.5.5, debug ~1.3.1, event-log ~1.5.1, feed ~1.4.7, image-conversion ~1.5.2 (with image-analysis 1.2.1), mail ~1.10.2, mvc-tools ~1.2.5, persistent-object ~1.8.3, signal-slot ~1.2.1 and system-information ~1.1.3, next to archive (~1.5.3) and webdav (~1.2.3), which were switched before. The optional forks authentication-database-tiein 1.2.1, graph 1.7.3 and workflow 1.5.2 were released too; the kernel does not require them. Each fork is tested on PHP 7.4 to 8.5.
  • Updated: se7enxweb/expcomponents-mail 1.10.2 brings upstream's protection against header injection: it refuses an address with whitespace or < > , ; " (it would end the address or add recipients to the header) before writing any header. ezpMailMessageIdTest MI-04 checks the localhost Message-Id with a sender domain that is no host name but a valid address, and MI-05 that such a sender is refused.
  • Updated: se7enxweb/expcomponents-image-conversion 1.5.2 scales and crops with ImageMagick again after upstream's switch to proc_open() argument lists; the fix is offered upstream as zetacomponents/ImageConversion pull request #9.
  • Updated: expComposerKernelRequirementsTest CR-03 and CR-04 check the fork names of the configuration and image conversion components.
  • Updated: The pages that named the Zeta Components as the kernel's current libraries name the expcomponents: the installation chapters (introduction, requirements, getting the code, setup wizard, troubleshooting, migrating from 4.x), INSTALL.md, UPDATE.md, the console upgrade guide, the REST guide, the ezie page, the standards pages (APIs and protocols; PHP and Composer, which now says that Composer's class map, not ezcBase::autoload, loads the libraries), the help center's ecosystem, hosting, integrator, PHP developer, trainer and project pages, the pages on licenses, copyright and third-party components, the goals and the support page. Historical statements stay in the past tense. All of it in German too.

2026-11-06: URL map helpers for moving a site

The fourth part of importing and exporting the URL translator and the URL wildcards: console helpers that build a URL map from the old site and check it against the new one. None of them changes the installation.

Added

  • Added: exp:url:from-sitemap (expUrlMapSitemapSource): the addresses of an old site's sitemap, sitemap index (with the sitemaps it names) or crawl list, gzip-compressed or not, as rows with an empty new_url; the sitemap's host only unless --only-host or --any-host, every address once, --limit, paths or --full-urls.
  • Added: exp:url:suggest (expUrlMapSuggester): for every row without a target, the visible pages of the siteaccess scored from 0 to 100 by their URL alias and name against the old address (decoded, transliterated, without endings such as .html); --fill-above fills new_url with the best one (never a tie), the others go into the note or --report. 4,811 old addresses against 4,496 pages take seconds.
  • Added: exp:url:from-404 (expUrlMap404Source): the addresses of the not-found log (var/log/404.log, its rotated and gzip files) or of web server access logs (--log), the most asked first, with --min-hits and --since, without assets, bots and methods other than GET and HEAD.
  • Added: exp:url:test (expUrlMapTester): every old address requested on --base, several at a time, the redirects followed hop by hop, and each row reported as landed, wrong-status, wrong-target, not-redirected or error, as a table, JSON or a CSV or JSON report; exit 1 when a row did not land.
  • Added: expUrlMapHttp, the HTTP client of the helpers: GET over curl_multi with a timeout, a user agent, a size limit and the proxy of site.ini, http and https only.
  • Added: The generated pages of the four commands and the section Helpers for moving a site of the BC note, also in German.
  • Added: Tests: the four helpers without a network or a database, and the command contract.

Updated

  • Updated: A URL map row's target path resolves the way the siteaccess serves it, below its PathPrefix first (PathPrefixExclude[] honored), so /recipes on a siteaccess with PathPrefix=fit-healthy is no longer a broken target; the export writes an alias stored below the prefix without it and with its siteaccess, so a round trip keeps it in place.
  • Updated: The help of --format, --limit, --report and --output-format of the exp:url commands names the new commands.

2026-11-06: URL map helpers in the administration

Added

  • Added: The import screen (content/urlimport) has the helpers as tabs, view parameter /(source)/sitemap|404|suggest: From the old sitemap (an address of a public host, never one of the server's own network, or an uploaded sitemap or list), From the 404 log and Suggest targets, each with the targets suggested and filled in from a score; the map is downloaded as CSV or opened as an import to preview. In the admin4 and admin designs.
  • Added: Test after an import: the old addresses of the file (the first 500) are requested on the site of its siteaccess and the rows that did not land are listed; the result stays with the import.
  • Added: expUrlMapHttp option public_only: an address is fetched only when every address of its host is a public one, and the connection is pinned to the checked address.
  • Added: The strings in every administration catalogue; tests of the tabs' helpers without a database.

2026-11-06: the documented INI load order

Updated

  • Updated: The documented INI load order is corrected, in English and German: after the defaults come the extensions a siteaccess activates for itself (ActiveAccessExtensions[]), the extension siteaccess directories, the siteaccess, the active extensions (ActiveExtensions[]) and the global override, as eZINI::overrideDirsByScope() reads them. An active extension therefore wins over settings/siteaccess/<siteaccess>/; the configuration guide says so and where to put a value of one siteaccess that must beat an extension. Within one layer the extension listed first wins, not the last one as the guide said (Configuring, section 2).

2026-11-10: URL map formats and examples in the administration

Whoever writes a URL map for the first time can now see what each format looks like before uploading one.

Added

  • Added: The tab Formats and examples of the URL map import (/content/urlimport/(source)/examples), also a button on the URL translator and URL wildcards pages: an example file of every format (CSV, CSV with wildcards, CSV with queries and temporary redirects, JSON, Excel, LibreOffice, Apache and nginx rules), each with what it shows, the extensions its format is read from and the rows the import makes of it; the text ones shown to copy (Copy in admin4), Download, and Open in the import, which puts the file into the import's text field with its format. Below: the columns and the kinds. The upload page links it next to the file field.
  • Added: expUrlMapExamples: the examples are the files of the documentation (URL map examples), the German ones for a German interface, each read with its format's reader.
  • Added: old-site.xlsx and old-site.ods (German alte-site.*) to the URL map examples, written from the CSV example by the import's own writers.
  • Added: The strings in every administration catalogue; the test expUrlMapExamplesTest.

Updated

2026-11-11: INI cache files of a process working elsewhere

A process that worked in another directory than the installation root (the Velocity warm-up test in a temporary root) stored a site.ini without settings/override/ in the INI cache under the name every process of the installation reads. The site, the administration and every script then ran without the global override, the siteaccess settings and the extensions' settings until the INI cache was cleared.

Updated

  • Updated: eZINI reads the base files and keeps its cache below the installation root, but reads the override directories relative to the working directory. Override directories that resolve to another place than the installation's own get a cache file name of their own (eZINI::workingSettingsDirectory()); a directory that resolves to the installation's own, through a symbolic link for example, counts as the same, and the usual cache file names do not change. Test: eZINIWorkingDirectoryTest.
  • Updated: The Velocity warm-up test switches the INI cache off while the warm-up runs, checks that the warm-up writes nothing into its temporary root, and removes all of that root, also after a failure.
  • Updated: Configuring names the case in its troubleshooting table, also in German.

2026-11-11: class ids follow the database

A process that switched its global database (a script, or the migration runner tests with their SQLite database) kept the class and class attribute ids by identifier it had read from the first one. A later class filter of the process then used the ids of that database, or missed a class it never had: in a full test run the hide cronjob hid nothing, and the forum, product and comment services found nothing, in tests that pass on their own.

Updated

  • Updated: eZContentClass::classIDByIdentifier(), classIdentifierByID() and eZContentClassAttribute::classAttributeIDByIdentifier(), classAttributeIdentifierByID() remember the database their ids were read from, and read them again when the global database is another one (eZDB::setInstance()). The file caches were already kept per database. Tests: RS-6 in expMigrationRunnerSqliteTest.
  • Updated: The migration runner tests expire the class caches when they switch back to the installation's database (runnerEnd()); RS-5 checks that the process keeps no ids of the test's database, without reading the installation's database, so it also runs where there is none.
  • Updated: eZContentAccessForAnotherUserTest gives the checks an empty class identifier hash and content language list of its own database handler, not those an earlier test left in memory.

Something wrong on this page? Report an error

Documentation license

This documentation is free documentation, licensed under the GNU Free Documentation License 1.3 or later.

Exponential Copyright © 1998-2026 Exponential Foundation and 7x