How to Find and Fix Outdated External Links in WordPress With WP-CLI

How to Find and Fix Outdated External Links in WordPress With WP-CLI

Codeable.io

A WP-CLI external links audit finds something almost every WordPress site accumulates over time without anyone noticing: outbound links pointing at pages that have since moved, been renamed, or disappeared entirely. A partner’s pricing page gets restructured, a linked product moves to a new domain, a contact page changes its URL – and every post that linked to the old one keeps quietly sending visitors to a dead end or a redirect chain nobody planned for.

Why External Links Need a Periodic Audit

Internal links get attention because they’re easy to notice breaking – a 404 on your own site shows up in your own logs. External links break silently, from your side, because the change happened on someone else’s server. A link that was perfectly correct when a post was written can be stale within months, and nothing about your own site will ever flag it on its own.

Find Every External Link With a WP-CLI Script

Rather than reading through posts individually, pull every external link out of published content in one pass, with a count of how often each one appears. WP-CLI’s eval-file command runs a script with full access to WordPress’s own functions, which makes this straightforward:


<?php
/**
 * Find every external link across published posts and pages, count how
 * many times each unique URL appears, and export the results sorted by
 * frequency, most-linked first.
 * Run with: wp eval-file find-external-links.php
 */

$site_host = parse_url( home_url(), PHP_URL_HOST );
$counts    = array();

$posts = get_posts( array(
    'post_type'      => array( 'post', 'page' ),
    'post_status'    => 'publish',
    'posts_per_page' => -1,
) );

foreach ( $posts as $post ) {
    if ( ! preg_match_all( '/href=["\']((?:https?:)?\/\/[^"\']+)["\']/i', $post->post_content, $matches ) ) {
        continue;
    }

    foreach ( $matches[1] as $url ) {
        $host = parse_url( $url, PHP_URL_HOST );

        if ( ! $host || $host === $site_host ) {
            continue; // skip internal links and anything unparsable
        }

        $counts[ $url ] = ( $counts[ $url ] ?? 0 ) + 1;
    }
}

arsort( $counts );

$output = fopen( 'external-links-report.txt', 'w' );
foreach ( $counts as $url => $count ) {
    fwrite( $output, sprintf( "%d\t%s\n", $count, $url ) );
}
fclose( $output );

printf( "Found %d unique external links. Written to external-links-report.txt\n", count( $counts ) );

Run it against a single install like this:


sudo -u www-data wp eval-file find-external-links.php --path=/var/www/example-site --allow-root

--path points WP-CLI at the WordPress install to run against, which matters whenever you’re not already sitting in that install’s own directory. --allow-root is only needed if you’re running the command as the root system user – WP-CLI blocks that by default as a safety measure, and this flag deliberately overrides it, so use it only when you actually need to.

Running the Same Script Against a Multisite Network

A WordPress Multisite network shares one codebase and one database across every site, so WP-CLI needs to know which specific site’s content you mean. Target one site directly with --url:


sudo -u www-data wp eval-file find-external-links.php --path=/var/www/network-install --url=site-one.example.com --allow-root

Without --url, a command in a multisite install runs against whichever site WP-CLI resolves as its default context, which isn’t necessarily the one you meant. Being explicit costs nothing and removes any ambiguity about which site’s data you just scanned. Multisite’s shared-but-separate structure shows up in exactly this way across most WP-CLI commands, not just this one.

Fix a Stale Link With wp search-replace, Dry Run First

Once the report shows a link that needs updating, wp search-replace handles the fix safely across every table. Always run it with --dry-run first, so you see exactly how many replacements it intends to make before anything actually changes:


# Preview only - makes no changes
sudo wp search-replace 'https://old-product-site.example.com/pricing' 'https://newsite.example.com/plugins/product-name/' --all-tables --precise --skip-columns=guid --allow-root --dry-run

# Confirmed - run for real
sudo wp search-replace 'https://old-product-site.example.com/pricing' 'https://newsite.example.com/plugins/product-name/' --all-tables --precise --skip-columns=guid --allow-root

--all-tables searches every table in the database, not just ones with the site’s own prefix – useful when a link might be sitting somewhere outside the usual posts and options tables. --precise forces exact PHP-based string matching instead of a faster SQL pattern match, which is more reliable for links containing characters that don’t always match consistently under a table’s default collation. --skip-columns=guid leaves post GUIDs untouched, since those are meant to stay permanent identifiers even when a URL inside them would otherwise match.

Targeting One Site in a Multisite Network for the Replace

The same --url flag applies to search-replace exactly as it did to the scan – point it at the specific site whose content needs the fix:


sudo wp search-replace 'https://old-product-site.example.com/contact' 'https://newsite.example.com/contact' --all-tables --precise --skip-columns=guid --allow-root --url=site-one.example.com

To fix the same broken link everywhere across an entire network at once instead of one site at a time, use --network in place of a specific --url – it loops the replacement through every site’s own tables in the network automatically.

Always Flush the Cache After a Search-Replace

A successful database replacement can still appear unchanged on the live site if a caching layer is still serving the old content. Clear it as the last step, every time:


sudo wp cache flush --allow-root

If the site also sits behind a page-caching plugin or a CDN, clear those separately too – wp cache flush only clears WordPress’s own object cache, not every caching layer that might sit in front of it.

A Pricing Page Consolidated Into a New One

For example, a product’s pricing information might originally live on its own dedicated site, later folded into a broader hub site as one page among several product pages. Every post written before that change still links to the old dedicated pricing URL. An external links audit catches every one of those references in a single pass, and a targeted search-replace updates them all at once, instead of relying on someone to remember to fix each one manually the next time they happen to edit that particular post.

The Pitfall: Writing Your Own SQL Instead of Using wp search-replace

WordPress stores arrays and objects as PHP serialized strings, and that format bakes the exact byte length of each string directly into itself. A plain SQL REPLACE() that changes a string’s length without updating that stored length silently corrupts the data – unserialize() either fails outright or truncates, and the failure often isn’t visible until much later. wp search-replace handles this correctly by default, recalculating lengths as it goes. A hand-written SQL query has no idea any of this matters, and it’s exactly the kind of mistake that looks like it worked until something downstream quietly breaks.

This matters even more for content built with a page builder that stores its layout as structured, often serialized data. A tool built specifically for that structure is worth using over a generic approach whenever the content in question is page-builder data rather than plain post content.

Your External Link Audit Checklist

Before considering an external link cleanup complete, confirm:

  • Every external link has been pulled and counted, not sampled from a handful of posts
  • Multisite scans and replacements are scoped explicitly with --url or --network, never left to a default
  • Every search-replace ran with --dry-run first and was reviewed before running for real
  • The cache – object cache, page cache, and any CDN – was cleared after every change
  • Any replacement was done with wp search-replace itself, never a hand-written SQL query

A periodic pass like this, run every few months, keeps outbound links pointing at what they’re actually supposed to – a small maintenance habit that costs a few minutes and quietly prevents a slow accumulation of dead ends across the whole site.

Leave a Reply

Your email address will not be published. Required fields are marked *