wikimedia/bcp-47-code
A lightweight PHP library offering value objects for BCP 47 language codes, letting you parse, validate, and format tags like en, de-AT, or zh-Hant safely instead of passing raw strings around.
This is a very small, single-purpose package: it gives you a Bcp47Code interface and a Bcp47CodeValue value object so that "a language code" can be passed around as a type instead of a bare string that might be either a BCP 47 tag or some application-internal code. It is not a parser, a negotiator, or an IANA registry lookup — treat it as a type-safety primitive.
composer require wikimedia/bcp-47-code
The whole surface area is two files under Wikimedia\Bcp47Code\:
Bcp47Code — the interface (toBcp47Code(): string, plus __toString()).Bcp47CodeValue — a final implementation with a named constructor.First use case — make invalid codes fail at the edge, not deep in a formatter:
use Wikimedia\Bcp47Code\Bcp47Code;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
function renderGreeting( Bcp47Code $lang ): string {
return match ( $lang->toBcp47Code() ) {
'en', 'en-GB' => 'Hello',
'fr' => 'Bonjour',
'de' => 'Hallo',
default => 'Hello',
};
}
renderGreeting( Bcp47CodeValue::newFromBcp47( 'en-GB' ) );
Read Bcp47CodeValue's constructor/newFromBcp47() first — it tells you exactly which tags are accepted and whether invalid input throws, which drives most of the advice below.
Type-hint at your module boundaries, construct at the edges. The value of the package comes from forcing every language code crossing an API, service, or event boundary to be a Bcp47Code. Parse/normalize once where input arrives (HTTP header, DB row, config), then pass the object inward.
Wrap legacy/internal codes with an adapter. The whole reason this package exists in MediaWiki is that the platform's internal codes (en-gb, zh-hans, simple) are not BCP 47. Do the same: keep your internal representation in one place and expose a thin adapter.
final class InternalLanguage implements Bcp47Code {
public function __construct( private string $internalCode ) {}
public function toBcp47Code(): string {
return str_replace( '_', '-', $this->internalCode ); // your mapping rules
}
public function __toString(): string {
return $this->toBcp47Code();
}
}
Implementing the interface yourself is the intended extension point since Bcp47CodeValue is final.
Normalize once, compare on the lowercase form. BCP 47 tags are case-insensitive, but the object preserves whatever casing you gave it. If you key caches, array_unique, or compare, normalize explicitly:
$key = strtolower( $lang->toBcp47Code() ); // safe cache key
Persist the string, rehydrate on read. Don't serialize the object. Store toBcp47Code() in the column, and rebuild with Bcp47CodeValue::newFromBcp47() on hydration — that also gives you a natural place to surface corrupt legacy rows.
Compose with a real negotiator. For Accept-Language handling you still need a parser/negotiator (negotiation, symfony/intl, or your own). Use this package afterward to guarantee that whatever the negotiator selects is a well-formed tag before it reaches business logic.
$selected = $negotiator->getBest( $available, $request->getHeaderLine( 'Accept-Language' ) );
$lang = Bcp47CodeValue::newFromBcp47( $selected->getValue() );
Test with realistic tags. Because construction validates, your fixtures should include a plain tag (fr), a region tag (pt-BR), a script tag (zh-Hans), and — if you rely on it — a private-use tag (x-mycompany). Tests for the unhappy path should assert the exception type rather than the message.
newFromBcp47() validates against a BCP 47-shaped grammar and throws InvalidArgumentException on garbage. Tags that look "wrong" but are legal (private use x-…, grandfathered forms) may pass; things like en_US or EN gb will not. Pin the version and read the regex rather than assuming.symfony/intl or a dedicated RFC 5646 library and only use this package for the type boundary.zh-hans ≠ zh-Hans as far as the other system is concerned.__toString() is a convenience, not a contract. Prefer explicit toBcp47Code() in production code; __toString() is there so the object behaves in string interpolation and log contexts. Never rely on implicit casting for anything security- or storage-sensitive.Bcp47CodeValue instances wrapping en-GB and en-gb are different strings. Normalize before comparing or hashing.Bcp47CodeValue is final. Add behavior through a decorator or your own implementation of the interface, not by subclassing.mediawiki.org/wiki/Developer_account. Expect a low-traffic project (single-digit stars, essentially zero public dependents outside MediaWiki) — budget for the possibility that you fix issues yourself rather than waiting on upstream.var_dump() the raw bytes first — trailing whitespace, non-breaking spaces, and _ vs - substitutions from copy-pasted locale data are the overwhelmingly common causes, and the exception message won't always make that obvious.How can I help you explore Laravel packages today?