Skip to content

URL Inspector — PHP source

Break any URL into its components - protocol, host, port, path, query params, hash, and credentials. Detects default ports and security at a glance, with a decode toggle for query values. Runs entirely in your browser.

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

<?php
/**
 * url-inspector — PHP polyglot showcase port.
 *
 * Wraps parse_url in a pure, never-throwing function and exposes a flat,
 * serialisable report of every URL component — including the signals the URL
 * API hides (credentials, default-vs-explicit ports, root-only/fragment-only
 * URLs).
 *
 * Display source — part of CosmoDev's polyglot tool pages.
 * Ported from src/lib/url-inspector.ts (the canonical TypeScript lib).
 *
 * parse_url is far more permissive than the WHATWG URL standard (it never
 * throws and happily accepts fragments a browser would reject), so this port
 * re-imposes WHATWG invariants by hand: a scheme is required, special schemes
 * require a host, the host is lowercased, a default port (https:443) is
 * stripped from the serialised host, and an empty path on a special scheme is
 * rendered as "/".
 */

namespace CosmoDev\UrlInspector;

/**
 * Well-known default ports per scheme, keyed with the trailing colon to match
 * the WHATWG `protocol` form. parse_url does not surface defaults, so we keep
 * the table to flag an explicitly-written port that equals the scheme default.
 */
const DEFAULT_PORTS = [
    'http:'  => '80',
    'https:' => '443',
    'ftp:'   => '21',
    'ws:'    => '80',
    'wss:'   => '443',
];

/**
 * Percent-decode a query value, treating '+' as a space.
 *
 * rawurldecode (like decodeURIComponent) only does percent-decoding and does
 * NOT convert '+', so we swap that first by hand. rawurldecode is lenient:
 * where decodeURIComponent would throw on a malformed sequence, PHP leaves it
 * intact, so we simply return the decoded result without a fallback path.
 */
function decode_param(string $v): string
{
    return rawurldecode(str_replace('+', ' ', $v));
}

/**
 * Read an explicitly-written port straight from the raw input.
 *
 * parse_url normalises default ports away, so we re-parse the authority to
 * recover them. Handles userinfo (`user:pass@`) and IPv6 literals
 * (`[::1]:8080`). Returns null when no numeric port is present.
 */
function raw_port(string $trimmed): ?string
{
    // Scheme must start with a letter, then alnum / '+' / '-' / '.', then "://".
    // The /s (PCRE_DOTALL) modifier lets "rest" span newlines like the TS /s flag.
    if (!preg_match('/^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/(.*)$/s', $trimmed, $m)) {
        return null;
    }
    $rest = $m[2];

    // The authority runs until the first path/query/fragment delimiter.
    // strcspn returns the length of the leading segment NOT containing / ? #.
    $authorityEnd = strcspn($rest, '/?#');
    $authority = ($authorityEnd === strlen($rest)) ? $rest : substr($rest, 0, $authorityEnd);

    // Drop userinfo: everything up to the LAST '@' belongs to credentials.
    $atIdx = strrpos($authority, '@');
    $hostport = ($atIdx === false) ? $authority : substr($authority, $atIdx + 1);

    $portCandidate = null;
    if (str_starts_with($hostport, '[')) {
        // IPv6 literal — the port (if any) lives after the closing bracket.
        $close = strpos($hostport, ']');
        if ($close === false) {
            return null; // unterminated bracket
        }
        $tail = substr($hostport, $close + 1);
        if (str_starts_with($tail, ':')) {
            $portCandidate = substr($tail, 1);
        }
    } else {
        $colon = strpos($hostport, ':');
        if ($colon !== false) {
            $portCandidate = substr($hostport, $colon + 1);
        }
    }

    if ($portCandidate === null) {
        return null;
    }
    return ctype_digit($portCandidate) ? $portCandidate : null;
}

/**
 * Decode a raw query string into ordered key/value pairs, preserving
 * duplicates. (parse_str would mangle keys with '.'/' ' and overwrite repeats,
 * so we parse by hand.)
 */
function parse_query(string $rawQuery): array
{
    if ($rawQuery === '') {
        return [];
    }
    $params = [];
    foreach (explode('&', $rawQuery) as $pair) {
        if ($pair === '') {
            continue; // skip empty pairs produced by "a=1&&b=2"
        }
        if (strpos($pair, '=') !== false) {
            [$key, $value] = explode('=', $pair, 2);
        } else {
            $key = $pair;
            $value = '';
        }
        $params[] = ['key' => decode_param($key), 'value' => decode_param($value)];
    }
    return $params;
}

/**
 * Parse and decompose a URL into a structured report; never throws.
 *
 * Returns an associative array shaped like the TypeScript UrlReport — absent
 * optional fields are emitted as null to match `undefined`.
 */
function inspect_url(?string $raw): array
{
    $warnings = [];
    $trimmed = trim((string) ($raw ?? ''));

    if ($trimmed === '') {
        return ['valid' => false, 'warnings' => ['URL is empty']];
    }

    $parts = parse_url($trimmed);
    if (!is_array($parts) || empty($parts['scheme'])) {
        return [
            'valid'    => false,
            'warnings' => ['Invalid URL — could not be parsed (include the scheme, e.g. https://)'],
        ];
    }

    // WHATWG lowercases the scheme; parse_url preserves case, so normalise.
    $scheme = strtolower($parts['scheme']);

    $special = in_array($scheme, ['http', 'https', 'ws', 'wss', 'ftp', 'file'], true);
    // Re-impose WHATWG validity: special schemes must carry a host.
    if ($special && empty($parts['host'])) {
        return [
            'valid'    => false,
            'warnings' => ['Invalid URL — could not be parsed (include the scheme, e.g. https://)'],
        ];
    }

    $proto = $scheme . ':'; // WHATWG url.protocol carries the trailing colon

    // Query parameters — decode in insertion order, duplicates preserved.
    $rawQuery = $parts['query'] ?? '';
    $searchParams = parse_query($rawQuery);

    // Credentials.
    $username = isset($parts['user']) ? (string) $parts['user'] : null;
    $password = isset($parts['pass']) ? (string) $parts['pass'] : null;
    if ($username !== null && $username !== '') {
        $warnings[] = 'URL contains a username credential';
    }
    if ($password !== null && $password !== '') {
        $warnings[] = 'URL contains a password credential';
    }

    // Hostname: parse_url keeps IPv6 brackets (matching WHATWG); lowercase the
    // ASCII host to match WHATWG host canonicalisation.
    $hostname = isset($parts['host']) ? strtolower((string) $parts['host']) : '';

    // Host (hostname:port) — but WHATWG drops a port equal to the scheme default.
    $portStr = isset($parts['port']) ? (string) $parts['port'] : '';
    $expected = DEFAULT_PORTS[$proto] ?? null;
    $host = $hostname;
    if ($portStr !== '' && ($expected === null || $portStr !== $expected)) {
        $host = $hostname . ':' . $portStr;
    }

    // Pathname — special schemes serialise an empty path as "/".
    $pathname = $parts['path'] ?? '';
    if ($pathname === '' && $special) {
        $pathname = '/';
    }
    if ($pathname === '/' && $rawQuery === '' && count($searchParams) === 0) {
        $warnings[] = 'URL points to the site root (no path or query)';
    }

    // Recover the explicit port and flag it if it's the scheme default.
    $explicitPort = raw_port($trimmed);
    $isDefaultPort = null;
    if ($explicitPort !== null) {
        $exp = DEFAULT_PORTS[$proto] ?? null;
        $isDefaultPort = ($exp !== null && $explicitPort === $exp);
        if ($isDefaultPort) {
            $warnings[] = "Port {$explicitPort} is the default for {$proto}";
        }
    }

    $isSecure = ($proto === 'https:' || $proto === 'wss:');

    // Origin — only special schemes with a host yield a non-opaque origin.
    $origin = null;
    if (in_array($scheme, ['http', 'https', 'ws', 'wss', 'ftp'], true) && $hostname !== '') {
        $origin = $scheme . '://' . $host;
    }

    $fragment = $parts['fragment'] ?? '';

    return [
        'valid'        => true,
        'protocol'     => $proto,
        'username'     => ($username !== null && $username !== '') ? $username : null,
        'password'     => ($password !== null && $password !== '') ? $password : null,
        'host'         => $host !== '' ? $host : null,
        'hostname'     => $hostname !== '' ? $hostname : null,
        'port'         => $explicitPort,
        'pathname'     => $pathname,
        'search'       => $rawQuery !== '' ? ('?' . $rawQuery) : null,
        'hash'         => $fragment !== '' ? ('#' . $fragment) : null,
        'searchParams' => $searchParams,
        'origin'       => $origin,
        'isSecure'     => $isSecure,
        'defaultPort'  => $isDefaultPort,
        'warnings'     => $warnings,
    ];
}

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 →