All posts
WordPressMay 6, 2026|5 min read

The rsync Exclude Trap That Breaks WordPress Plugin Migrations

Using --exclude='cache/' in your rsync command during a WordPress migration? That pattern matches at every directory level, and it just silently ate your plugin source code.

S

Showrav Hasan

WordPress & Infrastructure Engineer

WordPressLinuxMigrationrsyncDevOps

I migrated a WordPress site last week. File sync with rsync, database import with mysql, search-replace with WP-CLI. Standard stuff. The migration went smooth until I opened the site and hit a white screen.

PHP Fatal error: Uncaught Error: Class "Tutor\Cache\QuizAttempts" not found
in /home/myuser/public_html/wp-content/plugins/tutor/classes/Quiz_Attempts_List.php on line 69

A missing PHP class in the Tutor LMS plugin. My first thought was a PHP version mismatch. The source server was running PHP 8.3 and the destination was on PHP 7.4. I bumped the destination to 8.3.

Same error.

Then I checked if the class file actually existed:

find ~/public_html/wp-content/plugins/tutor-pro/ -path "*/Cache/QuizAttempts*"

Empty result. The file was simply not there. And that made no sense because the backup archive definitely had it. I had extracted and synced the files myself just minutes earlier.

So I went back and looked at my rsync command.

The Command That Looked Fine

Here is what I ran during the migration:

rsync -a \
  --exclude='.private/config.json' \
  --exclude='wp-content/themes/.git/' \
  --exclude='wp-content/wpvividbackups/' \
  --exclude='cache/' \
  --exclude='temp/' \
  --exclude='upgrade/' \
  ~/tmp_backup_restore/public_html/ ~/public_html/

The exclusions looked reasonable. I did not want to carry over cache files, temporary data, or the WordPress upgrade directory. These are all expendable directories that get regenerated automatically.

But there was a problem. Three of those patterns were way too broad.

How rsync Pattern Matching Actually Works

When you write --exclude='cache/', rsync does not interpret that as "exclude the cache directory at the top level." It matches cache/ as a component at any depth in the directory tree.

That means all of these get excluded:

wp-content/cache/                    ← intended (WordPress cache)
wp-content/plugins/tutor-pro/cache/  ← NOT intended (plugin source code)
wp-content/plugins/flyingpress/cache/ ← NOT intended (plugin internals)

The Tutor Pro plugin stores its QuizAttempts class inside a cache/ subdirectory within the plugin. My rsync command silently skipped that entire directory. The plugin files landed on the server without their caching classes, and the free tutor plugin tried to instantiate Tutor\Cache\QuizAttempts, which no longer existed.

Same thing happened with --exclude='upgrade/'. Elementor stores core upgrade manager classes in elementor/core/upgrade/. That directory got excluded too, and after I fixed the Tutor issue, Elementor threw its own fatal error:

PHP Fatal error: Uncaught Error: Class "Elementor\Core\Upgrade\Manager" not found
in /home/myuser/public_html/wp-content/plugins/elementor/core/experiments/manager.php

Two different plugins, same root cause: bare rsync exclude patterns matching plugin subdirectories that share names with WordPress temp directories.

The Double Red Herring

What made this tricky to debug was the layering of symptoms.

Red herring #1: PHP version. The source was PHP 8.3 and the destination was PHP 7.4. That is a legitimate compatibility gap, and recent versions of Tutor LMS do require PHP 8.0+. So when the first fatal error appeared, the PHP version looked like the obvious cause. But bumping to 8.3 did not fix it because the class file itself was missing from disk.

Red herring #2: Plugin version mismatch. The tutor (free) plugin expected a class from tutor-pro that was not there. This looked like a version mismatch between the two plugins. But the versions were fine. The class was simply in a directory that rsync had skipped.

Both red herrings were real issues in their own right (you should absolutely match PHP versions during a migration), but neither was the actual cause of the fatal error. The actual cause was three characters: cache/ vs wp-content/cache/.

The Fix

Scope every exclude pattern to its intended location:

rsync -a \
  --exclude='.private/config.json' \
  --exclude='wp-content/themes/.git/' \
  --exclude='wp-content/wpvividbackups/' \
  --exclude='wp-content/cache/' \
  --exclude='wp-content/temp/' \
  --exclude='wp-content/upgrade/' \
  ~/tmp_backup_restore/public_html/ ~/public_html/

The difference:

PatternWhat it matches
cache/Any directory named cache at any depth
wp-content/cache/Only wp-content/cache/ specifically

After re-running rsync with the scoped patterns, both plugins loaded cleanly. The site came up without errors.

A Recommended Exclusion List for WordPress Migrations

Based on every migration I have done, here is what I exclude and why:

rsync -a \
  --exclude='wp-content/cache/' \
  --exclude='wp-content/upgrade/' \
  --exclude='wp-content/uploads/cache/' \
  --exclude='wp-content/wpvividbackups/' \
  --exclude='wp-content/updraft/' \
  --exclude='wp-content/ai1wm-backups/' \
  --exclude='wp-content/debug.log' \
  --exclude='wp-content/themes/.git/' \
  /source/public_html/ /destination/public_html/

Every single pattern is scoped to wp-content/ so it cannot accidentally match plugin internals.

Things I do not exclude:

  • wp-content/plugins/ subfolders with names like cache, temp, upgrade, backup. These are plugin source code, not expendable data.
  • wp-config.php. I handle this separately (backup the destination copy before rsync, then restore it after).
  • .htaccess. Some migrations need the source .htaccess rules. I review and merge manually.

How to Verify You Are Not Excluding Too Much

If you want to check what rsync would skip before actually running it, use --dry-run with -v:

rsync -av --dry-run \
  --exclude='cache/' \
  /source/public_html/ /destination/public_html/ \
  | grep "cache"

This will list every file and directory that matches your exclude pattern. If you see plugin paths in the output, your pattern is too broad.

You can also check after the sync by looking for known files:

# Does the Tutor Pro cache class exist?
find ~/public_html/wp-content/plugins/ -path "*/cache/*.php" -type f

If a plugin's PHP files are missing from a cache/ directory, you know the exclude pattern caught them.

The Takeaway

rsync's --exclude is powerful but its matching behavior is not intuitive. A bare directory name like cache/ looks like it targets one specific directory, but it is actually a wildcard that matches everywhere in the tree.

For WordPress migrations specifically, always prefix your exclusions with wp-content/ to scope them to the WordPress cache and temp directories. This keeps your plugin and theme source code intact while still skipping the large, regenerable files you do not need to transfer.

It took me one broken migration to learn this. Hopefully this saves you from the same mistake.

S

Written by Showrav Hasan

WordPress & Infrastructure Engineer with 3,500+ resolved incidents across Rocket.net, Hostinger, and NameSilo. I write about the troubleshooting workflows, server strategies, and engineering decisions behind real production support.

Need hands-on help with this?

I use these same strategies to resolve critical incidents for production WordPress sites.