stromcom / php-i18n
Translator.stromcom.cz client + AST-based scanner for PHP / Twig / XSLT sources. Runtime t() helper, PSR-15 locale middleware, Symfony Console workflow. Zero regex on sources.
Requires
- php: ^8.4
- ext-dom: *
- ext-json: *
- ext-libxml: *
- nikic/php-parser: ^5.0
- php-di/php-di: ^7.0
- psr/container: ^2.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- symfony/console: ^7.0|^8.0
- symfony/http-client: ^7.0|^8.0
- symfony/http-client-contracts: ^3.0
- twig/twig: ^3.0
Requires (Dev)
- infection/infection: ^0.34.1
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0|^12.0|^13.0
Suggests
- ext-intl: Required for ICU plural / number formatting via MessageFormatter; without it the runtime falls back to plain {var} substitution via strtr() (no plurals).
- ext-xsl: Required for XsltScanner and XsltRenderer (XSLT-based templates). Without it those classes will throw 'XSLTProcessor not found' on first use — but auth.stromcom.cz / Twig-only projects do not need it.
This package is auto-updated.
Last update: 2026-08-07 06:16:21 UTC
README
Translator client + AST scanner for PHP / Twig / XSLT sources. Connects an application to the self-hosted translator.
Zero regexes on sources. PHP via the nikic/php-parser AST, Twig via a custom
Twig\NodeVisitorInterface plugged into Twig\NodeTraverser, XSLT via
DOMDocument + DOMXPath (element variant — XPath inside a select= expression is still TODO).
Architecture
| Tier | Classes | When it runs |
|---|---|---|
| Runtime | Translator, BundleLoader, LocaleResolver, LocaleMiddleware, LocaleContext, TwigI18nExtension, MissingKeyPolicy |
In Lambda / per request. Prefers build/locales/<locale>.cache.php (OPcache hot path) with a fallback to <locale>.json. |
| Scan | PhpScanner, TwigScanner, XsltScanner, ScannerPipeline, AST visitors |
Buildtime — CI or composer i18n:sync. |
| Build | TranslatorClient, BundleFetcher, KeySync, EtagStore |
Buildtime — HTTP communication with the translator. |
| Console | SyncCommand, FetchCommand, ScanCommand, StatusCommand |
Buildtime — CLI entry points. |
Use in a consumer
DI registration
// config/dependencies.php use Stromcom\I18n\Config\I18nConfig; use Stromcom\I18n\Config\I18nServiceProvider; use Stromcom\I18n\Runtime\MissingKeyPolicy; return array_merge(I18nServiceProvider::definitions(), [ I18nConfig::class => static fn () => new I18nConfig( projectId: 'auth-stromcom-cz', token: (string) ($_ENV['I18N_TOKEN'] ?? ''), baseUrl: 'https://translator.stromcom.cz', sourceLocale: 'en', targetLocales: ['cs', 'en', 'de', 'sk'], fallbackLocale: 'en', bundlesDir: dirname(__DIR__) . '/build/locales', scanPaths: [dirname(__DIR__) . '/src', dirname(__DIR__) . '/templates'], missingKeyPolicy: MissingKeyPolicy::LogAndFallback, isDevelop: false, ), // … your own DI definitions … ]);
Slim middleware pipeline
$app->add(\Stromcom\I18n\Runtime\LocaleMiddleware::class); // LocaleMiddleware must run after the session and before the route handler.
Twig environment
$twig->addExtension($container->get(\Stromcom\I18n\Runtime\TwigI18nExtension::class));
Symfony Console
foreach (\Stromcom\I18n\Config\I18nServiceProvider::consoleCommands() as $cmd) { $app->addCommand($container->get($cmd)); }
Use in the application
{# templates/login.twig #} <button type="submit">{{ t('login.form.submit', 'Sign in') }}</button> {# ICU plurals (requires ext-intl) #} <p>{{ t('cart.itemCount', '{count, plural, one {# item} other {# items}}', { count: itemCount }) }}</p> {# language switcher #} <select>{% for loc in available_locales %}<option {% if loc == current_locale %}selected{% endif %}>{{ loc }}</option>{% endfor %}</select>
// In a handler / domain service $msg = $this->translator->trans('email.password_reset.subject', 'Reset your password'); // With ICU values $msg = $this->translator->trans('admin.users.deleted', '{count, plural, one {# user deleted} other {# users deleted}}', ['count' => $n]); // With a note (3rd positional / named arg `note:`) — the scanner extracts it as metadata // for translators, the runtime ignores it: $msg = $this->translator->trans('signup.title', 'Sign up', note: 'Page heading');
XSLT — two-pass renderer
The consumer calls a single method; the package runs the XSLT transformation plus
post-processing of <i18n:t/> elements. AVTs in attributes (count="{$total}") are
evaluated in the first pass, and attributes with concrete values then feed into
MessageFormatter:
$renderer = $container->get(\Stromcom\I18n\Runtime\XsltRenderer::class); $html = $renderer->render( xslPath: __DIR__ . '/templates/product.xsl', data: $xmlSource, // string or DOMDocument locale: 'cs', // optional, default = LocaleContext::get() xsltParams: ['user' => 'Petr'], // <xsl:param> values outputFormat: null, // null = auto from <xsl:output method>, otherwise 'html'|'xml'|'text' );
In the XSL template:
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:i18n="https://stromcom.cz/i18n" exclude-result-prefixes="i18n"> <xsl:output method="xml" encoding="UTF-8"/> <xsl:template match="/"> <h1><i18n:t key="page.title" default="Welcome" note="Homepage heading"/></h1> <!-- ICU plural with a dynamic value from the data --> <p><i18n:t key="cart.count" default="{{count, plural, one {{# item}} other {{# items}}}}" count="{data/@items}"/></p> </xsl:template> </xsl:stylesheet>
code-analysis.yml Important — escaping ICU placeholders in XSL attributes:
XSLT 1.0 evaluates {...} in attributes of literal result elements as Attribute
Value Templates (AVTs) — XPath expressions. ICU placeholders such as {name} or
{count, plural, ...} therefore must be doubled to {{name}} / {{count, ...}}.
This is the XSLT 1.0 standard.
XsltScanner undoes that escaping, so the source_text synced to the platform is the
real ICU pattern ({count, plural, one {# item} other {# items}}) rather than the
doubled-brace spelling. Write the doubled form in the template and expect the collapsed
form everywhere else.
Do not use
<xsl:attribute>forkey/default. Its text content is not an AVT, so it avoids the doubling — but it is a child element, invisible to an attribute read. Such a template renders correctly while its key never reaches the platform. The scanner emits a warning naming the attribute instead of skipping in silence.<!-- renders fine, but is never synced --> <i18n:t key="greet"> <xsl:attribute name="default">Hello {name}</xsl:attribute> </i18n:t> <!-- write this instead --> <i18n:t key="greet" default="Hello {{name}}" name="{$user_name}"/>
Namespace matching is exact. Both the scanner and the renderer resolve <i18n:t/> by
namespace URI (https://stromcom.cz/i18n), never by prefix — any prefix bound to that URI
works. A mistyped declaration (http:// instead of https://) makes the scanner warn and
the renderer throw XsltRendererException, rather than leaking a raw <i18n:t/> tag into
the page.
Rules for <i18n:t/> attributes:
| Attribute | Meaning |
|---|---|
key |
Key identifier (required, must be a literal — no AVT expression) |
default |
Source text — ICU template (required, must be a literal) |
note |
Metadata for translators — ignored by the runtime |
| others | ICU MessageFormatter params ({paramName} in default); AVTs welcome here |
An element without key or default → removed from the output (warning in the scanner if it passes the scan).
A key or default holding an AVT expression (key="{$dynamic}") cannot be synced — the
scanner warns and skips it.
Note that omit-xml-declaration="yes" has no effect for XML output: pass 2 re-serialises
the post-processed DOM, so the declaration is always emitted. Use method="html" or strip
it yourself if a bare fragment is required.
CLI
composer i18n:scan # Debug dump of discovered keys (local only) composer i18n:sync # Scan + POST to /keys/sync (idempotent UPSERT) composer i18n:fetch # GET published bundles → build/locales/ composer i18n:fetch --draft # GET draft bundles (for local dev) composer i18n:fetch --locale=cs # A single locale only composer i18n:status # Coverage report (how many keys translated per locale)
Quality
composer install # inside packages/stromcom-i18n/ composer test # PHPUnit — every src class has tests composer stan # PHPStan level max + strict-rules → 0 errors composer coverage # PHPUnit + text coverage report (needs pcov or xdebug) composer mutate # Infection mutation testing (needs pcov or xdebug) composer ca # stan + test
Test layout mirrors src/, plus:
| Directory | Contents |
|---|---|
tests/Integration/ |
Cross-class contracts — notably the XSLT scanner ⇄ renderer round-trip |
tests/Support/ |
Doubles: TmpDir, CollectingLogger, HttpRecorder, RecordingScanner, InMemoryBundleLoader |
HTTP is exercised through symfony/http-client's MockHttpClient (no network in the
suite), console commands through CommandTester.
composer mutate accepts the usual Infection arguments, so a single class can be probed
in isolation:
composer mutate -- src/Scan/XsltScanner.php --show-mutations=max
Mutation testing needs a coverage driver and ext-intl for the ICU tests:
sudo apt install php8.4-pcov php8.4-intl # match your PHP minor version
Then in consumers' composer.json:
"repositories": [ - { "type": "path", "url": "packages/stromcom-i18n", "options": { "symlink": true } } + { "type": "vcs", "url": "https://github.com/stromcom/php-i18n.git" } ], "require": { - "stromcom/php-i18n": "@dev", + "stromcom/php-i18n": "^0.1", }
What the package does not do
- JavaScript / React — will be a separate npm package
@stromcom/i18n(different repo). AST parsing JS from PHP is hell, and the frontend needs its own runtime helper anyway. - The XPath function
i18n:t('key', 'default')inside aselect=attribute — requires an XPath parser and, on top of that, does not handle ICU plurals as elegantly as the element-only variant with attributes + AVTs. The two-pass renderer (XSLT + DOM post-processor) fully replaces that need. - Runtime fetch from the translator — bundles must be retrieved during the CI build (
composer i18n:fetch) and packed into the deploy artifact. The runtime only reads from disk.
On-disk bundle format
i18n:fetch writes two files per locale:
| File | Purpose | Who reads it |
|---|---|---|
build/locales/<locale>.json |
Source of truth, raw response from the translator (wrapped {version, locale, translations: {...}}) |
BundleLoader as a fallback, debugging |
build/locales/<locale>.cache.php |
<?php return [flat-map] via var_export |
OPcache hot path — require caches bytecode in shared memory, no json_decode |
BundleLoader tries .cache.php first only if its mtime is ≥ the JSON. After i18n:fetch
the mtimes are synced (via touch()). If someone manually edits the JSON (mtime > PHP cache),
the loader detects it and falls back to JSON — no stale cache.
For Lambda (singleton BundleLoader + in-memory cache) the difference is 0 — the bundle is
parsed once per cold start. For PHP-FPM hosting OPcache is a win: bytecode is shared between
workers, 0 parsing after the first request any worker made.