centralnic-reseller/idn-converter
A PHP utility for CentralNic Reseller integrations that converts internationalized domain names between Unicode and Punycode/ASCII, making it easier to validate, register, and look up IDN domains safely.
composer require centralnic-reseller/idn-converter
The package name and the repository (rtldev-middleware-php-idna-translator) do not match one-to-one, so read composer.json first for the real PSR-4 namespace before writing any use statement. In a repo of this shape the source lives in src/, and the converter is almost always a single class with a constructor that takes an options array or bitmask of UTS#46 flags, plus conversion methods for the ASCII (A-label / xn--) and Unicode (U-label) directions. Read in this order:
composer.json — namespace, PHP constraint, whether ext-intl or ext-mbstring is required.README.md — the canonical names of the encode/decode methods and the option keys. Do not guess these; they are the whole API surface.src/ — check whether a failed conversion throws or returns false/null. This determines every try/catch you write afterwards.tests/ — this is your free regression corpus. Copy the tricky fixtures (faß.de, *, _sip._tcp, trailing dots, mixed scripts) into your own test suite so a package upgrade is caught by your tests, not by a customer.The one thing you should do on day one is stop Unicode domain names from reaching an API or a database column unserialised. A user types münchen.de in a form; your registrar/EPP/SDK call expects xn--mnchen-3ya.de.
use Vendor\Idn\Converter; // confirm from composer.json
$converter = new Converter();
// Inbound: user input -> wire format
$ascii = $converter->toAscii('münchen.de'); // "xn--mnchen-3ya.de"
// Outbound: stored/wire format -> what the customer reads
$unicode = $converter->toUnicode('xn--mnchen-3ya.de'); // "münchen.de"
That single pair of calls is 90% of real usage. Everything else in this document is about doing it consistently and in the right place.
Run the converter against a handful of known-hard names in php -r or artisan tinker and eyeball the output. If münchen.de, xn--mnchen-3ya.de, faß.de and example.com. behave sanely, the package is usable for your case; if the method names or return types differ from the README, you have found it in under a minute rather than in a production incident.
The most common source of IDN bugs is not the conversion itself, it is doing it in three places with three different option sets. Pick one canonical form for persistence and comparison — the lowercased A-label — and convert exactly once, on ingress.
// app/Domain/DomainName.php
final class DomainName
{
private function __construct(public readonly string $ascii, public readonly string $unicode) {}
public static function fromUserInput(string $raw, Converter $idn): self
{
$trimmed = trim($raw);
$ascii = $idn->toAscii($trimmed); // throws on invalid input
return new self(strtolower($ascii), $idn->toUnicode($ascii));
}
}
Store ascii in the unique index, keep unicode as a derived/display column (or derive it at render time and store nothing extra). Comparing a U-label with === against another U-label is unsafe — normalization and case mean two different byte strings can be the same domain.
try/catchIn Laravel, wrap the converter in a rule so every form benefits and the error message is user-facing rather than a stack trace.
final class ValidDomain implements ValidationRule
{
public function __construct(private Converter $idn, private array $options = []) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$this->idn->toAscii((string) $value, $this->options);
} catch (Throwable $e) {
$fail('The :attribute is not a valid internationalised domain name.');
}
}
}
Pass an array $options through so per-TLD rules (see §5) do not require a new rule class.
The package has zero dependents and zero stars — treat it as an implementation detail you may need to swap. Declare App\Contracts\IdnConverter with toAscii/toUnicode, adapt the vendor class in App\Support\..., and bind it in a service provider. When you later need symfony/polyfill-intl-idn or ext-intl for a hot path, or the package stalls, only the adapter changes. This costs fifteen minutes and is the highest-value thing on this page.
// app/Providers/AppServiceProvider.php
$this->app->singleton(IdnConverter::class, fn () => new VendorConverterAdapter(
new Converter(/* flags */)
));
Never let a U-label leak back into a comparison, a queue payload, or an API request. Do the toUnicode at the point of rendering — a Blade component, an API resource, a PDF/email template:
// app/Http/Resources/DomainResource.php
'name' => $this->idn->toUnicode($this->resource->domain),
If toUnicode throws for a legacy row that predates the converter, catch and fall back to the raw stored value so one bad record cannot 500 an index page.
Whether faß.de becomes fass.de or xn--fa-hia.de depends on the registry's IDN policy (transitional vs non-transitional processing), not on what the package thinks is correct. Model that as a per-TLD option set resolved from config:
// config/idn.php
return [
'defaults' => ['transitional' => false, 'std3' => true],
'tlds' => ['de' => ['transitional' => true], 'ch' => ['transitional' => false]],
];
Resolve the options from the last label before calling the converter. Guessing wrongly means you submit a name the registry rejects, or worse, one it silently maps to a different name than the customer expects.
For zone imports, CSV uploads, or a backfill of an existing domains table, do not convert in a loop inside a request — a single invalid label throws and aborts the run. Chunk, convert per row inside its own try, and collect failures:
Domain::query()->whereNull('ascii_name')->chunkById(500, function ($rows) use ($idn, &$failed) {
foreach ($rows as $row) {
try {
$row->update(['ascii_name' => $idn->toAscii($row->name)]);
} catch (Throwable $e) {
$failed[] = [$row->id, $row->name, $e->getMessage()];
}
}
});
Wrap this in a queued job for anything over a few thousand rows, and write the failure list to a file the operator can hand back to the customer.
The converter's contract is a domain name. Anything else needs splitting before the call and re-joining after:
*., convert the remainder, re-prepend.@; converting the whole string will either fail or produce nonsense._dmarc.example.com and _sip._tcp.example.com need STD3 rules disabled or a pass-through for the underscore labels.example.com. and example.com are not two rows.If this is used alongside the CentralNic/ISPAPI client, convert before handing the domain to the SDK and never after — command parameters are serialised immediately and a münchen.de in a Check or AddDomain payload will be rejected downstream. Keep the conversion in one thin command/decorator layer so all SDK calls get the same treatment and you can audit it in one place.
Transitional vs non-transitional is the classic footgun. Both behaviours are "correct" UTS#46; they differ on ß, final sigma ς, and ZWNJ/ZWJ. The default in the constructor may not match the registry you are talking to. Decide explicitly, set it, and make it visible in config. Test faß.de against every option set you ship.
The package exists because idn_to_ascii() is not portable. PHP's idn_to_ascii() delegates to ICU through ext-intl, and ICU versions differ across distros, Docker images and PHP builds. That means results — and whether a given code point is allowed at all — can change under your feet on a base-image bump. Using a pure-PHP UTS#46 implementation is the point: keep the vendor call, resist the urge to "simplify" it back to idn_to_ascii() in a refactor.
Normalise before you compare. U-labels pasted from macOS may be NFD while browsers and most inputs produce NFC. Two visually identical names can be byte-different. Convert to A-label and compare that; if you must compare U-labels, normalise to NFC first. Never build a unique index on a raw Unicode input column.
Check for double-encoding. Feeding an already-punycoded label into a Unicode-oriented path can yield xn--xn--.... Make your DomainName::fromUserInput() idempotent: detect an existing xn-- label (or just always run toAscii and verify that toAscii(toAscii(x)) === toAscii(x) in a test) before assuming the input needs encoding.
Underscore labels break under STD3 rules. _dmarc, _sip._tcp, _domainkey are legal DNS names but not legal hostnames; strict STD3 validation rejects them. Internal tooling that validates DNS records must disable STD3 or bypass the converter for the service labels. This is a very common source of "our own DMARC checker rejects our own domain" tickets.
Length limits are in octets, not characters. A 63-character U-label can exceed 63 octets once punycoded, and a name under 253 Unicode characters can exceed 253 octets as an A-label. Do not validate length yourself — let the converter's DNS-length check do it, and make sure you have enabled that option.
Catch the right exception. The middleware family of packages tends to throw a dedicated exception rather than return false. Find its exact class in src/, catch that class (not \Exception) in request paths, and let programming errors surface. In bulk jobs, catch broadly but log per row — a silent skip is worse than a failure.
A rejected name today may be a valid name tomorrow, and vice versa. UTS#46 tables track Unicode versions; code points are added and occasionally disallowed, and registries grandfather existing registrations. If you validate on write only, you never notice. Log the converter version (composer.lock hash is enough) alongside any domain you reject, and re-run your corpus after every upgrade.
Test corpus over test coverage. Cover these and you have covered reality: münchen.de, xn--mnchen-3ya.de, faß.de, ☃.net, example.com., *.example.com, _dmarc.example.com, an over-long label, a label starting with a hyphen, a mixed-script/BiDi name (مثال.إختبار), and the empty string. Assert both directions and that toUnicode(toAscii($x)) is stable.
Pin the version; the bus factor is one. Zero stars and zero dependents means no ecosystem pressure to keep behaviour stable, and the "last release" date tells you nothing about activity. Commit a composer.lock, constrain to a minor range (^1.0), and keep the adapter from §3 so a swap is an afternoon, not a sprint.
When debugging, log input, output and flags together. Nearly every IDN bug report is "it says invalid" or "it changed my name" and is unactionable without the exact option set. A single structured log line with the raw input, the ASCII result, and the resolved per-TLD options turns those into five-minute fixes.
How can I help you explore Laravel packages today?