WordPress Moved Into a Subdirectory: 500 Error, Wrong URLs and an Admin User From SQL
Moved WordPress into /new/: fix RewriteBase, find a 500 with curl, set WP_HOME, rewrite links without breaking serialized data, add an admin via SQL.
A client site got a new static landing page at the root. The old WordPress didn't get deleted, it got moved into a subfolder, /new/, so it stays reachable while the new site is built. Shared hosting, FTP and phpMyAdmin, no SSH, no WP-CLI.
Moving the files was the easy part. Getting /new/ to actually load was not, because WordPress remembers where it lives in three different places, and every one of them was still wrong.
TL;DR: Fix RewriteBase and the last RewriteRule in /new/.htaccess. If you get a 500, curl a static file in the same folder: if that's 200, it's PHP, not rewrites. Set WP_HOME and WP_SITEURL in wp-config.php with your real domain, not the example one. Rewrite links in wp_posts with SQL, but never touch serialized data in wp_options or wp_postmeta with SQL REPLACE. Check $table_prefix before you write a single query, because the meta keys for user roles carry the prefix too.
Step 1: the .htaccess still points at the root
This is the file that came along with the move:
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
RewriteBase / and /index.php send every pretty URL to the root, where there is no WordPress anymore. Two lines change:
RewriteBase /new/
RewriteRule . /new/index.php [L]
Everything else stays. Below the WordPress block there was a Wordfence section that denies access to .user.ini. That one can stay as it is.
Also check the .htaccess in the root. If the old WordPress block is still there, it rewrites requests for missing files to a /index.php that doesn't exist anymore. For a static root page, delete that block. If the new index is HTML, DirectoryIndex index.html makes that explicit.
Step 2: the 500, and how to tell it's PHP
With the new .htaccess uploaded, /new/ returned a 500. Before guessing, I checked a few URLs from outside:
for u in https://example.com/ \
https://example.com/new/ \
https://example.com/new/readme.html \
https://example.com/new/wp-login.php \
https://example.com/new/wp-includes/css/dashicons.min.css; do
echo "$u -> $(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$u")"
done
Result: root 200, readme.html 200, the CSS file 200. /new/ and wp-login.php 500.
That's the useful part. Static files from the same folder are served fine, so the rewrite rules aren't killing the request. Only PHP dies. That points to something that runs before or inside WordPress with a path that's now wrong.
Gotcha: Wordfence and .user.ini
This site runs Wordfence. Its firewall puts a .user.ini into the WordPress folder with a line like this:
auto_prepend_file = '/path/to/webspace/wordfence-waf.php'
That's an absolute path. After the move, the real file sits under /path/to/webspace/new/, and wordfence-waf.php itself also contains absolute paths to wp-content/wflogs/. If PHP tries to prepend a file that isn't there, every PHP request fails before WordPress even starts.
The quick test is to switch it off:
- In
/new/.user.ini(and a.user.iniin the root, if there is one), comment the line out with;. - Rename
wp-content/plugins/wordfencetowordfence_off. - Wait. Depending on the host,
.user.iniis cached for a few minutes.
If that's not it, make the error visible in wp-config.php above /* That's all, stop editing! */:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
Then look at wp-content/debug.log or the error log in the hosting panel. Remove the debug lines afterwards.
I have to be honest here: about a minute later the 500 was gone, and I never got the log line that proves which change fixed it. The Wordfence path was the main suspect. If you hit this, look at the log instead of guessing like I had to.
Step 3: the redirect to a domain that isn't yours
The next check showed this:
/new/ -> 301 https://your-domain.example/new/
/new/wp-admin/ -> 302 https://your-domain.example/new/wp-login.php?...
PHP worked again, but every script tag, every link and the login now pointed to a placeholder domain. The snippet for wp-config.php I worked from had a placeholder in it, and it went into the file exactly like that. Easy mistake, and it looks like a brand-new problem when it happens.
The correct version, with the real domain and no trailing slash:
define('WP_HOME', 'https://example.com/new');
define('WP_SITEURL', 'https://example.com/new');
These two constants win over siteurl and home in the database. That's handy for a quick fix, and confusing later, when you change the options in the database and nothing happens. Set both, or know which one is in charge.
Step 4: find the table prefix first
The plan was one SQL script in phpMyAdmin: create an admin user, set siteurl and home, rewrite links in posts. It failed on the first statement, because I assumed wp_. The tables were called wp_abc_users, wp_abc_options and so on.
The prefix is in wp-config.php as $table_prefix, and the table list in phpMyAdmin shows it too. Look before writing anything. A failed first statement is the lucky case: nothing ran, nothing changed.
Step 5: an admin user straight in the database
There was no working login on the moved site, so the user went in through SQL:
INSERT INTO wp_abc_users
(user_login, user_pass, user_nicename, user_email, user_url,
user_registered, user_activation_key, user_status, display_name)
VALUES
('alice', MD5('CHANGE-ME'), 'alice', 'alice@example.com', '',
NOW(), '', 0, 'Alice');
SET @uid = LAST_INSERT_ID();
INSERT INTO wp_abc_usermeta (user_id, meta_key, meta_value) VALUES
(@uid, 'wp_abc_capabilities', 'a:1:{s:13:"administrator";b:1;}'),
(@uid, 'wp_abc_user_level', '10');
Two things matter here:
- The meta keys carry the prefix:
wp_abc_capabilities, notwp_capabilities. Get that wrong and the user exists, can maybe even log in, and has no rights at all. - MD5 is only the way in. WordPress still accepts an old MD5 hash and replaces it with a proper hash on the first successful login. Don't leave it at that, log in once.
Gotcha: "user created, but the password is wrong"
The user showed up in wp_abc_users, and the login still said wrong password. Two likely causes:
- The placeholder was never replaced. Then the password really is
CHANGE-ME, or whatever the placeholder said. - The script ran twice. In the standard WordPress schema the index on
user_loginisn't unique, as far as I know, so a second insert can go through and leave two users with the same name.
Check first:
SELECT ID, user_login, user_email, user_pass
FROM wp_abc_users WHERE user_login = 'alice';
One row: set the password again.
UPDATE wp_abc_users SET user_pass = MD5('your-real-password')
WHERE user_login = 'alice';
More than one row: delete the extra ones and their meta, keep the lowest ID, then run the UPDATE.
DELETE u, m FROM wp_abc_users u
LEFT JOIN wp_abc_usermeta m ON m.user_id = u.ID
WHERE u.user_login = 'alice'
AND u.ID > (SELECT * FROM (SELECT MIN(ID) FROM wp_abc_users
WHERE user_login = 'alice') x);
At the login form: use the username, not the email. Type the password by hand once, because a password manager with an old entry for that site will happily fill the wrong one. And Wordfence locks you out after a few failed tries. Its block tables have the prefix too (wp_abc_wfblocks7, wp_abc_wflogins). Emptying the blocks table lifts the lockout.
One more thing, since it happened: a password ended up in a screenshot in a chat. That password is burned. Pick a new one for the SQL and don't reuse it anywhere.
In my case the next message was already about links, so the login worked after this. I don't know which of the two causes it was.
Step 6: rewriting the links without breaking anything
For post content, plain SQL is fine, because post_content isn't serialized. The order matters:
-- 1. unify all variants on one base
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'http://www.example.com', 'https://example.com');
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'https://www.example.com', 'https://example.com');
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'http://example.com', 'https://example.com');
-- 2. add the subfolder
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'https://example.com/', 'https://example.com/new/');
-- 3. fix links that were already right and are now doubled
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, '/new/new/', '/new/');
The same three steps for post_excerpt. The guid column stays untouched. GUIDs are identifiers, not links, and feed readers use them.
What I did not do with SQL: wp_options (apart from siteurl and home) and wp_postmeta. Theme settings, widgets and the cookie banner plugin store serialized PHP there. A serialized string stores its own length, like s:22:"https://example.com/x". A plain REPLACE changes the text but not the number, PHP can't unserialize it anymore, and the setting is silently gone. For those tables use a tool that understands serialization, like the Better Search Replace plugin after login, or wp search-replace if you have WP-CLI. Dry run first, GUIDs off.
Gotcha: the menu wasn't in the posts
After all that, I fetched the page and counted link targets. 51 URLs in the HTML pointed to https://example.com/new. Five didn't: the header menu, still pointing to https://www.example.com/#section. Those were custom menu links. They live in wp_abc_postmeta under _menu_item_url, as plain text, not serialized, so SQL is safe here:
UPDATE wp_abc_postmeta
SET meta_value = REPLACE(REPLACE(REPLACE(meta_value,
'https://www.example.com/', 'https://example.com/'),
'http://www.example.com/', 'https://example.com/'),
'https://example.com/', 'https://example.com/new/')
WHERE meta_key = '_menu_item_url'
AND meta_value LIKE '%example.com/%'
AND meta_value NOT LIKE '%/new/%';
Yoast keeps its own copy of every permalink in wp_abc_yoast_indexable. Same idea, permalink column, with the NOT LIKE '%/new/%' guard. Then in the admin, Settings, Permalinks, click Save once.
I can't tell you from my notes that the menu fix was actually run. The page check before it is the last thing I saw.
Checklist
/new/.htaccess:RewriteBase /new/andRewriteRule . /new/index.php [L]. Root.htaccesswithout the old block.- 500? Curl a static file in the same folder. 200 means PHP. Check
.user.iniand the security plugin's absolute paths, then the error log. WP_HOMEandWP_SITEURLwith the real domain.$table_prefixbefore any SQL. Prefixed meta keys for the user role.- Database backup before the first
UPDATE. - Posts with SQL. Options and postmeta only with a serialization-aware tool. Menu links and SEO plugin tables extra.
- Fetch the page and count the link targets that are still wrong.
If the move is part of a bigger server change, build a test copy first, like in build a full test copy before you touch DNS. If files and database come from different places, restoring a site from different backups has the siteurl checks. And for rewrite rules on a non-WordPress app in a folder, see static SPA on Apache with .htaccess fallback.