Unicode MessageFormat 2 for PHP.
MF2 formats one message, already in the target language, against some data and a locale. It
is not a translation system and not a catalogue: message ids, resource files and locale lookup
are deliberately outside its specification, which is why they live in a separate package
(meraki/messageformat-resource) tracking the W3C's message resource work.
The specification became Stable in CLDR 47 (March 2025). There is no other PHP
implementation: ext-intl binds MessageFormat 1, and MF2 lives in icu::message2, which the
extension does not expose.
Status: in development. The scanner, the grammar's character classes and the error hierarchy exist. The parser, data model, selection engine and function registry do not yet. It is not usable and not released.
Everything the tests need — PHP 8.5, ext-intl, a pinned ICU, Xdebug, Composer — comes from
.devcontainer/. Nothing has to be enabled in a global php.ini.
In VS Code: Reopen in Container. Dependencies install on create.
Without VS Code:
docker compose run --rm dev composer ci # everything CI runs
docker compose run --rm dev composer test # just the suite
docker compose run --rm dev bash # a shellThis library's correctness is partly defined by a data version. ICU carries CLDR, CLDR carries the plural rules and the number and date formats, and some of the conformance suite's expected strings are CLDR data rather than library logic. "Whatever ICU this machine happens to have" is therefore not a good enough answer, so the version is declared in a file next to the code.
The trap that makes this concrete: on Alpine, icu-libs depends on a virtual icu-data
package that either icu-data-en (English only, 2.9 MB) or icu-data-full (31 MB) can satisfy.
Get the former and nothing fails — intl loads, INTL_ICU_VERSION reports correctly, and every
non-English locale quietly falls back to root. Czech would select one/other instead of
one/few/many/other, so the plural tests would be wrong rather than red.
composer test:environment exists to catch exactly that, and runs first in CI.
ICU version is the only knob, because it comes from the base image's distribution:
| service | base | ICU | CLDR | role |
|---|---|---|---|---|
dev |
php:8.5-cli-trixie |
76 | 46 | development and the CI gate |
icu72 |
php:8.5-cli-bookworm |
72 | 42 | oldest supported |
icu78 |
php:8.5-cli-alpine3.24 |
78 | 48 | newest; where ICU4J's MF2 sits |
docker compose run --rm icu72 composer test
docker compose run --rm icu78 composer testOnly dev is a gate. The other two are drift detection: when an assertion passes on one ICU
and fails on another, the difference is CLDR data rather than this library, and such an
assertion belongs in the icu PHPUnit group. CI marks the matrix continue-on-error for that
reason — a difference there is a review prompt, not a broken build.
No distribution packages ICU 77 (CLDR 47), the release MF2 stabilised in. That is accepted
rather than worked around: the conformance fixtures are generated by the working group's
JavaScript implementation, not by ICU, so pinning ICU 77 would not make them more
authoritative — it would only change what :number and :date print.
tests/fixtures/conformance/ is a verbatim copy of the Unicode working group's own
fixtures, pinned to one commit in tests/fixtures/conformance/UPSTREAM. Do not edit it.
docker compose run --rm dev composer suite:update # refresh and re-pin
docker compose run --rm dev composer suite:check # fail if the pin is behind upstreamA copy rather than a submodule, so that git clone plus composer install is enough to run the
tests, and so that an upstream change shows up in review as the expectations that moved rather
than as one line of hex. suite:check runs weekly on a schedule instead of on every push,
because upstream moving is not a reason to fail somebody's unrelated commit.
tests/fixtures/conformance/message.abnf is pinned from the same commit, and
tests/Syntax/AbnfAgreementTest.php re-derives every character class from it and fails if
Syntax\CodePoints has drifted — so the grammar cannot quietly disagree with the transcription
of it.
A Docker bind mount on Windows inherits the host filesystem's case-insensitivity, so a
path that differs from another only by case resolves to the wrong one inside the container
and the tests still pass. Linux CI is the only place that surfaces. It has already happened
once, between tests/conformance/ and a planned tests/Conformance/, which is why the
fixtures now live under tests/fixtures/. Treat a green local run as necessary and not
sufficient.
MIT.