A lightweight WordPress plugin that tracks changes to plugins, themes, and WordPress core — including changes made outside WordPress via FTP, SSH, deployment scripts, or hosting panel restores.
On a managed or agency-maintained WordPress site, the question "what changed and when?" is harder to answer than it looks.
The obvious approach — listening to WordPress hooks — only captures changes that go through WordPress itself. But a large class of real-world changes don't:
- A developer pushes a deploy via Capistrano, Deployer, or a custom pipeline that rsyncs files directly
- A client's hosting panel restore rolls back plugin files without touching the database
- Someone SSHes in and runs
composer updateor manually replaces a theme folder - WP-CLI is used with flags that skip hooks
- A staging-to-production push replaces files at the server level
- A cPanel File Manager upload overwrites a plugin
In all of these cases, every existing hook-based change tracker produces a clean, empty log — even though the site just changed in a significant way. This is the gap WP Change Ledger is designed to fill.
WP Change Ledger uses two complementary tracking mechanisms that run in parallel:
Listeners attach to WordPress core hooks and fire the moment a change happens through WordPress:
| Hook | What it captures |
|---|---|
activated_plugin / deactivated_plugin |
Plugin status changes with user attribution |
delete_plugin + deleted_plugin |
Plugin removal, version captured before files are gone |
upgrader_process_complete |
Plugin/theme/core updates, distinguishes automatic vs manual |
switch_theme |
Theme changes with previous theme version |
Hook-sourced entries include the WordPress user who made the change and whether it was an automatic or manual update.
Once per day, each checker reads the current state of plugins, themes, and core, then compares it against a stored snapshot. Any discrepancy — a version that changed, a plugin that appeared or disappeared — gets logged.
This is the layer that catches filesystem-level changes. It doesn't know who made the change or how, but it knows what changed and when (within the daily window).
Every cron entry checks whether a hook-sourced entry for the same event type, identifier, and version already exists within the past 25 hours. If it does, the cron skips it — the hook already captured it with better data. If it doesn't, the cron logs it, which is the signal that the change happened outside WordPress.
The source column is the key diagnostic field:
hook— WordPress knew about this change in real time. You know who did it and how.cron— WordPress did not know about this change when it happened. Something changed at the filesystem or database level outside of WordPress.
A cron-sourced entry with no corresponding hook entry means: look outside WordPress first.
- Plugin installs, updates, activations, deactivations, and deletions
- Theme switches and version updates
- WordPress core updates
- Whether the change was automatic or manual (hook-detected changes only)
- Which user performed the change (hook-detected changes only)
- Whether the change was caught in real time (
hook) or by the daily background check (cron) - Previous version on all update events
- Does not detect errors or failures
- Does not send alerts or notifications
- Does not monitor uptime or performance
- Does not predict problems
- Does not modify update behaviour in any way
- Does not attempt to fix issues
These are hard constraints, not missing features. The plugin is intentionally read-only and passive.
GitHub releases only — not available on the WordPress.org plugin directory.
- Download the latest release zip from the Releases page
- Upload and activate via Plugins → Add New → Upload Plugin
- Find the log under Tools → Change Ledger
- WordPress 6.0+
- PHP 8.0+
Tools → Change Ledger in the WordPress admin. A "Change Log" link also appears in the plugin's row on the Plugins screen.
hook— change was detected in real time by a WordPress hook.cron— change was detected by the daily background check. This is the expected source for filesystem-level changes (FTP, SSH, deployment pipelines, hosting restores, etc.).
A cron-only entry for a change you expected to see as hook is a signal worth investigating.
If the entry is source: cron, the change happened outside WordPress. Common causes: a deployment pipeline that copies files directly, a hosting backup restore, a manual file edit over FTP/SSH, or a staging push.
If the entry is source: hook, the change went through WordPress. Check the user_login column and the WordPress admin action log if you have one.
Not in the current version. The plugin operates at the individual site level and stores data per-site. Multisite support (network admin view, network-level tracking) is planned for a future release.
All log data and snapshots are preserved. This is intentional — deactivating and reactivating should not destroy your history. Cron events are properly cleared on deactivation and rescheduled on reactivation.
Data is also preserved on deletion. A data management option (export, clear, selective delete) is planned for a future release. To clear manually: delete the {prefix}change_ledger_log table and the wp_change_ledger_* options from the database.
Configurable under Tools → Change Ledger → Settings. Default is 90 days. Set to 0 to keep entries indefinitely. Pruning runs automatically as part of the daily background check.
Yes. As long as your cron job calls wp cron event run --due-now (or hits wp-cron.php), the plugin's scheduled events will fire normally. The admin notice about cron health respects DISABLE_WP_CRON and will not nag you about timing — it will only alert if the events are not registered in WordPress at all.
Daily is the right default balance between coverage and performance. The wp_options table is not a queue — running snapshot diffs every few minutes on a large plugin list would add meaningful overhead. For most real-world debugging scenarios ("what changed this week?"), daily resolution is sufficient.
If you need sub-daily resolution for a specific investigation, you can trigger the cron events manually: wp cron event run wp_change_ledger_check_plugins.
If a plugin is installed and then deleted within a single day before the cron checker runs, the snapshots will match and no change will be recorded. This is an inherent limitation of the snapshot diffing approach. It is documented here so you can account for it — a clean log does not guarantee nothing happened within the last 24 hours.
Complete rewrite.
- Renamed to WP Change Ledger
- Dedicated database table (
{prefix}change_ledger_log) instead of serialized option — proper indexing, no memory bloat, paginatable - Dual tracking: real-time hook listeners + daily cron snapshot checkers
- Hook-sourced events include user attribution and automatic vs manual detection
- Cron deduplication: skips logging if the same event type + identifier + version was already captured by a hook within 25 hours
sourcefield on every entry (hook/cron)previous_versionfield on all update events- Configurable log retention (default 90 days), pruning runs on daily cron cycle
- Cron health notice on Change Ledger pages only: warns if events are unscheduled; respects
DISABLE_WP_CRON - Cron events self-heal on
admin_initif another plugin accidentally clears them - PSR-4 class structure under
WPChangeLedger\namespace - Proper
register_activation_hook/register_deactivation_hooklifecycle manage_optionscapability check enforced on admin page render- All output escaped with
esc_html() - Paginated log table (50 entries per page)
- Tabbed admin UI: Log + Settings
- "Change Log" action link in plugin row on Plugins screen
- Consistent naming throughout (Tools → Change Ledger)
Initial release as ChangeTrack-o-Matic.
Robothead · wpchangeledger.com
GPL-2.0-or-later