Skip to content

omnibridge/twig

The Twig side of omnibridge: the contract a page rendered with Twig asks of any framework (path, url, asset, trans - omnisense/omnibridge's Urls and Translations), the Twig extension that provides it where the framework does not, and helpers.

Floor
Bridge
Version
1.x
Verified for real
Its README does not say
Licence
MIT
composer require omnibridge/twig:1.x-dev

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)) and Omnibridge\Contract\Translations (trans(id, parameters, domain, locale) on ICU catalogues, exists(id, domain, locale)).
  • ContractExtension: registers path(), url(), asset() and the trans filter 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) on Omnibridge\Contract\Access and csrf_token(intent) on Omnibridge\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's MapPrinter rule -, omnimap_export(view, attributes) and omnimap_suppress(view, attributes) - the buttons that change the cache, for whoever the host's Access grants the control attribute (ROLE_ADMIN), posting a Tokens token 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.

Propose a change 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()).

Propose a change 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'}) }}

Propose a change 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 &lt;mark class=&quot;markdown-word&quot;&gt;&lt;mark class=&quot;markdown-pattern&quot;&gt;Fuji&lt;/mark&gt;&lt;/mark&gt;
fr GB FR japonais
11 avril 2026 03:05 présent
y (none) emoji
<div class="card big" data-id="12"></div>
[..] éééééééééé
  • |str_shorten measures and cuts in characters (mb_strlen, mb_substr): 41 é shortened to 10 at the front keep their last ten.
  • The moment 18:05 UTC is printed in Tokyo's time (03:05 the next day).
  • |highlight is not marked safe: under autoescaping its marks are escaped, as the run shows. Write |highlight(...)|raw on a text that is already safe - the filter does not escape the text itself.

Propose a change 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.

Propose a change 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 TwigAccessTest and OmnibaseTwigTest.
  • 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_than and |datetime around a change of time zone offset: not covered by the tests.

Propose a change docs/verified.md