Get notified when WordPress plugins need updating: a plugin you can build today
· 5 min read
WordPress shows a badge in the admin toolbar when plugins need updating, but only if you are logged in and looking at it. There is no email that says three plugins on this site are waiting. If you run your own site and check the dashboard regularly, the badge is enough. If you manage sites for clients, or run several of your own, relying on remembering to log in leaves gaps.
The complete answer for managing multiple WordPress sites is MainWP. It gives you a central dashboard, shows update status across every site you connect, and lets you run updates in bulk. If you are managing more than a handful of sites and want a proper workflow, MainWP is worth setting up. ManageWP is the hosted alternative if you prefer not to run your own server.
The gap this plugin fills is narrower: a weekly email listing pending updates on a single site, sent whether or not you think to log in. No central server, no dashboard to maintain. It runs on each site independently and emails you whenever something falls behind. For a freelancer with two or three client sites, that is often enough.
What one build gives you
- Weekly or daily email digest of pending plugin, theme, and core updates
- Sends only when updates are pending, not a blank email on a fixed schedule
- Settings for recipient email, frequency, and which update types to include
- Test email button to verify delivery before waiting for the scheduled run
What it does not do
- Multiple sites: the plugin runs independently on each site with no central aggregation
- Applying updates: reports what is pending but does not run anything
- Update changelogs or release notes: lists version numbers only
- Filtering by update age: emails on the first pending update with no grace period setting
- Rollback or staging: no mechanism to test updates before applying them
ManageWP does these. This covers the part most sites use.
The prompt
Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.
Build a WordPress plugin called WP Update Notifier. Plugin Name: WP Update Notifier. Version: 1.0.0. Description: Email a digest of pending plugin, theme, and core updates on a configurable schedule. File structure: one PHP file. At the top level of the file (outside the class), register activation and deactivation hooks: register_activation_hook(__FILE__, ['WP_Update_Notifier', 'activate']); register_deactivation_hook(__FILE__, ['WP_Update_Notifier', 'deactivate']); add_action('plugins_loaded', function() { (new WP_Update_Notifier())->init(); }); Class WP_Update_Notifier: public static activate(): if (!wp_next_scheduled('wp_un_digest')) { $opts = get_option('wp_update_notifier_options', []); $freq = !empty($opts['frequency']) ? $opts['frequency'] : 'weekly'; wp_schedule_event(time(), $freq, 'wp_un_digest'); } public static deactivate(): $ts = wp_next_scheduled('wp_un_digest'); if ($ts) wp_unschedule_event($ts, 'wp_un_digest'); public init(): add_filter('cron_schedules', [$this, 'add_weekly_schedule']); add_action('wp_un_digest', [$this, 'send_digest']); add_action('admin_menu', [$this, 'add_menu']); add_action('admin_init', [$this, 'register_settings']); add_action('admin_init', [$this, 'handle_test']); add_action('admin_init', [$this, 'maybe_reschedule']); add_weekly_schedule($schedules): if (!isset($schedules['weekly'])) $schedules['weekly'] = ['interval' => 604800, 'display' => 'Once Weekly']; return $schedules; maybe_reschedule(): $opts = get_option('wp_update_notifier_options', []); $freq = !empty($opts['frequency']) ? $opts['frequency'] : 'weekly'; $ts = wp_next_scheduled('wp_un_digest'); if (!$ts) { wp_schedule_event(time(), $freq, 'wp_un_digest'); } else { $event = wp_get_scheduled_event('wp_un_digest'); if ($event && $event->schedule !== $freq) { wp_unschedule_event($ts, 'wp_un_digest'); wp_schedule_event(time(), $freq, 'wp_un_digest'); } } add_menu(): add_options_page('Update Notifier', 'Update Notifier', 'manage_options', 'wp-update-notifier', [$this, 'render_page']); register_settings(): register_setting('wp_update_notifier', 'wp_update_notifier_options', ['sanitize_callback' => [$this, 'sanitize_options']]). sanitize_options($input): return ['email' => sanitize_email($input['email'] ?? ''), 'frequency' => in_array($input['frequency'] ?? '', ['daily', 'weekly']) ? $input['frequency'] : 'weekly', 'include' => array_values(array_intersect((array)($input['include'] ?? []), ['plugins', 'themes', 'core']))]; render_page(): $defaults = ['email' => get_option('admin_email'), 'frequency' => 'weekly', 'include' => ['plugins', 'themes', 'core']]; $opts = wp_parse_args(get_option('wp_update_notifier_options', []), $defaults); Output a div.wrap with h1 'Update Notifier'. If $_GET['sent'] is set show: '<div class="notice notice-success"><p>Test email sent.</p></div>'. Form 1: method post, settings_fields('wp_update_notifier'), then a table.form-table with rows: Email (input[type=email name=wp_update_notifier_options[email] value=esc_attr($opts['email']) class=regular-text]), Frequency (select[name=wp_update_notifier_options[frequency]] with options value=weekly 'Weekly' and value=daily 'Daily', selected= matching $opts['frequency']), Notify about (three checkboxes each with name=wp_update_notifier_options[include][] and values plugins/themes/core, checked if value is in $opts['include'], labelled 'Plugins' / 'Themes' / 'WordPress core'). submit_button('Save Settings'). Form 2: method post, wp_nonce_field('wp_un_test'), hidden input name=wp_un_send_test value=1, submit_button('Send test email now', 'secondary'). handle_test(): if (empty($_POST['wp_un_send_test'])) return; check_admin_referer('wp_un_test'); $this->send_digest(); wp_redirect(add_query_arg(['page' => 'wp-update-notifier', 'sent' => 1], admin_url('options-general.php'))); exit; send_digest(): Force a fresh check: wp_update_plugins(); wp_update_themes(); wp_version_check(); $opts = get_option('wp_update_notifier_options', []); $include = !empty($opts['include']) ? (array)$opts['include'] : ['plugins', 'themes', 'core']; $to = sanitize_email(!empty($opts['email']) ? $opts['email'] : get_option('admin_email')); $lines = []; Plugins: if (in_array('plugins', $include)) { $data = get_site_transient('update_plugins'); foreach ((array)($data->response ?? []) as $file => $info) { $pd = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, false); $lines[] = 'Plugin: ' . ($pd['Name'] ?: $file) . ' ' . ($pd['Version'] ?: '?') . ' -> ' . ($info->new_version ?? '?'); } } Themes: if (in_array('themes', $include)) { $data = get_site_transient('update_themes'); foreach ((array)($data->response ?? []) as $slug => $info) { $t = wp_get_theme($slug); $lines[] = 'Theme: ' . $t->get('Name') . ' ' . $t->get('Version') . ' -> ' . ($info['new_version'] ?? '?'); } } Core: if (in_array('core', $include)) { $updates = get_core_updates(); if (!empty($updates) && isset($updates[0]->response) && $updates[0]->response === 'upgrade') { global $wp_version; $lines[] = 'WordPress: ' . $wp_version . ' -> ' . ($updates[0]->version ?? '?'); } } if (empty($lines)) return; $site = get_bloginfo('name'); $count = count($lines); $subject = '[' . $site . '] ' . $count . ' update' . ($count === 1 ? '' : 's') . ' pending'; $body = 'Updates pending on ' . get_bloginfo('url') . ":\n\n" . implode("\n", $lines) . "\n\nManage updates: " . admin_url('update-core.php'); wp_mail($to, $subject, $body); No custom database table, no JavaScript, no front-end output.
Why WordPress does not notify you about pending updates
WordPress does show update counts in the admin, and it has had auto-updates for core and plugins since version 5.5. When auto-updates run, WordPress sends an email confirming what was applied. What it does not send is a pre-update notification: a heads-up that updates are waiting and nothing has been applied yet.
This matters because some sites cannot be updated without testing first. A staging workflow, a client contract requiring approval, or a plugin combination that has broken on previous updates are all reasons to leave auto-updates off and handle them manually. Once you turn off auto-updates, the only way to know about pending updates is to log in.
Plugins for this exist in the WordPress directory. WP Update Notifier has been around since 2012 and does the job. But its settings are minimal, it has not been updated recently, and building your own means you can extend it: add a threshold that only alerts after plugins are a week out of date, or trigger a webhook instead of an email. The build here gives you the full logic to start from.
What the plugin does
On activation, the plugin registers a scheduled cron event that runs on a configurable interval, daily or weekly. When the event fires, it calls the WordPress update API to get fresh data: pending plugin updates, pending theme updates, and any available WordPress core update. If nothing is pending, no email is sent.
When updates are pending, it sends a plain-text email to the configured address listing each item and the version numbers involved, current and available. The subject line includes the site name and the update count so you can tell at a glance which site is writing and how many items need attention.
The settings page at Settings > Update Notifier has four fields: recipient email (defaults to the admin email), frequency (daily or weekly), and checkboxes for whether to include plugins, themes, and core in the check. A Send test email now button lets you verify delivery before waiting for the scheduled run.
Limits of this build
The plugin runs on one site. It has no knowledge of any other site and no central place to aggregate reports. If you manage ten client sites, you need the plugin on each one, each emailing a separate digest. That is workable for a few sites and impractical beyond that.
It checks for available updates but does not apply them. The email links to wp-admin/update-core.php where you handle updates manually. Automated updates with rollback are a different class of tool.
WordPress cron fires when a visitor loads a page. On low-traffic sites or development environments, scheduled events may run late. You can test whether the event is firing with WP Crontrol. On hosting that restricts PHP cron, set up a real cron job that hits wp-cron.php on a schedule to ensure the digest arrives reliably.
When to use a dedicated tool instead
MainWP is the right tool once you are managing more than a few sites and want a single view of all of them. It is self-hosted, installed on a separate WordPress site, free for the core dashboard, and connects to client sites through a companion plugin. The update view shows every site with pending updates in one place and lets you apply them in batch.
ManageWP is the same concept as a hosted service. No server to maintain, and the free tier covers basic update monitoring for a handful of sites. The paid tiers add automated backups, performance monitoring, and client reporting. If self-hosting a MainWP instance is not worth it for your use case, ManageWP is the practical alternative.
Both tools are meaningfully more than this plugin. If you are already spending real time on manual update rounds across multiple sites, the setup cost of either platform pays back quickly. This plugin is for the earlier stage: one or two client sites, wanting email visibility without committing to a management platform yet.
Questions
- Does this conflict with auto-updates being enabled?
No. If auto-updates run, the updates are applied and drop off the pending list. The digest simply will not mention them. You can run both at the same time.
- Why build this instead of installing WP Update Notifier from the directory?
If the free plugin does what you need, install it. Building your own makes sense if you want to extend it: trigger a webhook instead of an email, skip updates less than a week old, or fold it into a larger admin digest. The code here gives you the full logic to start from.
- Will it work on hosting that restricts cron?
WordPress cron fires when a visitor loads a page. On low-traffic sites, scheduled events may run late. You can verify the cron is working with the WP Crontrol plugin. On hosting that blocks PHP cron entirely, set up a server cron job that hits wp-cron.php on a schedule.
- Can it notify multiple email addresses?
Not in the initial build. wp_mail() takes a single recipient. To notify multiple addresses, change the $to variable to a comma-separated string or an array.
- What if I only want an alert after a plugin has been out of date for more than a week?
That requires storing the date each pending update was first detected. Add an option key per plugin recording when it was first seen, and skip any that were first seen less than seven days ago. The initial build does not include this.
The prompt
Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.
Build a WordPress plugin called WP Update Notifier. Plugin Name: WP Update Notifier. Version: 1.0.0. Description: Email a digest of pending plugin, theme, and core updates on a configurable schedule. File structure: one PHP file. At the top level of the file (outside the class), register activation and deactivation hooks: register_activation_hook(__FILE__, ['WP_Update_Notifier', 'activate']); register_deactivation_hook(__FILE__, ['WP_Update_Notifier', 'deactivate']); add_action('plugins_loaded', function() { (new WP_Update_Notifier())->init(); }); Class WP_Update_Notifier: public static activate(): if (!wp_next_scheduled('wp_un_digest')) { $opts = get_option('wp_update_notifier_options', []); $freq = !empty($opts['frequency']) ? $opts['frequency'] : 'weekly'; wp_schedule_event(time(), $freq, 'wp_un_digest'); } public static deactivate(): $ts = wp_next_scheduled('wp_un_digest'); if ($ts) wp_unschedule_event($ts, 'wp_un_digest'); public init(): add_filter('cron_schedules', [$this, 'add_weekly_schedule']); add_action('wp_un_digest', [$this, 'send_digest']); add_action('admin_menu', [$this, 'add_menu']); add_action('admin_init', [$this, 'register_settings']); add_action('admin_init', [$this, 'handle_test']); add_action('admin_init', [$this, 'maybe_reschedule']); add_weekly_schedule($schedules): if (!isset($schedules['weekly'])) $schedules['weekly'] = ['interval' => 604800, 'display' => 'Once Weekly']; return $schedules; maybe_reschedule(): $opts = get_option('wp_update_notifier_options', []); $freq = !empty($opts['frequency']) ? $opts['frequency'] : 'weekly'; $ts = wp_next_scheduled('wp_un_digest'); if (!$ts) { wp_schedule_event(time(), $freq, 'wp_un_digest'); } else { $event = wp_get_scheduled_event('wp_un_digest'); if ($event && $event->schedule !== $freq) { wp_unschedule_event($ts, 'wp_un_digest'); wp_schedule_event(time(), $freq, 'wp_un_digest'); } } add_menu(): add_options_page('Update Notifier', 'Update Notifier', 'manage_options', 'wp-update-notifier', [$this, 'render_page']); register_settings(): register_setting('wp_update_notifier', 'wp_update_notifier_options', ['sanitize_callback' => [$this, 'sanitize_options']]). sanitize_options($input): return ['email' => sanitize_email($input['email'] ?? ''), 'frequency' => in_array($input['frequency'] ?? '', ['daily', 'weekly']) ? $input['frequency'] : 'weekly', 'include' => array_values(array_intersect((array)($input['include'] ?? []), ['plugins', 'themes', 'core']))]; render_page(): $defaults = ['email' => get_option('admin_email'), 'frequency' => 'weekly', 'include' => ['plugins', 'themes', 'core']]; $opts = wp_parse_args(get_option('wp_update_notifier_options', []), $defaults); Output a div.wrap with h1 'Update Notifier'. If $_GET['sent'] is set show: '<div class="notice notice-success"><p>Test email sent.</p></div>'. Form 1: method post, settings_fields('wp_update_notifier'), then a table.form-table with rows: Email (input[type=email name=wp_update_notifier_options[email] value=esc_attr($opts['email']) class=regular-text]), Frequency (select[name=wp_update_notifier_options[frequency]] with options value=weekly 'Weekly' and value=daily 'Daily', selected= matching $opts['frequency']), Notify about (three checkboxes each with name=wp_update_notifier_options[include][] and values plugins/themes/core, checked if value is in $opts['include'], labelled 'Plugins' / 'Themes' / 'WordPress core'). submit_button('Save Settings'). Form 2: method post, wp_nonce_field('wp_un_test'), hidden input name=wp_un_send_test value=1, submit_button('Send test email now', 'secondary'). handle_test(): if (empty($_POST['wp_un_send_test'])) return; check_admin_referer('wp_un_test'); $this->send_digest(); wp_redirect(add_query_arg(['page' => 'wp-update-notifier', 'sent' => 1], admin_url('options-general.php'))); exit; send_digest(): Force a fresh check: wp_update_plugins(); wp_update_themes(); wp_version_check(); $opts = get_option('wp_update_notifier_options', []); $include = !empty($opts['include']) ? (array)$opts['include'] : ['plugins', 'themes', 'core']; $to = sanitize_email(!empty($opts['email']) ? $opts['email'] : get_option('admin_email')); $lines = []; Plugins: if (in_array('plugins', $include)) { $data = get_site_transient('update_plugins'); foreach ((array)($data->response ?? []) as $file => $info) { $pd = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, false); $lines[] = 'Plugin: ' . ($pd['Name'] ?: $file) . ' ' . ($pd['Version'] ?: '?') . ' -> ' . ($info->new_version ?? '?'); } } Themes: if (in_array('themes', $include)) { $data = get_site_transient('update_themes'); foreach ((array)($data->response ?? []) as $slug => $info) { $t = wp_get_theme($slug); $lines[] = 'Theme: ' . $t->get('Name') . ' ' . $t->get('Version') . ' -> ' . ($info['new_version'] ?? '?'); } } Core: if (in_array('core', $include)) { $updates = get_core_updates(); if (!empty($updates) && isset($updates[0]->response) && $updates[0]->response === 'upgrade') { global $wp_version; $lines[] = 'WordPress: ' . $wp_version . ' -> ' . ($updates[0]->version ?? '?'); } } if (empty($lines)) return; $site = get_bloginfo('name'); $count = count($lines); $subject = '[' . $site . '] ' . $count . ' update' . ($count === 1 ? '' : 's') . ' pending'; $body = 'Updates pending on ' . get_bloginfo('url') . ":\n\n" . implode("\n", $lines) . "\n\nManage updates: " . admin_url('update-core.php'); wp_mail($to, $subject, $body); No custom database table, no JavaScript, no front-end output.
Steem