Link-roundup themes, press-clipping posts, and “read the original” CTAs all need the first URL inside post HTML. The same scan can promote the first <img> to a featured image when an editor forgets to set one. In both cases you parse post_content (or filtered the_content) and pull attributes from the markup.
Regex feels faster to write. It fails on real WordPress HTML. Use a parser: WordPress’s own WP_HTML_Tag_Processor (in core since 6.2), the core helper get_url_in_content(), WP_HTML_Processor when the link’s context matters, PHP’s DOMDocument, or the new Dom\HTMLDocument in PHP 8.4. Escape every URL you print, and cache results when you run this on archives.
For custom theme and plugin work: WordPress developer.
Why regex fails to extract links from HTML
The regex version is the one most people find first:
function wppoland_get_first_link_url_regex( $content ) {
if ( empty( $content ) ) {
return false;
}
if ( preg_match( '/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
return $matches[1];
}
return false;
}It passes a test with one short sentence and then fails silently on real posts:
- Block comments and drafts. Gutenberg wraps every block in HTML comments such as
<!-- wp:paragraph -->. If an editor commented out an old link, the pattern matches that link before anything a reader can see. <script>and<style>contents. A Custom HTML block whose JavaScript builds a string like"<a href=...>"contains, as far as regex is concerned, a perfectly good anchor tag. An HTML parser knows that the inside of<script>is text, not markup.- Line breaks and attribute order. Attributes split across lines, single vs double quotes, and
data-*attributes beforehrefstill surprise people. - The backtracking limit. PHP’s PCRE engine gives up when a pattern exceeds
pcre.backtrack_limit, which defaults to 1000000 according to the PHP manual.preg_match()then returnsfalseinstead of 0 or 1, and onlypreg_last_error()reportsPREG_BACKTRACK_LIMIT_ERROR. On long posts with greedy patterns the link just disappears, with nothing in the log.
It is also harder to extend to title, rel, or “first external only”. Keep regex for one-off CLI cleanups, not theme templates. WordPress core went through the same lesson: get_url_in_content() ran on a regular expression for more than a decade before it moved to the HTML API, as shown below.
Extract the first link with WP_HTML_Tag_Processor
WordPress 6.2 added WP_HTML_Tag_Processor to core. The class docblock describes it plainly: it does not fully parse HTML or recurse into the structure; it scans linearly through the document and only parses tag openers. That is exactly what reading an href needs, without the memory cost of building a full DOM tree.
We tested the behaviour against the WordPress 7.1.3 source: next_tag( 'a' ) skips HTML comments and the contents of <script>, and get_attribute( 'href' ) returns the decoded value, so & in a query string reaches your code as &. A missing attribute returns null and a valueless boolean attribute returns true, so the is_string() check is not optional.
<?php
declare(strict_types=1);
/**
* Returns the first http(s) link URL in an HTML fragment.
*/
function wppoland_get_first_link_tag_processor( string $content ): ?string {
if ( '' === trim( $content ) ) {
return null;
}
$processor = new WP_HTML_Tag_Processor( $content );
// Walk the anchor tags in document order.
while ( $processor->next_tag( 'a' ) ) {
$href = $processor->get_attribute( 'href' );
if ( ! is_string( $href ) || '' === trim( $href ) ) {
continue;
}
$href = trim( $href );
$scheme = strtolower( (string) wp_parse_url( $href, PHP_URL_SCHEME ) );
// Skip mailto:, tel:, hash anchors and relative paths.
if ( in_array( $scheme, array( 'http', 'https' ), true ) ) {
return esc_url_raw( $href );
}
}
return null;
}Why this is the default choice on WordPress 6.2 and later:
- Memory: the processor moves through the string without allocating a node per tag.
- Resilience: comments,
<script>and<style>contents and unquoted attributes do not confuse it, because it follows HTML tokenizing rules instead of matching text patterns. - No extra extensions: it ships with WordPress and does not need ext-dom or libxml.
Use get_url_in_content in WordPress
Before writing your own helper, note that core already has one: get_url_in_content( $content ), documented as “Extracts and returns the first URL from passed content” in the official reference. It has existed since WordPress 3.6.0 and is used with the “link” post format: Twenty Thirteen calls it in twentythirteen_get_link_url() and falls back to the permalink when no URL is found.
The implementation changed in a way that matters. We compared the 6.8 and 6.9 branches of the official repository:
- Up to WordPress 6.8 the function used
preg_match( '/<a\s[^>]*?href=([\'"])(.+?)\1/is', ... ). On the same test HTML used throughout this article, that pattern returned the link inside an HTML comment, a link no reader ever saw. - From WordPress 6.9 it creates a
WP_HTML_Tag_Processor, walks theAtags and returns the first non-emptyhrefpassed throughsanitize_url(). The commented-out link is no longer found.
$url = get_url_in_content( get_the_content() );
if ( $url ) {
printf(
'<a href="%s" rel="noopener">Read the source</a>',
esc_url( $url )
);
}It returns false when nothing is found. Two limits apply. It accepts any scheme, so a mailto: link or a #comments anchor early in the post is returned before the editorial link. And on a site still running WordPress 6.8 or older you get the regex behaviour. On 6.9 or later, when the first href is what you want, use the core function; when you need scheme filtering or older versions, the helper above is the better fit.
Find the first link inside a paragraph with WP_HTML_Processor
The Tag Processor does not know where it is. The link wrapping an image in a core/image block and a link in the middle of a paragraph are just two a tags to it. When the rule is “the first link written in the text, not the image link”, use WP_HTML_Processor, available since WordPress 6.4. It tracks the stack of open elements and exposes it through get_breadcrumbs().
<?php
function wppoland_first_link_in_paragraph( string $content ): ?string {
$processor = WP_HTML_Processor::create_fragment( $content );
if ( null === $processor ) {
return null;
}
while ( $processor->next_tag( 'A' ) ) {
$crumbs = $processor->get_breadcrumbs();
// Only links inside <p> and outside <figure>.
if ( ! in_array( 'P', $crumbs, true ) || in_array( 'FIGURE', $crumbs, true ) ) {
continue;
}
$href = $processor->get_attribute( 'href' );
if ( is_string( $href ) && '' !== trim( $href ) ) {
return esc_url_raw( trim( $href ) );
}
}
return null;
}We check the breadcrumbs ourselves instead of using the next_tag( array( 'breadcrumbs' => array( 'P', 'A' ) ) ) query. That query requires the A to be a direct child of the P; in our test a link inside <p><strong><a> was skipped by it and found by the in_array() check.
The class documentation states the trade-off: when the processor meets markup it does not support yet, it stops instead of guessing. next_tag() then returns false and get_last_error() is no longer null. If you get null back for a post with visible links, check get_last_error() and fall back to the Tag Processor.
Get the first link URL with DOMDocument
Outside WordPress, or on legacy code, PHP’s DOMDocument (built on libxml2) is the classic option. Its HTML parser predates HTML5, so you need to suppress warnings about tags like <section> and <figure>, keep UTF-8 intact, and clear libxml’s error buffer.
function wppoland_get_first_link_url( $content ) {
if ( empty( $content ) ) {
return false;
}
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$wrapped = '<?xml encoding="utf-8" ?>' . $content;
$doc->loadHTML( $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$links = $doc->getElementsByTagName( 'a' );
if ( $links->length > 0 ) {
$href = $links->item( 0 )->getAttribute( 'href' );
return $href !== '' ? $href : false;
}
return false;
}LIBXML_HTML_NOIMPLIED is optional and behaves differently across libxml versions. If your host’s libxml is old and fragments break, drop those flags and keep the UTF-8 wrapper plus libxml_use_internal_errors( true ).
Older snippets use mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ) as the encoding trick. PHP 8.2 deprecated the HTML-ENTITIES text encoding for all mbstring functions (PHP 8.2 deprecations), and PHP 8.4 emits a Deprecated notice on the call. The notice itself points to mb_encode_numericentity() as the replacement.
Display the first link in the WordPress loop
$link = wppoland_get_first_link_url( get_the_content( null, false ) );
if ( $link ) {
printf(
'<a href="%s" class="read-more-external" rel="noopener noreferrer">Read original</a>',
esc_url( $link )
);
}Use get_post()->post_content when you need raw blocks without the_content filters (shortcodes, embeds). Use get_the_content() when you want the post HTML for the current loop item; it does not apply the_content filters either, so run apply_filters( 'the_content', ... ) when you need the HTML the front end would render.
Parse HTML5 with Dom\HTMLDocument in PHP 8.4
PHP 8.4 added a new DOM API in the Dom namespace that “includes standards-compliant support for parsing HTML5 documents”, according to the release announcement. Dom\HTMLDocument parses HTML according to the living standard, which removes the HTML5 warnings and the XML-header trick. DOMDocument stays available for compatibility.
<?php
function wppoland_get_first_link_dom84( string $html ): ?string {
if ( '' === trim( $html ) ) {
return null;
}
$doc = Dom\HTMLDocument::createFromString( $html, LIBXML_NOERROR, 'UTF-8' );
foreach ( $doc->querySelectorAll( 'a[href]' ) as $link ) {
$href = trim( $link->getAttribute( 'href' ) );
$scheme = strtolower( (string) parse_url( $href, PHP_URL_SCHEME ) );
if ( in_array( $scheme, array( 'http', 'https' ), true ) ) {
return $href;
}
}
return null;
}Three details from the createFromString() manual page: the second argument takes LIBXML_* constants combined with OR; the third, overrideEncoding, fixes the encoding instead of letting PHP guess; and an unknown encoding throws a ValueError. On PHP 8.4.24 this function returned https://exemplo.pt/ação?x=1&y=2 intact, with & decoded. CSS selectors also allow queries like p > a[href^="https://"], which need XPath with DOMDocument.
Which method to choose for the first link
We do not publish timings: they depend on the server, the PHP version and the post size, and only numbers measured on your own project mean anything. The structural differences are stable:
| Criterion | Regex (preg_match) | DOMDocument (libxml2) | Dom\HTMLDocument | WP_HTML_Tag_Processor |
|---|---|---|---|---|
| Parsing model | Text pattern | DOM tree, pre-HTML5 rules | DOM tree, HTML5 | Linear scan of tag openers |
| Memory | Low | Full tree | Full tree | No tree |
Comments and <script> | Unreliable | Correct | Correct | Correct |
| Link context (parent) | No | Yes | Yes, with CSS selectors | No (use WP_HTML_Processor) |
| Typical failure | false on backtrack limit | Warnings and UTF-8 | ValueError on a bad encoding | Does not know the link’s context |
| Minimum requirement | Any PHP | DOM extension | PHP 8.4 | WordPress 6.2+ |
On a current WordPress site the Tag Processor is the simplest reliable choice. Dom\HTMLDocument wins when code runs outside WordPress or needs selectors.
Get the first link’s text, title and rel
function wppoland_get_first_link_data( $content ) {
if ( empty( $content ) ) {
return false;
}
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $content );
libxml_clear_errors();
$links = $doc->getElementsByTagName( 'a' );
if ( 0 === $links->length ) {
return false;
}
$node = $links->item( 0 );
return array(
'url' => $node->getAttribute( 'href' ),
'text' => $node->textContent,
'title' => $node->getAttribute( 'title' ),
'target' => $node->getAttribute( 'target' ),
'rel' => $node->getAttribute( 'rel' ),
'class' => $node->getAttribute( 'class' ),
);
}$data = wppoland_get_first_link_data( get_the_content( null, false ) );
if ( $data && $data['url'] ) {
printf(
'<a href="%s" title="%s" target="%s" rel="%s">%s</a>',
esc_url( $data['url'] ),
esc_attr( $data['title'] ),
esc_attr( $data['target'] ? $data['target'] : '_blank' ),
esc_attr( $data['rel'] ? $data['rel'] : 'noopener noreferrer' ),
esc_html( $data['text'] )
);
}Get the first external link in WordPress
Roundup posts often start with an internal “jump to comments” or related-post link. Skip those:
function wppoland_get_first_external_link( $content ) {
if ( empty( $content ) ) {
return false;
}
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $content );
libxml_clear_errors();
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
$links = $doc->getElementsByTagName( 'a' );
foreach ( $links as $link ) {
$href = trim( $link->getAttribute( 'href' ) );
if ( '' === $href || '#' === $href[0] ) {
continue;
}
$host = wp_parse_url( $href, PHP_URL_HOST );
if ( $host && $host !== $site_host ) {
return $href;
}
}
return false;
}Get the first image URL from post content
function wppoland_get_first_image_url( $content ) {
if ( empty( $content ) ) {
return false;
}
$doc = new DOMDocument();
libxml_use_internal_errors( true );
$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $content );
libxml_clear_errors();
$images = $doc->getElementsByTagName( 'img' );
if ( $images->length > 0 ) {
$src = $images->item( 0 )->getAttribute( 'src' );
return $src !== '' ? $src : false;
}
return false;
}Auto-set featured image on save
add_action( 'save_post_post', function( $post_id ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( has_post_thumbnail( $post_id ) ) {
return;
}
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
$image_url = wppoland_get_first_image_url( $post->post_content );
if ( ! $image_url ) {
return;
}
$attachment_id = attachment_url_to_postid( $image_url );
if ( $attachment_id ) {
set_post_thumbnail( $post_id, $attachment_id );
}
}, 20 );attachment_url_to_postid() only resolves media already in the library. External hotlinked images will not become thumbnails without a download step; skip that unless you own the rights and have a clear import path.
What wp_http_validate_url and FILTER_VALIDATE_URL reject
Both names suggest “is this a valid URL”. Neither answers that question for editorial links.
wp_http_validate_url() protects outbound HTTP requests made by the server. Reading its reference page and source:
- it accepts only
httpandhttpsand rejects URLs with a username or password; - it rejects relative URLs, because it requires a host;
- for any host other than your own site it calls
gethostbyname(), so it performs a DNS lookup, and a name that does not resolve at that moment is rejected; - it rejects private network addresses unless the
http_request_host_is_externalfilter allows them; - by default it allows only ports 80, 443 and 8080 (filter
http_allowed_safe_ports).
In a save_post hook that means one DNS lookup per save and silent loss of valid links to domains that are briefly unreachable or to demo servers on a non-standard port. Use it only when your code is about to fetch that URL.
PHP’s FILTER_VALIDATE_URL has a different problem, stated in the manual: “This filter only works on ASCII URLs. This means that Internationalized Domain Names (IDN) will always be rejected.” In our PHP 8.4 test, https://exemplo.pt/ação was rejected, and FILTER_SANITIZE_URL turned the same address into https://exemplo.pt/ao, dropping the accented letters without a warning. For links written by editors, check the scheme with wp_parse_url(), clean the value with esc_url_raw() before storing it and esc_url() before printing it.
Escape and validate extracted URLs
- Always
esc_url()on output. Never trust content authors blindly. - Empty
href,javascript:URLs, anddata:URIs should be rejected if you only want http(s). - Block markup may wrap links in
<figure>or use relative paths. The parser still finds the<a>; to resolve a relative path, core hasWP_Http::make_absolute_url( $href, home_url( '/' ) )(since WordPress 3.4.0). Do not confuse it withwp_make_link_relative(), which does the opposite. - Run extraction in admin or on save when possible so front-end PHP stays thin.
Allow only http and https URLs in PHP
Before you echo a URL as href, allow only http(s) (and optionally relative paths you resolve yourself):
function wppoland_safe_http_url( $url ) {
$url = trim( $url );
if ( '' === $url ) {
return false;
}
$parsed = wp_parse_url( $url );
if ( empty( $parsed['scheme'] ) ) {
// Site-relative path.
return esc_url_raw( home_url( $url ) );
}
$scheme = strtolower( $parsed['scheme'] );
if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
return false;
}
return esc_url_raw( $url );
}Drop javascript:, data:, and vbscript: early. Editors paste weird things; treat content as untrusted input.
Cache the first link in post meta
Parsing HTML for every card on a category archive is wasteful. Store on save:
function wppoland_cache_first_link( $post_id ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || 'post' !== $post->post_type ) {
return;
}
$url = wppoland_get_first_link_url( $post->post_content );
update_post_meta( $post_id, '_wppoland_first_link', $url ? $url : '' );
}
add_action( 'save_post_post', 'wppoland_cache_first_link', 30 );
function wppoland_get_cached_first_link( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$cached = get_post_meta( $post_id, '_wppoland_first_link', true );
if ( '' === $cached ) {
return false;
}
if ( $cached ) {
return $cached;
}
// Backfill for old posts.
$post = get_post( $post_id );
$url = $post ? wppoland_get_first_link_url( $post->post_content ) : false;
update_post_meta( $post_id, '_wppoland_first_link', $url ? $url : '' );
return $url;
}Transients work too; post meta survives cache flushes and is easier to invalidate on save_post. WP_Query primes the post meta cache for the posts in the loop by default, so reading the meta in an archive does not add one query per card.
If you add a current_user_can() check to a save hook, remember that saves without a logged-in user, such as wp post update in WP-CLI without --user or a cron import, will then skip the meta update. The backfill below covers that gap.
Backfill old posts with WP-CLI
The save hook only covers posts saved after the code ships. For the existing archive, two documented WP-CLI commands are enough. wp post list --field=ID prints one ID per line and, per the command documentation, returns every matching post by default (--posts_per_page defaults to -1). wp eval-file runs a PHP file with WordPress loaded and places positional arguments in $args (documentation).
<?php
// backfill-first-link.php
foreach ( $args as $post_id ) {
wppoland_cache_first_link( (int) $post_id );
}wp post list --post_type=post --post_status=publish --field=ID \
| xargs -n 100 wp eval-file backfill-first-link.phpxargs -n 100 passes the IDs in batches of 100 and each batch starts a fresh PHP process, so memory does not grow with the archive. A common one-liner uses --format=ids with xargs -I %; that breaks, because --format=ids prints all IDs on one space-separated line and -I substitutes the whole line at once. Run the backfill on staging first and spot-check a few posts by hand.
Extract links from Gutenberg and classic editor posts
post_content in the database stores blocks as HTML comments plus inner HTML. A parser still finds <a> and <img> nodes inside static blocks. Dynamic blocks are different: core/navigation-link keeps its url as an attribute in the block comment (JSON) and renders the anchor on the server, while core/button stores the link in its saved HTML (in block.json the url attribute is sourced from the href of the a element). The core blocks reference lists each block’s attributes.
If you run apply_filters( 'the_content', $post->post_content ) first, dynamic blocks, shortcodes and embeds expand, which can insert extra links (share buttons, related posts injected by a plugin). For “first editorial link” semantics, parse raw post_content. For “first link the visitor would see”, parse after the_content, or run only do_blocks() and do_shortcode() to skip what plugins append.
Classic editor posts and imported XML often contain unclosed tags. That is exactly where regex fails and a real parser earns its keep.
PHPUnit test for the first link helper
A minimal PHPUnit / WP integration assertion saves regressions when someone “improves” the regex later:
public function test_first_link_skips_hash_and_finds_external() {
$html = '<p><a href="#comments">Comments</a> <a href="https://example.com/x">X</a></p>';
$this->assertSame(
'https://example.com/x',
wppoland_get_first_external_link( $html )
);
}Fixture a post with UTF-8 link text (Norwegian ø, Polish ł) and confirm textContent is not mojibake after loadHTML. Add a fixture with a commented-out link and one with a <script> block that contains <a href; both must be ignored.
Summary
- On WordPress 6.2 and later, use
WP_HTML_Tag_Processor; on 6.9 and later,get_url_in_content()already does this for the firsthref. - Use
WP_HTML_Processorwhen the link’s position matters, andDOMDocumentor PHP 8.4’sDom\HTMLDocumentoutside WordPress. - Validate the scheme with
wp_parse_url(), not withwp_http_validate_url()orFILTER_VALIDATE_URL; escape every attribute you print and addrel="noopener noreferrer"for_blank. - Cache on
save_postfor archive performance and backfill old posts with WP-CLI. - Filter for external hosts when building aggregator-style CTAs.
Need a production-safe helper packaged as a must-use plugin? Contact with the theme type (classic vs block) and whether you need first link, first image, or both.







