The Symfony host of omnisense/omnibridge's contracts, and the Symfony side of omnisense/omnibase: the adapters of the host's contracts over Symfony's own services (router, translator, security, CSRF, kernel), their container wiring, and the framework packages the core no longer requires. The mirror of omnibridge/laravel for Laravel.
A Symfony site requires it beside the core:
composer require omnisense/omnibase omnibridge/symfony
omnibase's BaseBundle refuses to start without it. A Laravel site does not require it: it requires
omnibridge/laravel instead. A Symfony application without omnibase registers OmnibridgeBundle
(Installation).
Beside the contracts, the package wires four families of the Omnisense catalogue into Symfony when they are installed (each only suggested):
| Namespace | Family | What it wires | Configured by |
|---|---|---|---|
Omnibridge\Symfony\Auth\ |
omnisense/omniauth | one Symfony authenticator for every way of signing in, the second factor's token, voter, gate and page, the ceremonies' routes | config/packages/omniauth.yaml (Signing in) |
Omnibridge\Symfony\Map\ |
omnisense/omnimap | the map providers, the map cache, the Twig functions, the cache's routes and controller | config/packages/omnimap.yaml (Maps) |
(config/services.php) |
omnisense/omnidrive | Omnidrive\Drives over the application's Flysystem storages |
nothing: the storages' own configuration (Storages and static export) |
Omnibridge\Symfony\Static\ |
omnibridge/static | the console command omnibridge:static:build |
nothing (Storages and static export) |
The contracts
Each adapter is bound under its contract's name and reached through Omnibridge\Runtime
(Runtime::urls()...) or by autowiring the contract.
Contract (Omnibridge\Contract\) |
Symfony (Omnibridge\Symfony\) |
Laravel (Omnibridge\Laravel\) |
Answers |
|---|---|---|---|
Urls |
RouterUrls |
RouterUrls |
the router's path()/url(), the asset packages' asset(); generate() for any reference type |
Translations |
TranslatorTranslations |
CatalogueTranslations |
the translator's trans(); exists() by its transExists() or its catalogue |
Environment |
Contract\KernelEnvironment |
Contract\AppEnvironment |
kernel.environment, kernel.debug, kernel.secret |
Access |
Contract\SecurityAccess |
Contract\GateAccess |
the authorization checker, the token's user |
Sessions |
Contract\SecuritySessions |
Contract\GuardSessions |
the token forgotten, the remember-me cookie cleared (its domain asked of a closure) |
Tokens |
Contract\CsrfTokens |
Contract\SessionTokens |
the CSRF token manager's token of an intent |
| PSR-20 clock, PSR-14 events | the framework's own services | Contract\DateClock, Contract\DispatcherEvents |
listed when the container has them |
Locales, Files, Routes |
not bound here (see below) | Contract\AppLocales, Contract\DiskFiles, Contract\RouterRoutes |
Locales, Files and Routes have no generic Symfony answer: an application's languages, storages
and route names are its own. omnibase binds them to its Localizer, its Flysystem storages and its
router (Base\Bridge\Symfony\*, below). Laravel's bridge binds them, and omnibase's own contracts,
itself, because Laravel has none of omnibase's services.
The pages' Twig functions on the contracts: omnibridge/twig's AccessExtension (is_granted() on
Access, csrf_token() on Tokens) is registered here, after twig-bridge's SecurityExtension and
CsrfExtension (TwigBundle adds the bridge's extensions first), so a page asks the same code under
Symfony as under Laravel (omnibridge/laravel's View\Twig), answered by the same services. Without
SecurityBundle or CSRF protection the contract is missing and its function left out.
Also here: FileLinks, the links of the error pages and the profiler mapped from the container to
the developer's machine (APP_PATH), Symfony's own FileLinkFormatter on a mapped format
(Contracts).
The wiring
config/services.php(OmnibridgeBundle::SERVICES): the six adapters, each aliased by its contract's name and taggedomnibridge.adapter.DependencyInjection\ContractsPass: the service locatorOmnibridge\Runtimereads (omnibridge.contracts), of the contracts whose service exists; an adapter whose framework service is missing (no SecurityBundle, no CSRF protection) is removed with the aliases that lead to it, so such a container still compiles. An application may run it with its own list, former names and id.OmnibridgeBundle, for a Symfony application without omnibase: imports the services, adds the pass, hands the locator toRuntimeat boot and takes it back at shutdown.
An omnibase site does not register OmnibridgeBundle: omnibase's BaseBundle imports the same
config/services.php (giving RouterUrls and SecuritySessions omnibase's router), runs the same
pass with omnibase's own contracts added (Nouns, Media, Pictures, Texts, the parameters, the mailer)
and seeds the registry itself.
What moved from omnibase, what stayed
Plan "omnibase agnostique", step A1. The rule: an adapter moved when it adapts a contract to Symfony's own services and nothing of omnibase; it stayed when it reads omnibase's own services or models. This package does not require omnisense/omnibase, so nothing is circular: the core's Symfony bundle stands on this package, never the reverse.
Moved:
| Former name | Now | Why |
|---|---|---|
Base\Bridge\Symfony\RouterUrls |
Omnibridge\Symfony\RouterUrls |
a URL generator and asset packages, any Symfony router |
Base\Bridge\Symfony\TranslatorTranslations |
Omnibridge\Symfony\TranslatorTranslations |
Symfony's translator contracts; omnibase's transExists() read when present |
Base\Bridge\Symfony\KernelEnvironment |
Omnibridge\Symfony\Contract\KernelEnvironment |
three kernel parameters |
Base\Bridge\Symfony\SecurityAccess |
Omnibridge\Symfony\Contract\SecurityAccess |
Security's checker and token storage |
Base\Bridge\Symfony\SecuritySessions |
Omnibridge\Symfony\Contract\SecuritySessions |
the token storage and the cookie; omnibase's router now a closure for the domain |
Base\Bridge\Symfony\CsrfTokens |
Omnibridge\Symfony\Contract\CsrfTokens |
the CSRF token manager |
Base\Bridge\Symfony\FileLinks |
Omnibridge\Symfony\FileLinks |
ErrorHandler's formatter, APP_PATH |
Base\DependencyInjection\Compiler\Pass\ContractsPass (its building) |
Omnibridge\Symfony\DependencyInjection\ContractsPass |
a locator of contracts; omnibase's pass keeps its list and delegates |
The former names were aliases of the new classes (declared by omnibase's src/Resources/Aliases.php,
their service ids aliases of the new ones) until omnibase 4's final pass, which removed them
(omnibase commit 02682623): name the classes of the "Now" column.
Stayed in the core, Base\ (omnibase's domain under Symfony, D3):
| Class | Why |
|---|---|
Base\Bridge\Symfony\LocalizerLocales |
omnibase's Localizer (its xx-XX locales, its fallbacks) |
Base\Bridge\Symfony\FlysystemFiles |
omnibase's Flysystem service and MediaService's image route |
Base\Bridge\Symfony\RouterRoutes |
omnibase's router (localized route names) and IconProvider |
Base\Bridge\Symfony\MediaPictures, ServiceMedia, WysiwygTexts |
omnibase's own contracts over its Twig extensions and MediaService |
Base\Bridge\Symfony\SymfonyScreenContext, ScreenResponder, AccountScreens |
omnibase's screens (Base\Screen), its settings, Themizer, account builder |
Base\Bridge\Symfony\NotificationMessages |
the Symfony half of omnibase's Notification entity |
Base\Bridge\Symfony\SettingBagWarmer |
omnibase's SettingBag |
Base\Bridge\Symfony\SecurityUser, TwoFactorUser |
what omnibase's User declares to Security and scheb/2fa (C7b) |
Base\Service\ParameterBag, HotParameterBag |
omnibase's parameters, the settings stored in its database over the container's |
Base\BaseBundle, its extension, configuration and compiler passes |
omnibase's bundle |
| forms, fields, controllers, subscribers | Symfony by nature, omnibase's (D3) |
The framework packages
composer.json requires what omnibase's Symfony side needs, so that the core does not: PHP 8.2 or later, omnisense/omnibridge and omnibridge/twig 1.x, Symfony 6, 7 or 8 (config, console, dependency-injection, doctrine-bridge, dotenv, error-handler, form, framework-bundle, http-foundation, http-kernel, messenger, routing, security-bundle, security-core, security-csrf, security-http, twig-bridge, twig-bundle), doctrine/doctrine-bundle 2 or 3, symfony/monolog-bundle 3.8 or 4, twig/extra-bundle 3. The suite runs on Symfony 8.1 and PHP 8.4.
How this manual is organised
- Installation: with omnibase, without it, the bundle's lifecycle.
- Contracts: each adapter, the services and their ids,
ContractsPass, the Twig functions,FileLinks. - Signing in:
Omnibridge\Symfony\Auth\- the fullomniauthconfiguration tree, the services, the authenticator, the second factor, the routes. - Maps:
Omnibridge\Symfony\Map\- the fullomnimapconfiguration tree, the services, the Twig functions, the cache's routes and controller. - Storages and static export:
Omnidrive\Drives,omnibridge:static:build. - Errors, Testing, Verified, and not.
Licence
MIT since 2026-10-09; earlier versions remain published under LGPL-3.0-or-later.
Propose a change docs/index.md
Installation
Requirements
- PHP 8.2 or later.
- Symfony 6, 7 or 8: the components listed in The framework packages, which Composer installs with the package.
symfony/asset(suggested):RouterUrls::asset()goes through the application's asset packages; without it, the path is answered as it is.symfony/clock(6.2 or later) for a PSR-20 clock in the registry; without itRuntime::clock()is omnibridge'sSystemClock.
The optional families: omnisense/omniauth (with omniauth/otp, omniauth/webauthn,
omniauth/google for TOTP, passkeys and Google), omnisense/omnimap (with omnimap/google,
omnimap/openstreetmap), omnisense/omnidrive, omnibridge/static. Each is wired only when its
classes exist.
On an omnisense/omnibase site
composer require omnisense/omnibase omnibridge/symfony
Nothing to register: omnibase's BaseBundle imports this package's config/services.php, runs
ContractsPass with omnibase's own contracts added (locator base.contracts), registers the
omniauth extension (OmniauthExtension::register()) and seeds Omnibridge\Runtime itself. Do not
add OmnibridgeBundle to config/bundles.php there.
In a Symfony application without omnibase
composer require omnibridge/symfony
There is no Flex recipe: add the bundle by hand.
// config/bundles.php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
// ...
Omnibridge\Symfony\OmnibridgeBundle::class => ['all' => true],
];
OmnibridgeBundle (an AbstractBundle) has no configuration of its own (no omnibridge: key):
| Moment | What it does |
|---|---|
loadExtension() |
imports config/services.php (OmnibridgeBundle::SERVICES) |
build() |
adds ContractsPass (default list, locator omnibridge.contracts); registers the omniauth extension when Omniauth\Omniauth exists and the omnimap extension when Omnimap\Registry exists |
boot() |
Runtime::setContainer() with the locator omnibridge.contracts |
shutdown() |
Runtime::setContainer(null), when the registry still holds this kernel's locator |
The bundle order does not matter for the contracts: the pass runs at compile time, after every bundle has loaded. For the Twig functions, TwigBundle must be registered (it is required).
A run of the package's test kernel (tests/Fixtures/TestKernel: FrameworkBundle, SecurityBundle,
TwigBundle, OmnibridgeBundle), booted with and without SecurityBundle in the package's image
(PHP 8.4, Symfony 8.1.9, 2026-10-27):
<?php
require 'vendor/autoload.php';
use Omnibridge\Contract\Access;
use Omnibridge\Contract\Tokens;
use Omnibridge\Runtime;
use Omnibridge\Symfony\DependencyInjection\ContractsPass;
use Omnibridge\Symfony\Tests\Fixtures\TestKernel;
foreach ([true, false] as $security) {
$kernel = new TestKernel('test', true, $security);
$kernel->boot();
$locator = $kernel->getContainer()->get(ContractsPass::SERVICE);
echo $security ? "With SecurityBundle\n" : "Without SecurityBundle\n";
echo ' locator: ', implode(', ', array_map(static fn ($id) => substr($id, strrpos($id, '\\') + 1), array_keys($locator->getProvidedServices()))), "\n";
echo ' path: ', Runtime::urls()->path('page', ['slug' => 'le-fuji']), "\n";
echo ' environment: ', Runtime::environment()->name(), ', debug ', var_export(Runtime::environment()->isDebug(), true), "\n";
echo ' Access: ', Runtime::has(Access::class) ? get_class(Runtime::access()) : 'none', "\n";
echo ' Tokens: ', Runtime::has(Tokens::class) ? get_class(Runtime::tokens()) : 'none', "\n";
echo ' clock: ', get_class(Runtime::clock()), "\n";
$kernel->shutdown();
echo ' after shutdown, container: ', null === Runtime::getContainer() ? 'none' : 'kept', "\n";
}
With SecurityBundle
locator: Urls, Environment, Translations, Access, Sessions, Tokens, ClockInterface, EventDispatcherInterface
path: /page/le-fuji
environment: test, debug true
Access: Omnibridge\Symfony\Contract\SecurityAccess
Tokens: Omnibridge\Symfony\Contract\CsrfTokens
clock: Symfony\Component\Clock\Clock
after shutdown, container: none
Without SecurityBundle
locator: Urls, Environment, Translations, ClockInterface, EventDispatcherInterface
path: /page/le-fuji
environment: test, debug true
Access: none
Tokens: none
clock: Symfony\Component\Clock\Clock
after shutdown, container: none
Without SecurityBundle (and with csrf_protection: false), SecurityAccess, SecuritySessions and
CsrfTokens are removed at compile time: the registry has no Access, Sessions nor Tokens, and
Runtime::access() would throw MissingContractException. Locales, Files and Routes are never
in this locator: bind them yourself (Contracts).
In a service
Every contract is an alias of its adapter, so a service autowires it:
use Omnibridge\Contract\Urls;
final class Mailer
{
public function __construct(private readonly Urls $urls) {}
public function link(string $slug): string
{
return $this->urls->url('article', ['slug' => $slug]);
}
}
The aliases are private, as Symfony's default: a service gets them by injection, not by
$container->get(). Omnibridge\Runtime reaches them through the public locator.
Propose a change docs/installation.md
Contracts
The adapters of omnisense/omnibridge's contracts over Symfony's services, defined in
config/services.php (OmnibridgeBundle::SERVICES). What each contract means is in the core's
manual (Contracts);
this page says how Symfony answers.
The services
| Service id (class) | Alias | Arguments | Tags |
|---|---|---|---|
Omnibridge\Symfony\RouterUrls |
Omnibridge\Contract\Urls |
router, assets.packages (null when absent) |
omnibridge.adapter |
Omnibridge\Symfony\TranslatorTranslations |
Omnibridge\Contract\Translations |
translator |
omnibridge.adapter |
Omnibridge\Symfony\Contract\KernelEnvironment |
Omnibridge\Contract\Environment |
%kernel.environment%, %kernel.debug%, %kernel.secret% |
omnibridge.adapter |
Omnibridge\Symfony\Contract\SecurityAccess |
Omnibridge\Contract\Access |
security.authorization_checker, security.token_storage |
omnibridge.adapter |
Omnibridge\Symfony\Contract\SecuritySessions |
Omnibridge\Contract\Sessions |
security.token_storage |
omnibridge.adapter |
Omnibridge\Symfony\Contract\CsrfTokens |
Omnibridge\Contract\Tokens |
security.csrf.token_manager |
omnibridge.adapter |
Omnibridge\Twig\AccessExtension |
- | Omnibridge\Contract\Access, Omnibridge\Contract\Tokens (each null when absent) |
twig.extension |
Omnidrive\Flysystem\OperatorDrives |
Omnidrive\Drives (public) |
a locator of the services tagged flysystem.storage, by their storage attribute |
- (only when omnidrive is installed) |
Omnibridge\Symfony\Static\BuildCommand |
- | kernel, router |
console.command (only when omnibridge/static is installed) |
omnibridge.contracts (ServiceLocator, public) |
- | the contracts found, by interface name | container.service_locator (made by ContractsPass) |
An application rebinds a contract by aliasing its interface again (services.yaml:
Omnibridge\Contract\Urls: '@App\MyUrls'): the locator takes whatever service the name leads to.
The adapters
RouterUrls
path() is the router's generate() with ABSOLUTE_PATH, url() with ABSOLUTE_URL - the same
strings Twig's path() and url() print. asset() is Packages::getUrl(), the path unchanged when
symfony/asset is not installed. Beyond the contract, generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH) gives any of the router's reference types
(a relative path, a network path): omnibase's Base\Contract\Link::to() asks it. An unknown route is
the router's RouteNotFoundException. On an omnibase site the router argument is omnibase's
advanced_router.
TranslatorTranslations
trans() is the translator's trans() with the same four arguments - what Twig's |trans answers.
exists():
- the translator's own
transExists($id, $domain, $locale)when it has one (omnibase's translator: the locale's catalogue and its fallbacks); - otherwise, when the translator is a
TranslatorBagInterface(Symfony's is), its catalogue of the locale:getCatalogue($locale)->has($id, $domain ?? 'messages'), which includes the fallback catalogues. An id written@domain.keyis read as the keykeyof the domaindomain; - otherwise false.
KernelEnvironment
The three kernel parameters, read once at compile time. secret() is kernel.secret
(framework.secret), marked #[\SensitiveParameter] in the constructor so a stack trace does not
print it.
SecurityAccess
isGranted() is the authorization checker's (the role hierarchy, the application's voters, the
access decision strategy); getUser() the token storage's token's user, null without a token.
SecuritySessions
signOut($user) answers false when no token is stored or when its user is not the very object given
(!==). Otherwise it empties the token storage and expires the REMEMBERME cookie twice with PHP's
setcookie(): once with no path nor domain, once on / and the domain the constructor's closure
answers (omnibase gives its router's domain; empty without a closure). The cookies are sent with the
response's headers, outside Symfony's Response.
CsrfTokens
csrf($intent) is the token manager's getToken($intent)->getValue() - what Twig's csrf_token()
prints; isValid($intent, $token) is isTokenValid(new CsrfToken($intent, $token)), false for a
null or empty token without asking.
ContractsPass
Omnibridge\Symfony\DependencyInjection\ContractsPass builds the locator the registry reads. Not
final: an application's bundle runs it with its own list.
new ContractsPass(
contracts: ContractsPass::CONTRACTS, // the names the locator answers
services: [], // a contract => the service id it is bound to, when not its own name
former: [], // a former name => the contract it is listed as (the same service)
id: ContractsPass::SERVICE, // the locator's id, 'omnibridge.contracts'
);
| Constant | Value |
|---|---|
SERVICE |
omnibridge.contracts |
ADAPTER |
omnibridge.adapter |
CONTRACTS |
Urls, Routes, Environment, Translations, Access, Sessions, Locales, Files, Tokens (all Omnibridge\Contract\), Psr\Clock\ClockInterface, Psr\EventDispatcher\EventDispatcherInterface |
What process() does:
- Each service tagged
omnibridge.adapterwhose constructor needs a service that does not exist (aReferencethat must be valid, in its arguments or in an inline definition among them) is removed, with every alias that leads to it, through other aliases too. SoSecurityAccessgoes without SecurityBundle,CsrfTokenswithoutframework.csrf_protection. - Each contract of the list whose id (its own name, or the one
servicesgives) exists in the container is put in the locator.Routes,LocalesandFilesare in the list but bound by nobody here: they appear when the application (or omnibase) binds them. The clock appears when symfony/clock aliasesPsr\Clock\ClockInterface(FrameworkBundle does when the component is installed); the dispatcher whenPsr\EventDispatcher\EventDispatcherInterfaceis aliased (FrameworkBundle does). - Each former name is added to the locator with its contract's service.
- The locator is registered public, tagged
container.service_locator: nothing is constructed before a contract is asked.
omnibase's BaseBundle runs it with omnibase's list (its Nouns, Media, Pictures, Texts,
the parameters, the mailer added) and the id base.contracts.
Binding what is not bound here
Locales, Files and Routes depend on how an application names its languages, its storages and
its routes. Without omnibase, bind them yourself; the locator picks them up by their names:
// src/Bridge/RequestLocales.php
namespace App\Bridge;
use Omnibridge\Contract\Locales;
use Symfony\Component\HttpFoundation\RequestStack;
final class RequestLocales implements Locales
{
public function __construct(private readonly RequestStack $requests) {}
public function getLocale(): string
{
return str_replace('_', '-', $this->requests->getCurrentRequest()?->getLocale() ?? 'fr-FR');
}
public function getDefaultLocale(): string { return 'fr-FR'; }
public function getAvailableLocales(): array { return ['fr-FR', 'en-GB']; }
}
# config/services.yaml
services:
App\Bridge\RequestLocales: ~
Omnibridge\Contract\Locales: '@App\Bridge\RequestLocales'
The Twig functions
Omnibridge\Twig\AccessExtension (omnibridge/twig) is registered with the contracts it finds:
| Function | Answered by | Left out when |
|---|---|---|
is_granted(attribute, subject = null) |
Access::isGranted() |
no Access (no SecurityBundle) |
csrf_token(intent) |
Tokens::csrf() |
no Tokens (no CSRF protection) |
TwigBundle registers twig-bridge's extensions first, so these two functions replace twig-bridge's
is_granted and csrf_token of the same names, answered by the same Symfony services: a template
behaves as it did, and asks the same code it asks under Laravel. path(), url(), asset() and
|trans stay twig-bridge's: omnibridge/twig's ContractExtension is not registered under Symfony.
The map functions are registered with omnimap (Maps).
FileLinks
Omnibridge\Symfony\FileLinks makes the links of the error pages and the profiler 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) against kernel.project_dir (its path in the
container), with Symfony's own means - FileLinkFormatter reads a mapping after the format,
vscode://file/%f:%l&/srv/app/>/Users/me/site/.
| Static method | What it does |
|---|---|
format(?string $ide, string $projectDir, ?string $hostDir): ?string |
the link format with the container-to-host mapping appended; $ide unchanged (null for none) when there is nothing to map (no host path, the same path, or a format that already maps). A name of ErrorRendererInterface::IDE_LINK_FORMATS (vscode, phpstorm...) is expanded first; no format at all is FileLinks::DEFAULT_FORMAT, vscode://file/%f:%l. The former syntax vscode://file/%f[%b:/host/path]:%l is read as the mapping |
formatter(?FileLinkFormatter $inner, ?string $ide, string $projectDir, ?string $hostDir, ?RequestStack $requestStack = null): ?FileLinkFormatter |
the factory of a decoration of debug.file_link_formatter: a FileLinkFormatter on the mapped format (with the profiler's /_profiler/open?file=%f&line=%l#line%l for a file without a format), or $inner when nothing maps |
export(?string $projectDir = null): void |
sets $_SERVER['SYMFONY_IDE'] and $_ENV['SYMFONY_IDE'] to the mapped format when APP_PATH is set and SYMFONY_IDE is not; the project directory defaults to Composer's root package's install path |
FrameworkBundle's framework.ide defaults, in debug, to %env(default::SYMFONY_IDE)%: export()
called before the kernel boots maps every link Symfony makes, including ErrorHandler's renderer for an
error before the container is built. omnibase's BaseBundle calls FileLinks::export() in its
constructor. OmnibridgeBundle calls neither method and registers no decoration: without omnibase,
call export() early (in public/index.php and bin/console, after the autoloader), or decorate
the formatter:
# config/services.yaml (dev)
services:
app.file_link_formatter:
class: Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter
decorates: debug.file_link_formatter
factory: ['Omnibridge\Symfony\FileLinks', 'formatter']
arguments: ['@.inner', '%env(default::SYMFONY_IDE)%', '%kernel.project_dir%', '%env(default::APP_PATH)%', '@request_stack']
Run in the package's image (a dev kernel with FrameworkBundle, TwigBundle and OmnibridgeBundle,
kernel.project_dir /srv/app, APP_PATH=/Users/me/site, 2026-10-27), the decorated formatter and
format():
$kernel->getContainer()->get('app.file_link_formatter')->format('/srv/app/src/Kernel.php', 12);
FileLinks::format(null, '/srv/app', '/Users/me/site');
FileLinks::format('phpstorm', '/srv/app', '/Users/me/site');
FileLinks::format('vscode://file/%f[%b:/Users/me/other]:%l', '/srv/app', null);
vscode://file//Users/me/site/src/Kernel.php:12
vscode://file/%f:%l&/srv/app/>/Users/me/site/
phpstorm://open?file=%f&line=%l&/srv/app/>/Users/me/site/
vscode://file/%f:%l&/srv/app/>/Users/me/other/
Propose a change docs/contracts.md
Signing in (omniauth)
Where omnisense/omniauth is installed (it is only
suggested), OmnibridgeBundle registers omniauth's extension
(Auth\DependencyInjection\OmniauthExtension; an omnibase site's BaseBundle calls
OmniauthExtension::register() too). With no config/packages/omniauth.yaml, nothing is wired. With
one, every way of signing in goes through omniauth's conductor and one Symfony authenticator.
This page is the reference of the Symfony side: the configuration tree, the services, the routes, what the authenticator does with each request. What each method does (a password, a sign-in link, an e-mailed code, backup codes, TOTP, passkeys, identity providers), its stores and events are omniauth's own manual: omniauth's Symfony page and its method pages.
Setting it up
# config/packages/security.yaml
security:
firewalls:
main:
custom_authenticators: [Omnibridge\Symfony\Auth\OmniauthAuthenticator]
entry_point: Omnibridge\Symfony\Auth\OmniauthAuthenticator # optional: start() sends to login_route
remember_me: { secret: '%kernel.secret%' } # Security's own, used when omniauth says "remember"
logout: { path: security_logout }
access_control:
- { path: ^/login/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
# config/routes/omniauth.yaml
omniauth: { resource: ., type: omniauth }
# config/packages/omniauth.yaml - each key turns its method on
omniauth:
password: { routes: [security_login] }
link: { route: security_loginWithToken } # 15 minutes, once, hashed
email_code: ~
backup_codes: ~
trusted_device: ~ # scheb/2fa's cookie and tokens
totp: { secret_key: '%env(OMNIAUTH_TOTP_KEY)%' } # omniauth/otp; the secrets sealed at rest (sodium)
passkey: { rp_id: '%env(RELYING_PARTY_ID)%' } # omniauth/webauthn
oauth: { providers: { google: { client_id: '...', client_secret: '...' } } } # omniauth/google (the mechanics: omniauth's Omniauth\OAuth\)
totp.secret_key (base64 of 32 bytes, from the vault or the environment, never in the repository;
the same on every host of one database - the Laravel twin's config/omniauth.php reads the same
variable) seals the TOTP secrets at rest: omniauth/otp's SealedTotpSecrets over a
SodiumSecretBox decorates the totp_secrets store, so the method and any page that reads or writes
the store (omnibase's enrolment and its QR code) go through it. Unset, the secrets are kept as the
store has them. A secret kept in clear before is read as it is until the application rewrites its
rows (omnibase sites: a migration).
A provider's return address is, unless it sets one, the path of its check route: omniauth puts it
on the host of each trip's request (the providers are built once per worker; an absolute address
made here would stay on the first request's host). Each provider package's factory
(Omniauth\OAuth\Provider\ProviderFactory, tag omniauth.oauth_provider_factory) makes its own kind;
omniauth/google's is registered when it is installed.
The stores (Omniauth\Contract\Accounts... the passkeys, the sender) are the application's, by their
contracts' names or those omniauth.yaml's stores names; omniauth's events go to Symfony's
dispatcher (listen to Omniauth\Event\SignedIn...). The accounts must be Symfony users
(UserInterface): the authenticator refuses any other with a LogicException.
The configuration tree
Dumped with Symfony's reference dumper (bin/console config:dump-reference omniauth) on a kernel of
FrameworkBundle, SecurityBundle, TwigBundle and OmnibridgeBundle, in the package's image (PHP 8.4,
Symfony 8.1.9, 2026-10-27):
# Default configuration for extension with alias: "omniauth"
omniauth:
# Keys the e-mailed codes' hashes and the trusted devices' tokens (scheb/2fa signed them with kernel.secret)
secret: '%kernel.secret%'
# The sign-in page: where a refused or abandoned attempt goes back to
login_route: security_login
stores:
accounts: Omniauth\Contract\Accounts
passwords: Omniauth\Contract\Passwords
link_tokens: Omniauth\Contract\LinkTokens
email_codes: Omniauth\Contract\EmailCodes
backup_codes: Omniauth\Contract\BackupCodes
trusted_version: Omniauth\Contract\TrustedVersion
totp_secrets: Omniauth\Contract\TotpSecrets
identities: Omniauth\Contract\Identities
passkeys: Omniauth\WebAuthn\Contract\Passkeys
sender: Omniauth\Contract\Sender
password:
enabled: true
# The routes a sign-in form posts to
routes:
# Default:
- security_login
# The form's name, its fields read under it; null: at the top of the request
form: null
identifier: _username
password: _password
remember: _remember_me
target_path_parameter: _target_path
default_target_path: /
# The class whose hasher security.yaml sets (password_hashers): the same hasher checks the passwords
user_class: null
csrf:
enabled: true
field: _csrf_token
intent: authenticate
link:
enabled: false
lifetime: 900
# The route of the link's page, which signs in when it carries a token
route: null
parameter: token
email_code:
enabled: false
digits: 6
lifetime: 600
backup_codes:
enabled: false
count: 8
trusted_device:
enabled: false
lifetime: 2592000
cookie: trusted_device
# The tokens' "fwl" claim; null: the firewall of the request
firewall: null
totp:
enabled: false
leeway: 0
period: 30
digits: 6
issuer: null
server_name: null
# The key the secrets are sealed with at rest (omniauth/otp's SodiumSecretBox): base64 of 32 bytes, from the vault or the environment - '%env(OMNIAUTH_TOTP_KEY)%' -, never written in the repository; the same on every host of the database. Unset: the secrets are kept as the store has them
secret_key: null
passkey:
enabled: false
rp_id: null
rp_name: null
origins: []
timeout: null
# Security's remember-me after a passkey (the JSON posted may say otherwise: "remember")
remember: true
options_path: /login/passkey/options
options_route: passkey_login_options
path: /login/passkey
route: passkey_login
oauth:
enabled: false
# Link an account by an address its provider vouches for: true, false, or a service (omniauth's EmailLinking); null: never one with a second factor
link_by_email: null
# Where an identity no account is linked to goes (its PendingIdentity in the session); null: the sign-in page
sign_up_route: null
providers:
# Prototype
name:
client_id: ''
client_secret: ''
# An absolute address, or a path each trip's host completes; null: the path of check_route
redirect_uri: null
scopes: []
hosted_domain: null
type: null
# A service that is the provider (omniauth's Provider), in place of the settings above
service: null
start_path: null
start_route: null
check_path: null
check_route: null
second_factor:
after:
# Defaults:
- password
- link
- oauth
form_path: /login/2fa
form_route: 2fa_login
check_path: /login/2fa/check
check_route: 2fa_login_check
template: security/login_2fa.html.twig
code: _auth_code
trusted: _trusted
csrf:
enabled: true
field: _csrf_token
intent: two_factor
throttle:
enabled: true
# A PSR-6 pool (Symfony's cache pools are): the tries counted, and the TOTP steps used
pool: cache.app
# Tries per window in seconds, by scope (omniauth's CacheThrottle::LIMITS by default): { password: [5, 60] }
limits:
# Prototype
scope: []
# The message of a failure, or of "failure:reason" ("%reason%" replaced), as the page translates it
messages:
# Prototype
failure: ~
A key written, even empty (email_code: ~), turns its method on (canBeEnabled()); password and
the two csrf blocks and throttle are on unless enabled: false (canBeDisabled()).
Top level
| Key | Type | Default | Effect |
|---|---|---|---|
secret |
string | %kernel.secret% |
keys the e-mailed codes' hashes (EmailCodeMethod) and the trusted devices' tokens (TrustedDevices) |
login_route |
string | security_login |
where start(), an abandoned or expired second factor, and a refused non-password attempt go back to |
stores.<name> |
service id | each contract's own interface name | the store each method reads: accounts (Omniauth\Contract\Accounts), passwords, link_tokens, email_codes, backup_codes, trusted_version, totp_secrets, identities, passkeys (Omniauth\WebAuthn\Contract\Passkeys), sender |
messages.<failure> |
string | - | the message of a failure, or of failure:reason (%reason% replaced), over the defaults below |
Methods
| Key | Options (default) | Service | Needs |
|---|---|---|---|
password (on) |
routes ([security_login]): the routes a sign-in form posts to; form (null): the form's name, its fields read under it, null at the top of the request; identifier (_username), password (_password), remember (_remember_me), target_path_parameter (_target_path), default_target_path (/); user_class (null): the class whose hasher security.yaml's password_hashers sets, so the same hasher checks the passwords; csrf.enabled (true), csrf.field (_csrf_token), csrf.intent (authenticate) |
omniauth.method.password (PasswordMethod: accounts, passwords, the hasher or null) |
- |
link |
lifetime (900, min 60), route (null): the route of the link's page, which signs in when it carries a token; parameter (token) |
omniauth.method.link (LinkMethod: accounts, link_tokens, sender, lifetime, clock) |
the route is the application's own; without route the method is mounted but no request is read as a link |
email_code |
digits (6, 4 to 9), lifetime (600, min 60) |
omniauth.method.email_code (a second factor) |
- |
backup_codes |
count (8, min 1) |
omniauth.method.backup_codes (a second factor) |
- |
trusted_device |
lifetime (2592000, 30 days, min 60), cookie (trusted_device), firewall (null: the request's firewall; the tokens' fwl claim) |
omniauth.method.trusted_device (TrustedDevices) |
- |
totp |
leeway (0), period (30), digits (6, 6 to 10), issuer (null), server_name (null), secret_key (null) |
omniauth.method.totp (a second factor; used steps kept in throttle.pool); with secret_key, omniauth.totp.box (lazy SodiumSecretBox) and omniauth.totp.sealed_secrets decorating the totp_secrets store |
omniauth/otp |
passkey |
rp_id, rp_name (null), origins ([]), timeout (null), remember (true: Security's remember-me after a passkey, unless the posted JSON says "remember"), options_path (/login/passkey/options), options_route (passkey_login_options), path (/login/passkey), route (passkey_login) |
omniauth.method.passkey (PasskeyMethod with a RelyingParty) |
omniauth/webauthn |
oauth |
link_by_email (null: NoSecondFactorLinking, an account linked by its address only when it has no second factor; true or false; or a service id implementing omniauth's EmailLinking); sign_up_route (null: the sign-in page); providers.<name> |
omniauth.method.oauth (OAuthMethod), omniauth.oauth.providers (public) |
omnisense/omniauth with Omniauth\OAuth\; a provider package |
A method whose package is missing stops the compilation: omniauth.yaml turns "totp" on, which needs omniauth/otp: composer require omniauth/otp. (the same for passkey, oauth).
oauth.providers.<name>
| Key | Default | Effect |
|---|---|---|
client_id, client_secret |
'' |
a provider without client id (and without service) is not served |
redirect_uri |
null | an absolute address, or a path each trip's host completes; null: the path of check_route |
scopes |
[] |
handed to the factory |
hosted_domain |
null | handed to the factory (Google's hd) |
type |
null | which factory makes it (google); the factory's own default otherwise |
service |
null | a service that is the provider (omniauth's Provider), in place of the settings above; another class is an InvalidArgumentException when the providers are built |
start_path, start_route |
/connect/<name>, oauth_<name> |
the trip out |
check_path, check_route |
/connect/<name>/check, oauth_<name>_check |
the return |
Other keys are kept (ignoreExtraKeys(false)) and handed to the factory.
second_factor
| Key | Default | Effect |
|---|---|---|
after |
[password, link, oauth] (omniauth's EnrolledSecondFactors::AFTER) |
the first factors a second one follows, for an account that has one enrolled |
form_path, form_route |
/login/2fa, 2fa_login |
the second factor's page |
check_path, check_route |
/login/2fa/check, 2fa_login_check |
where its form posts |
template |
security/login_2fa.html.twig |
the page's template, the application's |
code |
_auth_code |
the code's field |
trusted |
_trusted |
the "trust this device" field |
csrf.enabled, csrf.field, csrf.intent |
true, _csrf_token, two_factor |
the form's token, checked through the Tokens contract |
throttle
| Key | Default | Effect |
|---|---|---|
enabled |
true | false: no Throttle given to the conductor |
pool |
cache.app |
a PSR-6 pool: the tries counted, and the TOTP steps used |
limits.<scope> |
omniauth's CacheThrottle::LIMITS: password 5 per 60 s, link.begin 3 per 3600 s, email-code 5 per 900 s, email-code.begin 3 per 900 s, backup-code 5 per 900 s, totp 5 per 900 s, any other (*) 5 per 60 s |
[tries, seconds], replacing the default scope by scope; a missing number is 5 or 60 |
The services
| Id | Class | What it is |
|---|---|---|
Omniauth\Omniauth (public) |
Omniauth |
the conductor: accounts, the methods, the state, the policy, the throttle (or null), event_dispatcher, clock (null when absent) |
omniauth.methods (alias Omniauth\Method\Methods) |
Methods |
every service tagged omniauth.method |
omniauth.second_factors |
Methods |
the services tagged omniauth.second_factor (email code, backup codes, TOTP) |
omniauth.policy.default (alias Omniauth\Contract\SecondFactorPolicy) |
EnrolledSecondFactors |
the second-factor rule; an application decorates the alias |
Omnibridge\Symfony\Auth\SessionState (alias Omniauth\Contract\State) |
the attempt in the visitor's session (through the request stack); nothing kept without a session | |
Omniauth\CacheThrottle (alias Omniauth\Contract\Throttle) |
over throttle.pool, when throttle.enabled |
|
Omnibridge\Symfony\Auth\Contexts |
omniauth's Context of a request: origin, host, client IP, user agent, the trusted_device cookie, the firewall (trusted_device.firewall, else the request's firewall, else main) |
|
Omnibridge\Symfony\Auth\OmniauthAuthenticator |
the authenticator the firewall names | |
Omnibridge\Symfony\Auth\PendingVoter |
tag security.voter |
|
Omnibridge\Symfony\Auth\PendingTrustResolver |
decorates security.authentication.trust_resolver (ignored when absent) |
|
Omnibridge\Symfony\Auth\SecondFactorGate |
kernel.event_subscriber |
|
Omnibridge\Symfony\Auth\CeremonyController (public) |
tag controller.service_arguments |
|
Omnibridge\Symfony\Auth\AuthRouteLoader |
tag routing.loader, type omniauth |
|
omniauth.oauth.providers (public) |
Omniauth\OAuth\Provider\Providers |
OAuthProviders::create(): the configured providers through the factories, the service ones as they are |
omniauth.oauth.factory.google |
Omniauth\Google\GoogleFactory |
when omniauth/google is installed; any class implementing ProviderFactory is autoconfigured with the tag omniauth.oauth_provider_factory |
omniauth.method.<name> |
the method classes | one per method turned on, tagged omniauth.method (and omniauth.second_factor for the second factors) |
The routes
omniauth: { resource: ., type: omniauth } loads (AuthRouteLoader):
| Name (default) | Path (default) | Methods | Controller |
|---|---|---|---|
2fa_login |
/login/2fa |
GET | CeremonyController::secondFactor |
2fa_login_check |
/login/2fa/check |
any | CeremonyController::check (the authenticator answers the POST first) |
passkey_login_options |
/login/passkey/options |
POST | CeremonyController::passkeyOptions (with passkey) |
passkey_login |
/login/passkey |
POST | CeremonyController::check (the authenticator answers first) |
oauth_<name> |
/connect/<name> |
GET | CeremonyController::oauthStart (provider = name), per provider |
oauth_<name>_check |
/connect/<name>/check |
GET | CeremonyController::check (the authenticator answers first), per provider |
The names and addresses are those scheb/2fa, web-auth/webauthn-symfony-bundle and hwi/oauth-bundle had, so the pages and scripts that name them do not change. The password form's route and the link's route are the application's.
The authenticator
OmniauthAuthenticator (AbstractAuthenticator, AuthenticationEntryPointInterface, not final)
knows a sign-in request by its route (the router has run before the firewall):
| Request | Factor |
|---|---|
POST to a route of password.routes |
password (identifier, password, remember read under form or at the top; the CSRF field checked) |
GET of link.route with a non-empty link.parameter attribute |
link |
POST to passkey.route |
passkey (the JSON body; remember from the JSON, else passkey.remember) |
POST to second_factor.check_route |
the factor chosen on the page (else the first offered that is not a backup code, else TOTP); a code not shaped as that factor's digits is read as a backup code when backup codes are offered |
a provider's check_route |
oauth (code, state, error from the query) |
A method not turned on is never supported. Then Omniauth::verify(), and:
| omniauth's answer | Token | Response |
|---|---|---|
| authenticated | PostAuthenticationToken with the account's roles; RememberMeBadge when omniauth says remember; every passport carries an OmniauthBadge |
redirect to a posted or queried _target_path of this site (a path, or this host over http/https), else the firewall's saved target, else default_target_path; the trusted_device cookie set when omniauth gave one (HTTP only, SameSite Lax, secure on HTTPS, trusted_device.lifetime); a passkey's {"status": "ok", "errorMessage": ""} |
| second factor required | PendingToken (the offered factors) |
redirect to second_factor.form_route; a passkey's JSON with redirect |
| refused | SignInRefused |
password: back to the form's route, _security.last_error and _security.last_username in the session; link: no response, the route's token parameter emptied so the page answers as for no link; passkey: {"status": "failed", "errorMessage": ...}, 401; second factor: back to its page with the error, or to login_route when the attempt expired or is gone (the attempt abandoned, the token removed); other: login_route |
| identity unknown (oauth) | SignInRefused with the identity |
the identity kept in the session (omnibridge.auth.pending_identity: provider, subject, email, emailVerified, name) and a redirect to oauth.sign_up_route, else login_route |
start() (the entry point) redirects to login_route.
The messages (OmniauthAuthenticator::MESSAGES, overridden by messages), Symfony's security
catalogue's keys so a page translates them as it did:
| Failure | Message |
|---|---|
bad-credentials, invalid-link, refused, not-enrolled |
Invalid credentials. |
bad-code, not-offered |
code_invalid |
expired, no-attempt |
Authentication request could not be processed due to a system problem. |
throttled |
Too many failed login attempts, please try again in %minutes% minute. (%minutes% given as 1) |
account-refused |
Account is disabled. |
csrf |
Invalid CSRF token. |
A key failure:reason (refused:locked) wins over failure; %reason% in a message is replaced
by the reason. An unknown failure falls back on bad-credentials's message.
The session keys: omnibridge.auth.factor (OmniauthAuthenticator::CHOSEN, the second factor
chosen), omnibridge.auth.second_factor_error (SECOND_FACTOR_ERROR), omnibridge.auth.pending_identity
(PENDING_IDENTITY), and Security's _security.last_username, _security.last_error.
Between two factors
PendingToken: aTokenInterface(not anAbstractToken, as scheb/2fa'sTwoFactorToken), no role, the offered factors (offered()), serialised in the session like any token.PendingVoter: grantsIS_AUTHENTICATED_2FA_IN_PROGRESSto aPendingTokenand to nobody else (it abstains otherwise), so anaccess_controlline written for scheb/2fa still opens the page.PendingTrustResolver:isFullFledged()is false for aPendingToken, soIS_AUTHENTICATED_FULLYis refused to it.SecondFactorGate: onkernel.request(priority 7, after the firewall) andkernel.exception(priority 2, before the firewall's exception listener, for anAccessDeniedExceptionanywhere in the chain), a visitor holding aPendingTokenis redirected tosecond_factor.form_route- except on the second factor's own routes, a pageaccess_controlopens toPUBLIC_ACCESSor whose attributes the token is granted, and the firewall's sign-out path.
The second factor's page
CeremonyController::secondFactor() renders second_factor.template with the variables scheb/2fa's
form controller gave, so a template written for scheb/2fa renders unchanged:
| Variable | Value |
|---|---|
twoFactorProvider |
the factor shown, under scheb's names: totp, email (omniauth's email-code), backup-code |
availableTwoFactorProviders |
the offered factors but backup codes, under the same names |
authenticationError, authenticationErrorData |
the last refusal's message key and data, once |
displayTrustedOption |
whether an ambient method (trusted devices) is mounted |
authCodeParameterName, trustedParameterName |
second_factor.code, second_factor.trusted |
isCsrfProtectionEnabled, csrfParameterName, csrfTokenId |
from second_factor.csrf |
checkPathRoute, checkPathUrl |
the check route and its path |
logoutPath |
the firewall's sign-out path, null without one |
?preferProvider=email (or any offered factor) chooses the factor. The e-mailed code is sent when its
page is first shown, and again once it expired. Without a PendingToken or an attempt, the page
abandons the attempt and redirects to login_route. The answer is Cache-Control: no-store.
Without TwigBundle it throws LogicException ("The second factor's page is a Twig template: install
symfony/twig-bundle.").
passkeyOptions() answers the passkey's request options as web-auth/webauthn-symfony-bundle did
({"status": "ok", "errorMessage": "", ...options}, or failed and 400); oauthStart() redirects to
the provider's authorization address (404 when no provider is served or the name is unknown, back to
login_route on another failure); check() sends a stray GET of a check address back to its page.
Propose a change docs/omniauth.md
Maps (omnimap)
Where omnisense/omnimap is installed (suggested),
OmnibridgeBundle registers omnimap's extension (Map\DependencyInjection\OmnimapExtension, alias
omnimap), and config/packages/omnimap.yaml wires it: its providers (omnimap/google,
omnimap/openstreetmap, registered when installed) over the application's http_client, a PSR-16
view of a cache pool and a PSR-18 client for geocoder-php; the map cache over a storage of the
application's (an omnidrive drive, or any Omnimap\Snapshot\Storage); omnibridge/twig's
MapExtension (omnimap(view), omnimap_export(), omnimap_suppress()); the cache's routes and
their controller. With no omnimap.yaml, nothing is wired.
What a map, a view, a snapshot and a provider are is omnimap's own manual (omnimap's Symfony page, its cache); this page is the reference of the Symfony side.
Setting it up
# config/routes/omnimap.yaml
omnimap: { resource: ., type: omnimap }
# config/packages/omnimap.yaml
omnimap:
provider: openstreetmap # the page's map: a key of providers
secret: '%env(MAP_SECRET)%' # signs the pictures' names
providers:
openstreetmap: { user_agent: 'MySite/1.0 (+https://example.org)' }
google: { api_key: '%env(GOOGLE_MAPS_CLIENT_KEY)%', secret: '%env(GOOGLE_MAPS_SECRET)%' }
cache:
drive: local.uploads # an omnidrive drive (Omnidrive\Drives); or storage: an Omnimap\Snapshot\Storage service
public_directory: '%kernel.project_dir%/public' # tiles served are written there too
routes:
tile: { path: '/omnimap/{signature}/{id}.webp', name: omnimap_tile }
cache.drive needs omnisense/omnidrive (its Omnidrive\Drives is bound by this package,
Storages).
The configuration tree
Dumped with bin/console config:dump-reference omnimap on the same kernel as omniauth's (PHP 8.4,
Symfony 8.1.9, 2026-10-27):
# Default configuration for extension with alias: "omnimap"
omnimap:
# The provider of the page's map: a key of "providers".
provider: ~ # Required
# The provider that geocodes (Omnimap\GeocoderInterface): a key of "providers"; the page's provider by default.
geocoder: null
# Signs the pictures' names (URL-safe base64; Google's URL signing secret fits).
secret: ''
providers:
# Prototype
name: ~
# A visitor never gets the live map: the picture kept, or an empty frame.
cache_only: true
# Who may capture and delete pictures, and sees the live map while there is none.
control: ROLE_ADMIN
cache:
# The omnidrive drive the cache is kept on (Omnidrive\Drives: a storage's name, "local.uploads"); or storage.
drive: null
# A service implementing Omnimap\Snapshot\Storage, when the cache is on no omnidrive drive.
storage: null
directory: omnimap
pattern: '{signature}/image-{id}'
format: png # One of "png"; "jpeg"; "jpg"; "webp"
tilesize: 512
# Whether the server makes a missing picture itself: auto (when the provider makes images), true, false.
make: auto
size: 1512x938
scale: 1
# The PSR-6 pool keeping fetched tiles and geocoders' answers.
pool: cache.app
# The HTTP lifetime of a tile served by the route.
max_age: 86400
# Where a tile served is also written (the web server then serves it without PHP): the public folder.
public_directory: null
# The largest side of a tile served, in pixels (none: as kept).
public_size: null
html:
class: omnimap-snapshot
suffix: _snapshot
missing: ''
routes:
tile:
path: '/omnimap/{signature}/{id}.webp'
name: omnimap_tile
metadata:
path: '/omnimap/{signature}'
name: omnimap_metadata
export:
path: '/omnimap/{signature}/export'
name: omnimap_export
suppress:
path: '/omnimap/{signature}/suppress'
name: omnimap_suppress
| Key | Type | Default | Effect |
|---|---|---|---|
provider |
string, required, not empty | - | the provider of the page's map: a key of providers (Omnimap\MapRendererInterface) |
geocoder |
string | null (the page's provider) | the provider that geocodes (Omnimap\GeocoderInterface) |
secret |
string | '' |
signs the pictures' names (URL-safe base64; Google's URL signing secret fits) |
providers.<name> |
any (a map) | - | the provider's options, its factory's (omnimap/google, omnimap/openstreetmap); factory names another factory than the key |
cache_only |
bool | true | a visitor never gets the live map: the picture kept, or an empty frame |
control |
string | ROLE_ADMIN |
the attribute (asked of the Access contract) that may capture and delete pictures, and sees the live map while there is none |
cache.drive |
string | null | the omnidrive drive the cache is kept on (Omnidrive\Drives: a storage's name) |
cache.storage |
service id | null | a service implementing Omnimap\Snapshot\Storage, in place of a drive; one of drive or storage is required ("omnimap.cache needs a drive ... or a storage ...") |
cache.directory |
string | omnimap |
the folder of the pictures in the storage |
cache.pattern |
string | {signature}/image-{id} |
a picture's file name |
cache.format |
png, jpeg, jpg, webp |
png |
how pictures are kept |
cache.tilesize |
int | 512 | the side of the tiles a picture is cut into; 0 or less: not cut |
cache.make |
auto, true, false |
auto |
whether the server makes a missing picture itself: auto when the page's provider can make static maps, true always (another static provider of the registry when the page's cannot), false never |
cache.size |
WxH |
1512x938 |
the size of a picture the server makes |
cache.scale |
int, 1 or 2 | 1 | its scale |
cache.pool |
service id | cache.app |
the PSR-6 pool keeping fetched tiles and geocoders' answers (omnimap.cache, a Psr16Cache over it) |
cache.max_age |
int | 86400 | the HTTP lifetime of a tile served by the route |
cache.public_directory |
string | null | where a tile served is also written, at the route's own path, so the web server serves it without PHP the next time |
cache.public_size |
int | null | the largest side of a tile converted to WebP |
html.class, html.suffix, html.missing |
string | omnimap-snapshot, _snapshot, '' |
omnimap's MapHtml (the picture's markup); src is the tile route's path |
routes.<tile, metadata, export, suppress>.path, .name |
string | below | the cache's routes |
The services
| Id | Class | What it is |
|---|---|---|
Omnimap\Registry (public) |
the providers: the factories tagged omnimap.provider_factory, the providers options by name |
|
Omnimap\Google\GoogleFactory, Omnimap\OpenStreetMap\OpenStreetMapFactory, Omnimap\MapLibre\MapLibreFactory |
registered when their package is installed (OmnimapExtension::FACTORIES), Google's and OpenStreetMap's over http_client, omnimap.psr18 (or null), omnimap.cache, MapLibre's over http_client; tag omnimap.provider_factory |
|
Omnimap\MapRendererInterface |
Registry::renderer(provider) |
|
Omnimap\GeocoderInterface |
Registry::geocoder(geocoder ?? provider) |
|
omnimap.cache |
Symfony\Component\Cache\Psr16Cache |
over cache.pool |
omnimap.psr18 |
Symfony\Component\HttpClient\Psr18Client |
over http_client, when nyholm/psr7 or php-http/discovery is installed (geocoder-php's needs) |
Omnimap\Snapshot\SnapshotStore |
over cache.storage, or a DriveStorage over Drives::get(cache.drive) |
|
Omnimap\Snapshot\Snapshots (public) |
the renderer, a Signer of secret, the store, the static map (per cache.make), the size, the scale, the tile size |
|
Omnimap\MapHtml |
the html options |
|
Omnimap\MapPrinter (public) |
the page's map by omnimap's rule; a picture the server could not make is logged as a warning (omnimap: the map's picture could not be made: ...) on logger |
|
Omnibridge\Twig\MapExtension |
when omnibridge/twig's class exists and TwigBundle is registered; tag twig.extension |
|
Omnibridge\Symfony\Map\SnapshotController (public) |
tag controller.service_arguments |
|
Omnibridge\Symfony\Map\MapRouteLoader |
tag routing.loader, type omnimap |
A provider of another package is wired by its factory, tagged as the registry collects them:
# config/services.yaml - another provider's factory
services:
App\Map\AcmeFactory:
arguments: ['@http_client']
tags: [omnimap.provider_factory]
The Twig functions
Omnibridge\Twig\MapExtension (omnibridge/twig),
given the MapPrinter, the Urls, Access and Tokens contracts, control and the routes' names:
| Function | Output |
|---|---|
omnimap(view) |
the picture kept, or the live map, by MapPrinter's rule (HTML); '' for a null view |
omnimap_export(view, attributes = {}) |
the button that sends the live map's picture to the cache, for whoever Access grants control; posts to the export route with a token of intent omnimap-export; attributes.text is its content (default O) |
omnimap_suppress(view, attributes = {}) |
the button that deletes the picture kept, for the same; intent omnimap-suppress; default text X |
omnimap_signature(view) |
the view's signature (the picture's name) |
{{ omnimap(view) }}
{{ omnimap_export(view, {class: 'item', text: 'Capture'}) }}
{{ omnimap_suppress(view, {text: 'Delete'}) }}
The routes and the controller
omnimap: { resource: ., type: omnimap } loads (MapRouteLoader, the tile first because its path
may lie under the metadata's), signature matching [A-Za-z0-9_-]{1,128}:
| Name | Path | Methods | Action |
|---|---|---|---|
omnimap_tile |
/omnimap/{signature}/{id}.webp (id: digits, default 0) |
GET, HEAD | a tile of a picture kept |
omnimap_export |
/omnimap/{signature}/export |
POST | a picture captured by the browser |
omnimap_suppress |
/omnimap/{signature}/suppress |
POST | the picture deleted |
omnimap_metadata |
/omnimap/{signature} |
GET, HEAD | the picture's metadata |
SnapshotController:
- tile: the tile's bytes from the store (404 "No such map tile." when none or the signature is
invalid). When the route's path ends in
.webpand the store keeps another format, converted to WebP at quality 75 and fitted withinpublic_size- with GD when it was built with WebP, else Imagick, else served as kept. Written also underpublic_directoryat the route's path. Public,max-ageofmax_age,must-revalidate, an ETag (MD5 of the bytes), 304 when the request's matches. - metadata: the picture's metadata as JSON,
{"status": "BAD"}when none. - export: for whoever
Accessgrantscontrol(403 "Only whoever may change the map." otherwise, and when noAccessis bound), with a valid_tokenof intentomnimap-exportwhenTokensis bound (403 "Invalid token.");image(adata:URL; 400 "No picture was sent." when empty),tilesize(elsecache.tilesize); the picture saved, the public tiles of that signature deleted, its metadata answered. An invalid picture is a 400 with omnimap's message. - suppress: the same guard with intent
omnimap-suppress; the picture deleted with its public tiles;{"status": "OK"}.
The public tiles are deleted only inside public_directory and in a folder whose path holds the
signature.
Propose a change docs/omnimap.md
Storages and static export
Storages: omnidrive
Where omnisense/omnidrive is installed (suggested),
Omnidrive\Drives is the application's storages as omnidrive's drives: an
Omnidrive\Flysystem\OperatorDrives over the services tagged flysystem.storage, by their storage
attribute - the storages of league/flysystem-bundle's configuration (or omnibase's local ones without
the bundle). Nothing is configured a second time: config/packages/flysystem.yaml names them, the
code reads and writes them through Drive.
# config/packages/flysystem.yaml (league/flysystem-bundle)
flysystem:
storages:
local.uploads:
adapter: local
options: { directory: '%kernel.project_dir%/public/uploads' }
use Omnidrive\Drives;
final class Avatars
{
public function __construct(private readonly Drives $drives) {}
public function store(int $id, string $bytes): void
{
$this->drives->get('local.uploads')->write('avatars/'.$id.'.png', $bytes);
}
}
| Service | Class | |
|---|---|---|
Omnidrive\Flysystem\OperatorDrives |
a tagged_locator('flysystem.storage', 'storage') |
|
Omnidrive\Drives (public alias) |
the application's, by the storages' names (names(), get()) |
The drive is a FlysystemDrive over the very operator: a file written through the drive is in the host's
storage (DrivesTest). What a drive does (addresses, visibility, errors) is omnidrive's manual. The
map cache's cache.drive names one of these drives (Maps).
Static export: omnibridge/static
Where omnibridge/static is installed,
config/services.php registers the console command omnibridge:static:build
(Omnibridge\Symfony\Static\BuildCommand, tag console.command): omnibridge/static's own command
line (Console\Cli), handed what this host offers the site's file.
bin/console omnibridge:static:build static.php --out=var/static [--verify] [--strict]
| Argument or option | Default | Effect |
|---|---|---|
site |
static.php |
the file that declares the site (it returns an Omnibridge\Static\Site, or a closure that takes the host's offer); relative to the project directory unless absolute |
--out |
var/static |
the folder to export to (emptied first if it holds an earlier export) |
--render-origin |
the router's default URI | the origin the pages are rendered at: the request context's scheme and host, its port unless 80 or 443 |
--verify |
off | render every page twice; a page that differs is an error |
--strict |
off | a warning fails the build too |
--quiet-progress |
off | errors and counts only |
What the command hands Cli::build() as the host's offer:
| Key | Value |
|---|---|
Cli::HOST_NAME |
symfony |
Cli::HOST_RENDERER |
a RequestRenderer over $kernel->handle($request): each page a visitor's GET at the render origin, through the kernel's own request handling (firewall, listeners, controllers) |
Cli::HOST_ROUTES |
the router's RouteCollection |
Cli::HOST_CONTAINER |
the kernel's container |
Cli::HOST_PUBLIC_DIR |
<project>/public |
The exit code is Cli::build()'s (its report, its checks and its codes are omnibridge/static's
manual); an InvalidArgumentException it throws (a site file that is not one) is printed as an error
and the command exits with Command::INVALID (2).
Run on lesringostars (2026-10-24, the working copy loaded by hand, neither package installed there yet): the same report as the plain command line, but for the dev 404 page (Symfony's exception page, its trace).
Propose a change docs/drives-static.md
Errors
At compile time
| Error | When |
|---|---|
LogicException omniauth.yaml turns "<key>" on, which needs <package>: composer require <package>. |
totp without omniauth/otp, passkey without omniauth/webauthn, oauth without omniauth's Omniauth\OAuth\ |
InvalidConfigurationException omnimap.cache needs a drive ... or a storage ... |
omnimap.cache with neither drive nor storage |
InvalidConfigurationException (Symfony's) |
omnimap.provider missing or empty, a value out of range (email_code.digits 4 to 9, totp.digits 6 to 10, cache.scale 1 or 2, a lifetime under 60...), cache.format not one of the four |
| none | an adapter whose framework service is missing: removed by ContractsPass, the container compiles without that contract |
At run time
| Exception | Thrown by | When |
|---|---|---|
Omnibridge\Exception\MissingContractException |
Omnibridge\Runtime |
a contract asked that this container does not bind (no SecurityBundle: Access, Sessions, Tokens; Locales, Files, Routes unless the application or omnibase binds them) |
Symfony's RouteNotFoundException |
RouterUrls, and every place that generates a configured route |
a route name the router does not know (login_route, second_factor.form_route, oauth.sign_up_route...) |
Omnibridge\Symfony\Auth\Exception\SignInRefused |
OmniauthAuthenticator |
every refused sign-in: a CustomUserMessageAuthenticationException with factor, failure (omniauth's Failure or null), reason (csrf, identity-unknown, omniauth's), identity (an unknown provider identity); the message is the page's (messages). Security hands it to onAuthenticationFailure(), never to the application |
LogicException |
OmniauthAuthenticator::authenticate() |
omniauth's account is not a Symfony UserInterface |
LogicException |
Contexts::of() |
no request to sign in from |
LogicException |
CeremonyController::secondFactor() |
no TwigBundle ("The second factor's page is a Twig template..."); a second factor asked with none offered |
NotFoundHttpException |
CeremonyController::oauthStart() |
no identity provider served, or an unknown provider (404) |
InvalidArgumentException |
OAuthProviders::create() |
a provider's service is not an omniauth Provider |
InvalidArgumentException |
PendingToken |
an empty firewall name; an attribute asked that the token does not hold |
NotFoundHttpException |
SnapshotController::tile() |
no such tile (404) |
BadRequestHttpException |
SnapshotController::export(), suppress() |
no picture sent, an invalid picture or signature (400, omnimap's message) |
AccessDeniedHttpException |
SnapshotController::export(), suppress() |
the visitor lacks control, or no Access is bound; an invalid token (403) |
Propose a change docs/errors.md
Testing
Testing an application
Under a KernelTestCase or a WebTestCase, booting the kernel fills the registry (OmnibridgeBundle::boot()
or omnibase's BaseBundle), and a new kernel replaces the previous one's services: nothing to reset
between tests. A test that sets a contract by hand (Runtime::set()) takes it back after
(Runtime::set($contract, null) or Runtime::reset()), or the next tests see it.
The sign-in is tested as a visitor does it, with Symfony's browser: post the form, follow the
redirection to the second factor's page, post the code. The package's AuthTest does exactly that,
with an in-memory accounts store, omniauth's RecordingSender (the e-mails kept) and FakeProvider
(an identity provider simulated): copy its kernel (tests/Fixtures/AuthKernel.php) for an
application's own.
The package's suite
docker compose run --rm test # composer update, then PHPUnit
docker compose run --rm bare # without the suggested families
compose.yml's two services use the image omnibridge-symfony:php8.4 (docker/Dockerfile: PHP
8.4-cli, Composer 2, intl, zip, and GD with JPEG and WebP for omnimap's pictures), the package
mounted at /package (bare: read-only at /source), a named volume composer-cache. bare
(docker/bare.sh) copies the package, removes omnisense/omniauth and its methods, omnisense/omnimap and
its providers, omnisense/omnidrive and league/flysystem-memory, checks that none of their classes
loads, and runs the group without-omniauth. With the checkout's vendor/ installed, the suite runs
without network:
docker run --rm --network none -v "$PWD":/package omnibridge-symfony:php8.4 vendor/bin/phpunit --do-not-cache-result
The kernels (tests/Fixtures): TestKernel (FrameworkBundle, SecurityBundle with an in-memory user,
TwigBundle, OmnibridgeBundle; without SecurityBundle on demand; a Flysystem memory storage tagged
memory.uploads), AuthKernel (one firewall signing in through omniauth alone, every method on, the
accounts in memory, TOTP secrets sealed with a test key), MapKernel (omnimap on OpenStreetMap, its
tiles answered by the test, the cache on a local storage).
| Test class | What it checks |
|---|---|
ContractsTest |
the bundle seeds the registry with each adapter (one object through Runtime and the container); shutdown takes it back; Urls are the router's addresses; Translations the translator's messages; Environment the kernel's; Access the authorization checker's and Sessions sign the visitor out; the remember-me domain asked at sign-out; Tokens the CSRF manager's; without SecurityBundle the container compiles without Access, Sessions and Tokens |
TwigTest |
the pages' functions are the contracts; they answer as the framework's services; without security the contracts leave the functions out |
FileLinksTest |
the format maps the container to the host; the former syntax read as Symfony's; nothing to map; the decoration links to the host; an error page links to the host; export() gives the renderers built outside the container the mapping |
DrivesTest |
the tagged storages are the drives, by their names; a file written through the drive is in the storage |
AuthTest |
a password signs in; a wrong password, an unknown address and a forged token are refused; tries throttled; remember-me is Security's own; TOTP secrets sealed at rest with a key; an account with TOTP is asked its code and only its page opens; the second factor's page is no one's without a first factor; an e-mailed code sent hashed and served once; a backup code stands for the TOTP once; a trusted device skips the second factor until revoked; a sign-in link serves once and a failed one is no link; a passkey signs in without the second factor; an identity provider signs in by its subject and asks the second factor; the return address follows the host of each request; an unserved provider is no page |
MapTest |
the services; a visitor gets the picture the server made and its tiles (WebP, written in the public folder); an administrator captures and deletes with the page's tokens, a visitor refused |
WithoutOmniauthTest (group without-omniauth) |
without omniauth installed, nothing of Auth\ is wired |
Propose a change docs/testing.md
Verified, and not
What the tests prove
Run on 2026-10-27 in the package's image omnibridge-symfony:php8.4 (PHP 8.4, Symfony 8.1.9), the
checkout's own vendor/, no network: 38 tests, 246 assertions, green. What each class checks is in
Testing. The bare run (without the suggested families) needs
Composer's network and was not run that day; the commit f643d67 states it green.
The examples of this manual run in the same image on 2026-10-27, their output pasted: the test
kernel booted with and without SecurityBundle (Installation), the FileLinks
decoration and formats (Contracts), and the two configuration trees, dumped
with config:dump-reference (omniauth,
omnimap). For the dump, symfony/yaml (not installed in this
package's vendor) was loaded from omnibridge/laravel's vendor, the same version (8.1.9).
Verified elsewhere, as the commits and the README state
- The contracts and omniauth's sign-in on lesringostars (Symfony), with its parity suite against the Laravel twin.
- The map tiles on lesringostars: WebP converted with Imagick where GD has no WebP (commit 6cdce16), at quality 75 (3fa1968).
omnibridge:static:buildon lesringostars (2026-10-24, the working copy loaded by hand): the same report as the plain command line but for the dev 404 page (commit 67129db).- omniauth's provider factories through a service locator, after a compiled container (debug off)
dropped an inlined tagged iterator (lesringostars'
prod_demo_testkernel, Symfony 8.1.5).
What is not verified
- Symfony 6 and 7: composer.json admits them, the suite runs on Symfony 8.1 only.
- A real identity provider: Google is simulated (
FakeProvider) and configured byGoogleFactoryin the tests; no sign-in against Google's servers in this package. - A real passkey authenticator: the tests use omniauth/webauthn's in-memory passkeys.
- omnimap providers other than OpenStreetMap through this wiring: Google is registered, not
exercised; MapLibre is registered and resolved as a renderer (
MapTest::testTheServices), its page not drawn. FileLinks::formatter()as a decoration in an application: tested byFileLinksTestand run here, registered by no configuration of this package or omnibase.