Search by

bckp / translator-core

bckp

Simple and fast PHP translator

Package info

github.com/bckp/translator-core

pkg:composer/bckp/translator-core

Statistics

Installs: 4 077

Dependents: 2

Suggesters: 0

Stars: 3

Open Issues: 0

v3.0.0 2026-10-13 10:57 UTC

README

A small PHP 8.4+ translator with versioned sources and a compiled hot path. The translator and catalogue builder use typed source and storage interfaces without accessing files directly. Compiled PHP storage is included; NEON parsing and Nette integration are optional packages.

Version 3.0 is a breaking redesign. See MIGRATION.md when upgrading from 2.x.

Packages

Package Responsibility
bckp/translator-core Typed messages, source contracts, version checks, catalogue building, translation and compiled PHP storage
bckp/translator-neon NEON source plugin, in its own repository
bckp/translator-nette Nette DI, locale resolution, Latte and Tracy integration (manual)

PHP storage is part of bckp/translator-core in src/PhpCache, with its storage manual. It needs no separate package or repository. The NEON plugin and Nette adapter have their own Composer packages and repositories; neither is required to develop or test the core.

COMPOSER_ROOT_VERSION=3.0.x-dev composer install
composer tests
composer phpstan
composer phpcs
composer benchmark

PHPStan runs at level 8. Source code uses native types, strict types and attributes without docblocks. PHP CS Fixer uses the PER Coding Style 3.0 preset with tab indentation and PHP 8.4 migration rules. EditorConfig also selects tabs for PHP files.

Installation

composer require bckp/translator-core:^3.0

PHP 8.4+ and the tokenizer extension are required. The core has no third-party runtime dependencies. Both PhpCatalogueStorage and MemoryStorage are included and autoloaded from this package.

For NEON files, install the NEON source. For Nette, follow the adapter's installation and configuration.

For development, clone the core and run COMPOSER_ROOT_VERSION=3.0.x-dev composer install. The Nette and NEON development setups use sibling checkouts; an application's Composer configuration must declare its own development/path repositories because Composer does not inherit repositories from dependencies.

Quick start

<?php

declare(strict_types=1);

use Bckp\Translator\CatalogueBuilder;
use Bckp\Translator\MessageCatalogue;
use Bckp\Translator\PhpCache\PhpCatalogueStorage;
use Bckp\Translator\PluralMessage;
use Bckp\Translator\Sources\StaticSource;
use Bckp\Translator\Translator;

require __DIR__ . '/vendor/autoload.php';

$messages = (new MessageCatalogue())
    ->add('messages.welcome', 'Vítejte')
    ->add('messages.hello', 'Ahoj %s')
    ->add('messages.people', new PluralMessage(
        zero: 'žádný člověk',
        one: '%d člověk',
        few: '%d lidé',
        other: '%d lidí',
    ));

$builder = (new CatalogueBuilder(
    storage: new PhpCatalogueStorage(__DIR__ . '/temp/translations'),
    locale: 'cs',
    namespace: 'my-application',
))
    ->addSource('application', new StaticSource($messages, 'release-42'))
    ->setCheckProbability(0.01);

$catalogue = $builder->compile();
$translator = new Translator($catalogue);
echo $translator->translate('messages.hello', 'Ada');
echo $translator->translate('messages.people', 4);

A MessageCatalogue only accepts a string or a PluralMessage. A plural message has named, nullable string variants and must contain at least one variant. Empty translations and the key/value "0" are valid. A missing key returns the original message; a missing plural variant falls back to the first defined variant in this order: other, many, few, two, one, zero. This order is independent of the input's order. Diagnostics can report a missing form even when a fallback is available.

Direct catalogue access

A runtime Catalogue::get(string $key, float|int|string|null $number = null): ?string returns the selected text, including its fallback, or null for an unknown key. Ordinary messages ignore the number. Only plural entries inspect the quantity: numeric values are converted to integers for the locale's rule, while omitted or nonnumeric values select Other. The translator forwards the first positional formatting parameter unchanged.

$catalogue->get('messages.welcome', 4); // 'Vítejte'
$catalogue->get('messages.people', 4); // '%d lidé'
$catalogue->get('messages.people'); // '%d lidí'
$catalogue->get('messages.missing'); // null
$translator->translate('messages.people', 4); // '4 lidé'

get() selects the text without formatting it. translate() also substitutes formatting parameters. The source-input container is different: MessageCatalogue::get() returns string|PluralMessage|null before compilation.

The built-in storages also implement the optional Interfaces\FormattedCatalogue. Its format() method selects the text with the default normalizer already applied, ready for vsprintf(). Existing Catalogue implementations require no changes; the translator normalizes their texts at runtime.

Catalogue::missingPluralForm() accepts the same key and quantity and returns the missing Plural category, or null for a defined form, ordinary text or unknown key. It does not report anything or mutate the catalogue. When diagnostics are enabled, the translator uses a DiagnosticCatalogue wrapper to report unknown keys and missing forms. The production translation path contains no diagnostics checks.

Sources merge in registration order: the last source wins for an identical key. StaticSource snapshots its input. Storage also snapshots the definition, so later edits to source messages do not change a live catalogue.

Formatting and normalization

translate() accepts a string or Stringable message and string|int|float formatting parameters. With no parameters, it returns the selected text unchanged, including any placeholders. With parameters, it reads the normalized text and applies vsprintf(). Invalid formatting throws TranslatorException with the original ValueError as its cause.

The default Translator::normalize() escapes Nette form tokens %label, %value and %name so that formatting leaves them intact. The built-in storages apply it during compilation and retain only texts it changes. Supply a Closure to change normalization, for example to preserve another literal token:

$builder->setNormalizer(
	static fn(string $text): string => Translator::normalize(str_replace('%product', '%%product', $text)),
	'product-token-v1',
);

Register the normalizer before compile(). It must return a string and runs once for each source text and defined plural variant during compilation. A warm cache and translations do not call it. The required nonempty identifier participates in the cache key; change it when the callback's behavior changes, or use forceRecompile() to rebuild explicitly. Callbacks should produce stable output for the identifier. Use first-class callable syntax such as normalizeTranslation(...), or Closure::fromCallable(), to convert another callable into a closure. The built-in storages support custom compilation normalizers; a custom storage must return a catalogue implementing FormattedCatalogue and compile CatalogueDefinition::normalizedMessages().

Translator::setNormalizeCallback() is marked with PHP's native #[Deprecated] attribute on both the implementation and interface. Calling it emits E_USER_DEPRECATED and recommends CatalogueBuilder::setNormalizer(). Legacy runtime callbacks still receive the original selected text on each formatted translation, preserving stateful callbacks and catalogue replacement; they are not called for unknown keys or translations without parameters.

Callback sources and mandatory versions

Register sources before calling compile():

<?php

declare(strict_types=1);

use Bckp\Translator\CatalogueBuilder;
use Bckp\Translator\MessageCatalogue;
use Bckp\Translator\Sources\CallbackSource;
use Bckp\Translator\Storage\MemoryStorage;
use Bckp\Translator\Translator;

$revision = 'initial';
$text = 'Hello';

$source = new CallbackSource(
    loader: static function (string $locale) use (&$text): MessageCatalogue {
        return (new MessageCatalogue())->add('messages.hello', $text);
    },
    version: static function (string $locale) use (&$revision): string {
        return $revision;
    },
);

$builder = new CatalogueBuilder(new MemoryStorage(), 'en');
$builder->addSource('database', $source);
$translator = new Translator($builder->compile());

Both callbacks receive the normalized locale. The loader must return MessageCatalogue; the version callback must return a string. The version argument can also be a fixed string. Invalid callback results throw an exception.

A version is an opaque string compared for equality, never ordered. Use a content hash, database revision, timestamp converted to string, deployment identifier, or a constant for immutable data. Empty strings and "0" are valid versions too. There is no unversioned source.

For a native contract with statically checked callback signatures, implement Bckp\Translator\Interfaces\TranslationSource:

interface TranslationSource
{
    public function getVersion(string $locale): string;

    public function __invoke(string $locale): MessageCatalogue;
}

Keep source constructors free of IO where practical. Nette delays source construction until a catalogue actually needs a version check or reload.

Checking and rebuilding

Situation Version callbacks Load callbacks
Missing cache Always Always
Warm cache, skipped check Never Never
Warm cache, selected check, unchanged versions Once per source Never
Warm cache, changed versions Once per source Once per source
forceRecompile() Always Always, even with identical versions
Subsequent compile() calls on the same builder Never Never
translate() Never Never

setCheckProbability() accepts a finite value from 0 to 1; the default is 0.01. The decision happens once when the locale's catalogue is first initialized. setDebugMode(true) always checks at that point, regardless of probability. It does not check on every individual translation.

At a 1% probability, each warm request using a locale has a 1% chance of checking it. Detection time depends on traffic; this is not a fixed refresh interval. A value of 0 disables automatic verification while still building missing caches.

Create a new builder scope per request or job in long-running workers. A builder holds its catalogue until explicitly forced.

$builder->setDebugMode(true);

$catalogue = $builder->forceRecompile();
$translator->replaceCatalogue($catalogue);

A core translator uses only its current catalogue. replaceCatalogue() updates an existing translator and requires the same locale. The Nette translator offers forceRecompile() directly and updates all existing references automatically.

A failed source load or storage write throws and leaves the existing in-memory catalogue in place. The PHP storage publishes a complete cache file by atomic rename.

Cache identity

The stable storage key includes format version, namespace, normalized locale, plural rule and ordered source IDs. Source versions are stored in the compiled catalogue, so a skipped check does not need to access the sources.

IDs identify the logical sources. When changing a source's configuration while keeping the same ID, update its version, change the namespace, or force a rebuild. A request that skips verification deliberately uses the existing cache. Use separate namespaces when sharing storage between applications or tenants.

Source registrations cannot be added or changed after a builder initializes its catalogue. Source data can change independently; its version must reflect the change or a rebuild must be forced.

NEON files

Install/use the NEON plugin and register it as an ordinary source:

composer require bckp/translator-neon:^1.0
use Bckp\Translator\Neon\NeonSource;

$builder->addSource('files', new NeonSource(__DIR__ . '/locales'));

Files follow {resource}.{locale}.neon, for example messages.cs.neon:

welcome: 'Vítejte'
hello: 'Ahoj %s'
people:
    zero: 'žádný člověk'
    one: '%d člověk'
    few: '%d lidé'
    other: '%d lidí'

Register the source before calling compile(). The file prefix produces keys such as messages.welcome. The plugin computes a content version automatically, including added and removed files. A configured constant version can avoid hashing for immutable release assets. See the plugin documentation.

Storage and diagnostics

MemoryStorage is an alternative without disk persistence. Implement Interfaces\CatalogueStorage to provide another backend; it receives a typed CatalogueDefinition and returns a Catalogue.

Compiled PHP catalogues return ordinary texts directly from a generated match and keep plural strings in private const array Plurals. They create no PluralMessage objects at runtime and inspect quantities only for plural entries. Translation does no source IO, random sampling or version checks. composer benchmark-simple measures plain, formatted and plural translations and asserts that source call counters do not change. See the current v2 comparison and benchmark instructions for PHP 8.4 and 8.5 results, including the generated catalogue's OPcache memory cost.

The PHP storage copies the selected plural rule's body from PluralProvider at compilation. The compiled catalogue then selects plurals directly, without calling the provider. Rebuilding captures any later change to that rule.

An optional Diagnostics implementation receives missing messages, plural warnings and CatalogueStatus records containing the stored versions and whether initialization checked/recompiled the catalogue. Rendering diagnostics does not invoke sources.

Pass the same diagnostics object to the builder for catalogue status and to the translator for translation reports:

use Bckp\Translator\Diagnostics\Diagnostics;
use Bckp\Translator\Storage\MemoryStorage;

$diagnostics = new Diagnostics();
$builder = new CatalogueBuilder(new MemoryStorage(), 'cs', diagnostics: $diagnostics);
$builder->addSource('application', new StaticSource($messages, 'release-42'));
$translator = new Translator($builder->compile(), $diagnostics);
$translator->translate('messages.missing');
$diagnostics->getUntranslated()->each(static function (string $key): void {
	echo $key, "\n";
});

The built-in getWarnings() and getUntranslated() return StringList snapshots. Use eachCatalogue() to read stored CatalogueStatus objects. Debug version checking and translation diagnostics are separate choices; setDebugMode(true) does not attach diagnostics by itself.

The built-in plural provider retains Czech, English-style and invariant rules and selects by the base language of normalized locales such as cs-cz. Override getRule() to choose among these rules. A different rule implementation requires extending the catalogue/storage contract.

See MIGRATION.md for the 2.x API changes.

Quality tooling

GitHub Actions runs Nette Tester on PHP 8.4 and 8.5, PHPStan level 8, PER 3 with tabs, and composer audit. Existing coverage checks remain in place. The audit fails on known vulnerability advisories and reports abandoned development dependencies; PHPBench currently requires the abandoned Doctrine annotations package even when benchmarks use attributes.

API compatibility

Roave Backward Compatibility Check has isolated dependencies:

composer bc-install
composer bc-check

The check compares committed HEAD against the highest stable 3.x.y or v3.x.y tag. Prereleases and older majors are excluded. Until the first stable 3.x release, it explicitly reports that no baseline exists. CI also compares HEAD with itself to verify the checker and local dependency resolution before that release.

Use composer bc-check -- <tag-or-commit> for an explicit baseline. Keep sibling repositories available as described above; they are linked into the checker's temporary directory. Temporary comparisons are stored under the ignored temp/ directory. Uncommitted changes are not checked.

Dependency updates

renovate.json groups development dependencies and GitHub Actions updates, checks the default branch and nextgen, and schedules updates before 07:00 Europe/Prague on Mondays. Automatic merging is disabled. CI validates this configuration using the official Renovate validator.

The hosted Renovate GitHub App must be enabled for the repository. It discovers configuration on the default branch, so this file must also be present there before activation while nextgen is a separate development branch.

Mutation testing

Infection is deferred because it does not directly support Nette Tester. Adoption will be considered together with a possible move to PHPUnit.

Performance

composer benchmark runs PHPBench with attributes, warmed compiled PHP catalogues, OPcache enabled and JIT/Xdebug disabled. It verifies that translations never call a source loader or version callback. See benchmark instructions for storing and comparing results. CI uploads results separately for PHP 8.4 and 8.5; timing differences are informational.

Publishing a release

A release request is committed in .github/release.json, with the notes in docs/releases/<tag>.md. After that commit reaches main, the Release workflow waits for the successful Tests workflow and all named checks on the same commit before creating an immutable tag and a stable GitHub release. Updating code without changing the request does not publish another release. Existing tags are never moved.