Skip to content

IPv4 ↔ IPv6 Converter — PHP source

Convert between IPv4 and IPv6 addresses both ways. Parse and validate addresses, expand and compress IPv6 to its canonical RFC 5952 form, map an IPv4 into IPv4-mapped and IPv4-compatible IPv6 (or any custom /96 prefix), and extract an embedded IPv4 back out.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
// =============================================================================
//  ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: PHP)
//  CosmoDev polyglot port of ip-converter, ported from src/lib/ip-converter.ts.
//  Display source — part of CosmoDev's polyglot tool pages.
//
//  Pure, deterministic IPv4/IPv6 address conversion logic. Every parse function
//  returns `null` (or '' for the string renderers) on invalid input rather than
//  throwing, so the UI can show a graceful error. IPv6 text follows RFC 5952:
//  lowercase hex, no leading zeros, the single longest run of zero groups
//  collapsed to `::`, and a dotted-decimal tail only for IPv4-mapped
//  (`::ffff:`) addresses.
//
//  Namespaced under CosmoDev\IpConverter to keep the global space clean. Uses
//  only PHP's standard library (preg_match, explode/implode, dechex/hexdec).
// =============================================================================

namespace CosmoDev\IpConverter;

// One IPv6 group: 1-4 hex digits. (Namespace-level consts in PHP carry no
// visibility modifier; they are implicitly package-visible here.)
const HEX = '/^[0-9a-fA-F]{1,4}$/';
// One IPv4 octet token: 1-3 decimal digits (range checked separately).
const DEC3 = '/^\d{1,3}$/';

/**
 * Options for embedding an IPv4 octet quad into an IPv6 address.
 */
final class Ipv4ToIpv6Options
{
    /**
     * Embedding family. 'mapped' (default) → `::ffff:a.b.c.d`;
     * 'compatible' → `::a.b.c.d`. Ignored when $prefix is non-null.
     */
    public ?string $mode = null;

    /**
     * Optional custom high-96-bit prefix (a valid IPv6 string; its first 6
     * groups are used and its low 32 bits are overwritten by the IPv4). e.g.
     * `64:ff9b::` yields a NAT64-style `64:ff9b::a.b.c.d`. Overrides $mode.
     */
    public ?string $prefix = null;

    public function __construct(?string $mode = null, ?string $prefix = null)
    {
        $this->mode = $mode;
        $this->prefix = $prefix;
    }
}

/**
 * True when $groups is exactly eight integers in the 16-bit range.
 */
function isValidIpv6Groups(array $groups): bool
{
    if (count($groups) !== 8) {
        return false;
    }
    foreach ($groups as $v) {
        if (!is_int($v) || $v < 0 || $v > 0xffff) {
            return false;
        }
    }
    return true;
}

/**
 * Lowercase hex for one 16-bit group, with no leading zeros.
 */
function hexGroup(int $v): string
{
    return dechex($v);
}

/**
 * Parse a dotted-decimal IPv4 string into four octets, validating each is 0-255.
 * Returns `null` for anything that is not exactly four numeric octets in range.
 *
 * @return array<int>|null
 */
function parseIpv4(string $s): ?array
{
    $parts = explode('.', trim($s));
    if (count($parts) !== 4) {
        return null;
    }
    $octets = [];
    foreach ($parts as $p) {
        if (!preg_match(DEC3, $p)) {
            return null;
        }
        // DEC3 guarantees digits only; intval is safe and intentional here.
        $n = intval($p, 10);
        if ($n < 0 || $n > 255) {
            return null;
        }
        $octets[] = $n;
    }
    return $octets;
}

/**
 * Render four octets as `a.b.c.d`, or `''` if the octets are out of range.
 *
 * @param array<int> $octets
 */
function ipv4ToString(array $octets): string
{
    if (count($octets) !== 4) {
        return '';
    }
    foreach ($octets as $o) {
        if (!is_int($o) || $o < 0 || $o > 255) {
            return '';
        }
    }
    return implode('.', $octets);
}

/**
 * Parse an IPv6 string (with `::` compression, hex groups, and an optional
 * dotted-decimal IPv4 tail for mapped/compatible forms) into eight 16-bit
 * groups. Returns `null` on any malformed input — never throws.
 *
 * @return array<int>|null
 */
function parseIpv6(string $s): ?array
{
    $input = trim($s);
    if ($input === '') {
        return null;
    }
    // At most one `::` run is legal; reject ambiguous double-compression.
    if (substr_count($input, '::') > 1) {
        return null;
    }

    $dc = strpos($input, '::');
    if ($dc !== false) {
        $before = substr($input, 0, $dc);
        $after = substr($input, $dc + 2);
        $headTokens = $before === '' ? [] : explode(':', $before);
        $tailTokens = $after === '' ? [] : explode(':', $after);

        $head = [];
        foreach ($headTokens as $g) {
            if (!preg_match(HEX, $g)) {
                return null;
            }
            $head[] = intval($g, 16);
        }

        $tail = [];
        $tailCount = count($tailTokens);
        for ($i = 0; $i < $tailCount; $i++) {
            $g = $tailTokens[$i];
            // A dotted-quad IPv4 tail is permitted only in the final slot,
            // where it contributes two groups (high octet pair, low octet pair).
            if ($i === $tailCount - 1 && strpos($g, '.') !== false) {
                $oct = parseIpv4($g);
                if ($oct === null) {
                    return null;
                }
                $tail[] = ($oct[0] << 8) | $oct[1];
                $tail[] = ($oct[2] << 8) | $oct[3];
            } else {
                if (!preg_match(HEX, $g)) {
                    return null;
                }
                $tail[] = intval($g, 16);
            }
        }

        $total = count($head) + count($tail);
        // `::` must elide at least one group.
        if ($total >= 8) {
            return null;
        }
        $zeroPad = array_fill(0, 8 - $total, 0);
        return array_merge($head, $zeroPad, $tail);
    }

    // No compression: split on ':' and parse, allowing a dotted-quad only in
    // the last slot. The result must be exactly eight groups.
    $tokens = explode(':', $input);
    $groups = [];
    $tokenCount = count($tokens);
    for ($i = 0; $i < $tokenCount; $i++) {
        $g = $tokens[$i];
        if ($i === $tokenCount - 1 && strpos($g, '.') !== false) {
            $oct = parseIpv4($g);
            if ($oct === null) {
                return null;
            }
            $groups[] = ($oct[0] << 8) | $oct[1];
            $groups[] = ($oct[2] << 8) | $oct[3];
        } else {
            if (!preg_match(HEX, $g)) {
                return null;
            }
            $groups[] = intval($g, 16);
        }
    }
    return count($groups) === 8 ? $groups : null;
}

/**
 * True when the eight groups form an IPv4-mapped (`::ffff:`) address.
 *
 * @param array<int> $g
 */
function isMapped(array $g): bool
{
    return $g[0] === 0 && $g[1] === 0 && $g[2] === 0 && $g[3] === 0
        && $g[4] === 0 && $g[5] === 0xffff;
}

/**
 * True when the eight groups form an IPv4-compatible (`::`) address.
 *
 * @param array<int> $g
 */
function isCompatible(array $g): bool
{
    return $g[0] === 0 && $g[1] === 0 && $g[2] === 0 && $g[3] === 0
        && $g[4] === 0 && $g[5] === 0;
}

/**
 * Collapse the longest run (length >= 2) of zero groups into `::` (first run
 * wins on ties) and strip leading zeros — RFC 5952 canonical text for pure-hex
 * IPv6. Does not emit dotted-decimal; call `renderCanonical` for that.
 *
 * @param array<int> $groups
 */
function compressGroups(array $groups): string
{
    $bestStart = -1;
    $bestLen = 0;
    $curStart = -1;
    $curLen = 0;
    // Track the longest run of consecutive zero groups. $bestStart records the
    // first run of the longest length encountered (strict > keeps earliest).
    for ($i = 0; $i < count($groups); $i++) {
        if ($groups[$i] === 0) {
            if ($curStart < 0) {
                $curStart = $i;
            }
            $curLen++;
            if ($curLen > $bestLen) {
                $bestLen = $curLen;
                $bestStart = $curStart;
            }
        } else {
            $curStart = -1;
            $curLen = 0;
        }
    }

    if ($bestLen < 2) {
        return implode(':', array_map('dechex', $groups));
    }
    $before = implode(':', array_map('dechex', array_slice($groups, 0, $bestStart)));
    $after = implode(':', array_map('dechex', array_slice($groups, $bestStart + $bestLen)));
    return $before . '::' . $after;
}

/**
 * Render a compressed high part followed by a dotted-decimal IPv4 tail. When
 * the high part already ends in `::` (its zero run reaches the boundary) the
 * IPv4 attaches directly; otherwise a single `:` separates them — so
 * `::ffff:` → `::ffff:a.b.c.d` and `::` → `::a.b.c.d`.
 *
 * @param array<int> $high
 * @param array<int> $octets
 */
function renderWithEmbeddedTail(array $high, array $octets): string
{
    $highStr = compressGroups($high);
    $ipv4 = implode('.', $octets);
    // str_ends_with is PHP 8+; we keep the suffix check explicit for clarity.
    return substr($highStr, -2) === '::'
        ? $highStr . $ipv4
        : $highStr . ':' . $ipv4;
}

/**
 * Canonical RFC 5952 text for eight groups: a dotted-decimal tail for
 * IPv4-mapped (`::ffff:`) addresses, otherwise pure compressed hex. The
 * deprecated IPv4-compatible range (`::/96`) is NOT rendered dotted here —
 * that would mis-render the unspecified (`::`) and loopback (`::1`) addresses
 * as `::0.0.0.0` / `::0.0.0.1`. Compatible extraction is still available via
 * `ipv6ToIpv4`; on-demand compatible generation via `ipv4ToIpv6` is untouched.
 *
 * @param array<int> $groups
 */
function renderCanonical(array $groups): string
{
    if (isMapped($groups)) {
        $octets = [
            ($groups[6] >> 8) & 0xff,
            $groups[6] & 0xff,
            ($groups[7] >> 8) & 0xff,
            $groups[7] & 0xff,
        ];
        return renderWithEmbeddedTail(array_slice($groups, 0, 6), $octets);
    }
    return compressGroups($groups);
}

/**
 * Render eight groups as canonical compressed IPv6, or `''` if invalid.
 *
 * @param array<int> $groups
 */
function ipv6ToString(array $groups): string
{
    if (!isValidIpv6Groups($groups)) {
        return '';
    }
    return renderCanonical($groups);
}

/**
 * Expand an IPv6 string to its full eight-group, four-hex-digit form; `''` if
 * invalid.
 */
function expandIpv6(string $s): string
{
    $g = parseIpv6($s);
    if ($g === null) {
        return '';
    }
    // str_pad left-pads each group to a fixed 4-digit width: 0000..ffff.
    $parts = array_map(
        fn(int $v): string => str_pad(dechex($v), 4, '0', STR_PAD_LEFT),
        $g
    );
    return implode(':', $parts);
}

/**
 * Compress an IPv6 string to its RFC 5952 canonical form; `''` if invalid.
 */
function compressIpv6(string $s): string
{
    $g = parseIpv6($s);
    if ($g === null) {
        return '';
    }
    return renderCanonical($g);
}

/**
 * Embed an IPv4 octet quad into an IPv6 address. By default produces the
 * IPv4-mapped form `::ffff:a.b.c.d`; mode 'compatible' yields `::a.b.c.d`; a
 * non-null prefix overrides both and places the IPv4 after any custom /96
 * prefix (e.g. `64:ff9b::a.b.c.d`). Returns `''` for invalid octets or prefix.
 *
 * @param array<int>       $octets
 * @param Ipv4ToIpv6Options|null $opts
 */
function ipv4ToIpv6(array $octets, ?Ipv4ToIpv6Options $opts = null): string
{
    if (count($octets) !== 4) {
        return '';
    }
    foreach ($octets as $o) {
        if (!is_int($o) || $o < 0 || $o > 255) {
            return '';
        }
    }

    if ($opts !== null && $opts->prefix !== null) {
        $p = parseIpv6($opts->prefix);
        if ($p === null) {
            return '';
        }
        return renderWithEmbeddedTail(array_slice($p, 0, 6), $octets);
    }
    if ($opts !== null && $opts->mode === 'compatible') {
        return renderWithEmbeddedTail([0, 0, 0, 0, 0, 0], $octets);
    }
    return renderWithEmbeddedTail([0, 0, 0, 0, 0, 0xffff], $octets);
}

/**
 * Extract the embedded IPv4 from an IPv4-mapped (`::ffff:a.b.c.d`) or
 * IPv4-compatible (`::a.b.c.d`) address, returning dotted-decimal or `null`
 * when the address carries no embedded IPv4 (or is unparseable).
 */
function ipv6ToIpv4(string $s): ?string
{
    $g = parseIpv6($s);
    if ($g === null || !(isMapped($g) || isCompatible($g))) {
        return null;
    }
    $octets = [
        ($g[6] >> 8) & 0xff,
        $g[6] & 0xff,
        ($g[7] >> 8) & 0xff,
        $g[7] & 0xff,
    ];
    return implode('.', $octets);
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →