Administration Menus and Settings API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

The menu API adds admin navigation entries routed to a callback; the Settings API declares settings, builds the form and saves it through wp-admin/options.php with nonce, capability and sanitizing done; the Options API stores values in wp_options. This page, for plugin developers, builds the Catalogue menu of lumen-catalog, the Lumen Press book catalogue plugin: an Overview, a Settings screen with four typed, REST-exposed settings, a notice and Help.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0 and WPCS 3.4.1, and checked on 2026-10-28. The outputs come from a 7.1.3 site on PHP 8.4 and MariaDB 11.8 (the stack of Local Development Environments). WordPress 7.2 (in beta, due December 2026) changes none of these APIs so far. The plugin skeleton is in Plugin Fundamentals.

How the admin menu is built

wp-admin/menu.php fills the globals $menu (keyed by position) and $submenu (keyed by parent slug) with the core items, then fires admin_menu (network_admin_menu, user_admin_menu in the other admins), where plugins add theirs. wp-admin/includes/menu.php removes items the user lacks the capability for and checks an opened screen with user_can_access_admin_page(): "Sorry, you are not allowed to access this page." (403).

Function Adds

add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback, $icon_url, $position )

A top-level item. $icon_url is a Dashicons class (dashicons-book-alt), a URL, or a base64 SVG data URI (data:image/svg+xml;base64,...) that the admin recolours.

add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $callback, $position )

An item under a plugin’s top-level slug or a core file such as options-general.php or edit.php?post_type=lp_book.

add_options_page(), add_management_page(), add_theme_page(), add_users_page(), add_plugins_page(), add_dashboard_page(), add_posts_page(), add_pages_page(), add_media_page(), add_comments_page()

Shortcuts for add_submenu_page() under Settings, Tools, Appearance, Users, Plugins, Dashboard, Posts, Pages, Media and Comments.

remove_menu_page(), remove_submenu_page()

Removal from the arrays. The URL still opens for anyone with the capability.

Core positions are 2 (Dashboard), 5 (Posts), 10 (Media), 20 (Pages), 25 (Comments), 60 (Appearance), 65 (Plugins), 70 (Users), 75 (Tools) and 80 (Settings), with separators at 4, 59 and 99; post types without menu_position take 26 and up. A taken position gets a fraction derived from the slug instead of being overwritten.

Each function returns the screen’s hook suffix (false without the capability), used by load-{$hook_suffix} (help tabs, form handling) and admin_enqueue_scripts. A submenu page’s suffix is <parent>_page_<slug>, where <parent> is the sanitized parent menu title, so translation changes it: keep the returned value.

The $capability decides visibility and access: manage_options for site configuration, the content’s capability for content screens, here edit_lp_books from the lp_book type (see Custom Post Types and Taxonomies). Lumen Press lets its Catalogue Manager role change catalogue settings without manage_options, so the Settings screen uses a custom capability, manage_lumen_catalog, granted on activation to Administrators and Catalogue Managers (Roles and Capabilities API). options.php requires manage_options unless the option_page_capability_{$option_group} filter (3.2; also applied to direct options.php requests since 7.0) returns the screen’s capability.

The Settings API

  1. register_setting( $option_group, $option_name, $args ) declares an option and adds it to the group’s allow list; options.php saves only registered options.

  2. add_settings_section( $id, $title, $callback, $page ) adds an <h2> and an intro callback.

  3. add_settings_field( $id, $title, $callback, $page, $section, $args ) adds a row; label_for in $args turns the title into a <label for>.

  4. The page prints a form posting to options.php with settings_fields( $option_group ) (hidden option_page, action=update, _wpnonce, _wp_http_referer), do_settings_sections( $page ) and submit_button().

options.php verifies the nonce for {$option_group}-options, checks the capability, calls update_option() for each option of the group, keeps add_settings_error() messages in the settings_errors transient for 30 seconds and redirects with settings-updated=true. Screens under Settings print the messages through options-head.php; other screens must call settings_errors().

Argument Since Meaning

type

4.7

string, boolean, integer, number, array or object; used by the REST API, not cast by the form.

label, description

6.6, 4.7

The REST schema’s title and description.

sanitize_callback

4.7

Hooked to sanitize_option_{$option}: runs on every write, from any code, the REST API and WP-CLI.

show_in_rest

4.7

true, or name and schema; array needs schema.items, object needs schema.properties.

default

4.7

Returned while the option is missing, unless the get_option() call passes its own default.

Register settings on init, not admin_init, which does not run for REST or WP-CLI requests. A core group (general, reading…​) puts a field on that core screen.

The Options API and autoload

get_option(), add_option(), update_option() and delete_option() work on wp_options (get_site_option() on network options); wp_set_option_autoload_values() (6.4) changes autoload in place. Since 6.6 the autoload column holds on/off for explicit choices and auto, auto-on, auto-off when WordPress decides (auto loads; values over 150,000 bytes become auto-off); see Database Schema and Options, Transients and Object Cache.

Autoload what every request reads, not data for one screen or a cron job. options.php and the REST API pass no $autoload, and in 7.1.3 two paths undo an explicit off: a missing option is added as auto, and a stored value identical to the registered default makes update_option() fall through to add_option(), whose INSERT ... ON DUPLICATE KEY UPDATE reset off to auto in testing. The wp_default_autoload_value filter (6.6), consulted whenever no value is passed, is reliable: returning false stores auto-off.

The lumen-catalog settings screen

One array option, lp_catalog_settings, holds the ISBN prefix, books per archive page, stock display and the distributor feed of the stock job. Plugin::boot() wires it:

	/** Runs once per request on plugins_loaded. */
	public static function boot(): void {
		add_action( 'init', array( self::class, 'register_post_types' ) );
		add_action( 'init', array( Settings::class, 'register' ) );
		add_filter( 'wp_default_autoload_value', array( Settings::class, 'autoload' ), 10, 2 );

		if ( is_admin() ) {
			Admin::register();
		}
	}

src/Settings.php

An object with a JSON schema: the REST API (key lumen_catalog) validates writes before the sanitizer runs.

<?php
/**
 * The catalogue settings: one registered option, its sections and its fields.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Registers lp_catalog_settings with the Settings API.
 */
final class Settings {

	public const OPTION = 'lp_catalog_settings';
	public const GROUP  = 'lumen_catalog';
	public const PAGE   = 'lumen-catalog-settings';
	public const CAP    = 'manage_lumen_catalog';

	public const PLACEHOLDER_FEED = 'https://distributor.example';

	/**
	 * Default values, also used to fill keys missing from an older stored array.
	 *
	 * @return array<string, mixed>
	 */
	public static function defaults(): array {
		return array(
			'isbn_prefix'     => '978',
			'books_per_page'  => 12,
			'show_stock'      => true,
			'distributor_url' => self::PLACEHOLDER_FEED,
		);
	}

	/**
	 * The stored settings merged over the defaults.
	 *
	 * @return array<string, mixed>
	 */
	public static function get(): array {
		return wp_parse_args( (array) get_option( self::OPTION, array() ), self::defaults() );
	}

	/** Runs on init, so the setting exists for options.php, the REST API and WP-CLI alike. */
	public static function register(): void {
		register_setting(
			self::GROUP,
			self::OPTION,
			array(
				'type'              => 'object',
				'label'             => __( 'Lumen catalogue settings', 'lumen-catalog' ),
				'sanitize_callback' => array( self::class, 'sanitize' ),
				'default'           => self::defaults(),
				'show_in_rest'      => array(
					'name'   => 'lumen_catalog',
					'schema' => array(
						'additionalProperties' => false,
						'properties'           => array(
							'isbn_prefix'     => array(
								'type' => 'string',
								'enum' => array( '978', '979' ),
							),
							'books_per_page'  => array(
								'type'    => 'integer',
								'minimum' => 1,
								'maximum' => 100,
							),
							'show_stock'      => array( 'type' => 'boolean' ),
							'distributor_url' => array( 'type' => 'string' ),
						),
					),
				),
			)
		);
	}

The sanitizer guards every write. It starts from the stored values, so a partial REST write changes only its keys, and it must accept its own output, because it can run twice per save.

	/**
	 * Filters wp_default_autoload_value, so writes without $autoload store auto-off.
	 *
	 * @param bool|null $autoload Default decision.
	 * @param string    $option   Option name.
	 * @return bool|null
	 */
	public static function autoload( $autoload, $option ) {
		return self::OPTION === $option ? false : $autoload;
	}

	/**
	 * Sanitizes every write; absent keys keep their stored value.
	 *
	 * @param mixed $value Raw value.
	 * @return array<string, mixed>
	 */
	public static function sanitize( $value ): array {
		$value = is_array( $value ) ? $value : array();
		$clean = self::get();

		if ( isset( $value['isbn_prefix'] ) ) {
			$prefix = sanitize_text_field( (string) $value['isbn_prefix'] );
			if ( in_array( $prefix, array( '978', '979' ), true ) ) {
				$clean['isbn_prefix'] = $prefix;
			} else {
				add_settings_error( self::OPTION, 'isbn_prefix', __( 'The ISBN prefix must be 978 or 979.', 'lumen-catalog' ) );
			}
		}

		if ( isset( $value['books_per_page'] ) ) {
			$clean['books_per_page'] = min( 100, max( 1, absint( $value['books_per_page'] ) ) );
		}

		if ( isset( $value['show_stock'] ) ) {
			$clean['show_stock'] = rest_sanitize_boolean( $value['show_stock'] );
		}

		if ( isset( $value['distributor_url'] ) ) {
			$url = esc_url_raw( trim( (string) $value['distributor_url'] ), array( 'https' ) );
			if ( '' === $url ) {
				add_settings_error( self::OPTION, 'distributor_url', __( 'The distributor feed must be an https:// URL.', 'lumen-catalog' ) );
			} else {
				$clean['distributor_url'] = $url;
			}
		}

		return $clean;
	}

The fields post one array. An unticked checkbox posts nothing ("unchanged"), so a hidden 0 precedes it.

	/** Runs on admin_init: the sections and fields of the settings screen. */
	public static function add_fields(): void {
		add_settings_section(
			'lumen_catalog_display',
			__( 'Catalogue display', 'lumen-catalog' ),
			static function () {
				echo '<p>' . esc_html__( 'How books appear on the public site.', 'lumen-catalog' ) . '</p>';
			},
			self::PAGE
		);
		add_settings_section( 'lumen_catalog_stock', __( 'Stock synchronisation', 'lumen-catalog' ), '__return_null', self::PAGE );

		$fields = array(
			'isbn_prefix'     => array( __( 'ISBN prefix', 'lumen-catalog' ), 'lumen_catalog_display', 'select' ),
			'books_per_page'  => array( __( 'Books per archive page', 'lumen-catalog' ), 'lumen_catalog_display', 'number' ),
			'show_stock'      => array( __( 'Show stock level', 'lumen-catalog' ), 'lumen_catalog_stock', 'checkbox' ),
			'distributor_url' => array( __( 'Distributor feed URL', 'lumen-catalog' ), 'lumen_catalog_stock', 'url' ),
		);
		foreach ( $fields as $key => list( $label, $section, $control ) ) {
			add_settings_field(
				'lumen_catalog_' . $key,
				$label,
				array( self::class, 'render_field' ),
				self::PAGE,
				$section,
				array(
					'label_for' => 'lumen_catalog_' . $key, // Wraps the title in <label for>.
					'key'       => $key,
					'control'   => $control,
				)
			);
		}
	}

	/**
	 * Prints one control. Names are lp_catalog_settings[key], so the form posts one array.
	 *
	 * @param array<string, string> $args Arguments passed to add_settings_field().
	 */
	public static function render_field( array $args ): void {
		$value = self::get()[ $args['key'] ];
		$id    = $args['label_for'];
		$name  = self::OPTION . '[' . $args['key'] . ']';

		switch ( $args['control'] ) {
			case 'select':
				printf( '<select id="%s" name="%s">', esc_attr( $id ), esc_attr( $name ) );
				foreach ( array( '978', '979' ) as $prefix ) {
					printf( '<option value="%1$s"%2$s>%1$s</option>', esc_attr( $prefix ), selected( $value, $prefix, false ) );
				}
				echo '</select>';
				break;
			case 'number':
				printf( '<input type="number" id="%s" name="%s" value="%d" min="1" max="100" class="small-text">', esc_attr( $id ), esc_attr( $name ), (int) $value );
				break;
			case 'checkbox':
				// The hidden 0 is posted when the box is unticked; an unticked checkbox sends nothing.
				printf( '<input type="hidden" name="%s" value="0">', esc_attr( $name ) );
				printf( '<input type="checkbox" id="%s" name="%s" value="1"%s>', esc_attr( $id ), esc_attr( $name ), checked( $value, true, false ) );
				break;
			default:
				printf( '<input type="url" id="%s" name="%s" value="%s" class="regular-text" placeholder="https://">', esc_attr( $id ), esc_attr( $name ), esc_attr( $value ) );
		}
	}
}

src/Admin.php

A submenu call with the parent’s own slug renames the automatic first entry "Overview". Passing 'show_in_menu' => 'lumen-catalog' to the post type would nest the Books list under Catalogue.

<?php
/**
 * The Catalogue admin menu, its screens, help tabs and notices.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Admin-only wiring; loaded when is_admin() is true.
 */
final class Admin {

	public const MENU = 'lumen-catalog';

	/** Hooks the admin screens. */
	public static function register(): void {
		add_action( 'admin_menu', array( self::class, 'add_menus' ) );
		add_action( 'admin_init', array( Settings::class, 'add_fields' ) );
		add_action( 'admin_notices', array( self::class, 'notices' ) );
		add_action( 'admin_enqueue_scripts', array( self::class, 'notice_script' ) );
		add_action( 'wp_ajax_lumen_catalog_dismiss', array( self::class, 'dismiss_notice' ) );
		// options.php checks manage_options unless this filter names another capability.
		add_filter(
			'option_page_capability_' . Settings::GROUP,
			static function () {
				return Settings::CAP;
			}
		);
	}

	/** Runs on admin_menu: a top-level Catalogue menu with Overview and Settings. */
	public static function add_menus(): void {
		add_menu_page(
			__( 'Lumen catalogue', 'lumen-catalog' ),
			__( 'Catalogue', 'lumen-catalog' ),
			'edit_lp_books',
			self::MENU,
			array( self::class, 'render_overview' ),
			'dashicons-book-alt',
			26
		);
		// Same slug as the parent: renames the automatic first item to "Overview".
		add_submenu_page( self::MENU, __( 'Lumen catalogue', 'lumen-catalog' ), __( 'Overview', 'lumen-catalog' ), 'edit_lp_books', self::MENU );

		$hook = add_submenu_page(
			self::MENU,
			__( 'Catalogue settings', 'lumen-catalog' ),
			__( 'Settings', 'lumen-catalog' ),
			Settings::CAP,
			Settings::PAGE,
			array( self::class, 'render_settings' )
		);
		if ( $hook ) {
			add_action( 'load-' . $hook, array( self::class, 'add_help_tabs' ) );
		}
	}

	/** The Overview screen. */
	public static function render_overview(): void {
		$counts = wp_count_posts( 'lp_book' );
		printf(
			/* translators: %d: number of published books. */
			'<div class="wrap"><h1>%s</h1><p>' . esc_html__( '%d published books.', 'lumen-catalog' ) . '</p></div>',
			esc_html( get_admin_page_title() ),
			(int) ( $counts->publish ?? 0 )
		);
	}

	/** The Settings screen: options.php does the saving. */
	public static function render_settings(): void {
		if ( ! current_user_can( Settings::CAP ) ) {
			return;
		}
		echo '<div class="wrap"><h1>' . esc_html( get_admin_page_title() ) . '</h1>';
		settings_errors(); // Core prints these only under Settings (options-general.php).
		echo '<form action="options.php" method="post">';
		settings_fields( Settings::GROUP );
		do_settings_sections( Settings::PAGE );
		submit_button();
		echo '</form></div>';
	}

Help tabs

Tabs go on WP_Screen on load-{$hook_suffix} (or current_screen with an ID check): an id, a title, content or a callback, and a priority (4.4); set_help_sidebar() fills the right-hand column.

	/** Runs on load-{hook}: the Help tab of the settings screen. */
	public static function add_help_tabs(): void {
		get_current_screen()->add_help_tab(
			array(
				'id'      => 'lumen-catalog-overview',
				'title'   => __( 'Overview', 'lumen-catalog' ),
				'content' => '<p>' . esc_html__( 'These settings apply to the whole catalogue. The stock job (every six hours) reads the distributor feed.', 'lumen-catalog' ) . '</p>',
			)
		);
	}

Admin notices

Notices print on admin_notices (all_admin_notices also in the network and user admins) as a div.notice with notice-success, -error, -warning or -info; wp_admin_notice() (6.4) builds and wp_kses_post()-filters it. Show one only to users who can act, where it matters, while the condition holds. is-dismissible hides it until the next page; a lasting dismissal is stored by a request that checks a nonce.

	/** A dismissible notice while the distributor feed is still the placeholder default. */
	public static function notices(): void {
		if ( Settings::PLACEHOLDER_FEED !== Settings::get()['distributor_url']
			|| ! current_user_can( Settings::CAP )
			|| get_user_meta( get_current_user_id(), 'lumen_catalog_hide_feed_notice', true )
			|| ! in_array( get_current_screen()->id, array( 'dashboard', 'plugins', 'toplevel_page_lumen-catalog' ), true ) ) {
			return;
		}
		wp_admin_notice(
			sprintf(
				/* translators: %s: settings screen URL. */
				__( 'Stock synchronisation still uses the placeholder distributor feed. Set the real one in <a href="%s">Catalogue settings</a>.', 'lumen-catalog' ),
				esc_url( menu_page_url( Settings::PAGE, false ) )
			),
			array(
				'type'        => 'info',
				'dismissible' => true,
				'id'          => 'lumen-catalog-feed-notice',
				'attributes'  => array( 'data-nonce' => wp_create_nonce( 'lumen_catalog_dismiss' ) ),
			)
		);
	}

	/** Remembers a dismissal; core's is-dismissible only hides the notice until the next page. */
	public static function notice_script(): void {
		wp_add_inline_script(
			'common',
			"document.addEventListener( 'click', function ( e ) {
				var n = e.target.closest( '#lumen-catalog-feed-notice' );
				if ( n && e.target.classList.contains( 'notice-dismiss' ) ) {
					fetch( window.ajaxurl, { method: 'POST', credentials: 'same-origin', body: new URLSearchParams(
						{ action: 'lumen_catalog_dismiss', _ajax_nonce: n.dataset.nonce } ) } );
				}
			} );"
		);
	}

	/** Handles wp_ajax_lumen_catalog_dismiss: a nonce, then a per-user flag. */
	public static function dismiss_notice(): void {
		check_ajax_referer( 'lumen_catalog_dismiss' );
		update_user_meta( get_current_user_id(), 'lumen_catalog_hide_feed_notice', 1 );
		wp_send_json_success();
	}
}

Dashboard widgets and DataViews

The Lumen Press Dashboard widget (wp_add_dashboard_widget()) is built in Dashboard and Admin Screens. For list screens with filters and bulk actions, or settings saved without a reload, the modern alternative to Settings API forms is a React page with @wordpress/dataviews, mounted by add_menu_page() and saving through the same /wp/v2/settings endpoint: see Components, DataViews and Admin Screens.

Verifying the screen

admin (role given both custom capabilities, as activation does) and the Editor maria.editor; php -l (PHP 7.4, 8.4) and WPCS 3.4.1 passed. WP-CLI is not an admin request, so the menu check loads menu.php itself:

wp eval 'require_once ABSPATH . "wp-admin/includes/admin.php"; Lumen\Catalog\Admin::register();
  global $menu, $submenu; require ABSPATH . "wp-admin/menu.php";
  foreach ( $menu as $p => $i ) { echo $p, " ", $i[2], "\n"; }
  foreach ( $submenu["lumen-catalog"] ?? array() as $i ) { echo $i[0], " | ", $i[1], " | ", get_plugin_page_hookname( $i[2], "lumen-catalog" ), "\n"; }' \
  --user=admin | grep -E '^(25|26|59)[ .]|\|'
25 edit-comments.php
26 edit.php?post_type=lp_book
26.25075 lumen-catalog
59 separator2
Overview | edit_lp_books | toplevel_page_lumen-catalog
Settings | manage_lumen_catalog | catalogue_page_lumen-catalog-settings

lp_book held 26, so Catalogue moved to 26.25075. As maria.editor: no Catalogue lines, and 403 on the Settings URL.

With the option missing, wp option get lp_catalog_settings printed the registered default, and get_option( 'lp_catalog_settings', false ) returned false. A first update_option() with isbn_prefix => 979, books_per_page => 500, distributor_url => ftp://x ran the sanitizer twice (update_option(), then the add_option() it fell through to), stored 979, 100 and the default URL, reported "The distributor feed must be an https:// URL." once (the second run got clean input), and wp option get-autoload printed auto-off (auto without the filter). The form post, then without a nonce:

#!/bin/sh
# Logs in as admin, reads the settings form's nonce and posts it to options.php.
set -e
SITE=https://lumenpress.test
JAR=$(mktemp)
curl -s -c "$JAR" -b 'wordpress_test_cookie=WP%20Cookie%20check' -o /dev/null \
  --data-urlencode 'log=admin' --data-urlencode "pwd=$WP_ADMIN_PASSWORD" -d 'testcookie=1' "$SITE/wp-login.php"
FORM=$(curl -s -b "$JAR" "$SITE/wp-admin/admin.php?page=lumen-catalog-settings")
NONCE=$(printf '%s' "$FORM" | sed -n 's/.*name="_wpnonce" value="\([0-9a-f]*\)".*/\1/p')
echo "nonce: $NONCE"
curl -s -b "$JAR" -o /dev/null -w '%{http_code} %{redirect_url}\n' "$SITE/wp-admin/options.php" \
  -d option_page=lumen_catalog -d action=update -d "_wpnonce=$NONCE" \
  -d '_wp_http_referer=/wp-admin/admin.php?page=lumen-catalog-settings' \
  -d 'lp_catalog_settings[isbn_prefix]=979' -d 'lp_catalog_settings[books_per_page]=24' \
  -d 'lp_catalog_settings[show_stock]=0' \
  -d 'lp_catalog_settings[distributor_url]=https://feeds.distributor.example/v2/'
curl -s -b "$JAR" -o /dev/null -w 'no nonce: %{http_code}\n' "$SITE/wp-admin/options.php" \
  -d option_page=lumen_catalog -d action=update -d 'lp_catalog_settings[books_per_page]=5'
rm -f "$JAR"
nonce: 555a44e97f
302 https://lumenpress.test/wp-admin/admin.php?page=lumen-catalog-settings&settings-updated=true
no nonce: 403

The option became {"isbn_prefix":"979","books_per_page":24,"show_stock":false,"distributor_url":"https:\/\/feeds.distributor.example\/v2\/"}, the page showed "Settings saved." and the Help tab, and the Editor’s post got "You need a higher level of permission.".

/wp/v2/settings requires manage_options whatever the screen’s capability. With an application password (see REST API: Using It):

curl -s -u "admin:$APP_PASSWORD" "https://lumenpress.test/wp-json/wp/v2/settings?_fields=lumen_catalog"
curl -s -u "admin:$APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
  -d '{"lumen_catalog":{"books_per_page":30}}' "https://lumenpress.test/wp-json/wp/v2/settings?_fields=lumen_catalog"
curl -s -u "admin:$APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
  -d '{"lumen_catalog":{"isbn_prefix":"977"}}' https://lumenpress.test/wp-json/wp/v2/settings

The first request returned the four values; the second {"lumen_catalog":{...,"books_per_page":30,...}} with the other keys intact, because the sanitizer merges; the third a 400 rest_invalid_param ("lumen_catalog[isbn_prefix] is not one of 978 and 979."). The Editor got 403 rest_forbidden. A stored value that fails the schema (977 written with the sanitizer removed) made the GET return "lumen_catalog": null, not an error.

The notice rendered as <div id="lumen-catalog-feed-notice" class="notice notice-info is-dismissible" data-nonce="...">; the dismissal returned -1 (403) with a wrong nonce and {"success":true} with the page’s, after which it was gone.

Common errors and pitfalls

Symptom Cause and fix

"Error: The lumen_catalog options page is not in the allowed options list."

No setting of that group was registered for the options.php request, or settings_fields() and register_setting() use different groups. Register on init, same group string.

A REST update erased other keys, or the setting is not in /wp/v2/settings

The sanitizer did not merge; or show_in_rest is off, it was registered on admin_init, the schema lacks properties, or the user lacks manage_options.

An option set to autoload off is autoloaded again

A write without $autoload re-evaluated it. Filter wp_default_autoload_value.

Where older books are outdated

Older advice Current state

Settings forms that read $_POST and call update_option() while printing the page

The Settings API does the nonce, capability, allow list and redirect; a custom handler must do all four in a load- or admin_post_ action.

Settings stored without a sanitizer and printed unescaped (one older handbook does this for theme mods)

Every register_setting() has a sanitize_callback, and every value is escaped on output.

autoload 'yes'/'no', new options autoloaded by default

on, off, auto, auto-on, auto-off since 6.6; large options not autoloaded; 'yes'/'no' deprecated since 6.7.

admin-ajax.php requests without a nonce (one older plugin book)

Check a nonce with check_ajax_referer() and a capability; consider the REST API for new code.

Admin customization by hiding menu items

remove_menu_page() is cosmetic; restrict screens with capabilities.