When WordPress admin stops being enough
Client sends: “Hey, we’re updating the text under the contact form on all pages – except the training ones, keep those as they were”. You open wp-admin, filter the pages, and the counting begins: 40, 80, 200 pages. Manually it’s half a day of clicking, and someone will definitely miss something.
A PHP script does the same in a minute. But before you write the first line, there are a few things you need to have sorted out – otherwise disaster comes easily.
STOP – before you write anything
1. Check where your content actually lives
This is the most important thing – and the most common reason a script “does nothing”. Every editor stores content in a different place in the database:
- Classic editor + Gutenberg – content is in the wp_posts table, in the post_content column. That’s what this post is about.
- ACF fields – content is in the wp_postmeta table, not in post_content. You need to search by meta_key of the given ACF field. That’s a separate topic.
- Elementor – content is in wp_postmeta, in the _elementor_data field, stored as JSON with escaped quotes. Requires different approach.
If your site has a mix (custom theme + Gutenberg + some ACF fields) – first figure out where exactly the content you want to change lives. Open the page in the editor and see which field the text sits in.
2. Back up the database
A PHP script writes directly to the database. There’s no “Undo”. If something goes wrong, the only rescue is a backup from before the run.
The simplest way I do it: since I’m already in phpMyAdmin to check how many posts match my phrase, I immediately click Export on the wp_posts table and save the SQL file to disk. 30 seconds, sleep peacefully.
3. Check the encoding – especially with non-ASCII characters
If you’re searching for a phrase with non-ASCII characters, check three things:
- PHP file saved as UTF-8 – in VS Code the bottom-right corner shows the file encoding. It has to be UTF-8 without BOM. If the file is in Windows-1250 or Latin-1, str_replace won’t find the phrase even with identical text in the database – because the bytes are different.
- WP database in utf8mb4 – standard since WP 4.2. In phpMyAdmin check that wp_posts table has collation utf8mb4_unicode_ci.
- HTML entity vs raw character – in Gutenberg content, apostrophes might be stored as HTML entity. If you search for one and the other is there – no match. Solution: copy the search phrase directly from the Gutenberg editor (Code view) – you’ll get the exact format that’s in the database.
Three lines at the start of the script force UTF-8 everywhere and solve most weird character issues in the output:
header('Content-Type: text/html; charset=UTF-8');
mb_internal_encoding('UTF-8');
$wpdb->query("SET NAMES 'utf8mb4'");
Script skeleton
I put the script as a PHP file in the theme, in a scripts subfolder. Upload via FTP, run by visiting the URL in the browser. Delete the file after use – as long as it’s there, anyone who finds it can run it.
Configuration at the top – what to search for, what to replace with, what to exclude:
// Security - only for logged-in admin
if (!current_user_can('manage_options')) {
wp_die('Insufficient permissions');
}
// Configuration
$SEARCH = 'Old text to change';
$REPLACE = 'New text after update';
// Exceptions - skip these post types
$EXCLUDED_POST_TYPES = ['event', 'acf-field-group', 'revision'];
$EXCLUDED_SLUGS = ['training', 'privacy-policy'];
// Test mode - set an ID to run the script on ONE page only
$TEST_ONLY_ID = 123;
// Mode - true = preview, false = save to database
$DRY_RUN = isset($_GET['dry']) ? true : false;
Search and replace
The heart of the script is a query for posts containing the search phrase, then a loop with replacement. The esc_like function protects percent and underscore characters – so they aren’t treated as wildcards:
global $wpdb;
$posts = $wpdb->get_results($wpdb->prepare(
"SELECT ID, post_type, post_name, post_title
FROM {$wpdb->posts}
WHERE post_content LIKE %s
AND post_status = 'publish'",
'%' . $wpdb->esc_like($SEARCH) . '%'
));
foreach ($posts as $post) {
// Test mode - skip everything except this one ID
if ($TEST_ONLY_ID && (int) $post->ID !== $TEST_ONLY_ID) continue;
// Exceptions
if (in_array($post->post_type, $EXCLUDED_POST_TYPES)) continue;
if (in_array($post->post_name, $EXCLUDED_SLUGS)) continue;
if (!$DRY_RUN) {
$content = get_post_field('post_content', $post->ID);
$new_content = str_replace($SEARCH, $REPLACE, $content);
// wp_slash() is important - without it apostrophes get mangled
wp_update_post([
'ID' => $post->ID,
'post_content' => wp_slash($new_content),
]);
echo "Changed: " . esc_html($post->post_title) . "<br>";
} else {
echo "[DRY RUN] Found in: " . esc_html($post->post_title) . "<br>";
}
}
Why wp_slash()
This little detail can eat half a day of debugging. The wp_update_post function expects “slashed” data – the way it comes in from a POST form through WordPress. If you give it a raw string with an apostrophe, WP does its internal unslash and eats the apostrophe.
Effect without wp_slash: the phrase “it’s” after saving becomes “its”. A phrase with HTML link can also fall apart. Always wp_slash with wp_update_post.
Run order – my routine
- Backup the wp_posts table via phpMyAdmin. Always.
- Upload the script file via FTP to the scripts folder in your theme. Log into wp-admin (the script should check permissions with current_user_can).
- Open a test page in a second tab in the Gutenberg editor. Don’t refresh.
- Dry-run – visit the script URL with a dry parameter. You see the list: how many found, what will change, what will be skipped. Nothing happens in the database.
- Single page test – if the list looks OK, run with a test page ID set. The script changes only one page. Check in wp-admin if it looks good.
- Real run – if the test is OK, run on all matching pages.
- DELETE the file via FTP when done. Critical – otherwise a publicly accessible file stays in the theme forever.
Quick content backup – the Ctrl+A / Ctrl+C trick
This doesn’t replace a database backup, but it saves the day in the typical “I replaced something and it doesn’t look right on this one page” situation.
Before I run the script, I open the test page in a second browser tab – in the Gutenberg editor. And I DON’T REFRESH that tab.
I run the script in the first tab. The post in the database changes. But the second tab – the one with the editor opened earlier – still shows the old content. I click into the editor, Ctrl+A, Ctrl+C. I have the entire old content in the clipboard.
If something goes wrong – I refresh the tab, now I see the new (broken) content, Ctrl+A to select, Ctrl+V to paste the old from clipboard, save. Back to pre-script state in 3 seconds.
Idempotency – why you can run the script 10 times
The script is naturally idempotent – after the first successful replacement, a second run does nothing. Because the search phrase is no longer in the database. The SELECT query with the old phrase returns an empty list.
Exceptions – two, sometimes three lists
I split exceptions into separate lists because they work at different levels:
- Post types – excludes entire categories of posts. “Don’t touch events”, “don’t touch revisions”
- Slugs – excludes single specific pages. “Leave the contact page alone”
- Parent slugs – excludes entire subpage trees. “Everything under /help/ – children, grandchildren – stays”
What not to automate
Not every content change fits a script. Do it manually if:
- There are just a dozen changes – opening tabs is faster
- The text differs on each page with no common pattern
- The client requires approval for each change before saving
- Content is in Elementor or ACF – then the script is different, and an error can break the entire block structure
Summary – iron rules
- Check where the content is – Gutenberg vs ACF vs Elementor are three different games
- Backup before anything – table export via phpMyAdmin, one minute of work
- Dry-run – always, no exceptions
- Single page test – before releasing on 200
- Ctrl+A / Ctrl+C on a test page opened in a second tab – quick emergency backup
- wp_slash() with wp_update_post – otherwise apostrophes get mangled
- UTF-8 in the PHP file – if you have non-ASCII characters, check the file encoding
- Delete the file from FTP after finishing
Without these, a bulk content replacement script is like a bulldozer without a driver – it’ll arrive, just not where it was supposed to.



