The small contract a page rendered with Twig asks of any framework, so the same template renders under Symfony, Laravel or plain PHP (plan 0, rule 13: a screen is described once, a view bridge renders it).
composer require omnibridge/twig
- The contract is omnisense/omnibridge's, the
family's core:
Omnibridge\Contract\Urls(path(name, parameters),url(name, parameters),asset(path)) andOmnibridge\Contract\Translations(trans(id, parameters, domain, locale)on ICU catalogues,exists(id, domain, locale)). ContractExtension: registerspath(),url(),asset()and thetransfilter under the names Symfony gives them. Symfony already has them and does not register it; another framework registers it with its own answers to the contract.AccessExtension:is_granted(attribute, subject)onOmnibridge\Contract\Accessandcsrf_token(intent)onOmnibridge\Contract\Tokens, under Symfony's names. Every host registers it, Symfony included (omnibridge/symfony, after twig-bridge's own, which it then answers with the same services): a page asks the same code under either framework. A contract the host does not bind leaves its function out.MapExtension(with omnisense/omnimap, suggested):omnimap(view)- the picture of a map kept in omnimap's cache, or the live map, by omnimap'sMapPrinterrule -,omnimap_export(view, attributes)andomnimap_suppress(view, attributes)- the buttons that change the cache, for whoever the host'sAccessgrants the control attribute (ROLE_ADMIN), posting aTokenstoken to the host's map cache routes -,omnimap_signature(view). omnibridge/symfony and omnibridge/laravel register it when omnimap is configured.HelperExtension: the filters omnibase's templates use that are not Twig's own.
The former names Omnibridge\Twig\Contract\Urls and Omnibridge\Twig\Contract\Translations,
aliases of omnisense/omnibridge's interfaces since its step A2, were removed at omnibase 4's final
pass (commit 976de23): name Omnibridge\Contract\Urls and Omnibridge\Contract\Translations.
Pilot: lesringostars' article screen, rendered by Symfony and by Laravel from one template.
Who registers what
| Extension | Under Symfony (omnibridge/symfony) | Under Laravel (omnibridge/laravel's View\Twig) |
Plain PHP |
|---|---|---|---|
ContractExtension |
not registered: twig-bridge's path(), url(), asset(), |trans answer |
registered with the container's Urls and Translations |
by hand |
AccessExtension |
registered after twig-bridge's, with the container's Access and Tokens (each null when absent) |
registered with the container's Access and Tokens when bound |
by hand |
MapExtension |
registered when omnimap.yaml is configured and TwigBundle is there |
registered when config/omnimap.php names a provider |
by hand |
HelperExtension |
registered first by omnisense/omnibase; omnibase's own filters of the same names win | registered with the page's locale and time zone | by hand |
Requirements
PHP 8.2 or later, omnisense/omnibridge 1.x, twig/twig 3.10 or later; omnisense/omnimap for
MapExtension (suggested). HelperExtension's lang_name and datetime use PHP's intl
(\Locale, \IntlDateFormatter), its text filters mbstring - both required by composer.json
(Helpers).
How this manual is organised
- Installation: a page rendered in plain PHP, and its output.
- Extensions:
ContractExtension,AccessExtension,MapExtension- every function and filter, their arguments, what answers them. - Helpers:
HelperExtension's function and thirteen filters, with a run. - Testing, Verified, and not.
Licence
MIT.
Proposer une modification docs/index.md
Installation
composer require omnibridge/twig
Under Symfony or Laravel the host bridge registers the extensions (Who registers what): nothing to do. Without a framework, give each extension the contracts it asks.
A page in plain PHP
<?php
require 'vendor/autoload.php';
use Omnibridge\Contract\Access;
use Omnibridge\Contract\Tokens;
use Omnibridge\Contract\Translations;
use Omnibridge\Contract\Urls;
use Omnibridge\Twig\AccessExtension;
use Omnibridge\Twig\ContractExtension;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
$urls = new class implements Urls {
public function path(string $name, array $parameters = []): string { return '/'.$name.'/'.implode('/', $parameters); }
public function url(string $name, array $parameters = []): string { return 'https://example.org'.$this->path($name, $parameters); }
public function asset(string $path): string { return '/build/'.$path; }
};
$translations = new class implements Translations {
private const MESSAGES = ['article.read' => 'Lire « {title} »'];
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return strtr(self::MESSAGES[$id] ?? $id, array_combine(array_map(static fn ($k) => '{'.$k.'}', array_keys($parameters)), $parameters));
}
public function exists(string $id, ?string $domain = null, ?string $locale = null): bool { return isset(self::MESSAGES[$id]); }
};
$access = new class implements Access {
public function isGranted(mixed $attribute, mixed $subject = null): bool { return 'ROLE_ADMIN' === $attribute; }
public function getUser(): ?object { return null; }
};
$tokens = new class implements Tokens {
public function csrf(string $intent): string { return hash('sha256', 'session'.$intent); }
public function isValid(string $intent, ?string $token): bool { return $token === $this->csrf($intent); }
};
$twig = new Environment(new ArrayLoader(['article.html.twig' => <<<'TWIG'
<a href="{{ path('article', {slug: 'le-fuji'}) }}">{{ 'article.read'|trans({title: 'Le Fuji'}) }}</a>
<link rel="canonical" href="{{ url('article', {slug: 'le-fuji'}) }}">
<link rel="stylesheet" href="{{ asset('app.css') }}">
{% if is_granted('ROLE_ADMIN') %}<form method="post"><input type="hidden" name="_token" value="{{ csrf_token('delete_12')|slice(0, 12) }}"></form>{% endif %}
TWIG]));
$twig->addExtension(new ContractExtension($urls, $translations));
$twig->addExtension(new AccessExtension($access, $tokens));
echo $twig->render('article.html.twig');
Its output (PHP 8.4, Twig 3, 2026-10-27):
<a href="/article/le-fuji">Lire « Le Fuji »</a>
<link rel="canonical" href="https://example.org/article/le-fuji">
<link rel="stylesheet" href="/build/app.css">
<form method="post"><input type="hidden" name="_token" value="e6a6fba63d4d"></form>
In a real host the contracts are the bridge's adapters (omnibridge/laravel's RouterUrls,
CatalogueTranslations, GateAccess, SessionTokens), or Omnibridge\Runtime's:
new ContractExtension(Runtime::urls(), Runtime::translations()).
Proposer une modification docs/installation.md
Extensions
Three final classes extending Twig's AbstractExtension, in Omnibridge\Twig\.
ContractExtension
new ContractExtension(Urls $urls, Translations $translations)
| Twig | Answered by | Notes |
|---|---|---|
path(name, parameters = {}) |
Urls::path() |
twig-bridge's name; the contract has no third argument (relative) |
url(name, parameters = {}) |
Urls::url() |
the contract has no third argument (schemeRelative) |
asset(path) |
Urls::asset() |
the contract has no package name argument |
|trans(parameters = {}, domain = null, locale = null) |
Translations::trans() |
the parameters as the catalogue writes them ({title} for ICU) |
Not marked safe: their output is escaped like any string (an address is safe to escape).
AccessExtension
new AccessExtension(?Access $access = null, ?Tokens $tokens = null)
| Twig | Answered by | Registered when |
|---|---|---|
is_granted(attribute, subject = null) |
Access::isGranted() |
an Access is given |
csrf_token(intent) |
Tokens::csrf() |
a Tokens is given |
Registered after twig-bridge's SecurityExtension and CsrfExtension under Symfony, so the same
names answer through the contracts (and the same Symfony services). A contract not given leaves its
function out: a template that calls it fails to compile ("Unknown function"), as it would under
Symfony without SecurityBundle.
MapExtension
new MapExtension(
MapPrinter $printer, // omnimap's
Urls $urls,
?Access $access = null,
?Tokens $tokens = null,
string $control = 'ROLE_ADMIN',
array $routes = [], // ['export' => route name, 'suppress' => route name]
)
| Twig | Output |
|---|---|
omnimap(view) |
MapPrinter::map($view, $mayChange): the picture kept in omnimap's cache when there is one (made now by the server when it can), else the live map - for whoever may change the map only when the printer is cache_only (an empty frame for a visitor), for everyone otherwise; '' for a null view. HTML, marked safe |
omnimap_export(view, attributes = {}) |
the button that sends the live map's picture to the cache, for whoever may change the map when the live map is what they see; posts to Urls::path(routes.export ?? 'omnimap_export', {signature}) with Tokens::csrf('omnimap-export'); attributes.text is its content (default O), the rest its HTML attributes; '' otherwise |
omnimap_suppress(view, attributes = {}) |
the button that deletes the picture kept, for the same, when there is a picture; route routes.suppress ?? 'omnimap_suppress', intent omnimap-suppress, default text X |
omnimap_signature(view) |
the view's signature, the picture's name |
"May change the map" is Access::isGranted($control); false without an Access. Without Tokens
the buttons carry an empty token. The constants MapExtension::EXPORT and MapExtension::SUPPRESS
are the two intents, which the hosts' controllers check. The buttons post to the routes named by
the host: under Symfony omnibridge/symfony's map cache routes; under Laravel routes the application
declares itself (omnibridge/laravel routes the tile only, and names the others for the buttons).
{{ omnimap(view) }}
{{ omnimap_export(view, {class: 'item', text: 'Capture'}) }}
{{ omnimap_suppress(view, {text: 'Delete'}) }}
Proposer une modification docs/extensions.md
Helpers
HelperExtension: the small helpers omnibase gives the templates of its sites, for a framework
rendering those templates without omnibase - the same names and results as omnibase's
(Resources/Functions.php and FunctionTwigExtension), framework-free. omnibase registers this
extension first and its own win where they do more; |at and |emoji_only are only here.
new HelperExtension(string $locale = 'fr', ?string $timezone = null)
$locale is the language of |lang_name and |datetime; $timezone the zone |datetime prints
in (the date's own when null).
Function
| Function | Result |
|---|---|
html_attributes(attributes, ...more) |
key="value" pairs joined by spaces, the arrays merged, each key and value trimmed, nothing escaped; marked safe |
Filters
| Filter | Arguments | Result |
|---|---|---|
str_shorten |
length = 100, position = 1, separator = ' [..] ' |
the text without tags, cut to length characters with the separator where it was cut: at the back (1, SHORTEN_BACK), in the middle (0), at the front (-1); unchanged when it is at most length plus the separator long; '' for a length of 0 |
mb_ucfirst |
the first letter upper case, multibyte; each item of a list | |
second |
a list's second value, null when it has none | |
join_if_exists |
separator |
the non-empty items joined; null for null |
highlight |
pattern (a string or a list) |
each word containing a pattern wrapped in <mark class="markdown-word">, the pattern itself in <mark class="markdown-pattern"> |
less_than |
diff ('+2 day', a date, seconds) |
whether a date (a string, a DateTimeInterface, a timestamp) is more recent than the difference |
html_decode |
HTML entities decoded (HTML5, quotes) | |
lang |
the language of a locale: fr-FR, fr_FR => fr |
|
country |
its country: en-GB => GB; a bare language its own (fr => FR) |
|
lang_name |
the language's name in the extension's locale (ja-JP => japonais) |
|
datetime |
pattern = 'YYYY-MM-dd HH:mm:ss', locale = null |
a date by an ICU pattern, lower case; an ISO 8601 string (a screen's moment) read as that moment; any other string only lowered; nothing (null, '', false, 0) the pattern itself; an integer a timestamp |
at |
index |
a list's entry at an index, null when it has none |
emoji_only |
whether a text is emoji alone, spaces aside (a comment of one emoji shown big) |
HelperExtension::isMoment() is the ISO 8601 test |datetime uses (a date, a time, a zone or
not).
A run
{{ 'Le mont Fuji, vu depuis le lac Kawaguchi au lever du jour'|str_shorten(20) }}
{{ 'Le mont Fuji, vu depuis le lac Kawaguchi au lever du jour'|str_shorten(20, 0) }}
{{ 'été'|mb_ucfirst }} {{ ['a', 'b', 'c']|second }} {{ ['a', null, 'c']|join_if_exists(', ') }}
{{ 'le mont Fuji'|highlight('Fuji') }}
{{ 'fr-FR'|lang }} {{ 'en-GB'|country }} {{ 'fr'|country }} {{ 'ja-JP'|lang_name }}
{{ '2026-04-10T18:05:00+00:00'|datetime('d MMMM YYYY HH:mm') }} {{ 'Présent'|datetime }}
{{ ['x', 'y']|at(1) }}{{ ['x', 'y']|at(5) is null ? ' (none)' }} {{ '👍 🎉'|emoji_only ? 'emoji' : 'text' }}
<div {{ html_attributes({class: 'card big', 'data-id': 12}) }}></div>
{{ 'ééééééééééééééééééééééééééééééééééééééééé'|str_shorten(10, -1) }}
With new HelperExtension('fr', 'Asia/Tokyo'), PHP 8.4 with intl, 2026-10-27:
Le mont Fuji, vu dep [..]
Le mont Fu [..] er du jour
Été b a, c
le mont <mark class="markdown-word"><mark class="markdown-pattern">Fuji</mark></mark>
fr GB FR japonais
11 avril 2026 03:05 présent
y (none) emoji
<div class="card big" data-id="12"></div>
[..] éééééééééé
|str_shortenmeasures and cuts in characters (mb_strlen,mb_substr): 41éshortened to 10 at the front keep their last ten.- The moment
18:05 UTCis printed in Tokyo's time (03:05the next day). |highlightis not marked safe: under autoescaping its marks are escaped, as the run shows. Write|highlight(...)|rawon a text that is already safe - the filter does not escape the text itself.
Proposer une modification docs/helpers.md
Testing
Testing a template
The extensions take the contracts as constructor arguments: a test gives them doubles, renders the
template with Twig's ArrayLoader or FilesystemLoader, and compares the HTML - the
Installation page's script is such a test. A template shared by two hosts is
rendered by both and compared (lesringostars' parity suite does it for its pages).
The package's suite
The package has no Docker harness of its own: its suite runs in any PHP 8.2+ image with the checkout's
vendor/ installed. With omnibridge/symfony's image (PHP 8.4, intl, GD):
docker run --rm --network none -v "$PWD":/package:ro omnibridge-symfony:php8.4 vendor/bin/phpunit --do-not-cache-result
| Test class | What it checks |
|---|---|
ContractExtensionTest |
a page uses the contract under Symfony's names (path, url, asset, |trans) |
AccessExtensionTest |
a page asks the host's contracts; it answers in place of the framework's own functions; a contract the host lacks leaves its function out |
MapExtensionTest |
a visitor (the frame, no button), an administrator before a picture (the live map, the export button, no suppress) and after (the picture, both buttons); skipped without GD |
HelperExtensionTest |
the sites' helpers; an entry and emoji alone; dates in the page's language and words as they are; recent and highlighted; an ISO 8601 moment is that moment |
The CI (GitHub Actions, glitchr-studio/ci's package workflow) runs PHPUnit and PHPStan at the highest
level against phpstan-baseline.neon.
Proposer une modification docs/testing.md
Verified, and not
What the tests prove
Run on 2026-10-27 in omnibridge-symfony:php8.4 (PHP 8.4.26, PHPUnit 11.5.57), the checkout's own
vendor/, no network: 10 tests, 54 assertions, green (Testing).
The examples of this manual were run in the same image that day and their output pasted: the plain PHP page (Installation) and the helpers (Helpers).
Verified elsewhere
- Under Symfony: omnibridge/symfony's
TwigTest(the functions are the contracts, answered as the framework's services, left out without security). - Under Laravel: omnibridge/laravel's
TwigAccessTestandOmnibaseTwigTest. - lesringostars' article screen, one template rendered by both hosts (the pilot).
What is not verified
- Twig versions other than the one the checkout installs (composer.json admits 3.10 and later).
- A PHP without intl or mbstring: composer refuses to install the package (it requires both; Helpers).
|less_thanand|datetimearound a change of time zone offset: not covered by the tests.