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.
Proposer une modification 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 |
glitchr/backup-manager (the TimeMachine, the backups) is suggested, not required: see below.
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:5.x-dev omnibridge/symfony:1.x-dev omnibridge/doctrine:1.x-dev omnibridge/twig:1.x-dev
Since 5.0 the core is a library that requires neither Symfony's framework nor Doctrine nor Twig (What the core requires): omnibridge/symfony is the bundle and the framework, omnibridge/doctrine the persistence, omnibridge/twig Twig's helpers. A feature that needs a package the core only suggests (symfony/http-client, phpoffice/phpspreadsheet, web-auth/webauthn-lib, florianv/swap, league/flysystem-ftp, symfony/discord-notifier, the Twig extras) asks the application to require it. Upgrading from 4.x: UPGRADE-5.0.md.
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.
omnibridge/doctrine and omnibridge/twig are the application's to require since 5.0
(4.x required omnibridge/doctrine).
The backups (Backups, the timemachine:* commands)
need glitchr/backup-manager, a suggestion of the core since 4.0: a site that
backs up through the TimeMachine requires it at its root.
composer require glitchr/backup-manager:4.0.*-dev
Without it the TimeMachine and its commands are not registered, and
base.time_machine is read by nothing.
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],
// ...
// omnibase's bundle, omnibridge/symfony's since 5.0 (Base\BaseBundle in 4.x)
Omnibridge\Symfony\Omnibase\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).
Proposer une modification docs/10-getting-started.md
Scaffolding a site
A new omnibase site starts as a copy of an existing site (~/Sites/<site>),
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 the reference site - 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
the reference site'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". The reference site'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
- Copy the files of the first part - tracked files only,
symfony.lockamong them, neither.env.localnor.htpasswd; rename (APP_NAME, ports,composer.json,package.json); give the two networks their subnets. make install-dev(composer and yarn in the setup containers,COMPOSER_ALLOW_SUPERUSER=1): see the order above.- Generate the key pairs (
secrets:generate-keys, dev and test: see Secrets), thenmake doctrine-migration,make doctrine-migrateandmake database-test. These run from a script or an agent as well as from a terminal: the Makefile'sDOCKER_WEB,DOCKER_DATABASE,DOCKER_PROXYandDOCKER_ASSETSpassdocker exectheTTY_FLAG(-tiwhen standard input is a terminal,-iotherwise), and so domake shell/make shell-debuggivenARGS. The reference site'sDOCKER_DATABASEandDOCKER_PROXYused a bare-ti, andmake database-teststopped 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. - Write the site's
SettingsSectionInterfaceclasses for its own settings and keys. docker exec <app>-web-1 php vendor/bin/phpunit: green before the first commit.
The reference site 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.
Proposer une modification 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.
Proposer une modification 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 —
Threadand 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\Doctrineunder 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(formerlybase-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.
Proposer une modification docs/20-architecture/index.md
What the core requires
Since 5.0 (branch 5.x, plan "omnibase agnostic", step F6) omnisense/omnibase is a library
(type: library) that requires the agnostic core's packages alone, the list bare --strict holds
it to: PHP 8.4 and its extensions (bcmath, exif, igbinary, imagick); the PSRs (cache, clock,
container, event-dispatcher, log, simple-cache); omnisense/omnibridge, omnisense/omnidrive,
omnibridge/composer; doctrine/collections; embed, endroid/qr-code, svg-sanitize, hashids, imagine,
crawler-detect, league/flysystem; the standalone Symfony components kept (string, uid, intl,
translation, mime, mailer, notifier, property-access, options-resolver, yaml, password-hasher,
filesystem, finder, serializer) and the contracts (service, translation, event-dispatcher, cache,
http-client). docker/bin/bare --install --alone installs exactly that (var/bare-alone), and
bare --strict --alone loads the agnostic core on it (BareTest's testTheCoreAloneLoadsTheAgnosticCore).
Left the core's require |
Comes from |
|---|---|
| doctrine/orm, dbal, persistence, event-manager, common, beberlei/doctrineextensions, scienta/doctrine-json-functions, omnibridge/doctrine | omnibridge/doctrine (the persistence; it requires beberlei and scienta since then), suggested by the core |
| symfony/validator, cache, event-dispatcher, process, workflow, var-dumper, error-handler, asset, expression-language | omnibridge/symfony |
| symfony/http-client, discord-notifier, league/flysystem-ftp, phpoffice/phpspreadsheet, web-auth/webauthn-lib, florianv/swap | suggest: the application that uses the feature requires it |
The bundle itself is omnibridge/symfony's since 5.0: Omnibridge\Symfony\Omnibase\BaseBundle, its
Bundle\*, DependencyInjection\*, the Symfony controllers and the service configuration
(config/omnibase/); their former names (Base\BaseBundle...) stay aliases until 6.0. The bundle's
path is still the core's src/ (@BaseBundle/..., the templates, the translations), and its
extension maps the core's entities under the bundle's name, as auto_mapping did.
The classes of the core that stand on a package it now suggests load where that package is
installed: the constraints of Base\Validator\ (symfony/validator), Base\Service\Model\WorkflowInterface
(symfony/workflow), Base\Twig\AssetPackage (symfony/asset). bare reports them as optional
where the package is absent, as it does src/Twig without Twig. The local caches (the
obfuscator's map...) take any PSR-6 pool: symfony/cache's PHP array file where it is installed,
Base\Cache\Pool\MemoryPool otherwise. doctrine/lexer, which egulias/email-validator reads when
symfony/mime checks an address, is accepted by --strict as the mailer stack's.
The symfony/process patch is still declared by the core (applied only where symfony/process is installed): it moves to omnibridge/symfony when 4.x is no longer served, because the 4.x core declares it too and the second declaration would fail on the sites still on 4.x.
4.x (branch 4.x, 4.0.x-dev) is kept for the applications locked on it; the bridges' 1.x serve
both majors. Upgrading: UPGRADE-5.0.md. What follows is the 4.0 state.
4.0
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; 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); the libraries (imagine, embed, qr-code, spreadsheets...); omnibridge/doctrine |
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 view engine | twig/twig and its extras (cache, cssinliner, inky, intl), omnibridge/twig: the Twig layer of src/Twig, registered only where Twig is installed, over the neutral helpers Base\View\* (since step F4 of the plan "omnibase agnostic", Twig reference) |
suggest of the core; a Twig application requires them (omnibridge/symfony and omnibridge/laravel require twig/twig and omnibridge/twig) |
| The libraries the core works with | glitchr/backup-manager (the TimeMachine: Base\Service\TimeMachine extends its Manager, and the timemachine:* commands; registered only where it is installed, since 2026-11-01) |
suggest of the core; the application requires it when it backs up through the TimeMachine |
| 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 |
omnibridge/doctrine stays required: the core does not load without it.
Base\Database\Type\UtcDateTimeType, UtcDateTimeImmutableType and DateTimeTypeUTC extend
omnibridge/doctrine's types and Base\Database\Type\Utc reads its zone (the types registered at
every boot, BaseExtension::prependDoctrineTypes(), BaseBundle::boot());
Base\Database\Mapping\NamingStrategy extends its NamingStrategy;
Base\DependencyInjection\Compiler\Pass\DoctrineConfigurationPass and Base\Bridge\Doctrine\Mapping
wrap every entity manager's driver in its DeclarationDriver. Since the agnostic core's step F3
the core's Doctrine classes themselves are omnibridge/doctrine's (Omnibridge\Doctrine\Omnibase\, their
former names aliased by the core: the Doctrine mapping); leaving the package to the
application is the composer.json switch, F6. omnibridge/twig left the core's requirements with Twig at step F4:
config/services/twig.php registers its HelperExtension with the Twig layer, and
Base\Twig\Extension\FunctionTwigExtension calls it (join_if_exists, less_than), both only
where Twig is installed.
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/.
The core's Symfony host code is omnibridge/symfony's, under Omnibridge\Symfony\Omnibase\ with the same
path (step F5c): the form layer (Field\Type\*, Form\*), the console (Console\*), the kernel subscribers
(Subscriber\*), the router (Routing\{AdvancedRouter, AdvancedRouterInterface, Generator, Matcher, Loader}),
the cache warmers and clearer (Cache\{Warmer, Clearer}\*, Cache\Abstract\AbstractLocalCacheWarmer), the
data collectors (Inspector\*), Response\{PdfResponse, XmlResponse}, Symfony Security's authenticators, user
providers, user checker, sign-in guard, session storage and voters (Security\*, but the framework-free ones), the
contracts' Symfony adapters (Bridge\Symfony\X is Omnibridge\Symfony\Omnibase\X). The pages of this manual still name
them by their former names (Base\Subscriber\...), which the core aliases until 6.0
(src/Resources/former-names.symfony.php).
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\NotifierandApp\Repository- one per plain classBase\<Name>\Xthe application does not define insrc/<Name>/, the aliasAbstractBaseBundledeclares at run time - by the providersBase\Bridge\Composer\Stub\*, written after everydump-autoloadintovendor/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-envon symfony/process: aProcessbuilt without a timeout waitsSYMFONY_PROCESS_TIMEOUTseconds, 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). The class is
omnibridge/symfony's Omnibridge\Symfony\Omnibase\Console\Application since step F5c; its former name
stays aliased until 6.0.
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\UserimplementsBase\Contract\SecurityUserandBase\Contract\TwoFactorUser. Where symfony/security-core and scheb/2fa are installed, they are aliases ofBase\Bridge\Symfony\SecurityUser(Security'sUserInterfaceandPasswordAuthenticatedUserInterface) andBase\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'svendor/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:bareloads 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\UserRepositoryimplementsBase\Contract\SecurityUserLoaderandSecurityUserProvider, aliases ofBase\Bridge\Symfony\SecurityUserLoader(doctrine-bridge'sUserLoaderInterface) andSecurityUserProvider(Security'sUserProviderInterfaceandPasswordUpgraderInterface) where symfony/doctrine-bridge and symfony/security-core are installed, empty interfaces elsewhere. So do the passkeys' repositories (plan "omniauth", AU1):PasskeyRepositoryandWebauthnUserEntityRepositoryimplementBase\Contract\PasskeyRecordsandPasskeyUsers, WebauthnBundle's interfaces where it is installed. - league/flysystem-bundle -
Base\Service\Flysystem(theflysystemservice, 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 taggedflysystem.storage, which the bundle tags from the application'sconfig/packages/flysystem.yaml. Without the bundle,Base\Discovery'sstorages(compiled by omnibridge/symfony'sDiscoveryPass) gives omnibase's local storages:local.cache(var/cache/<env>/images),local.uploadsandlocal.wysiwyg(var/storage/...), each with its.publicunderpublic/.
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.
Proposer une modification 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.
The interfaces are listed in Base\Repository\Aliases::INTERFACES (29 with
Repositories, since step E2' of the plan "omnibase agnostic": User\Complaints,
and Thread\Comments answering what omnibase/blog asks - findVisible(),
countVisible(), countPending(), findPending()). A site or a bundle types
on them, not on a repository's class: since E2' the core's bundles and
lesringostars do, but for omnibase/admin's dashboard tile of complaints, which
keeps ComplaintRepository while the bundle serves omnibase 4.x as well (4.x has
no Complaints).
The finder language, read without Doctrine
A finder's name - findOneByEmailAndUsername(), cacheOneByStateAndSlugStartingWith(),
countByStateAndPublishedAtYoungerThan(), cachePreviousOneByClassOfAndState(),
lengthOfTagsByState() - is read by Base\Database\Repository\Finder\Parser,
which knows nothing of an ORM (plan "omnibase agnostic", 2026-11-01, step F1).
Its parse($method, $arguments) gives a Finder\Query, the language's neutral
form:
methodandarguments: the internal request the name leads to (__findBy,__findOneBy,__findLastBy,__findAtMostBy,__findPreviousBy,__findNextBy,__findRandomlyBy,__findAll,__countBy,__distinctCountBy,__lengthOfBy...) and its arguments - the criteria first (those the name declares,"field:index" => value, merged into the array given, then the specials' numbers and keys as"special:..."), then the order, the limit, the offset, the grouping, the selected expressions;criteriaandoptions: the criteria the name declares and their words (EqualTo,NotEqualTo,GreaterThan,LowerThan,YoungerThan,OlderThan,Within,IsOver,StartingWith,Null,Empty,True,Insensitive,Partial,ClosestTo,InstanceOf,ClassOf,MemberOf...);criterions()gives them asFinder\Criterionobjects (field, index, value, options),extras()the specials' numbers and keys;separator(AndorOr),column(countFor<Column>By,lengthOf<Column>By,sumOf<Column>By...),cacheable,eagerly,request(find, count, lengthOf, sumOf, minOf, maxOf) andspecial(One, All, Randomly, AtMost, Last, LastOne, Previous, PreviousOne, Next, NextOne).
What the parser asks of the entity is a Finder\Subject: its class, its fields
(a field whose name holds a word of the language is refused), whether it has a
field or association, a Query-by-Example's criteria. A host's request given to a
...WithRoute finder is turned into route parameters by the resolver the parser
is given (Request and KernelEvent under Symfony).
A persistence compiles the Query into its own queries. On Doctrine it is
ServiceEntityParser: the Subject of its entity, read() (the parse alone) and
execute() (the DQL, the joins, the date bounds, the DQL functions, the caches,
the eager loading); parse() does both, as ServiceEntityRepository::__call()
asks. The compiler is omnibridge/doctrine's since step F3
(Omnibridge\Doctrine\Omnibase\Database\Repository\ServiceEntityParser, its former
name aliased by the core, see the Doctrine mapping), and
omnibridge/eloquent compiles the same Query into Illuminate's query builder at
F7: the language is one, whatever the backend.
The parse is the one of before F1, word for word:
tests/Database/Repository/FinderParityTest compares, for 57 finder calls (the
core's own shapes and one of each word), the parse and the query (DQL,
parameters, first and maximum results, or what a running finder gave or threw)
with finder-parity.json, recorded on the code of before
(FINDER_PARITY_RECORD=1). lesringostars' FinderParityTest compares the
results on the site's data. tests/Database/Repository/Finder/ParserTest reads
each word on an entity known by its fields alone, and
BareTest::testTheFinderLanguageIsReadWithoutDoctrine parses under
bare --strict.
Each word does what its name says (fixed before step F2,
tests/Database/Repository/FinderDefectsTest): LowerEqualTo, GreaterEqualTo, OlderEqualTo
and YoungerEqualTo compare with <= and >=; a negative word is read before the positive one it
ends with (NotStartingWith, NotEndingWith, NotInstanceOf, NotClassOf, NotMemberOf), and
NotClassOf is ClassOf's opposite (anything but that class exactly); lengthOf<Collection>By
gives each entity's number of members (SIZE()), lengthOf<Field>By its length (LENGTH()); a
DateTimeImmutable compared with a field of a mutable type is bound as the same moment; Null and
NotNull bind no parameter.
Groups, aggregates, and the queries one persistence runs (step E2)
A repository's method is written in the finder language wherever it can be, so that every persistence compiles it (plan "omnibase agnostic", step E2; the inventory of the repositories' hand-written queries, each sorted, is in the plan). Two features were added for it:
-
Groups. A name joins its criteria with one separator,
AndorOr. The parentheses a name cannot say are an entry of the criteria array,Finder\Group::by(<name>, ...values), read by the same parser as the criteria part offindBy<name>, its own separator inside:$notifications->count(['user' => $user, 'isRead' => false, Group::by('TitleNotEmptyOrSubjectNotEmptyOrContentNotEmpty')]); $translations->findByTranslatableNotNull([ Group::by('InsensitivePartialTitleOrInsensitivePartialExcerpt', '%word%', '%word%'), 'translatable.state' => ['STATE_PUBLISH'], ]);Every word of a criterion is allowed in a group; not what is no condition (
ClosestTo,FarestTo,Model),WithRoute, nor a criteria array or any argument after the named values.Query::criterions()gives a group as one criterion whose value is theGroupread (Group::criterions(),Group::separator()); omnibridge/doctrine compiles it into a parenthesised expression whose parameters and options are its own. -
Aggregates.
sumOf<Column>By...,minOf<Column>By...andmaxOf<Column>By...giveSUM(),MIN()orMAX()of a field over the rows the criteria select (sumOfViewsByDateGreaterEqualTo($since, ['path' => '/']),minOfDate()): the database's value as it gives it,nullwithout a row (Query::$requestsumOf,minOf,maxOf; internal__sumOfBy...).
omnibridge/doctrine's compiler also binds an equality on one value with the field's type when
that type converts it (ConvertedInCriteria: the passkeys' base64 and AAGUID columns), as
Doctrine's own findBy() does: findOneBy(['publicKeyCredentialId' => $bytes]) finds the row.
What the language does not say stays the backends', declared so:
#[Base\Database\Repository\BackendOnly(['doctrine', 'eloquent'], reason)] on the core's interface
method - the analytics' native SQL (upserts, INSERT IGNORE, DATE() groupings, correlated
subqueries) and Redirections::hit()'s bulk UPDATE. No finder compiles them: each persistence
writes them in its own terms, omnibridge/doctrine through DBAL (its implementation declares
BackendOnly('doctrine', ...) too), omnibridge/eloquent the same statements through Illuminate's
connection (step F7f; Redirections::hit() as one UPDATE ... SET hits = hits + 1).
BackendOnly::declared() lists them (14, kept in tests/Database/Repository/BackendOnlyTest),
BackendOnly::runs() tells whether a persistence runs one, and a persistence not named answers
BackendOnly::refuse() - an UnsupportedQueryException that names the method, the backends it is
written for and why - until it writes the query in its own terms, never a silent wrong answer.
None of the 14 is tied to Doctrine itself: plain SQL a connection runs, and an UPDATE whose
hits + 1 the database computes (no DQL-only construct, no Doctrine locking).
Each rewritten method gives the rows of its former query, in the same order:
FinderParityTest::testTheRewrittenQueriesGiveTheRowsOfBefore runs both (the former queries
kept word for word in tests/Database/Repository/HandWrittenQueries.php) on rows it writes in the
harness, and lesringostars' FinderParityTest runs them on the site's data. Under omnisense/omnibase
4.x, whose finder has neither groups nor aggregates, omnibridge/doctrine's methods written on them
keep their former query (ServiceEntityRepository::finderReadsGroups()).
An association compared by its key, without a join (step E2b)
A criterion on an association is compiled through a join of it (LEFT JOIN e.thread e_thread,
e_thread = :thread), and a persistence that scopes the joined entity puts its scope there:
Doctrine's trash filter on Thread goes into the join's ON, so a comment whose thread is in the
trash reads as a comment without a thread. Finder\Key::of($value), a criterion's value,
compares the row's own foreign key instead, with no join:
$comments->findBy([
'state' => CommentState::APPROVED,
'thread' => Key::of($thread), // e.thread = :key_thread; null: e.thread IS NULL
'parent' => Key::of(null),
], ['createdAt' => 'DESC'], 200);
$comments->findByThreadNotEqualTo(Key::of([3, 4])); // e.thread NOT IN (:key_thread_0)
It takes an entity, its identifier, a list of them (IN; an empty list leaves the criterion out,
as any list of the language) or null, with EqualTo (the default) or NotEqualTo only, on an
association of the entity itself; the Parser refuses a path (thread.parent needs the join) and
any other word, the compiler a field that is no single-valued association. In the criteria array,
in a name's arguments or in a group alike; Criterion::byKey() tells a compiler.
omnibridge/doctrine compiles it to e.<association> (=, <>, IN, NOT IN, IS NULL,
IS NOT NULL), omnibridge/eloquent to the foreign-key column. Not a word of the name: "Key" in a
name would make every field holding the word (Passkey's publicKeyCredentialId) one the
language refuses.
Comments::findVisible() and countVisible() are written on it: the comments of a thread in the
trash stay its own, as with their former query (FinderParityTest writes them, beside a live
thread's and the visitors' book's). Under omnisense/omnibase 4.x, which has no Key, the
methods keep their former query (ServiceEntityRepository::finderReadsKeys()).
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 ofgetTranslations()) until a value is written in it: a read creates no translation. A written one joins the collection at the nextgetTranslations()or flush (commitPendingTranslations(), called byIntlSubscriberbefore 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-BRon apt-PTpage), else the default locale's, else the first available locale's, else any written one.- The magic getters (
$post->getTitle(),post.titlein Twig) readtranslate(), 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().
Proposer une modification 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.
SiteVariableandAdminVariable(app.site,app.admin) andEmailVariableread the settings from theSettingBagand the route index from the router;FormTypeBootstrapExtensionthe parameters;ErrorControllertheEnvironmentcontract (Base\Service\Model\DevelopmentEnvironment,isDevelopment()'s rule);MainControllerandUserSettingsControllernever called it. The core'sgetService()->isDebug(),getSalt(),isGranted(),isEntity()calls areRuntime::environment()->isDebug(),->secret(),Runtime::access()->isGranted(), and BaseTrait's staticisEntity()(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 stay on the lazy base.runtime locator, one service built on its first
call; since F2f (below) they name contracts only, and the framework's services they handed
out are deprecated.
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.
BaseTrait on contracts only (step F2f)
Base\Traits\BaseTrait names contracts only: each type its accessors take or answer is an
interface of omnisense/omnibridge or Base\Contract\, or a class of the agnostic core
(tests/Agnostic.php) - never a class of Symfony, Doctrine or Twig
(tests/Traits/BaseTraitContractsTest). Each service comes in two forms:
| Form | Answers | For |
|---|---|---|
getX() |
the service, or Omnibridge\Exception\MissingContractException when the host has none |
a caller that needs it - it used to fail further on, on a call on null |
findX() |
the service, or null | a caller that does without: an optional service, a host that does not answer it |
The services: AttributeReader, SettingBag, EntityHydratorInterface, MediaService,
Obfuscator, IconProvider, LocalizerInterface, BaseNotifierInterface,
TranslatorInterface, TradingInterface, ParameterBagInterface (getParameterBag($key)
still answers null for a key the parameters do not have: only a host without parameters
throws). isEntity() answers a bool, and throws without the class metadata manipulator
(no persistence) instead of answering null. getEnvironment(), getProjectDir() and the
directories are unchanged.
The framework's accessors are deprecated - getRouter(), getTwig(), getDoctrine(),
getObjectManager(), getEntityManager(), getRepository(), getClassMetadataManipulator(),
getTokenStorage(), getRequestStack(), getFirewallMap(), getSlugger(), getService().
They moved to Base\Traits\FrameworkAccessorsTrait, which BaseTrait still uses: same types,
same null where the host has no such service, and an E_USER_DEPRECATED on each call naming
what to call instead (Link::to() for getRouter()->generate(), the unit of work and the
repositories for Doctrine's, Runtime::access() / ::sessions() / ::routes() for the
security and request ones, Omnibridge\Contract\Views for Twig). They are removed with the
former names at 6.0, after the sites have moved (step S). The core's host code - BaseService,
the validators, EditorController, Notification's mails until they render through Views
(F5a) - asks the same services of Base\Bridge\Symfony\FrameworkLocator, without the
deprecation; it moves with the host code (Doctrine's part at F3, Symfony's at F5c). The bundles,
which still accept the core 3.x, call no FrameworkLocator: their entities link through Link::to()
(in 3.x too), and marketplace's Product and Review::__autocomplete() keep the deprecated
getTwig() until Views has a Symfony adapter (F5a).
The deprecation path, for an application (step S):
getX()of a contract that it tests for null (?->,if (!...),??):findX();getX()it calls straight on: nothing to change, its type is no longer nullable;- a framework accessor: what its deprecation names (or
FrameworkLocatorin host code).
What was measured when F2f was made (core, the omnibase/* bundles, omnibridge/*,
lesringostars and lesringostars-laravel; 116 calls in 39 files, plus one static call on an
entity class): 63 calls on the contracts' accessors, of which 4 do without the service
(Uploader's getMediaService()?->, Notification's notifier check, AbstractExtension's
isEntity(), lesringostars' Notification::getTranslator() with ?->) and 3 test a missing
parameter key (marketplace); 53 calls on the framework's accessors - 31 getRouter() (20 of
them ->generate() in an entity's __toLink(), now Link::to(); the other 11 in BaseService), 6 getTwig(), 8
getRequestStack(), 3 getDoctrine(), 3 getEntityManager(), 2 getTokenStorage(). No
omnibridge package and nothing in lesringostars-laravel calls them.
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:
- the
$baggiven to the call, - the settings (
add(); when the overlay is on and$useHotBagis true), - 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 thebase.contractslocator (Base\Discovery'scontracts, compiled by omnibridge/symfony'sDiscoveryPass): 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 callsRuntime::setContainer()with a PSR-11 container whose ids are the interfaces' names (Laravel's application is one), orRuntime::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 throughBaseTraitthat 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.
A link by a router's reference type: Base\Contract\Link
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.
Since step E1 Link::to() is the static door of Base\Service\Links, a service
on Urls (in Base\Services::definitions()): the registry's where the host
lists it, else one made on Runtime::urls(). The widgets' __toLink()
(Page, Link, Attachment) default to Link::ABSOLUTE_PATH and import
nothing of the routing component.
The entities reach no service (step E1)
No entity of src/Entity imports Symfony's routing component, property-access
or a Symfony bridge, nor calls an accessor of BaseTrait (self::/static::
get*(), $this->getNotifier()...): docker/bin/bare --strict reads the
sources and refuses it (its report's entities, asserted empty by
BareTest). What an entity needs of a service it asks the registry:
Notification its notifier, translator and folder through
Runtime::notifier(), ::translator(), ::projectDir(); User reads its
identifier field itself, as property-access read it; Redirection and
TextOverride declare Base\Validator\Constraints\Unique (UniqueEntity on
the unit of work's Repositories). The entities keep use BaseTrait for the
applications that call its accessors through them.
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.
Proposer une modification 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).
In omnibridge/doctrine since step F3
Since step F3 of the plan "omnibase agnostic" (2026-11-02), the core's Doctrine
classes are omnibridge/doctrine's, under Omnibridge\Doctrine\Omnibase\: 142
classes, by one rule - Base\Bridge\Doctrine\X is Omnibridge\Doctrine\Omnibase\X,
any other keeps its path below Omnibridge\Doctrine\Omnibase\.
| Former name | Now |
|---|---|
Base\Bridge\Doctrine\* (the unit of work, backend, shapes, repositories, mapping, types, configuration, snapshot, Behaviour\*) |
Omnibridge\Doctrine\Omnibase\* |
Base\Database\Mapping\{ClassMetadataManipulator, ClassMetadataCompletor, ClassMetadataFactory, NamingStrategy} |
Omnibridge\Doctrine\Omnibase\Database\Mapping\* |
Base\Database\Type\{ArrayType, DateTimeTypeUTC, MySQLSetType, UtcDateTimeType, UtcDateTimeImmutableType, Passkey\*} |
Omnibridge\Doctrine\Omnibase\Database\Type\* |
Base\Database\{Walker, Function, Filter, Middleware, Event}\*, Base\Database\Schema\Describer |
Omnibridge\Doctrine\Omnibase\Database\... |
Base\Database\Repository\{ServiceEntityRepository, ServiceEntityParser} (the finder's Doctrine compiler) |
Omnibridge\Doctrine\Omnibase\Database\Repository\* |
the 81 repositories Base\Repository\**\*Repository |
Omnibridge\Doctrine\Omnibase\Repository\**\*Repository |
Base\DatabaseSubscriber\{Attribute, Intl, InverseCollectionCache, TrackingPolicy}Subscriber, Base\EntitySubscriber\{Extension, Trasheable, Versionable, Setting}Subscriber |
Omnibridge\Doctrine\Omnibase\{DatabaseSubscriber, EntitySubscriber}\* |
What stays in the core: the neutral parts - the finder language (Base\Database\Repository\Finder\*),
Entities, Repositories, the repository interfaces (Base\Repository\Users, Thread\Tags...) and
Aliases, the shapes (EntityShape, EntityShapes, DescriptionShapes), the schema rules, the
enumerations (EnumType, SetType, Enumerations), Utc, the behaviours' attributes and their
neutral backends' contracts, the listeners' logic (Base\Database\Lifecycle\*). The Doctrine half of
Base\Bridge\Symfony\FrameworkLocator is Omnibridge\Doctrine\Omnibase\DoctrineLocator (FrameworkLocator's
methods call it). #[Cache]'s regions, #[Vault]'s unique constraints and #[OrderColumn]'s order field -
the attributes' loadClassMetadata(), typed on Doctrine's ClassMetadata - are
Omnibridge\Doctrine\Omnibase\Behaviour\DoctrineMappingHooks, which AttributeSubscriber and the
manipulator call for each attribute before its own (deprecated) Doctrine-typed hook.
The former names keep working, until 6.0: src/Resources/former-names.php (required by
Functions.php) aliases each one, from src/Resources/former-names.doctrine.php (former => new;
Base\Database\FormerNames reads it). The alias is declared whichever name is loaded first - asked
by its former name, the moved class is loaded and aliased; loaded by its new name (or as a parent),
its file is included by the core's autoloader and the former name aliased at once, because PHP checks
an instanceof or a type written with a name without autoloading it. For the same reason the core's
extension sets .container.dumper.inline_class_loader to false: Symfony's debug container would
otherwise include the services' files itself, past the autoloader.
- The entities still name their repositories by their former names (
repositoryClass: UserRepository::class,Base\Repository\UserRepository): Doctrine's metadata, andMetadataSnapshot, are those of before. BaseBundle maps the moved repositoriesBase\Repository\X=>App\Repository\Xfrom omnibridge/doctrine's folder, and registers them under their former ids, as before. - An entity's
repositoryClassmay name a repository interface of the core (Base\Repository\Thread\Tags):Omnibridge\Doctrine\Omnibase\RepositoryFactory::implementation()gives the repository bound to it (Base\Repository\Aliases::INTERFACES), and under SymfonyBase\Discovery'srepositories(omnibridge/symfony'sDiscoveryPass) adds the interfaces to DoctrineBundle's repository locator. - The moved services are registered under their new names; their former ids are aliases (config/services/database.php).
MetadataSnapshotnames a filter or a type whose class moved by its former name: it compares the mapping, not where a class lives.- The core still requires omnibridge/doctrine (and doctrine/*) until the composer.json switch (F6), so that the sites keep everything they had.
Without Symfony
use Doctrine\DBAL\DriverManager;
use Omnibridge\Doctrine\EntityManagers;
use Omnibridge\Doctrine\Omnibase\Mapping; // formerly 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 enumerations
An enumeration of omnibase (Base\Database\Type\EnumType, SetType: ThreadState, UserRole,
an application's or a bundle's) is a plain PHP class since step F2a of the plan "omnibase agnostic":
its constants are its values, toDatabaseValue() and toPHPValue() its conversions (a SetType's
values joined by commas). On Doctrine each is a Base\Bridge\Doctrine\EnumTypeAdapter registered
under its getStaticName() (thread_state, user_role): an ENUM(...) column, SET(...) for a
SetType, a CLOB on SQLite. Base\Database\Type\Enumerations::classOf('user_role') gives the
enumeration of a type's name, whatever persistence registered it.
- Under Symfony an enumeration is still listed in DoctrineBundle's
doctrine.dbal.types(the core's and the application's byBaseExtension, a bundle's by its own prepend: omnibase/marketplace);Base\Discovery'senumerations(omnibridge/symfony'sDiscoveryPass) takes them out of it (DoctrineBundle instantiates each type's class) and gives them toBase\Bridge\Doctrine\EnumerationsMiddleware, which registers the adapters when the connection is made, before any metadata is read - the moment DoctrineBundle registered them. The parameterbase.database.enumerationslists them by name. - By hand,
Types::register()registers them (the folders' enumerations, and those given by name). ClassMetadataManipulator::getDoctrineType()answers an enumeration's column with an instance of the enumeration, as the type was before, soinstanceof EnumTypeand its static methods answer as they did;MetadataSnapshotwrites an enumeration's type by the enumeration's class, so the recorded metadata is unchanged.
The behaviours' hooks
omnibase's behaviours (#[Timestamp], #[Slugify], #[Uploader]...: Base\Attributes\AbstractAttribute)
answer the moments of an entity's life. Since step F2b of the plan "omnibase agnostic" they have
neutral hooks (onPrePersist(Lifecycle\PrePersist $event, ?string $property), ...,
onOnFlush(Lifecycle\OnFlush $event, object $entity, ?string $property)) taking omnisense/omnibridge's
events, which carry the entity and the unit of work (Base\Bridge\Doctrine\DoctrineUnitOfWork on
Doctrine). AttributeSubscriber relays Doctrine's events: the neutral hook first, then the
Doctrine-typed hook of before (prePersist(LifecycleEventArgs, ClassMetadata, $entity, ?string)),
kept for the applications' and bundles' attributes that override it. Another persistence dispatches
the neutral events alone. Only the fields a neutral PreUpdate hook gave a new value
(setNewValue()) are passed on to Doctrine's update; a hook that changes the entity and calls
recompute() keeps what Doctrine's change set says.
The persistence's part of a behaviour
What only a given persistence can do is asked of a small interface of
Base\Database\Attribute\Backend, one per behaviour, implemented once per persistence and
registered for its unit of work class with Backends::register(); a behaviour asks
$this->backend($event, VaultBackend::class) and gets the implementation for the unit of work its
event carries, or an UnsupportedBehaviourException when the persistence has none.
| Interface | What it does | Doctrine (Base\Bridge\Doctrine\Behaviour) |
|---|---|---|
VaultBackend |
an entity written again though its change set is empty (a secured field's new plain value) | scheduleForUpdate() |
SlugifyBackend |
the flush's pending writes in order; a unique index on the slug's column alone | scheduled insertions then updates; the schema manager |
CacheBackend |
what the cache between requests keeps of an entity or an association dropped | the second-level cache's evictions |
OrderColumnBackend |
a lazy to-many association keeps its stored positions until read (step F2c) | the PersistentCollection's inner collection swapped |
AttributeSubscriber registers Doctrine's (DoctrineBehaviours::register()) when it is made, under
Symfony and in Base\Bridge\Doctrine\Mapping alike. #[Uploader] needs none: the unit of work's
contract covers it (contains(), isNew(), recompute()). The mapping hooks
(loadClassMetadata(): #[Cache]'s regions, #[Vault]'s unique constraints) stay Doctrine's. At step
F3 the Doctrine implementations move to omnibridge/doctrine as they are; omnibridge/eloquent brings its
own at F7 (a persistence without a second-level cache implements CacheBackend as nothing to do).
The entity's shape
A behaviour reads the entity's mapping through $this->shape($event, $entity), a
Base\Database\Mapping\EntityShape (one Omnibridge\Schema\Entity of the Description: fields and
types, associations and their kind and side, identifier, root, discriminator), never Doctrine's
ClassMetadata. The shapes come from the persistence (EntityShapes, registered in Backends like the
behaviours' parts: DoctrineShapes on Doctrine, each class's metadata written by
MetadataDescription) or from a Description (DescriptionShapes). #[Alias], #[Associate] and
#[OrderColumn] read it; ClassMetadataManipulator answers the association kinds and sides from it
for an entity or a class (a ClassMetadata being built is read as it stands), and EntityHydrator its
defaults and identifiers. OrderColumnBackend keeps a lazy collection's positions until it is read
(Doctrine's PersistentCollection); without it the property is sorted when loaded.
The entity's values, the old entity and its changes (step F7c)
AbstractAttribute's helpers getFieldValue(), setFieldValue(), getPropertyValue(),
setPropertyValue(), getEntityChangeSet(), getOriginalEntityData(), getOldEntity(),
getOldEntityData(), getEntityFromData() and getPropertyOwnerRepository() take, last, the unit of
work of the hook's event. Given one whose persistence registered its shapes, they answer on
omnisense/omnibridge's contracts, whichever persistence keeps the entity:
- the values through
Base\Database\Entity\EntityValues(the shapes in place of Doctrine's metadata: a mapped property read and written as it is held, an enumeration field as its backing value, any other through Symfony's property accessor, paths andtranslationsfollowed; what ClassMetadataManipulator does); - the changes:
recompute()thenchanges(); the old entity:Snapshots::original(), the identifier beside, the changes' former values over it (a persistence withoutSnapshotsis refused withUnsupportedBehaviourException); - the slug's holder: the persistence's
Repositories.
#[Slugify] and #[Uploader] pass their event's unit of work; #[Timestamp], #[Blameable],
#[Randomize] and #[GenerateUuid] too. Without one the helpers keep omnibridge/doctrine's
ClassMetadataManipulator, as before (an application's attribute calling them statically). Still read
on Doctrine's metadata where Doctrine is installed (step F7d makes them neutral): #[OrderColumn]'s
field name, #[Uploader]'s declaring class (its path) and static readers (getPublic(), get()),
#[Hashify] and #[Countify]'s helpers. omnibridge/eloquent runs the behaviours on its unit of work
(its relay, its parts, its listeners' adapters: its docs/behaviours.md); the core's behaviour tests run on
both backends (docs/30-operations/harness.md, the harness switch).
The core's services on the unit of work
Plan "omnibase agnostic", step F2d. The core's services, listeners, stores, controllers and commands
take omnisense/omnibridge's UnitOfWork where they took Doctrine's entity manager, and ask it nothing
of the ORM:
- writing:
persist(),remove(),flush(),refresh(),contains(); an entity's identifier and class:identifier(),className(); - reading: their repository by its interface (
Base\Repository\*, a newUser\Identities, thePasskeysfinderfindOneByCredential()), orUnitOfWorks::repositories($unitOfWork)->of($class)for a class named at run time (Base\Database\Repository\Repositories); - an entity's fields:
UnitOfWorks::shapes($unitOfWork)(the entity's shape, above); - what only a persistence does, a small interface of
Base\Database\Backendregistered inBackendslike the behaviours' parts:
| Interface | What it does | Who asks | Doctrine (Base\Bridge\Doctrine\Behaviour) |
|---|---|---|---|
RowLocks |
a row locked for writing while some work runs, in a transaction | the like (Api\ThreadController), thread:publishable |
DoctrineRowLocks: a pessimistic write lock |
ConditionalUpdates |
one field written only if it still holds a value, in one statement | the sign-in link claimed once (Auth\UserLinkTokens) |
DoctrineConditionalUpdates: a DQL UPDATE |
EntityCache |
the cache of entities between requests evicted | SettingBag |
DoctrineEntityCache: the second-level cache |
Base\Database\UnitOfWorks::of() gives the unit of work for what a service was handed: the unit of work
itself, or a persistence's manager adapted once (UnitOfWorks::ADAPTERS, Doctrine's entity manager to
DoctrineUnitOfWork; another persistence registers its own). The services' constructors still accept
the entity manager, as the deprecated other half of a union (UnitOfWork|EntityManagerInterface, until
6.0): omnibridge/laravel, the bundles' wiring and the tests hand it to them. Under Symfony the
container's Omnibridge\Persistence\UnitOfWork is DoctrineUnitOfWork over the entity manager, and
the union is aliased to it, so that an autowired constructor gets the same instance. A
DoctrineUnitOfWork registers Doctrine's backends when it is made (DoctrineBehaviours::ensureRegistered()),
before any Doctrine event.
With base.persistence: eloquent (step F7d) every service that takes the unit of work or a repository interface
answers from omnibridge/eloquent. The declarations therefore name the core's repository interfaces, never a Doctrine
repository class (Analytics, SettingBag, the connections' tracker and listeners, FormGuard, the enhancers, the push and
notification services: step F7d-3), and ConnectionSubscriber, DemoAccountFactory and UniqueEntityValidator work on
the unit of work. What is still Doctrine's under Eloquent: Doctrine's metadata where it is read (field types, the
AttributeReader), the TimeMachine, the console commands named after Doctrine, the back office, and the repositories
without an interface (Layout\Widget\PageRepository, TextOverrideRepository, User\GroupRepository,
User\PermissionRepository).
What keeps the entity manager, on purpose: the mapping (ClassMetadataManipulator,
ClassMetadataFactory, EntityHydrator), ServiceEntityRepository, the Doctrine listeners, the
Doctrine commands (doctrine:database:import, doctrine:array:upgrade, the schema's charset), the
validators and CropperType (the form layer, F5b), AbstractEventDispatcher and BaseNotifier (F5a),
DemoAccountFactory (Doctrine's fixtures), Base\Console\Command (omnibridge/symfony's Omnibase\Console\Command since F5c; its deprecated $entityManager, read
by the applications' commands, beside its $unitOfWork), Base\Bridge\Symfony\FrameworkLocator (since F2f: the entity
manager and the registry BaseTrait handed out, now deprecated there). tests/Database/UnitOfWorkPathTest
keeps the converted files off the entity manager.
The scopes and the pages
Step F2e. The trash and the vault are scopes (omnisense/omnibridge's Scope):
Base\Database\Scope\TrashScope keeps a #[Trasheable] entity while its deletion field is null,
VaultScope a #[Vault] entity whose vault field is null or names the environment it was enabled with.
Doctrine compiles them as its SQL filters trash_filter and vault_filter (TrashFilter, VaultFilter,
same SQL as before); Base\Bridge\Doctrine\DoctrineBackend (omnibridge's Backend, the service
Omnibridge\Persistence\Backend and in Backends) enables and disables them by the scope's name.
TrashManager::withTrash() asks the Backend; the extension rows of a destroyed entity go through
Base\Database\Backend\BulkDeletes.
Pagination pages an array, or Base\Service\Model\Pages (a count and a slice). A Doctrine Query is
adapted to Base\Bridge\Doctrine\QueryPages, which keeps what Pagination did with it: the rows grouped by
id through Doctrine's Paginator, the translations joined, the total counted on a clone by CountWalker.
Both move to omnibridge/doctrine at F3; an Eloquent backend brings its own Backend and Pages at F7.
The lifecycle listeners: logic and Doctrine wiring
The listeners that record something keep their logic in Base\Database\Lifecycle, on no
persistence, and their Doctrine part in their former class, which keeps its name and its wiring:
| Logic (core) | Doctrine wiring | What the wiring keeps |
|---|---|---|
SettingsInvalidation (neutral PostPersist/PostUpdate/PostRemove) |
EntitySubscriber\SettingSubscriber |
the relay |
TrashRecorder (purge or trashing, the trash entry) |
EntitySubscriber\TrasheableSubscriber |
the deletion cancelled, the stamp's extra update, the cascade rescued, the second-level cache evicted |
RevisionRecorder (the diff, the values' form, the revision, no repeat) |
EntitySubscriber\VersionableSubscriber |
the updated entities and dirty collections, the revision in the running flush, the trim |
ThreadParticipants (a reply's followers, one Mention per user the text mentions; neutral PrePersist/OnFlush) |
EntitySubscriber\ThreadSubscriber |
the threads whose text the flush writes, a reply moved under another parent (its followers' collection recomputed) |
The others are Doctrine's by nature and move as they are at F3: IntlSubscriber (the translations'
mapping and discriminators, orphan removal), ExtensionSubscriber (extension rows written inside the
flush), InverseCollectionCacheSubscriber (the second-level cache), TrackingPolicySubscriber (the
mapping), AttributeSubscriber itself (the relay, the mapping and query hooks). DemoAccountLockSubscriber
reads the request and the session (step F5a); ConnectionSubscriber, UserSubscriber and
ThreadSubscriber (besides its Doctrine wiring above) listen to Symfony's events (F5c).
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 withDOCTRINE_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.
Proposer une modification 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\Declarationreads what the entity classes declare from their attributes by reflection, without instantiating them - the attributes areDoctrine\ORM\Mapping's, the vocabulary every omnibase entity is written in, but their classes need not exist (Vocabularyholds their parameters and defaults as data).Schema\Descriptionis the neutral description: eachEntity(table, kind, parent and root,Inheritancewith its column, map and value, identifier,Fields with their portable types,Relations with theirJoinColumns orJoinTable) and eachTable(Columns, primary key,Indexes,ForeignKeys), plain data too (toJson()).Schema\Builderbuilds 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\MySqlSchemareads a MySQLinformation_schemaand compares it with a description. - omnibridge/doctrine: the Doctrine side.
DeclarationDriverbuilds Doctrine's metadata from the declarations (wrapping the application's driver, which still says which classes exist);MetadataDescriptionwrites what an entity manager maps in the neutral form;NamingStrategyanswers Doctrine's naming strategy with the family'sNaming. - the core,
Base\Database\Schema(Foundation): what is omnibase's own - the rules its listeners add to Doctrine's metadata, written once more asOmnibridge\Schema\Builder\Rules applied at the same three moments (beforethe declaration is read, oncedeclared, onceloaded):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, thetranslationsandtranslatablerelations,locale,<table>_unique_translation),TargetRule(the targets DoctrineBundle resolves) andExtensionRule(extension_entity); andDescriber, 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
Describerbuilds without any ORM isMetadataDescription'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_versionsandmessenger_messagesapart).
The schema created from it (step F7a)
A new site creates its tables from the description, whatever the persistence; migrations evolve an existing database and belong to the persistence chosen (the plan "omnibase agnostic", answer 11).
Base\Database\Schema\EntitySchemais the core's describer, with no persistence installed: the same description asDescriber(omnibridge/doctrine's, which feeds Doctrine; the test compares them), and omnibase's column types as data (columnTypes(): the enumerations' and sets' values,arrayand the passkeys' base64 and AAGUID columns as texts, their trust path as JSON - the SQL Doctrine's types declare, the test compares them on MySQL and SQLite).- omnisense/omnibridge's
Omnibridge\Schema\Database\SchemaCreatorwrites and runs the statements (MySQL, SQLite), no ORM. omnibridge:schema:createon every host:bin/console omnibridge:schema:create(omnibridge/symfony: the folders, targets and enumerations Doctrine's configuration was given, the connection's PDO and default table options),php artisan omnibridge:schema:create(omnibridge/laravel:omnibridge.doctrine.entitiesbeside the core's,omnibridge.doctrine.omnibase's options, Laravel's PDO), and in plain PHPvendor/bin/omnibridge-schema-create --schema=vendor/omnisense/omnibase/src/Resources/schema.php(the current folder'ssrc/Entitybeside the core's, orOMNIBASE_PROJECT_DIR;DATABASE_URLor--url/--dsn). Each has--dump-sql, and refuses a database that already holds a table of the description.
use Base\Database\Schema\EntitySchema;
use Omnibridge\Schema\Database\SchemaCreator;
$options = ['project_dir' => $projectDir];
$description = EntitySchema::describe(EntitySchema::directories($projectDir), $options);
(new SchemaCreator($pdo, EntitySchema::columnTypes($options)))->create($description);
The proof, in SchemaDescriptionTest: the database SchemaCreator makes is the one Doctrine's
SchemaTool makes from the mapping - tables, columns in order (type, nullable, default,
auto-increment), primary keys, indexes, foreign keys - on SQLite in the harness, and on MySQL where
OMNIBASE_SCHEMA_DATABASES names two empty databases (lesringostars: 132 tables, 552 columns, 144
indexes, 157 foreign keys, no difference). BareTest creates the core's tables on SQLite from the core
installed alone (no Doctrine at all) and reads them back as the description.
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->tableis the table,$entity->identifierthe key(s),$entity->fieldsthe 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::$kindsaysbelongsTo(many_to_one, by itsjoinColumns),hasMany(one_to_many, through the owning side's join column named bymappedBy),hasOne/belongsToforone_to_one, andbelongsToManywithJoinTable::$nameand itsjoinColumns/inverseJoinColumns; - the hierarchies:
Entity::$inheritancegives 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.
Proposer une modification 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).
Proposer une modification docs/20-architecture/time.md
The services, everywhere
Marco's answer 3 to the plan "omnibase agnostic" (2026-11-01): the host-only Symfony code leaves for omnibridge/symfony, but the services stay available everywhere - under Symfony, under Laravel, and in a plain PHP script. Step F5a (2026-11-02) is what makes that true.
One list, three containers
Base\Services::definitions() is the core's services as data (every one of them since step F5e slice 4a: 192
definitions in src/Resources/services/<group>.php): each a class (or its parent's), its service id (the class by
default), the contracts it answers (interfaces and former ids, aliased to it, with their visibility), its arguments by
position or by name in Omnibridge\Container's syntax - '@id' a service, '@?id' one or null, '%name%' a
parameter, ['!tagged_iterator' => tag], ['!service_closure' => '@id'] -, its bindings, calls, tags, decoration and
factory, what a host must have installed for it (requires) and the environment it is for (environment); what only
Symfony's container gives it, by Symfony's own ids, is its symfony part. The format is omnisense/omnibridge's
Omnibridge\Container\Declarations (its docs/container.md).
| Host | Reads the list with |
|---|---|
| Symfony | omnibridge/symfony's Omnibase\DependencyInjection\Declarations::group(), called by its config/omnibase/services*.php for each group in its place among the bridge's own definitions (the first group in services-list.php, loaded first): the compiled container is the one the former Symfony files built, the order of the services a tag collects included. tests/ServicesTest checks that the container answers every public service and contract. |
| Laravel | omnibridge/laravel's Container\Definitions::bind(), at boot: every declared service that applies (its requires, its environment) and can be built there (what its constructor needs is bound or declared, its options configured) and that it (or the application) does not bind yet is a singleton of its class, its contracts aliases, its tags Laravel's; a decoration is left to the host's own wiring. |
| Plain PHP | omnisense/omnibridge's Omnibridge\Container::fromDefinitions() (docs: omnibridge's docs/container.md). |
The whole graph builds in plain PHP: tests/Services/graph-without-framework.php (BareTest, on the core installed
alone) gives Omnibridge\Container the host's contracts of container-without-framework.php, stand-ins for the
repositories and the request's contracts, and builds every definition that applies (91 there; the others say what they
require: Twig, omniauth, symfony/validator, the Symfony router...).
What a constructor asks and the list does not give is the host's: omnibridge's contracts (Urls,
Views, Environment, Translations, Locales...), Base\Service\ParameterBagInterface,
Base\Contract\MediaRouter, Base\Service\Storages, and three ids - imagine.bitmap, imagine.svg
and Symfony\Component\Translation\Translator (the catalogues the core's translator decorates).
$container = Omnibridge\Container::fromDefinitions(Base\Services::definitions(), $parameters, [
Base\Service\ParameterBagInterface::class => new Base\Service\ArrayParameterBag($parameters),
Omnibridge\Contract\Views::class => $views,
Omnibridge\Contract\Urls::class => $urls,
Omnibridge\Contract\Environment::class => $environment,
Base\Contract\MediaRouter::class => $mediaRouter,
Base\Service\Storages::class => new Base\Service\Flysystem($drives),
'imagine.bitmap' => new Imagine\Imagick\Imagine(),
'imagine.svg' => new Base\Imagine\Svg\Imagine(),
Symfony\Component\Translation\Translator::class => $catalogues,
]);
$container->get(Base\Notifier\Mail\EmailComposer::class)->compose('anne@example.org', '@App/mail/welcome.html.twig', ['name' => 'Anne']);
tests/Services/container-without-framework.php is such a script, run by bare --strict
(BareTest::testTheServicesAreBuiltByAPlainPhpContainer): nothing of Symfony's container, the framework,
Doctrine nor Twig is loaded.
Rendering through Views
The services that rendered with Twig's environment render through omnibridge's Views (and, for a
picture an e-mail embeds, ViewSources): the notifier (BaseNotifier::getViews()), EmailComposer
and InlineImages, Notification::renderHtml(), MediaService/FileService's media tags,
Sitemapper, QrSheet, the sharing adapters, WysiwygEnhancer/EditorEnhancer, the
ScreenResponder. Under Symfony and Laravel Views is omnibridge/twig's TwigViews over the host's
environment (config/services/twig.php, omnibridge/laravel's provider); an application binds its own
to render them with another engine. The e-mail's text part is written in PHP (EmailComposer::text(),
what Twig wrote from EmailComposer::TEXT).
Each of these constructors still accepts Twig's environment, deprecated until 6.0
(Base\View\ViewsAdapter wraps it in a TwigViews, with an E_USER_DEPRECATED).
BaseNotifier::getEnvironment() is deprecated for getViews(); BaseNotifierInterface declares
getViews() in its place.
On contracts
PaginatorandPagination: their links throughUrls(Urls|RouterInterface; Symfony'sadvanced_routerunder Symfony, the links as they were).Translator: whether the kernel debugs through omnibridge'sEnvironment(Environment|KernelInterface).Obfuscator: its compressions may be given to its constructor (iterable $compressions).AbstractEventDispatcherandBaseNotifier: omnibridge'sUnitOfWork(the entity manager accepted, deprecated, as step F2d did;getEntityManager()deprecated forgetUnitOfWork()); the dispatcher's association change set reads the entity's shape.DemoAccountLockSubscriber: its rule isBase\Demo\DemoAccountLock's (the original row, the columns, how to read and write them); the listener keeps Doctrine's identity map (it leaves with omnibridge/doctrine); the request and the flash message go throughOmnibridge\Contract\Requests(F5c).
The request, the session and the events (F5c)
omnisense/omnibridge's Omnibridge\Contract\Requests is the request being answered, read without the
framework's request object: has(), ip(), header(), uri(), host(), route(), attribute(), the
session's session(), remember(), forget(), its flashes flash(), flashes(), and request() for code
that still needs the host's own object. Symfony answers it with omnibridge/symfony's StackRequests,
Laravel with omnibridge/laravel's LaravelRequests; outside a request nothing is read or written.
| Service | Reads now | Former argument (deprecated until 6.0) |
|---|---|---|
AbstractEventDispatcher (user., thread., an application's) |
Requests (the event's request), any PSR-14 dispatcher |
RequestStack, Symfony's dispatcher contract |
SpamChecker |
Requests (address, browser, referrer, page), the home page through the router or Urls |
RequestStack |
Referrer |
Requests (the target paths kept in the session, the referer) |
RequestStack |
BrowserPlusChannel (browser+) |
Requests (the flashes) |
RequestStack |
DemoAccountLockSubscriber |
Requests (whether a request is answered, the warning) |
RequestStack |
UserSubscriber |
Access (who is signed in) |
Security's token storage |
Launcher, MaintenanceProvider |
Access (roles, who is signed in) |
Security's checker and token storage |
BaseNotifier, Notifier |
omnibase's router or Urls (the links), Requests (the route) |
- |
Localizer |
omnibase's ParameterBagInterface or the container's |
- |
A RequestStack given is wrapped by Base\Http\RequestsOf in omnibridge/symfony's StackRequests.
The entity events are dispatched with Omnibridge\Event\Events::dispatch() through any PSR-14
dispatcher. AbstractEvent is a NamedEvent: its dispatcher sets the name it is raised under
(user.register), Symfony's dispatcher is given that name, omnibridge/laravel's DispatcherEvents gives
the event to the listeners of its name, so UserSubscriber::listen() registers the same listeners on
Laravel's dispatcher as getSubscribedEvents() does on Symfony's.
A notification's e-mail is the Twig bridge's NotificationEmail under Symfony; on a host without Symfony's
Twig bridge (Laravel) it is EmailComposer::compose()'s, from NotificationMessages::composed().
The options, one schema (F5e slice 4b)
The options under base: are declared once too: Base\Options reads src/Resources/options.php, a schema in
omnisense/omnibridge's Omnibridge\Options\Processor format. Each node has a type (bool, int, float,
scalar, enum with its values, mixed, array), an info, a default, and may be deprecated, bounded
(min, max) or nested: an array holds named children (completed with their defaults when defaults is true)
or a prototype (a list, or a map by its key attribute; default_children, default, deep_merging).
| Host | Reads it with | Gives |
|---|---|---|
| Symfony | omnibridge/symfony's Options\SchemaTree |
BaseConfiguration's tree: config:dump-reference base, debug:config base, the base.* parameters |
| Laravel | Options::process() on config/base.php when omnibridge/laravel boots |
the config validated (Symfony's messages) and completed, placeholders resolved by the application |
| Plain PHP | Options::parameters() + Options::environmentParameters() |
Omnibridge\Container's parameters |
Base\Options::process(['guard' => ['min_delay' => 5]])['guard']['min_delay']; // 5
Base\Options::parameters()['base.paginator.page_size']; // 10
Base\Options::process(['guard' => ['min_delay' => -1]]);
// Omnibridge\Options\InvalidOption: The value -1 is too small for path "base.guard.min_delay". Should be greater than or equal to 0
The defaults keep their placeholders (%kernel.project_dir%/public/maintenance.lock, %env(APP_ENV)%): each host
resolves them (omnisense/omnibridge's Options\Placeholders). Options::environment() holds the variables a service
reads through %env()% that need a value where the environment has none - Symfony's env(NAME) parameters, set by
omnibridge/symfony where the group of services that reads them is compiled, and omnibridge/laravel's %env()%
default. tests/Options/OptionsHostsTest gives the same configurations to Symfony's tree and to the Processor and
expects the same values, refusals and deprecations.
What is discovered, one declaration (F5e slice 4c)
What a host looks for among the services is declared once as well: Base\Discovery reads
src/Resources/discovery.php, one registry per kind (Discovery::KINDS, registries(), registry($kind)). Until slice
4c omnibridge/symfony held these lists in fifteen compiler passes and BaseExtension; one generic pass, DiscoveryPass,
compiles them now.
| Kind | What it says | Symfony | Laravel | Plain PHP |
|---|---|---|---|---|
autoconfigure |
the tags a service of an interface gets (instanceof, tags, requires) |
registerForAutoconfiguration() in BaseExtension |
Definitions::discover(): the tags; the entity listeners' events |
Omnibridge\Container |
collect |
the tagged services (or those of a class) handed to a collecting service by a method | method calls | extend() on the collecting service |
calls |
fallbacks |
a service no one binds: the first candidate, else a default (the admin's links) | alias or definition | binding | alias or definition |
preferred |
an alias moved to a better service where it exists (the rewritten texts' Redis pool) | alias | - | - |
contracts |
the contracts Base\Contract\Runtime reaches, the locator's id |
the base.contracts locator |
the container itself | the container itself |
arguments |
arguments from another package's configuration (the guards' captcha) | arguments | - | - |
texts |
the application's own texts, read by Base\Translation\ApplicationTexts::read() |
the translator's argument | - | - |
storages |
the local storages where the host has none | Flysystem definitions | - (its disks) | - (its storages) |
enumerations |
where the enumerations are and what they extend (Discovery::enumerations()) |
out of DoctrineBundle's types, into its middleware | - | Discovery::enumerations() |
repositories |
the core's repository interfaces and their repositories (Base\Repository\Aliases) |
DoctrineBundle's repository locator | aliases | - |
The generic kinds (autoconfigure, collect, fallbacks) are in omnisense/omnibridge's Container\Discovery format:
Omnibridge\Container::fromDefinitions($definitions, $parameters, $services, Discovery::registries()) builds with them,
and BareTest checks that it discovers what Symfony's container discovers on lesringostars
(tests/Services/discovered-by-symfony.php: the obfuscator's five compressions, the six sharing adapters, the
enumerations, in their order). Symfony's own passes stay omnibridge/symfony's: TwigComponent, DoctrineConfiguration,
WebauthnMapping, WebauthnDeprecatedAlias, Workflow, FormerFormTypes.
Left for later steps
- F5b: the field and form types (the neutral form layer); the services that take Symfony's form factory.
- F5c, what its first part left:
BaseService(the facade, Symfony's by nature),HotParameterBag(the container's half ofHotParameters),SecurityPolicy(the role hierarchy), the field types'twigarguments,EntityHydrator(Doctrine's metadata),Referrer's and the Notifier's use of omnibase's router; the moves of §2.2 (Console, the kernel subscribers, Routing, the warmers, Security, the controllers, DI, BaseBundle); config/services/*.php, which then read the list.
Proposer une modification docs/20-architecture/services.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:
- a text rewritten in the back office (omnibase/admin's « Textes du site »):
its
key._politeif it rewrote one, else itskey; - the application's own catalogue (
translations/of the project): itskey._polite, else itskey; - the bundles': their
key._polite, else theirkey.
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
(Base\Discovery's texts, Base\Translation\ApplicationTexts, 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
messageis 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
- 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).
- Write each variant under
<key>._polite(or._plain,._formal) right after its text, same indentation, same parameters, same meaning. - 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.
Proposer une modification 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, omnisense/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 omnisense/omnishield
omnisense/omnishield is suggested, not required. 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.
Consent
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.
Proposer une modification 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).
The account's pages, written once (Base\Auth\Account\)
The decisions of the account's pages are the core's (plan "omnibase agnostic", step F5e, slice 5a); each host's
controller or route keeps the forms, the firewall and the authenticator, and turns the rule's
Base\Auth\Account\Outcome into its response.
| Page | Rule |
|---|---|
/login, /login/token/{token} |
SignIn::page(), link(), linkRequested() |
/logout |
SignOutPage::confirms() (omnibridge/laravel's sign-out page asks it too), left(), after() |
/register, /reset-password |
AlreadySignedIn::check() (the twin's sign-up asks it too) |
/verify-email, /verify-email/{token}, /admin-approval |
Verification::request(), tokenOwner(), confirm(), approval() |
/account-goodbye, /welcome-back/{token} |
Closing::close(), welcomeBack() |
/reset-password/{token}, /change-password |
Passwords::resetRequested(), resetToken(), reset(), changeToken(), change() |
/m, the launch, pending and approval pages |
WaitingPages |
/profile/{id}, /profile/{id}/edit/ |
ProfileAccess::show(), edit() (the twin's editor asks it too) |
/rescue |
RescuePage |
/login/demo |
Base\Demo\DemoSignIn::decide() |
/locale/{_locale} |
Base\Request\LocaleSwitch (omnibridge/laravel's Http\SwitchLocale asks it too) |
/settings, its passkeys and sessions |
Devices::sessions(), renamePasskey(), deletePasskey(), signOut() (slice 5b) |
/settings/2fa, /settings/2fa/disable, /settings/2fa/qr-code, /members/qr/totp |
SecondFactor::enrolment(), pending(), confirm(), invalidCode(), disable(), waiting(), enabled(), png() |
/search/user |
MemberSearch::find() |
/timezone/{_timezone} |
Base\Request\TimezoneSwitch |
The other pages (slice 5b)
The rest of omnibridge/symfony's controllers ask the core the same way; a page that answers JSON gets a
Base\Screen\Json (its data and its status), a download a Base\Screen\File.
| Page | Rule |
|---|---|
/notifications, /push/config, /push/subscribe, /push/unsubscribe, /sw.js |
Base\Notifier\NotificationCenter |
/api/thread/{slug}/{publish, hide, follow, unfollow, like, unlike} |
Base\Thread\ThreadActions (the api_thread token, who may act, the writes, the flash) |
/search |
Base\Thread\ThreadSearch::find() |
/contact |
Base\Notifier\ContactMessage::send() |
/_profiler/email, /_profiler/email/send |
Base\Notifier\TestRecipient::recipient() |
| the error page | Base\Request\ErrorPage::choose() (the debug page, else the back office's or the site's template) |
s.<host>/{slug} |
Base\Request\ShortLinkRedirect::follow() |
/attachment/{slug} |
Base\Request\AttachmentDownload::file() |
/sitemap.{xml,txt} |
Base\Request\SitemapPage::build() |
/ without a page of the site's |
Base\Screen\Welcome::html() |
/ux/validation/{hashid} |
Base\Field\Validation\Autovalidate::answer() |
/ux/autocomplete/currency/{source}/{target}/{data} |
Base\Field\Search\ExchangeRate::rate() |
Each is decided under bare --strict (tests/Request/Pages/pages-without-framework.php and its expected result).
Proposer une modification 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.orgunlessemail:does. A demonstration account is public: that is what it is for. Base\Demo\DemoAccountRegistrygathers 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 throughsecurity.role_hierarchy(aROLE_EDITORabove it) - is refused with aLogicExceptionas 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 indemoit 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 |
|
|---|---|
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 omnisense/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 askSecurityPolicy::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\DemoAccountLockSubscriberputs 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 anddemo:resetwrite 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_STAFFreading the orders under/admin) - gets a 403 on every page of it (
/admin, omnibase/admin's routes);
- a demonstration account or one a visitor created, whatever its roles give
it elsewhere (the kitchen's
- 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_databaseis unset: nothing could be compared, anddemo:resetempties 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 (omnibridge/symfony's Omnibase\Console\Command\DemoResetCommand since F5c, former name aliased) |
demo:reset |
tests/Demo/ |
the suite; Fixtures\DemoTestKernel boots a host application in demo or prod |
Proposer une modification 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
selectedoption - also in the lists that printed their options already (achoice_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.
requiredis what the field says,trueunless 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.
The neutral form layer (plan "omnibase agnostic", step F5b)
Every site has forms, and the admin builds its own from the Fields (Marco's answer 4): forms are not Symfony's alone. The core describes a form without a form library; each host renders and processes it.
Field descriptors name a kind
A field descriptor (TextField::new('title'), FieldDescriptor) names a kind
(Base\Field\Kind: text, textarea, email, password, integer, number, money,
choice, country, association, date, datetime, file, image, collection, boolean,
editor...), never a Symfony form type class. Its options are plain data (getOptions(),
getOption('attr.maxlength')), its constraints plain objects (addConstraint()) that the host's
Omnibridge\Contract\Validation reads, its label and help translatable keys.
Base\Field\Kinds maps a kind to a host's own name for it: Symfony's form type for
getFormType() (the map SymfonyKinds, read by name: the same class each descriptor named before),
a theme's block for another host. A bundle or an application registers its own kinds:
Kinds::register('symfony', ['signature' => SignatureType::class]);
setFormType() stays, as the escape hatch of a Symfony-only field: a type a kind maps to sets that
kind too (a TextField given ChoiceType is a choice), another keeps the kind set, or is
Kind::CUSTOM.
A form: FormDefinition
use Base\Form\Definition\FormDefinition;
$form = FormDefinition::new('comment')
->add(TextareaField::new('content', 'comment.content')->setRequired(true)->addConstraint(new NotBlank()))
->add(BooleanField::new('notify'), 'options')
->addAction('submit', 'comment.submit')
->setDataClass(Comment::class);
// or the fields a CRUD page shows
$form = FormDefinition::fromFields('crud_form', $controller->configureFields('edit'), 'edit');
A definition holds its fields by name, their groups, its actions (the buttons; the one clicked is
the submitted intent), its method and target, the intent of its token (its name by default), the
data class, the translation domain (the host's default when null) and, as an escape hatch, options
only one host reads (setHostOptions([...], 'symfony')).
What a site's form needs beyond fields, each read by every host:
FormDefinition::field('email', Kind::EMAIL): a bare field, none of what a descriptor class adds for the back office;setFormTypeOption('data', true): a value fixed whatever the data;'choices' => $repository->subscribable(...): a callable, asked when read; a list of records as choices (each says its own name, its id the value);addRewrite(fn (array $sent): array => ...): what was sent rewritten before it is read (Symfony's PRE_SUBMIT);Kind::REPEATED: one value typed twice, optiontypethe kind of the two inputs, the mismatch said under the first;addAction('submit', null, Kind::BUTTON, [], ['confirmation' => true, 'type' => 'submit']): ButtonType's modal;setGuard(['action' => 'signup']):FormGuard's trap, stamp, lists, under Symfony by the optionguard, elsewhere byFormProcessor;setValidationGroups(['new']),setValidationEntity(User::class, ['username']): the record's class-level rules on the form's values, its refusals the form's own but for the fields named.FormSubmission::isTaken('email')tells a page which.
A model's or entity's rules stay on one line: Omnibridge\Validation\AttributeConstraints reads symfony/validator's attributes by name, their
class never loaded, as the kept constraints. Unique asks Base\Form\Definition\UniqueLookup, over Repositories, on both hosts.
Binding, view, processing
FormBinderturns what is sent into the data (an array, an entity, a DTO) and the data into the values shown, by kind: a trimmed string (a password untrimmed),''as null or the empty data, an integer, a number, a date (input:datetime,datetime_immutable,string), a choice among the field'schoices, an association by its id (UnitOfWork::reference()), a file as the host gives it, a missing checkbox as false. A value it cannot read is a violation of its field with Symfony's own message ("Please enter an integer.") and leaves the data untouched; a disabled field keeps the data's value; the data is written only where its value changes (Properties: getters, setters, public properties, "." paths, without property-info). Transformers are plain callables:withTransformer($kind, $reverse, $transform), or a field's custom optionsFormBinder::OPTION_REVERSE/OPTION_TRANSFORM.FormViewis a plain tree a renderer reads: each view'svars(name, full_name, id, kind, type, value, label, help, errors, attr, required, disabled, choices as a list of value/label, multiple, checked; the root's method, action, valid) named as Symfony's FormView names them, its children by name, the token as the hidden child_token.toArray()(andjson_encode()) gives the props of a React page.FormProcessorhandles a request's part: bind, check the token (Omnibridge\Contract\Tokens, the definition's intent), validate (Omnibridge\Contract\Validation: the data against what its class declares, each field's value against the field's constraints), then keep the data (Omnibridge\Persistence\UnitOfWork) when asked and valid. Each contract is optional. It is inBase\Services::definitions(), so every host's container gives it.
$submission = $processor->handle($form, $request->request->all('comment') + $request->files->all('comment'), $comment, persist: true);
if ($submission->isValid()) {
return redirect(...);
}
return view('comment', ['form' => $submission->createView()]); // the values sent, their errors, a token
What a host does once a form holds (step F5c)
- An empty string a definition gives as its empty data (
setEmptyData('')) stays a string, as Symfony'sTextTypekeeps it, whether the field came empty or not at all (Laravel sends an empty field as null). - What the binder could not read ("The values do not match.") is said in the page's language: the
validatorsdomain, when the processor hasTranslations. - A record's uniqueness declared without its class (
#[UniqueEntity(fields: ['email'])]on the entity) is looked up among the records of the form's validation entity. - A refusal the code that keeps the data gives (a spam, a record not found) is added to the submission:
$submission->withError($message, $field), a new submission, as Symfony'saddError(). - A record the form has just written into is held to its class's rules before it is kept:
RecordRules::violations($validation, $record, $groups)- its uniqueness above all, the record itself not counted as taken. omnibase's profile editor does it on both hosts: a name another account has is said under its field, the account read again as it is stored (it answered 500 and signed the session out). - A sign-up once its form holds is
Base\Auth\Registration::register()on every host (approved, its password, named after its address when its class has a username left empty, kept); the host signs it in. - A rule of the data on a field with
error_bubblingis the form's error, as a rule of the field's own. - The sign-up's terms of use are checked by the server (
agreeTerms,user.agree_terms), in the default group and the sign-up'snew.
The Symfony adapter (omnibridge/symfony)
The Symfony half of the form layer lives in Omnibridge\Symfony\Omnibase\ since F5b, each class under its
former path: Field\Type\*, Form\Type\*, Form\Extension\*, Form\Transformer\*, Form\Traits\*,
Form\Common\{AbstractType, FormTypeInterface}, Form\{FormFactory, FormProxy, FormProcessor} and their
interfaces (78 classes). Their Base\ names keep working until 6.0: src/Resources/former-names.symfony.php.
Sites keep createForm(), their themes and Base\Field\Type\X::class everywhere. Because ::class of a former
name is a plain string while Symfony's registry asks a type's extensions by its declared name,
FormerFormTypesPass gives the type locator both names and files the extensions under the declared one. A
PreloadedExtension (a hand-built factory, in a test) knows the declared name only: ask it by the new one.
App\Form\ stubs and aliases read both folders (FormStub, BaseBundle::symfonyFormDir()).
Omnibridge\Symfony\Omnibase\Form\DefinitionForms makes a Symfony form of a definition - types by kind, options
as the admin maps a CRUD field's, kept constraints translated (SymfonyConstraints), actions as buttons,
getHostOptions() winning - and Omnibridge\Contract\Validation is SymfonyValidation over symfony/validator.
A site's form stays a type, what Symfony asks for: Omnibridge\Symfony\Omnibase\Form\DefinitionType builds it from a definition
(definition(array $options)), so FormProxy::createProcessor(), its name and its block prefix do not change.
The generic renderers - the Twig theme (omnibridge/twig),
the Blade theme (omnibridge/laravel), React - read FormView. The whole layer loads under
bare --strict (BareTest::testTheFormLayerWorksWithoutAFormLibrary).
Every kind on every host: behaviours, coverage, conformance (step F5d)
What a rich field does - a date picker, a slug made from another field, a switch that asks first, a select2 searched remotely, a colour, a localized number, a password typed twice - lived only in its Symfony type, and a host that did not know a kind drew a plain input in silence. Three pieces close it.
Behaviours. omnisense/omnibridge's Omnibridge\Form\FieldBehaviour is the contract one behaviour
answers whatever host draws it: options() (the given ones over its defaults, normalized), vars() (what
its widget reads, plain data: datetimepicker, select2, data attributes under attr), transform() and
reverse() (the value shown, the value read back; \UnexpectedValueException for a value it cannot read),
assets() (the script's entry, the prefix of the attributes its controller reads, FieldAssets),
endpoints() (the host's routes it calls, Endpoint: a select's remote search). The core's
Base\Field\Behaviour\* hold the logic the Symfony types held, and Behaviours::of($kind) gives them:
| Kind | Behaviour | Widget |
|---|---|---|
datetime_picker |
DateTimePicker: ICU format, flatpickr's settings merged key by key |
datetimepicker |
slug |
Slug: separator, kept characters, strict, lock, the target path |
slug |
boolean |
Toggle: switch, confirmations, the toggle route |
boolean |
color |
JsColor: #RRGGBBAA shown, as short as written kept |
jscolor |
color_picker |
ColorPickr: Pickr's settings, the alpha completed |
color |
number |
Number: the page's language (48,857), divisor, prefix, suffix, steppers |
number |
password |
Password: plain and plain_repeater, strength bounds |
password2 |
choice, role, state, gender, currency, country |
Select (CountrySelect): select2's settings (Select::select2()), an enum's choices (Choices::of()), a record's remote search (ux_autocomplete) |
select2 |
icon |
Icon: the icon provider's search (ux_autocomplete_icons) |
select2 |
locale |
ChoiceList: Intl's locales |
a plain select |
The Symfony types read them (DateTimePickerType, SlugType, BooleanType, ColorType, ColorPickerType,
NumberType, SelectType's select2 settings): the 13 forms and the back office's pages draw the same markup.
The neutral FormBinder reads through a behaviour first (the field's options completed with its defaults),
then the kind's generic rule; the neutral FormView gives a rich field its widget, webpack_entry, settings
and endpoints (the address of each route on this host, null where it has none). A select2 is its form's
choice part on both hosts (name[value][choice]), as SelectType names it. A field whose host type is
named (setFormType()) has no behaviour: its type decides.
Coverage. Each host declares every kind, implemented or Coverage::unsupported('why') (omnisense/omnibridge's
Omnibridge\Form\Coverage): Symfony's SymfonyKinds::COVERAGE, Laravel's LaravelKinds::COVERAGE, read by
Kinds::coverage($host) (completed by Kinds::declare()); Kinds::faults($host) names a kind left undeclared,
an application's own kinds included. A kind a host declares unsupported is refused aloud when drawn
(Omnibridge\Form\UnsupportedKind): DefinitionForms for Symfony (html), omnibridge/laravel's FormRenderer
for Laravel, which also refuses a select whose remote search route it does not have (an application without
omnibase's media, through which the search's payload goes). A field that names its host
type (setFormType()) and keeps a kind is drawn from its generic view by the generic renderers (explicit in its
vars); one with no kind at all (custom) is refused there. lesringostars-react declares its own coverage
(src/screens/kinds.json) against src/Resources/field-kinds.json, the core's list of kinds as JSON.
The record's mapping completes what a field leaves out, as the Symfony types guess it from Doctrine
(Base\Field\Behaviour\Guess, on the unit of work's entity shapes, aliases resolved): a select's class (an
association's target, an enumeration column's class) and whether it holds several, its sorting (#[OrderColumn]),
a slug's required (its column not nullable). A select2's entries are omnibase's Autocomplete::resolve()'s (an
enumeration's word and icon) where the host gives omnibase's translator; a country's carries its flag.
The remote search. A select of records (an association picked, class a record's) and an icon picker
search their entries on the host: Base\Field\Search\RemoteSearch, the same on every host, at the routes of
RemoteSearch::ROUTES - ux_autocomplete (/ux/autocomplete/{data}: a class's records through its repository's
cacheByInstanceOf(), paged by the Paginator, named by Autocomplete, kept when their text holds the term; an
enumeration's values) and ux_autocomplete_icons (/ux/autocomplete/{provider}/{pageSize}/{data}: an icon adapter's
icons, through Base\Service\Icons). data is RemoteSearch::data(): the class, the fields, the filters
(RemoteSearch::filters()), the case, whether the entries are HTML and show faces, and the visitor's token of
intent select2 (the Tokens contract), obfuscated in its short form; the search refuses another token, an unknown
payload, a method the environment does not take (GET and POST in debug, POST otherwise: autocomplete_type).
Symfony's AutocompleteController and omnibridge/laravel's SearchController hand it the request's method, query
and body and send its Answer as JSON. SelectType makes its data with it; the neutral FormView gives the
search endpoint its data and starts select2 with the entries chosen (the records found by their ids, an icon
named by its adapter's list), so both hosts draw the same picker.
Uploads. A file field (file, image, avatar, audio, video) is Base\Field\Behaviour\Upload on every host:
the defaults and normalizers of omnibase's FileType and its children, and what their blocks show of the record's files -
the stored files' names (the hidden input [file]), their public addresses, thumbnails and previews, the download links, the
largest size and the types accepted (the record's #[Uploader] and #[File]), an image's thumbnail, modal and cropper. The
file to send is [raw] (or [url] with allow_url); a field of several files with a dropzone sends each file as it is dropped
to Base\Field\Upload\Dropzone's ux_dropzone, which keeps it under a uuid in the cache folder's dropzone/ until the form
is sent (ux_dropzone_preview shows it again, ux_dropzone_delete removes it; its payload carries the visitor's token of intent
dropzone). What is sent back is read as FileType reads it (Upload::reverse()): the file sent, else the one fetched, else
what [file] names - the dropzone's kept files, or a stored file's name -; a compound sent empty removes the file, a field not
sent keeps it. The record's #[Uploader] stores the file when the record is flushed. Symfony's types and its
DropzoneController ask the behaviour and the service; the neutral FormView gives the behaviour the record it edits.
The file sent is refused when the form is validated, as FileType's File constraint on [raw] refuses it, on every host
(Upload::refusal(), which the neutral FormBinder applies): a type its mime_types do not match (file.mime_type), else a
size over its ceiling - PHP's upload limit, narrowed by its class' #[Uploader] and #[File] and by max_size -
(file.max_size); the field's error is the constraint's message (the validators catalogue), the data is left untouched, the
record's #[Uploader] is never reached. Base\Validator\Constraints\File reads an integer as bytes: FileType's ceiling,
which it read as bits until then (32 MB refused a file over 4 MB).
Records made from uploads. An association_file field (a gallery's photos, a photo's picture: AssociationFileField) is
Base\Field\Behaviour\AssociationFile on every host: a compound holding one upload field (file, image or avatar, by its
form_type) named by the first part of entity_file, the path from a related record to its file (image.source). The upload
field shows the related records' files (files()) and carries each stored file's name by its record's id (entries(),
data-entry-id); several records when the association is to-many (the entity's shape), in order where it is kept so. What is sent
is made into the related records (records()): a file sent (or kept by the dropzone) a new record - hydrated, then given
entity_data, a callable or values, else its file -, a stored file's name its record kept, a name left out its record left; the
neutral FormBinder writes them into the record (bind(): a to-many collection replaced, an inverse side's records told their
owner, from the shape), Symfony's AssociationFileType into its PersistentCollection as before. The admin's edit link of a
record (href) stays the Symfony back office's.
Editors. A text written in Editor.js (editor) or in Quill (wysiwyg) is kept in a hidden input on every host, its
script's element beside it. Base\Field\Behaviour\Wysiwyg gives Quill's settings (wysiwyg: theme, toolbar, placeholder,
height); Base\Field\Behaviour\Editor gives Editor.js's (editor: the placeholder and, when collab_autosave or collab_live
is asked and the form's record is stored, its field's room - token, version hash, the account signed in; collab_live only
where a relay is configured), the value with each picture's address made from its stored origin, and the five addresses of
the editor's routes (uploadByFile, uploadByUrl, endpointByUser, endpointByThread, endpointByKeyword); a document
sent without a block is read back as no text. Both widgets' ids have their dashes made underscores (a translation's fr-FR
part). The routes are Base\Field\Editor\EditorJs's (ROUTES, the ux_editorjs_* names): the mentions with the editor's
token (editorjs), five by page; a picture sent or fetched, stored in the editor's storage (base.twig.editor.storage); the
relay's ticket and one field's autosave with its room's own token (a stale version refused, 409). Symfony's
EditorController and omnibridge/laravel's answer them through it; the neutral FormView gives the behaviour the record and
the field's name (editor_record, editor_field).
Embedded forms, lists and attributes. A related record's form (association, an AssociationField that embeds) is
drawn on every host as AssociationType builds it: one record, its fields and its _index; several (a to-many association),
the collection _collection whose entries, by their index in the association, are each one record's form, with the
prototype of a new one where entries are added (__prototype__) and the window of a capped collection (max_entries,
entry_offset; the indexes drawn sent back as _rendered). Base\Field\Behaviour\Association holds what the type decides
(several records are never required, and nothing inside them; entries added and deleted only inside a group; the
collection's and an entry's options) and its fields (RecordFields, the field's fields, autoload, excluded_fields), the
association read through its alias (#[Alias]: a comment's replies); the neutral FormBinder binds each entry sent into its
record, a new one from the prototype told its owner, one not sent removed where the field deletes (an entry's own collection
of records is not written from it, as AssociationType leaves it). A list (collection, array) is
Base\Field\Behaviour\Collection: CollectionType's and ArrayType's defaults (a pattern's {n} counted as a fixed
length that neither adds nor deletes), the window, the empty markup, what is sent read as the list kept (an associative array's
{key, value} entries as key => value). A record's attributes (attribute) are Base\Field\Behaviour\Attribute: the
attribute's class and its adapters', the select of adapters (searched remotely, filter narrowing them), each attribute's part
named by its adapter's code and id; bound, an attribute kept for each adapter still chosen, a new one for an adapter newly
chosen. A translated attribute's value (its intl part, a TranslationType of several records) is drawn by Symfony's forms
only: omnibridge/laravel refuses such a field aloud once it holds one. Symfony's AssociationType, CollectionType,
ArrayType and AttributeType ask these helpers; a list of records written into the collection a record holds is written
in place by the neutral binder (the records no longer chosen out, the new ones in), as Symfony's forms do through adders.
Translations. A translations field (translation) is one part per language, each the translation entity's
fields, on every host: Base\Field\Behaviour\Translation holds what TranslationType decides (the languages drawn -
every offered one, or for single_locale the default one, the record's one language when it has exactly one -; which
part is required - the default and required_locales; locale_options per language; a callable required), and
Symfony's type asks it. The translation entity's fields are Base\Field\Behaviour\RecordFields::of(), what
ClassMetadataManipulator::getFields() lists from Doctrine read from the entity's shape: the fields named first, then
the other columns unless autoload is off, id, translatable, locale and excluded_fields left out, each with
the kind its column says (string a text, text a textarea, json or array a select of free tags, integer a number,
datetime a datetime picker, an enumeration a select of its values) or the one its form_type names. The neutral
FormView gives such a field a part per language (form[translations][fr-FR][title], ids
form_translations_fr-FR_title), the record's values in each, and TranslationType's vars (nonempty_locales,
compatible_locale...); FormBinder writes what is sent into the record's translations (translate()), a language
left empty is not kept. omnibridge/laravel draws it as translatable_widget does - a tab per language -, and refuses it
aloud when one of its fields is a kind it does not draw.
Conformance. Base\Field\Conformance\Suite runs Samples::all() (one sample per kind of Kind::all(), with
Symfony's values: the reference) on a Host - its name, render(), submit(): every kind declared, each
implemented one drawn under its name, its value shown, what is sent bound to the same value, a value it cannot
read refused; each unsupported one refused. A kind added to Kind without a sample fails Samples::all();
without a host's word, the host's suite. The hosts' suites: tests/Field/SymfonyConformanceTest (the harness,
Omnibridge\Symfony\Omnibase\Form\Conformance\SymfonyHost), omnibridge/laravel's tests/FieldConformanceTest
(Omnibridge\Laravel\Form\Conformance\LaravelHost), and an application's own.
Proposer une modification 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.phponly. The private key (prod.decrypt.private.php) goes to the server by hand, or as theSYMFONY_DECRYPTION_SECRETenvironment 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
- Generate the pairs (dev, test, prod) as above.
- 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. - Nothing to migrate in the schema: the
vaultcolumn and the fields keep their types.
Proposer une modification 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.twigfor a path under/adminwhen omnibase/admin is installed; the kernel gives the response the error's status.exception.html.twigextends the site'slayout3.html.twig, or itslayout1.html.twigwhen 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.)
File links
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 grantedBACKEND(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.
Proposer une modification 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
- Run the test suite.
- Build assets.
- Run migrations.
- 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_loopbackistrueby default: every e-mail goes to the technical recipient. Production sets it tofalse.base.site.indexnames the home page's route,base.uploader.storageandbase.twig.editor.storagethe 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) |
Proposer une modification 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.
The TimeMachine needs glitchr/backup-manager, which the core suggests and
no longer requires (since 2026-11-01): Base\Service\TimeMachine extends its
Manager. A site that backs up requires it at its root:
composer require glitchr/backup-manager:4.0.*-dev
Without it, neither the time_machine service nor the timemachine:* commands
are registered, and base.time_machine is read by nothing.
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\TimeMachineInterface::DEFAULT_EXCLUDES
(TimeMachine::DEFAULT_EXCLUDES as well, its implementation's; the interface loads without
glitchr/backup-manager):
./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.
Proposer une modification 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 omnisense/omnitrade, shipping through omnisense/omnibus and signing through
omnisense/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 fromtests/harness.php, which loads.envthen.env.test(as an application's owntests/bootstrap.phpdoes), then the suite's own bootstrap (it registers the suite's test namespace).docker/app/.env.testholds 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.
The agnostic core: bare --strict
The Foundation allows Doctrine, Twig and the standalone Symfony components
(decisions D1, D2, D4 of 2026-10-17). Since 2026-11-01 the goal is wider: the
core imposes neither Doctrine, nor Twig, nor Symfony beyond the components
kept (plan "omnibase agnostic", step F0). bare --strict measures that goal
the same way, by loading, on the same vendor without the framework; it refuses,
besides the framework:
- every Doctrine package but doctrine/collections (and doctrine/deprecations, which it reads): the ORM, DBAL, persistence, the event manager, common;
- Twig and its extras;
- the bridges to a host or a persistence: omnibridge/doctrine, twig, symfony
and laravel; Laravel itself (
Illuminate\); - every Symfony component outside the kept ones - string, uid, intl, translation, mime, mailer, notifier (not its transports), property-access, options-resolver, yaml, password-hasher, filesystem, finder, serializer, the contracts and the polyfills -: validator, cache, event-dispatcher, http-client (HTTP is a contract only), process, workflow, var-dumper, error-handler, asset, expression-language, css-selector, property-info...
Its list is tests/Agnostic.php: the classes of the Foundation that pass, none
of src/Bridge/ (the adapters, which move to the omnibridge/* packages). It
only grows, as the steps of the plan put the core's code on contracts; a class
leaves it only by a decision written in the plan and in CHANGELOG.md (its move
to a bridge is one). BareTest::testTheAgnosticCoreLoadsWithoutDoctrineNorTwig
is its guard, testTheStrictCheckNamesDoctrineAndTwig shows it is not blind
(an enum's DBAL parent and a Twig extension are named). --strict combines
with the other options: bare --strict --all --each says which classes of
src/ pass, bare --strict --names names the wider set.
The core alone: bare --alone (5.0)
Since 5.0 (plan "omnibase agnostic", step F6) the core requires the agnostic core's packages alone.
bare --install --alone makes var/bare-alone/vendor from the core's require and nothing else
(not extra.omnibase.bare, which var/bare adds: omniauth and its methods, glitchr/backup-manager
and omnibridge/doctrine), and bare --strict --alone loads tests/Agnostic.php there
(BareTest::testTheCoreAloneLoadsTheAgnosticCore, which also builds the services' list in plain PHP
on it). demo test makes both vendors before the core's suite. A class that stands on a package the
core only suggests (symfony/validator, workflow, asset; omniauth, glitchr/backup-manager on the
vendor alone) is reported as optional where that package is not installed, as src/Twig is without
Twig. --strict accepts doctrine/lexer, which egulias/email-validator reads when symfony/mime
checks an address. On 2026-11-03 (core 5.x): 741 agnostic classes on both vendors, the Foundation
802 (19 optional for symfony/validator, 35 for Twig, 1 each for workflow and asset).
On 2026-11-01 (core 4.x e156cfaf): 625 of the Foundation's 860 classes. What
keeps the others out, most often: Twig's AbstractExtension, doctrine/orm's
EntityRepository (the 81 repositories), the console, DBAL's Type (the 20
enums, through EnumType), the validator, the event dispatcher.
Twig is optional (step F4): twig/twig, its extras and omnibridge/twig are
suggestions of the core, so var/bare/vendor has no Twig. There bare reports a
class of src/Twig that cannot load for want of a class of Twig's (an extension,
the environment, the loader) as "optional": "twig/twig" - not a failure -; the
Twig-free classes of src/Twig and every other class are measured as before, and
none may need Twig (BareTest::testTwigIsOptional). On a vendor where Twig is
installed (OMNIBASE_AUTOLOAD, this run's) nothing is optional.
tests/View/helpers-without-twig.php runs the neutral view helpers (Base\View\*)
there under --strict (testTheViewHelpersWorkWithoutTwig). After step F4: 677 of
the Foundation's 889 classes (+16 helpers, and Base\Service\BaseService, which no
longer declares Twig's RuntimeExtensionInterface). After step F2c: 681 of 893; after step
F2d: 686 of 898 (Base\Database\UnitOfWorks, the backends RowLocks, ConditionalUpdates,
EntityCache, the Identities interface); after step F2e: 690 of 902 (the scopes
TrashScope and VaultScope, BulkDeletes, Pages).
The behaviours on both backends: compose.eloquent.yml (step F7c)
docker compose -f compose.yml -f compose.checkouts.yml -f compose.eloquent.yml run --rm omnibase test
The overlay mounts omnibridge/eloquent's checkout (${OMNIBRIDGE:-../../../omnibridge}/eloquent, with its own
vendor/) and sets OMNIBASE_ELOQUENT, the harness switch: each test using Tests\Base\Database\Backend\BothBackends
(OrderColumnLazyTest, SlugifyPerTypeTest, UploaderSplFileInfoTest, EmptyTrashTest, ThreadParticipantsTest,
BehaviourBackendsTest) runs once on Doctrine and once on omnibridge/eloquent, on the same database, the same calls
through omnisense/omnibridge's UnitOfWork. Without the overlay the Eloquent cases are skipped, saying so. In an
application's suite on base.persistence: eloquent they run on the application's own persistence instead (the switch
unneeded), and the tests writing what the code under test reads write through the persistence the host chose
(Tests\Base\Database\Backend\HostPersistence, used by HttpTestTrait - hostUser() - and DemoKernelTestCase); a test of Doctrine
itself says so and is skipped there. The package's
own behaviour parity (every row written on both) runs here too:
test --filter WriteParityTest /srv/omnibridge/eloquent/tests/Harness/WriteParityTest.php.
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 (omnisense/omnitrade 1.x with its catalogue,
omnitrade/stripe, omnisense/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.
Proposer une modification 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 (omnisense/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 des Exemples', 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
Proposer une modification 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.
Proposer une modification 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().
Proposer une modification 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 des Exemples","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 des Exemples', 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().
Proposer une modification 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).
Proposer une modification 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.
Proposer une modification 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.
Proposer une modification 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) implementsSpamProtectionInterface; the score decides the state (toComment($thread, $autoApprove)). - Robots:
Base\Form\Type\CommentTypeadds a trap field (url) and the time the form was opened (opened);Base\Service\CommentGuard::check($form, $request)answerstrapped(thank it, save nothing),too_fast,floodor 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).
Proposer une modification 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).
A page's language
Base\Subscriber\LocalizerSubscriber decides it on each main request, in this order
(LocalizerSubscriber::choose()):
- the page's address, when its route has one language (
/en/...,app_legal.de); - a signed-in member's own language (
User::getLocale(), which a choice of language through/locale/{_locale}updates) - but while impersonating, the impersonator's choice (LANG) first; - the language the visitor's last page was shown in, or the one they chose (the
LANGcookie, which each response keeps up to date); - on a first visit, the browser's (
Accept-Language, among the site's languages); - the site's (
framework.default_locale).
The request's, the translator's and the router's locale follow, and so does intl's default
(\Locale::setDefault()), which NumberFormatter and IntlDateFormatter use when they are given
no locale: an amount or a date prints in the page's language.
Proposer une modification 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 omnisense/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
omnisense/omnisign is suggested, not required. Without it nothing of this exists in a site:
Base\Service\Signaturesis not registered;Base\Entity\Signature\Envelopeis not an entity - nosignature_envelopetable, nothing in the site's schema, migrations or fixtures (its fields are those of the mapped superclassAbstractEnvelope, which has no table);POST /signatures/{gateway}/webhookanswers 404.
With it: composer require omnisense/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:SIGNEDwhen a signer signed, thenCOMPLETED,DECLINED,EXPIREDorCANCELED;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 omnisense/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.
Proposer une modification 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: 'My site'} %}
| 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:
- a player that starts sounding dispatches
media:playondocument, withdetail: { player, kind?, id?, … }-playeris any object that is this player and no other; - a player that hears
media:playfrom 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.
Proposer une modification 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.
Proposer une modification 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.
Proposer une modification 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
headerblock prints one only when there is one - thelogovariable of the context, else the back office's setting (base.settings.logo.email, thenbase.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.
Proposer une modification 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.
Proposer une modification 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
# The persistence: doctrine or eloquent (null: the only one installed; required when both are)
persistence: null
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 omnisense/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". |
persistence |
scalar | null |
The persistence the core's contracts answer from (plan "omnibase agnostic", step F7d): doctrine (omnibridge/doctrine) or eloquent (omnibridge/eloquent); null runs on the only one installed. With both installed it must name one (or the environment variable OMNIBRIDGE_PERSISTENCE): the container refuses to compile otherwise. Read when the container is compiled. omnibridge/eloquent's docs/hosts.md says what each host wires. |
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 |
Deprecated, no effect (see the note below). |
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. |
RouterSubscriberapplies the IP-address rule only whenisGranted('VALIDATE_IP', $route)is true; no voter of the core decidesVALIDATE_IP(onlyPermissionVotercould, for a user holding that permission tag), soip_accesshas no effect. It is deprecated rather than wired: wired, its default (false) would redirect every request that reaches a site by its IP address, health checks included.
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): read only
where glitchr/backup-manager is installed, a suggestion of the core.
| 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". |
demo, opening_hours, local_business, guard, signatures, comments, vault, download_links
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 ofconfig/services/),services-public.php,services-fix.phpandservices-decoration.php; - puts
twig.form_themesafterbase.twig.form_themesintwig.form.resources; - before every extension loads (
prepend()): setsbase.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 typesutc_datetime_immutable,utc_datetime, the passkey's columns and every type ofsrc/Enum(the package's and the application's) that has agetStaticName(); and, when symfony/ux-twig-component is installed, the component namespacesBase\Twig\Component\(templates@Base/components/, prefixbase) andApp\Twig\Component\(@App/components/, prefixapp); - 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) |
Proposer une modification 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.
Tasks and hosts (5.0)
Since 5.0 (plan "omnibase agnostic", step F5e, slice 3) the logic of translation:*, cache:clear,
cache:clear:sessions, demo:reset and uploader:* is a task of the core, Base\Console\Task\*: its
options as a readonly input, what it says in a Base\Console\Report (a PSR-3 logger plus text, a success block,
a table). omnibridge/symfony's command prints the Report on Symfony's console; omnibridge/laravel registers the same
tasks as artisan commands (php artisan translation:entities; cache:clear is omnibase:cache:clear there, Laravel
keeping its own). A script runs a task without a console:
$report = (new TranslationEntities($translator, $localizer, $locales))->run(new TranslationEntitiesInput('src'));
echo $report->text(); // plain, as --no-ansi prints it
exit($report->status());
What a task needs of its host is given to it: the host's clear for CacheClear (a closure), its other commands
for DemoReset (Base\Console\Commands), its routes for TranslationControllers.
Since slice 3b every command of the list below is a task: Base\Console\Task\*, or, for the three Doctrine-typed
ones (doctrine:database:import, doctrine:schema:charset, doctrine:array:upgrade), omnibridge/doctrine's
Omnibridge\Doctrine\Omnibase\Console\Task\*. A question the command asks is the host's, given to the task as a
closure: the confirmations of timemachine:snapshot:backup and :restore, the file, the sheets and the confirmation
of doctrine:database:import (and its progress bar), the transport base:mailer:trap release sends through.
Under Laravel, artisan has translation:*, omnibase:cache:clear, cache:clear:sessions, demo:reset,
uploader:*, base:mapping, icon:enums, icon:entities, autocomplete:entities, entity:discriminator,
app:notifier, notifier:mail:test, user:notifications, thread:publishable, trash:purge and user:create.
These stay Symfony's: base:mailer:trap (the trap holds Symfony Mailer's messages at send time and releases them on
mailer.transports; Laravel's mailer never consults it), timemachine:* (the TimeMachine is built on DoctrineBundle's
registry and Symfony's console output, and only the Symfony container registers it), doctrine:* (upgrades and
imports of the database the Symfony site owns: lesringostars' twin reads that database and changes neither its schema
nor its rows in bulk).
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) |
omnibridge:schema:create |
Creates the installed entities' tables in an empty database from their description, no ORM (--dump-sql; Symfony's console, artisan, or vendor/bin/omnibridge-schema-create in plain PHP: The data structure) |
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:
- It takes a lock,
var/cache/<env>/.lock(a lock file older than 120 seconds is taken as stale and removed). When anothercache:clearholds it, it prints "Cache clear aborted: another process is already clearing the cache." and exits 0. - 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), aphpinfopage and the public symlinks are made or checked - all before the cache goes, while the services they read can still be built. - Symfony's
cache:clearruns, with the same options. - A counter of clears is kept in
var/cache/<env>.txt;--sessionsthen runscache:clear:sessions. - Unless
--no-extension: on the first clear of an installation and withbase.autoclear,cache:clearruns 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?
Registered only where glitchr/backup-manager is installed, a suggestion of the core (Backups).
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:snapshotstops on "Could not find fieldserverVersionin configuration for connection typedefault": 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.
Proposer une modification 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).
The mentions it mails are stored at flush (Base\Database\Lifecycle\ThreadParticipants,
since 2026-11-02): each user a thread's text mentions (its <mention> tags, every
translation) has one Mention, added or removed as the text changes; a reply is
followed by its parent's owners. A thread written before then gets its mentions at
the next save that changes its text, or at the flush that publishes it: it mails its
mentionees once, at its publication.
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).
Proposer une modification 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.
Optional, over neutral helpers
Twig is the application's choice, not the core's (plan "omnibase agnostic", step F4).
twig/twig, its extras and omnibridge/twig are suggestions of the core: where Twig is
installed, config/services/twig.php registers everything on this page (and
services-public.php, services-decoration.php, services-fix.php their Twig part); where it
is not, nothing of src/Twig is registered or loaded, and the rest of the core loads and
works the same (BareTest::testTwigIsOptional(), on the vendor without Twig). No class
outside src/Twig names Base\Twig\ but the three Symfony host classes of the layer
(TwigSubscriber, WebpackCacheWarmer, FormTypeWebpackExtension, which leave for
omnibridge/symfony at F5c): tests/View/TwigIsOptionalTest.php is the guard.
The logic of the functions and filters is in neutral helpers, Base\View\*, which name
no Twig type; each extension is a thin wrapper over them, under the same names, so no
template changes. A Blade view (omnibridge/laravel) or a React page's props call the same
helpers:
| Helper | What it gives | The Twig names over it |
|---|---|---|
Formats (static) |
highlight, mailto, mailparse, truncate, pickup, colour names, preg_split, pad, short class names, static calls and properties, property paths |
highlight, mailto, mailparse_email, mailparse_name, pickup, color_name, preg_split, pad, transforms, nargs, instanceof, is_linkable, method_exists, static_call, static_property, property_accessor, greater_than, get_short_class |
Captions |
a controller's title and excerpt, a value as text | title(), excerpt(), stringify |
Dates |
a moment as a pattern says it, by PHP's intl as twig/intl-extra formats it | datetime (in Twig's time zone) |
Money |
an amount in the visitor's currency, by PHP's intl as twig/intl-extra formats it | format_currency, apply_currency_rate |
Pagination |
a pagination's links, and what its template is given | paginator(), paginator_*() |
Links |
a link to an address or a linkable record, a record's address | urlify, linkify |
Enums |
an enumeration by class or type name, its colours, a value's words | enum(), colorify, trans_enum, trans_enumExists |
Menus |
the language, theme (modes, filters, widths), time zone and country menus as data | render_locale(), render_theme_*(), render_timezone() (Twig renders the dropdowns) |
Bell |
the unread count, the latest notifications, the token, push | notifications_*(), push_available(), push_vapid_key(), notification_admin_url() |
Pictures |
a stored picture's address, crop and thumbnails in a view's context (an e-mail's) | image(), crop, thumbnail* |
Commons |
embeds, QR codes, opening hours, a stored picture's URL (the screens' Pictures contract) | embed_url(), qr_code(), opening_hours(), picture |
Wysiwyg |
a stored text rendered, its headings (the screens' Texts contract) | wysiwyg, wysiwyg_toc (through HtmlTwigExtension) |
Demo |
the demonstration's mode and accounts | demo_mode(), demo_accounts() |
DeployVersion |
the deploy's version | deploy_version() |
Widgets |
a slot's widget, its template and options | render_slot() (Twig renders it) |
AssetPaths (contract) |
an asset's path as the page links it | omnibase's Base\Twig\Environment answers it |
Twig-only by nature, as they are Twig: what renders a template the core ships (crudify,
htmlify, countdown, progress, clipboard(), render_breadcrumb(), share), what reads
Twig's loader or environment (embed, inline_css_email, call_user_func_if_exists()), the
Symfony form's (form_proxy(), form_view()), the tag renderers' (html_entry_*(),
encore_entry_*()), the globals (app, security_policy) and the variables. The services that
already were the logic (the translator's trans*, the Localizer's and the Themizer's filters,
iconify, semantify, local_business(), analytics_*(), the Texts contract) are called
directly by their extensions, as before.
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 |
Texts and links
| 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
Proposer une modification 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 |
Proposer une modification 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.
Proposer une modification docs/50-reference/index.md
Routes and access
Importing the routes
The core's controllers carry their routes as attributes. A site imports them
through omnibridge/symfony's loader (type: omnibase, since 5.0), beside its own: it reads
omnibridge/symfony's Symfony controllers (Omnibridge\Symfony\Omnibase\Controller\*) and the
core's actions written once for every host (src/Controller/Action, SignatureController).
In 4.x the import was resource: "@BaseBundle/Controller", type: attribute.
# config/routes.yaml
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
base_controller:
resource: .
type: omnibase
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 |
Pages, widgets, short links
| 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 (omnibridge/symfony's config/omnibase/services/database.php).
Since 5.0 (plan "omnibase agnostic", step F5e) each is an adapter of a policy of the core,
Base\Auth\Policy\*, which holds its rule: Access, Maintenance and Launch (AccessVoter),
Admin (AdminVoter), Permission (PermissionVoter), Complaint (ComplaintVoter). The
restriction a request meets (SecuritySubscriber) is Restriction, the word of welcome after a
sign-in Welcome. omnibridge/laravel's gate (Auth\Policies) and its AccessRestriction
middleware ask the same policies, so both hosts take the same decisions.
The request rules
The rules a request meets beside the voters are the core's too, Base\Request\Rule\*, each returning a
Base\Request\Decision that the host applies (plan "omnibase agnostic", step F5e, slice 2):
| Rule | What it decides | Symfony | Laravel |
|---|---|---|---|
AccessControl, EntryPoint |
the pages kept for some accounts (access_control); a refused visitor sent to the sign-in, the address kept |
the firewall; LoginFormAuthenticator::start() |
Http\AccessControl (omnibridge.access_control) |
MaintenancePage, LaunchPage |
the maintenance and launch pages | SecuritySubscriber |
Auth\Middleware\SitePages |
Accounts: Impersonation, Kicked, Presence, Verification, Approval |
the account signed in: its reminder, its kick, its presence, its verification's notice, its approval | SecuritySubscriber |
Auth\Middleware\AccountRules |
Locale |
the page's language, LANG, the time zone | LocalizerSubscriber |
Http\Localize |
Redirection |
an address not found led to the site's redirection | RedirectionSubscriber (through Redirections) |
the 404's renderer (Omnibase\Redirections) |
Integrity |
the session's secret and connection | IntegritySubscriber (registered by nothing) |
- |
Analytics |
the back office's figures | AnalyticsSubscriber |
- |
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_MODERATORor hold the permission tagCOMPLAINT.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.
Proposer une modification 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()(HTTPSon, orX-Forwarded-Proto: httpsfrom an address withinframework.trusted_proxies), then, withoutHTTPS, the variables the core always read (USE_HTTPS,REQUEST_SCHEME,X-Forwarded-Proto). It is the functionget_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 setsHTTPS(orUSE_HTTPS) to a false value (off,false,0) in its environment. PHP's globals hold no scheme in a console, andget_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()replaceshttp://andhttps://by it); - which declared ports the host fallbacks are filtered against
(
getFallbackParameters():HTTPS_PORTon https,HTTP_PORTon http), whether a fallback port is the scheme's default (getPortFallback()), and the schemegetPort()judges by when the address has none; - the scheme of the address a request outside the allowed IPs is sent to
(
RouterSubscriber), the hostgetUrl()strips from an address asked as a path, andapp.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),
a site's tests on 8081 - the router now says http:
- an address of
_profileror_wdtis http, as the page is; - the fallbacks' ports of an http request are filtered by
HTTP_PORT, asgetPort()already judged the visited port: that site's (HTTP_PORT='[8081]', noHTTPS_PORT) is 8081, whereHTTPS_PORT's[443]made it 443 and the IP restriction sent tohttps://localhost/; AdvancedUrlGenerator::generate()no longer forces an absolute address whenHTTPSis 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.testsetsHTTPS=on, the client askshttp://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.