What is Language Tag (BCP 47)?
BCP 47 is the IETF standard for language tags, the identifiers you see in HTML lang attributes, the Accept-Language header, and most web APIs. A tag is built from subtags: a primary language subtag (usually ISO 639-1, like en or es), optionally a script subtag (ISO 15924, like Hant for Traditional Chinese), a region subtag (ISO 3166-1, like US or BR), and further variants. Examples: en, en-US, zh-Hant, sr-Latn-RS.
The distinction from ISO 639-1 matters: ISO 639-1 is just the list of two-letter language codes, one ingredient. BCP 47 is the grammar that composes those codes with scripts and regions into a full identifier, plus matching rules for finding the best available locale for a request.
Convention note: BCP 47 uses hyphens (en-US), while POSIX and PHP locale identifiers use underscores (en_US). Web-facing surfaces like the HTML lang attribute should get the hyphenated form, so convert at the boundary if your app stores locales with underscores.
Reading a tag
Subtags are separated by hyphens, and each kind has a recognizable shape. Two or three letters is a language (en, es, fil), four letters is a script (Latn, Cyrl, Hant), and two letters or three digits is a region (BR, TW, 419). Tags are case-insensitive, but the convention is a lowercase language, a title-case script and an uppercase region, and that is what most tools emit.
A few tags show why the parts matter. es-419 is Spanish as used in Latin America, written with a UN M.49 numeric code because no single country covers the region. zh-Hant and zh-Hans separate Traditional and Simplified Chinese by script, which is usually the better choice over zh-TW and zh-CN when what you care about is the writing system rather than the country. sr-Latn-RS is Serbian in Latin script as used in Serbia.
Locale::parseLocale('sr-Latn-RS');
// ['language' => 'sr', 'script' => 'Latn', 'region' => 'RS']
Locale::getDisplayName('es-419', 'en'); // "Spanish (Latin America)"
Locale::canonicalize('zh-hant-tw'); // "zh_Hant_TW", ICU's underscored form
BCP 47 at the edges of a Laravel app
Laravel's lang directories and the plural rules behind trans_choice() use underscored identifiers like pt_BR, while browsers, HTTP headers and HTML use hyphens. Keep one internal form, the underscored one, and convert only where a locale enters or leaves the app.
Coming in, $request->getPreferredLanguage() parses Accept-Language and already returns underscored values. When nothing matches, it returns the first locale of the list you pass rather than null, so put your default first. Locales that arrive through URLs, cookies or JSON bodies need converting yourself before App::setLocale(): a locale set as pt-BR finds no lang/pt-BR directory, and trans_choice() treats it as a language without plural forms.
Going out, the HTML lang attribute, hreflang annotations and the Content-Language header expect the hyphenated form, which is one str_replace() away.
// Accept-Language: pt-BR,pt;q=0.9,en;q=0.8
$request->getLanguages(); // ['pt_BR', 'pt', 'en']
$request->getPreferredLanguage(['en', 'pt_BR', 'es']); // "pt_BR"
$request->getPreferredLanguage(['en', 'es']); // "en": no match, first item
// A locale from a URL segment, after checking it against your supported list
App::setLocale(str_replace('-', '_', $locale));
// Back to BCP 47 in the layout
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
Matching when the exact tag is missing
Requests rarely name a locale you have exactly, so something has to decide what to serve instead. The lookup algorithm defined alongside BCP 47 removes subtags from the end until something matches: zh-Hant-TW tries zh-Hant, then zh. PHP's Locale::lookup() implements it, which means a request for pt-PT will not match a pt_BR translation: it truncates to pt and stops there.
Symfony's getPreferredLanguage(), which Laravel requests inherit, is more forgiving and also falls back across regions of the same language. That is convenient for fr-CA reaching fr, and wrong for a zh-Hant-TW request that matches a supported zh_CN, serving Simplified Chinese to a reader of Traditional. Decide per language which fallbacks are acceptable, and add explicit mappings for scripts.
Locale::lookup(['en', 'pt', 'es'], 'pt-BR', true, 'en'); // "pt"
Locale::lookup(['en', 'pt_BR', 'es'], 'pt-PT', true, 'en'); // "en"
// Accept-Language: zh-Hant-TW
$request->getPreferredLanguage(['en', 'zh_CN']); // "zh_CN"
Common mistakes
Using country codes as language codes is the most common: the language of Japan is ja, not jp; Danish is da, not dk; Swedish is sv, while se is Northern Sami. Inventing regions is next: the ISO 3166-1 code for the United Kingdom is GB, so en-GB is correct and en-UK is not, even where a library quietly accepts it. The last is treating the tag as a currency or time zone setting. A user reading en-US may live in Berlin and pay in euros, so keep those as separate preferences.