stellarwp / core-update-notice
A WordPress core update notice scoped to consuming plugins' admin pages, with dismissal shared across plugins.
Requires
- php: ^7.4 || ^8.0
Requires (Dev)
- brain/monkey: ^2.6
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^9.5
- squizlabs/php_codesniffer: ^3.9
- stellarwp/nexcess-coding-standards: ^2.0
- szepeviktor/phpstan-wordpress: ^2.0
This package is auto-updated.
Last update: 2026-08-18 17:57:42 UTC
README
A WordPress core update notice for plugin-owned admin pages, dismissed once across every plugin that displays it.
Table of contents
- Installation
- Notes on examples
- Displaying the notice
- Translations
- Service containers
- Dismissal
- Choosing which plugin displays the notice
- Shared state
- Development
Installation
Install the package from Packagist:
composer require stellarwp/core-update-notice
Then prefix it with Strauss. This is required, not a suggestion: WordPress plugins share one PHP namespace, so two plugins shipping unprefixed copies of this package would collide on whichever autoloader registered first, and the version that won would be whichever plugin happened to load earliest.
See the Strauss setup docs.
The package is built for this. Everything it shares between plugins is a string key, which Strauss leaves alone while it rewrites namespaces and class names, so prefixed copies still agree on one dismissal. See Shared state.
Notes on examples
Because the package is prefixed, all examples use StraussGeneratedNamespace to stand in for
whatever prefix you configure.
Displaying the notice
Register the notice on init and provide a callback that identifies your plugin's admin pages:
use StraussGeneratedNamespace\StellarWP\CoreUpdateNotice\CoreUpdateNotice; use StraussGeneratedNamespace\StellarWP\CoreUpdateNotice\Register; $isPluginPage = static function (): bool { $screen = get_current_screen(); return $screen !== null && in_array( $screen->id, [ 'toplevel_page_my-plugin', 'my-plugin_page_my-plugin-settings', ], true ); }; add_action( 'init', static fn() => Register::notice( new CoreUpdateNotice(), $isPluginPage ) );
Use your plugin's existing admin-page helper instead when it has one:
$isPluginPage = static fn(): bool => my_plugin_is_admin_page();
The callback is required so the notice cannot become a global wp-admin notice by accident. It runs
during admin_notices, when get_current_screen() is available, not when Register::notice() is
called. Each callback is evaluated at most once per request, and its result is reused for subsequent
display-filter evaluations. Return true only for screens owned by the consuming plugin.
Registration hooks admin_init for dismissal and admin_notices for output. The notice is shown
only on an eligible plugin page, only to users with the update_core capability, and only while
WordPress reports an available core upgrade.
Translations
The default copy is English and untranslated. Pass translated copy using your plugin's text domain:
add_action( 'init', static function (): void { Register::notice( new CoreUpdateNotice( [ 'heading' => __( 'Keep your site protected. Update to the latest version of WordPress.', 'my-plugin' ), 'body' => __( 'Your site is running on an outdated version of WordPress, …', 'my-plugin' ), 'dismiss' => __( 'Dismiss this notice.', 'my-plugin' ), ] ), static fn(): bool => my_plugin_is_admin_page() ); } );
Any key you leave out falls back to the English default. Call this on init, when translations are
loaded and before admin_init runs. Registration at or after admin_init is rejected because it
would miss dismissal handling and could enter the winner contest too late.
Service containers
The package does not depend on a container. A project that uses one can let the container construct the notice, then pass that instance to the registration boundary:
use StraussGeneratedNamespace\StellarWP\CoreUpdateNotice\CoreUpdateNotice; use StraussGeneratedNamespace\StellarWP\CoreUpdateNotice\Register; $container->singleton( CoreUpdateNotice::class ); $isPluginPage = static fn(): bool => my_plugin_is_admin_page(); add_action( 'init', static fn() => Register::notice( $container->get( CoreUpdateNotice::class ), $isPluginPage ) );
Alternatively, construct the notice first and bind that same instance according to your container's API before registering it:
add_action( 'init', static function () use ( $container, $copy, $isPluginPage ): void { $notice = new CoreUpdateNotice( $copy ); $container->singleton( CoreUpdateNotice::class, $notice ); Register::notice( $notice, $isPluginPage ); } );
Container setup remains a responsibility of the consuming project; this package only requires a
CoreUpdateNotice instance.
Dismissal
Dismissals are stored by exact WordPress version. If a site running 6.7.1 dismisses an offer to update to 6.8.2, only the notice for 6.8.2 is hidden. A later offer for 6.8.3 or 6.9.0 appears normally. Likewise, dismissing 6.9.0 does not hide a subsequent 6.8.3 security update because they are separate versions.
The dismiss link carries the offered version and its nonce is bound to that exact value. The handler validates and adds the version the user saw instead of re-querying an offer that may have changed since the page was rendered. Stale links add their version without replacing other dismissals.
Choosing which plugin displays the notice
Every copy enters a global dismissal contest when Register::notice() runs. During
admin_notices, only copies whose plugin-page callback returns true enter a separate display
contest. The eligible instance with the highest CoreUpdateNotice::NOTICE_VERSION renders. If no
copy is eligible for the current screen, nothing renders.
For example, on a Kadence Blocks page, Kadence's copy can render while GiveWP's copy stands down, even if GiveWP bundles a newer release. If more than one plugin claims the screen, the newest eligible copy wins and plugin load order does not decide between different versions.
The newest copy registered anywhere in wp-admin handles dismissal. This lets it process the shared
dismiss link even when an older copy rendered on its own plugin page. Plugin-page callbacks are not
evaluated during admin_init dismissal handling.
Equal versions fall back to the first instance registered.
Bump NOTICE_VERSION whenever the notice's copy or behaviour changes. It is deliberately separate
from the package version, so a release that only touches tooling does not reshuffle which plugin
owns the notice.
Shared state
Each plugin prefixes its own copy, which rewrites namespaces and class names but not string literals. Everything shared between plugins is therefore a string key:
| Key | Purpose |
|---|---|
nx_wp_core_update_notice_dismissed |
Site option containing exact dismissed WordPress versions. Non-autoloaded. |
nx-dismiss-wp-core-update-notice |
Dismiss query argument and nonce action, bound to the rendered WordPress version. |
nx_wp_core_update_notice_winner |
WordPress filter that elects the global dismissal handler. |
nx_wp_core_update_notice_display_winner |
WordPress filter that elects a renderer from copies eligible for the current screen. |
The shared filters require a version string and object reference at minimum, so prefixed copies can participate without sharing PHP classes or direct globals. Copies must preserve additional fields unchanged for forward compatibility.
These keys and the values passed through them are a cross-version compatibility contract. Do not rename them or require the winner object to belong to a particular PHP class: another plugin may still be running an older prefixed copy. Both winner filters use this minimum payload shape:
[ 'version' => CoreUpdateNotice::NOTICE_VERSION, 'notice' => $notice, ]
The highest version wins, and equal versions keep the first candidate. The dismiss nonce action
is always CoreUpdateNotice::DISMISS_ACTION . ':' . $offeredVersion; changing that formula would
prevent a different bundled copy from handling the rendered link.
Dismissal is a nonce-protected link rather than the core dismiss button, which only removes the node
client side. The notice carries is-dismissible because that rule supplies the position: relative
and padding-right: 48px the absolutely positioned control needs, and core's
makeNoticesDismissible() skips notices that already contain a .notice-dismiss, so no second,
non-persisting button is appended. No script ships.
On multisite, the notice intentionally appears only on individual site admin screens, not in
Network Admin. The update_core capability still limits it to super administrators, while
update_option stores each site's dismissal separately and the update transient remains network
wide.
Development
composer install composer check
| Command | What it runs |
|---|---|
composer phpcs |
Nexcess coding standards over src and tests |
composer phpstan |
Level 8, src only |
composer test |
PHPUnit |
composer check |
All three, in that order |
The suite uses Brain\Monkey to stub the WordPress
functions the package calls, so it runs with nothing but Composer installed. exit cannot be
intercepted in a test, so CoreUpdateNotice::terminate() wraps it and the suite overrides that
method.
Requires PHP 7.4+ and WordPress 6.6+.