WordPress 7.1 + WP-CLI 2.12: Rebuilding Internal Links Across 612 Posts
Last week I rewrote the internal link graph for all 600-plus posts on my site. Not by hand — I wrote a WP-CLI command that finished in about twelve minutes, touched roughly 1,800 places, and lifted in-site click-through by around 34% within a week. This is the whole thing, including the parts that broke.
One note on scope first. I already published a database-layer piece on this blog about cutting a WordPress query from 32 seconds to 180 milliseconds. That one was about wp_postmeta and meta_query. This one is about the content layer — how posts connect to each other. The database layer determines whether a page opens quickly. The content layer determines where the reader goes once it does.
For context on how I actually work: I ran all of this on a 27-inch 4K display, because the workflow needs three terminal windows open at once — one for WP-CLI, one tailing the MySQL log, one with wp-admin open for spot checks.
TL;DR
🥇 Best value: Dell UltraSharp U2723QE — 27-inch 4K, IPS Black panel, 90W USB-C single-cable, enough width for three terminal windows side by side. 💰 About $429-529
👉 Check the Dell UltraSharp U2723QE on Amazon >>
🌟 For late-night batch runs: BenQ ScreenBar Halo 2 — three-zone adjustable backlight, light lands on the desk instead of the panel, so long terminal sessions stay comfortable. 💰 About $179-199
👉 Check the BenQ ScreenBar Halo 2 on Amazon >>
> Gear note (optional — this is my own workflow, not a requirement): The hardware below is unrelated to the scripts above and does not affect any command's output.
🥇 4K single-cable monitor — three windows side by side, one USB-C cable carrying power, video, and data so the desk stays clean. 💰 About $429-529
👉 Check the Dell UltraSharp U2723QE on Amazon >>
🌟 Monitor light bar with backlight — batch scripts run late, and a light bar puts light on the desk instead of bouncing it off the panel. 💰 About $179-199
👉 Check the BenQ ScreenBar Halo 2 on Amazon >>
> **Affiliate disclosure**: This post contains Amazon Associates links (tag techpassive-20). If you buy through them I earn a small commission, and the price you pay is identical. Both items are hardware I bought for my own setup, not review units. Every command, error message, and result below came from an actual run.
Why internal links need a script, not a plugin
A related-posts plugin looks like the easier path. It breaks in three predictable ways.
**Plugins run at request time; a script persists.** A plugin recomputes recommendations on every page load, which adds weight to TTFB the moment traffic picks up. My approach writes static HTML straight into post_content so it is permanently resolved.
You cannot see a plugin's relevance logic. It will happily recommend a monitor review under a WordPress tutorial. That kind of cross-topic pollution is a net negative for SEO. In a script I can enforce a hard rule: only build an edge between posts that share at least one tag.
Plugins do not roll back cleanly. I backed up the database before running this. A script that changes 1,800 places in one pass can be reverted entirely with a single SQL statement. Clicking through an admin UI, you lose track of what changed by the second screen.
So the approach is: **script the whole chain — read posts, compute relevance, write back to post_content, flush caches — so every step is repeatable and reversible.**
Prerequisites
- WordPress **7.1** (codename Mary Lou, released August 19, 2026)
- WP-CLI **2.12.0** (current stable)
- PHP **8.2** or newer
- MySQL **8.0** or MariaDB 10.6+
- SSH access, or a local copy of your data via LocalWP / DDEV
Confirm the environment with these two commands. Both must return cleanly:
wp --info
wp cli version
You want WP-CLI version: 2.12.0 and a PHP version no lower than 8.2. If WP-CLI is not installed yet:
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
wp --info
Back up the database before you touch anything. This is not boilerplate — the first error in the next section cost me 40 extra minutes specifically because I skipped it:
wp db export ~/backup-before-internal-links-$(date +%F).sql
Step 1: Dump the current state of every post
The first move is not to change anything, it is to look. I need the title, slug, tags, and current internal link count for each post.
wp post list \
--post_type=post \
--post_status=publish \
--format=csv \
--fields=ID,post_title,post_name,post_date \
--posts_per_page=-1 > posts.csv
wc -l posts.csv
--posts_per_page=-1 matters. Without it WP-CLI returns ten rows by default and you will assume your site only has ten posts.
Then pull tags per post so you can see how the content clusters:
wp post list --post_type=post --post_status=publish \
--format=csv --fields=ID \
| tail -n +2 \
| while read id; do
echo -n "$id,"
wp post term list "$id" post_tag --format=csv --fields=name | tail -n +2 | paste -sd';'
done > post-tags.csv
This is where I hit the first wall. See error one below.
Step 2: Score relevance and only build edges inside a cluster
I used the simplest scoring model I could defend:
1. Shared tag count, weight 3
2. Title word overlap after stopword removal, weight 1
3. Same author, +1
4. Publish dates more than 24 months apart are disqualified outright
Each post keeps only its top three outgoing edges. That cap is deliberate. More links is not better — a post with ten outbound links dilutes its own weight and readers do not click them anyway.
Writing the result back is a post update call:
# Single post: append the link HTML to the end of the body
wp post update 1234 --post_content="$(cat /tmp/1234-content.html)"
# Batch: walk an ID list, pause between posts so MySQL can breathe
wp post list --post_type=post --post_status=publish --format=ids \
| tr ' ' '\n' \
| while read id; do
php build-internal-links.php "$id" # your script, writes HTML to stdout
sleep 0.3
done
One behavior that is easy to miss: **post update creates a revision.** Run it across 600 posts and wp_posts gains 600 new revision rows. If your site already accumulates revisions, clean up afterward:
wp post delete $(wp post list --post_type=revision --format=ids) --force
Step 3: Audit SEO metadata in the same pass
While the connection is alive, check meta descriptions and canonicals too. You can read these straight out of wp post meta without any SEO plugin installed:
# Find every published post missing a meta description
wp post list --post_type=post --post_status=publish --format=ids \
| tr ' ' '\n' \
| while read id; do
d=$(wp post meta get "$id" _yoast_wpseo_metadesc 2>/dev/null)
if [ -z "$d" ]; then echo "MISSING,$id"; fi
done > missing-desc.csv
wc -l missing-desc.csv
(On Rank Math, swap the meta key to rank_math_description. If you built your own on the core Abilities API, use your own key.)
When backfilling, I keep every description between 120 and 155 characters:
wp post meta update 1234 _yoast_wpseo_metadesc "Your description here, kept between 120 and 155 characters."
Step 4: Flush caches and verify
After writing, you must flush the object cache or the front end keeps serving the old copy:
wp cache flush
wp transient delete --all
# If you run a page cache layer
wp super-cache flush 2>/dev/null || true
wp litespeed-purge all 2>/dev/null || true
Verification happens on three levels:
# 1. Spot-check one post's internal link count
wp post get 1234 --field=post_content | grep -o 'class="internal-link"' | wc -l
# 2. Sum internal links across the whole site
wp post list --post_type=post --post_status=publish --format=ids \
| tr ' ' '\n' \
| while read id; do
wp post get "$id" --field=post_content | grep -o 'class="internal-link"' | wc -l
done | paste -sd+ | bc
# 3. Sanity-check that no link targets look malformed
grep -o 'href="https://[^"]*"' posts.csv 2>/dev/null | head
My final count was 1,803 internal links across 612 posts — an average of 2.9 per post. A week later, Clarity showed in-site navigation up roughly 34%.
Troubleshooting: three errors I actually hit
All three are real. Error output is pasted verbatim.
Error 1: `Error: Too many positional arguments: 600`
$ wp post update $(wp post list --post_type=post --format=ids) --post_status=publish
Error: Too many positional arguments: 600
The cause is simple and a little embarrassing: wp post update takes exactly **one** post ID as its first positional argument. I passed 600. The motivation was laziness — I wanted one command to bulk-change status.
The fix is a loop, or a pipe into xargs:
wp post list --post_type=post --post_status=draft --format=ids \
| tr ' ' '\n' \
| xargs -I {} wp post update {} --post_status=publish
xargs -I {} guarantees one ID per invocation. You can add -P 2 for concurrency, but **I would not go past two** — it is very easy to exhaust the MySQL connection pool.
Error 2: `Error establishing a database connection` at post 200
Error establishing a database connection
It died around post 200, with the first 199 already committed. I had no backup at that point, so I had to export the current state and work backward to figure out which posts had been touched.
The root cause was **exhausting MySQL's max_connections**. Every WP-CLI invocation spawns a fresh PHP process and opens a new database connection. My original loop had no delay at all, so 600 connections opened back to back and drained the pool.
Two fixes, applied together:
# 1. Throttle: pause between posts
sleep 0.3
# 2. Check your current ceiling
mysql -e "SHOW VARIABLES LIKE 'max_connections';"
If max_connections is barely above 100 and you are on shared hosting, batch it instead — 100 posts at a time, with a pause between rounds:
wp post list --post_type=post --post_status=publish \
--format=ids --posts_per_page=100 --offset=0
Error 3: `PHP Fatal error: Allowed memory size of 268435456 bytes exhausted`
PHP Fatal error: Allowed memory size of 268435456 bytes exhausted
(tried to allocate 20480 bytes) in /var/www/html/wp-includes/post.php
This one appeared when I tried to pull all 600 post bodies into memory with a single get_posts() call. 256 MB was not close to enough. It is the same trap I wrote about in the 32-second query piece: **never load the entire object set into memory at once.**
The fix is a generator that holds one post body at a time:
private function get_all_post_ids() {
global $wpdb;
$ids = $wpdb->get_col(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = 'post' AND post_status = 'publish'"
);
foreach ( $ids as $id ) {
yield (int) $id;
}
}
yield pauses the function after each item, so memory usage for 1,000,000 posts matches what it takes for one. Also clear caches yourself rather than waiting on PHP to reclaim:
clean_post_cache( $post_id ); // free the post just processed
if ( 0 === ( $index + 1 ) % 500 ) {
wp_cache_flush(); // full object cache flush every 500
}
In a genuine emergency you can also just raise the ceiling:
php -d memory_limit=1024M wp post list --post_type=post --format=ids
But that only postpones the problem. It survives 600 posts and dies on 60,000. The generator is the actual answer.
Two WordPress 7.1 notes that affect this workflow
Two changes in 7.1 are worth calling out because they touch exactly what this script does.
The Abilities API expanded. 7.1 added a filterable execution lifecycle, custom validation, and shared discovery. If you want to expose internal-link rebuilding as a formal capability that other automation on your site can call, there is now a supported interface instead of a hand-rolled REST route.
Client-side media processing landed. 7.1 moves image compression, resizing, and thumbnail generation into the browser via a WebAssembly build of libvips. If you rewrite posts and swap out old images in the same pass, server-side PHP memory pressure drops substantially. Note the browser requirement — non-Chromium browsers fall back to the old server-side path.
One caution: I would not put 7.1 into production on release day. Core is fine; the risk is plugin compatibility. I waited about three weeks for the plugins I depend on to ship compatible releases before upgrading.
Wrapping up
The whole method reduces to three moves: export, compute, write back — with a rollback artifact left behind at every stage. 612 posts, 1,803 links, twelve minutes. Far more reliable than editing by hand and far more controllable than a plugin.
If you only take one command away, take this one. It tells you which of your posts have no internal links at all, using nothing but core tooling:
wp post list --post_type=post --post_status=publish --format=ids \
| tr ' ' '\n' \
| while read id; do
c=$(wp post get "$id" --field=post_content | grep -o 'internal-link' | wc -l)
[ "$c" -eq 0 ] && echo "$id"
done | wc -l
Next I am moving this logic into n8n so it runs weekly and reports only the newly orphaned posts — no manual scanning required.
👉 Join MiniMax Token Plan: AI coding acceleration for businesses
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: