Admin notices and admin UI conventions

Show the right notice to the right user on the right screen, let them dismiss it for good, and make plugin screens look and behave like core.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Show an admin notice
  • Follow admin UI patterns
  • Make a dismissal persist per user
  • Show a one-off message after an action

Before you start

  • Admin menus and pages
  • Admin menus and pages, from this course
  • Nonces, from the WordPress Development Complete Course

Admin notices are the coloured boxes at the top of admin screens. Used well, they confirm that an action worked or warn about a real problem. Used badly, they are the reason users uninstall plugins: banners on every screen, upgrade adverts, messages that come back after being closed. This lesson shows the mechanics and the manners.

The basic notice

Notices are printed on the admin_notices action, which fires near the top of every admin screen. Current versions of WordPress include wp_admin_notice(), which builds the markup for you:

PHP
<?php
// wp-content/plugins/myplugin/admin/notices.php
defined( 'ABSPATH' ) || exit;

add_action( 'admin_notices', 'myplugin_missing_table_notice' );

function myplugin_missing_table_notice(): void {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    if ( get_option( 'myplugin_db_version' ) ) {
        return;
    }

    wp_admin_notice(
        esc_html__( 'My Plugin could not create its bookings table. Deactivate and reactivate the plugin, or contact your host.', 'myplugin' ),
        array(
            'type' => 'error',
            'id'   => 'myplugin-missing-table',
        )
    );
}

wp_admin_notice() takes the message and an array of options:

OptionEffect
typesuccess, error, warning or info, which sets the colour
dismissibleAdds a close button when true
idAn HTML ID for the notice
additional_classesExtra CSS classes, as an array
paragraph_wrapWraps the message in <p>; true by default

The markup it produces is the classic pattern, which you will also see in older plugins:

HTML
<div id="myplugin-missing-table" class="notice notice-error">
    <p>My Plugin could not create its bookings table.</p>
</div>

The function filters the HTML with wp_kses_post(), but escape the message yourself anyway, as above, so the code is safe whatever function prints it.

Who, where and when

Every notice should pass three checks before it prints:

  1. Who: only users who can act on it. A database error means nothing to an editor. Check a capability.
  2. Where: only on screens where it matters. A setup reminder belongs on the plugin's own screens, not on every page of the admin.
  3. When: only while it is true. The table notice above disappears as soon as the version option exists.

get_current_screen() tells you where you are. Its id property identifies the screen, and post_type is set on post screens:

PHP
<?php
// wp-content/plugins/myplugin/admin/notices.php

/**
 * True on the Events list, the event editor and the plugin's own pages.
 */
function myplugin_is_plugin_screen(): bool {
    $screen = get_current_screen();

    if ( ! $screen ) {
        return false;
    }

    return 'myplugin_event' === $screen->post_type || str_contains( $screen->id, 'myplugin' );
}

The post type check covers the events list and editor. The ID check covers the plugin's own pages, because the screen ID of a menu page is its hook suffix, which contains the prefixed menu slug, such as myplugin-bookings.

One-off messages after an action

When a handler on admin-post.php finishes, it redirects back to a screen. To confirm what happened, add a short code to the redirect URL and show a message for that code:

PHP
<?php
// In a handler, after a successful import.
wp_safe_redirect(
    add_query_arg(
        'myplugin_notice',
        'imported',
        admin_url( 'edit.php?post_type=myplugin_event' )
    )
);
exit;
PHP
<?php
// wp-content/plugins/myplugin/admin/notices.php
add_action( 'admin_notices', 'myplugin_action_notices' );

function myplugin_action_notices(): void {
    if ( ! isset( $_GET['myplugin_notice'] ) ) {
        return;
    }

    $messages = array(
        'imported'  => array( 'success', __( 'Events imported.', 'myplugin' ) ),
        'cancelled' => array( 'success', __( 'Booking cancelled.', 'myplugin' ) ),
        'failed'    => array( 'error', __( 'Something went wrong. Nothing was changed.', 'myplugin' ) ),
    );

    $code = sanitize_key( wp_unslash( $_GET['myplugin_notice'] ) );

    if ( ! isset( $messages[ $code ] ) ) {
        return;
    }

    wp_admin_notice(
        esc_html( $messages[ $code ][1] ),
        array(
            'type'        => $messages[ $code ][0],
            'dismissible' => true,
        )
    );
}

add_filter( 'removable_query_args', 'myplugin_removable_query_args' );

function myplugin_removable_query_args( array $args ): array {
    $args[] = 'myplugin_notice';
    return $args;
}

The URL carries a code, never the message text. If it carried text, anyone could send an administrator a link that shows a convincing fake message inside their own admin. The allowlist means only your messages can appear.

The removable_query_args filter tells WordPress to remove the parameter from the address bar after the page loads, so a refresh or a bookmark does not show the message again. Core uses the same trick for its own updated and deleted messages.

Dismissals that stay dismissed

Adding is-dismissible gives a notice a close button, but closing it only hides it in the browser. On the next page load it is back. For a notice the user may legitimately ignore, such as a setup suggestion, store the dismissal per user.

This version uses a plain link with a nonce, so it works without JavaScript. The AJAX lesson shows how to make the same dismissal happen without a page reload.

PHP
<?php
// wp-content/plugins/myplugin/admin/notices.php
add_action( 'admin_notices', 'myplugin_setup_notice' );

function myplugin_setup_notice(): void {
    if ( ! current_user_can( 'manage_options' ) || ! myplugin_is_plugin_screen() ) {
        return;
    }

    $dismissed = (array) get_user_meta( get_current_user_id(), 'myplugin_dismissed_notices', true );

    if ( in_array( 'setup', $dismissed, true ) ) {
        return;
    }

    $settings_url = menu_page_url( 'myplugin-settings', false );
    $dismiss_url  = wp_nonce_url(
        admin_url( 'admin-post.php?action=myplugin_dismiss_notice&notice=setup' ),
        'myplugin_dismiss_notice_setup'
    );

    $message = sprintf(
        '%1$s <a href="%2$s">%3$s</a> | <a href="%4$s">%5$s</a>',
        esc_html__( 'Choose your currency and booking limits before taking bookings.', 'myplugin' ),
        esc_url( $settings_url ),
        esc_html__( 'Open settings', 'myplugin' ),
        esc_url( $dismiss_url ),
        esc_html__( 'Dismiss', 'myplugin' )
    );

    wp_admin_notice( $message, array( 'type' => 'info' ) );
}

add_action( 'admin_post_myplugin_dismiss_notice', 'myplugin_handle_dismiss_notice' );

function myplugin_handle_dismiss_notice(): void {
    $notice = isset( $_GET['notice'] ) ? sanitize_key( wp_unslash( $_GET['notice'] ) ) : '';

    if ( ! current_user_can( 'manage_options' ) || ! in_array( $notice, array( 'setup' ), true ) ) {
        wp_die( esc_html__( 'You are not allowed to do this.', 'myplugin' ), '', array( 'response' => 403 ) );
    }

    check_admin_referer( 'myplugin_dismiss_notice_' . $notice );

    $user_id   = get_current_user_id();
    $dismissed = (array) get_user_meta( $user_id, 'myplugin_dismissed_notices', true );

    $dismissed[] = $notice;
    update_user_meta( $user_id, 'myplugin_dismissed_notices', array_values( array_unique( array_filter( $dismissed ) ) ) );

    wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
    exit;
}

The pattern is the same as every state change in this course: capability, allowlist, nonce, then the write. Storing dismissals in user meta means each administrator decides for themselves. For a notice that should go away for everyone once one person acts, store a flag in an option instead. Add myplugin_dismissed_notices to your uninstall clean-up.

To welcome users after activation, set an option in the activation routine, such as add_option( 'myplugin_show_welcome', 1 ), show the notice while it exists, and delete it when the notice is dismissed. Activation itself must never print.

Do not nag

The WordPress.org plugin guidelines and users agree on these:

  • No adverts, upsells or review requests on screens that are not yours, and none that cannot be dismissed permanently.
  • No notices on the Dashboard, Plugins screen or every admin page for anything less than a real problem.
  • No notices to users who cannot act on them.
  • Never hide or restyle other plugins' notices or core's.
  • Errors explain what to do next, not just what went wrong.

Admin UI conventions

Plugin screens should look like part of WordPress. Core provides the styles; use them rather than your own:

ElementMarkup
Page wrapper<div class="wrap">
Title with an action button<h1 class="wp-heading-inline"> followed by <a class="page-title-action">
End of the header area<hr class="wp-header-end">; core moves notices to just after it
Settings-style form rows<table class="form-table" role="presentation">
Buttonssubmit_button(), or class="button" and class="button button-primary"
Help text under a field<p class="description">
Field widthsregular-text, small-text, large-text
IconsDashicons, such as <span class="dashicons dashicons-calendar-alt"></span>

A page header in the core style:

PHP
<div class="wrap">
    <h1 class="wp-heading-inline"><?php echo esc_html( get_admin_page_title() ); ?></h1>
    <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=myplugin_event' ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add event', 'myplugin' ); ?></a>
    <hr class="wp-header-end">
    <!-- Page content. -->
</div>

Beyond markup: one primary button per screen, labels on every field, sentence case for headings, and forms that keep the user's input when validation fails. Users already know how core screens work. Every place your plugin behaves the same way is one less thing to learn.

Summary

  • Print notices on admin_notices, with wp_admin_notice() or the notice notice-{type} markup, and escape the message.
  • Show each notice only to users who can act, only on relevant screens, and only while the condition is true.
  • After an action, redirect with an allowlisted code, never message text, and add the parameter to removable_query_args.
  • is-dismissible only hides a notice in the browser; persist dismissals in user meta or an option, behind a capability and nonce check.
  • Use core classes for layout and buttons so plugin screens look and behave like WordPress.

Further reading