rowbot/idna
rowbot/idna provides IDNA 2008 and UTS #46 processing for PHP: convert internationalized domain names to ASCII (Punycode) and back, with validation and normalization for safe, spec-compliant handling of Unicode domains.
rowbot/idna is a plain library, not a Laravel package — there's no service provider, no config/idna.php, nothing to publish. composer require rowbot/idna and you're done. That also means it's safe with Octane and config caching, and there's nothing to register in config/app.php.
Where to look first:
Rowbot\Idna\Idna — the entry point. Two static methods matter for 95% of real usage: Idna::toAscii() and Idna::toUnicode().string back, you get an object with getDomain(), hasErrors() and getErrors().ErrorCollector — the diagnostics extension point if you need per-codepoint error reporting.The single most common first use case: a user types münchen.de into a signup form and you need the ASCII form to store, compare, and eventually hand to DNS.
use Rowbot\Idna\Idna;
$result = Idna::toAscii('münchen.de');
$result->getDomain(); // "xn--mnchen-3ya.de"
$result->hasErrors(); // false
$result->getErrors(); // [] — or a list of UTS#46 processing errors
// Display path:
$display = Idna::toUnicode('xn--mnchen-3ya.de')->getDomain(); // "münchen.de"
The reason to reach for this over idn_to_ascii() is that it does not require ext-intl. And unlike the intl function, it implements the WHATWG-flavoured UTS#46 processing with error reporting, so you can distinguish "this is a valid IDN" from "this got mangled into something". If you're also parsing full URLs, pair it with its sibling rowbot/url; rowbot/idna only handles hosts, not schemes, ports or paths.
Wrap the result-object dance in one small class and bind it as a singleton. Every other pattern below calls this.
namespace App\Support;
use Rowbot\Idna\Idna;
final class DomainNormalizer
{
/** Returns the canonical ASCII form, or null if it isn't a usable domain. */
public function toAscii(string $input): ?string
{
$input = trim($input);
if ($input === '') {
return null;
}
// IP literals are not domains — UTS#46 has nothing to say about them.
if (filter_var($input, FILTER_VALIDATE_IP) !== false) {
return $input;
}
$result = Idna::toAscii($input);
if ($result->hasErrors()) {
return null;
}
return $result->getDomain();
}
public function toUnicode(string $input): ?string
{
$result = Idna::toUnicode(trim($input));
return $result->hasErrors() ? null : $result->getDomain();
}
}
'url' and filter_var(..., FILTER_VALIDATE_URL) both reject Unicode hosts outright, and neither is IDNA-aware. Use a dedicated rule instead:
namespace App\Rules;
use App\Support\DomainNormalizer;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class ValidDomain implements ValidationRule
{
public function __construct(private DomainNormalizer $normalizer) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$ascii = $this->normalizer->toAscii((string) $value);
if ($ascii === null || !str_contains($ascii, '.') || strlen($ascii) > 253) {
$fail("The :attribute field must be a valid domain name.");
}
}
}
Then $request->validate(['domain' => ['required', new ValidDomain(app(DomainNormalizer::class))]]). For email, split on the last @ and normalize only the domain part — don't run the whole address through IDNA.
This is the pattern that pays for itself fastest. Keep an xn-- column as the source of truth for uniqueness/DNS, and a Unicode column purely for UI.
protected static function booted(): void
{
static::saving(function (self $model) {
$normalizer = app(DomainNormalizer::class);
$model->domain_ascii = $normalizer->toAscii($model->domain_input);
$model->domain_unicode = $normalizer->toUnicode($model->domain_ascii);
});
}
Index domain_ascii as unique. münchen.de and xn--mnchen-3ya.de are the same host, and this makes that a database-level guarantee rather than a hope.
Anywhere you compare hosts — trusted-host middleware, tenant resolution from Request::getHost(), an email-domain allowlist — normalize both sides before ===. Incoming Host headers are raw bytes and may arrive as Unicode, punycode, mixed case, or with a trailing dot. A tenant lookup keyed on the raw header will silently miss.
Processing walks Unicode tables per codepoint. It's fast, but it's not free, and imports/seeders/queues can hammer it. Memoize in the normalizer or wrap with Cache::remember('idna:' . md5($input), ...). Avoid calling it inside a foreach over 100k rows without a cache.
Run normalization once on ingest and persist the canonical value. Never normalize at render time for anything security-relevant — you want the stored value to already be the compared value.
Transitional vs. nontransitional processing. faß.de becomes xn--fa-hia.de under nontransitional rules, but fass.de under the old transitional rules. Browsers (and WHATWG) are nontransitional. Be explicit about which you want and keep it consistent between validation, storage, and any comparison against idn_to_ascii() output from elsewhere in the stack — otherwise the same domain gets two different keys.
hasErrors() is not a registry policy check. It catches UTS#46-level problems: disallowed codepoints, bidi rule violations, hyphen placement, label-length and total-length limits. It does not know that a TLD forbids emoji or that a registrar has its own restrictions. Emoji domains can pass cleanly.
It normalizes more than case. UTS#46 mapping folds full-width characters, the ideographic full stop 。 (U+3002), and various lookalikes into .. That's a feature — example。com normalizes predictably — but it means your "invalid domain" log may contain input that looked nothing like a domain to the user. Echo back what they typed, not the normalized form.
Don't double-encode. toAscii() on an already-punycode host is safe/idempotent, but a toUnicode() → toAscii() round trip is not guaranteed to be byte-stable for every edge case. Pick ASCII as the stored canonical form and never round-trip it again.
Trailing dots. example.com. is a valid FQDN and will survive processing with the dot attached. Strip it deliberately (or don't) — but be consistent, or example.com and example.com. become two tenants.
Length limits are enforced by UTS#46, but check the total yourself. 63 bytes per label is enforced; a 253-byte total is easy to check in your rule and saves a mystifying failure.
Unicode version drift on upgrade. The tables track a Unicode release. Minor/patch bumps usually don't change results, but major bumps can — pin the version and re-run your domain fixtures after composer update.
The beStrict flag. The second argument mirrors the WHATWG beStrict flag: strict mode treats processing failures as fatal rather than returning a lenient best-effort result. For anything driven by user input, leave it at the default and branch on hasErrors() so you can render a validation message instead of a 500. Verify the exact signature against the installed version before assuming.
Debugging with the error collector. When a domain fails and you can't see why, process it with an error collector and dump $result->getErrors() — you'll get the specific codepoints and rule violations rather than a bare false. This is the fastest path from "why is this rejected" to "because of a ZWJ at index 4".
Homograph / spoofing is out of scope. toAscii() will happily give you a valid punycode host that visually impersonates another brand. If you care, add your own mixed-script check (compare the codepoint ranges in the Unicode form) on top — IDNA validation is not phishing protection.
Don't feed it bare strings and expect a boolean. The most common bug in PRs using this library is if (Idna::toAscii($host)) — an object is always truthy. Always go through hasErrors().
How can I help you explore Laravel packages today?