algo26-matthias/idna-convert
PHP library for converting internationalized domain names between Unicode and Punycode. Implements IDNA 2003 and UTS #46 (IDNA 2008) standards with configurable encoding and decoding options. A mature, standalone package compatible with PHP 5.3 through 8.x and used by phpMyAdmin.
Install with Composer; there is no service provider or config file to publish:
composer require algo26-matthias/idna-convert
Check composer.json for the PHP constraint before adopting — the 3.x line targets modern PHP (7.4/8.x). If you are pinned to an older runtime, pin the matching major.
First place to look is the README's two-class example, because the whole package is essentially two entry points:
use Algo26\IdnaConvert\ToIdn;
use Algo26\IdnaConvert\ToUnicode;
$ascii = (new ToIdn())->convert('münchen.example'); // xn--mnchen-3ya.example
$unicode = (new ToUnicode())->convert('xn--mnchen-3ya.example'); // münchen.example
Everything else in src/ (Punycode\ToPunycode, Punycode\FromPunycode, the legacy IdnaConvert wrapper) is a lower-level or backward-compatible view of the same operation.
The first real use case is a single choke point: take whatever host a user typed (registration form, tenant subdomain, webhook config, email address) and convert it to ASCII before you validate it, resolve it, store it in a unique index, or hand it to an HTTP client. Do the reverse conversion only at the display edge.
Wrap it once, inject everywhere. Bind a thin service in the container rather than new ToIdn() at call sites. It gives you one place to set the IDNA2003/2008 flag, one place to add caching, and one place to mock in tests.
namespace App\Support;
use Algo26\IdnaConvert\ToIdn;
use Algo26\IdnaConvert\ToUnicode;
class Idn
{
public function __construct(
private ToIdn $toIdn,
private ToUnicode $toUnicode,
) {}
public function toAscii(string $host): string
{
$host = mb_strtolower(rtrim(trim($host), '.'), 'UTF-8');
return str_contains($host, 'xn--') ? $host : $this->toIdn->convert($host);
}
public function toUnicode(string $host): string
{
return str_contains($host, 'xn--') ? $this->toUnicode->convert($host) : $host;
}
}
Canonicalize on write, decorate on read. Store the xn-- form as the canonical key — that is what DNS, TLS/SNI, cookies, curl, Guzzle, and your database unique index all agree on. Keep the original Unicode input in a separate display_domain column (or re-derive it with ToUnicode) and expose it through an accessor. Two rows for münchen.de and xn--mnchen-3ya.de is the classic duplicate-tenant bug.
Convert the host only — never the whole URL. Use parse_url(), convert ['host'], reassemble. This automatically sidesteps schemes, ports, userinfo, and paths that the converter will otherwise reject.
Email: split before converting. Convert the domain after the last @; leave the local part untouched unless you are deliberately building an SMTPUTF8 system. Converting the full address mangles quoted local parts and + tags.
[$local, $domain] = explode('@', $email, 2);
$canonical = $local . '@' . $this->idn->toAscii($domain);
Custom validation rule. Laravel's email and url/active_url rules are unreliable on raw Unicode hosts. Normalize first, then validate the ASCII form:
Validator::extend('idn_domain', function ($attribute, $value) {
$ascii = app(Idn::class)->toAscii($value);
return strlen($ascii) <= 253
&& filter_var($ascii, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
});
Length checks belong after conversion. Punycode expands labels: the 63-octet label limit and 253-character domain limit must be measured on the ASCII output, not the UTF-8 string.
Wildcards and multi-label hosts. Split on ., skip *, convert label by label when you need to handle wildcard DNS or _acme-challenge-style records where the full-string converter may not behave as you expect. Use Punycode\ToPunycode when each label is already validated and you only want the encoding step.
Cache hot paths. Conversion is pure and deterministic, so Cache::remember('idn:'.$host, ...) is safe if you are normalizing thousands of hosts per request (log ingestion, sitemap generation). For typical form traffic it is not worth it.
Round-trip test everything. Add a fixture test asserting toUnicode(toAscii($x)) === NFC($x) for the vectors you care about — münchen.de, 例え.テスト, faß.de (where 2003 and 2008 disagree), and your own production domains. This catches accidental IDNA-version drift after a dependency bump.
ß, ς, and ZWJ-containing labels resolve differently. Whichever flag you pick in the constructor, pick it once in your wrapper and be consistent across validation, storage, and outbound requests. A registrar using 2003 and an app using 2008 will disagree about faß.de.ü as one codepoint vs u + combining diaeresis) can produce two visually identical stored values. Run Normalizer::normalize($host, Normalizer::FORM_C) (or the polyfill) before conversion, and add a DB unique constraint on the ASCII column as the real backstop.false on invalid input; newer ones throw. Wrap the call in your service and translate both into a domain-specific exception so callers never get a surprise false written into a varchar.example.com. and example.com:8080 are common inputs and are not valid converter arguments.xn--. Re-encoding ASCII is usually harmless, but a guard makes the code readable and protects you if the library's leniency ever changes.filter_var will reject your Unicode domain. That is expected — always validate the ASCII output. The same applies to FILTER_VALIDATE_EMAIL.curl, and some DNS resolvers will fail or silently mangle a Unicode host. Convert before setting the URL, and use the ASCII form for cookie domains, Host headers, and TLS certificate matching.utf8mb4 index byte limits if you index the display column too.xn-- conversion is a transport encoding, not a security control. If users see domains from other tenants, add your own mixed-script detection or show the ASCII form on hover — the converter will happily produce a Cyrillic lookalike.idn_to_ascii() from ext-intl (if installed) — a mismatch almost always means a normalization or IDNA-version difference, not a networking problem.How can I help you explore Laravel packages today?