rowbot/punycode
rowbot/punycode is a pure PHP implementation of the Punycode algorithm (RFC 3492) for encoding and decoding internationalized domain names. It offers a simple API with strict error handling, making it easy to convert Unicode labels to ASCII and back again.
Install the package with Composer:
composer require rowbot/punycode
First, read the README and the public API in src/Punycode.php; then glance at the test suite for RFC 3492 vectors and exception behavior. The package is a low-level Punycode codec, not a full IDNA processor, so the main entry point is the codec’s encode() / decode() pair.
A good first use case is converting a single non-ASCII domain label to its ASCII A-label and back:
use Rowbot\Punycode\Punycode;
$codec = new Punycode();
$label = $codec->encode('mañana'); // "maana-pta"
$domain = 'xn--' . $label; // "xn--maana-pta"
$unicode = $codec->decode('maana-pta'); // "mañana"
Keep in mind that Punycode operates on individual labels, not whole domains. The xn-- ACE prefix is normally added/removed by your IDNA layer, not by the raw Punycode algorithm.
Wrap it in a small IDN service. Avoid scattering encode()/decode() calls throughout controllers and models. A dedicated service makes prefix handling, caching, validation, and swapping implementations much easier.
final class IdnService
{
public function __construct(private Punycode $codec) {}
public function labelToAscii(string $label): string
{
if ($label === '' || preg_match('/^[\x00-\x7F]+$/', $label)) {
return strtolower($label);
}
return 'xn--' . strtolower($this->codec->encode($label));
}
public function labelToUnicode(string $label): string
{
if (!str_starts_with(strtolower($label), 'xn--')) {
return $label;
}
return $this->codec->decode(substr($label, 4));
}
}
Process domains label by label. Split on ., encode only labels containing non-ASCII characters, then rejoin. Do not pass münchen.de to encode() as one string.
public function domainToAscii(string $domain): string
{
$trailingDot = str_ends_with($domain, '.');
$labels = explode('.', rtrim($domain, '.'));
foreach ($labels as &$label) {
$label = $this->labelToAscii($label);
}
return implode('.', $labels) . ($trailingDot ? '.' : '');
}
Store a canonical ASCII form. In Laravel, keep the A-label form as the normalized value used for uniqueness, lookup, and outbound requests, and keep the Unicode form for display. For example, a domains table can have ascii_name indexed and unicode_name for UI.
$domain->ascii_name = $idn->domainToAscii($request->input('domain'));
$domain->unicode_name = $request->input('domain');
Normalize email domains. Split at the last @, apply domainToAscii() to the domain part, and leave the local part untouched. This is useful when validating or de-duplicating users with internationalized email domains.
[$local, $domain] = explode('@', $email, 2);
$normalized = $local . '@' . $idn->domainToAscii($domain);
Normalize URL hosts before validation or fetching. Use parse_url() to isolate the host, convert it with the IDN service, then rebuild carefully. Remember that parse_url() is not a full URL parser; for complex URLs, prefer a dedicated URL library.
Bind it as a singleton in Laravel. The codec is stateless and cheap to reuse.
$this->app->singleton(Punycode::class, fn () => new Punycode());
Add a validation rule. A custom rule that attempts conversion and catches codec exceptions gives much better errors than letting a PunycodeException bubble up as a 500.
try {
$ascii = $idn->domainToAscii($value);
} catch (\Throwable $e) {
$fail('The :attribute is not a valid internationalized domain.');
}
Cache repeated conversions. Domains are often processed repeatedly in imports, crawls, or list views. A small in-request array cache keyed by label is usually enough.
ext-intl (idn_to_ascii() / idn_to_utf8()) or a full IDNA package such as rowbot/idna. Use this package when you specifically want a small RFC 3492 codec or need to avoid ext-intl.encode('münchen.de') is not the same as encoding münchen and leaving .de alone. Always split labels first.xn-- prefix explicitly. Most Punycode implementations return the encoded label without the ACE prefix. Add it when producing DNS/ASCII labels and strip it before decoding.mañana and its canonically equivalent forms can produce different A-labels. Apply NFC at minimum, and use full IDNA mapping if you need full compatibility.xn-- labels can throw. Treat decode errors as “not a valid IDN” rather than allowing an exception to crash the request.example.com. is a valid absolute domain name; naive explode()/implode() handling can drop the trailing empty label... Full IDNA mapping handles these; raw Punycode does not.bücher → bcher-kva, mañana → maana-pta, 例え → r8jz45g, and round-trip assertions (decode(encode($label)) === $label) catch most integration mistakes.composer.json for the required PHP version and extensions before adopting it in older Laravel projects.How can I help you explore Laravel packages today?