Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Idna Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Rowbot\Idna\Idna — the entry point. Two static methods matter for 95% of real usage: Idna::toAscii() and Idna::toUnicode().
  2. The result object returned by those methods — this is the thing that surprises people. You don't get a string back, you get an object with getDomain(), hasErrors() and getErrors().
  3. 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.

Implementation Patterns

A normalizer service (wire it once, use everywhere)

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();
    }
}

Validation rule / FormRequest

'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.

Store both forms: canonical ASCII + display Unicode

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.

Deduplicating allowlists and tenant lookups

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.

Caching

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.

Bulk / import pipeline

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.

Gotchas and Tips

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().

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons