Comprehensive Regex for URL Detection and Spam Filtering
Regular expressions are one of the most common tools used to identify URLs or links in user-generated content. Whether moderating comments, filtering forum posts, or protecting contact forms, a reliable URL-matching regular expression (regex) can help detect spam before it reaches users.
In this article, a full regex for detecting URLs is provided with explanations for how it works and its role in spam detection. Practical examples of where it succeeds and its limitations are reviewed.
A Word of Caution
This regex is a monster. Regular expressions are powerful but notoriously difficult to read, and this one is especially long. To help, it is formatted and commented for clarity.
It is important to note that this is not a strict URL validator as it does not fully comply with RFC specifications. Instead, it is tailored for spam detection, where flexibility matters more than strict accuracy. Spammers often disguise links, so this regex leans toward catching more rather than less.
Practical Use
A modified version of this regex is used in the Analytical Spam Filter Plugin for WordPress. The plugin scans comments, identifies and counts URLs, and flags a comment as spam if too many links are detected. PHP provides functions such as parse_url() for parsing individual URLs, but it does not provide a built-in function for reliably detecting and extracting URLs embedded in arbitrary text.
The plugin version is slightly simplified to improve performance and reduce false positives, but it is not as comprehensive as the full regex presented here.
This pattern was also inspired by the article An Improved Liberal, Accurate Regex Pattern for Matching URLs. The regex presented in this article was built to avoid maintaining a hard-coded TLD list (a perfectly valid approach) and as a personal challenge, given the complexity of handling internationalized domains and name variations.
The Regex
Here is the full regex pattern:
~
# HOSTNAME Subroutine
(?(DEFINE)
(?<HOSTNAME>
(?:
(?:
# Internationalized hostnames
[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?
|
# Punycode hostnames
xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?
|
# ASCII hostnames
[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?
)
(?:\.
(?:
# Internationalized hostnames
[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?
|
# Punycode hostnames
xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?
|
# ASCII hostnames
[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?
)
)+
\.?
)
)
)
# Ensure no word characters before (boundary)
(?<![\p{L}\p{N}\p{M}_])
(?:
(?:
# With explicit scheme / protocol (for example, http, https, ftp, ftps)
(?:https?|ftps?)://
# Optional user information (for example, username:[password]@)
(?:
# Username
[\p{L}\p{N}\p{M}\-._\~!$&'()*+,;=%]+
# Optional password
(?::[\p{L}\p{N}\p{M}\-._\~!$&'()*+,;=%]*)?
@
)?
# Host / domain (for example, localhost, domain name, IPv4 address, or IPv6 address in brackets)
(?:
localhost
|
# Internationalized / punycode / ASCII hostnames
(?&HOSTNAME)
|
# IPv4
(?:
(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)
(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}
)
|
# IPv6
\[[0-9A-Fa-f:]+\]
)
)
|
# Without explicit scheme / protocol
# Internationalized / punycode / ASCII hostnames
(?&HOSTNAME)
)
# Optional port
(?::\d{1,5})?
# Optional path, query, fragment/anchor
(?:
[/?#]
(?:
(?:%[0-9A-Fa-f]{2})
|
[A-Za-z0-9\-._\~]
|
[\p{L}\p{N}\p{M}\p{S}\p{P}]
)*
)?
# Ensure no word characters after (boundary)
(?![\p{L}\p{N}\p{M}_])
~isugx
Here is the same regex pattern in a single line, with all formatting, comments, and the x mode modifier removed:
~(?(DEFINE)(?<HOSTNAME>(?:(?:[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?|xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?|[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?|xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?|[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))+\.?)))(?<![\p{L}\p{N}\p{M}_])(?:(?:(?:https?|ftps?)://(?:[\p{L}\p{N}\p{M}\-._\~!$&'()*+,;=%]+(?::[\p{L}\p{N}\p{M}\-._\~!$&'()*+,;=%]*)?@)?(?:localhost|(?&HOSTNAME)|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|\[[0-9A-Fa-f:]+\]))|(?&HOSTNAME))(?::\d{1,5})?(?:[/?#](?:(?:%[0-9A-Fa-f]{2})|[A-Za-z0-9\-._\~]|[\p{L}\p{N}\p{M}\p{S}\p{P}])*)?(?![\p{L}\p{N}\p{M}_])~isug
Here is the same regex pattern demonstrated in PHP using preg_match_all in a single line, with all formatting, comments, and the x and g mode modifiers removed. A few additional characters have been escaped, perhaps excessively.
This code scans a string ($text_to_search) for every URL that matches the regex, collects them into an array ($matches), and returns a match count ($count).
$count = preg_match_all( '~(?(DEFINE)(?<HOSTNAME>(?:(?:[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?|xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?|[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[\p{L}\p{N}\p{M}](?![\p{L}\p{N}\p{M}-]{1,2}--)(?:[\p{L}\p{N}\p{M}-]{0,61}[\p{L}\p{N}\p{M}])?|xn--(?![A-Za-z0-9]{2}--)(?:[A-Za-z0-9-]{1,59}[A-Za-z0-9])?|[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))+\.?)))(?<![\p{L}\p{N}\p{M}_])(?:(?:(?:https?|ftps?)://(?:[\p{L}\p{N}\p{M}\-\._\~!$&\'()*+,;=%]+(?::[\p{L}\p{N}\p{M}\-\._\~!$&\'()*+,;=%]*)?@)?(?:localhost|(?&HOSTNAME)|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|\[[0-9A-Fa-f:]+\]))|(?&HOSTNAME))(?::\d{1,5})?(?:[/?#](?:(?:%[0-9A-Fa-f]{2})|[A-Za-z0-9-._\~]|[\p{L}\p{N}\p{M}\p{S}\p{P}])*)?(?![\p{L}\p{N}\p{M}_])~isu', $text_to_search, $matches );
Step-by-Step Breakdown
Mode Modifiers / Flags
Regex mode modifiers (sometimes called flags) are special switches that change the way the regex engine interprets your pattern. For this pattern, the modifiers isugx are used.
icase-insensitive (so bothxn--andXN--will match).sDot matches newlines.uUnicode enabled (so\p{L}and other classes work).gGlobal match (finds all URLs in text). Not a valid PCRE modifier. PHP relies onpreg_match_allfor this instead of a flag.xFree-spacing mode (allows spaces and comments in the regex).
HOSTNAME Subroutine
The (?(DEFINE) … ) block defines a reusable subpattern named HOSTNAME which is later called with (?&HOSTNAME).
It matches:
- Internationalized domain names (IDN) using Unicode characters.
- Punycode labels starting with
xn--(with safeguards against malformed values). - ASCII hostnames with alphanumerics and internal hyphens.
It enforces:
- Labels cannot begin or end with a hyphen.
- Labels are limited to 63 characters.
- Domains can have multiple labels separated by dots.
- An optional trailing dot, which is valid in DNS.
Word Boundaries
Before and after the URL, the regex uses lookarounds:
(?<![\p{L}\p{N}\p{M}_])
…
(?![\p{L}\p{N}\p{M}_])
These create custom boundaries before and after a URL. Unlike the standard \b (word boundary), they:
- Work with Unicode letters.
- Exclude underscores as word characters.
- Prevent partial matches inside words.
The standard \b word boundary is not ideal here because its definition of a word character depends on the regex engine and configuration, and it treats underscore as a word character.
Scheme Handling
The regex supports:
- Explicit schemes:
http://,https://,ftp://,ftps:// - Scheme-less URLs:
example.com,example.net
User Info
Optionally matches username:password@. Usernames may include letters, numbers, and common URL-safe symbols.
Hosts
Hosts can be:
localhost- Hostnames (
example.com,example.net). - IPv4 addresses (
0.0.0.0to255.255.255.255) with strict validation. - IPv6 addresses (
[...]), though loosely validated as the pattern accepts any0-9A-Fa-f:combination.
Port
Optional numeric ports are matched up to 5 digits (for example, :80, :443, :65535). This is loosely validated as it will accept any combination (for example, :99999).
Path, Query, Fragment
An optional suffix beginning with /, ?, or #. It supports:
- Percent-encoded sequences (
%20). - Safe URL characters (
-,.,_, and~). - Unicode categories: letters, numbers, marks, symbols, punctuation.
This makes it flexible for internationalized paths.
Use for Spam Detection
This regex is particularly useful in spam detection systems:
- Counting links: Flag comments with too many URLs.
- Extracting domains: Captured hostnames can be compared against blocklists.
- Blocking suspicious patterns: For example, IP-based links are often spam signals.
Gaps and Limitations
- IPv6:
\[[0-9A-Fa-f:]+\]matches any sequence of colons and hex digits, not strictly valid IPv6. - Obfuscations: Variants like
hxxp://orexample(dot)comwill not match. - Missing schemes:
mailto:anddata:are not covered. - Underscores: Domains with underscores are rejected, though some DNS labels permit them.
- Domain length: Total maximum length (253 characters) is not enforced.
Summary
This regex is a balanced, practical tool for URL detection. It handles Unicode domains, Punycode, IPs, and scheme-less URLs while minimizing false positives, making it strong enough to count and extract legitimate-looking links for spam filtering. Obfuscations and exotic schemes can bypass it, so it works best as a first-pass filter combined with heuristics, blocklists, and DNS checks.