How to extract the first link from post content (PHP snippet)

How to extract the first link from post content (PHP snippet)

Last verified: October 14, 2026
18 min read
Tutorial
Full-stack developer

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.

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:

  1. 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.
  2. <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.
  3. Line breaks and attribute order. Attributes split across lines, single vs double quotes, and data-* attributes before href still surprise people.
  4. 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 returns false instead of 0 or 1, and only preg_last_error() reports PREG_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.

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 &amp; 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 the A tags and returns the first non-empty href passed through sanitize_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.

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.

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.

$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 &amp; decoded. CSS selectors also allow queries like p > a[href^="https://"], which need XPath with DOMDocument.

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:

CriterionRegex (preg_match)DOMDocument (libxml2)Dom\HTMLDocumentWP_HTML_Tag_Processor
Parsing modelText patternDOM tree, pre-HTML5 rulesDOM tree, HTML5Linear scan of tag openers
MemoryLowFull treeFull treeNo tree
Comments and <script>UnreliableCorrectCorrectCorrect
Link context (parent)NoYesYes, with CSS selectorsNo (use WP_HTML_Processor)
Typical failurefalse on backtrack limitWarnings and UTF-8ValueError on a bad encodingDoes not know the link’s context
Minimum requirementAny PHPDOM extensionPHP 8.4WordPress 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.

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'] )
	);
}

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;
}
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 http and https and 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_external filter 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, and data: 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 has WP_Http::make_absolute_url( $href, home_url( '/' ) ) (since WordPress 3.4.0). Do not confuse it with wp_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.php

xargs -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.

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.

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

  1. 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 first href.
  2. Use WP_HTML_Processor when the link’s position matters, and DOMDocument or PHP 8.4’s Dom\HTMLDocument outside WordPress.
  3. Validate the scheme with wp_parse_url(), not with wp_http_validate_url() or FILTER_VALIDATE_URL; escape every attribute you print and add rel="noopener noreferrer" for _blank.
  4. Cache on save_post for archive performance and backfill old posts with WP-CLI.
  5. 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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

Why use DOMDocument instead of regex to extract links?#
DOMDocument understands HTML structure. Regex breaks on attribute order, nested tags, and malformed markup that Gutenberg and classic editors still produce.
How do I handle content with UTF-8 characters?#
Prefer loading with an explicit UTF-8 meta charset wrapper, or convert carefully before loadHTML. Always test with non-ASCII titles and alt text.
Can I extract the first image instead of the first link?#
Yes. Use getElementsByTagName('img') and getAttribute('src') instead of anchors and href.
Should I run this on every page view?#
Cache the result in a transient or post meta on save_post. Parsing HTML on every archive row is unnecessary CPU work.
How do I skip internal links and take the first external URL?#
Loop the DOMNodeList, skip empty hrefs and hash anchors, then compare the host against home_url() before returning.
Does get_url_in_content() return mailto links or anchors?#
It returns the first non-empty href whatever the scheme. A mailto: link or a #comments anchor early in the text wins over the editorial link, so write your own helper if you need http or https only.
Do I need WordPress to use Dom\HTMLDocument?#
No. Dom\HTMLDocument is part of the PHP 8.4 DOM extension and works in any PHP script, for example an importer that runs outside WordPress.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles