mlocati/idna
Pure-PHP implementation of IDNA 2008 and UTS #46 for converting internationalized domain names between Unicode and ASCII (punycode). Validates and normalizes domain names, supports PHP 5.3+, and works without the intl extension, making it a lightweight drop-in for encoding/decoding IDN domains.
mlocati/idna is a standalone PHP implementation of IDNA 2008 (not a Laravel package), so there is no service provider, config file, or facade to publish. Install it and start converting names:
composer require mlocati/idna
Where to look first, in order:
README.md — the canonical usage examples and the exact factory/accessor names for the installed version.src/DomainName.php — the single value object you will use 95% of the time.src/Exception/ — the exception types thrown on invalid input.The first real use case is almost always: accept a user-entered Unicode domain, validate it, and persist the ASCII/Punycode form for DNS and mail while keeping the Unicode form for display.
use MLocati\IDNA\DomainName;
try {
$domain = DomainName::fromUnicode('münchen.example');
$punycode = $domain->getPunycode(); // xn--mnchen-3ya.example
$unicode = $domain->getUnicode(); // münchen.example
} catch (\Throwable $e) {
// invalid domain name
}
If your framework or app has no IDN support yet, wire this in as the single conversion entry point (a small DomainNormalizer service) so conversions are consistent across validation, storage, and outbound HTTP/DNS.
Normalize once, store twice. Keep the Punycode form as the canonical, unique, lowercase column and the Unicode form for rendering. Never compare Unicode forms directly.
$ascii = DomainName::fromUnicode($input)->getPunycode();
$model->domain_ascii = strtolower($ascii);
$model->domain_unicode = DomainName::fromUnicode($input)->getUnicode();
Laravel validation rule. Wrap construction in try/catch so malformed input becomes a validation message instead of a 500. In Laravel 10+:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use MLocati\IDNA\DomainName;
class IdnDomain implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || $value === '') {
$fail("The {$attribute} must be a domain name.");
return;
}
try {
$ascii = DomainName::fromUnicode($value)->getPunycode();
} catch (\Throwable $e) {
$fail("The {$attribute} is not a valid internationalized domain name.");
return;
}
if (strlen($ascii) > 253) {
$fail("The {$attribute} is too long.");
}
}
}
Use it as ['required', new IdnDomain] anywhere a hostname arrives (signup, custom domains, mail-from addresses).
Email addresses. Split on the last @, convert only the domain part, and store the ASCII form for delivery while displaying the Unicode form:
$pos = strrpos($email, '@');
$local = substr($email, 0, $pos);
$domain = substr($email, $pos + 1);
$asciiEmail = $local . '@' . DomainName::fromUnicode($domain)->getPunycode();
URLs and redirects. Let parse_url() do URL parsing; pass only the host component into this library. This keeps the package's responsibility narrow and avoids surprises with paths, ports, and userinfo.
$host = parse_url($url, PHP_URL_HOST);
$asciiHost = DomainName::fromUnicode($host)->getPunycode();
Outbound HTTP / DNS. Any client that resolves hostnames (Guzzle, Laravel's HTTP client, curl) should receive the Punycode host, not the Unicode string. Centralize that in a middleware, a Guzzle handler, or a small wrapper service so outbound calls cannot accidentally send raw Unicode.
Bulk imports and queues. Conversion is pure CPU and fast, but for large imports (tens of thousands of rows) process in chunks inside a queued job and cache results keyed by input string. The underlying IDNA tables are loaded into memory per process, so a long-running worker amortizes that cost naturally.
Optional container binding. Since DomainName is an immutable value object, there is nothing to singleton-bind. If you want testability, wrap it:
$this->app->bind(DomainNormalizer::class, MlOcatiNormalizer::class);
and inject that everywhere instead of calling DomainName::fromUnicode() directly, so tests can stub conversions.
Display layer. Add a Blade helper/accessor such as $model->display_domain that returns the stored Unicode form, falling back to Punycode. Keep the ASCII value as the source of truth for uniqueness constraints and lookups.
idn_to_ascii() follows UTS #46 (and can be transitional), so the two disagree on characters like ß, ς, and ZWJ/ZWNJ sequences. Pick one implementation and use it consistently; don't mix this package with intl for the same field.fromUnicode() treats xn--... as a literal ASCII label. Detect the xn-- prefix and choose fromPunycode() vs fromUnicode() explicitly, or normalize both directions and compare.Normalizer::normalize($value, Normalizer::FORM_C) (ext-intl) before conversion if you accept pasted or imported data; otherwise visually identical strings can map to different labels.example.com.), because it affects uniqueness checks.аpple.com with a Cyrillic а). If you deal with user-supplied brand domains, add a confusables check separately.parse_url() first. This library is for domain names, not URLs. Feeding it a full URL, an email, or a host with a port will fail or, worse, silently validate a wrong substring.vendor/. Confirm your deploy pipeline (rsync excludes, Docker COPY filters, --no-dev with custom pruning) does not strip non-PHP files from this package.composer.json, review the changelog on upgrade, and wrap it behind your own normalizer so swapping to symfony/polyfill-intl-idn or true/punycode later is a one-class change.täst.de → xn--tst-qla.de, 例え.テスト → xn--r8jz45g.xn--zckzah) to catch accidental behavior changes after upgrades.How can I help you explore Laravel packages today?