Skip to content

omnisense/omnibase

omnibase: entities, view models, contracts, forms and helpers for any PHP host; under Symfony with omnibridge/symfony, under Laravel with omnibridge/laravel, persisted through omnibridge/doctrine

Floor
Core
Version
4.x
Verified for real
Its README does not say
Licence
MIT
composer require omnisense/omnibase:5.x-dev

You are reading the 4.x series; the current one is 5.x. Read 5.x

This is the manual shipped by omnisense/omnibase itself (formerly base-bundle, then glitchr/omnibase and omniscient/omnibase), the framework core of the omnibase sites. It describes what the core provides to every application built on it, and to the omnibase/* plugins.

How this manual is organised

  • Getting started: requirements, installation in a Symfony application, the Base\ / App\ convention.
  • Scaffolding a site: what a new site copies, the demonstration, the first start.
  • Architecture: what the core requires, entities and repositories, the contracts of the Foundation, the Doctrine mapping, settings, time, politeness, the forms and their guard, account security, the demo environment, sealed fields, errors.
  • Operations: deployment, backups, the Docker harness.
  • Shared bricks: opening hours, JSON-LD, redirections, the sitemap, calendars, invitations, downloads and QR codes, signatures, PDF, e-mails, comments, pages and texts, the front end.
  • Reference: the configuration tree, the console commands, the events, the Twig functions and filters, the form types, the routes and the voters.
  • Decisions: the decisions recorded, with their reasons.

Pages are markdown files on disk, not database rows, so they are versioned alongside the code they describe and reviewed in the same diff.

How this manual is assembled

The manual reads from several roots, declared lowest priority first in config/packages/wikidoc.yaml. A page is identified by its path inside a root, so an application can replace any base page by creating a file at the same path:

vendor/omnisense/omnibase/docs/30-operations/deployment.md   <- base
docs/30-operations/deployment.md                              <- wins

The path is the file's, numeric prefix included (omnibase/docs' DocumentationRegistry::pathFor() strips only .md and a final index): the prefix orders the pages, it is also part of their identity. This is the same override rule Symfony uses for bundle templates. A page that is overriding a base one is marked in the sidebar and in its header.

Writing a page

  • A directory becomes a section; its index.md, if present, becomes that section's own page.
  • The title comes from front matter, else the first # heading, else the filename.
  • Ordering comes from front matter order:, else a numeric filename prefix (10-), else the title.

Propose a change docs/index.md

Getting started

Requirements

What composer.json requires:

Component Version
PHP 8.4 or later, with bcmath, exif, igbinary and imagick
Doctrine ORM 3.4+, DBAL 4.2+, Persistence 4
Twig 3
Symfony's standalone components 6, 7 or 8 (cache, mailer, notifier, serializer, translation, validator, workflow...)
omnisense/omnibridge, omnisense/omnidrive, omnibridge/composer, omnibridge/doctrine, omnibridge/twig 1.x

The framework itself is not required by the core (since 4.0, see What the core requires): under Symfony it comes with omnibridge/symfony (FrameworkBundle, SecurityBundle, TwigBundle, DoctrineBundle, MonologBundle, Messenger, Form, 6.1 to 8). The sites run MySQL 8; the core's harness runs on SQLite. The commands doctrine:schema:charset and doctrine:array:upgrade, and the backups, are MySQL's.

Installation in a Symfony application

composer config allow-plugins.omnibridge/composer true
composer require omnisense/omnibase omnibridge/symfony

omnibridge/composer is a Composer plugin (it applies the core's patch to symfony/process): Composer asks before it runs one, hence the first line.

Register the bundle in config/bundles.php, after the bundles it works with:

return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    // ...
    Base\BaseBundle::class => ['all' => true],
];

Give it its configuration, config/packages/base.yaml - the smallest one is in the Configuration reference - and import its routes (Routes and access). Then the schema and the front end:

php bin/console doctrine:migrations:migrate
npm install && npm run dev
php bin/console cache:clear

A new site is better started from an existing one: Scaffolding a site says what to copy and in which order to start it. The core's harness (The Docker harness) runs the core with every omnibase/* plugin, without a site.

Conventions

The Base\ namespace mirrors App\: a class in Base\Entity\User is aliased so the application can override it at App\Entity\User without the bundle knowing. This is deliberate and load-bearing — do not rename it. bin/console base:mapping lists the aliases (Console commands).

Propose a change docs/10-getting-started.md

Scaffolding a site

A new omnibase site starts as a copy of Apfelschule (~/Sites/apfelschule), the reference application: Symfony 8, PHP 8.4, Docker, omnisense/omnibase 3.x with omnibase/admin, omnibase/marketplace and the omnibase/* bundles of its trade. This page is the exact list: what is copied and then adapted, and what is no longer copied because a package provides it.

What a new site copies

The project's frame (copied as is, then names and ports changed)

Path Then
Makefile APP_NAME, ports (plan 0 §2: one HTTP/HTTPS pair per site); its docker exec shortcuts take a TTY only when there is one (see below)
docker-compose.yml, docker-compose.override.yml, docker-compose.setup.yml, docker-compose.override.setup.yml, docker-compose.admin.yml the ../omnibase, ../canva, ../padlet mounts only for the path packages the site uses
deployments/docker/* (web, proxy, database, assets, composer, cron; collab if it co-edits live) nothing
.env, .env.dev, .env.prod, .env.test APP_NAME, DEFAULT_URI, the dev ports, MAILER_CONTACT
.editorconfig, .gitignore, .php-cs-fixer.dist.php, AGENTS.md nothing
bin/, public/index.php, src/Kernel.php, config/bootstrap.php, config/preload.php nothing
composer.json its name and description; the require of its bundles; path repositories for the packages not on Packagist yet
symfony.lock nothing - it is copied with composer.json (see below); composer.lock is not, the first composer install writes it
package.json, webpack.config.js, postcss.config.js its name; @glitchr/flowjs at the version the core takes (^1.3.1, from npm: the core's base-async loads its Flow first); no @glitchr/cookie
phpunit.xml.dist, tests/bootstrap.php, tests/Mock/NoNetwork.php nothing
initdb.sql the database name

Never copied: .env.local and .htpasswd. Both are git-ignored and belong to one deployment: .env.local holds the other site's secrets (its Stripe and mail keys, its database password) and would point the new site at them; .htpasswd holds the other site's beta credentials. A copy made with cp -R or rsync takes them along with the rest: copy from git ls-files (or git archive HEAD | tar -x -C ../newsite), or exclude the two by name. The new site writes its own - .env.local by hand, .htpasswd with make htpasswd (the proxy mounts ./.htpasswd: without the file Docker creates a directory of that name, and the protected hosts cannot check a password).

symfony.lock comes with composer.json

symfony.lock is Flex's record of the recipes already applied, one entry per package. Without it Flex takes every package of the first composer install for a new one and plays every recipe again: it rewrites config/bundles.php, drops its default config/packages/*.yaml, config/routes/*.yaml, .env blocks, src/Kernel.php, public/index.php and bin/console over the files just copied from Apfelschule - the site then boots on Symfony's defaults instead of omnibase's configuration (or does not boot: two Doctrine configurations, a second security firewall).

So the copy takes symfony.lock (it is tracked: git ls-files and git archive include it). After the first install, git status must show no change under config/; a package added later is a composer require, whose recipe adds its entry to the lock - commit it with the rest.

A copy made without it is repaired before the first install: take Apfelschule's symfony.lock, remove the entries of the packages the new site does not require (or leave them: an entry without its package is ignored). If the recipes already ran, git checkout -- config/ .env src/Kernel.php public/index.php bin/console on a copy that was committed first, else copy those paths again.

Fonts: @fontsource 5.3 and its exports

The sites install their fonts from @fontsource/<font> (^5.2 in package.json) and import the files they use in assets/styles/app-async.scss:

@import "@fontsource/andika/latin-400.css";      // <subset>-<weight>[-italic].css
@import "@fontsource/andika/latin-700.css";

From 5.3.0 a fontsource package declares an exports map, and the bundler (webpack's resolver, which sass-loader and css-loader use) refuses every path the map does not list. What it lists, and so what still resolves by package name: the package itself (@fontsource/andika: index.css), the stylesheets at its root (/latin-400.css, /400.css, /latin.css...), the font files (/files/andika-latin-400-normal.woff2), and /scss (the metadata, from Sass only).

What no longer resolves by package name - "Package path ./scss/mixins is not exported from package …/@fontsource/andika" - is anything else inside the package, the Sass mixins first:

@use "@fontsource/andika/scss/mixins" as andika;                          // 5.3: refused
@use "../../node_modules/@fontsource/andika/scss/mixins.scss" as andika;  // by relative path: resolves

A relative path through node_modules is not a package request: the exports map does not apply to it, and it resolves whatever the version. Use it for the paths the map leaves out; keep the package name for the stylesheets above, which it lists. (Checked on 5.3.0 with webpack's enhanced-resolve 5.26, sass-loader 16 and css-loader 7 - the versions the sites build with.)

Docker networks: a subnet per site

Each site has two networks (extranet, intranet), and Docker gives each a subnet of its default address pools. With many sites up at once the pools run out and up fails on "could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network" (or "all predefined address pools have been fully subnetted"). A site therefore fixes its two subnets in docker-compose.override.yml, numbered after its ports so that no two sites share one:

# docker-compose.override.yml
networks:
  extranet:
    ipam:
      config:
        - subnet: ${NETWORK_EXTRANET_SUBNET:-10.110.126.0/24}   # HTTP port 8126: 126 and 127
  intranet:
    ipam:
      config:
        - subnet: ${NETWORK_INTRANET_SUBNET:-10.110.127.0/24}

NETWORK_EXTRANET_SUBNET and NETWORK_INTRANET_SUBNET (.env.local) move them on a host where those ranges are taken. A subnet changes only when its network is recreated: make down, then make up. See also Stopping a site.

Configuration (copied, then trimmed to the site's bundles)

config/bundles.php, config/services.yaml (its app.site.* parameters), config/routes.yaml (one import per bundle's client controllers), config/routes_paths.yaml (the localized paths), config/routes/* (base_admin.yaml is what routes the back office, its system pages included), and config/packages/*: base.yaml, admin.yaml, doctrine.yaml, security.yaml, framework.yaml, cache.yaml (its cache.redis pool), marketplace.yaml and omnitrade.yaml for a shop, consent.yaml, one file per bundle, and the rest as they are.

The site's own code (copied as a model, rewritten)

Path What it is
src/Entity/User.php, src/Repository/UserRepository.php the account. In a site with a shop, User implements Base\Marketplace\Model\MerchantInterface (see below)
src/Site/Identity.php the Twig global site (name, town, e-mail, legal notice)
src/Controller/MainController.php home, about, contact (on Base\Form\Type\ContactType, see below), legal pages
src/Controller/AccountController.php "Mon compte"
src/Controller/Admin/DashboardController.php the back office's menu and tiles
src/DataFixtures/ the site's fixtures (a dev payment method for the marketplace's DevGateway)
src/Tools/* only if the site uses Canva or Padlet (PadletKey also declares its API key field)
migrations/ not copied: a new site starts its own (make doctrine-migration)
templates/base.html.twig, templates/email.html.twig, templates/layout/layout1.html.twig the page frame
templates/client/**, templates/security/**, templates/email/**, templates/partials/_tools.html.twig the pages, the sign-in, the e-mails, the tools bar (Accès, cookies through @Consent/_cookie.html.twig)
translations/messages+intl-icu.*.yaml the site's texts
assets/app-async.js, assets/layout1-async.js, assets/bootstrap.js, assets/controllers.json, assets/styles/* styles and Stimulus
assets/app-defer.js reduced to Base.boot({...}) and the site's own effects (its curtain, its menu)
tests/Functional/* the site's tests, SharedBricksTest.php included

A shop's User is its merchant

omnibase/marketplace's products, stores and orders are related to "whoever sells", Base\Marketplace\Model\MerchantInterface, which Doctrine binds to a class of the application. A site that installs the marketplace declares both; without them Doctrine cannot map those entities (the target entity Base\Marketplace\Model\MerchantInterface is not found) and no migration can be generated:

// src/Entity/User.php
use Base\Marketplace\Model\MerchantInterface;

class User extends \Base\Entity\User implements MerchantInterface
{
    #[ORM\Column(length: 255, nullable: true)]
    protected ?string $companyName = null;

    public function getCompanyName(): ?string { return $this->companyName; }   // the interface's one method
}
# config/packages/doctrine.yaml
doctrine:
    orm:
        resolve_target_entities:
            Base\Marketplace\Model\MerchantInterface: App\Entity\User

A shop with a merchant class of its own (App\Entity\User\Merchant, a subclass of User) names that class instead. A site without the marketplace needs neither.

Glitch Art's signature: mirror: true on the left

The footer's credit is @Base/partials/_credits.html.twig, black or white only (Front end). Its detail line opens towards the inside of the page: by default on the left of the signature, for a signature set on the right of its footer. A site that sets the signature on the left passes mirror: true, or the detail opens outwards, over the page's margin:

{% include '@Base/partials/_credits.html.twig' with {mirror: true} %}                  {# signature on the left #}
{% include '@Base/partials/_credits.html.twig' with {color: 'white', outline: true} %}  {# on the right, on a photo #}

Secrets {#secrets}

bin/console secrets:set, secrets:generate-keys and secrets:remove write under config/secrets/<env>/. The web container cannot: it mounts the project read-only (.:/srv/app:ro; only var/, vendor/ and public/ are writable), and the command fails on "Unable to create the secrets directory (/srv/app/config/secrets/dev)" - a make shortcut that runs in <app>-web-1 fails the same way.

Run them where the project is writable: the one-shot composer service of docker-compose.setup.yml, which mounts .:/srv/app:rw and reads the same .env and .env.local:

# the key pair of an environment (once per environment; see Sealed fields)
docker compose -f docker-compose.yml -f docker-compose.setup.yml run --rm --entrypoint "" composer \
    php bin/console secrets:generate-keys

# a secret, read from standard input so that it stays out of the shell's history
printf %s "$STRIPE_SECRET" | docker compose -f docker-compose.yml -f docker-compose.setup.yml run --rm -T --entrypoint "" composer \
    php bin/console secrets:set STRIPE_API_KEY -

# production's: with the public key alone, on any machine
docker compose -f docker-compose.yml -f docker-compose.setup.yml run --rm --entrypoint "" composer \
    php bin/console secrets:set STRIPE_API_KEY --env=prod

(--entrypoint "" skips the service's own entrypoint, which runs a whole composer install. On a machine with PHP, php bin/console secrets:set … from the project's directory does the same.)

Then: commit config/secrets/<env>/ except prod.decrypt.private.php (git-ignored: check .gitignore has a line for config/secrets/prod*/prod*.decrypt.private.php), and clear the cache of the running site (docker exec <app>-web-1 php bin/console cache:clear): the secrets are read when the container is compiled. Reading them works in the web container: make secrets (secrets:list --reveal).

The same key pair seals the fields marked #[Vault] - the API keys typed in the back office - and without it they are refused, not stored in clear: see Sealed fields.

The demonstration: what a site adds {#demo}

APP_ENV=demo runs the site as a demonstration (The demo environment): production's behaviour, a database of its own (<database>_demo), the demonstration account named in the banner of every page, one button for each role on the sign-in page, a back office that opens with a secret, a reset every night. Everything is in omnisense/omnibase; a site adds these lines.

.env.demo (committed: defaults only, as .env.prod; no secret):

HTTPS=on
HTTP_REDUCTION=true
HTTP_DOMAIN='["demo.example.org", "localhost"]'
HTTP_SUBDOMAIN='["", "www"]'
HTTP_MACHINE='[""]'
HTTP_BASEDIR=/
HTTP_PORT='[80,8126]'
HTTPS_PORT='[443,8643]'
USE_SYS_TMPDIR=false
# DEMO_SUPERADMIN_PASSWORD and DEMO_ADMIN_PASSWORD are NOT here: the passwords
# of the super-administrator and of the back office's accounts (ROLE_ADMIN) in
# the demonstration are secrets of the server (.env.demo.local, git-ignored,
# or real variables). Unset, those accounts cannot sign in.

A real variable beats every .env* file, and the containers get .env as real variables: .env.demo cannot change what .env defines. What differs in demo is said under when@demo.

config/bundles.php - the fixtures are loaded in demo:

Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true, 'demo' => true],

(doctrine/doctrine-fixtures-bundle is in require-dev: deployments/docker/composer/entrypoint.sh installs the dev packages for demo too - a demo*) composer install --no-interaction ;; beside test*).)

config/services.yaml - the fixtures are services where the bundle is:

when@dev: &fixtures
    services:
        App\DataFixtures\:
            resource: '../src/DataFixtures/'
            autowire: true
            autoconfigure: true

when@demo: *fixtures

config/packages/doctrine.yaml - production's pools, a database of its own:

when@prod: &prod
    framework:
        cache:
            pools:
                doctrine.result_cache_pool: { adapter: cache.app }
                doctrine.system_cache_pool: { adapter: cache.system }

when@demo:
    <<: *prod
    doctrine:
        dbal:
            connections:
                default:
                    dbname_suffix: '_demo'

config/packages/base.yaml - which database production's is (the demonstration refuses to start on it, and without this line):

when@demo:
    base:
        demo:
            production_database: 'mysql://%env(DOCTRINE_DATABASE_HOST)%:%env(DOCTRINE_DATABASE_PORT)%/%env(DOCTRINE_DATABASE)%'
            reset:
                purge: []      # storages of the site's own that demo:reset empties

Every other when@prod (monolog.yaml, ...): when@prod: &prod, then when@demo: *prod at the end of the file. config/packages/mailer.yaml: when@demo: { framework: { mailer: { dsn: 'null://null' } } } (omnibase refuses every message in demo whatever the DSN; this keeps anything else from trying).

deployments/docker/cron/crontab - the same file in every environment, so the line checks:

30  3  * * *  [ "$APP_ENV" = demo ] && php /srv/app/bin/console demo:reset >> /srv/app/var/log/cron.demo-reset.log 2>&1

config/packages/flysystem.yaml - the demonstration's files in directories of its own, which demo:reset empties: nothing of another environment is ever there (a development machine runs both from one checkout):

when@demo:
    flysystem:
        storages:
            local.uploads: { local: { directory: '%kernel.project_dir%/var/storage/demo/uploads' } }
            local.wysiwyg: { local: { directory: '%kernel.project_dir%/var/storage/demo/wysiwyg' } }
            # and every other storage visitors write to (omnibase/office's local.share...)
# config/packages/base.yaml, under when@demo's base.demo
            reset:
                purge: ['%kernel.project_dir%/var/storage/demo']
                orphans: false     # everything is in the directory above: no search among the uploads

Makefile:

## Configure environment for the demonstration (production's behaviour, the database <name>_demo)
env-demo:
	@APP_ENV=demo APP_DEBUG=0 $(MAKE) env

## Create the demonstration's database (<name>_demo), its schema and its fixtures
database-demo:
	@$(DOCKER_DATABASE) mysql -h localhost -u root -p$(MYSQL_ROOT_PASSWORD) -e "CREATE DATABASE IF NOT EXISTS $(MYSQL_DATABASE)_demo; GRANT ALL ON $(MYSQL_DATABASE)_demo.* TO '$(MYSQL_USER)'@'%'; FLUSH PRIVILEGES;"
	@$(DOCKER_WEB) php bin/console doctrine:migrations:migrate --env=demo --no-debug --no-interaction
	@$(DOCKER_WEB) php bin/console doctrine:fixtures:load --env=demo --no-debug --no-interaction

The keys - what the fixtures need to seal (omnibase/office's SHARE_MASTER_KEY, omnibase/mailbox's MAILBOX_KEY) is given in .env.demo as demonstration keys, known to everyone and used nowhere else, as .env.test does (base64 of demo-key-demo-key-demo-key-00001): what they seal is the fixtures' fictional data, emptied every night. The one real secret of a demonstration, DEMO_SUPERADMIN_PASSWORD, is the server's: .env.demo.local, a real variable, or the demo vault (secrets:generate-keys --env=demo then secrets:set DEMO_SUPERADMIN_PASSWORD --env=demo, see Secrets; its private key stays on the server, as production's - .gitignore: config/secrets/demo*/demo*.decrypt.private.php). That key pair is also what #[Vault] needs if the super-administrator types API keys in the demonstration's back office (Sealed fields).

The templates - one line each:

{# templates/base.html.twig (or the layout): first thing in <body> #}
{{ include('@Base/demo/_banner.html.twig') }}

{# templates/security/login.html.twig: under the form #}
{{ include('@Base/demo/_accounts.html.twig') }}

The accounts - a bundle of the trade declares its roles; the site adds its own in src/Demo/DemoAccounts.php (DemoAccountProviderInterface), and its fixtures take them from Base\Demo\DemoAccountFactory instead of writing them a second time.

src/Kernel.php - only if it lists the environments it allows (getAllowedEnvs(), Symfony 8.1's skeleton): add 'demo'.

Then:

make env-demo && make up       # production's stack, APP_ENV=demo
make database-demo             # once

On a development machine, to look at it without leaving the dev stack: the web container alone in demo, by an override of two lines (services: { web: { environment: { APP_ENV: demo, APP_DEBUG: "0" } } }, docker compose ... -f that-file.yml up -d web), then make database-demo; make up brings dev back. The links public/uploads and public/images follow whichever environment cleared its cache last.

What a new site no longer copies

No longer in the site Use instead
src/EventSubscriber/NestHeaderSubscriber.php omnibase/admin's NestHeaderSubscriber: every page under /admin (the site's own screens there included), and any route with defaults: ['_nest' => true]
src/Controller/Admin/SystemController.php omnibase/admin's SystemController (admin_settings, admin_apikey, admin_settings_quick); the site's own fields in a class implementing Base\Admin\Settings\SettingsSectionInterface
src/Form/ContactType.php, src/Model/ContactMessage.php Base\Form\Type\ContactType with ['phone' => true, 'subject' => false, 'attachments' => false, 'buttons' => false, 'trap' => true], Base\Form\Model\ContactModel (isRobot()) - created with createNamed('contact', …) to keep the field names
A consent checkbox and a data-protection sentence under a form ContactType's privacy, privacy_consent, privacy_parameters options, or Base\Form\Type\PrivacyType in any form (Pages and texts)
src/Twig/ImageExtension.php omnisense/omnibase's |picture filter
src/Entity/TextOverride.php, src/Repository/TextOverrideRepository.php, src/Translation/OverridingTranslator.php, src/Translation/TextOverrideCacheListener.php, src/Controller/Admin/Crud/TextOverrideCrudController.php Base\Entity\Layout\TextOverride (table layoutTextOverride), Base\Translation\*, omnibase/admin's TextOverrideCrudController
src/Market/DevGateway.php omnibase/marketplace's DevGateway, registered in debug only
templates/partials/_credits.html.twig and its SCSS (.as-credits, .nk-credits, .mh-credits, .gl-credits…) @Base/partials/_credits.html.twig (color: black|white, outline, mirror, rule, a credits_detail block)
The boot code of assets/app-defer.js (closestScrollable guard, <style> headlock, nest-frame escape, Sticky.ready, StickyStops.attach, Transparent.ready, the current link) Base.boot() from vendor/omnisense/omnibase/assets/boot.js
A copy of opening hours, ICS / Google Calendar, invitations, signed downloads, QR sheets, allergens, comments, URL embeds, a polling script omnisense/omnibase's shared bricks: docs/40-commons
A hand-written LocalBusiness JSON-LD around opening_hours().schema() {{ local_business_jsonld() }} (LocalBusiness JSON-LD)
A screen for the hours and the days off; a table of old addresses and its listener omnibase/admin's /admin/hours; Base\Entity\Layout\Redirection and omnibase/admin's "Redirections" (Redirections)
A column type, or a helper, keeping moments in UTC the Doctrine type utc_datetime_immutable (Time and time zones)

First start, in this order

make install-dev          # = make env-dev, make build, make up - build BEFORE up
make doctrine-migration   # the site's first migration
make doctrine-migrate
make database-test        # the test database
docker exec <app>-web-1 php vendor/bin/phpunit

make build comes before any up. /srv/app/public is a named volume (<app>-public) that no image seeds: the composer step of make build copies ./public (its index.php) into it when the volume is empty, then composer's assets:install links the bundles' assets (public/bundles/*). A stack started first - make up, a bare docker compose up - creates the volume with the public/assets mountpoint alone: not empty, so never seeded, and every page answers "File not found". Apfelschule's Makefile repairs that case itself since 2026-10-04 (seed-public, run by make build: a volume that exists without index.php gets ./public); with an older Makefile, by hand:

docker run --rm -v <app>-public:/to -v "$PWD/public":/from:ro alpine cp /from/index.php /to/
docker exec <app>-web-1 php bin/console assets:install public --symlink --relative

Stopping a site

make down (docker compose down, without -v): the containers and the site's two networks go, the volumes (database, public, vendor, storage) stay, and make up brings the site back as it was. Not docker compose stop: it keeps the networks, each holding a subnet of Docker's address pool, and with enough stopped sites the next up fails on "could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network". -v deletes the volumes: the database with them.

Steps

  1. Copy the files of the first part - tracked files only, symfony.lock among them, neither .env.local nor .htpasswd; rename (APP_NAME, ports, composer.json, package.json); give the two networks their subnets.
  2. make install-dev (composer and yarn in the setup containers, COMPOSER_ALLOW_SUPERUSER=1): see the order above.
  3. Generate the key pairs (secrets:generate-keys, dev and test: see Secrets), then make doctrine-migration, make doctrine-migrate and make database-test. These run from a script or an agent as well as from a terminal: the Makefile's DOCKER_WEB, DOCKER_DATABASE, DOCKER_PROXY and DOCKER_ASSETS pass docker exec the TTY_FLAG (-ti when standard input is a terminal, -i otherwise), and so do make shell / make shell-debug given ARGS. Apfelschule's DOCKER_DATABASE and DOCKER_PROXY used a bare -ti, and make database-test stopped on "the input device is not a TTY" outside a terminal (fixed 2026-10-04): a site copied from an older Makefile changes those two lines the same way.
  4. Write the site's SettingsSectionInterface classes for its own settings and keys.
  5. docker exec <app>-web-1 php vendor/bin/phpunit: green before the first commit.

Apfelschule moved onto the shared bricks in migrations/Version20261004090000.php (textOverride → layoutTextOverride, blog_comment → threadComment, the hours tables): the model for the existing sites when they are attached one by one.

Propose a change docs/scaffold.md

Settings

Settings are stored per locale and compiled into a single snapshot at warmup, so reading one at runtime never queries the database. They are what the back office edits (base.settings.title, base.settings.maintenance, api.spam.akismet...), read through Base\Service\SettingBag (Base\Service\SettingBagInterface).

base.parameter_bag.use_setting_bag must be on: off, the snapshot is empty and every setting reads as unset (Configuration reference).

Reading a setting

$title = $settingBag->getScalar('base.settings.title');          // 'My site', or null
$title = $settingBag->getScalar('base.settings.title', 'fr-FR'); // in that locale
$tree  = $settingBag->get('base.settings');                      // the values under the path

get() answers an array: the path's own value under _self, the values of the paths under it by their names (get('base.settings.title') is ['_self' => 'My site']). getScalar() is get($path, $locale)['_self'] ?? null. Both take a list of paths too. A value is read in the locale asked, else the default locale, else the first locale it has.

A missing setting degrades to null rather than throwing - a missing setting is a normal state during a deployment, not an error. A malformed path (_self in the middle of it) still throws.

In a template, app.settings is get('app.settings'), and the setting base.settings.maintenance puts the site under maintenance.

Writing a setting

$settingBag->set('base.settings.title', 'My site', 'en-GB');
$settingBag->set('base.settings.title', null, 'en-GB'); // removes that locale's value

set() creates the setting when it does not exist, refuses a locked one (lock(), unlock()), flushes, evicts the second-level cache and invalidates the snapshot. setLabel() and setHelp() give the back office's label and help text, secure() marks a setting as secret. has(), remove().

Translatable values

A setting's value lives in SettingIntl, one row per locale. The default locale is not special-cased at the storage layer; every locale is a row.

A setting over a parameter

setBag($path, $parameterName) links a setting to a parameter of the configuration: with base.parameter_bag.use_hot_bag on, the setting's value (in the default locale) answers for that parameter before base.yaml's (getBagParameters(), laid over the parameters by HotParameters on each request and command). This is how the back office changes an option of the configuration without a deploy.

Propose a change docs/20-architecture/settings.md

Architecture

The bundle is organised around a few long-lived pieces:

  • What the core requires — since 4.0 its Foundation alone (Doctrine, Twig, the standalone components); under Symfony the framework comes with omnibridge/symfony, and the bundles the core works with are suggested: What the core requires.
  • Entities — Thread and its subtypes carry the shared title/slug/content model that articles, comments and pages all reuse.
  • The contracts of the Foundation — what framework-free code asks of the application (URLs, translations, access, locales, files, the clock, events, mail), through Base\Contract\Runtime: The contracts.
  • The Doctrine mapping — what omnibase assembles at runtime (the attributes, the translations, the discriminator maps, the filters, the types), wired by Base\Bridge\Doctrine under Symfony and on an entity manager built by hand, on doctrine/orm as published: The Doctrine mapping.
  • Settings — a compiled snapshot of configuration, read through SettingBag.
  • Admin — the back office is a package of its own, omnibase/admin (formerly base-bundle-admin), with its own manual.
  • Account security — what the administrator sets, and a second factor required of some roles: Account security.
  • The demo environment — APP_ENV=demo: declared demonstration accounts, one click to sign in, a nightly reset, and what it refuses: The demo environment.
  • Forms — the theme, and where a field's texts are translated: Forms.
  • Sealed fields — #[Vault], its key pair, and what happens without one: Sealed fields.
  • Errors — which status an error is answered under, and the 404 that are meant: Errors and refusals.
  • Time — PHP's zone is the visitor's; a moment is stored in UTC (utc_datetime_immutable), see Time and time zones.
  • The forms' guard — a trap, the time, the lists, a captcha, the classifier, one option (guard): The forms' guard.
  • Politeness — tu or vous, です / ます or 敬語: one setting of the site (base.translator.politeness) and a variant per text (key._polite), see How the site addresses people.

The warm-up: every class of every bundle

On its first boot (and after a cache:clear), BaseBundle::warmUp() maps each Base\* bundle's directories onto their mirrors (Base\X\Entity\Y ↔ Base\Entity\X\Y, Base\Entity\Y ↔ App\Entity\Y...) and, to do so, loads every class it finds there. The result is kept in var/cache/<env>/pools/base/bundle.php.

A bundle may hold classes for a package it only suggests - a digest source implementing omnibase/newsletter's interface, an exporter extending a provider's class. Without that package, such a class cannot be declared (Interface "..." not found, or Class, Trait, Enum). The warm-up passes it by: it is not aliased, and the rest of the directory is. Nothing registers it without its package (the bundle's service configuration loads it only when the package is there), so nothing misses it. Any other error while loading a class

  • a parse error, a broken bundle - still stops the boot.

AbstractBaseBundle::classLoads($class) is that check (true, false, or the error rethrown); AbstractBaseBundle::getUnloadableClasses() lists what the warm-up passed by in this process, with the reason - the first place to look when an alias is missing.

Rebuilding an entity's previous state

The attributes that act on a change (#[Uploader] above all) compare an entity with its state before the change: AbstractAttribute::getOldEntity() rebuilds it, without its constructor, from Doctrine's original data through object_hydrate(). Doctrine keeps an enum field there as its backing value ("draft", not Status::Draft); object_hydrate() turns it back into the case (BackedEnum::tryFrom(), or the case of that name for a pure enum) whenever the property is typed with an enum, and leaves the property out when no case matches. property_enum_value(ReflectionProperty, $value) is that conversion, for any other code that fills typed properties by reflection.

Propose a change docs/20-architecture/index.md

What the core requires

Since 4.0 (branch 4.x, plan "omnibase agnostique", step C7) omnisense/omnibase requires its Foundation alone: what tests/Foundation.php loads, and what it stands on. The framework is no longer a requirement of the core: a Symfony application adds it, a Laravel application does not.

What Where it comes from
The Foundation PHP 8.4 and its extensions (exif, igbinary, imagick); Doctrine (orm, dbal, collections, persistence, event-manager, common) and its DQL functions; Twig and its extras (cache, cssinliner, inky, intl); the standalone Symfony components (asset, cache, error-handler, event-dispatcher, expression-language, filesystem, finder, http-client, intl, mailer, mime, notifier, options-resolver, password-hasher, process, property-access, serializer, string, translation, uid, validator, var-dumper, workflow, yaml, the contracts); the PSRs (cache, clock, container, event-dispatcher, log, simple-cache); league/flysystem; web-auth/webauthn-lib (Base\Entity\User\Passkey); omnibridge/doctrine and omnibridge/twig; the libraries (imagine, embed, qr-code, spreadsheets, barcodes...) require of the core
The framework FrameworkBundle, DoctrineBundle, SecurityBundle (security-core, -http, -csrf), TwigBundle and twig-bridge, MonologBundle, twig/extra-bundle, Config, DependencyInjection, HttpKernel, HttpFoundation, Routing, Form, Console, Messenger, Dotenv, doctrine-bridge omnibridge/symfony, which a Symfony application requires beside the core
The bundles the core works with scheb/2fa, league/flysystem-bundle, hwi/oauth-bundle, web-auth/webauthn-symfony-bundle, api-platform/symfony and doctrine-orm, symfony/webpack-encore-bundle, symfony/ux-twig-component, glitchr/ux-google, glitchr/well-known, nelmio/cors-bundle, nelmio/security-bundle suggest of the core; the application requires the ones its config/bundles.php registers

symfony/console is still installed with the core alone: doctrine/orm 3 requires it. Nothing of the Foundation loads it, which is what bare checks: "free of Symfony" reads nothing of the framework loaded, not absent from vendor.

Doctrine: the latest major, one line. The core follows each Doctrine package's current major, open upwards - doctrine/orm ^3.4, doctrine/dbal ^4.2 (omnibridge/doctrine's floors), doctrine/collections ^3.1 (which requires PHP 8.4, the core's floor), doctrine/event-manager ^2.0, doctrine/persistence ^4.0, doctrine/common ^3.0 - with no older branch kept alive, no upper bound and no pinned version; Doctrine is used as published (no patch, no fork). doctrine/dbal included (4.0, step C6b): no Composer plugin rewrites its schema managers; what omnibase needs of them goes through DBAL's own SchemaManagerFactory (Base\Bridge\Doctrine\SchemaManagerFactory, a SET column read with its values, the (DC2Type:...) comments of before left out). A new major is taken up when it comes, not maintained beside the old one.

A Symfony application

composer require omnisense/omnibase:4.0.*-dev omnibridge/symfony:1.x-dev

then the bundles its config/bundles.php registers, for an application made on omnibase's usual set:

composer require scheb/2fa league/flysystem-bundle hwi/oauth-bundle web-auth/webauthn-symfony-bundle \
    api-platform/symfony api-platform/doctrine-orm symfony/webpack-encore-bundle symfony/ux-twig-component \
    glitchr/ux-google glitchr/well-known nelmio/cors-bundle nelmio/security-bundle

An application coming from 3.x already had every one of them, through the core: requiring them itself keeps them (Composer would otherwise remove what nothing requires any more). No Flex recipe runs again for a package already locked: nothing changes in config/.

Composer: omnibridge/composer

The core requires one Composer plugin, omnibridge/composer (in place of glitchr/base-plugin until 4.0), and declares what it asks of it in its own composer.json, extra.omnibridge:

  • the stubs of App\Entity, App\Enum, App\Form, App\Notifier and App\Repository - one per plain class Base\<Name>\X the application does not define in src/<Name>/, the alias AbstractBaseBundle declares at run time - by the providers Base\Bridge\Composer\Stub\*, written after every dump-autoload into vendor/composer/omnibridge/stubs/, which no autoloader reads (for an IDE; never a definition at run time; StubsTest). The providers are loaded by the engine alone, outside the Foundation;
  • one patch, process-timeout-from-env on symfony/process: a Process built without a timeout waits SYMFONY_PROCESS_TIMEOUT seconds, 60 when unset.

An application allows the plugin:

"config": { "allow-plugins": { "omnibridge/composer": true } }

and checks vendor/ in its CI with composer omnibridge:patches --check (and composer omnibridge:stubs --check). composer omnibridge:patches --diff shows the patch.

Nothing else is changed in an application: its bin/console keeps its own line, use Base\Console\Application; in place of FrameworkBundle's (glitchr/base-plugin used to rewrite it at each install; a site made from the skeleton writes it once).

None required de facto

Up to step C7b two of them could not be left out; each has its fallback now, and the user class and the storages load without either:

  • scheb/2fa - Base\Entity\User implements Base\Contract\SecurityUser and Base\Contract\TwoFactorUser. Where symfony/security-core and scheb/2fa are installed, they are aliases of Base\Bridge\Symfony\SecurityUser (Security's UserInterface and PasswordAuthenticatedUserInterface) and Base\Bridge\Symfony\TwoFactorUser (scheb's TOTP, e-mail, backup-code and trusted-device interfaces): under Symfony the user implements exactly what it did. Elsewhere they are empty interfaces, and the user loads without them. Installed means declared, and nothing else decides (Base\Contract\Framework::declares(), step C7c): no environment variable, no switch - a Laravel application reading a Symfony application's vendor/ gets the user with Security's interfaces, which ask nothing of it but the methods the user has. Without the bundle registered (the package installed) the interfaces are still declared: only the two-factor sign-in itself is the bundle's. Where the framework is not installed is measured on such a vendor: bare loads the Foundation with the core installed alone (composer install --no-dev, see the harness). The users' repository follows the same rule (step R2): Base\Repository\UserRepository implements Base\Contract\SecurityUserLoader and SecurityUserProvider, aliases of Base\Bridge\Symfony\SecurityUserLoader (doctrine-bridge's UserLoaderInterface) and SecurityUserProvider (Security's UserProviderInterface and PasswordUpgraderInterface) where symfony/doctrine-bridge and symfony/security-core are installed, empty interfaces elsewhere. So do the passkeys' repositories (plan "omniauth", AU1): PasskeyRepository and WebauthnUserEntityRepository implement Base\Contract\PasskeyRecords and PasskeyUsers, WebauthnBundle's interfaces where it is installed.
  • league/flysystem-bundle - Base\Service\Flysystem (the flysystem service, which the file, image, upload, backup and cache-clear services read) is the core's own class, built from a PSR-11 container of storages by name: the services tagged flysystem.storage, which the bundle tags from the application's config/packages/flysystem.yaml. Without the bundle, FlysystemStoragesPass gives omnibase's local storages: local.cache (var/cache/<env>/images), local.uploads and local.wysiwyg (var/storage/...), each with its .public under public/.

The others have a fallback:

Bundle Without it
scheb/2fa the user still declares its interfaces when the package is installed; no two-factor sign-in
league/flysystem-bundle the flysystem service on omnibase's local storages (above)
hwi/oauth-bundle the service Base\Security\UserProvider is a UserProvider (with it, an OAuthUserProvider: the same, declared OAuth-aware); oauth_available() false
web-auth/webauthn-symfony-bundle PasskeyRepository and WebauthnUserEntityRepository declare nothing of it (Base\Contract\PasskeyRecords and PasskeyUsers are empty interfaces) and nobody asks them; the passkeys' table is the core's (Passkey and its three column types); no passkey ceremony
api-platform/symfony the decoration of its resource name collection is dropped; #[ApiResource] is an attribute nothing reads
symfony/webpack-encore-bundle encore_entry_*_tags (the core's own functions) print the core's build alone; the warmer reads no application build
symfony/ux-twig-component the twig_component defaults are not prepended
glitchr/ux-google AnalyticsSubscriber gets no ga.service; no captcha badge, no reCAPTCHA field
glitchr/well-known, nelmio/cors-bundle, nelmio/security-bundle nothing of the core names them

A Laravel application

Laravel does not install Symfony's framework: it requires the core and, when it exists, omnibridge/laravel, which fills the contracts (Base\Contract\Runtime, see The contracts) with Laravel's services. Doctrine stays the storage (laravel-doctrine/orm), Twig the view (omnibridge/twig). The pilot, lesringostars-laravel, does so already from a Symfony application's vendor/.

The core's own suite

require-dev holds omnibridge/symfony and the bundles the core's code names (scheb/2fa, league/flysystem-bundle, hwi/oauth-bundle, web-auth/webauthn-symfony-bundle, api-platform/symfony, symfony/webpack-encore-bundle, glitchr/ux-google), so that Dockerfile.test (composer install of the core alone) resolves what its kernel tests use. The harness's application (docker/Dockerfile) requires omnibridge/symfony and every bundle its config/bundles.php registers, as a site does.

Propose a change docs/20-architecture/packages.md

Entities and repositories

Repositories

Base\Database\Repository\ServiceEntityRepository is doctrine/orm's EntityRepository built from the ManagerRegistry (doctrine/persistence), as DoctrineBundle's service repository is - __construct(ManagerRegistry $registry, ?string $entityName = null), the entity guessed from the repository's name - plus the finder language (findByStateAndLocale(), cacheOneBySlug(), countByDomain()...). It does not extend DoctrineBundle's class: a repository loads without the framework (it is in tests/Foundation.php). Under Symfony every service extending it is tagged doctrine.repository_service (BaseExtension, registerForAutoconfiguration()), so an application's App\Repository\* is still handed out by getRepository() and autowired.

Counting gives an integer. count([]), count(['state' => 'draft']), countByState('draft') and distinctCountForLocaleByDomain('messages') each run one SELECT COUNT(...) and return an int, as Doctrine's count() promises. Only a grouped count (a $groupBy or a $selectAs argument) returns rows, each with its count (ThreadRepository::countForChildrenIn()).

What is registered as a repository. A bundle maps its src/Repository (setMapping() in its Bundle class): every class there is aliased under the application's namespace, and the ones that are repository services are registered (doctrine.repository_service, built with doctrine). A repository service is a class whose name ends with Repository, that can be instantiated and extends omnibase's ServiceEntityRepository or implements DoctrineBundle's ServiceEntityRepositoryInterface (AbstractBaseBundle::isRepositoryService()). An abstract base repository, an interface or a trait in that directory is aliased, never registered: a bundle may keep AbstractCatalogueRepository beside the repositories that extend it.

Repositories behind interfaces. The code of a site and of the core asks its repositories through interfaces named by what they answer, in the namespace of their entity (audit "lesringostars face au master plan", step R15): Base\Database\Repository\Entities - what every repository answers: find(), findAll(), findBy(), findOneBy(), count(), persist(), remove(), flush() -, and one per entity: Base\Repository\Threads, Users, Thread\Tags, User\Notifications, Layout\Settings; a site adds its own beside its repositories (App\Repository\Article\Articles extends Threads). Nothing of the ORM in what they take or give back: arrays of criteria and of order (field => ASC|DESC), the entities, lists of them, integers. The Doctrine repository implements its interface, each method by the finder call it replaces (Threads::oneBySlugAndState() is cacheOneBySlugWithRouteAndState()): same query, same result cache. Under Symfony each interface is an alias of the repository service (config/services/database.php; a site's own by #[AsAlias] on its repository); under Laravel the application binds each to the repository omnibridge/laravel resolves by its class. Another persistence implements the same interfaces over the same tables (the structure: schema.md).

The finder language (cacheOneBySlug()...) stays the repositories' own: an application calls the interface's methods, never the finder through it.

Translations

An entity using TranslatableTrait reads and writes its translated fields through translate():

$post->translate('de')->setTitle('Apfel');   // written in German, persisted at the next flush
$post->translate('ja')->getTitle();          // null when nothing is written in Japanese; creates nothing
$post->translate()->getTitle();              // what the current page shows
  • translate($locale) is that locale's translation. When the entity has none, it is a new, empty one the entity holds aside (out of getTranslations()) until a value is written in it: a read creates no translation. A written one joins the collection at the next getTranslations() or flush (commitPendingTranslations(), called by IntlSubscriber before each flush).
  • translate() without a locale is the translation to show on the current page: the page's language when something is written in it, else the same language in another region (pt-BR on a pt-PT page), else the default locale's, else the first available locale's, else any written one.
  • The magic getters ($post->getTitle(), post.title in Twig) read translate(), then the default locale when the value is null.

An isEmpty() translation is never persisted (IntlSubscriber::onFlush).

Countries

{{ code|country_name }} names a country from its ISO 3166-1 code, in the page's language; {{ code|country_name('de') }} in the one given (Localizer::getCountryName(), on symfony/intl). JP is "Japon" on a French page; alpha-3 (JPN) is read too; an unknown code is printed as it is. The country of a locale (fr-BE → "Belgium") is |locale_country_name.

A taxon's slug is unique per type

Base\Entity\Thread\Taxon is the root of a JOINED hierarchy: a FAQ's Section, a classroom's Subject, a menu's MenuSection, a site's own Category all share the table threadTaxon and its slug column. The slug is unique among the taxa of one type - the unique index is on (class, slug), class being the discriminator - so a menu's section and a blog's category may both be desserts. (It was unique on the column alone: the second one became desserts-2, whatever its type.)

#[Slugify(reference: 'translations.label', perType: true)] is what numbers a slug only against the entities of the same class; any hierarchy that shares a slug column may use it.

The migration an application generates after updating omnibase (bin/console doctrine:migrations:diff), on threadTaxon: the unique index on slug is dropped, a unique index thread_taxon_type_slug on (class, slug) and a plain index thread_taxon_slug on slug are created. No row changes; the new constraint is weaker than the old one, so it always applies. Until that migration has run, omnibase sees the old index and keeps numbering slugs for all types together: nothing fails in between.

A taxon is found by its slug through the repository of its type (SectionRepository::findOneBySlug()): TaxonRepository itself may now find several types under one slug.

The second-level cache after the fixtures

A site that turns on Doctrine's second-level cache (doctrine.orm.second_level_cache) caches its entities by id. A command that rewrites the database behind the ORM's back leaves that cache wrong: doctrine:fixtures:load purges the tables with DELETE / TRUNCATE, the new rows are numbered from 1 again, and the cache answered yesterday's entity for today's id until it expired.

Base\Subscriber\SecondLevelCacheConsoleSubscriber empties every region (entities, collections, queries) of every entity manager when one of these commands ends: doctrine:fixtures:load, doctrine:schema:{drop,create,update}, doctrine:database:{drop,import}, doctrine:migrations:{migrate,execute}, dbal:run-sql. Nothing to configure; cache:pool:clear by hand after loading fixtures is no longer needed. A command of your own that writes SQL directly calls it: $subscriber->evict().

Propose a change docs/20-architecture/entities-and-repositories.md

The contracts of the Foundation

What omnibase's framework-free code (an entity's getter, an attribute, a value object) asks of the application it runs in, by an interface, never by a Symfony service (plan "omnibase agnostique", step C4). Each is reached through one registry, Base\Contract\Runtime, which the framework fills at boot.

The host's contracts are omnisense/omnibridge's (plan "omnibase agnostique", A2). What any host answers - addresses, translations, languages, access, sessions, tokens, stored files, routes, environment - lives in the omnibridge family's core, omnisense/omnibridge, as Omnibridge\Contract\*, with the registry itself (Omnibridge\Runtime, its MissingContractException and SystemClock). What describes omnibase's own models stays here as Base\Contract\*: Nouns, Media, Pictures, Texts, the screens' ContextProvider, the user's SecurityUser/TwoFactorUser, its repository's SecurityUserLoader/SecurityUserProvider and the passkeys' repositories' PasskeyRecords/PasskeyUsers. Base\Contract\Runtime is omnibridge's registry (the same state) with omnibase's accessors beside it: parameters(), nouns(), media(), pictures(), texts(), mailer(). The former names (Base\Contract\Urls... and omnibridge/twig's Omnibridge\Twig\Contract\Urls and Translations), aliases of the moved interfaces since A2, were removed at omnibase 4's final pass: name Omnibridge\Contract\*.

Contract What it answers Under Symfony
Omnibridge\Contract\Urls (formerly Base\Contract\Urls) (omnibridge/twig's Urls, whose url() it declared itself before omnibridge/twig d786505) path() and url() of a route, asset() of a public file omnibridge/symfony's Omnibridge\Symfony\RouterUrls (formerly Base\Bridge\Symfony\RouterUrls, step A1): advanced_router, the asset packages - Twig's path(), url(), asset()
Omnibridge\Contract\Environment (formerly Base\Contract\Environment) (step C4b) the environment's name (prod, dev, test), debug, the application's secret omnibridge/symfony's Omnibridge\Symfony\Contract\KernelEnvironment (formerly Base\Bridge\Symfony\KernelEnvironment): kernel.environment, kernel.debug, kernel.secret - BaseService::getEnvironment(), isDebug(), getSalt()
Base\Service\ParameterBagInterface (step C3c; in the registry since C4b) the application's parameters by a dotted path parameter_bag: HotParameterBag, the settings stored in the database over the container's - BaseTrait::getParameterBag(), and the autowiring of this interface since C9; where there is no container, Base\Service\ArrayParameterBag, and Base\Service\HotParameters over it for the settings (below)
Omnibridge\Contract\Routes (formerly Base\Contract\Routes) (step C4b) what the routes are beyond their addresses: a route's declared path (pattern()), the route an address leads to (match()), a route's icons Base\Bridge\Symfony\RouterRoutes: advanced_router (its route collection, getRouteName()) and the IconProvider
Omnibridge\Contract\Translations (formerly Base\Contract\Translations and omnibridge/twig's Translations, one interface since A2) trans(), and whether the catalogue holds a key (exists()), for a code that tries several keys omnibridge/symfony's Omnibridge\Symfony\TranslatorTranslations (formerly Base\Bridge\Symfony\TranslatorTranslations): the same chain - Base\Service\Translator::transExists()
Base\Contract\Nouns (step C4b) what an entity is called: its noun (entity()), singular or plural, a gender, a level of politeness, a property's label; entityExists() Base\Service\Model\EntityNouns from Base\Service\Translator::getEntityNouns(): the rule transEntity() answers by
Omnibridge\Contract\Access (formerly Base\Contract\Access) isGranted() an attribute, on a subject (a Thread...), getUser() omnibridge/symfony's Omnibridge\Symfony\Contract\SecurityAccess (formerly Base\Bridge\Symfony\SecurityAccess): the authorization checker and the token storage - BaseService::isGranted(), Twig's is_granted()
Omnibridge\Contract\Sessions (formerly Base\Contract\Sessions) (step C4b) a user signed out when it is the visitor signed in (signOut()), false otherwise omnibridge/symfony's Omnibridge\Symfony\Contract\SecuritySessions (formerly Base\Bridge\Symfony\SecuritySessions): the token storage, the REMEMBERME cookie on the path and the site's domain
Omnibridge\Contract\Locales (formerly Base\Contract\Locales) the current locale, the default, the available ones (fr-FR) Base\Bridge\Symfony\LocalizerLocales: the localizer
Omnibridge\Contract\Files (formerly Base\Contract\Files) a stored file's public path and web address, its local path, whether its storage is remote Base\Bridge\Symfony\FlysystemFiles: flysystem, and the image route for a remote storage, as the uploader resolves it
Base\Contract\Media (step C4b) the image route for a stored image: the extension it is served with, its obfuscated data, the route's address Base\Bridge\Symfony\ServiceMedia: MediaService (getExtension(), obfuscate(), generate())
Base\Contract\MediaRouter (audit R6) what the media rule asks a router: a named route's address, an address's route and parameters, its name, a public file's address, an address as it is - FileService, MediaService and Base\Service\ImageRoute (the image route's answers, ROUTES) take it advanced_router (AdvancedRouterInterface extends it); omnibridge/laravel's over Laravel's router
Base\Contract\Pictures (step C5) an image's address at a size, what |picture answers Base\Bridge\Symfony\MediaPictures: CommonsTwigExtension::picture() (MediaService's thumbnails)
Base\Contract\Texts (step C5) a stored text rendered (Editor.js or HTML), its table of contents, whether it is Editor.js Base\Bridge\Symfony\WysiwygTexts: the pipeline of |wysiwyg and |wysiwyg_toc
Omnibridge\Contract\Tokens (formerly Base\Contract\Tokens) (step C5) the token a form carries for an intent (csrf()), what a screen's actions send back omnibridge/symfony's Omnibridge\Symfony\Contract\CsrfTokens (formerly Base\Bridge\Symfony\CsrfTokens): the CSRF token manager - Twig's csrf_token()
Psr\Clock\ClockInterface (PSR-20) now() the framework's clock (symfony/clock); the machine's (Omnibridge\SystemClock) when there is none
Psr\EventDispatcher\EventDispatcherInterface (PSR-14) dispatch() event_dispatcher
Symfony\Component\Mailer\MailerInterface send() mailer.mailer

Locales is plural, as omnibridge's Urls and Translations are, and so that importing it never shadows PHP's \Locale.

A token per intent, under Symfony only. Symfony's CSRF tokens are one per intent (2fa_disable, passkey_delete_12...): a screen's action carries the token of its own intent, and the controller checks that one. Laravel has a single session token for every intent: an action pointing at a Symfony route, rendered by Laravel, would carry a token Symfony refuses. Nothing does that today - the Laravel twin shows no account page; a framework rendering another's actions binds Tokens to the other's tokens, or posts to its own routes.

No contract for the mail. symfony/mailer is a standalone component, allowed in the Foundation (decision D1), and Laravel's own mail is built on it: its MailerInterface is the contract, and another framework hands a Mailer over its own transport.

BaseTrait and BaseService: the application's shortcuts (step C9)

The rule: BaseTrait to go fast in an application; the contracts in the core and the bundles. Base\Traits\BaseTrait (static accessors usable from an entity, an attribute, a subscriber) and Base\Service\BaseService (its facade) are the tool for writing an application quickly, and they stay - neither is deprecated, nor removed in 5.0. The core and its bundles add no new use of either: a class of theirs asks Base\Contract\Runtime (or is given the service it needs), so that it runs where there is no Symfony.

What changed in C9:

  • BaseService is a lazy service. The container hands out a ghost of the class (PHP 8.4's lazy objects, not a proxy subclass), built with its 23 services the first time one of its methods reads a property. Injected in a class that never calls it, it builds nothing.
  • Nothing on a page's path injects it any more. SiteVariable and AdminVariable (app.site, app.admin) and EmailVariable read the settings from the SettingBag and the route index from the router; FormTypeBootstrapExtension the parameters; ErrorController the Environment contract (Base\Service\Model\DevelopmentEnvironment, isDevelopment()'s rule); MainController and UserSettingsController never called it. The core's getService()->isDebug(), getSalt(), isGranted(), isEntity() calls are Runtime::environment()->isDebug(), ->secret(), Runtime::access()->isGranted(), and BaseTrait's static isEntity() (the class metadata manipulator).
  • What a contract answers, they ask the contract when the registry has it, so they answer outside Symfony too, and the methods below read no property of BaseService - called on the ghost, they build nothing:
Accessor Asks Else (no contract in the registry)
BaseTrait::getParameterBag() Runtime::parameters() (under Symfony parameter_bag, the same object) the base.runtime locator's parameter_bag
BaseTrait::getEnvironment() Runtime::environment()->name() the kernel's environment, seeded at boot
BaseService::isDebug() Runtime::environment()->isDebug() the kernel
BaseService::isDevelopment() DevelopmentEnvironment::is(Runtime::environment()) the same rule on the kernel
BaseService::getSecret(), getSalt() Runtime::environment()->secret() kernel.secret
BaseService::isGranted() Runtime::access()->isGranted() the authorization checker

The other accessors hand out the framework's services, typed as they always were (getRouter(), getTranslator(), getLocalizer(), getTwig(), getTrading(), getDoctrine(), getTokenStorage()...): no contract has their type, so they stay on the lazy base.runtime locator, one service built on its first call.

The bundles' uses (for step C8, which moves them to the contracts; nothing changed in the bundles): no bundle injects BaseService or calls getService(). Fourteen entities use BaseTrait - marketplace's Order (getTrading() x8, getTranslator() x3, getRouter(), getParameterBag()), Product (getParameterBag(), getRouter(), getTranslator(), getTwig()), Order\OrderItem (getTrading() x3), Order\Method\PaymentMethod (getParameterBag()), ShippingMethod (getParameterBag() x2), Sales\Attribute\Rule\TotalPriceAdapter (getTrading()); forum's Category (getRouter()); and, calling none of its accessors, marketplace's Sales\Discount, Fee, Sales\Attribute\Action\FixedAmountAdapter, forum's Post, mailbox's Conversation, classroom's Entitlement and Resource.

The parameters and the settings over them (step C9)

Base\Service\DottedParameterBag (Foundation) reads parameters given by their full names (base.user.active_delay => 300) by a dotted path - a path naming the values under it (base.user) gives them all. ArrayParameterBag (an array) and ParameterBag (the Symfony container's parameters, and DependencyInjection's two interfaces) read through it. has() is whether get() finds a value: a parameter set to false, 0 or "" is there, one set to null is not (it was the value's truth).

Base\Service\HotParameters (Foundation) lays the settings stored in the database - a back office's setting linked to a parameter - over any ParameterBagInterface. get() reads, in this order, by the presence of a value:

  1. the $bag given to the call,
  2. the settings (add(); when the overlay is on and $useHotBag is true),
  3. the configuration.

A setting stored as false wins over the configuration's true; a setting whose value is null (none was typed) is no value, the configuration's is read. The overlay is on when base.parameter_bag.use_hot_bag is true; off, the configuration is read (it read nothing at all before: every parameter was null unless a $bag was given). has() reads the same sources in the same order.

Under Symfony parameter_bag is HotParameterBag, that overlay over ParameterBag as DependencyInjection's ParameterBagInterface and ContainerBagInterface; it refuses clear() and remove() as the frozen bag does, and HotParameterBagSubscriber gives it the settings on the first request or command. parameter_bag, the autowiring of Base\Service\ParameterBagInterface (which gave the plain ParameterBag, without the settings: an autowired EditorController, AttributeReader read the configuration alone), DependencyInjection's interfaces, Runtime::parameters() and BaseTrait::getParameterBag() are one object (ParameterBagServicesTest). Elsewhere: new HotParameters(new ArrayParameterBag([...]), true) and add() the settings.

Base\Service\SettingBag (the settings themselves) keeps its snapshot in a PSR-6 pool (Psr\Cache\CacheItemPoolInterface, no longer Symfony's CacheInterface); its cache warmer is Base\Bridge\Symfony\SettingBagWarmer. It is in the Foundation: Doctrine and psr/cache.

The registry

use Base\Contract\Runtime;

Runtime::urls()->path('blog_article', ['slug' => $slug]);
Runtime::access()->isGranted('ROLE_ADMIN');
Runtime::translations()->trans('@entities.article._singular', [], null, Runtime::locales()->getLocale());
  • Filled at boot. BaseBundle::boot() hands it the base.contracts locator (ContractsPass): each contract by its interface's name, bound to the service the container gives that name - an application rebinds one by aliasing the interface. Only the contracts whose service exists are listed, and a locator holds closures: nothing is built before it is asked. Another framework calls Runtime::setContainer() with a PSR-11 container whose ids are the interfaces' names (Laravel's application is one), or Runtime::set() one contract at a time (it wins over the container's).
  • Loud when empty. A contract asked and not filled throws Base\Exception\MissingContractException, which names it - where a getter through BaseTrait that found no service returned null in silence. Runtime::find() answers null instead, for a caller that can do without.
  • One kernel at a time. A different container forgets what the previous one gave (as BaseCommonTrait::setRuntime() does); Runtime::reset() forgets everything (a test's tear-down).

The contracts, the registry, SystemClock and the adapters load without the framework (tests/Foundation.php); tests/BareTest.php runs tests/Contract/runtime-without-framework.php with the autoloader alone: plain PHP implementations bound as another framework binds them, and a user of the contracts that answers through them. tests/Bridge/Symfony/ContractAdaptersTest compares every adapter's answers with the services and the Twig functions they stand for.

The entities' __toLink(array $routeParameters = [], int $referenceType = ...) take Symfony's reference types. Link::to($name, $parameters, $referenceType) makes the address through Runtime::urls(): Link::ABSOLUTE_PATH (1) is path(), Link::ABSOLUTE_URL (0) is url() - the values of UrlGeneratorInterface's constants, named here so that a link is made without the routing component (LinkableInterface's default is Link::ABSOLUTE_PATH). A relative or a network path has no equivalent in the contract: it is asked of the implementation's own generate($name, $parameters, $referenceType) when it has one (RouterUrls, the router as before) and refused otherwise. It is a helper, not a contract.

The entities' getters on the contracts (step C3e)

What an entity of the core asked of BaseTrait's services and now asks of the registry, with the same answers under Symfony (tests/Contract/EntityGettersTest compares each with the service it used) and answers where there is no framework (tests/Contract/entities-without-framework.php, run by BareTest):

Getter Before Now
Semantic::__toLink(), getUrl() getRouter()->generate() Link::to() (a route the router cannot make: null, as before; no Urls: MissingContractException)
ShortLink::__toLink() getRouter()->generate() (by __call, on the current translation's BaseTrait: the Localizer first) Link::to()
Thread::isVisible() getService()->isGranted('ROLE_ADMIN'), and the owners compared with the token storage's token (never one of them: never true) Runtime::access()->isGranted('ROLE_ADMIN'); the token line dropped, the answers unchanged
User::isGranted($role) getService()->isGranted($role, $this) Runtime::access()->isGranted($role, $this) (User itself still loads the framework: Security's UserInterface, Scheb's)
TranslatableTrait::translate() (every translatable entity's titles, its __call getters and setters), its __get and __isset fallbacks, addTranslation() BaseService::getLocalizer(): getDefaultLocale(), getAvailableLocales(), getLocale($locale), getLocaleLang() Runtime::find(Locales::class) (none: null, as when no Localizer was found) and LocaleNormalizer
TranslationTrait::getLocale(), setLocale() Localizer::normalizeLocale() (its statics, filled when the Symfony service is built) LocaleNormalizer::here(): the rule with the Locales contract's default and available locales

Base\Service\Model\LocaleNormalizer is the Localizer's rule for omnibase's xx-XX form, taken out of it: Localizer::normalizeLocale(), __toLocaleLang() and getLocales() now ask it with the Localizer's own statics, so the strings are the same by construction; the entities ask it with the Locales contract's (here()). The test keeps a copy of the rule as it was and compares both paths with it.

The screens (step C5)

A controller hands a template and a view model (Base\Screen\View: template, Base\Screen\Screen, the React component's name, the status) to Base\Bridge\Symfony\ScreenResponder - Symfony's adapter of the framework-free Base\Screen\Negotiation (step R10; omnibridge/laravel's Screen\Responder is Laravel's), which also answers what an action written once for every host hands back: a Template (its data in JSON too when it allows it), Json (its templates rendered), a Shell (omnibridge/react's protocol), a File, or null for no such page -, which renders it with the request's frame (Base\Screen\ContextProvider, under Symfony SymfonyScreenContext: locale, site, links, viewer, theme, a site's own fields; the flashes) - Twig with frame and screen, or omnibridge/react's page in JSON (X-Omnibridge; without omnibridge/react, a request asking application/json alone gets the props). A JSON answer carrying a viewer is private and varies on Cookie. The view models (Base\Screen\*: Frame, Site, Viewer, Author, Tag, Link, StoryText, Card, Story, Comment, Reply, Discussion) keep lesringostars' prop names (App\Screen\*, the pilot's); Base\Screen\Builder\Stories makes them from a Thread and its comments with the Texts and Pictures contracts and the clock (recent, so that no template compares a moment). All of them are in the Foundation (tests/Screen/screens-without-framework.php).

The account's pages are screens too (C5, its second part): a member's profile, the settings, the notifications (Base\Screen\Account\{ProfilePage, SettingsPage, NotificationsPage} and their parts), built by Base\Screen\Builder\Accounts from the entities and the security policy, with the Urls and Tokens contracts and the clock (a notification's group, in Twig's time zone), and handed by the controllers with Base\Bridge\Symfony\AccountScreens (what only Symfony knows: the viewer, an avatar's thumbnail, the providers, web push). A visitor's profile carries what its page shows them, nothing of the account. The settings' small forms are Actions (an address and the token of its intent); a screen carrying tokens is Base\Screen\Confidential, whose answers are private, no-store and vary on Cookie. A screen's props never take one of the frame's keys (Frame::RESERVED): the responder refuses it. A template a site overrides gets the variables of before as well (ScreenResponder::legacy()); a host page renders the core's profile template with the user alone, account_profile(user) giving it the model. The forms (profile edit, sign-in, sign-up, passwords, two-factor setup) stay Symfony's (D3).

What the classes read through BaseTrait, on contracts (step C4b)

What a class of the core still asked of BaseTrait's services after C3e, now asked of the registry, in the order lesringostars' pages go through it. Each move keeps the answers under Symfony (a test compares them with the service's, its old code copied as it was) and gives answers where there is no framework (tests/Contract/basetrait-seams-without-framework.php, run by BareTest). BaseTrait itself stays, its accessors' signatures unchanged, for the bundles and the sites that call them (since C9 the ones a contract answers ask it, above).

Class Before Now
Thread::__toString(), Widget::__toString() without a title getTranslator()->transEntity(static::class) Runtime::nouns()->entity(static::class) (NounsTest)
Thread::getPublishTimeStr(), Token::getRemainingTimeStr(), getThrottleTimeStr() getTranslator()->transTime($seconds) Duration::spell($seconds, Runtime::translations()) (DurationTest: the texts rewritten in the back office printed as before)
Image::__toLink() getMediaService()->getExtension(), obfuscate(), then getRouter()->generate() Runtime::media()->extension(), obfuscate(), then Link::to() (MediaTest)
ImageCrop::__toLink() getMediaService()->getExtension() (after a getExtensions() whose answer it overwrote), generate() Runtime::media()->extension(), link()
Semantic::getPath(), Widget\Route::getPath() getRouter()->getRouteCollection()->get($name)?->getPath() Runtime::routes()->pattern($name) (RoutesTest)
Semantic::getRoute(), Widget\Route::getRoute() getRouter()->getRoute($this->getUrl()): a Route object handed to a ?string return type, a TypeError for any route found Runtime::routes()->match($url): the name of the route the address leads to
Semantic::getRouteIcons(), Widget\Route::getRouteIcons() and its __iconize() getIconProvider()->getRouteIcons($name) (every route's icons for no route) Runtime::routes()->icons($name) (null for no route)
Widget\Route::__toLink(), getUrl(), generate() getRouter()->generate() Link::to(), as Semantic since C3e (a route the router cannot make: null; no Urls: MissingContractException)
User::getActiveDelay(), getOnlineDelay() self::getParameterBag()->get() Runtime::parameters()->get() (EnvironmentTest)
User::isTester() getService()->isDebug(), getService()->getParameterBag('base.notifier.test_recipients') Runtime::environment()->isDebug(), Runtime::parameters()->get()
Token::__construct() getService()->getParameterBag('base.user.token_default_throttling'), getService()->getSalt() Runtime::parameters()->get(), Runtime::environment()->secret() (a token is now built where there is no framework)
VaultTrait::setSecure() getEnvironment() Runtime::environment()->name()
User::getLocale(), setLocale() without a locale of the user's Localizer::__toLocale(getTranslator()->getLocale()) Runtime::locales()->getLocale() (SessionsTest: the same for every language the site speaks; for a bare language it does not speak, the Localizer's form, ja-FR, as the rest of the page, where intl's country came before, ja-JP)
User::logout() getTokenStorage(): the token forgotten, the cookies cleared, through getRouter()->getDomain(); else kick() Runtime::sessions()->signOut($this), else kick()

Base\Entity\User reads nothing through BaseTrait any more (it still uses it: the sites call its accessors on a user).

Where C4b stops. Left on BaseTrait, each for a reason: Notification (the notifier, Twig and the project's directory - its message half is already Bridge\Symfony, step C3f; the notifier is a Symfony service by nature), AbstractExtension::isEntity() (Doctrine's metadata, a question for Bridge/Doctrine), Uploader's AttributeReader (omnibase's own service, already in the Foundation), MediaEnhancer (the media service inside the Wysiwyg pipeline, which Texts already covers from outside), the forms, fields, controllers and validators (Symfony by nature, decision D3), and the bundles' entities (step C8).

What an entity is called: Base\Service\Model\EntityNouns. The rule of Translator::transEntity(), transEntityExists() and parseClass() (the key of a class: its name less its entity namespace, dotted, in snake case - Base\Marketplace\Entity\Order is marketplace.order; then the variants its options ask for, NounVariants, the first the "entities" catalogue holds) moved into a class of the Foundation, built on the Translations contract and the entity namespaces of the application's bundles. The translator now answers by an instance of it over itself (getEntityNouns(), the bundles BaseBundle knows), and that instance is the Nouns contract under Symfony: one rule, the same strings. A host without the framework builds one with new EntityNouns($translations, EntityNouns::namespacesOf([...its bundle classes])) and binds it to Base\Contract\Nouns.

A number of seconds in words: Base\Service\Model\Duration. Translator::transTime()'s words (base.years to base.seconds, ICU plurals; a month of 30 days) over the Translations contract (trans()). Under Symfony that is the translator chain, so a unit the team rewrote in the back office is the one printed, as before; transTime() now answers by it.

Propose a change docs/20-architecture/contracts.md

The Doctrine mapping, with or without Symfony

omnibase assembles part of its entities' mapping at runtime: the attributes read by Base\Attributes\AttributeReader (#[DiscriminatorEntry], #[Alias], #[Hierarchify], #[Cache], #[OrderColumn], #[Cascade], #[OrphanRemoval], #[Vault]...), the translations' associations and discriminators (IntlSubscriber), the enumerations as column types, the trash_filter and the vault_filter, the DQL functions. Since step C6 of the plan "omnibase agnostique" all of it is wired by Base\Bridge\Doctrine - under Symfony through the bundle, as before, and on an entity manager built by hand without DoctrineBundle - on doctrine/orm as published: nothing needs a member Doctrine keeps private, and nothing patches doctrine/orm (since 4.0 the core declares its one patch, on symfony/process, to omnibridge/composer: extra.omnibridge).

Without Symfony

use Doctrine\DBAL\DriverManager;
use Omnibridge\Doctrine\EntityManagers;
use Base\Bridge\Doctrine\Mapping;

$connection = DriverManager::getConnection(['driver' => 'pdo_mysql', 'host' => ..., 'dbname' => ...]);
$entityManager = EntityManagers::create($connection, [
    $vendor.'/omnisense/omnibase/src/Entity',
    $projectDir.'/src/Entity',
    // a bundle's src/Entity...
], configure: Mapping::configure(...));   // the repository factory, before the entity manager exists
$mapping = Mapping::register($entityManager, [
    'project_dir' => $projectDir,          // its src/Enum, its src/Attribute
    'environment' => 'prod',               // the vault filter's
    'types' => [/* a bundle's enumerations, by name */],
    'resolve_target_entities' => [/* interface => class */],
]);

$articles = $entityManager->getRepository(App\Entity\Blog\Article::class)->findBy([], ['id' => 'DESC'], 10);
$article = $entityManager->createQuery('SELECT a FROM App\Entity\Blog\Article a WHERE a.slug = :slug')->setParameter('slug', 'le-fuji')->getOneOrNullResult();

Mapping::register() is called before the first metadata is read. Doctrine's own ClassMetadataFactory is used; the class names are the core's (D6).

What Symfony (the bundle) By hand (Mapping::register())
Column types: datetime/datetimetz in UTC, array, utc_datetime(_immutable), the enumerations BaseExtension::prependDoctrineTypes(), BaseBundle::boot() → Base\Bridge\Doctrine\Types::register() Types::register()
Filters (trash_filter, vault_filter), DQL functions (RAND, FIELD, JSON_*), the default hint DoctrineConfigurationPass → Base\Bridge\Doctrine\Configuration's lists; filters enabled at boot (Configuration::enableFilters()) Configuration::apply(), enableFilters()
The driver: discriminator maps generated, the resolveDiscriminator event, the table names checked DoctrineConfigurationPass wraps every entity manager's driver in Base\Bridge\Doctrine\MappingDriver the same wrapper
Naming naming_strategy: Base\Database\Mapping\NamingStrategy set on the configuration
The factory class_metadata_factory_name: Base\Database\Mapping\ClassMetadataFactory (kept: the naming strategy of a cached ClassMetadata, isEntity(), getAllClassNames()) Doctrine's own
Listeners: AttributeSubscriber, IntlSubscriber, the targets resolved, the entity listeners attached, TrackingPolicySubscriber, ExtensionSubscriber; on flush TrasheableSubscriber, VersionableSubscriber, InverseCollectionCacheSubscriber tags doctrine.event_listener (config/services/media.php), in their priorities' order added in that order
Repositories DoctrineBundle's ContainerRepositoryFactory (the services) Base\Bridge\Doctrine\RepositoryFactory, set by Mapping::configure() as omnibridge/doctrine's configure: (from 4d2436d), before the entity manager exists: its own getRepository() builds a repository taking a ManagerRegistry from Base\Bridge\Doctrine\Registry. $mapping->getRepository() (deprecated) hands the same one, and is the way for an entity manager built without configure: (Doctrine's default factory)
App\Entity\X = Base\Entity\X BaseBundle::warmUp()'s aliases, AbstractBaseBundle's fallback autoloader Base\Service\Model\ClassAliases::register() (the same convention)

What the hand-built side does not have: the framework's services. The AttributeReader is given no router, request stack, token storage, filesystem nor event dispatcher (an asset's base path, the user signed in, the routes' attributes answer without them), IntlSubscriber no localizer, and the parameters are those given (Base\Service\ArrayParameterBag). Symfony's doctrine-bridge types (uuid, ulid, date_point...) are Symfony's alone.

The discriminator maps

A root entity that declares an inheritance and no #[DiscriminatorMap] gets one generated by MappingDriver, after the attributes are read: the class and every subclass the driver knows, each under its class name, which #[DiscriminatorEntry] (AttributeSubscriber, loadClassMetadata) then renames (common, marketplace_product, forum_topic...). Doctrine generates such a map itself, under the classes' lower-cased short names - which collide here (App\Entity\Thread\Tag and Base\Entity\Thread\Tag are both tag) - and its getShortName() is private: until C6 glitchr/base-plugin made it protected so that omnibase's factory could answer the class name. The map is now there before Doctrine looks, with the keys the factory gave.

The resolveDiscriminator event (Base\Database\Events) is dispatched by the driver before a class is read, as the factory dispatched it: IntlSubscriber gives a translation class (ThreadIntl...) the inheritance and the map of its translatable.

The proof

tests/Bridge/Doctrine/HandBuiltEntityManagerTest (the harness, or an application's vendor suite):

  • an entity manager built by hand in a PHP process of its own (tests/Bridge/Doctrine/hand-built.php: Composer's autoloader, a DBAL connection, EntityManagers::create(), Mapping::register()) gives the metadata Symfony's gives, class by class - Base\Bridge\Doctrine\MetadataSnapshot: every public property of each ClassMetadata (table, inheritance, discriminator map and value, fields and their types, associations, cache, callbacks, entity listeners, repository), what each filter adds to a query of it, the completor the attributes filled (aliases, hierarchy), the types registered - on the same database, and reads the thread Symfony wrote; the process loads neither the kernel nor a bundle;
  • Symfony's entity manager gives the metadata it gave before C6 (fixtures/doctrine-metadata.json, recorded on the code of before with DOCTRINE_SNAPSHOT=1; the discriminator maps and the subclasses compared sorted: generated, they come in the order PHP declared the classes).

Since step R14 the mapping driver also reads the classes' declarations rather than the attributes (omnibridge/doctrine's DeclarationDriver): the structure is described once, outside Doctrine - see schema.md.

The harness runs it on doctrine/orm as released: docker compose -f compose.yml -f compose.checkouts.yml run --rm -v <doctrine/orm's release>/src:/srv/demo/vendor/doctrine/orm/src:ro omnibase test.

Propose a change docs/20-architecture/doctrine.md

The data structure, described outside Doctrine

Marco's bridge principle (2026-10-19): the omni bridges are only the adapters of contracts for one technique, so that several persistence techniques can map the same tables - Doctrine today, Eloquent or another tomorrow - without changing the structure (the naming, the columns, the relations, the discriminators). Since step R14 of the audit "lesringostars face au master plan", that structure is described once, outside any ORM, and Doctrine reads it.

Where it lives, and why

  • omnisense/omnibridge, Omnibridge\Schema (framework-free, ORM-free): the generic part. Schema\Declaration reads what the entity classes declare from their attributes by reflection, without instantiating them - the attributes are Doctrine\ORM\Mapping's, the vocabulary every omnibase entity is written in, but their classes need not exist (Vocabulary holds their parameters and defaults as data). Schema\Description is the neutral description: each Entity (table, kind, parent and root, Inheritance with its column, map and value, identifier, Fields with their portable types, Relations with their JoinColumns or JoinTable) and each Table (Columns, primary key, Indexes, ForeignKeys), plain data too (toJson()). Schema\Builder builds it from the declarations alone, the way Doctrine completes its metadata and the way its SchemaTool makes the tables; Schema\Naming (CamelCaseNaming) names what the declarations leave unnamed, including an index or a foreign key as DBAL names it. Schema\Database\MySqlSchema reads a MySQL information_schema and compares it with a description.
  • omnibridge/doctrine: the Doctrine side. DeclarationDriver builds Doctrine's metadata from the declarations (wrapping the application's driver, which still says which classes exist); MetadataDescription writes what an entity manager maps in the neutral form; NamingStrategy answers Doctrine's naming strategy with the family's Naming.
  • the core, Base\Database\Schema (Foundation): what is omnibase's own - the rules its listeners add to Doctrine's metadata, written once more as Omnibridge\Schema\Builder\Rules applied at the same three moments (before the declaration is read, once declared, once loaded): DiscriminatorRule (the maps generated under class names, #[DiscriminatorEntry]'s values), VaultRule (#[Vault]'s unique constraints), OrderColumnRule (#[OrderColumn]'s json column), TranslationRule (a translation's inheritance from its translatable, the translations and translatable relations, locale, <table>_unique_translation), TargetRule (the targets DoctrineBundle resolves) and ExtensionRule (extension_entity); and Describer, which builds an application's description from its entities' folders with omnibase's naming and rules.

The split follows the A1/A2 rule of the plan: generic glue for a host or a technique goes to omnibridge, what reads omnibase's own models stays in the core.

How Doctrine uses it

Under Symfony DoctrineConfigurationPass wraps every entity manager's driver in omnibridge/doctrine's DeclarationDriver, then in Base\Bridge\Doctrine\MappingDriver; Base\Bridge\Doctrine\Mapping::register() does the same on an entity manager built by hand. Doctrine thus no longer reads the attributes itself: its metadata are built from the declarations - the same source the ORM-free description is built from - and they are those its attribute driver built, to the property (MetadataSnapshot, every class, on both hosts).

use Base\Database\Schema\Describer;

$description = Describer::describe([$vendor.'/omnisense/omnibase/src/Entity', $projectDir.'/src/Entity'], ['project_dir' => $projectDir]);
$description->table('thread')->column('slug');                    // string(255), not null
$description->entity(Base\Entity\Thread::class)->relation('tags'); // many_to_many, join table thread_tags
file_put_contents('var/schema.json', $description->toJson());

The proof

tests/Bridge/Doctrine/SchemaDescriptionTest (the harness, or an application's suite run from vendor/):

  • the entity manager's driver is the DeclarationDriver;
  • the description Describer builds without any ORM is MetadataDescription's of the entity manager, entity by entity and table by table (the harness: 202 classes; lesringostars: 115), and #[DiscriminatorEntry]'s values derived without the attribute's reader are Doctrine's;
  • on MySQL (lesringostars), the description is the database's own schema: every table, column (SQL type, nullable, default, auto-increment), primary key, index and foreign key read from information_schema, nothing of Doctrine asked (doctrine_migration_versions and messenger_messages apart).

How another persistence would read it

An Eloquent (or any other) bridge needs no attribute of Doctrine's and no class of the core's mapping: it reads Omnibridge\Schema\Description - built by Describer::describe() once, or read back from the JSON file it wrote - and maps from it:

  • a model per Entity: $entity->table is the table, $entity->identifier the key(s), $entity->fields the attributes with the column each one is kept in (Field::$column), its portable type (string, integer, json, datetime, thread_state...: the casts), nullable, length, enum (the PHP enumeration to cast to), generated (identity: the database gives the key);
  • the relations: Relation::$kind says belongsTo (many_to_one, by its joinColumns), hasMany (one_to_many, through the owning side's join column named by mappedBy), hasOne/belongsTo for one_to_one, and belongsToMany with JoinTable::$name and its joinColumns/inverseJoinColumns;
  • the hierarchies: Entity::$inheritance gives the discriminator column, the map (value => class) and the entity's own value - a single-table hierarchy is a global scope on the discriminator, a joined one a model per table keyed by the root's identifier (Entity::$root, Entity::$parent);
  • the tables themselves (Description::$tables) for migrations or a schema check, with the same names, types, indexes and foreign keys as Doctrine's.

Nothing of it needs Doctrine installed: omnisense/omnibridge requires PHP and three PSR interfaces, and its BareTest proves the Schema loads with nothing else. The Eloquent bridge itself is a later step; this one gives it what to read.

Propose a change docs/20-architecture/schema.md

Time and time zones

omnibase sets PHP's time zone per visitor: BaseBundle::boot() reads the visitor's zone in the cookie the page's script writes (LOCATION/TIMEZONE where the visitor answers for the time zone apart, else USER/INFO's timezone; User::getCookie('timezone') reads both) and calls date_default_timezone_set() with it, UTC without one - a visitor who refused that cookie included. new \DateTime(), date() and Twig's |date are therefore the visitor's clock: "today" is the visitor's day.

What a column holds depends on its Doctrine type:

Type Column Written Read back
datetime, datetimetz DATETIME in UTC (Base\Database\Type\DateTimeTypeUTC, with base.database.use_custom) as UTC
datetime_immutable DATETIME the wall clock of the object's zone in the request's zone
utc_datetime_immutable DATETIME in UTC as UTC
utc_datetime DATETIME in UTC as UTC
date, date_immutable DATE a calendar day, no zone the same day

datetime_immutable is Doctrine's own: the same instant is stored at different hours by a visitor in Paris and one in Tokyo, and a row written by one comes back as another moment to the other. It is right for a wall clock (a reservation at 20:00 in the restaurant, whoever looks at it) and wrong for a moment (when a message was sent, when a link expires).

A moment: utc_datetime_immutable

use Base\Database\Type\UtcDateTimeImmutableType;

#[ORM\Column(type: UtcDateTimeImmutableType::NAME)]   // 'utc_datetime_immutable'
private \DateTimeImmutable $sentAt;

#[ORM\Column(type: UtcDateTimeImmutableType::NAME, nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;

The bundle registers the type (and utc_datetime, its \DateTime twin): nothing to configure. Whatever zone the object given carries, the column gets the moment in UTC; what comes back is a \DateTimeImmutable in UTC, the same moment for every request. To show it, {{ message.sentAt|date('d/m/Y H:i') }} converts to PHP's zone (the visitor's); in PHP, ->setTimezone(new \DateTimeZone('Europe/Paris')).

In a query, bind the parameter with the type, or as a UTC moment (Base\Database\Type\Utc):

$qb->andWhere('m.sentAt >= :since')
   ->setParameter('since', $since, UtcDateTimeImmutableType::NAME);
// or
   ->setParameter('since', Utc::from($since));          // the same moment, in UTC
Utc::now();                                             // the present, in UTC

A \DateTimeInterface bound without a type is formatted in its own zone, and compared as text with a column that holds UTC.

Existing columns

Nothing changes for a column that is not given the type: datetime_immutable keeps Doctrine's behaviour, datetime stays UTC.

Moving a column from datetime_immutable to utc_datetime_immutable changes no schema (both are the platform's DATETIME: doctrine:migrations:diff finds nothing on DBAL 4), but it changes what the stored values mean: rows written by requests in another zone than UTC hold that zone's wall clock. A site whose visitors have no timezone cookie yet (PHP was in UTC) has nothing to convert; otherwise the migration that accompanies the change shifts the rows from the zone they were written in:

UPDATE office_appointment SET startsAt = CONVERT_TZ(startsAt, 'Europe/Paris', 'UTC');

For bundles

Base\Restaurant\Model\Instant and Base\Office\Database\UtcDateTimeImmutableType answered the same need before the core did; a bundle now names the core's type and drops its own (utc_datetime_immutable is the same name as office's: its columns need no change).

Propose a change docs/20-architecture/time.md

Politeness

A site says tu or vous, du or Sie, 常体, 丁寧語 or 敬語. That is one setting of the site, and one variant per text that changes with it.

# config/packages/base.yaml
base:
    translator:
        politeness: polite      # plain | polite | formal - unset by default

Unset, nothing changes: every text is the one written in the catalogue. The parameter is base.translator.politeness; Base\Service\Translator holds it (getPoliteness(), setPoliteness() for a message written to somebody the site addresses otherwise).

The three levels and the key of a variant

Level Constant Suffix French German Japanese
plain Translator::POLITENESS_PLAIN _plain tu du 常体 (だ / する)
polite Translator::POLITENESS_POLITE _polite vous Sie 丁寧語 (です / ます)
formal Translator::POLITENESS_FORMAL _formal - - 敬語 (いたします / ございます)

A text keeps one key and one base text. A variant is that key followed by the level, beside it in the same file:

# translations/notifications+intl-icu.fr.yaml
login:
    already: Tu es déjà connecté !
    already._polite: "Vous êtes déjà connecté !"

Nothing changes where the text is asked: '@notifications.login.already'|trans answers the base text or its variant, by the site's level.

What is tried, the first found wins:

Site's level Tried in order
formal key._formal, key._polite, key
polite key._polite, key
plain key._plain, key
unset key

A variant takes the parameters of its text ({0}, {name}, a plural). It is a text like another: asked by its own key (key._polite) it is answered as it is, whatever the level.

The base text is not at the same level in every language

The base text is whatever was written first. What a level needs depends on it:

Language The base text plain needs polite needs formal needs
French (omnibase) mostly tu; some texts already say vous (the contact form, passkeys, comments, the privacy notice) _plain for the texts that say vous - none written _polite for the texts that say tu - all written (78) nothing more: French has no third level, formal reads the polite ones
English (omnibase) you nothing nothing nothing
German (the bundles that have it: agenda, blog, restaurant...) Sie _plain (du) - none written nothing nothing
Japanese (marketplace, restaurant, consent) 丁寧語, です / ます; buttons and labels in the dictionary form, as interfaces are _plain for the sentences - none written nothing _formal for the sentences - none written

omnibase itself ships French and English. A language's row holds for a bundle's or an application's catalogue in that language too: say in which level the base text is written, write the variants of the others.

For a Japanese site in formal: a _formal variant of each sentence addressed to the visitor (confirmations, errors, e-mails, the checkout's steps - 保存しました。 becomes 保存いたしました。, できません becomes いたしかねます), in the catalogues of the bundles it uses and its own. Labels, titles and buttons stay as they are. Until a variant is written the sentence stays in です / ます, which a 敬語 page tolerates.

Language first, level second

The catalogues are walked as Symfony falls back between languages, and in each one the variants are looked for before the base text. A German page whose text has no _plain variant keeps its German text - it is not given the French one that has it. A text missing in German altogether is read in the fallback language, at the level asked.

Whose text it is

Within a language, the closest hand wins:

  1. a text rewritten in the back office (omnibase/admin's « Textes du site »): its key._polite if it rewrote one, else its key;
  2. the application's own catalogue (translations/ of the project): its key._polite, else its key;
  3. the bundles': their key._polite, else their key.

So an application that replaced a bundle's text under its key - a practice's « Aucun message. » in place of a forum's « Tu n'as aucun message dans ta boîte. » - keeps its sentence when the bundle brings a polite variant of the one it replaced, and still gets the bundle's variants for what it did not replace. Nothing in the merged catalogues tells whose a text is: the application's keys are read while the container is built (ApplicationTextsPass, from translator.default_path: YAML, PHP and JSON catalogues), and only when a level is set.

Where it applies

Base\Service\Translator is the translator service (it decorates Symfony's), so the level holds on every way a text is asked:

  • the service (trans(), transQuiet(), transEntity(), transEnum());
  • Twig: |trans, {% trans %}, the form themes' labels, helps and errors;
  • the e-mails and the notifications, which translate through it;
  • the validator's messages (a constraint's message is a key like another).

A text that refers to another one ("@notifications.login.success.day.first") is answered at the same level.

A call that gives its own level

It is answered at that one, the site's is not asked:

$translator->trans('@emails.resetPassword.subject', [Translator::TRANSLATION_POLITENESS => Translator::POLITENESS_PLAIN]);
$translator->trans('@emails.resetPassword.subject', ['politeness' => 'none']);   // the text as written
$translator->transEntity($user, 'email', [Translator::POLITENESS_FORMAL, Translator::NOUN_SINGULAR]);
{{ '@notifications.login.already'|trans({politeness: 'plain'}) }}

politeness is taken out of the parameters before the text is formatted.

Adding variants in a bundle or an application

  1. Find the texts that address the reader at the base level (in French: tu, te, ton, ta, tes, toi and the imperatives - Choisis, Clique - which no pronoun gives away).
  2. Write each variant under <key>._polite (or ._plain, ._formal) right after its text, same indentation, same parameters, same meaning.
  3. An application overrides a bundle's variant as it overrides any text: the same key in its own translations/<domain>+intl-icu.<lang>.yaml.

tests/Translation/PolitenessCataloguesTest is the check omnibase runs on its own catalogues: every variant beside its text, with its parameters; in French no polite variant that still says tu, no text that says tu without one. Copy it for a bundle's catalogues.

Propose a change docs/20-architecture/politeness.md

The forms' guard

One option on a form, every check in its order:

$this->createForm(ContactType::class, $model, ['guard' => true]);
$this->createForm(ContactType::class, $model, ['guard' => ['action' => 'contact', 'min_delay' => 5]]);
Step What Answer
1. the trap guard_website, a field off-screen for people, filled by robots refused, on the form (guard.trapped)
2. the time guard_opened, when the form was shown, signed with the application's secret refused when sent faster than min_delay (guard.too_fast), or with a stamp that is not the site's (guard.stale)
3. the lists whoever sends it - the address, the e-mail, the name - asked of the gateways of base.guard.reputation a disposable e-mail on its field (guard.disposable); anything else known on the form (guard.known)
4. the captcha guard_captcha, glitchr/omnishield's ChallengeType on the gateway of base.guard.challenge - shown and asked only from captcha_after refused tries (below) on its field
5. what was written data implementing SpamProtectionInterface, classified by SpamChecker (Akismet) told to the data (getSpamCallback()); blatant spam refused (guard.spam)

Step 5 is spam_protection, on by default outside the back office as it always was: it runs only once the form is otherwise valid, so nothing is sent to the classifier for a form already refused. A robot caught by steps 1 to 3 does not reach the captcha's provider either.

The messages are the forms domain's guard.* (French and English), the captcha's the validators domain's - none says tu.

The option

Key Default
trap true the trap field
min_delay base.guard.min_delay (3) seconds; 0: not checked
challenge true the captcha: true for base.guard.challenge, a gateway's name, false for none
action the form's name what the form is for, signed into the captcha's token
reputation true ask the lists
email, name email, name the fields - or the data's properties - holding the sender's e-mail and name
captcha_after base.guard.captcha_after (3) refused tries of this form, from this visitor, before its captcha is shown and asked; 0: always

The captcha after a few refused tries

A visitor who fills a form once does not see the captcha. Each try of a guarded form that is refused - by the guard (trap, time, lists, the classifier's spam) or because the form is invalid - counts one for that form and that visitor (the address, else the session; in cache.app, for 15 minutes); a form sent forgets them. From captcha_after tries on, the form prints the widget and the guard asks its token; below, nothing of the captcha is printed (neither the widget nor its script) and no token is asked. The trap, the time and the lists are asked every time.

The form refused at the threshold, printed again, carries the widget: the field is always in the form, but below the threshold it is taken out of the form's view, so form.guard_captcha is defined is false, form_rest and form_end print nothing of it, and a template that writes {% if form.guard_captcha is defined %}{{ form_row(form.guard_captcha) }}{% endif %} needs no change. Without cache.app - or without an address or a session to count by - the captcha is always shown, as it was before the threshold. captcha_after: 0, on a form or in base.guard, does the same.

Form captcha_after
any guarded form base.guard.captcha_after, 3
omnibase/newsletter's sign-up 1: one refusal and the captcha shows
the sign-in 0: SignInGuard decides when it carries the captcha (sign_in_after)

Without glitchr/omnishield

glitchr/omnishield is suggested, not required (it is not on Packagist yet). Without it - or without a gateway configured - the trap and the time alone, and no error. With it:

// config/bundles.php
Omnishield\Bridge\Symfony\OmnishieldBundle::class => ['all' => true],
# config/packages/omnishield.yaml
omnishield:
    gateways:
        forms: { factory: altcha, options: { hmac_key: '%env(ALTCHA_HMAC_KEY)%' } }
        emails: { factory: disposable }
    challenge: { gateway: forms }

when@test:
    omnishield:
        gateways:
            forms: { factory: fixed }      # the hidden field omnishield-token carries omnishield-fixed-token
# config/packages/base.yaml
base:
    guard:
        challenge: ~             # the captcha: null for omnishield.challenge.gateway, a gateway's name, false for none
        fallback: ~              # the captcha of a visitor who refused the third party the captcha reaches
        reputation: [emails]     # the lists asked about the sender
        classifier: ~            # the classifier behind SpamChecker; null: Akismet with api.spam.akismet
        unreachable: accept      # a list or a classifier that does not answer: accept, or reject
        min_delay: 3
        sign_in_after: 3         # failed sign-ins from an address before the sign-in asks the captcha
        captcha_after: 3         # refused tries of a form from a visitor before it shows its captcha; 0: always

The captcha's own conduct when its provider does not answer is omnishield's (omnishield.challenge.unreachable).

With glitchr/ux-google

Where google.recaptcha.enable is true, ux-google's extension adds its own captcha (_captcha) to every form: the option guard then adds no second one - the trap, the time, the lists and the classifier stay. The places that ask ux-google's reCAPTCHA themselves (CommentType's recaptcha, omnibase/faq's AskType, the sign-in's badge) are left as they are.

Guarded by default

Form guard
ContactType action: contact guard: false for a form a site guards otherwise
SecurityRegistrationType (sign-up) action: signup a disposable e-mail refused on its field
SecurityResetPasswordType, SecurityLoginTokenType reputation: false the same answer for every address
CommentType the lists and the captcha its own trap (url) and time (opened) stay, read by CommentGuard; no captcha when it asks ux-google's (recaptcha)

The captcha is checked by the guard itself, in its order and whatever the form's validation groups (the sign-up validates in new alone, where a field's constraint is not asked).

The sign-in

Symfony's login_throttling is the first line (five tries a minute). From base.guard.sign_in_after failed sign-ins from an address (3, counted for 15 minutes in cache.app, forgotten after a success), the sign-in form carries the captcha and a sign-in without a valid token is refused before its password is checked (Base\Security\SignInGuard). Not the rescue door, not the demonstration's one click; nothing where ux-google's reCAPTCHA is on.

ALTCHA, the default, reaches nobody: omnishield's Symfony bridge serves its widget's script from the site (omnishield/altcha ships it: /omnishield/altcha/3.3.0/altcha.min.js), so the page loads nothing from anyone else and there is no consent to ask. Nothing to add on a site but omnishield's bundle; omnishield.serve_scripts: false would put the CDN back.

A captcha whose widget reaches a third party (Widget::reachesOthers(): Turnstile, reCAPTCHA, ALTCHA's script when a site names a CDN) waits for the visitor's consent - omnibase/consent's feature CAPTCHA, declared by Consent.use() - kept inert in a <template> until then. Beside it, the fallback that reaches nobody (base.guard.fallback, ALTCHA) is shown, and stays after a refusal: a refusal does not open the form. The guard asks the third party's token when there is one, the fallback's otherwise. Without a fallback, a widget that sets cookies (reCAPTCHA) waits all the same; one that sets none is loaded at once. A page without omnibase/consent's script shows the fallback alone.

In a site's tests

The test client submits a form the moment it reads it, and a suite signs in wrong on purpose:

when@test:
    base:
        guard:
            min_delay: 0          # the stamp is still checked: post the one the page prints
            sign_in_after: 0

Or keep the site's delay and move the clock rather than wait: the guard reads the time from the clock (Symfony's clock, else Base\Contract\Runtime::clock()), never time(); the core's HTTP tests use Tests\Base\Http\MovesTheClockTrait (takeTheTimeAPersonTakes() between reading the form and sending it, putTheClockBack() in tearDown()).

With factory: fixed as the test captcha, the page prints a hidden omnishield-token field holding omnishield-fixed-token, outside the form: a test client that submits the page's form sends it; a request built by hand adds it. Below captcha_after the page prints no captcha and none is asked: a token posted all the same is not read.

The comment forms

Base\Service\CommentGuard is the comment forms' face of FormGuard: CommentType's url trap and opened time, and a second comment from the same address within base.comments.flood_interval. omnibase/blog and omnibase/video call it.

Propose a change docs/20-architecture/guard.md

Account security

Base\Service\SecurityPolicy is the one place that says what an account may change about its own security; the settings page (/settings), the actions behind its buttons and the enrolment prompt all ask it. In a template it is the global security_policy.

What the administrator sets

Settings, saved in the back office (each has its own default while unsaved):

Setting Default
base.settings.security.two_factor on a second factor (one-time code, code by email) is offered
base.settings.security.two_factor.mandatory off every account must have one
base.settings.security.passkeys on passkeys are offered as a sign-in method
base.settings.security.new_device_email off a sign-in from an unknown browser is confirmed by email
base.settings.security.new_device_prompt off that sign-in offers, once, to set up a one-time code

A second factor for some roles

"Mandatory" in the settings is for everyone or no one. An application that requires a second factor of some accounts only - the staff of a practice, not its patients - says which roles:

# config/packages/base.yaml
base:
    security:
        two_factor:
            required_roles: [ROLE_STAFF]   # held directly or through security.role_hierarchy
            postpone: true                 # false: no "not now" on the enrolment prompt

The list is one value: defined again - in the file's when@dev or when@test block, or in another file - the later definition replaces it (Symfony would append the items of a list; this one opts out), so an environment can require it of other roles, or of none:

when@test:
    base:
        security:
            two_factor:
                required_roles: []         # nobody is sent to the enrolment page in the tests

A definition that does not name required_roles (only postpone, say) keeps the list as it was.

An account holding one of these roles and no second factor is sent to /settings/security-required (user_settings_enrolment) by SecurityEnrolmentSubscriber - on a plain page load only: a form post, an XHR, a download are never interrupted, nor the settings, sign-in and sign-out pages. It cannot switch its last factor off afterwards. Every other account stays free to choose. While base.settings.security.two_factor is off, nothing is required of anyone.

postpone: true (the default) keeps the prompt's "not now": the answer lasts the session, and the prompt returns at the next sign-in. With false the button is gone and the account is sent back to the prompt on every page until it has a second factor. An administrator impersonating the account is never the one asked to enrol.

$policy->isTwoFactorMandatory();        // for the whole site (the administrator's setting)
$policy->isTwoFactorMandatory($user);   // for this account: the setting, or one of its roles
$policy->isTwoFactorRequiredByRole($user);
$policy->needsEnrolment($user);         // mandatory for it, and it has none
$policy->canDisableTwoFactor($user);
$policy->canSkipEnrolment($user);

A passkey replaces the password; it is not counted as a second factor.

In the demo environment

A declared demonstration account (The demo environment) is shared by every visitor: nothing above is required of it - not the second factor of its roles, not "mandatory for everyone" - and it changes none of its credentials (isDemoAccount($user), canChangeCredentials($user), canEnableTwoFactor($user)). Every other account, and every environment other than demo, is as described here.

Under omniauth (Base\Auth\)

With omnisense/omniauth installed (the core suggests it, its identity providers included, and its method packages omniauth/webauthn and omniauth/otp - formerly omniauth/passkey and omniauth/totp; Google is omniauth/google), Base\Auth\ fills its contracts on omnibase's own tables - the same rows Symfony's sign-in reads today, so an account signs in the same way under any host. Nothing is wired by the core itself: a host bridge (omnibridge/symfony, omnibridge/laravel) mounts them.

omniauth's store Base\Auth\ What it reads and writes
Accounts UserAccounts user (and the application's table): by address, then by the application's identifier field; the id as the handle; check() refuses restricted, banned, locked, disabled (@notifications.login.<reason>)
Passwords UserPasswords user.password
LinkTokens, PlainLinkTokens UserLinkTokens userToken "login-token": the link's SHA-256, used once; the links sent before in clear until their deadline
TotpSecrets UserTotpSecrets user.secret: sealed at rest (otp:v1:..., omniauth/otp's SodiumSecretBox) when omniauth.yaml sets totp.secret_key - the bridge wraps the store, and the settings' enrolment and QR code go through it
EmailCodes UserEmailCodes user.emailAuthEnabled, user.emailAuthCode (the code's keyed hash)
BackupCodes UserBackupCodes user.backupCodes
TrustedVersion UserTrustedVersion user.trustedTokenVersion
Passkeys UserPasskeys userPasskey
Identities UserIdentities userIdentity: provider, subject, the account, the address then and whether it was vouched for, created, last used

The TOTP secrets at rest (Marco's decision QA5). A site turns sealing on with totp: { secret_key: '%env(OMNIAUTH_TOTP_KEY)%' } in omniauth.yaml (its Laravel twin: 'secret_key' => env('OMNIAUTH_TOTP_KEY') in config/omniauth.php, the same key): base64 of 32 bytes, from the secrets vault in production, .env.local in development, a fixed key of the test environment's own in .env.test - never a committed real key. The settings page (UserSettingsController) writes the enrolled secret through omniauth's TotpSecrets and opens it only to draw the QR code; the sign-in opens it only to verify a code. The secrets written in clear before are read as they are until the site's migration rewrites them (lesringostars: Version20261023120000, up seals, down opens, idempotent); the column (VARCHAR(255)) holds the 130 characters of a sealed secret unchanged. Rotating the key: omniauth/otp's docs/otp.md.

Base\Auth\Policy is this page's rule on omniauth's second-factor rule: a demonstration account is never asked for one; an account a second factor is required of and that has none is signed in, then sent to enrol (needsEnrolment()); turning off the last second factor of such an account is refused (onRevoking(), reason two-factor-mandatory), any credential of a demonstration account too (demo-account); and an account that has a second factor or an administration role (SecurityPolicy::isAdministrator()) is never linked to an identity provider by its address alone (allowsEmailLinking(), through PolicyLinking).

The listeners, Base\Auth\Listener\*, each say which events they take (events()): GuardSignIn (the captcha after failures from an address, SignInGuard), TrackConnections (the browsers' connections), WarnNewDevice (the e-mail and the offer of a new device), UpdateAccount (unknown roles, time zone, language, kick, at each sign-in) and WelcomeBack (the e-mail of a disabled account).

Propose a change docs/20-architecture/account-security.md

The demo environment

A fourth environment beside dev, test and prod: APP_ENV=demo runs a site as a demonstration - production's behaviour (no debug), a database of its own, and accounts that are always there, one for each role of the trade. A visitor reads the account's name in the banner of any page, opens the sign-in page, clicks "Patient", and is in. The back office is not part of what is public: its accounts sign in with a secret.

Everything below lives in omnisense/omnibase and is active when kernel.environment is demo, and only then. In dev, test and prod none of it is in the container: no route, no listener, no header. What an application adds to run it is listed in Scaffolding a site.

Declaring the accounts

A bundle declares the roles of its trade, the application its own, by implementing Base\Demo\DemoAccountProviderInterface (autoconfigured, tag base.demo_account_provider):

namespace App\Demo;

use Base\Demo\DemoAccount;
use Base\Demo\DemoAccountProviderInterface;

final class DemoAccounts implements DemoAccountProviderInterface
{
    public function getDemoAccounts(): iterable
    {
        yield new DemoAccount(
            identifier: 'client',                      // what it signs in with (User::$userIdentifier)
            label: 'demo.client.label',                // the button: a translation key, or the text itself
            description: 'demo.client.description',    // one sentence on what this role sees
            roles: ['ROLE_USER'],                      // its own roles
            position: 20,                              // the order on the sign-in page
        );
        yield new DemoAccount('docteur', '@health.demo.practitioner.label', '@health.demo.practitioner.description',
            group: 'Praticiens', groupRoles: ['ROLE_PRACTITIONER']);   // a Base\Entity\User\Group and its roles
    }
}
  • The password is the identifier unless password: says otherwise, the address <identifier>@example.org unless email: does. A demonstration account is public: that is what it is for.
  • Base\Demo\DemoAccountRegistry gathers every declaration. The application's replaces a bundle's for the same identifier; base.demo.exclude: [kine] drops the ones a site has no use for.
  • A super-administrator is never a demonstration account. A declaration whose roles reach ROLE_SUPERADMIN - its own, its group's, or through security.role_hierarchy (a ROLE_EDITOR above it) - is refused with a LogicException as soon as the registry is read, in every environment.
  • An account that opens the back office is declared, and offered to nobody. One whose roles reach base.demo.admin_roles ([ROLE_ADMIN], through the hierarchy too) is created by the fixtures as the others are - dev and the tests sign in with it - and in demo it is neither in the banner nor on the sign-in page: DemoAccountRegistry::offered() is the list without them. See The back office.

One account for each role of the application, none for the back office

The roles of the application are the constants of its user_role type (App\Enum\UserRole, else Base\Enum\UserRole). Each one below the back office has a demonstration account holding it - its own roles or its group's; ROLE_USER is held by any account. Those that open the back office (base.demo.admin_roles, ROLE_SUPERADMIN, ROLE_EDITOR above it) have none offered. Roles a group gives and the enum does not name (a practice's ROLE_PRACTITIONER) are the bundle's to declare, as they do.

$registry->missingRoles();   // [] when every role has its account; demo:reset warns otherwise

A site's suite asserts it (self::assertSame([], $registry->missingRoles())). The banner names one account, the first offered (position); the others wait on the sign-in page.

In the fixtures

Base\Demo\DemoAccountFactory turns the declarations into rows, so that dev, test and demo have the same accounts and nobody writes them twice:

final class SiteFixtures extends Fixture
{
    public function __construct(private readonly DemoAccountFactory $accounts) {}

    public function load(ObjectManager $manager): void
    {
        $docteur = $this->accounts->account('docteur', $manager);   // the database's, or created from its declaration
        $this->accounts->load($manager);                            // every declared account

        // ...the practice's team, agenda and files, attached to $docteur
        $manager->flush();                                          // the factory flushes nothing
    }
}

An account the database already has is returned as it is: fixtures that create theirs themselves keep them, identifier included. A group is created with groupRoles when the database has none of that name, and left alone otherwise.

On the sign-in page

@Base/demo/_accounts.html.twig prints "Comptes de démonstration": one button for each account offered that exists. omnibase's own sign-in page includes it; an application with its own templates/security/login.html.twig adds the line under its form:

{{ include('@Base/demo/_accounts.html.twig') }}

Each button is a POST form with a CSRF token to security_loginDemo (/login/demo) - never a link: a GET that signs in is followed by a prefetch, a crawler, a link preview. The route is declared for the demo environment only. Base\Controller\DemoController signs in an account that is offered and nothing else: an identifier nobody declared, an account of the back office, or one promoted to it since, is sent back to the sign-in page.

The partial prints nothing outside demo. Its look hangs on .base-demo-accounts (it inherits the page's colours). A button and its sentence stand side by side; in a narrow place - a sign-in card of some twenty rem - the sentence goes under its button, full width (the flex line folds when less than 14rem are left beside the button: no script, no query on the window). To rewrite it, templates/bundles/BaseBundle/demo/_accounts.html.twig. In a template of your own: demo_mode() (true in demo) and demo_accounts() (the list).

The banner

@Base/demo/_banner.html.twig prints "Démonstration — données remises à zéro chaque nuit" (fr, en: the demo translation domain). A layout includes it once, first thing in <body>; omnibase/admin's layout does, so the back office shows it too:

<body>
    {{ include('@Base/demo/_banner.html.twig') }}

Two custom properties set its colours (--demo-banner-bg, --demo-banner-fg); templates/bundles/BaseBundle/demo/_banner.html.twig rewrites it.

The account, on every page

To a visitor who is not signed in, the banner goes on: "Compte de démonstration « Client » : identifiant client, mot de passe client. Se connecter" - the first account offered, what it signs in with, and a link to the sign-in page ("Se connecter ou choisir un autre rôle" when there are several). A site that includes the banner has nothing to add; a site with no account to offer keeps the banner's first sentence alone. Signed in, the sentence is gone.

It is a sentence and a link, not a button: a button that signs in is a form with a CSRF token, a token needs a session, and no public page starts one for a visitor who only reads.

demo:reset

php bin/console demo:reset                # the fixtures, the uploaded files, the caches
php bin/console demo:reset --keep-files   # the database and the caches only

In order: it refuses outside demo (and on what the guard below refuses); takes a lock (symfony/lock, the lock.factory service: two resets never overlap); empties the directories of base.demo.reset.purge; runs doctrine:fixtures:load; creates the declared accounts the fixtures left out; deletes the uploaded files no row names any more (uploader:entities --delete-orphans, unless base.demo.reset.orphans: false); empties every cache pool and Doctrine's second-level cache. Each step is written to the output and to the log; the exit code is not 0 when one failed.

It is written for the cron container. The crontab is the same in every environment, so the line checks the environment itself:

30 3 * * *  [ "$APP_ENV" = demo ] && php /srv/app/bin/console demo:reset >> /srv/app/var/log/cron.demo-reset.log 2>&1
# config/packages/base.yaml
when@demo:
    base:
        demo:
            reset:
                purge:                                   # storages of the application's own
                    - '%kernel.project_dir%/var/storage/documents'

The sessions open at that moment end: the accounts are new rows.

Give the demonstration storages of its own (when@demo in flysystem.yaml: var/storage/demo/uploads...), name that directory in purge, and turn the orphan search off (orphans: false): everything a visitor sent is then in one place that no other environment writes to. The orphan search reads the uploads through public/uploads, a link that points at the storage of whichever environment cleared its cache last - right on a server that runs the demonstration alone, not on a machine that runs dev from the same checkout.

The safeguards

In demo
E-mail None leaves. Base\Subscriber\DemoSubscriber refuses every message where the mailer hands it over (MessageEvent::reject()), as it is queued and as it is sent, whatever MAILER_DSN says; recipients and subject go to the demo log channel.
Indexing X-Robots-Tag: noindex, nofollow on every response.
A demonstration account's credentials Its identifier, address, password, one-time code, e-mail code and backup codes do not change; it is not disabled nor deleted.
The staff's second factor base.security.two_factor.required_roles and the administrator's "mandatory for everyone" are lifted for the demonstration accounts; any other account is asked as usual.
The back office Its accounts (base.demo.admin_roles) are offered to nobody and sign in with base.demo.admin_password (the DEMO_ADMIN_PASSWORD secret) or not at all; it answers 403 to every other account.
The super-administrator Signs in with base.demo.superadmin_password (the DEMO_SUPERADMIN_PASSWORD secret) or not at all.
Starting Refused in debug, and on the production database.
Payments omnibase/marketplace offers its dev gateway and none of glitchr/omnitrade's (its docs/dev-gateway-and-keys.md).

E-mail: refused, not looped back

The two ways were a null transport and the "technical loopback" of dev (every message rewritten to one mailbox). The demonstration takes the first, and enforces it in the bundle rather than leaving it to MAILER_DSN: a visitor signed in as "patient" types any address in a form, and a loopback still delivers - to a mailbox someone then has to empty, and to a real person the day the loopback is misconfigured. A message that is never handed to a transport cannot reach anyone. An application still sets framework.mailer.dsn: 'null://null' under when@demo, so that nothing is even attempted by a library that would bypass Symfony's mailer.

What would have gone is in the log, at the info level of the demo channel (production's fingers_crossed handler keeps it for the context of an error; bin/console ... -vv shows it):

demo.INFO: Demonstration: e-mail not sent. {"to":["patient@example.org"],"subject":"Votre rendez-vous","queued":false}

What a demonstration account cannot change

Every visitor signs in with the same account: whoever changed its password would lock the next one out until the night's reset.

  • omnibase's pages refuse with a message (@notifications.demo.locked): the second factor (/settings/2fa, its switches), closing the account (/account-goodbye), a password reset asked for or followed. The settings page says so first. They ask SecurityPolicy::isDemoAccount($user) / canChangeCredentials($user): use the same in a page of your own.
  • For every other form - the application's "my account" page, the back office's users - Base\DatabaseSubscriber\DemoAccountLockSubscriber puts the values back before the row is written (and flashes the same message), and answers 403 to a deletion. Only during a request: the fixtures and demo:reset write these accounts from the console.
$policy->isDemoAccount($user);          // a declared account, in the demo environment
$policy->canChangeCredentials($user);   // false for it
$policy->canEnableTwoFactor($user);     // false for it
$policy->needsEnrolment($user);         // false for it, whatever its roles

The back office

A demonstration's password is its identifier: whoever reads "client / client" in the banner can guess "gestion / gestion". Behind the back office are the accounts of the visitors who signed up that day, the settings, the uploads - what one visitor writes there, every other one reads until the night's reset. So it is not public.

In demo, an account whose roles reach base.demo.admin_roles ([ROLE_ADMIN] by default; through security.role_hierarchy too) - declared as a demonstration account or not:

  • is not offered: not in the banner, not on the sign-in page, not signed in by /login/demo;
  • is the only one the back office opens to: any other account signed in
    • a demonstration account or one a visitor created, whatever its roles give it elsewhere (the kitchen's ROLE_STAFF reading the orders under /admin)
    • gets a 403 on every page of it (/admin, omnibase/admin's routes);
  • signs in with base.demo.admin_password - by default %env(default::DEMO_ADMIN_PASSWORD)% - typed in the ordinary sign-in form with its identifier ("gestion"). The stored password is not looked at. Unset or empty, it cannot sign in by any means.

DEMO_ADMIN_PASSWORD is the secret given to whoever is shown the back office - a prospect, for the time of a presentation; changing it closes the door again. It is the deployment's, like the super-administrator's: a real environment variable or .env.demo.local, never .env.demo. Once in, the account is a demonstration account like the others: its credentials do not change, the staff's second factor is not asked of it.

when@demo:
    base:
        demo:
            admin_roles: [ROLE_ADMIN, ROLE_MANAGER]   # another role opens the back office here
            # admin_roles: []                         # ...or none: the back office as public as the rest

A role below these - the kitchen's ROLE_STAFF, a practitioner's group - stays public, with whatever pages of the back office the application opens to it: name it in admin_roles when those pages are not for everyone.

The super-administrator

The fixtures give Glitch Art's account a password everyone knows ("glitchr" / "glitchr"); a demonstration is public. In demo, Base\Subscriber\DemoBackOfficeSubscriber compares what is typed with base.demo.superadmin_password - by default %env(default::DEMO_SUPERADMIN_PASSWORD)% - and never looks at the stored password. Unset or empty, an account whose roles reach ROLE_SUPERADMIN cannot sign in by any means ("This account is not available in the demonstration."). It is not listed on the sign-in page and not signed in by /login/demo either way.

DEMO_SUPERADMIN_PASSWORD is a secret of the deployment: a real environment variable or .env.demo.local (git-ignored) on the demonstration's server. Never .env.demo, which is committed.

What it refuses to start on

Base\Demo\DemoGuard, asked by BaseBundle::boot() - for a page as for a command - throws Base\Exception\DemoRefusedException when:

  • debug is on: APP_DEBUG=0 (bin/console --env=demo --no-debug);

  • a connection reaches the production database: the application names it, and the guard compares server, port and database name (not the user: the same database under another account is the same database);

    when@demo:
        doctrine:
            dbal:
                connections:
                    default:
                        dbname_suffix: '_demo'            # <database>_demo, as the tests have <database>_test
        base:
            demo:
                production_database: 'mysql://%env(DOCTRINE_DATABASE_HOST)%:%env(DOCTRINE_DATABASE_PORT)%/%env(DOCTRINE_DATABASE)%'
    
  • base.demo.production_database is unset: nothing could be compared, and demo:reset empties the database it is given.

One configuration for prod and demo

The demonstration behaves as production does: what a file sets under when@prod is shared, not copied. A YAML anchor does it within a file:

# config/packages/monolog.yaml
when@prod: &prod
    monolog:
        handlers: ...

when@demo: *prod

and a merge key when demo adds something of its own:

# config/packages/doctrine.yaml
when@prod: &prod
    framework:
        cache:
            pools: ...

when@demo:
    <<: *prod
    doctrine:
        dbal:
            connections:
                default:
                    dbname_suffix: '_demo'

(<< merges the top-level keys only: a key demo repeats replaces prod's whole block of that name.) A setting that has to differ in dev and test only is better turned round: the value at the root of the file, overridden under when@dev and when@test - then prod and demo both read the root.

A real environment variable beats every .env* file, and the containers receive .env and .env.local as real variables: .env.demo cannot change a value .env defines. What differs in demo is therefore said in the configuration (when@demo), and .env.demo holds only what .env leaves out (the hosts, as .env.prod does).

Reference

base:
    demo:
        production_database: ~            # DSN of production's database; required in demo
        superadmin_password: '%env(default::DEMO_SUPERADMIN_PASSWORD)%'
        admin_roles: [ROLE_ADMIN]         # the roles that open the back office: not offered, signed in by the secret
        admin_password: '%env(default::DEMO_ADMIN_PASSWORD)%'
        exclude: []                       # declared identifiers this site leaves out
        reset:
            purge: []                     # directories demo:reset empties before the fixtures
            groups: []                    # fixture groups it loads (--group); none: every fixture
            orphans: true                 # uploader:entities --delete-orphans after them
Base\Demo\DemoAccount, DemoAccountProviderInterface a declaration, and who declares
Base\Demo\DemoAccountRegistry every declaration (all()), the ones a visitor is offered (offered()), the roles without one (missingRoles()); get(), of($user), isAdministrator($user), isSuperAdmin($user)
Base\Demo\DemoAccountFactory the rows: account(), load(), missing()
Base\Demo\DemoMode isActive(): the demo environment, and no other
Base\Demo\DemoGuard what demo refuses to start on
Base\Console\Command\DemoResetCommand demo:reset
tests/Demo/ the suite; Fixtures\DemoTestKernel boots a host application in demo or prod

Propose a change docs/20-architecture/demo.md

Forms

omnibase's form theme (templates/form/form_div_layout.html.twig, added to twig.form_themes by the bundle) renders every form: the form2 blocks, the label strip, the custom field types.

Labels, help and placeholders are translation keys

Texts are translated by Base\Service\Translator. A key may name its domain (@mailbox.form.subject), or be looked up in the field's translation_domain.

Every field has the domain fields by default (Base\Form\Extension\FormTypeExtension), where omnibase's own field types keep their words. A form type that declares its own domain is followed by its fields:

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults(['translation_domain' => 'mailbox']);
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder->add('subject', TextType::class, [
        'label' => 'form.subject',                         // mailbox.form.subject
        'help' => 'form.subject_help',
        'attr' => ['placeholder' => 'form.subject_placeholder'],
    ]);
}

The rule, applied when the view is finished (FormTypeExtension::inheritTranslationDomain()): when none of a field's label, help and placeholder exists in the field's own domain and one of them exists in the domain of a form above it, the field is rendered in that domain. Otherwise nothing changes: a field whose texts are in fields stays there, a key that names its domain is translated in it, and 'translation_domain' => false prints the texts as written.

(A field used to ignore its form's domain: the key was looked up in fields, found nothing, and the label was printed empty. The bundles named the domain in every key to go around it; they no longer need to.)

A PHP enum in a select

Base\Field\Type\SelectType bound to a property that holds a PHP enum finds its choices by itself - the cases - and hands the case back to the record:

$builder->add('state', SelectType::class);                                   // Doctrine enumType:, or a property typed with the enum
$builder->add('states', SelectType::class, ['class' => CommentState::class, 'multiple' => true]);

The select holds a case by its backed value (its name when the enum is not backed, Base\Form\Common\NativeEnum::id()). A case is labelled, in this order: by itself when the enum implements Symfony's TranslatableInterface; by the key <short class name in snake case>.<value> of the enums domain (comment_state.pending); by its name made readable. A value that names no case is an invalid choice, and the record keeps what it had.

A field that gives choices (or a choice_loader) keeps them as they are: the enum is not guessed over them, and ['Label' => 'value'] shows the label (translated when it is a key such as @agenda.role.soloist).

ClassMetadataManipulator::getFields() - what AssociationType builds an embedded form from - maps such a column to a SelectType as well; it used to be a TextType, which cannot print a case.

An entity in a select

SelectType with an entity class (or bound to a Doctrine association) is fed by the autocompletion and loads what was chosen from the class's repository: cacheById() on omnibase's repositories, find() / findBy() on a plain Doctrine one.

AssociationType embeds the related record's own form, always. The back office decides, for an AssociationField, between that form and a picker: see omnibase/admin's docs/crud-fields.md.

A select works before its script does

SelectType is drawn by select2, but the <select> the server prints is a whole one: on a page whose script did not run - blocked, failed, not loaded yet - it can be read, changed and sent.

  • Its options. The choices of a static list or of an enum, groups as <optgroup>, with the labels select2 shows; for an autocompleted list, the records already chosen. select2 empties the select before filling it from its own data, as it always did: nothing is listed twice.
  • Its selection. The record's value is the selected option - also in the lists that printed their options already (a choice_loader: CurrencyType), where none was selected and the browser sent the first of the list in place of the record's currency.
  • An empty first option in a select of one value, carrying the placeholder: without it the browser picks the first choice for a record that has none. It is the option select2 itself asks for to show a placeholder.

(The select was printed with no option at all and filled by select2 alone: without the script a product's availability arrived null.)

required is checked by the server

A required SelectType sent empty is an error of the form - "This value should not be blank.", in the visitor's language (validators domain) - on the field itself, where its row prints it. The browser's required attribute was the only check, and the back office's forms are novalidate.

The empty value is not written into the record, which keeps what it had: the field fails as a transformation does (it is not synchronized), so a setter that takes no null - setStatus(PublicationStatus $status) - is not called with one. That call answered 500 ("Expected argument of type ..., null given") in place of the form's error.

  • required is what the field says, true unless said otherwise ('required' => false, ->setRequired(false) on a CRUD field): a select on a nullable property that may stay empty has to say so.
  • A list of several may be empty, as before, unless 'required_when_multiple' => true.
  • A disabled field is not asked.

The field's errors no longer go up to the form (error_bubbling is false, as for Symfony's own compound choice and date fields): a constraint of the property is printed beside the select too, not at the top of the form.

Propose a change docs/20-architecture/forms.md

Sealed fields (#[Vault])

Base\Database\Attribute\Vault seals the fields of an entity before they reach the database, with libsodium (a sealed box, through Symfony's SodiumMarshaller), and opens them when the entity is loaded. omnibase uses it for the settings typed on the back office's "API keys" page (Base\Entity\Layout\SettingIntl::$value); an application uses it for its own secrets:

use Base\Database\Attribute\Vault;
use Base\Database\Entity\Extension\VaultTrait;

#[ORM\Entity]
#[Vault(fields: ['apiKey'])]
class AssistantKey
{
    use VaultTrait;          // the `vault` column: null (not secured) or the environment whose key sealed the row

    #[ORM\Column(type: 'text', nullable: true)]
    protected ?string $apiKey = null;
}

$key->setSecure(true);       // sealed from the next flush on, with the running environment's key

A row is sealed when its vault column names an environment (setSecure(true), SettingBag::secure($path)); a row whose vault is null is stored as it is.

The key pair

The key is the one of Symfony's secrets vault for the environment: config/secrets/<env>/<env>.decrypt.private.php (and its public half, <env>.encrypt.public.php). One command creates both:

php bin/console secrets:generate-keys                 # the running environment (dev)
php bin/console secrets:generate-keys --env=test      # the test suite seals too
php bin/console secrets:generate-keys --env=prod
  • dev and test: commit both files, as Symfony's documentation says for these environments (they protect nothing that is not in the repository already).

  • prod: commit prod.encrypt.public.php only. The private key (prod.decrypt.private.php) goes to the server by hand, or as the SYMFONY_DECRYPTION_SECRET environment variable:

    php -r 'echo base64_encode(require "config/secrets/prod/prod.decrypt.private.php");'
    

    #[Vault] reads the file first, then that variable. Losing the private key loses every sealed value: keep a copy where the backups are not.

  • In Docker, the web container mounts the project read-only: it cannot write config/secrets/. Run the command where the project is writable - the one-shot composer service - see Scaffolding a site:

    docker compose -f docker-compose.yml -f docker-compose.setup.yml run --rm --entrypoint "" composer \
        php bin/console secrets:generate-keys
    

A row remembers the environment that sealed it: a database copied from production to a development machine keeps its sealed values unreadable there (they come back as the sealed text), which is the point.

Without the key pair: refused, not stored in clear

When a secured field is about to be written and the vault has no key pair, the flush fails with Base\Exception\VaultKeyNotFoundException:

The "dev" vault has no key pair (…/config/secrets/dev/dev.decrypt.private.php): a secured value would be stored in clear, so it is refused. Generate the pair with "php bin/console secrets:generate-keys --env=dev" […]

The message names the vault, the file and the command, never the value. omnibase/admin's "API keys" page checks first (Vault::canSeal()) and shows the same explanation instead of saving.

Until omnibase 3.x of 2026-10-05 the value was written unsealed, without a word - a site without keys believed its API keys were encrypted. A site that wants that behaviour back says so:

# config/packages/base.yaml
base:
    vault:
        allow_plaintext: true      # no key pair: secured fields are stored in clear

Reading never fails: a value stored in clear before the keys existed is read as it is, and sealed at its next save.

What an existing site does

  1. Generate the pairs (dev, test, prod) as above.
  2. Open the "API keys" page and save it once: the rows stored in clear are sealed. Or, in a migration or a command: $settingBag->secure('api.stripe.secret') for each path.
  3. Nothing to migrate in the schema: the vault column and the fields keep their types.

Propose a change docs/20-architecture/vault.md

Errors and refusals

An application hands its errors to omnibase:

# config/packages/framework.yaml
framework:
    error_controller: Base\Controller\ErrorController::Main
  • In debug (dev, and the test environment): Symfony's debug page (ErrorController::Rescue()), under the error's own status.
  • Otherwise: exception.html.twig, or @Admin/error.html.twig for a path under /admin when omnibase/admin is installed; the kernel gives the response the error's status. exception.html.twig extends the site's layout3.html.twig, or its layout1.html.twig when it has no layout3 (until 2026-10-06 a site without layout3 could not render the page: every unknown address answered 500 in prod).
What happened Status
The signed-in user may not do this (AccessDeniedException, #[IsGranted], a voter) 403
A visitor who is not signed in asks for a protected page 302 to the sign-in page
A payload that does not validate (#[MapRequestPayload], #[MapQueryString]) 422
No such page, no such record 404
Anything that is not an HTTP error 500

A test asserts the real status: assertResponseStatusCodeSame(403) for a refusal, 422 for a validation failure.

(The debug page used to answer 404 whatever the error, so in dev and in the tests every refusal and every validation failure of a signed-in user read as "not found". Production was right. Tests that expected 404 for a refusal expect 403 now.)

The debug page's and the profiler's links open the file on the developer's machine when the site runs in a container: APP_PATH (the project's path on the host, which a site's Makefile writes in .env.local) is mapped from kernel.project_dir with Symfony's own format, vscode://file/%f:%l&/srv/app/>/Users/me/site/ (omnibridge/symfony's Omnibridge\Symfony\FileLinks, formerly Base\Bridge\Symfony\FileLinks). debug.file_link_formatter is decorated with Symfony's FileLinkFormatter on that format (the HTML error renderer and the profiler receive it), and SYMFONY_IDE is set to it when Base\BaseBundle is constructed, for the page Symfony's ErrorHandler renders by itself before a request is handled (a container that does not compile). A site's own framework.ide or SYMFONY_IDE is kept; one that already maps (&...>) too; with none, VS Code's format. omnibase's former syntax, which the sites' framework.ide writes - "vscode://file/%%f[%%b:%env(APP_PATH)%]:%%l", %b read as the path in brackets - is read as Symfony's mapping.

Until 4.0, Base\Inspector\HtmlErrorRenderer and FileLinkFormatter did this by extending Symfony's classes - the formatter is @final - and glitchr/base-plugin patched symfony/error-handler for it (@final removed, private made protected, the ErrorHandler's new HtmlErrorRenderer replaced). Gone: symfony/error-handler is installed as published.

The 404 that are meant

Two refusals are answered 404 on purpose, so as not to tell that the place exists:

  • While access is restricted (the base.settings.access_restriction.* settings: a site not open yet, or under maintenance), the back office and the profiler answer 404 to whoever is not granted BACKEND (Base\Subscriber\SecuritySubscriber).
  • The profiler outside debug answers 404 (Base\Subscriber\ProfilerSubscriber).

An application that wants a record to stay unknown to those who may not see it (a private order, another customer's file) throws createNotFoundException() itself rather than denying access: that is its choice, record by record, not something omnibase does for it.

Propose a change docs/20-architecture/errors.md

Deployment

This is the base deployment page. An application that deploys differently should override it by creating docs/30-operations/deployment.md.

Generic flow

  1. Run the test suite.
  2. Build assets.
  3. Run migrations.
  4. Warm the cache.

Cache

Warm the cache before switching traffic. Several warmers touch the database, so a cold cache on the first request after a deploy is both slow and risky.

omnibase's cache:clear checks the PHP configuration, the disk, webpack's build and the technical recipient before Symfony's clear, holds a lock (a second cache:clear started meanwhile exits without clearing), and on an installation's first clear runs a second one in a process of its own (base.autoclear). See Console commands.

Before switching traffic

  • base.notifier.technical_loopback is true by default: every e-mail goes to the technical recipient. Production sets it to false.
  • base.site.index names the home page's route, base.uploader.storage and base.twig.editor.storage the storages of the uploads (Configuration reference).

Commands to schedule

Command Why
bin/console thread:publishable --publish Publishes the scheduled threads whose time has come (and mails their authors)
bin/console trash:purge --force Destroys what was trashed longer ago than base.extension.empty_trash
bin/console timemachine:snapshot:backup <storage> --batch The backups (Backups)
bin/console demo:reset The nightly reset of a demonstration (The demo environment)

Propose a change docs/30-operations/deployment.md

Backups

timemachine:snapshot:backup builds one archive of the application (its code, its uploaded data and a database dump) and transfers it to one or more configured storages.

php bin/console timemachine:snapshot:backup sftp.glitchr --batch --database default

The positional argument names the destination storages, not the sources - every storage listed in config/packages/flysystem.yaml may be used, and the snapshot is written to each of them in turn.

What ends up in the archive

The tarball deliberately leaves out anything a deployment can rebuild by itself. On a typical application vendor/ and node_modules/ alone account for roughly three quarters of the tree, and none of it is data you could not reproduce with composer install and yarn.

The default exclusion list is Base\Service\TimeMachine::DEFAULT_EXCLUDES:

./vendor  ./node_modules  ./var/cache  ./var/log  ./var/coverage  ./var/phpunit  ./.git

Override it per application when the defaults do not fit:

# config/packages/base.yaml
base:
    time_machine:
        excludes:
            - ./vendor
            - ./node_modules
            - ./var/cache
            - ./public/build

Restoring a snapshot built with these defaults therefore needs composer install (and a front-end build) before the application boots. Everything that is not reproducible - uploads, the database dump - is always archived.

Where the archive is staged

The snapshot is assembled on local disk before being transferred, by default under the kernel cache directory. On a host where the application shares a filesystem with everything else, a large snapshot can therefore fill the disk the application itself is running on.

Point the staging directory at another volume when that is a risk:

base:
    time_machine:
        snapshot_dir: /mnt/storage/snapshots

Before building anything, the command measures the source (with the exclusions applied) and refuses to start when the target filesystem cannot hold roughly twice that - the tarball and its compressed copy exist side by side while compressing:

Not enough free space to build the snapshot in "/srv/app/var/cache/prod":
~2.6GB needed (source ~1.2GB x2.2), only 900MB available.

Retention

Snapshots older than time_machine.time_limit (default +30 days) are deleted from the destination storage at the start of every run, and max_cycle bounds how many same-day cycles are kept. A destination is therefore not an archive of record: it is a rotating window. Keep anything that must outlive that window somewhere the command does not prune.

Propose a change docs/30-operations/backups.md

The Docker harness

docker/ builds this checkout into one Symfony application with every omnibase/* plugin (admin, marketplace, forge, forum, mailbox, docs), paying through glitchr/omnitrade, shipping through glitchr/omnibus and signing through glitchr/omnisign (a DocuSeal gateway, its recorded answers in the tests), on SQLite. It is where the bundles' suites run: there is no PHP on the host.

cd docker
docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase test              # omnisense/omnibase's suite
docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase test admin        # a plugin's: admin, marketplace, forge...
docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase test forge --filter License
docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase check             # container and templates linted
docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase bare              # the Foundation loaded without the framework
docker compose -f compose.yml -f compose.checkouts.yml up                                  # the demo, http://localhost:8000/

compose.checkouts.yml mounts the checkouts beside this one (../admin, ../marketplace...) over the installed packages: what is edited is what runs, no rebuild needed. Without it, the plugins are the Packagist ones.

omnibase/blog, omnibase/classroom, omnibase/agenda, omnibase/scholar, omnibase/press and omnibase/consulting are not installed by the image; the overlay mounts their checkouts too (test blog, test classroom, test agenda, test scholar, test press, test consulting), and omnisense/omnischolar's (OMNISCHOLAR, ../../../omnischolar by default), whose classes the scholar's tests' bootstrap registers. Their tests' bootstrap registers their classes (and the omnibase/* bundles they require, read beside them), and the demo's config/bundles.php and config/routes/plugins.php - mounted as checked out, with demo.sh - register their bundles and their pages for their own suites only: the other suites' map the same entities as before.

What test sets up

  • The test environment. APP_ENV=test; PHPUnit starts from tests/harness.php, which loads .env then .env.test (as an application's own tests/bootstrap.php does), then the suite's own bootstrap (it registers the suite's test namespace). docker/app/.env.test holds test values for every variable the bundles' configuration reads: KERNEL_CLASS, DATABASE_URL, DEFAULT_URI, CORS_ALLOW_ORIGIN, GOOGLE_RECAPTCHA_SITE_KEY / GOOGLE_RECAPTCHA_SECRET (Google's public test keys), MAILER_DSN, MAILER_CONTACT, MESSENGER_TRANSPORT_DSN, the Stripe keys (empty). A suite that dies before its first test on a missing variable wants a line there.
  • A fresh database. var/test.db (SQLite) is deleted, and its schema created from every mapped entity, at each run; the test container cache (var/cache/test) too - first, before the bundles' public files are linked: the previous suite's container may list bundles this one has not.

The suites that need a host application (KernelTestCase: core's analytics, admin's widgets, the forge's entities) run here as they do inside an application. Those that need rows to look at skip when the fresh database has none.

The Foundation: bare

tests/Foundation.php lists the classes of the core that load without the framework, sorted. tests/BareTest.php, part of the suite, runs docker/bin/bare in a PHP process of its own: Composer's autoloader alone - no kernel, no container, no bundle booted - then each class of the list, and every class, interface, trait and file PHP loaded on the way is checked. None may be the framework's: the Symfony bundles (FrameworkBundle, DoctrineBundle, TwigBundle, SecurityBundle, every *-bundle) and bridges, dependency-injection, config, http-kernel, http-foundation, routing, security-*, form, console, ux-*, API Platform, HWI, Scheb, Webauthn's bundle, Nelmio (a namespace segment ending in Bundle is a bundle's, except symfony/intl's resource readers, Symfony\Component\Intl\Data\Bundle\). The standalone Symfony components (string, uid, intl, translation, validator, serializer, mime, mailer, notifier, cache, workflow, event-dispatcher, http-client, the contracts...), Doctrine (storage) and Twig (the view) are allowed. A failure names the framework's class or file, the class of the list that loaded it and the class that asked for it. A second case loads Base\BaseBundle and Base\Repository\UserRepository the same way and checks the report names the framework (HttpKernel\Bundle\Bundle, asked by AbstractBaseBundle; Security's UserProviderInterface, implemented by the users' repository): the check is not blind. A third loads a repository (ThreadRepository): doctrine/orm's EntityRepository, nothing of the framework (plan "omnibase agnostique", C3b).

docker compose run --rm omnibase bare                      # the Foundation; exits 1 when the framework was loaded
docker compose run --rm omnibase bare 'Base\Entity\User'    # the classes named
docker compose run --rm omnibase bare --all --each         # every class of src/, each in a process of its own: those that can join
docker compose run --rm omnibase bare --json               # the whole report
docker compose run --rm omnibase bare --names              # informative: the classes of the Foundation that name the framework
docker compose run --rm omnibase bare vendor/omnisense/omnibase/tests/Contract/runtime-without-framework.php   # a script, with the autoloader alone

Where the framework is not installed (plan "omnibase agnostique", C7c): the classes are loaded with the autoloader of var/bare/vendor, the core installed alone by composer install --no-dev - its composer.json as it is, its autoload pointed back at the checkout's src/ - so none of Symfony's framework packages is there (symfony/console aside, doctrine/orm's). bare --install makes it, and makes it again when composer.json changed; demo.sh runs it before bare and before the core's suite, whose BareTest measures the Foundation there (skipped in an application's suite, where it was not made). A class declares what is installed: Base\Entity\User's contracts are empty interfaces there, Security's and scheb/2fa's on the harness's own vendor (BareTest checks both). OMNIBASE_AUTOLOAD names another autoloader to measure on.

What is checked is what loading a class loads - its parents, interfaces, traits, what its file runs - not what its methods do when called: an attribute's class, a type in a signature and a new in a method body load nothing until they run. Composer's autoloader itself includes api-platform/jsonld's HydraContext.php (a constant, a files entry) before any class: it is reported, not held against one.

A script given in place of a class (a path ending in .php) is run the same way, with the autoloader alone, and what it returns is in the --json report: BareTest runs tests/Contract/runtime-without-framework.php so, the contracts of the Foundation used without the framework (see The contracts of the Foundation).

The list only grows. A class enters it when it loads without the framework (bare --all --each); a class leaves it only by a decision written down in the plan "omnibase agnostique" and in CHANGELOG.md. On 2026-10-17 it held 599 of the 895 classes of src/; 691 of 908 after step C4; 694 of 911 after steps C3e and C3f; 718 of 921 after step C6 (the Doctrine mapping's bridge, and the services of omnibase's own caches, AbstractLocalCacheInterface no longer extending HttpKernel's WarmableInterface). The bare command is demo.sh's: an image built before it has the script only, docker compose run --rm omnibase php vendor/omnisense/omnibase/docker/bin/bare.

The names report: bare --names

A second report, informative, not a guard: which classes still name the framework, where loading them loads none of it (a type in a signature, a new in a method, an attribute). Nothing is loaded; each file of src/ is read, its use lines (grouped ones expanded, use function and use const too) and its fully qualified names. Two counts: the classes that name the framework themselves (●), and those that name it through the Base\ classes they name, followed to the end (○, with the chain). It is the dashboard of the seams (C3-C5): the Foundation only grows, this report only falls. --all reads every class of src/, a list of classes reads those; --json gives the whole report. On 2026-10-18 (after C4): of the 691 classes of the Foundation, 133 name the framework themselves (131 of the 679 before it, the two more are Symfony adapters), 420 themselves or through a Base\ name. After C3e and C3f: of 694, 129 name it themselves (128 of the classes of before, and Notification's Symfony half), 410 themselves or through a Base\ name. After C6: of 718, 134 name it themselves (the 129, and five of the services that joined: the AttributeReader and the ClassMetadataManipulator - form types in a method -, the Localizer, the Themizer, WebpackTagRenderer), 422 themselves or through a Base\ name.

doctrine/orm as released

Since 4.0 the image's doctrine/orm is as released: glitchr/base-plugin, which patched it for the cores before step C6, is gone (omnibridge/composer applies only what the core declares, extra.omnibridge: symfony/process). To run the suites on another release of doctrine/orm, mount its src/ over the installed one:

git clone --depth 1 --branch 3.7.5 https://github.com/doctrine/orm.git /tmp/orm
docker compose -f compose.yml -f compose.checkouts.yml run --rm -v /tmp/orm/src:/srv/demo/vendor/doctrine/orm/src:ro omnibase test

(docker compose run --rm omnibase composer show doctrine/orm names the installed version.) See The Doctrine mapping.

SQLite

The harness's database is SQLite; the applications run MySQL/MariaDB. Code that writes SQL by hand says so per platform: the analytics counters (Base\Repository\Analytics\*) upsert with ON DUPLICATE KEY UPDATE / INSERT IGNORE on MySQL and ON CONFLICT elsewhere (VisitRepository::insertIgnore()), and count distinct pairs over a SELECT DISTINCT subquery rather than MySQL's COUNT(DISTINCT a, b, c).

Rebuilding the image

The image installs the plugins and the sibling families from Packagist, their development branches (glitchr/omnitrade 1.x with its catalogue, omnitrade/stripe, glitchr/omnibus 2.x and two carriers). Docker keeps that layer until this checkout changes; to fetch the families' latest commits:

docker compose -f compose.yml -f compose.checkouts.yml build --build-arg OMNI_REFRESH=$(date +%s) omnibase

The same goes for the core's own dependencies on their development branches (omnibridge/doctrine, omnibridge/twig, 1.x-dev): an image built before a commit of theirs that the core needs keeps the older one. Rebuild with OMNI_REFRESH rather than mounting a checkout over vendor/; the image of 2026-10-18 has omnibridge/doctrine cf6b3b9 (the UTC types the core extends), and the suite runs on it without a mount.

docker/app/ (configuration, .env.test, tests/harness.php) is copied into the image: a change there needs a build too.

Propose a change docs/30-operations/harness.md

Shared bricks

What several sites need and used to copy, written once in omnisense/omnibase. An application or an omnibase/* bundle uses these; it does not keep a copy.

Brick Where Page
Opening hours, special days, per place, JSON-LD Base\Entity\Hours\*, Base\Service\OpeningHours Opening hours
JSON-LD LocalBusiness (address, phone, hours) Base\Service\LocalBusiness, Twig local_business_jsonld() LocalBusiness JSON-LD
Redirections of a site's old addresses Base\Entity\Layout\Redirection, Base\Service\Redirections Redirections
The sitemap: declared routes, the pages of records #[Sitemap], Base\Service\Sitemapper, SitemapEvent Sitemap
ICS file, Google Calendar link Base\Service\Calendar\{CalendarEntry, Ics, GoogleCalendarLink} Calendar
Invitation by token Base\Entity\User\Invitation, Base\Service\Invitations Invitations
Signed download links Base\Service\DownloadLinks Downloads and QR codes
QR codes, A4 and Avery sheets Base\Service\Qr\{QrCode, QrSheet} Downloads and QR codes
Documents signed for any entity (glitchr/omnisign, suggested) Base\Service\Signatures, Base\Entity\Signature\Envelope, SignatureEvent Signatures
An HTML page answered as a PDF Base\Response\PdfResponse (dompdf/dompdf, suggested) PDF responses
The e-mail frame, a subject written in the template @Base/notifier/email.html.twig, {% block subject %} E-mails
EU allergens Base\Enum\Allergen Allergens
Comments Base\Entity\Thread\Comment, CommentGuard, CommentType Comments
Framing an address Twig embed_url() Pages and texts
Contact form, data-protection notice and consent box Base\Form\Type\ContactType, ContactModel, PrivacyType Pages and texts
Pictures, texts rewritten in the back office Twig picture, Base\Entity\Layout\TextOverride Pages and texts
Glitch Art's signature @Base/partials/_credits.html.twig Front end
Base.boot(), Stimulus poll assets/boot.js, assets/controllers/poll_controller.js Front end
One thing sounds at a time: the media:play event assets/media/media.js (bundles/base/js/media.js) Front end

Installation

Nothing to enable: the services are registered by the bundle (config/services/commons.php), the entities mapped with the others. A site that updates omnisense/omnibase gets these tables on its next migration (hoursWeekDay, hoursSpecialDay, hoursScopedWeek, threadComment, layoutTextOverride, layoutRedirection):

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

Their names differ from the copies some sites kept (weekDayHours, specialDay, blog_comment, textOverride): a site that has not moved yet is not disturbed, and moving is a RENAME TABLE (or, for the blog's comments, an INSERT … SELECT) in the migration that drops its copy. See Scaffolding a site for what a new site copies.

Configuration

# config/packages/base.yaml
base:
    opening_hours:
        timezone: Europe/Paris       # the hours are given in it
        cutoff: '18:00'              # past it, orderDay() is the next open day (null: the day's last closing)
        week:                        # until a week is saved in hoursWeekDay
            3: [['09:00', '13:00'], ['16:30', '19:00']]
            6: [['09:00', '18:00']]
    local_business:                  # see LocalBusiness JSON-LD
        address: { street: '1 rue Lindebuckel', postal_code: '67300', locality: Schiltigheim, country: FR }
    comments:
        min_delay: 4                 # seconds: a form sent faster is a robot
        flood_interval: 60           # seconds between two comments from one address
    download_links:
        ttl: 600                     # seconds a signed link stays valid

Propose a change docs/40-commons/index.md

Opening hours

Base\Service\OpeningHours says when the place is open: the usual week (Base\Entity\Hours\WeekDayHours, one row per ISO day, else base.opening_hours.week) and the days off it (Base\Entity\Hours\SpecialDay: closed, or open at other hours, for one day or a run of days).

public function __construct(private OpeningHours $hours) {}

$this->hours->isOpenAt();                    // now, in base.opening_hours.timezone
$this->hours->hoursOn($day);                 // [['09:00', '13:00'], ['16:30', '19:00']], special days included
$this->hours->orderDay();                    // the day an order made now is for
$this->hours->slots($day, step: 30);         // ['12:00-12:30', ...] not before now + 30 min
$this->hours->summary();                     // the footer's lines: days in a row with the same hours together
$this->hours->notices(days: 14);             // special days to announce

Saving the week and a day off:

$weekDays->save([3 => [['09:00', '13:00']], 6 => [['09:00', '18:00']]]); // WeekDayHoursRepository
$em->persist((new SpecialDay(new \DateTime('2026-12-24'), new \DateTime('2026-12-26'), 'Noël')));

In a template, opening_hours() is the service:

{% for line in opening_hours().summary() %}…{% endfor %}
{{ local_business_jsonld() }}

schema() gives schema.org's openingHoursSpecification and specialOpeningHoursSpecification (a closed day opens and closes at 00:00): Google reads the days off from there. local_business_jsonld() prints them with the business's name, address and phone: LocalBusiness JSON-LD.

The week and the days off are edited in the back office (omnibase/admin's "Opening hours" screen, /admin/hours).

Several places

A site with one place needs nothing more. With several (shops, practices, rooms), each place is a scope: a key its owner chooses ('store:12'), or an entity (OpeningHours::scopeOf($store) is its class and id).

$hours = $this->hours->for($store);          // or ->for('store:12'): the same service, for that place
$hours->isOpenAt(); $hours->hoursOn($day); $hours->schema(); // ... every method above

$scopedWeeks->save('store:12', [1 => [['08:00', '12:00']]]);  // ScopedWeekRepository: the place's own week
$scopedWeeks->forget('store:12');                             // back to the site's
$em->persist(new SpecialDay($from, $until, 'Travaux', 'store:12'));
{% for line in opening_hours(store).summary() %}…{% endfor %}
  • The week: the place's own (Base\Entity\Hours\ScopedWeek, one row per place) when it has one, else the site's.
  • The special days: the place's own and the site's; on a date both cover, the place's wins (a shop open on a day the company is closed).
  • The injected service and opening_hours() without an argument stay the whole site's: for() returns a new instance.

An update of omnisense/omnibase adds the table hoursScopedWeek and a nullable scope column (indexed) to hoursSpecialDay; hoursWeekDay is untouched.

Tests and a change made in the same request: withWeek([...]) and withSpecialDays([...]) replace what would be read from the database.

Propose a change docs/40-commons/opening-hours.md

Calendar

Base\Service\Calendar\Ics writes iCalendar files (RFC 5545: CRLF, lines folded at 75 octets without cutting a character, escaped texts, a VTIMEZONE for each timezone used, whole days as dates) and GoogleCalendarLink the "add to Google Calendar" link. Both read a CalendarEntry, so any date of any bundle can be written: map your entity to one.

use Base\Service\Calendar\CalendarEntry;

$entry = new CalendarEntry(
    uid: $appointment->getId().'@'.$host,
    title: 'Consultation',
    start: $appointment->getStartsAt(),          // in the timezone it happens in
    end: $appointment->getEndsAt(),              // null: start + defaultDuration (PT2H)
    description: "Bring your card\nSecond floor",
    location: '12 rue de la Paix, Paris',
    url: $urls->generate('app_appointment', [...], UrlGeneratorInterface::ABSOLUTE_URL),
    cancelled: $appointment->isCancelled(),
);

return new Response($ics->calendar([$entry], 'Mes rendez-vous'), 200, ['Content-Type' => 'text/calendar; charset=utf-8']);
$link = $google->for($entry);

Ics::escape(), Ics::fold() and Ics::unfold() are public for whoever writes or reads a line by hand. omnibase/agenda maps its Event through Event::toCalendarEntry().

Propose a change docs/40-commons/calendar.md

LocalBusiness JSON-LD

schema.org's LocalBusiness for a page: who the business is, where, how to reach it, when it is open. Search engines read the address, the phone and the hours - the days off too - from there.

{# in the layout's <head>, or on the contact page #}
{{ local_business_jsonld() }}
<script type="application/ld+json">{"@context":"https://schema.org","@type":"LocalBusiness","@id":"https://soifdepub.com/#business","name":"PANO Strasbourg","url":"https://soifdepub.com/","telephone":"03 55 40 13 81","address":{"@type":"PostalAddress","streetAddress":"1 rue Lindebuckel","postalCode":"67300","addressLocality":"Schiltigheim","addressCountry":"FR"},"openingHoursSpecification":[…],"specialOpeningHoursSpecification":[…]}</script>

Where each property comes from

In this order: what the template gives, the back office's settings, the configuration. A property nobody gives is left out.

Property Setting Configuration (base.local_business)
@type type (LocalBusiness; Restaurant, Store, ProfessionalService…)
name base.settings.title name (wins over the title when set)
slogan base.settings.slogan
url, @id url (else the site's home address)
telephone base.settings.phone telephone
email email
image image (a path under public/, or an address)
address base.settings.address.street, .postal_code, .locality, .region, .country address.street, .postal_code, .locality, .region, .country
geo geo.latitude, geo.longitude
priceRange price_range
sameAs, areaServed same_as, area_served
anything else extra (merged as it is)
openingHoursSpecification, specialOpeningHoursSpecification OpeningHours::schema()
# config/packages/base.yaml
base:
    local_business:
        type: LocalBusiness
        image: assets/ico/android-chrome-512x512.png
        address: { street: '1 rue Lindebuckel', postal_code: '67300', locality: Schiltigheim, country: FR }
        geo: { latitude: 48.6071, longitude: 7.7399 }
        same_as: ['https://www.facebook.com/panostrasbourg/', 'https://www.instagram.com/pano_strasbourg']
        area_served: [Strasbourg, Schiltigheim, Bas-Rhin]
        extra:
            parentOrganization: { '@type': Organization, name: PANO, url: 'https://pano-group.com' }

omnibase/admin's settings page has the phone and the address (BusinessSettingsSection): what is typed there wins over the configuration.

In a template

{{ local_business_jsonld({priceRange: '€€', servesCuisine: 'Japanese'}) }}   {# properties of the page's own; null removes one #}
{{ local_business_jsonld({name: store.name}, store) }}                       {# one place: its own hours (OpeningHours::for()) #}
{% set business = local_business() %}                                        {# the properties, to put in a graph of the page's own #}

The script is printed as it is (<, > and & are escaped inside the JSON: a title cannot close the script). In PHP: Base\Service\LocalBusiness::schema() and jsonLd().

Propose a change docs/40-commons/local-business.md

Invitations

Nobody signs up, someone is invited: a random token (48 hexadecimal characters) is sent by e-mail and never stored - only its SHA-256 is kept. Its link, before it runs out, creates the account (the link proved the address) or attaches the existing one.

  • Base\Entity\User\Invitation: a mapped superclass (e-mail, name, tokenHash, createdAt, expiresAt, acceptedAt, invitedBy). Each bundle keeps its own table and says what an invitation opens.
  • Base\Service\Invitations: token(), hash(), expiry($days), save(), findOpen($class, $token), markAccepted(), createUser(), existingUser().
#[ORM\Entity]
#[ORM\Table(name: 'office_invitation')]
class Invitation extends \Base\Entity\User\Invitation
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id = null;
    #[ORM\ManyToOne] private Practice $practice;
    public function getId(): ?int { return $this->id; }
}

$token = Invitations::token();
$invitations->save(new Invitation($email, $name, Invitations::hash($token), Invitations::expiry(14)));
// mail the link with $token; on the link:
$invitation = $invitations->findOpen(Invitation::class, $token) ?? throw new GoneHttpException();

omnibase/estate extends both (its Invitation keeps estate_invitation, its Invitations opens an associate, a tenant, a contractor or a member).

Propose a change docs/40-commons/invitations.md

Downloads and QR codes

Base\Service\DownloadLinks signs a route's absolute URL with Symfony's UriSigner for base.download_links.ttl seconds (or the TTL given). Who may download is checked when the link is handed out; the download action only checks the signature.

return $this->redirect($links->sign('forge_download_file', ['id' => $artifact->getId(), 'filename' => $artifact->getFilename(), 'license' => $license->getId()], 900));

#[Route('/telechargements/fichier/{id}/{filename}', name: 'forge_download_file')]
public function file(Request $request, ...): Response
{
    if (!$this->links->verify($request)) { throw $this->createAccessDeniedException(); }
    ...
}

omnibase/forge (artifacts) and omnibase/classroom (resources) use it.

QR codes

Base\Service\Qr\QrCode draws a code with endroid/qr-code: svg($data), dataUri($data, $size, $margin, 'svg'|'png'). In Twig: qr_code(url).

Base\Service\Qr\QrSheet lays codes out on A4 pages to print at 100 %:

Layout Labels Size (mm)
a4 1 per page 210 × 297
L7160 21 (3 × 7) 63.5 × 38.1
L7163 14 (2 × 7) 99.1 × 38.1
L7121 20 (4 × 5) 45 × 45 (square, made for QR codes)
#[Route('/admin/tables/qr')]
public function sheet(QrSheet $sheet): Response
{
    $items = array_map(fn (Table $t) => ['data' => $this->generateUrl('app_table', ['id' => $t->getId()], UrlGeneratorInterface::ABSOLUTE_URL), 'label' => $t->getName()], $tables);

    return new Response($sheet->render($items, 'L7160', 'Tables', skip: 2)); // the first two labels already used
}

pages() gives the positions without rendering, for a template of your own. Check a layout once with a test print: label sheets of other makes differ by a millimetre or two.

Propose a change docs/40-commons/downloads-and-qr.md

Allergens

Base\Enum\Allergen lists the fourteen allergens a food business declares in the European Union (Regulation (EU) No 1169/2011, Annex II), in the Annex's order: gluten, crustaceans, eggs, fish, peanuts, soybeans, milk, nuts, celery, mustard, sesame, sulphites, lupin, molluscs.

#[ORM\Column(type: 'json')]
private array $allergens = [];                       // ['gluten', 'milk']

Allergen::fromList($dish->getAllergens());            // [Allergen::GLUTEN, Allergen::MILK], in the Annex's order
Allergen::MILK->number();                             // 7
$builder->add('allergens', ChoiceType::class, ['choices' => Allergen::choices(), 'multiple' => true, 'expanded' => true, 'choice_translation_domain' => 'enums']);
{% for a in allergens %}<abbr title="{{ ('@enums.' ~ a.trans)|trans }}">{{ a.number }}</abbr>{% endfor %}

Names in the enums domain (allergen.<value>), French and English.

Propose a change docs/40-commons/allergens.md

Comments

Base\Entity\Thread\Comment is what a visitor leaves under a thread (a blog post, a sequence, a video) - or, with no thread, in the site's visitors' book: a name, an e-mail (never shown), a site, a text, a reply to another comment (one level), the account when signed in. Table threadComment.

  • States (Base\Enum\CommentState): pending, approved, spam, trash.
  • Akismet: the form's model (Base\Form\Model\CommentModel) implements SpamProtectionInterface; the score decides the state (toComment($thread, $autoApprove)).
  • Robots: Base\Form\Type\CommentType adds a trap field (url) and the time the form was opened (opened); Base\Service\CommentGuard::check($form, $request) answers trapped (thank it, save nothing), too_fast, flood or null (base.comments.min_delay, base.comments.flood_interval).
  • Reading: Base\Repository\Thread\CommentRepository::findVisible($thread), countVisible(), findPending(), countPending().
$model = CommentModel::forUser($user);
$form = $this->createForm(CommentType::class, $model, ['signed_in' => null !== $user]);
$form->handleRequest($request);
if ($form->isSubmitted() && CommentGuard::TRAPPED === $guard->check($form, $request)) { return $this->redirect($back); }
if ($form->isSubmitted() && $form->isValid()) {
    $comment = $model->toComment($thread, autoApprove: true)->setIp($request->getClientIp());
    $em->persist($comment); $em->flush();
}

A bundle that needs more on its comments extends Comment (JOINED) with a #[DiscriminatorEntry] of its own and passes its class to toComment(). omnibase/blog is the reference user (moderation screen, notifications, Akismet reports).

Propose a change docs/40-commons/comments.md

Pages and texts

{% set frame = embed_url(link) %}
{% if frame and frame.src %}
    <div style="aspect-ratio: {{ frame.ratio }}"><iframe src="{{ frame.src }}" title="{{ frame.title ?? frame.provider }}" loading="lazy" allowfullscreen></iframe></div>
{% else %}
    <a href="{{ link }}">{{ link }}</a>
{% endif %}

frame is {provider, src, ratio, height, title, html, url, known} (height: a player of a fixed height across the width, SoundCloud's, has no ratio). Known providers are read from the address alone (known: true): YouTube (as youtube-nocookie.com), Vimeo, Canva, Padlet, LearningApps, Genially, Google Slides. Any other address is asked to its site through the embed/embed library (oEmbed, Open Graph, a requirement of omnisense/omnibase), cached a day per address, and only the iframe's address is kept. embed_url(link, false) never asks.

Contact form

Base\Form\Type\ContactType on Base\Form\Model\ContactModel, as it is:

$form = $this->createForm(ContactType::class, $message = new ContactModel(), ['phone' => true, 'subject' => false, 'attachments' => false, 'buttons' => false, 'trap' => true]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
    if (!$message->isRobot()) { $mailer->send(...); }   // the trap filled: thanked, nothing sent
}

Options: phone (off), subject (on), attachments (on), buttons (submit and reset, on), trap (the off-screen website field, off). The model validates the name, the e-mail and the message.

The data-protection notice, and the box to tick

A form that collects personal data says what they are used for (GDPR, art. 13), and may ask for a box to be ticked before it is sent. One brick for every site and bundle, Base\Form\Type\PrivacyType, which ContactType adds through three options:

Option Default
privacy false true: omnibase's notice (@forms.privacy.notice); a translation key: the site's own; false: none
privacy_consent false the box privacy[accept], required: the form is invalid until it is ticked (IsTrue, message privacy.consent_required in validators)
privacy_parameters [] the notice's parameters - a link to the privacy page, for instance
$form = $forms->createNamed('contact', ContactType::class, $message = new ContactModel(), [
    'phone' => true, 'subject' => false, 'attachments' => false, 'buttons' => false, 'trap' => true,
    'privacy' => '@messages.contact.privacy',              // its text links {url}, the privacy page
    'privacy_parameters' => ['url' => $this->generateUrl('app_legal')],
    'privacy_consent' => true,
]);
{{ form_row(form.message) }}
{{ form_row(form.privacy) }}   {# the notice, then the box when asked #}

Any other form - a quote request, an application, a registration - adds the same field rather than its own checkbox and sentence:

$builder->add('privacy', PrivacyType::class, [
    'notice' => '@forms.privacy.notice',   // or the bundle's key, or false
    'notice_parameters' => [],
    'consent' => true,
    'consent_label' => '@forms.privacy.consent',
    'consent_message' => 'privacy.consent_required',
]);

The field is not mapped: the model keeps the message, not the tick (read it with $form->get('privacy')->get('accept')->getData() if it must be kept). The notice is printed by omnibase's form theme (block base_privacy_row: a div.form-privacy holding p.form-privacy-notice, then the box) as HTML, since it is a translation the site wrote. The default texts exist in English and French (forms: privacy.notice, privacy.consent); a site in another language, or with other words, gives its own keys.

Pictures: |picture

<img src="{{ post.cover|picture(640) }}">   {# an upload, resized; a URL or /path as is; else public/assets/<it> #}

Texts rewritten in the back office

Base\Entity\Layout\TextOverride (table layoutTextOverride): a key, a domain, a language and the new text. Base\Translation\OverridingTranslator decorates the translator (between omnibase's and Symfony's): a rewritten key wins over translations/, ICU syntax included. The texts are cached (the application's cache.redis pool when it has one, else cache.app) and the cache is emptied when one is saved. The screen is omnibase/admin's "Textes du site" (Base\Admin\Controller\Crud\TextOverrideCrudController).

A text written in the editor: |wysiwyg

A field edited with omnibase's editor (WysiwygField, EditorJS) stores a JSON document, {"blocks": [...]}; a field edited with Quill stores HTML. |wysiwyg prints either:

{{ page.content|wysiwyg|raw }}
{{ page.content|wysiwyg({headings: 3, semantics: true, media: {storage: 'local.public'}})|raw }}
{{ page.content|wysiwyg({hydrate: false})|raw }}        {# the HTML alone #}

The blocks of an EditorJS document are rendered on the server (Base\Service\Model\Wysiwyg\EditorJsRenderer): paragraph, header, list (nested, ordered, checklist), quote, image, embed, table, code, delimiter, warning / alert, raw, link, attachment. The page reads without any script, and so does a crawler. (It used to print an empty <div data-edjs="…"> that only the editor's script could fill: a site that does not load that script on its public pages showed nothing.)

Option
hydrate true (default): the document also stays in data-edjs, and a page that loads the editor's script (form.editor) gets a read-only editor in place of the HTML, as before. false: the HTML alone
headings anchors on the headings, down to that level (true: all) - with |wysiwyg_toc for the table of contents
semantics the site's keywords linked
media false, or the image settings (storage)
row_attr attributes of the wrapping <div class="codex-container wysiwyg">

A block's text keeps the editor's inline HTML (bold, links, marks, mentions); tags outside that list, on… attributes and javascript: addresses are removed. In PHP: EditorJsRenderer::render($json) gives the HTML of a document (an e-mail, a feed, a search index).

Propose a change docs/40-commons/pages-and-texts.md

Signatures

A document sent to be signed - a lease, a quote, a surety - for any entity of the application, through glitchr/omnisign (Yousign, DocuSign, a DocuSeal instance of one's own): signed by the provider's e-mail or in the site's own page, followed until it is over, and once completed the signed document and its evidence kept in the site's private storage.

use Omnisign\Model\{Document, Envelope as Request, Field, File, Signer};

$envelope = $signatures->send($quote, new Request(
    'Devis D-2026-042',
    [new Document('quote', new File($pdf, 'devis-D-2026-042.pdf'))],
    [new Signer('client', 'Camille Érable', 'camille@erable.example')],
    [new Field('client', 'quote', page: 1, x: 72, y: 640)],
    embedded: true,                                    // signed in the site's page, no e-mail from the provider
));
return $this->redirect($signatures->signingUrl($envelope, 'client', $returnUrl));

$signatures->refresh($envelope);                       // on the way back - the provider's webhook does it too
$signatures->signed($envelope);                        // the signed PDF, once completed
$signatures->evidence($envelope);                      // its audit trail or certificate
$signatures->of($quote);                               // the envelopes about the quote, newest first

Only with the family

glitchr/omnisign is suggested, not required. Without it nothing of this exists in a site:

  • Base\Service\Signatures is not registered;
  • Base\Entity\Signature\Envelope is not an entity - no signature_envelope table, nothing in the site's schema, migrations or fixtures (its fields are those of the mapped superclass AbstractEnvelope, which has no table);
  • POST /signatures/{gateway}/webhook answers 404.

With it: composer require glitchr/omnisign omnisign/docuseal (or omnisign/yousign, omnisign/docusign; not on Packagist yet: the vcs repositories of omnisense/omnibase's composer.json), register Omnisign\Bridge\Symfony\OmnisignBundle, configure a gateway, then make:migration and doctrine:migrations:migrate for the table:

# config/packages/omnisign.yaml
omnisign:
    gateways:
        contracts:
            factory: docuseal
            options:
                url: '%env(DOCUSEAL_URL)%'                 # https://sign.example.org/api
                api_key: '%env(DOCUSEAL_API_KEY)%'
                webhook_secret: '%env(default::DOCUSEAL_WEBHOOK_SECRET)%'

# config/packages/base.yaml
base:
    signatures:
        gateway: contracts        # null: the only gateway configured
        storage: ~                # null: the uploads' storage (base.uploader.storage, var/storage/uploads)

What an envelope keeps

subjectClass, subjectId the entity it is about - any one, by Doctrine's name of its class and its id
gateway, reference the omnisign gateway and its reference there: what to ask it again by
status draft, sent, completed, declined, expired, canceled
envelope what it takes to ask the provider again: the signers and where each stands (getSigners()), the documents' keys and names, the provider's own references - not the documents' content
signedFile, evidenceFile their paths in the private storage, once completed: signatures/<year>/<random>/<name>

The files are read back through signed() and evidence(); a site serves them to whoever may see them, with a signed link (Base\Service\DownloadLinks) or its own access check - the storage itself is out of public/.

Following it

  • refresh() asks the provider where it stands (a page the signer comes back to, a command).
  • The provider's webhook: POST /signatures/{gateway}/webhook (base_signature_webhook) - its signature checked by the gateway (webhook_secret, or the provider's own key), the envelope it names asked again. A callback whose signature does not hold is answered 400.
  • Events (Base\Event\SignatureEvent), dispatched once each: SIGNED when a signer signed, then COMPLETED, DECLINED, EXPIRED or CANCELED; getSubject() gives the entity it is about. The application answers there: a quote accepted, a lease in force.
#[AsEventListener(event: SignatureEvent::COMPLETED)]
public function onSigned(SignatureEvent $event): void
{
    if ($event->getSubject() instanceof Quote) { ... }
}

remind() asks the provider to remind who has not signed; cancel() withdraws it.

Tests

tests/Service/SignaturesTest.php, in the harness (which installs glitchr/omnisign and omnisign/docuseal and registers a DocuSeal gateway): a lease sent for a member, signed in the page, followed to completion, its files kept and read back, its events, the webhook's signature - DocuSeal answering as an instance of its open-source edition answered (omnisign/docuseal's recorded answers). What a provider does for real is said in each gateway's documentation.

Propose a change docs/40-commons/signatures.md

Front end

The same on every site: the year on two lines beside the pixel monkey (it dances on hover), Source Code Pro bold 18pt/21px, linking to https://www.glitchr.io. Its stylesheet (bundles/base/css/credits.css) is linked by the partial.

{% include '@Base/partials/_credits.html.twig' %}                                   {# black #}
{% include '@Base/partials/_credits.html.twig' with {color: 'white', outline: true} %}
{% include '@Base/partials/_credits.html.twig' with {mirror: true, rule: true, site_name: 'Apfelschule'} %}
Parameter Values
color black (default), white the text and the monkey; grey on hover. Never a colour of the site.
outline false, true a white contour, on a photo or a busy background
mirror false, true the detail line on the right of the signature instead of the left: for a signature set on the left of its footer
rule false, true a dotted rule between the detail's two lines
detail true, false the detail line at all
site_name text its second line

The detail line (the address, the time the page took, the site's name) fades in while the signature is pointed at or focused, and is always shown without a pointer (@media (hover: none)). Filling it differently:

{% embed '@Base/partials/_credits.html.twig' with {color: 'black'} %}
    {% block credits_detail %}{{ parent() }}<span class="ga-credits-line" lang="de">Ein Apfel am Tag</span>{% endblock %}
{% endembed %}

The signature stands against the edge the site sets it on, whether its detail is shown or not: where the detail fades in and out (@media (hover: hover)) it holds no room and overflows on its own side. It is declared on the inner side: the default (detail on the left) for a signature on the right of its footer, mirror: true (detail on the right) for one on the left - else the detail opens outwards, over the page's margin. Without a pointer the detail is always shown and part of the block.

Nothing else is customisable: neither the font nor the colours.

Base.boot()

What every site's assets/app-defer.js repeated: Sticky.ready(), StickyStops.attach() and Transparent.ready() in that order, the back office as a website-in-website. The three come from @glitchr/flowjs (one library, one module per feature; the stops measure themselves again after each navigation). Install it from npm (yarn add @glitchr/flowjs) and import only the modules the site uses: Base.boot() starts a module only if its global is there, so a site that imports the navigation alone starts the navigation alone. The core's own bundle imports none of them - in particular not Ajaxer, which a site that sends named AJAX channels imports itself (import '@glitchr/flowjs/ajaxer').

The one module the core does import is the library's sixth, the cookies: base-async brings @glitchr/flowjs/cookie (the Cookie global, with flowjs's core) to every page, see Cookies below. The core's base-async runs before a site's entries and the library keeps the first Flow loaded, so a site takes the same @glitchr/flowjs as the core (^1.3.1 in its package.json, 1.3.1 or later in its yarn.lock): an older navigation or sticky module would otherwise run on the newer core.

import '@glitchr/flowjs/navigation';   // the modules the site uses: '/sticky', '/stops', '/navigation'
import Base from '../vendor/omnisense/omnibase/assets/boot.js';

Base.boot({
    nest: ['/admin*'],                                  // default
    stops: { reach: 140, settle: 320, duration: 700 },  // StickyStops' options; true: its defaults; false: none
    viewTransitions: false,
    exceptions: ['/agenda.ics'],                        // after /_*, /login*, /logout*, /register*, /locale/*, /connect/*
    current: '.site-nav a',                             // aria-current="page" follows the page
    transparent: { exit_duration: 380 },                // anything else for Transparent.ready(); false: not started
    sticky: false,                                      // Sticky.ready()'s options; false: not started
});

It also makes jQuery's closestScrollable safe, keeps the <style> blocks scripts inject into <head> across swaps (data-headlock), and sends a site page that lands inside the nest frame to the main window. The routes that open in the nest answer X-Transparent-Nest (omnibase/admin's NestHeaderSubscriber: every page under /admin, and any route with defaults: ['_nest' => true]).

media:play: one thing sounds at a time

A page may hold several players - the audio bar of omnibase/music, the video player of omnibase/video, a film in its card, a third-party embed, a plain <audio>. They share nothing but this contract:

  1. a player that starts sounding dispatches media:play on document, with detail: { player, kind?, id?, … } - player is any object that is this player and no other;
  2. a player that hears media:play from another player pauses itself.

No queue, no common state, no interface to implement: each player keeps its own. media.js (no dependency) is those two lines written once:

<script src="{{ asset('bundles/base/js/media.js') }}" defer></script>
import '../vendor/omnisense/omnibase/assets/media/media.js';     // bundled: the same script, window.MediaPlay

const leave = MediaPlay.join(player, () => player.pause());      // 2. paused when another starts
MediaPlay.announce(player, { kind: 'audio', id: track.id });     // 1. when this one starts
leave();                                                         // the player is destroyed
MediaPlay.current;                                               // the player that sounded last, or null

A script that does not load media.js takes part with the event alone:

document.dispatchEvent(new CustomEvent('media:play', { detail: { player: me } }));
document.addEventListener('media:play', (event) => { if (event.detail.player !== me) pause(); });

Plain elements need nothing. An <audio> or a <video> that starts with its sound on announces itself (detail.player is the element, kind its tag), and is paused when another player starts. A muted one - a hero's silent loop, a preview on hover - neither announces nor is paused; unmuted while playing, it announces itself then. data-media-play="off" on an element, or on anything around it, leaves it out (a sound effect; an element driven by a player that calls announce() itself).

A third-party player that cannot be paused (an embed without an API) is removed, or its frame reloaded, by the function given to join(); a player whose pause fails does not keep the others from pausing.

Stimulus poll

Ask a JSON address every few seconds and react when a value grows - a kitchen board, a queue.

// assets/bootstrap.js
import Poll from '../vendor/omnisense/omnibase/assets/controllers/poll_controller.js';
app.register('poll', Poll);
<div data-controller="poll" data-poll-url-value="{{ path('app_kitchen_state') }}"
     data-poll-key-value="latest" data-poll-since-value="{{ latest }}"
     data-poll-chime-value="true" data-poll-awake-value="true"
     data-action="poll:change->board#show">
    <button data-action="poll#arm">Activer les alertes</button>
    <button data-action="poll#silence">Vu</button>
</div>

The answer must hold the compared field ({"latest": 42, ...}). On a larger value: poll:change (detail: the answer), the class is-ringing, and with chime three notes every two seconds until poll#silence. arm() is the touch browsers ask before a page may make sound; it also keeps the screen on (awake). A hidden page is not polled.

Nothing from elsewhere

A page of a site loads its scripts, stylesheets and fonts from the site itself: no CDN sees the visitor (tests/Http/NothingFromElsewhereHttpTest reads the contact page of the harness - which links base and form as a site's layout does - and what its files pull in).

The editor's code blocks (editorjs-code-highlight) are coloured by highlight.js's default theme. The package's own bundle @imports it from cdnjs.cloudflare.com, and injects it with its styles on every page the editor's script is on - every page with a form. That @import is dropped at build (assets/loaders/no-remote-import.js); the theme is the bundle's copy, bundles/base/css/highlight.js/default.css (highlight.js 11.6.0, BSD 3-Clause, its licence beside it as LICENSE.txt), linked by the editor's script once, and only on a page that shows an editor or a block of code.

The emoji picker (EmojiPickerType, entry form.emoji, picmo) fetched its emojis and their labels from jsDelivr. They are the bundle's copy: bundles/base/emoji/<locale>/data.json and messages.json (emojibase-data 17.0.0, MIT, its licence beside them as LICENSE.txt), in English, French, German and Japanese - the page's <html lang> picks, English otherwise. form-type-emoji.js fetches them on the first click on a field and gives them to the picker, which then fetches nothing; what remains of picmo's own fetching is pointed at the same copy at build (assets/loaders/local-emoji-data.js). The harness's /fields carries one.

The JavaScript dependencies

yarn.lock names every package the bundles are built from, and make assets installs those alone (yarn install --frozen-lockfile): from a clean copy it rebuilds bundles/base byte-identical to what is committed. To take newer packages: ALLOW_ASSETS_UPDATE=1 make assets, then commit the lock with the bundles it built. Node 24 or later (select2's engines).

Cookies

base-async imports @glitchr/flowjs/cookie: the Cookie global on every page (Cookie.set(group, name, value, expires), Cookie.get(), Cookie.cookies, Cookie.local, Cookie.session, Cookie.use(), see the library's docs/cookie.md). It replaces @glitchr/cookie, whose methods and localStorage keys (cookie/<GROUP>) it keeps: a site drops @glitchr/cookie from its package.json and, if it imported it itself, imports nothing in its place.

What is written goes through it, so that the visitor's answer decides (omnibase/consent declares the categories and shows the panel; without that bundle a group is written until Cookie.setConsent(false, group) refuses it):

Cookie Written by Under
USER/INFO assets/styles/js/user.js: the browser's language, calendar, numbering system, light or dark theme USER
LOCATION/TIMEZONE user.js: the browser's time zone LOCATION, when the site declares that category for its visitors to answer (omnibase/consent's default); else the zone stays in USER/INFO (timezone), under USER
USER/THEME/* a site's theme switch USER
ANALYTICS/VISITOR_ID a site's own script, if it counts unique visitors (Cookie.set('ANALYTICS', 'VISITOR_ID', ...)) - the core only reads it (PageViewSubscriber) ANALYTICS

| USER/LAST_VISIT | the server, when a template asks app.site.is_newcomer() - not for a visitor who refused USER (Base\Security\CookieAnswer::refused('USER') reads the answer the script keeps in the cookie cookie-consent) | USER |

The server reads the others and writes none of them: User::getCookie('timezone') is LOCATION/TIMEZONE, else USER/INFO's; getCookie('country') is read from that zone. A script of a site writes its own the same way (Cookie.set('USER', 'THEME/MODE', mode, ...), or Consent.cookie(group, name, value, expires)), never with document.cookie: what is written behind Cookie is neither kept from being written after a refusal nor known to the detailed section.

Propose a change docs/40-commons/front-end.md

Redirections

The addresses a site no longer answers, and where they lead now: the pages of a site taken over (/produit/enseigne-lumineuse/ → /savoir-faire/enseigne), a page renamed. One row each in Base\Entity\Layout\Redirection (table layoutRedirection), written in the back office (omnibase/admin's "Redirections" screen), by fixtures or by an import.

public function __construct(private Redirections $redirections) {}   // Base\Service\Redirections

$this->redirections->add('/produit/enseigne-lumineuse/', '/savoir-faire/enseigne');          // 301
$this->redirections->add('/soldes', '/boutique', Redirection::TEMPORARY);                    // 302
$this->redirections->add('/?p=123', '/actualites/ouverture');                                // a WordPress address
$this->redirections->add('/categorie-produit/*', '/savoir-faire/*');                         // everything beneath
$this->redirections->add('/charte', 'https://pano-group.com/charte', flush: false);          // another site; flushed by the caller

When

Only when a request ends in "not found" - no route matched, or a controller found no record (Base\Subscriber\RedirectionSubscriber, on the kernel's exception). A page the site answers is never redirected, whatever the table says, and costs no query. GET and HEAD only: a form sent to an old address is not replayed elsewhere.

Which

Source Matches
/produit/enseigne that path, with or without a trailing slash, whatever its query
/?p=123 that path with exactly that query
/categorie-produit/* that path and everything beneath it; a * in the target takes what the star stood for

A source is stored as its path (Redirection::normalize(): no host, no trailing slash, no fragment), so a whole address pasted from the old site is fine. The one written for the address with its query comes first, then the one for its path, then the longest prefix.

The case of an exact source is the database's to judge: with MySQL's default collation /Produit/Enseigne is /produit/enseigne and is redirected (the row was found, then refused for its case: the address answered 404); on a case-sensitive database (SQLite, a _bin collation) only the same letters match. A prefix (/categorie-produit/*) is compared letter for letter everywhere.

The target is a path of the site or a whole address. When the source has no query and the target none either, the visitor's query follows (?utm_source=…).

What it keeps

status (301 by default: search engines move what they know of the old address to the new one; 302 for a detour that will not last), enabled, hits and lastHitAt (counted at each redirection, in UTC): the back office shows which old addresses are still visited, and which can go.

A site that updates omnisense/omnibase gets the table on its next migration. Until then nothing is redirected and nothing fails.

Propose a change docs/40-commons/redirections.md

PDF responses

Base\Response\PdfResponse answers an HTML page as a PDF: an invoice, a rent receipt, a quote. It renders with Dompdf, a suggested package - omnibase does not require it:

composer require dompdf/dompdf

Without it, PdfResponse throws a LogicException that says so (Try running "composer require dompdf/dompdf"), where PHP used to stop on Class "Dompdf\Dompdf" not found.

use Base\Response\PdfResponse;

return new PdfResponse($this->renderView('invoice.pdf.twig', ['order' => $order]));

return new PdfResponse($html, 200, [
    'filename' => 'facture-2026-041.pdf',
    'stream' => ['Attachment' => true],            // downloaded, not shown in the browser
    'options' => ['defaultPaperSize' => 'a4'],     // Dompdf's options
    'Cache-Control' => 'private, no-store',        // anything else is a header
]);

$pdf = PdfResponse::render($html);                 // the PDF as a string: an e-mail attachment, a file to keep
Key of $headers
filename the name in Content-Disposition (document.pdf)
stream Attachment (false: shown inline), compress (true)
options Dompdf's options, over omnibase's defaults (PdfResponse::options())
debug true: the HTML itself is answered, to look at it in a browser

The PDF is the response's content

The PDF is in getContent(), with Content-Type: application/pdf and a Content-Disposition; the kernel sends it like any response. (It used to be sent by Dompdf's stream() from the constructor: headers and bytes went out before the kernel answered, a test could not read the response, a listener could not touch it.)

Remote resources are off

isRemoteEnabled is false by default: an <img>, a stylesheet or a font at an http(s) address is not fetched, and is left out of the PDF. The page often carries what a visitor typed (a name, an address, a message), and fetching what it names from the server is a request forgery (SSRF).

  • images: embed them, <img src="{{ logo|embed_base64 }}">;
  • styles: in a <style> of the page; fonts: Dompdf's own (DejaVu, Helvetica, Times) or files installed with Dompdf's font loader;
  • a page that names only addresses you wrote yourself may turn it back on: ['options' => ['isRemoteEnabled' => true]].

A template that linked a web font (<link href="https://fonts.googleapis.com/…">) now prints in the fallback font until it does one of the above.

Propose a change docs/40-commons/pdf.md

E-mails

omnibase's frame

@Base/notifier/email.html.twig is the frame of omnibase's e-mails (Inky, styles inlined). A notification renders it with its own context; a plain TemplatedEmail extends it and gives nothing but its blocks:

{# templates/email/appointment.html.twig #}
{% extends '@Base/notifier/email.html.twig' %}
{% block subject %}{{ 'booking.email.subject'|trans({date: date}) }}{% endblock %}
{% block content %}<p>{{ 'booking.email.lead'|trans }}</p>{% endblock %}
$mailer->send((new TemplatedEmail())
    ->to($client->getEmail())
    ->htmlTemplate('email/appointment.html.twig')
    ->context(['date' => $date]));
  • Every variable has a default: importance, markdown, raw, content, action_url, action_text, exception, footer_text. (The frame used to stop on "Variable importance does not exist" outside a notification.)
  • Blocks: subject, title, style, header, lead, content, action, exception, footer, footer_content.
  • The logo is optional: the header block prints one only when there is one - the logo variable of the context, else the back office's setting (base.settings.logo.email, then base.settings.logo). A site without a logo sends its e-mails without one.

A site with its own frame (templates/email.html.twig, as the scaffold copies) keeps it: omnibase's own templates extend email.html.twig, which is the site's when it has one.

The subject is written in the template

{% block subject %} of a TemplatedEmail's template is the message's subject (Base\Subscriber\TemplatedEmailSubjectSubscriber, before the body is rendered): rendered with the e-mail's context, tags removed, entities decoded. It was only printed in the body, and the message left without a subject.

A subject set in PHP wins - ->subject('Rappel') - and is handed to the template as the subject variable, which the frame prints as its title and lead instead of the block.

A notification and its e-mail

Base\Entity\User\Notification is what a notification is - subject, content, importance, channels, recipients, context, templates - and its sending through omnibase's notifier (send(), sendBy(), sendAdmins()). What needs Symfony is Base\Bridge\Symfony\NotificationMessages' (plan "omnibase agnostique", step C3f): the e-mail of asEmailMessage() (Twig's NotificationEmail, the uploaded attachments embedded, the HTML rendered), render()'s Response (renderHtml() is the HTML alone), and the error an ExceptionEvent or a FlattenException carries made the content. The entity's methods are those of before and answer the same; it names nothing of the framework (Symfony's Notifier is a standalone component). addAttachment() takes a \SplFileInfo; the e-mail embeds the uploaded files (UploadedFile), as it did.

Propose a change docs/40-commons/emails.md

Sitemap

/sitemap.xml and /sitemap.txt (app_sitemap) list the pages a site wants indexed. Base\Service\Sitemapper builds the list at each request, in two passes.

Pages without a parameter: #[Sitemap]

use Base\Attributes\Attribute\Sitemap;

#[Route('/agenda', name: 'agenda_index')]
#[Sitemap(priority: 0.8, changefreq: 'weekly')]
public function index(): Response

Every route whose action carries the attribute is listed (registerAttributes()), once per locale. A route with a parameter is listed too when its requirement spells its values out ({extension} with xml|txt): one page per value.

Pages of records: SitemapEvent

A route open to any slug or id is the application's - or the bundle's - to enumerate, from a listener:

#[AsEventListener(event: SitemapEvent::BUILD)]
final class SitemapListener
{
    public function __invoke(SitemapEvent $event): void
    {
        foreach ($this->posts->findAllPublished() as $post) {
            $event->getSitemapper()->register('blog_post', ['slug' => $post->getSlug()], $post->getUpdatedAt()?->format('c'));
        }
    }
}

The route's action still carries #[Sitemap]: the attribute says the page belongs in the sitemap, with which priority; the listener says which pages there are. registerUrl('/blog/petit-cours') does the same from an address.

A page that cannot be listed is left out, never fatal

register() and registerUrl() do not throw. A page is left out, and the reason logged once per request (warning, Sitemap: page left out, ...), when:

  • the route does not exist;
  • the address, parameters filled in, matches no route;
  • the route's action carries no #[Sitemap] - no #[Sitemap] on "App\Controller\BlogController::post" (route "blog_post").

The last one used to throw SitemapNotFoundException: a listener registering a route nobody had declared put the whole /sitemap.xml - every other page with it - in 500. The fix is still to add the attribute; the log says where. A route whose controller is given as an array ([Controller::class, 'method']) or a closure is read without failing as well.

The attributes of a class are read once and kept with the container's cache (var/cache/<env>/pools/simple/, Base\Attributes\AttributeReader): a #[Sitemap] just added is not seen until that cache is rebuilt. When the page stays out after a cache:clear, delete var/cache/<env> and warm it again.

Propose a change docs/40-commons/sitemap.md

Configuration reference

Everything under the base: key of config/packages/base.yaml, as Base\DependencyInjection\BaseConfiguration declares it, with its type, its default and what reads it. The values are processed once, when the container is built, by Base\DependencyInjection\BaseExtension::load().

How the values are read

BaseExtension does not hand the processed tree to services one by one: it sets every node as a container parameter named after its path, base. and the keys joined by dots (AbstractBaseExtension::setConfiguration()). An array is set both whole and leaf by leaf: base.time_machine.excludes is the list, base.time_machine.excludes.0 its first entry. The code reads them through Base\Service\ParameterBagInterface ($parameterBag->get('base.uploader.storage')), %base.download_links.ttl% in a service definition, or Base\Contract\Runtime::parameters() where there is no container (an entity's getter).

Under Symfony the parameter bag is Base\Service\HotParameterBag: the settings stored in the database are laid over the container's parameters (see The parameters and the settings over them). A path the configuration does not declare reads as null.

The smallest configuration

The harness's application (docker/app/config/packages/base.yaml) runs on this:

base:
    parameter_bag:
        use_hot_bag: true
        use_setting_bag: true
    database:
        use_custom: true
    router:
        use_custom: true
    attributes:
        use_custom: true
    twig:
        use_custom: true
        use_form2: true
        use_bootstrap: true
        editor:
            storage: "local.wysiwyg"
    uploader:
        storage: "local.uploads"
    site:
        index: demo_index

The four use_custom flags and the two parameter_bag flags default to null (off): a site that leaves them out runs without omnibase's router, Twig loader, attribute reader and Doctrine features, without the settings stored in the database, and without those settings laid over the parameters (HotParameters reads the configuration whether the overlay is on or not). The storages are Flysystem storages of the site's own (config/packages/flysystem.yaml).

Dumping the tree

bin/console config:dump-reference base dumps it, and debug:config base the site's values (BaseExtension::getConfiguration()). The tree below was dumped in the core's harness with Symfony's reference dumper, from this script (the image's vendor, the core mounted over its copy):

<?php
// dump.php
require '/srv/demo/vendor/autoload.php';

use Symfony\Component\Config\Definition\Dumper\YamlReferenceDumper;

echo (new YamlReferenceDumper())->dump(new Base\DependencyInjection\BaseConfiguration());
docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnisense/omnibase:ro -v "$PWD/var/scratch":/scratch:ro \
    --entrypoint php omnisense/omnibase-harness:latest /scratch/dump.php

The full tree, with its defaults

The options read by nothing are deprecated (excluded_fields, share, access_restriction.redirect_on_deny, site.maintenance.exception, uploader.warmup, files.storage, images.autorotate, images.disable_profiler, images.warmup, images.formats, extension.logging_default_expiry): a site that sets one gets Symfony's deprecation notice, and they go at the next major.

twig.default_path is the package's own templates/ wherever Composer put it: /srv/demo/vendor/omnisense/omnibase/templates is the harness's.

base:

    # Automatically clear cache in case of integrity issue
    autoclear:            true
    database:

        # Fallback warning disable in case there is no fallback
        fallback_warning:     true

        # Use custom database settings
        use_custom:           null
        excluded_fields:

            # Defaults:
            - id
            - locale
            - translatable
    share:

        # Prototype
        -
            id:                   ~
            url:                  ~
    user:

        # Default throttling time between two tokens received
        token_default_throttling: 180

        # Allow using a token sent by email
        login_with_token:     false

        # Property used to identity user
        identifier:           email

        # Threshold considering user as inactive ("active delay" is expected to be lower than "online delay")
        active_delay:         60

        # Threshold considering user as offline
        online_delay:         300
        notifications:

            # Time before erasing data if read
            expiry:               '+30 days'
        register:

            # Administrator check required
            autoapprove:          false

            # Administrator are notify when someone register
            notify_admins:        false
    access_restriction:

        # Redirection in case of access restriction
        redirect_on_deny:     null

        # Access to public visitors
        public_access:        true

        # Access to users
        user_access:          true

        # Access to administrators
        admin_access:         true
        firewalls:

            # Default:
            - main

        # Routes (regular expressions on their names) no access restriction stops, and the referrer never remembers: the sign-in pages, the language switch, the UX endpoints.
        route_exceptions:

            # Default:
            - /^(?:locale_|ux_|user_|security_)/
        exceptions:

            # Prototype
            -
                locale:               null
                env:                  null

                # Regex 
                host:                 null

                # Regex 
                path:                 null

                # Regex 
                domain:               null

                # Regex 
                subdomain:            null

                # Regex 
                scheme:               null
    site:

        # Website home page
        index:                app_index
        maintenance:

            # Maintenance redirection
            redirect_on_deny:     security_maintenance
            exception:

                # Prototype
                code:                 ~

            # Maintenance lock file location
            lockpath:             '%kernel.project_dir%/public/maintenance.lock'
    router:

        # Use custom router
        use_custom:           null

        # Allow accessing by
        ip_access:            false

        # Restrict to permitted hosts
        use_fallbacks:        false

        # Fallback warning disable in case there is no fallback
        fallback_warning:     true
        host_fallbacks:

            # Prototype
            -
                locale:               null
                env:                  '%env(APP_ENV)%'
                reduction:            null

                # Sub-domain
                subdomain:            []

                # Machine name
                machine:              []

                # Domain
                domain:               []

                # Port number
                port:

                    # Default:
                    - 80

                # Base directory
                base_dir:             /
    translator:

        # How the site addresses people (docs/20-architecture/politeness.md): a text asked without a level of its own is taken in its "._plain", "._polite" or "._formal" variant when the catalogue has one (formal falls back on polite), else as it is. Unset: the texts as they are written.
        politeness:           null # One of null; "plain"; "polite"; "formal"
    parameter_bag:

        # Use setting bag from database
        use_setting_bag:      null

        # Use hot bag parameter rewriting
        use_hot_bag:          null
    notifier:

        # Enable mailer
        mailer:               true

        # Do not allow sending email until loopback is removed
        technical_loopback:   true
        technical_recipient:

            # Technical contact email
            email:                null

            # Default phone number
            phone:                null

        # Administrators receiving notifications
        admin_role:           ROLE_EDITOR
        test_recipients:

            # Default:
            - @localhost$
        options:

            # Prototype
            -
                channel:              ~
                markAsRead:           ~
    spam:

        # AKISMET API Key
        akismet:              ''
    time_machine:

        # Default compression algorithm
        compression:          gzip

        # Default time limit before deleting
        time_limit:           '+30 days'

        # Default compression algorithm
        max_cycle:            -1

        # Directory the snapshot is staged in before transfer (defaults to the kernel cache directory)
        snapshot_dir:         null

        # Paths left out of the application tarball
        excludes:

            # Defaults:
            - ./vendor
            - ./node_modules
            - ./var/cache
            - ./var/log
            - ./var/coverage
            - ./var/phpunit
            - ./.git
    icon_provider:

        # Default icon provider class
        default_adapter:      Base\Service\Model\IconProvider\Adapter\FontAwesomeAdapter
    uploader:

        # Automatic image uploader warmup
        warmup:               true

        # Upload storage location
        storage:              null
        formats:

            # Prototype: Specific formats
            -
                class:                ~
                property:             ~
                width:                ~
                height:               ~
    files:
        storage:              null
    images:
        max_resolution:       Base\Imagine\Filter\Basic\Definition\UltraHighDefinitionFilter
        autorotate:           true
        debug:                false
        disable_profiler:     true
        max_quality:          1
        timeout:              60
        enable_webp:          true
        no_image:

            # Prototype: Replacement image
            -
                extension:            ~
                path:                 ~
        fallback:             false
        warmup:               true
        formats:

            # Prototype: Specific formats
            -
                class:                ~
                property:             ~
                width:                ~
                height:               ~
    paginator:

        # Range around the current page
        page_range:           1

        # Number of element per page
        page_size:            10

        # Default parameter name in route
        page_parameter:       page

        # Default template used to display pages in sliding control
        default_template:     '@Base/paginator/sliding.html.twig'
    obfuscator:

        # Compression level
        level:                -1

        # Use uuid shortening
        uuid:                 6ba7b811-9dad-11d1-80b4-00c04fd430c8

        # Compression encoding
        encoding:             null

        # Decoding max length
        max_length:           0

        # Compression algorithm
        compression:          'null'

        # Identifier-map storage directory; must survive cache:clear/deploys or previously-issued short URLs become permanently unresolvable
        cache_dir:            '%kernel.cache_dir%'
    twig:
        breakpoints:

            # Prototype
            -
                name:                 ~
                media:                ~
        themes:
            audiences:

                # Prototype
                name:
                    age:                  null
                    icon:                 null
            widths:

                # Prototype
                name:
                    icon:                 null
                    class:                null

            # Default audience key among valid audiences
            default_audience:     null
            events:

                # Prototype
                name:
                    begin:                null
                    end:                  null
                    priority:             0
                    icon:                 null
            modes:

                # Prototype
                name:
                    class:                null
                    icon:                 null
                    prefers:              null
            filters:

                # Prototype
                name:
                    icon:                 null
                    filter:               null
        script_attributes:
            defer:                false
            async:                false

            # Scripts run in the order of the page: a file named -async is loaded defer. The core's base-async sets window.jQuery, which the site's entries read as an external; false brings back async, with no guarantee that it runs first.
            ordered:              true
        link_attributes:
            defer:                false
            async:                false

        # Use base filesystem loader
        use_custom:           null

        # Use custom base form style
        use_form2:            true

        # Include EA form style
        use_ea:               true

        # Use bootstrap style in forms
        use_bootstrap:        true

        # Autoappend required dependencies in html content
        autoappend:           true

        # Default twig path: the templates/ of this package, wherever Composer installed it (vendor/omnisense/omnibase, a path repository, a checkout)
        default_path:         /srv/demo/vendor/omnisense/omnibase/templates
        form_themes:

            # Default:
            - ./form/form_div_layout.html.twig
        editor:
            storage:              null
        paths:

            # Prototype
            -
                path:                 ~
                namespace:            ~
    attributes:

        # Use custom attribute reader
        use_custom:           null
        paths:

            # Default:
            - %kernel.project_dir%/src/Attribute
    security:
        two_factor:

            # Roles whose holders must have a second factor (directly or through the role hierarchy), e.g. [ROLE_STAFF]. Empty: the administrator's "mandatory for everyone" setting alone decides. A later definition (when@dev, when@test, another file) replaces the list, it does not add to it.
            required_roles:       []

            # Whether the enrolment prompt offers a "not now" (kept for the session). False: an account a second factor is required of is sent back to the prompt on every page until it has one.
            postpone:             true
    demo:

        # The DSN of the production database ("mysql://host:3306/name"; user and password are not compared). The demo environment refuses to start on it - and refuses to start while this is unset.
        production_database:  null

        # The super-administrator's password in the demo environment: a secret (DEMO_SUPERADMIN_PASSWORD). Unset or empty: no account whose roles reach ROLE_SUPERADMIN can sign in there.
        superadmin_password:  '%env(default::DEMO_SUPERADMIN_PASSWORD)%'

        # The roles that open the back office. In the demo environment an account whose roles reach one of them is not offered to visitors - neither in the banner nor on the sign-in page - and signs in with base.demo.admin_password or not at all. Empty: the back office is as public as the rest of the demonstration.
        admin_roles:

            # Default:
            - ROLE_ADMIN

        # The password of the back office's accounts in the demo environment: a secret (DEMO_ADMIN_PASSWORD), given to whoever is shown the back office. Unset or empty: no account whose roles reach base.demo.admin_roles can sign in there.
        admin_password:       '%env(default::DEMO_ADMIN_PASSWORD)%'

        # Identifiers of declared demonstration accounts this site leaves out.
        exclude:              []
        reset:

            # Directories demo:reset empties before it reloads the fixtures: the files visitors sent to a storage of the application's own.
            purge:                []

            # The fixture groups demo:reset loads (doctrine:fixtures:load --group). Empty: every fixture.
            groups:               []

            # Whether demo:reset deletes the uploaded files no row names any more (uploader:entities --delete-orphans).
            orphans:              true
    opening_hours:

        # The timezone the hours are given in.
        timezone:             Europe/Paris

        # "HH:MM": past it, orderDay() is the next open day. Null: the day itself until it closes.
        cutoff:               null

        # The usual week until one is saved (hoursWeekDay): ISO day (1 = Monday) => [["09:00", "13:00"], ...].
        week:                 []
    local_business:

        # The schema.org type: LocalBusiness, or one of its kinds (Restaurant, Store, Dentist, ProfessionalService...).
        type:                 LocalBusiness

        # Null: the site's title (base.settings.title).
        name:                 null

        # Null: the site's home address.
        url:                  null

        # Null: the setting base.settings.phone.
        telephone:            null
        email:                null

        # A path under public/ ("assets/ico/android-chrome-512x512.png") or an address.
        image:                null

        # "€€", "10-50 €"...
        price_range:          null
        address:
            street:               null
            postal_code:          null
            locality:             null
            region:               null

            # ISO 3166-1 alpha-2: FR.
            country:              null
        geo:
            latitude:             null
            longitude:            null

        # The business elsewhere: its Facebook, Instagram, LinkedIn pages.
        same_as:              []
        area_served:          []

        # Any other schema.org property, merged as it is: {parentOrganization: {"@type": Organization, name: PANO}}.
        extra:                []
    guard:

        # The captcha: a gateway of omnishield.gateways. Null: omnishield.challenge.gateway when glitchr/omnishield is installed; false: none.
        challenge:            null

        # The captcha shown to a visitor who refused the third party the captcha reaches (consent): a gateway that reaches nobody, ALTCHA.
        fallback:             null

        # The lists asked about whoever submits (IP address, e-mail, name): gateways of omnishield.gateways - disposable, stopforumspam.
        reputation:           []

        # The classifier of what is written (SpamChecker): a gateway of omnishield.gateways. Null: Akismet with the key api.spam.akismet (base.spam.akismet) when omnishield/akismet is installed.
        classifier:           null

        # A list or a classifier that does not answer: the form goes on (accept) or is refused (reject). The captcha's is omnishield.challenge.unreachable.
        unreachable:          accept # One of "accept"; "reject"

        # Seconds between a guarded form being shown and sent: faster is a robot. 0: not checked.
        min_delay:            3

        # Failed sign-ins from an address before the sign-in form asks the captcha too. 0: never.
        sign_in_after:        3

        # Refused tries of a guarded form from a visitor (address, else session; 15 minutes) before its captcha is shown and asked. 0: always shown. A form sets its own with the option guard.captcha_after.
        captcha_after:        3
    signatures:

        # The gateway envelopes go through: one of omnisign.gateways. Null: the only one configured.
        gateway:              null

        # The private Flysystem storage the signed documents and their evidence are kept in. Null: the uploads' (base.uploader.storage).
        storage:              null
    comments:

        # Seconds between a comment form being opened and sent: faster is a robot.
        min_delay:            4

        # Seconds between two comments from the same address.
        flood_interval:       60
    vault:

        # #[Vault]: without the environment's key pair (bin/console secrets:generate-keys), store a secured field in clear instead of refusing it. Off: the save fails with VaultKeyNotFoundException.
        allow_plaintext:      false
    download_links:

        # Seconds a signed download link stays valid.
        ttl:                  600
    extension:

        # Max number of revision for a given entity
        max_revisions:        5

        # Time a trashed entity is kept before the purge may delete it for good: a relative date PHP reads ("+7 days", "+1 month"); a bare number is a number of days
        empty_trash:          '+7 days'

        # Default logging expiry
        logging_default_expiry: 180
    screens:

        # The version of the front end that renders the screens (omnibridge/react's X-Omnibridge-Version): a front end of another version reloads the page whole. Null: none checked.
        version:              null

Top level

Option Type Default Effect
autoclear bool true On the first cache:clear of an installation, the command runs cache:clear a second time in a process of its own (CacheClearTrait::doubleCacheClear()); in debug, when webpack's entrypoints.json changed during a request, WebpackTagRenderer clears the cache instead of throwing "Entrypoint ... got modified".

database

Option Type Default Effect
use_custom bool null omnibase's Doctrine features: BaseBundle::boot() registers its types over DBAL's (the UTC datetime, see Time and time zones); cache:clear reports it.
fallback_warning bool true cache:clear warns when the entity manager's metadata factory is not omnibase's ClassMetadataFactory.
excluded_fields list of strings [id, locale, translatable] Deprecated, read by nothing: the translation fields take their own excluded_fields option (TranslationType, TranslationField).

share

A list of {id, url}. Deprecated, read by nothing: the sharing buttons come from the adapters tagged base.service.sharing (Base\Service\Model\Sharing\SharingAdapterInterface).

user

Option Type Default Effect
token_default_throttling int (seconds) 180 The throttle a Base\Entity\User\Token gets when it is built without one: the time before the same token may be sent again.
login_with_token bool false Whether the sign-in page offers a link by e-mail (allow_login_token) and security_loginWithToken answers; off, that route redirects to the sign-in page.
identifier string email The User property an account is identified by (BaseService::getUserIdentifier(), the access-token handler, SecurityController).
active_delay int (seconds) 60 User::isActive(): active within that many seconds of its last activity (activeAt).
online_delay int (seconds) 300 User::isOnline(), the same with this delay.
notifications.expiry string (relative date) +30 days The age past which user:notifications --clear erases read notifications (--expiry sets another for one run).
register.autoapprove bool false A new account is approved without an administrator (SecurityController::Register, and SecuritySubscriber for an account still waiting). Off, an account that is not approved is sent to security_pendingForApproval until an administrator approves it.
register.notify_admins bool false The administrators (notifier.admin_role) are sent an approval request when a verified account registers (SecurityController::Register).

access_restriction

Who may see the site, checked by Base\Security\Voter\AccessVoter (see Routes and access).

Option Type Default Effect
public_access bool true ANONYMOUS_ACCESS: visitors who are not signed in may browse. Off, only signed-in users do (when user_access or admin_access is on).
user_access bool true USER_ACCESS: signed-in users may browse; administrators (ROLE_ADMIN) may whenever admin_access is on.
admin_access bool true ADMIN_ACCESS: administrators may browse; a ROLE_SUPERADMIN always may.
firewalls list of strings [main] The firewalls the exceptions below apply to; a request under another firewall is never restricted by them.
route_exceptions list of regular expressions ['/^(?:locale_|ux_|user_|security_)/'] Route names that no restriction stops and that the referrer never remembers (the sign-in pages, the language switch, the UX endpoints).
exceptions list of {locale, env, host, path, domain, subdomain, scheme} [] Addresses let through during a maintenance or before the launch (EXCEPTION_ACCESS): the entry for the current locale (then its language, the default locale, its language, then the entry without a locale) is matched; env and locale are compared, the others are regular expressions on the parts of the address.
redirect_on_deny string null Deprecated, read by nothing: the redirection on a refusal is the setting base.settings.access_restriction.redirect_on_deny (back office).

site

Option Type Default Effect
index string (route) app_index The home page's route: AdvancedRouter::getRouteIndex(), where redirections without a target go (the demonstration's sign-in, the end of a pending approval).
maintenance.redirect_on_deny string (route) security_maintenance The route a visitor is sent to during a maintenance (MaintenanceProvider): the core's maintenance page, security_maintenance (/m), or a route of the site's. Once the maintenance is over, a visitor on that page is sent to site.index.
maintenance.exception map of code [] Deprecated, read by nothing.
maintenance.lockpath string (path) %kernel.project_dir%/public/maintenance.lock The lock file whose presence puts the site under maintenance (its first line the downtime, its second the uptime), beside the setting base.settings.maintenance.

router

Option Type Default Effect
use_custom bool null AdvancedRouter's own generation and matching (AdvancedUrlGenerator, AdvancedUrlMatcher: the routes by locale and host); off, routes are generated and matched as Symfony's router does.
ip_access bool false Whether a site reached by its IP address is served as it is.
use_fallbacks bool false Whether host_fallbacks is used at all: off, AdvancedRouter::getFallbackParameters() gives none and addresses are built from the request's host.
fallback_warning bool true cache:clear warns when no host fallback is configured.
host_fallbacks list see the tree One entry per locale (and environment): subdomain, machine, domain, port (lists, the first one is the canonical one), base_dir, reduction (whether a non-canonical host is reduced to the canonical one). AdvancedRouter builds absolute URLs from it and CookieDomain the cookies' domain.

RouterSubscriber applies the IP-address rule only when isGranted('VALIDATE_IP', $route) is true; no voter of the core decides VALIDATE_IP (only PermissionVoter could, for a user holding that permission tag), so ip_access has no effect at this commit.

translator

Option Type Default Effect
politeness null, plain, polite, formal null The level of address texts are taken in: see How the site addresses people.

parameter_bag

Option Type Default Effect
use_setting_bag bool null SettingBag reads the settings stored in the database; off, every setting reads as unset.
use_hot_bag bool null HotParameters lays the settings over the container's parameters: a setting linked to a parameter (its bag, SettingBag::setBag($path, 'base.user.login_with_token')) answers for that parameter before the configuration, its value in the default locale (a stored false, 0 or "" included; null is no value). HotParameterBagSubscriber loads them on each request and command. Off, the parameters are read from the configuration alone.

notifier

Option Type Default Effect
mailer bool true The notifier sends e-mail (when symfony/mailer's Mailer exists).
technical_loopback bool true On by default: every e-mail goes to the technical recipient instead of its addressee (Notifier::isTest() is true for every recipient). Turn it off in production.
technical_recipient.email string null The technical address: the sender of the e-mails, the loopback's addressee, and the error page's support address when the setting mail gives none. Unset: postmaster@<the site's domain>.
technical_recipient.phone string null The technical recipient's phone number.
admin_role string (role) ROLE_EDITOR Who receives the administrators' notifications (Notifier::getAdminRecipients(): the users holding that role).
test_recipients list of regular expressions ['@localhost$'] With the kernel in debug, an e-mail to an address matching one goes to the technical recipient; User::isTester() reads the same list.
options list of {channel, markAsRead} [] Handed to the notifier as they are (BaseNotifier::getOptions()).

spam

Option Type Default Effect
akismet string '' The Akismet key SpamChecker uses when the back office's setting api.spam.akismet is empty (see The forms' guard).

time_machine

The backups (Base\Service\TimeMachine, Backups).

Option Type Default Effect
compression string gzip The compression of the snapshots.
time_limit string (relative date) '+30 days' How long a snapshot is kept.
max_cycle int -1 The number of snapshot cycles kept; -1: no limit.
snapshot_dir string (path) null Where a snapshot is staged before it is sent; unset: the kernel's cache directory.
excludes list of paths see the tree Left out of the application's tarball.

icon_provider

Option Type Default Effect
default_adapter class name Base\Service\Model\IconProvider\Adapter\FontAwesomeAdapter The icon set IconType (the icon picker) offers by default.

uploader and files

Option Type Default Effect
uploader.storage string (Flysystem storage) null The storage an #[Uploader] property writes to when it names none.
uploader.formats list of {class, property, width, height} [] The sizes uploader:images warms up.
uploader.warmup bool true Deprecated, read by nothing.
files.storage string null Deprecated, read by nothing.

images

Read by Base\Service\MediaService.

Option Type Default Effect
max_resolution class name Base\Imagine\Filter\Basic\Definition\UltraHighDefinitionFilter The filter that caps an image's definition.
max_quality number 1 The highest quality an image is encoded at.
timeout int (seconds) 60 The time limit set while an image is processed.
enable_webp bool true Images served as WebP where the browser takes it.
debug bool false The debug variant of the image routes.
no_image list of {extension, path} [] The picture served instead of a missing file, per extension.
fallback bool false Serve no_image for a missing file; on, a missing no_image.<extension> is an error.
autorotate, disable_profiler, warmup, formats see the tree Deprecated, read by nothing (the bitmap filters rotate by their own autorotate option, true by default).

paginator

Option Type Default Effect
page_range int 1 Pages shown on each side of the current one.
page_size int 10 Entries per page.
page_parameter string page The route parameter that carries the page.
default_template string '@Base/paginator/sliding.html.twig' What paginator() renders.

obfuscator

Base\Service\Obfuscator: the data of the media and UX routes, and the |obfuscate filter.

Option Type Default Effect
level int -1 The compression level.
uuid string (UUID) 6ba7b811-9dad-11d1-80b4-00c04fd430c8 The namespace of the shortened identifiers (the URL namespace by default).
encoding int null The compression encoding.
max_length int 0 The longest string decoded.
compression string 'null' The algorithm ('null': none).
cache_dir string (path) %kernel.cache_dir% Where the map of shortened identifiers is kept. It must outlive cache:clear and deploys, or the short addresses already given out stop resolving.

twig

Option Type Default Effect
use_custom bool null omnibase's Twig loader (Base\Twig\Loader\FilesystemLoader) and its webpack warm-up.
use_form2 bool true The form theme's form2 variant (FormTypeExtension).
use_ea bool true The form theme's easyadmin variant.
use_bootstrap bool true Bootstrap classes on the form rows (FormTypeBootstrapExtension).
autoappend bool true TwigSubscriber appends the dependencies a page needs (scripts, stylesheets) to its HTML.
default_path string (path) the package's templates/ The @Base namespace's directory.
form_themes list [./form/form_div_layout.html.twig] Put before twig.form_themes (the parameter twig.form.resources).
paths list of {path, namespace} [] More directories for the loader, each under its namespace (the main one when none); an entry without a path is an error.
editor.storage string (Flysystem storage) null Where the editor's uploads go (EditorType, EditorController).
breakpoints list of {name, media} [] The stylesheets per media query the webpack renderer adds (<entry>-<name>.css).
script_attributes {defer, async, ordered} false, false, true The default attributes of the script tags; ordered (see the tree).
link_attributes {defer, async} false, false The same for the stylesheet links.
themes.modes, themes.filters, themes.widths, themes.audiences, themes.events maps by name [] What Themizer offers: the colour modes (class, icon, prefers), filters, widths, audiences (age, icon) and dated events (begin, end, priority, icon); the menus render_theme_modes()... draw them.
themes.default_audience string null The audience of a visitor whose age is unknown.

attributes

Option Type Default Effect
use_custom bool null omnibase's attribute reader (the #[Uploader], #[Vault]... of the entities).
paths list of paths ['%kernel.project_dir%/src/Attribute'] Where the reader looks for the application's attribute classes.

security

Option Type Default Effect
two_factor.required_roles list of roles [] Roles whose holders must have a second factor: see Account security.
two_factor.postpone bool true Whether the enrolment prompt offers "not now".

Each has its page, which describes its options: The demo environment, Opening hours, LocalBusiness JSON-LD, The forms' guard, Signatures, Comments, Sealed fields, Downloads and QR codes. Their types and defaults are in the tree above; the integer options of guard and comments refuse a negative value, download_links.ttl one below 1.

extension

Option Type Default Effect
max_revisions int 5 Revisions kept per versioned entity (VersionableSubscriber).
empty_trash string (relative date, or a number of days) '+7 days' How long a trashed entity is kept before trash:purge may destroy it (TrasheableSubscriber, TrashManager).
logging_default_expiry int 180 Deprecated, read by nothing.

screens

Option Type Default Effect
version string null The front end's version a screen expects: ScreenResponder compares it with the request's X-Omnibridge-Version, and a front end of another version reloads the page whole.

What the extension registers

Besides the parameters, BaseExtension:

  • loads the package's config/services.php (every file of config/services/), services-public.php, services-fix.php and services-decoration.php;
  • puts twig.form_themes after base.twig.form_themes in twig.form.resources;
  • before every extension loads (prepend()): sets base.package_dir, the package's directory wherever Composer installed it (what a configuration names instead of a vendor path: '%base.package_dir%/docs'); declares to DoctrineBundle the DBAL types utc_datetime_immutable, utc_datetime, the passkey's columns and every type of src/Enum (the package's and the application's) that has a getStaticName(); and, when symfony/ux-twig-component is installed, the component namespaces Base\Twig\Component\ (templates @Base/components/, prefix base) and App\Twig\Component\ (@App/components/, prefix app);
  • tags by autoconfiguration what implements:
Interface or class Tag
Base\Service\Model\IconProvider\AbstractIconAdapter base.service.icon
Base\Service\Model\IconProvider\IconAdapterInterface base.icon_provider
Base\Database\Entity\EntityExtensionInterface base.entity_extension
Base\Attributes\AttributeInterface base.attribute
Base\Service\Model\Sharing\SharingAdapterInterface base.service.sharing
Base\Cache\Abstract\AbstractLocalCacheInterface base.simple_cache
Base\Service\Model\Currency\CurrencyApiInterface currency.api
Base\Service\Model\Obfuscator\CompressionInterface obfuscator.compressor
Base\Twig\Renderer\TagRendererInterface twig.tag_renderer
Symfony\Component\Workflow\WorkflowInterface workflow
Base\Demo\DemoAccountProviderInterface base.demo_account_provider
Base\Database\Repository\ServiceEntityRepository doctrine.repository_service
Base\EntityDispatcher\EventDispatcherInterface doctrine.event_listener on prePersist, postPersist, preUpdate, postUpdate, preRemove, postRemove, postFlush; base.entity_dispatcher; kernel.reset (method reset)

Propose a change docs/50-reference/configuration.md

Console commands

The commands the core registers (config/services/core.php, config/services/demo.php, services-decoration.php), as bin/console list shows them in the core's harness. Most extend Base\Console\Command, which needs a ConsoleOutput (it throws LogicException on any other output, a BufferedOutput included) and adds a --purpose option to some of them; user:create is a plain Symfony command, usable from scripts and tests.

The usages below are bin/console help <name> in the harness, Symfony's common options (-h, -q, -v, -e, --no-debug...) left out.

Summary

Command What it does
cache:clear Symfony's cache:clear, decorated: checks around it, a lock, a second clear
cache:clear:sessions Deletes the session files of var/sessions
user:create Creates an account, verified and approved, with a temporary password
user:notifications Counts, shows and erases the notifications read
notifier:mail:test Sends the test e-mail
app:notifier Lists the notifications the Notifier can send
base:mailer:trap Holds outgoing mail, then purges or releases it
thread:publishable Publishes the scheduled threads whose time has come
trash:purge Destroys the trashed entities past their grace period
timemachine:snapshot Lists the backups' connections, storages and snapshots
timemachine:snapshot:backup Makes a backup
timemachine:snapshot:restore Restores a backup
uploader:entities Lists the files of the #[Uploader] properties, finds and deletes orphans
uploader:images The same, and warms up the images' cache
uploader:images:crop The same for the image crops
icon:entities Lists the icons of the entities that have some
translation:controllers Lists the routes' titles in each locale
translation:settings Lists the settings' values in each locale
entity:discriminator Checks that a class is an entity
base:mapping Lists the Base\ classes aliased to App\
doctrine:database:import Imports rows from a spreadsheet
doctrine:schema:charset Shows or changes the database's and the tables' character set (MySQL)
doctrine:array:upgrade Converts DBAL 3's serialized array columns to JSON (MySQL)
demo:reset Reloads the demonstration (The demo environment)

The developer's lookups, each listing what it names without changing anything:

Command What it lists
autocomplete:entities The entities that complete (AutocompleteInterface), or one's rows (--entity, --id) with their autocompletion
icon:enums The icons of the values of omnibase's enums (--enum)
timemachine:storage The storages the time machine can back up to
translation:crud The CRUD pages' titles in each locale (--crud, --action, --suffix, --locale)
translation:entities The entities' names in each locale (--entity, --suffix, --locale)
translation:enums The enums' and their values' names in each locale (--enum, --suffix, --locale)

cache:clear

Usage:
  cache:clear [options]

Options:
      --sessions             Kill all existing sessions
      --no-extension         Skip base extension
      --no-warmup            Do not warm up the cache
      --no-optional-warmers  Skip optional cache warmers (faster)

Base\Console\Command\CacheClearCommand runs Symfony's own command (console.command.cache_clear) between omnibase's steps:

  1. It takes a lock, var/cache/<env>/.lock (a lock file older than 120 seconds is taken as stale and removed). When another cache:clear holds it, it prints "Cache clear aborted: another process is already clearing the cache." and exits 0.
  2. Unless --no-extension: the PHP configuration, disk and memory, the cache, the virtualisation and the PHP extensions are checked and reported, then webpack's build, the technical recipient (base.notifier.technical_recipient), a phpinfo page and the public symlinks are made or checked - all before the cache goes, while the services they read can still be built.
  3. Symfony's cache:clear runs, with the same options.
  4. A counter of clears is kept in var/cache/<env>.txt; --sessions then runs cache:clear:sessions.
  5. Unless --no-extension: on the first clear of an installation and with base.autoclear, cache:clear runs a second time in a process of its own; then OPcache is reset.

cache:clear:sessions

Usage:
  cache:clear:sessions [options]

Options:
      --purpose[=PURPOSE]  Show command description ?

Deletes every sess_* file of var/sessions, which signs everybody out where the sessions are kept in files there.

$ php bin/console cache:clear:sessions
 [INFO] Session files cleared from /srv/demo/var/sessions.

user:create

Usage:
  user:create [options] [--] <username> <email>

Arguments:
  username                 Its username
  email                    Its e-mail address

Options:
  -r, --role=ROLE          Its role(s): USER, ADMIN, SUPERADMIN... [default: ["USER"]] (multiple values allowed)
  -p, --password=PASSWORD  Its password (otherwise generated)
      --keep-password      Do not ask for a new password at the first sign-in
      --no-email           Do not e-mail the account its username and password

The account is verified and approved. A role is a key of UserRole, with or without ROLE_ (-r ADMIN -r EDITOR); an unknown one fails the command. Without --password, the password is four groups of four letters and digits, without the characters read alike (0/O, 1/l/I), printed once. Without --keep-password the account gets a change-password token, and PasswordChangeSubscriber asks for a new password at its first sign-in. Without --no-email, Notifier::sendAccountCreated() mails it its username and password. The username is written only where the application's User has a username property (Base\Entity\User has none); an e-mail address or a username already taken fails the command.

$ php bin/console user:create jdoe jdoe@example.com --no-email

 [OK] jdoe (jdoe@example.com), ROLE_USER.

Password: a56J-S2gs-m7Lj-CLQj - to be changed at the first sign-in

user:notifications

Usage:
  user:notifications [options]

Options:
      --user=USER      The account (its identifier); every account without it
      --expiry=EXPIRY  The age past which a read notification is erased ("+30 days"); base.user.notifications.expiry without it
      --show           List them
      --clear          Erase the read ones older than the expiry
      --clear-all      Erase every read one, whatever its age

Counts every notification (the account's with --user), those read, and those read and older than the expiry (--expiry, else base.user.notifications.expiry, +30 days); --show lists them, --clear erases the read ones past the expiry, --clear-all every read one. An unread notification is never erased.

$ bin/console user:notifications --user=anne@example.org --clear
3 notification(s) found, 1 notification(s) are marked as read, 1 notification(s) older than +30 days
These notifications are now erased..

notifier:mail:test

Usage:
  notifier:mail:test [options]

Options:
      --user[=USER]     User ID (or email, or username) to send the notification to

Sends Notifier::testEmail() to the user named, else to the user whose address is the technical recipient's, else to an unsaved user with that address. With base.notifier.technical_loopback on, it says so: the e-mail goes to the technical recipient whoever it is for.

$ php bin/console notifier:mail:test

 ! [NOTE] Notifier loopback is enabled. Notifications will be sent to the
 !        technical recipient:
 !        "admin@example.org <admin@example.org>" / en-GB

 [WARNING] No user found in database as technical recipient: "admin@example.org
           <admin@example.org>" / en-GB

 [OK] Sent test email to admin@example.org (Name: admin@example.org, ID: 0)
      using async transport.

app:notifier

Lists the public methods of Base\Notifier\Notifier that BaseNotifier does not declare: the notifications it can build.

$ php bin/console app:notifier
Available templated notifications:
 * getContactRecipient
 * testEmail
 * contactEmail
 * contactEmailConfirmation
 * userAccountGoodbye
 * userApprovalRequest
 * userApprovalConfirmation
 * accountCreated
 * resetPasswordRequest
 * verificationEmail
 * loginToken
 * loginLink
 * emailCode
 * userWelcomeBack
 * words
 * duration

base:mailer:trap

Usage:
  base:mailer:trap [options] [--] [<action> [<id>]]

Arguments:
  action                status, on, off, list, show, purge, release [default: "status"]
  id                    Which held message, for show/purge/release

Options:
      --all             Apply to every held message
      --reason=REASON   Note why the trap was armed, for whoever finds it later

Holds the outgoing mail during an incident. Armed (on, or arm), the trap (Base\Mailer\MailTrap, listening to symfony/mailer's MessageEvent at priority -1000) keeps every message in var/mail-trap/ instead of sending it - at send time, so mail already queued for a worker is caught too.

Action Effect
status Armed or not (since when, why), the number of messages held, the directory; a note when messages are held while the trap is off
on / arm Arms it; --reason is kept with it
off / disarm Disarms it. The messages held stay held: release or purge them
list A table: id, captured at, to, subject
show <id> One message in full
purge <id> / --all Deletes them, never sent
release <id> / --all Sends them on the real transport chain (mailer.transports), past the trap even while it is armed, then forgets them
$ php bin/console base:mailer:trap status

 [OK] Trap off - mail is being delivered normally.

 Held messages: 0
 Directory:     /srv/demo/var/mail-trap

thread:publishable

Usage:
  thread:publishable [options]

Options:
      --publish         Should I publish them ?

Reads the threads in the FUTURE state (Threads::byStateUncached()), says which are publishable (their publication time has come), and with --publish publishes each: its row locked (PESSIMISTIC_WRITE) and read again, so that two runs at once publish it once, then poke()d and flushed, which raises thread.publishable (see Events). With -v each thread is listed. Meant for a cron.

$ php bin/console thread:publishable

 [OK] 0 scheduled thread(s) found: 0 thread(s) publishable.

trash:purge

Usage:
  trash:purge [options]

Options:
      --force           Actually destroy them (without this, only reports)
      --limit=LIMIT     Destroy at most N entries in this run

Destroys the trashed entities (TrashBall) past their grace period (base.extension.empty_trash). A dry run unless --force: it lists what it would destroy. Meant for a cron.

$ php bin/console trash:purge
Nothing to purge. The trash holds 0 entries, none past its grace period.

timemachine:snapshot, :backup, :restore

Usage:
  timemachine:snapshot [options] [--] [<storages>...]

Arguments:
  storages                   What storages do you want to backup?

Options:
      --cycle[=CYCLE]        Which version do you want to get?
      --prefix[=PREFIX]      Which prefix do you want to use? [default: "dev"]
      --database[=DATABASE]  Which database do you want to backup?

The prefix's default is the kernel's environment (dev in the harness). :backup adds --id, --batch (no confirmation asked) and --userlog (the user configuration included); :restore adds --id.

timemachine:snapshot lists the database connections, the storages of the backups (Flysystem storages, remote ones marked) and, with -v, the snapshots by day. :backup asks for confirmation (none with --batch), says whether the user configuration is included (--userlog), and fails without a storage named. :restore asks whether to restore the latest version (when no --id), then whether to restore the application from its tarball and the database. See Backups and base.time_machine in the configuration.

In the harness (a SQLite connection) timemachine:snapshot stops on "Could not find field serverVersion in configuration for connection type default": the backups read the connection's parameters of a MySQL server.

uploader:entities, uploader:images, uploader:images:crop

Usage:
  uploader:entities [options]

Options:
      --entity[=ENTITY]      Should I consider only a specific entity ?
      --property[=PROPERTY]  Should I consider only a specific property ?
      --uuid[=UUID]          Should I consider a specific uuid ?
      --orphans              Do you want to get orphans ?
      --delete-orphans       Do you want to delete orphans ?

uploader:entities reads every #[Uploader] property of the entities (App\Entity\... and Base\Entity\..., or the one --entity names), counts their files and, with --orphans, finds the orphans: files of the storage no row names, and names in rows with no file. --delete-orphans deletes them from the storage. A property declared missable is passed by (it cannot have orphans). -vv lists the orphans, -vvv every file. demo:reset runs it with --delete-orphans (base.demo.reset.orphans).

uploader:images adds --warmup (build the cached images: each file, as is and in WebP, and its thumbnails at the sizes of base.uploader.formats), --format (only one WIDTHxHEIGHT), --cache and --batch (both flags, they take no value). uploader:images:crop adds --normalize and --max-definition, and warms up the crops of the ImageCrop entities (each crop of each image's source, WebP and not).

icon:entities

Usage:
  icon:entities [options]

Options:
      --id[=ID]          Should I pick up one specific entityId ?
      --entity[=ENTITY]  Should I consider only a specific entity ?

Without --entity, each entity class implementing IconizeInterface (the core's and the application's) and its static icons; with --entity, the icons of each row (or the one --id names).

$ php bin/console icon:entities
Entity list:
 * Base\Entity\Extension\Abstract\AbstractExtension: [fa-solid fa-external-link]
 * Base\Entity\Extension\Revision: [fa-solid fa-sort-numeric-down]
 * Base\Entity\Extension\TrashBall: [fa-solid fa-trash-alt]
 * Base\Entity\Layout\ShortLink: [fa-solid fa-compress-alt fa-rotate-45]
 ...

translation:controllers and translation:settings

Usage:
  translation:controllers [options]

Options:
      --controller[=CONTROLLER]  Should I consider only a specific controller ?
      --suffix[=SUFFIX]          Should I add a specific translation suffix to the default path ? [default: "title"]
      --locale[=LOCALE]          Should I display only a specific locale ?

Usage:
  translation:settings [options]

Options:
      --path[=PATH]      Should I consider only a specific setting path ?
      --raw              Should I display the raw information ? (including user label and help information)
      --locale[=LOCALE]  Should I display only a specific locale ?

translation:controllers prints, for each route whose controller is in App\ or Base\ (or starts with --controller), the text of @controllers.<route>.<suffix> in each available locale, "no translation found" where the catalogue has none. translation:settings prints the settings (those under --path) in each locale, --raw their labels and help texts. A --locale outside the available locales is an error.

entity:discriminator

Usage:
  entity:discriminator <entity>

Throws "Class "..." is not an entity." when the class is not a mapped entity; otherwise prints nothing and exits 0.

base:mapping

Lists the classes of the core's Enum, Form, Entity and Repository directories, each "aliased to" its App\ name when the application has no class of that name, or overridden by the application's.

doctrine:database:import

Usage:
  doctrine:database:import [options] [--] [<path>]

Arguments:
  path                             Path or URL

Options:
      --spreadsheet[=SPREADSHEET]  Import selected spreadsheet (e.g. 1,3,4 [NB: starts from 1])
      --extension[=EXTENSION]      Specify file extension to be used
      --entries[=ENTRIES]          Only read N-rows
      --skip[=SKIP]                Skip i-entries [default: 0]
      --batch[=BATCH]              Persist by batch of X entries. [default: 25]
      --notify                     Send notification if needed
      --force                      Import without asking confirmation.
      --on-the-fly                 Import right after each single entity got hydrated

Reads a workbook (xls, xlsx, xlsm, or xml), or a csv or txt file as one sheet named after the file (from the file's MIME type or --extension; the path is asked when not given), lets you choose its sheets (or takes --spreadsheet), and imports each sheet as entities of the class its first column's heading names. The second line of a sheet is a comment line, skipped. A heading is a property path; name:unique makes the column a key (a row already in the database is not imported again), tags:explode(,) splits a cell into a list, parent[title] finds an associated entity by one of its fields. The notifier is disabled unless --notify.

doctrine:schema:charset

Usage:
  doctrine:schema:charset [options]

Options:
      --update                         Update database charset,collate
      --charset[=CHARSET]              Database charset value
      --collate[=COLLATE]              Database collate value
      --update-tables                  Update table collate
      --table[=TABLE]                  Table name
      --table-collate[=TABLE-COLLATE]  Table collate value

MySQL only (SHOW VARIABLES, SHOW TABLE STATUS, ALTER DATABASE). Without options it shows the database's character set and collation and each table's collation. --update alters the database to --charset/--collate (else the connection's charset and defaultTableOptions.collate); --update-tables converts every table (or --table) to them. --table-collate only appears in the message printed: the tables are converted to --collate.

doctrine:array:upgrade

MySQL only. Finds the columns whose comment says (DC2Type:array) (DBAL 3's serialized arrays) or that are JSON, rewrites each row's serialized value as JSON (an empty value becomes [], a value already JSON is left, a value that does not unserialize is skipped and reported), then alters an array column to JSON. Errors are printed, not thrown.

Propose a change docs/50-reference/commands.md

Events

The events the core dispatches - through Symfony's event dispatcher, and through Doctrine's event manager for those of the mapping and the queries - and the listeners it registers on the framework's own events. The lists of listeners are bin/console debug:event-dispatcher and doctrine:debug:event-manager in the core's harness, the core's alone (the plugins' listeners left out).

Events the core dispatches

Event name Event class Dispatched by When
thread.scheduled Base\EntityDispatcher\Event\ThreadEvent ThreadEventDispatcher A thread is saved scheduled (its publication time in the future)
thread.publishable ThreadEvent ThreadEventDispatcher A thread is saved and its publication time has come
thread.published ThreadEvent ThreadEventDispatcher A thread is saved published
user.register Base\EntityDispatcher\Event\UserEvent UserEventDispatcher A user is persisted
user.approval UserEvent UserEventDispatcher A user becomes approved
user.verified UserEvent UserEventDispatcher A user becomes verified
user.enabled UserEvent UserEventDispatcher A user becomes enabled
user.disabled UserEvent UserEventDispatcher A user becomes disabled
user.kickout UserEvent UserEventDispatcher A user becomes kicked out
user.locked UserEvent UserEventDispatcher A user becomes locked
user.banned UserEvent UserEventDispatcher A user becomes banned
user.newcomer UserEvent UserEventDispatcher A user becomes a newcomer again (isNewcomer() was false, is true; a new account is one from its user.register)
user.ghost UserEvent UserEventDispatcher A user stops being a ghost (isGhost() was true, is false)
sitemap.build Base\Event\SitemapEvent SitemapController (/sitemap.{extension}) Once the routes carrying a Sitemap attribute are registered
base.signature.signed Base\Event\SignatureEvent Base\Service\Signatures A signer of an envelope signed (the gateway's answer applied)
base.signature.completed, .declined, .expired, .canceled SignatureEvent Base\Service\Signatures An envelope reached that state

The constants are the names to listen to: ThreadEvent::PUBLISHABLE, UserEvent::APPROVAL, SitemapEvent::BUILD, SignatureEvent::COMPLETED...

Entity events

ThreadEvent and UserEvent extend Base\EntityDispatcher\AbstractEvent:

Method Returns
getObject() the entity (getThread(), getUser() typed)
getObjectClass() its class
getLifecycle() Doctrine's LifecycleEventArgs the event comes from
getObjectManager() the entity manager
getRequest() the current request, or null (a command, a worker)

Their dispatchers (Base\EntityDispatcher\AbstractEventDispatcher) are Doctrine listeners: on prePersist and preUpdate they compare the entity with its state before the change (EntityHydrator::getOriginalEntity()) and record the events it raises; on postPersist, postUpdate and postRemove they dispatch them, each event once per entity. A listener may change entities, but never flushes: the dispatcher flushes those changes once the flush that raised the events is over, from EntityDispatcherFlushSubscriber (on kernel.response, console.terminate and WorkerMessageHandledEvent, priority 64), for five rounds at most. A flush the application makes later in the request takes them along.

An application adds its own: a class extending AbstractEventDispatcher, named after its event class with Dispatcher appended (App\EntityDispatcher\Event\OrderEventDispatcher dispatches App\EntityDispatcher\Event\OrderEvent, which extends AbstractEvent), with supports(), onPersist(), onUpdate() or onRemove() calling addEvent($name, $entity). Autoconfiguration tags it (see What the extension registers).

namespace App\EntityDispatcher\Event;

use App\Entity\Order;
use Base\EntityDispatcher\AbstractEventDispatcher;
use Doctrine\Persistence\Event\LifecycleEventArgs;

class OrderEventDispatcher extends AbstractEventDispatcher
{
    public function supports(mixed $subject): bool
    {
        return $subject instanceof Order;
    }

    public function onUpdate(LifecycleEventArgs $event)
    {
        $order = $event->getObject();
        if ($order->isPaid() && !$this->entityHydrator->getOriginalEntity($event)->isPaid()) {
            $this->addEvent(OrderEvent::PAID, $order);
        }
    }
}

What the core does on them

Base\EntitySubscriber\ThreadSubscriber listens to thread.scheduled (the thread's state becomes FUTURE) and thread.publishable (its state becomes PUBLISH; each author is e-mailed thread.published, each member mentioned who is not an author thread.mentioned).

Base\EntitySubscriber\UserSubscriber tells an account's owner what happened to it, each notification sent with the importance success through the site's channel policy (framework.notifier.channel_policy), and only for what the owner did, or what happened with nobody signed in (a command, a worker) - an administrator closing someone's account sends nothing:

Event What is sent
user.register an account whose address is not verified gets a verify-email token (24 hours, another one an hour later at the earliest) and Notifier::verificationEmail(); a verified one (user:create, a social account) nothing
user.approval an account that waited for an administrator (a valid admin-approval token) gets Notifier::userApprovalConfirmation(); its request is revoked at the next flush
user.enabled the notice accountWelcomeBack.success (the e-mail with the link came first, from UserChecker)
user.disabled Notifier::userAccountGoodbye()

SitemapEvent

registerAttributes() can only list the routes it can generate without parameters; the pages of entities are the application's to list. getSitemapper() gives the SitemapperInterface being built, getHostname() the scheme and host of the request. See Sitemap.

SignatureEvent

getEnvelope() the Envelope, getSubject() the entity it is about when it still exists, getSigner() the signer's key (SIGNED only). See Signatures.

Doctrine events of the core

Base\Database\Events names four events dispatched on the entity manager's event manager:

Event Arguments Dispatched by When
resolveDiscriminator Base\Database\Event\ResolveDiscriminatorEventArgs (a LoadClassMetadataEventArgs) Base\Bridge\Doctrine\MappingDriver While a class's metadata is loaded, to complete its discriminator map
preQuery Base\Database\Event\DoctrineQueryEventArgs (getQueryBuilder(), setQueryBuilder()) ServiceEntityParser::getQuery() (omnibase's repositories' magic finders) Before the query is built: a listener may replace the query builder
onQuery DoctrineQueryEventArgs (getQuery(), setQuery()) the same Once the query is built
postQuery DoctrineQueryEventArgs (getQuery()) the same After the cache settings are applied

DoctrineQueryEventArgs::getClassMetadata() and getEntityName() say which entity is queried. Each is dispatched only when a listener exists.

What the core listens to

The kernel

Event Listener Priority
kernel.request RequestScopedStateSubscriber::onKernelRequest 4096
ProfilerSubscriber::onKernelRequest 512
HotParameterBagSubscriber::onRequest 512
ValidCacheSubscriber::onValidCache 128
TwigSubscriber::onKernelRequest 8
AnalyticsSubscriber::onUserRequest 8
LocalizerSubscriber::onKernelRequest 8
RouterSubscriber::onKernelRequest 7
AnalyticsSubscriber::onGoogleAnalyticsRequest 7
SecuritySubscriber::onAccessRequest 6
PasswordChangeSubscriber::onKernelRequest 5
SecurityEnrolmentSubscriber::onKernelRequest 4
SecuritySubscriber::onMaintenanceRequest 4
SecuritySubscriber::onLaunchRequest 4
ReferrerSubscriber::onKernelRequest 4
SecuritySubscriber::onKernelRequest 3
NotifierSubscriber::onKernelRequest 0
kernel.controller_arguments IsGrantedSubscriber::onKernelControllerArguments 0
kernel.view Base\Bridge\Symfony\ActionResults::__invoke (an action's View, Template, Json... answered by the ScreenResponder) 0
kernel.response EntityDispatcherFlushSubscriber::onResponse 64
ProfilerSubscriber::onKernelResponse 0
TwigSubscriber::onKernelResponse 0
SecuritySubscriber::onKernelResponse 0
ReferrerSubscriber::onKernelResponse 0
LocalizerSubscriber::onKernelResponse 0
FlashBagSubscriber::onKernelResponse -10
kernel.exception RedirectionSubscriber::onKernelException (the redirections of the back office, see Redirections) 16
TwigSubscriber::onKernelException 0
kernel.terminate PageViewSubscriber::onKernelTerminate (the page views of Base\Service\Analytics) 0

The Subscriber classes are in Base\Subscriber\. In the demo environment, and only there, config/services/demo.php registers DemoSubscriber (kernel.response at -1024: noindex on every response; MessageEvent at 1024: no e-mail leaves), DemoBackOfficeSubscriber (CheckPassportEvent at 128, kernel.request at 4) and the Doctrine listener DemoAccountLockSubscriber (preFlush): see The demo environment. IntegritySubscriber, in the same directory, is registered by no configuration, on purpose: it would open a session on every main request, run bin/console cache:clear inside the request on a Doctrine TypeError, and sign the user out when its checks fail.

The console, the mailer and Messenger

Event Listener Priority
console.command RouterSubscriber::onCommand 2048
HotParameterBagSubscriber::onRequest 2048
console.terminate EntityDispatcherFlushSubscriber::flush 64
SecondLevelCacheConsoleSubscriber::onTerminate -64
Symfony\Component\Mailer\Event\MessageEvent TemplatedEmailSubjectSubscriber::onMessage 16
MailTrapSubscriber::onMessage (see base:mailer:trap in Console commands) -1000
Symfony\Component\Messenger\Event\WorkerMessageHandledEvent EntityDispatcherFlushSubscriber::flush 64

Security and omniauth

Event Listener Priority
CheckPassportEvent Base\Security\SignInGuard::onCheckPassport (the captcha after failed sign-ins, see The forms' guard) 300
Base\EntitySubscriber\ConnectionSubscriber::onCheckPassport 10
LoginSuccessEvent SignInGuard::onLoginSuccess 0
NewDeviceSubscriber::onLoginSuccess 0
ConnectionSubscriber::onLoginSuccess -65
SecuritySubscriber::onLoginSuccess -65
LoginFailureEvent SignInGuard::onLoginFailure, ConnectionSubscriber::onLoginFailure, SecuritySubscriber::onLoginFailure 0
LogoutEvent ConnectionSubscriber::onLogout, SecuritySubscriber::onLogout 0
security.switch_user LocalizerSubscriber::onSwitchUser 0
Omniauth\Event\SignedIn Base\Auth\Listener\TrackConnections::onSignedIn 16
Base\Auth\Listener\WarnNewDevice::onSignedIn 8
Base\Auth\Listener\UpdateAccount::onSignedIn, Base\Auth\Listener\GuardSignIn::onSignedIn 0
Omniauth\Event\SignInFailed TrackConnections::onSignInFailed, GuardSignIn::onSignInFailed 0
Omniauth\Event\Verifying GuardSignIn::onVerifying 0
Omniauth\Event\AccountRefused Base\Auth\Listener\WelcomeBack::onAccountRefused 0

The Omniauth\Event\* listeners exist where omnisense/omniauth is installed (see Account security).

Doctrine

Event Listeners, in order
loadClassMetadata AttributeSubscriber, IntlSubscriber, (Doctrine's AttachEntityListenersListener), TrackingPolicySubscriber, ExtensionSubscriber
resolveDiscriminator AttributeSubscriber, IntlSubscriber
onFlush AttributeSubscriber, IntlSubscriber, TrasheableSubscriber, VersionableSubscriber, ExtensionSubscriber, InverseCollectionCacheSubscriber
preFlush AttributeSubscriber, IntlSubscriber
postFlush AttributeSubscriber, TrasheableSubscriber, VersionableSubscriber, Base\Translation\TextOverrideCacheListener, ThreadEventDispatcher, UserEventDispatcher
prePersist AttributeSubscriber, IntlSubscriber, ThreadEventDispatcher, UserEventDispatcher
postPersist AttributeSubscriber, TextOverrideCacheListener, SettingSubscriber, ThreadEventDispatcher, UserEventDispatcher, ExtensionSubscriber
preUpdate AttributeSubscriber, ThreadEventDispatcher, UserEventDispatcher
postUpdate AttributeSubscriber, TextOverrideCacheListener, SettingSubscriber, ThreadEventDispatcher, UserEventDispatcher
preRemove AttributeSubscriber, ThreadEventDispatcher, UserEventDispatcher
postRemove AttributeSubscriber, TextOverrideCacheListener, SettingSubscriber, ThreadEventDispatcher, UserEventDispatcher
postLoad AttributeSubscriber, IntlSubscriber
preQuery, postQuery AttributeSubscriber
onQuery AttributeSubscriber, IntlSubscriber

AttributeSubscriber, IntlSubscriber, TrackingPolicySubscriber and InverseCollectionCacheSubscriber are Base\DatabaseSubscriber\; TrasheableSubscriber, VersionableSubscriber, ExtensionSubscriber, SettingSubscriber are Base\EntitySubscriber\. Outside Symfony, Base\Bridge\Doctrine\Mapping adds the same listeners to an entity manager built by hand (see The Doctrine mapping).

Propose a change docs/50-reference/events.md

Twig reference

The globals, functions, filters and tests the core's Twig extensions add, as the harness's twig environment lists them (Base\Twig\Extension\*, the plugins' left out). Where two extensions give the same name, the one registered last wins: get_class is PHP's (ClassTwigExtension), count and round are MathTwigExtension's, trans is omnibase's translator's.

Globals

Global Value
app Base\Twig\AppVariable: Symfony's app (app.request, app.user, app.session, app.flashes...) behind a proxy, and app.bag(key) (a parameter by its dotted path, or the bag itself), app.settings (the setting app.settings), app.referrer, app.locale (_self, lang, country), app.theme (the Themizer), app.site, app.email, app.admin, app.random
security_policy Base\Service\SecurityPolicy: the administrator's account-security policy, for the sign-in page and the layout (whether passkeys are offered)

app.site (Base\Twig\Variable\SiteVariable) gives title(), slogan(), logo(), index(), meta(), url(), scheme(), host(), domain(), under_maintenance(), launchdate(), age(), is_newcomer() and execution_time(); app.email (EmailVariable) the same site's title(), slogan(), logo(), homepage(), address() for an e-mail; app.random uuidv4() and rand().

Addresses, files and pages

Name Kind What it gives
url(name, parameters = [], referenceType = absolute) function, filter AdvancedRouter::getUrl(): a route's address (always absolute inside an e-mail)
asset(nameOrUrl, package = null) function, filter AdvancedRouter::getAssetUrl()
embed(src, options = []) function, filter A file embedded in an e-mail (MailTwigExtension::source())
inline_css_email(body, ...css) filter The body with the stylesheets inlined
obfuscate filter Obfuscator::encode(): the data of the media routes
ondisk test Whether a path exists under public/
embed_url(url, remote = true) function What a page can frame of an address (Pages and texts)
qr_code(data, size = 600) function A QR code as an SVG data: URI (Downloads and QR codes)
deploy_version() function Eight hexadecimal characters that change when the application's git HEAD, or that of a package checked out under vendor/glitchr, vendor/omnisense or vendor/omnibase, changes; dev when none is a git checkout
oauth_available(name) function Whether hwi_oauth declares that resource owner on some firewall with a client id
render_stylesheet(href, attributes = [], keepIfNotFound = true), render_javascript(src, ...) functions A <link> / <script> for a public file; with keepIfNotFound false, nothing when the file is missing or empty
html_entry_link_tags(), html_entry_script_tags(), html_entry_head_tags(), html_entry_noscript_tags(), html_entry_body_tags() functions The tags HtmlTagRenderer collected for the page
encore_entry_link_tags(), encore_entry_script_tags(), encore_entry_css_source() functions omnibase's webpack renderer (WebpackTagRenderer): the entry's tags with the breakpoints and -async/-defer variants (base.twig.breakpoints, script_attributes)

Pictures and media

Name Kind What it gives
image(path, config = [], filters = []) function MediaService::image(): the image route's address (cached locally)
imageset(path, ...srcset) function A srcset
video(path, config, filters), audio(...) functions The media route's address
embed_base64(path) function, filter The image as a base64 data: URI
imagify(attributes = [], ...srcset), vidify(attributes), soundify(attributes) filters An <img>, <video>, <audio> tag
lightbox filter The image in a lightbox's markup
crop(x, y, width, height, position) filter A cropped image's address
thumbnail(width, height, config, filters) and thumbnail_inset, thumbnail_outbound, thumbnail_noclone, thumbnail_upscale filters A thumbnail's address, in that mode
picture(width = null, height = null) filter A stored picture's address: an upload through the image resolver at that size, a URL or a public path as it is, anything else under public/assets/
public(storage = null), downloadable(config) filters FileService: a stored file's public address, its download address
mimetype, extensions, filesize(unitPrefix) filters A file's MIME type, its extensions, a size in bytes made readable
obfuscate_file(config), obfuscate_image(config, filters) filters The obfuscated data of a file, of an image
Name Kind What it gives
wysiwyg(options = []) filter A stored text (Editor.js or HTML) rendered: mentions, links, media, anchored headings (Pages and texts); print it with |raw
wysiwyg_toc(maxLevel = null) filter Its headings: a list of {tag, slug, title}
semantify(attributes = []), semantify_only(words, attributes = []) filters The text with its semantic words (or the ones given) linked
urlify(label = null, attributes = []) filter A link to an address or a LinkableInterface record; class highlight when it is the page's own
linkify filter A LinkableInterface record's address
iconify(attributes = [], wrap = true) filter An icon's name, a list, an enumeration's or an IconizeInterface's icons, as HTML
highlight(pattern, gateLength = -1) filter The words matching a pattern (or a list) wrapped as highlighted, accents ignored
htmlify(options = []) filter An HtmlizeInterface object's HTML (__toHtml(), else its template)
crudify(options = []) filter A link to an entity's page in the back office (@Base/admin-legacy/crudify.html.twig), empty without a CRUD for it
mailto(address, label) function, filter <a href='mailto:...'>label</a>
mailparse(addresses), mailparse_email(recipient), mailparse_name(recipient) functions An address list parsed (address => name), its first address, its first name
title(name, parameters = [], domain = 'controllers', locale = null), excerpt(...) functions The text of <name>.title / <name>.excerpt in that domain, else @<domain>.<name>.title

Translation, locales and themes

Name Kind What it gives
trans(parameters, domain, locale, recursive = true) filter Base\Service\Translator::trans(), in place of Symfony's filter: @domain.key references resolved, the politeness level applied (How the site addresses people)
trans_quiet, trans_exists filters The text or null when the catalogue has none; whether it has one
trans_time filter A number of seconds in words
trans_entity(property = null, options = singular), trans_entityExists filters An entity's (or class's) noun, a property's label
trans_enum(class, options = singular), trans_enumExists filters An enumeration value's words
parse_class(parseBy) filter A class name as the translator turns it into a key
enum(classOrTypeName) function An enumeration by its class or its DBAL type's name (thread_state); a list for a list
colorify(entry = null) filter The colours of a ColorizeInterface, an enumeration's by value
lang, lang_name, country, locale_country_name filters A locale's language, its language's name, its country, its country's name (fr-BE: fr, BE, Belgium)
country_name(locale = null) filter A country code's name ('JP'|country_name('de'))
compatible_locale(...) function Localizer::compatibleLocale()
render_locale(options, template = '@Base/localizer/locale_dropdown.html.twig'), render_timezone(options, template) functions The language and time-zone menus
mode, filters filters Themizer::getMode(), getFilters()
compatible_theme(...) function Themizer::compatibleLocale()
render_theme_modes(options, template), render_theme_filters(...), render_theme_widths(...) functions The theme menus (base.twig.themes)
datetime(pattern = 'YYYY-MM-dd HH:mm:ss', dateFormat, timeFormat, timezone, calendar, locale) filter A moment (a DateTime, a timestamp, an ISO 8601 string) formatted by an ICU pattern, lowered; a list of patterns gives a list
countdown(parameters = []), progress(end, parameters) filters The countdown and the progress bar of @Base/progress/

Money

Name Kind What it gives
format_currency(currency, attrs = [], locale = null) filter In place of twig/intl-extra's filter of that name: an amount in minor units (divided by attrs.scale, 100 by default) formatted, converted to the currency the visitor sees when a rate is known (attrs.use_rate, true by default). In the harness {{ 1234|format_currency('EUR') }} prints € 12.34
apply_currency_rate(currency) function, filter The amount converted to the visitor's currency, or null without a rate

The account, notifications and audience

Name Kind What it gives
account_profile(user) function A member's profile as its view model (Base\Screen\Account\ProfilePage), as the visitor sees it
notifications_unread(), notifications_latest(limit = 8) functions The signed-in user's unread count, latest notifications (0 and [] for a visitor)
notifications_token() function The CSRF token of the intent notifications
notification_admin_url(notification) function The back-office page a notification is about, or null
push_available(), push_vapid_key() functions Whether web push is configured and a user is signed in; the VAPID public key
analytics_page_views(path = null, window = null, source = null), analytics_unique_visitors(window), analytics_unique_users(window), analytics_summary(), analytics_daily_breakdown(days = 14), analytics_week_over_week_change() functions Base\Service\Analytics's counters (window: today, 7d, 30d...; source: 'ai' the AI crawlers' hits)
demo_mode(), demo_accounts() functions Whether the kernel runs demo; the declared accounts a visitor may sign in as (The demo environment)
opening_hours(scope = null) function Base\Service\OpeningHours, or one place's (Opening hours)
local_business(overrides = [], scope = null), local_business_jsonld(...) functions schema.org's LocalBusiness, as an array or as its <script type="application/ld+json"> (LocalBusiness JSON-LD)

Layout pieces

Name Kind What it gives
render_slot(slot, options = [], template = null) function A widget slot's widget rendered (empty when the slot or its widget is missing)
all_slots(), all_widgets() functions WidgetProvider's slots and widgets
render_breadcrumb(name, request, options = []) function A breadcrumb (Breadgrinder) rendered
paginator(pagination, routeName, parameters = []) (or paginator_slidingcontrol) function The pagination's template rendered; paginator_first, _previous, _current, _next, _last, _rewind, _fastforward, _firstOnes, _previousOnes, _nextOnes, _lastOnes, _firstSeparator, _lastSeparator give its parts
share(identifier, options = [], template = null) filter on a LinkableInterface A sharing button (an adapter tagged base.service.sharing)
clipboard(template, value, attributes = []), clipboard_target(template, '#target', attributes = []) functions A copy button (@Base/clipboard/<template>.html.twig); the target must start with #
form_proxy(name), form_view(name) functions A form registered with the FormProxy, and its view

Generic helpers

Name Kind What it gives
count(value, mode), sqrt(value) functions PHP's
round(num, precision = 0, mode) function Rounded (see the defect below)
get_class, class_exists, get_short_class(object) functions PHP's; the short class name, first letter lowered (appVariable)
method_exists, is_callable, is_countable, is_linkable, is_json, is_string, empty functions Tests as functions (is_callable, is_countable, is_linkable, is_json, is_uuidv4 and empty are filters too)
static_call(class, method, ...args), static_property(class, name) functions A static method's result, a static property's value
call_user_func_with_defaults(fn, ...args), call_user_func_if_exists(name, args) functions A callable called; a Twig function called by its name, '' when there is none
property_accessor(object, path, enableMagicCall = false) function A property path read
cast(object, class, ...args) function An object copied into another class
count_leaves, synopsis, addslashes, exit functions The leaves of an array; a class's synopsis dumped (debug); PHP's addslashes, exit
email_preview() function True when the template is rendered outside an e-mail (no email in the context)
str_starts_with, str_ends_with functions PHP's
str_shorten(length = 100, position, separator = ' [..] '), mb_ucwords, url_decode, basename, intval, strval, addslashes, ceil, floor, sign filters Strings and numbers (sign: + or -)
uniq, pad(length, value), array_flatten, head, tail, third, fourth, fifth, closest(position), pickup(n), transforms(arrow) filters Arrays: unique values, padded, flattened, first element, the rest, the 3rd/4th/5th, the element at a position (or the nearest end), n random entries, a closure applied
distance(other), greater_than(seconds), instanceof(class), nargs, stringify, preg_split(pattern) filters The distance between two vectors; whether a date is older than that; is_instanceof(); a closure's number of parameters; any value as a string; preg_split()
color_name filter The nearest CSS named colour of a #RRGGBB code ('#ff0001' is red), the same in every locale (Base\Service\Model\Color\Intl\Colors: CSS's names, symfony/intl ships none)

round(number, precision = 0, mode) keeps its decimals: {{ round(3.14159, 2) }} is 3.14.

In the harness

round(3.14159, 2) = {{ round(3.14159, 2) }}
-3|sign = {{ (-3)|sign }}
str_shorten = {{ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit'|str_shorten(20) }}
'JP'|country_name('de') = {{ 'JP'|country_name('de') }}
'fr-BE'|lang = {{ 'fr-BE'|lang }} / country = {{ 'fr-BE'|country }}
mailparse_email = {{ mailparse_email('Ada <ada@example.org>') }} / name = {{ mailparse_name('Ada <ada@example.org>') }}
mailto = {{ mailto('ada@example.org', 'Ada') }}
[1,[2,3]]|count_leaves = {{ [1,[2,3]]|count_leaves }}
qr_code = {{ qr_code('https://example.org', 100)|slice(0, 26) }}...
get_short_class = {{ get_short_class(app) }}
is_json('{"a":1}') = {{ is_json('{"a":1}') ? 'true' : 'false' }}
'2026-04-10T18:05:00+00:00'|datetime('dd MMMM yyyy') = {{ '2026-04-10T18:05:00+00:00'|datetime('dd MMMM yyyy') }}
deploy_version() = {{ deploy_version() }}
demo_mode() = {{ demo_mode() ? 'true' : 'false' }}

rendered by the harness's twig service (test environment):

round(3.14159, 2) =
Deprecated: Implicit conversion from float 3.14 to int loses precision in /srv/demo/vendor/omnisense/omnibase/src/Twig/Extension/MathTwigExtension.php on line 35
3
-3|sign = -
str_shorten = Lorem ipsum dolor si [..]
'JP'|country_name('de') = Japan
'fr-BE'|lang = fr / country = BE
mailparse_email = ada@example.org / name = Ada
mailto = <a href='mailto:ada@example.org'>Ada</a>
[1,[2,3]]|count_leaves = 3
qr_code = data:image/svg+xml;base64,...
get_short_class = appVariable
is_json('{"a":1}') = true
'2026-04-10T18:05:00+00:00'|datetime('dd MMMM yyyy') = 10 april 2026
deploy_version() = 4e52a720
demo_mode() = false

Propose a change docs/50-reference/twig.md

Form types

The form types and type extensions of the core, with the options each one declares itself - what bin/console debug:form reports for it, the options of its parent and of the extensions left out. They were read in the core's harness from the form registry ($type->getOptionsResolver() against its parent's). How labels, help texts and selects behave is in Forms; the guard option in The forms' guard.

The extensions on every form

Every FormType (so every form and field) gets these options:

Option Default Extension Effect
form2 base.twig.use_form2 FormTypeExtension The form2 variant of the theme
easyadmin base.twig.use_ea FormTypeExtension The easyadmin variant of the theme
form_flow, form_flow_id true, '_flow_token' FormTypeExtension The multi-step flow of a form whose data_class is a FormModelInterface (the id is then the model's type name)
validation_entity null FormTypeExtension The entity a form model is validated as
use_model false FormTypeExtension Map the form on a model
translation_domain see Forms FormTypeExtension, FormTypeTranslateExtension Where the field's texts are translated
bootstrap, bootstrap_label base.twig.use_bootstrap, true FormTypeBootstrapExtension Bootstrap's classes on the row and the label
collab false FormTypeCollabExtension false, 'autosave' or 'live': presence and autosave on any field (data-collab-* attributes; the actions ux_editorjs_collabTicket and ux_editorjs_autosave)
csrf_field_name '_csrf_token' FormTypeCsrfExtension The CSRF field's name
webpack_entry null FormTypeWebpackExtension The webpack entry the field's script is in
guard, spam_protection see the guard's page FormTypeGuardExtension The forms' guard: trap, delay, lists, captcha, classifier

FormTypeCollectionExtension declares no option.

Field types (Base\Field\Type)

Every one is a service (bin/console debug:form lists them under "Service form types"). The block prefix is the name of its block in the form theme (<prefix>_widget, <prefix>_row).

Type Parent Block prefix Its own options
ArrayType FormType array length, max_entries, entry_offset, allow_object, allow_add, allow_delete, html, href, prototype, prototype_data, prototype_name, group, checkbox, row_group, entry_collapsed, entry_type, entry_label, entry_options, entry_required, delete_empty, target, associative, pattern, placeholder
CollectionType FormType collection2 the same as ArrayType without target, associative, pattern, placeholder
AssociationType FormType association class, form_type, embed, autoload, href, html, entry_collapsed, max_entries, entry_offset, entry_label, entry_required, fields, keep_indexes, length, excluded_fields, recursive, multiple, group, row_group, allow_add, allow_delete, allow_entity, allow_null
AssociationFileType FormType associationfile class, form_type, entity_file, entity_data, multiple, allow_delete, allow_delete[confirmation], href, max_size, max_files, mime_types, sortable
AttributeType FormType attribute abstract_class, class, recursive, multiple, multivalue, filter, filter_code, sortable, allow_add, allow_delete
DiscriminatorType FormType discriminator exclude_root, discriminator_autoload (a choice among the classes of an entity's discriminator map)
TranslationType FormType translatable locale, locale_options, single_locale, default_locale, required_locales, available_locales, autoload, fields, excluded_fields, translation_class, translatable_class, multiple
TranslatableType FormType translatable translation_field_name, translation_class, autoload, fields, excluded_fields, single_locale, default_locale, required_locales, available_locales, multiple
SelectType FormType select2 class, class_priority, disable, choices, choice_loader, choice_thumbnails, choice_filter, choice_value, choice_label, select2, select2-template, select2-templateResult, select2-templateSelection, theme, placeholder, capitalize, language, required_when_multiple, multiple, multivalue, vertical, maximum, tabulation, tags, highlight, minimumInputLength, tokenSeparators, closeOnSelect, selectOnClose, minimumResultsForSearch, dropdownCssClass, containerCssClass, use_html, avatar, href, autocomplete, autocomplete_endpoint, autocomplete_endpoint_parameters, autocomplete_fields, autocomplete_data, autocomplete_processResults, autocomplete_delay, autocomplete_type, sortable
CountryType FormType (extends SelectType) select2 SelectType's and alpha3
ForexType FormType (extends SelectType) select2 SelectType's and autocomplete_pagesize (a currency pair, autocompleted by ux_autocomplete_forex)
IconType FormType (extends SelectType) select2 SelectType's and adapter (base.icon_provider.default_adapter), autocomplete_pagesize
CurrencyType SelectType currency display_symbol, display_code, choice_translation_domain, choice_translation_locale
RouteType SelectType route choice_translation_domain, choice_translation_locale (a choice among the router's routes)
GenderType, RoleType, StateType SelectType gender, role, state none: SelectType on Gender, UserRole, ThreadState (class)
QuadrantType FormType quadrant class (Quadrant8 by default: a compass direction)
BooleanType CheckboxType boolean confirmation[onCheck], confirmation[onUncheck], toogle_url, switch, inline
ButtonType ButtonType (Symfony's) button2 confirmation, confirmation-bubbleup, type
SubmitType Base\Field\Type\ButtonType submit2 none
CodeType TextType code length, alphabet, uppercase, submit_on_complete (a code of known length typed one character per box, carried by one hidden input)
SlugType TextType slug separator, keep, upper, lock, strict, target
ColorType TextType jscolor jscolor, is_nullable
ColorPickerType TextType colorpickr pickr, is_nullable
EmojiPickerType TextType emojipickr renderer, is_nullable
DateTimePickerType DateTimeType datetimepicker debug, datetimepicker
NumberType FormType number2 scale, grouping, rounding_mode, input, html5, stepUp, stepDown, divisor, step, throttleUp, throttleDown, inline, throttle, min, max, suffix, prefix, autocomplete, keyUp, keyDown
MoneyType FormType money scale, grouping, rounding_mode, divisor, currency, html5, input, currency_target, currency_label, currency_exchange, currency_list, use_swap
StockType FormType stock scale, grouping, rounding_mode, input, html5, allow_infinite, stepUp, stepDown, target
PasswordType FormType password2 inline, secure, hint, revealer, repeater, autocomplete, suggestions, min_length, min_strength, max_strength, options, options[repeater]
FileType FormType fileupload class, dropzone, allow_delete, allow_delete[confirmation], allow_cancel[confirmation], multiple, clipboard, href, title, allow_url, allow_reupload, sortable, sortable-js, lightbox, alt, thumbnail_width, thumbnail_height, max_size, max_files, mime_types, data_mapping, parallel_uploads, upload_multiple, inline (the upload through ux_dropzone)
ImageType FileType imageupload thumbnail, modal, cropper
AvatarType ImageType avatar none
AudioType, VideoType FileType audioupload, videoupload multisource
CropperType FormType cropper cropper_info, cropper_actions, cropper, quadrant, target, natural_width, natural_height, fields, aspectRatios
EditorType HiddenType editor placeholder, collab_autosave, collab_live (Editor.js; its uploads in base.twig.editor.storage)
WysiwygType HiddenType wysiwyg theme, placeholder, height, modules

EditorType and WysiwygType default empty_data to null: an empty submission is null, not HiddenType's "".

Form types (Base\Form\Type)

The forms of the core's pages. LayoutSettingListType and LayoutWidgetListType are services; the others are built by their class.

Type Block prefix Its own options Used by
SecurityLoginType _base_security_login identifier, allow_login_token security_login
SecurityLoginTokenType _base_security_login_token allow_login_token the sign-in by an e-mailed link
Login2FAType login2_fa none user_settings_2fa (the code that confirms a new authenticator app)
SecurityLoginTwoFactorType security_login_two_factor none no page of the core
SecurityRegistrationType _base_security_registration none security_register
SecurityResetPasswordType, SecurityResetPasswordConfirmType security_reset_password, security_reset_password_confirm none security_resetPassword, security_resetPasswordWithToken
UserProfileType _base_user_profile none user_profileEdit (as App\Form\Type\UserProfileType, its alias, with use_model)
UserSettingsType user_settings none no page of the core (user_settings builds its forms itself)
UserSearchType user_search none user_search
ThreadSearchType, ThreadSearchbarType thread_search, thread_searchbar parent (the searchbar) thread_search
ContactType _base_contact phone, subject, attachments, buttons, trap, privacy, privacy_consent, privacy_parameters base_contact
CommentType comment signed_in, recaptcha, max_length, placeholders, trap_class Comments
PrivacyType base_privacy notice, notice_parameters, consent, consent_label, consent_message the privacy notice and consent of a form
LayoutSettingListType _base_layout_setting_list fields, fields[single_locale], excluded_fields, locale the settings' forms of the back office
LayoutWidgetListType _base_layout_widget_list widgets, sortable, excluded_widgets, locale the widgets' forms of the back office

Propose a change docs/50-reference/form-types.md

Reference

What the core declares to the framework, listed from its code and from the harness (docker/), with what each piece does:

  • Configuration reference: every option of base:, its type, default and reader; the parameters it becomes; what the extension registers (tags, prepended configuration).
  • Console commands: the 24 commands the core registers.
  • Events: the events the core dispatches, the entity dispatchers, the Doctrine events of the mapping and the queries, and what the core listens to.
  • Twig reference: the globals, functions, filters and tests.
  • Form types: the field and form types, the type extensions and their options.
  • Routes and access: the routes of the core's controllers and its voters.

The contracts of the Foundation (Base\Contract\*, Omnibridge\Contract\*) are in The contracts of the Foundation.

Propose a change docs/50-reference/index.md

Routes and access

Importing the routes

The core's controllers carry their routes as attributes. A site imports them with the BaseBundle alias, beside its own:

# config/routes.yaml
controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

base_controller:
    resource: "@BaseBundle/Controller"
    type: attribute
    prefix: /

Base\Attributes\Attribute\Route extends Symfony's #[Route]; it reads the localized paths of config/routes_paths.yaml. A route with paths per language is several routes, <name>.<lang> (thread_search.en is /search, thread_search.fr is /rechercher). To have the route cache rebuilt when that file changes alone, import it with the loader base_route_paths (Base\Routing\Loader\RouteTranslationsResourceLoader, which registers the file as a resource and declares no route).

The actions of src/Controller/Action/ are plain classes whose #[Route] the same import reads; what they return (a View, a Template, Json...) is answered by the ScreenResponder (kernel.view, see Events).

The core's routes

bin/console debug:router in the core's harness, the routes whose controller is the core's (Base\Controller\, Base\Bridge\), the plugins' left out. ANY: no method restriction. The short links answer on the subdomain s only (subdomain: "s", priority 1). security_loginDemo (POST /login/demo) exists in the demo environment only (The demo environment).

The sign-in, the account and its security

Route Path Methods Controller
qr_code_totp /members/qr/totp ANY Client\UserSettingsController::displayTotpQrCode
security_rescue /rescue ANY RescueController::LoginRescue
security_login /login ANY SecurityController::Login
security_logout /logout ANY SecurityController::Logout
security_logoutRequest /logout-request ANY SecurityController::LogoutRequest
security_loginWithToken /login/token/{token} ANY SecurityController::LoginTokenRequest
security_register /register ANY SecurityController::Register
security_verifyEmail /verify-email ANY SecurityController::VerifyEmailRequest
security_verifyEmailWithToken /verify-email/{token} ANY SecurityController::VerifyEmailResponse
security_adminApproval /admin-approval ANY SecurityController::AdminApprovalRequest
security_accountGoodbye /account-goodbye ANY SecurityController::DisableAccountRequest
security_accountWelcomeBackWithToken /welcome-back/{token} ANY SecurityController::EnableAccountRequest
security_resetPassword /reset-password ANY SecurityController::ResetPasswordRequest
security_resetPasswordWithToken /reset-password/{token} ANY SecurityController::ResetPasswordResponse
security_changePassword /change-password ANY SecurityController::ChangePassword
security_maintenance /m ANY SecurityController::Maintenance
security_launch.fr /est/bientot/en/ligne ANY SecurityController::Launch
security_launch.en /is/coming/soon ANY SecurityController::Launch
security_pending.fr /est/bientot/disponible ANY SecurityController::Pending
security_pending.en /is/soon/available ANY SecurityController::Pending
security_pendingForApproval.fr /en/attente/de/validation ANY SecurityController::PendingForApproval
security_pendingForApproval.en /waiting/for/approval ANY SecurityController::PendingForApproval
admin_rescue /rescue-request ANY RescueController::index

The member's pages and settings

Route Path Methods Controller
user_settings_2fa_email_toggle /settings/2fa/email POST Action\Settings\EmailCodeToggle
user_settings_enrolment /settings/security-required ANY Action\Settings\Enrolment
user_settings_enrolment_skip /settings/security-required/skip POST Action\Settings\EnrolmentSkip
user_profileEdit /profile/{id}/edit/ ANY Client\UserProfileController::Edit
user_profile /profile/{id} ANY Client\UserProfileController::Show
user_search /search/user ANY Client\UserSearchController::Main
user_settings /settings ANY Client\UserSettingsController::Settings
user_settings_passkey_rename /settings/passkeys/{id}/rename POST Client\UserSettingsController::RenamePasskey
user_settings_passkey_delete /settings/passkeys/{id}/delete POST Client\UserSettingsController::DeletePasskey
user_settings_session_logout /settings/sessions/{id}/logout POST Client\UserSettingsController::LogoutSession
user_settings_2fa /settings/2fa ANY Client\UserSettingsController::TwoFactorAuthentication
user_settings_2fa_disable /settings/2fa/disable POST Client\UserSettingsController::TwoFactorAuthentication_Disable
user_settings_2fa_qrcode /settings/2fa/qr-code ANY Client\UserSettingsController::TwoFactorAuthentication_QrCode

Notifications and web push

Route Path Methods Controller
user_notifications_delete /notifications/delete POST Action\Notification\Delete
user_notifications_latest /notifications/latest GET Action\Notification\Latest
user_notifications_read /notifications/read POST Action\Notification\Read
user_notifications /notifications ANY Client\NotificationController::Index
push_config /push/config GET Client\NotificationController::PushConfig
push_subscribe /push/subscribe POST Client\NotificationController::PushSubscribe
push_unsubscribe /push/unsubscribe POST Client\NotificationController::PushUnsubscribe
push_service_worker /sw.js GET Client\NotificationController::ServiceWorker

Threads

Route Path Methods Controller
api_thread_publish /api/thread/{slug}/publish POST Api\ThreadController::Publish
api_thread_hide /api/thread/{slug}/hide POST Api\ThreadController::Hide
api_thread_follow /api/thread/{slug}/follow POST Api\ThreadController::Follow
api_thread_unfollow /api/thread/{slug}/unfollow POST Api\ThreadController::Unfollow
api_thread_like /api/thread/{slug}/like POST Api\ThreadController::Like
api_thread_unlike /api/thread/{slug}/unlike POST Api\ThreadController::Unlike
thread_search.en /search ANY Client\ThreadSearchController::Main
thread_search.fr /rechercher ANY Client\ThreadSearchController::Main

Media: images, files, uploads

Route Path Methods Controller
ux_dropzone /ux/dropzone/{data} ANY UX\DropzoneController::Main
ux_dropzone_preview /ux/dropzone/{data}/{uuid} ANY UX\DropzoneController::Preview
ux_dropzone_delete /ux/dropzone/{data}/{uuid}/delete ANY UX\DropzoneController::Delete
ux_serve /contents/{data} ANY UX\MediaController::Serve
ux_imageCropExtension_cacheless /images/cacheless/cropper/{identifier}/{data}/image.{extension} ANY UX\MediaController::ImageCropCacheless
ux_imageCrop_cacheless /images/cacheless/cropper/{identifier}/{data} ANY UX\MediaController::ImageCropCacheless
ux_imageCropExtension /images/cropper/{identifier}/{data}/image.{extension} ANY UX\MediaController::ImageCrop
ux_imageCrop /images/cropper/{identifier}/{data} ANY UX\MediaController::ImageCrop
ux_imageWebp_cacheless /images/cacheless/{data}/image.webp ANY UX\MediaController::ImageWebpCacheless
ux_debug_imageExtension /images/debug/{data}/image.{extension} ANY UX\MediaController::ImageDebug
ux_debug_image /images/debug/{data} ANY UX\MediaController::ImageDebug
ux_imageExtension_cacheless /images/cacheless/{data}/image.{extension} ANY UX\MediaController::ImageCacheless
ux_image_cacheless /images/cacheless/{data} ANY UX\MediaController::ImageCacheless
ux_imageWebp /images/{data}/image.webp ANY UX\MediaController::ImageWebp
ux_imageSvg /images/{data}/image.svg ANY UX\MediaController::ImageSvg
ux_imageExtension /images/{data}/image.{extension} ANY UX\MediaController::Image
ux_image /images/{data} ANY UX\MediaController::Image

UX endpoints: autocompletion, validation, the editor

Route Path Methods Controller
ux_editorjs_collabTicket /ux/editorjs/collab-ticket POST UX\EditorController::CollabTicket
ux_editorjs_autosave /ux/editorjs/autosave POST UX\EditorController::Autosave
ux_autocomplete /ux/autocomplete/{data} ANY UX\AutocompleteController::Main
ux_autocomplete_forex /ux/autocomplete/currency/{source}/{target}/{data} ANY UX\AutocompleteController::Forex
ux_autocomplete_icons /ux/autocomplete/{provider}/{pageSize}/{data} ANY UX\AutocompleteController::Icons
ux_validation /ux/validation/{hashid} ANY UX\AutovalidateController::Main
ux_editorjs_endpointByUser /ux/editorjs/user/{data}/{page} ANY UX\EditorController::EndpointByUser
ux_editorjs_endpointByKeyword /ux/editorjs/keyword/{data}/{page} ANY UX\EditorController::EndpointByKeyword
ux_editorjs_endpointByThread /ux/editorjs/thread/{data}/{page} ANY UX\EditorController::EndpointByThread
ux_editorjs_uploadByFile /ux/editorjs/{data} ANY UX\EditorController::UploadByFile
ux_editorjs_uploadByUrl /ux/editorjs/{data}/fetch ANY UX\EditorController::UploadByUrl
Route Path Methods Controller
shlink_redirect /{slug} ANY ShortLinkController::Main
shlink_redirectByLocale /{slug}/{_locale} ANY ShortLinkController::Main
widget_page /page/{slug} ANY Action\Widget\Page
base_contact /contact ANY Client\ContactController::Contact
app_sitemap /sitemap.{extension} ANY SitemapController::Main
widget_attachment /attachment/{slug} ANY WidgetController::Attachment
app_welcome / ANY MainController::Index

Language, time zone, signatures, profiler

Route Path Methods Controller
_profiler_email /_profiler/email ANY ProfilerController::Email
_profiler_email_send /_profiler/email/send ANY ProfilerController::SendEmail
_switch_locale /locale/{_locale} ANY LocalizerController::Locale
_switch_timezone /timezone/{_timezone} ANY LocalizerController::Timezone
base_signature_webhook /signatures/{gateway}/webhook POST SignatureController::webhook

Voters

The core registers four voters (config/services/database.php).

AccessVoter

Base\Security\Voter\AccessVoter decides who may browse the site, from base.access_restriction (Configuration reference). SecuritySubscriber asks it on each request.

Attribute Granted when
ADMIN_ACCESS admin_access is on, or the user is ROLE_SUPERADMIN
USER_ACCESS user_access is on, or admin_access is on and the user is ROLE_ADMIN
ANONYMOUS_ACCESS public_access is on, or (admin_access or user_access is on) and the user is ROLE_USER
MAINTENANCE_ACCESS the site is not under maintenance, or EXCEPTION_ACCESS
LAUNCH_ACCESS the site is launched (Launcher), or EXCEPTION_ACCESS
EXCEPTION_ACCESS the address is under no firewall, or under a firewall not listed in firewalls, or one of exceptions matches it

The subject is a User (the roles are read on it) or an address (a string, a Referrer; the current address otherwise).

AdminVoter

BACKEND, on the back office's and the profiler's routes only: granted to a signed-in ROLE_ADMIN.

PermissionVoter

Any attribute in capitals that does not start with ROLE_, IS_ or EA_ (ARTICLE.PUBLISH, COMPLAINT.READ): granted when the user, or one of its groups, holds a Base\Entity\User\Permission with that tag (compared without case); a tag ending in .* grants every attribute it prefixes (ARTICLE.* grants ARTICLE.PUBLISH).

// a controller of the application
$this->denyAccessUnlessGranted('ARTICLE.PUBLISH');

ComplaintVoter

COMPLAINT_READ and COMPLAINT_HANDLE, on a Base\Entity\User\Complaint (or no subject: may this user read complaints at all):

  • ROLE_SUPERADMIN (the top role) reads and handles them all;
  • otherwise the user must reach ROLE_MODERATOR or hold the permission tag COMPLAINT.READ / COMPLAINT.HANDLE;
  • nobody reads a complaint about themselves;
  • a complaint about a user is only for those who outrank them: whose reachable roles (the role hierarchy) hold all of theirs and more.

Propose a change docs/50-reference/routes-and-access.md

The router's scheme is the request's

Until 2026-10-17 AdvancedRouter::getScheme() read

$use_https ??= $_SERVER["REQUEST_SCHEME"] ?? true;
return $use_https ? "https" : "http";

so it answered https whatever the request: REQUEST_SCHEME is a string, and "http" is as true as "https". Only an empty REQUEST_SCHEME (or "0") gave http. It now answers the scheme of the request being handled.

What it answers

  • A request on the stack: get_url_scheme() of the main request - Request::isSecure() (HTTPS on, or X-Forwarded-Proto: https from an address within framework.trusted_proxies), then, without HTTPS, the variables the core always read (USE_HTTPS, REQUEST_SCHEME, X-Forwarded-Proto). It is the function get_url() gives the visited address and the site's routes their scheme with (f115e368), so the router and the addresses agree.
  • A web request not yet on the stack: the same function on PHP's globals.
  • No request (a command, a worker, a test between two requests): https, unless the site sets HTTPS (or USE_HTTPS) to a false value (off, false, 0) in its environment. PHP's globals hold no scheme in a console, and get_url_scheme() would say http there: links written by a command keep the https they had.

What it decides

It does not decide the scheme of the addresses of the site's own routes: AdvancedUrlGenerator::resolveParameters() sets the request context's scheme from get_url(), and https when no request gives one. The visited address (get_url()) and its port (getPort(), 532f991a) follow the request too.

getScheme() decides:

  • the scheme of the absolute addresses of Symfony's own routes (_profiler, _wdt, _switch_locale...), and of every route when the router's advanced features are off (AdvancedUrlGenerator::generate() replaces http:// and https:// by it);
  • which declared ports the host fallbacks are filtered against (getFallbackParameters(): HTTPS_PORT on https, HTTP_PORT on http), whether a fallback port is the scheme's default (getPortFallback()), and the scheme getPort() judges by when the address has none;
  • the scheme of the address a request outside the allowed IPs is sent to (RouterSubscriber), the host getUrl() strips from an address asked as a path, and app.site.scheme.

What moved

On every site served over https (all of them, behind a proxy that terminates TLS, in development and in production) nothing changes: the request is https, as the router said. Where a site is answered over plain http - the harness (http://localhost:8000), the sites' tests (http://localhost), latoucheoriginale's tests on 8081 - the router now says http:

  • an address of _profiler or _wdt is http, as the page is;
  • the fallbacks' ports of an http request are filtered by HTTP_PORT, as getPort() already judged the visited port: latoucheoriginale's (HTTP_PORT='[8081]', no HTTPS_PORT) is 8081, where HTTPS_PORT's [443] made it 443 and the IP restriction sent to https://localhost/;
  • AdvancedUrlGenerator::generate() no longer forces an absolute address when HTTPS is on in the environment while the scheme is http. The rule never fired while the scheme was always https, and following the request it would have written every link of a site's tests (.env.test sets HTTPS=on, the client asks http://localhost) as an absolute address with the request's own scheme and host, which a path says as well. A route with a host of its own is still written whole.

How it is checked

tests/Routing/RouterSchemeTest: a plain http request is http (the router, _switch_locale and security_login absolute, a path still a path); https is https; FrankenPHP behind nginx terminating TLS (HTTPS "", REQUEST_SCHEME http, X-Forwarded-Proto: https on 8663) is https from a trusted address and http from an untrusted one; without a request it is https, http when the site sets HTTPS=off or USE_HTTPS=false; an http site on 8081 keeps its port and scheme in its fallbacks, getPort(), its links and through the kernel (200); the fallbacks' ports of a site with HTTP_PORT='[80,8094]' and HTTPS_PORT='[443,8473]' are the https ones on https and the http ones on http. Every test but the https one fails on the previous code. ForwardedHttpsUrlTest, NonDefaultPortUrlTest, AbsoluteUrlWithoutRequestTest and BasicAuthUrlHttpTest stay green, and so do the core's, omnibase/admin's and omnibase/marketplace's suites in the harness.

omnibase/marketplace's QuickOrderTest failure, once suspected of this, was its own: a Location answered to an http request compared with QuickOrder::doneUrl() made outside a request (marketplace d1d93bf).

Code that needs the scheme the visitor used may read the router's now, or the request's (get_url_scheme(), app.request.scheme): they are the same.

Propose a change docs/decisions/router-scheme.md