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 →