{"id":20872,"date":"2026-10-14T08:06:00","date_gmt":"2026-10-14T06:06:00","guid":{"rendered":"https:\/\/www.lukaswojcik.com\/blog\/?p=20872"},"modified":"2026-10-09T22:44:49","modified_gmt":"2026-10-09T20:44:49","slug":"settings-api-register-setting","status":"publish","type":"post","link":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/","title":{"rendered":"Settings pages with the Settings API and register_setting"},"content":{"rendered":"<p>Almost every plugin needs a page where administrators can adjust a handful of values. Many plugins still build it by hand, with a custom POST handler, capability checks, nonces and a dozen separate rows in <code>wp_options<\/code>. This can leave a capability check in one place and a forgotten nonce in another. Since version 2.7, WordPress core has provided the Settings API for this work. It renders the form in the familiar admin layout, posts it to <code>wp-admin\/options.php<\/code> and handles the nonce, capability check, sanitizing and storage there. Since WordPress 4.7, <code>register_setting()<\/code> also describes the data itself: type, default value, sanitize callback and, with <code>show_in_rest<\/code>, visibility in the REST endpoint <code>\/wp\/v2\/settings<\/code>.<\/p>\n<p>This article builds a small but complete plugin, \u201cLW Settings Demo\u201d: a notice bar whose three settings live in one bundled option, a page under Settings, a sanitize callback that serves both the form and the REST API, and a short script that reads and writes the same option with <code>apiFetch<\/code>. Along the way it follows a single form field from the input element to its row in <code>wp_options<\/code> and shows where the REST path takes a different route. The core source was read on 29.09.2026; the current release according to the WordPress.org version check is 7.1.2, and the example requires at least WordPress 6.6.<\/p>\n<h2>Four jobs, four groups of functions<\/h2>\n<p>The Settings API splits a settings page into clearly separated tasks. Each group of functions belongs to its own hook, and mixing them up is the most common reason why a page renders fine but refuses to save.<\/p>\n<table>\n<thead>\n<tr>\n<th>Function<\/th>\n<th>Hook<\/th>\n<th>Job<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>add_options_page()<\/code><\/td>\n<td><code>admin_menu<\/code><\/td>\n<td>Menu entry below Settings, capability for showing the menu item<\/td>\n<\/tr>\n<tr>\n<td><code>register_setting()<\/code><\/td>\n<td><code>init<\/code> (explained below)<\/td>\n<td>Puts the option on the allowed list of a group, attaches sanitize callback, default value and REST schema<\/td>\n<\/tr>\n<tr>\n<td><code>add_settings_section()<\/code>, <code>add_settings_field()<\/code><\/td>\n<td><code>admin_init<\/code><\/td>\n<td>Layout: headings, labels, callbacks that print the input elements<\/td>\n<\/tr>\n<tr>\n<td><code>settings_fields()<\/code>, <code>do_settings_sections()<\/code><\/td>\n<td>Page callback<\/td>\n<td>Hidden fields with nonce, then all sections and fields of the page<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><code>add_options_page()<\/code> is a thin wrapper: it calls <code>add_submenu_page()<\/code> with <code>options-general.php<\/code> as parent. The capability passed here decides whether the menu item appears; users without it also get status 403 when they open the page address directly. Its docblock states explicitly that the page callback must check the capability as well, which is why the example starts its render function with <code>current_user_can( 'manage_options' )<\/code>. Saving is checked a third time and separately, in <code>options.php<\/code>, as the section on the storage path shows.<\/p>\n<p>The link between form and storage is the option group. <code>register_setting( 'lwsd_notice_bar', 'lwsd_settings', \u2026 )<\/code> adds the option <code>lwsd_settings<\/code> to the group <code>lwsd_notice_bar<\/code>. <code>settings_fields( 'lwsd_notice_bar' )<\/code> sends that group name as the hidden field <code>option_page<\/code>. The page ID used by <code>add_settings_section()<\/code> and <code>do_settings_sections()<\/code> is a different identifier; in the example it matches the menu slug <code>lwsd-notice-bar<\/code>.<\/p>\n<h2>One option instead of dozens<\/h2>\n<p>The Plugin Handbook recommends storing related values as an array in a single option, because reading or writing an array happens in one database transaction instead of many. The source of <code>options.php<\/code> adds a second, less obvious reason. On save, it iterates over <strong>every<\/strong> option registered in the submitted group and calls <code>update_option()<\/code> for each one. If a field is missing from the POST data, the value passed along is <code>null<\/code>. Two forms that share a group therefore overwrite each other\u2019s values. A single array option per form avoids that trap, and the sanitize callback sees all values at once, which makes rules across fields possible, such as \u201cno active notice bar without a message\u201d.<\/p>\n<p>The price is serialization: the array ends up as a serialized string in one row, and individual keys cannot be filtered in SQL. For configuration values that is rarely a problem; for data that needs querying, <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/web-development\/wordpress-database-performance-limits-of-wp_postmeta-and-how-to-build\/\">the article on the limits of <code>wp_postmeta<\/code> and custom tables<\/a> covers the alternatives.<\/p>\n<h3>Setting autoload on purpose<\/h3>\n<p>WordPress 6.6 reworked the autoload column. The <code>$autoload<\/code> parameter of <code>add_option()<\/code> now defaults to <code>null<\/code>, and the column stores <code>on<\/code> and <code>off<\/code> for explicit decisions, <code>auto<\/code> when nobody decided, and <code>auto-on<\/code> or <code>auto-off<\/code> when a filter decided dynamically. The old values <code>yes<\/code> and <code>no<\/code> are deprecated since 6.7. Options larger than 150,000 bytes receive no autoload by default (filter <code>wp_max_autoloaded_option_size<\/code>). According to <code>wp_autoload_values_to_autoload()<\/code>, <code>auto<\/code> is currently still loaded; the 6.6 dev note, however, says that this default may change in future versions.<\/p>\n<p>This matters for the Settings API because <code>options.php<\/code> calls <code>update_option()<\/code> without an autoload argument. If the option does not exist yet, <code>update_option()<\/code> calls <code>add_option()<\/code>, which creates the row with <code>auto<\/code>. To set autoload explicitly, a plugin can create the option itself. The example does this on activation with <code>add_option( \u2026, true )<\/code>, because the notice bar is read on every front-end request.<\/p>\n<p>Later calls of <code>update_option()<\/code> without an autoload argument keep an explicit <code>on<\/code> or <code>off<\/code>; only the <code>auto<\/code> variants are re-evaluated. An option needed only on a single admin page would get <code>false<\/code> instead.<\/p>\n<p>To change autoload afterwards, <code>wp_set_option_autoload()<\/code> (since 6.4) works without changing the stored option value. By contrast, <code>update_option()<\/code> only applies a new autoload value when the option value itself changes. The explanation in <span class=\"lw-artikel-geplant\">a separate, planned article on autoload<\/span> covers what the individual values in the column mean in practice.<\/p>\n<h2>The complete plugin<\/h2>\n<p>The main file contains everything: registration, page, fields, sanitize callback, front-end output and activation. The prefix <code>lwsd_<\/code> is used consistently for functions, constants, the option and the HTML IDs.<\/p>\n<pre><code>&lt;?php\n\/**\n * Plugin Name:       LW Settings Demo\n * Description:       Notice bar with a settings page built on the Settings API, also available via \/wp\/v2\/settings.\n * Version:           1.0.0\n * Requires at least: 6.6\n * Requires PHP:      7.4\n * Author:            Lukas Wojcik\n * License:           GPL-2.0-or-later\n * License URI:       https:\/\/www.gnu.org\/licenses\/gpl-2.0.html\n * Text Domain:       lw-settings-demo\n *\n * @package LW_Settings_Demo\n *\/\n\nif ( ! defined( 'ABSPATH' ) ) {\n\texit;\n}\n\nconst LWSD_OPTION = 'lwsd_settings';   \/\/ Row in wp_options.\nconst LWSD_GROUP  = 'lwsd_notice_bar'; \/\/ Option group, sent as option_page.\nconst LWSD_PAGE   = 'lwsd-notice-bar'; \/\/ Menu slug and settings page ID.\n\n\/**\n * Default values of the bundled option.\n *\n * @return array\n *\/\nfunction lwsd_default_settings() {\n\treturn array(\n\t\t'enabled'  =&gt; false,\n\t\t'message'  =&gt; '',\n\t\t'position' =&gt; 'bottom',\n\t);\n}\n\n\/**\n * Returns the stored settings merged with the defaults.\n *\n * @return array\n *\/\nfunction lwsd_get_settings() {\n\t$settings = get_option( LWSD_OPTION );\n\n\tif ( ! is_array( $settings ) ) {\n\t\t$settings = array();\n\t}\n\n\treturn wp_parse_args( $settings, lwsd_default_settings() );\n}\n\n\/**\n * Registers the option on init, so admin screens and REST requests both know it.\n *\/\nfunction lwsd_register_setting() {\n\tregister_setting(\n\t\tLWSD_GROUP,\n\t\tLWSD_OPTION,\n\t\tarray(\n\t\t\t'type'              =&gt; 'object',\n\t\t\t'label'             =&gt; __( 'Notice bar', 'lw-settings-demo' ),\n\t\t\t'description'       =&gt; __( 'Visibility, text and position of the notice bar.', 'lw-settings-demo' ),\n\t\t\t'sanitize_callback' =&gt; 'lwsd_sanitize_settings',\n\t\t\t'default'           =&gt; lwsd_default_settings(),\n\t\t\t'show_in_rest'      =&gt; array(\n\t\t\t\t'schema' =&gt; array(\n\t\t\t\t\t'type'       =&gt; 'object',\n\t\t\t\t\t'properties' =&gt; array(\n\t\t\t\t\t\t'enabled'  =&gt; array(\n\t\t\t\t\t\t\t'type' =&gt; 'boolean',\n\t\t\t\t\t\t),\n\t\t\t\t\t\t'message'  =&gt; array(\n\t\t\t\t\t\t\t'type'      =&gt; 'string',\n\t\t\t\t\t\t\t'maxLength' =&gt; 200,\n\t\t\t\t\t\t),\n\t\t\t\t\t\t'position' =&gt; array(\n\t\t\t\t\t\t\t'type' =&gt; 'string',\n\t\t\t\t\t\t\t'enum' =&gt; array( 'top', 'bottom' ),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t);\n}\nadd_action( 'init', 'lwsd_register_setting' );\n\n\/**\n * Sanitizes the whole settings array.\n *\n * Runs for options.php and for REST writes, because both end in update_option().\n * Must be idempotent: on the first save of a missing option it runs twice.\n *\n * @param mixed $input Raw value.\n * @return array\n *\/\nfunction lwsd_sanitize_settings( $input ) {\n\t$defaults = lwsd_default_settings();\n\n\tif ( ! is_array( $input ) ) {\n\t\treturn $defaults;\n\t}\n\n\t$message  = isset( $input['message'] ) ? sanitize_text_field( $input['message'] ) : '';\n\t$position = isset( $input['position'] ) ? sanitize_key( $input['position'] ) : '';\n\n\t$output = array(\n\t\t'enabled'  =&gt; ! empty( $input['enabled'] ),\n\t\t'message'  =&gt; mb_substr( $message, 0, 200 ),\n\t\t'position' =&gt; in_array( $position, array( 'top', 'bottom' ), true ) ? $position : $defaults['position'],\n\t);\n\n\tif ( $output['enabled'] &amp;&amp; '' === $output['message'] ) {\n\t\t$output['enabled'] = false;\n\n\t\t\/\/ add_settings_error() lives in wp-admin\/includes\/template.php,\n\t\t\/\/ which a REST request does not load.\n\t\tif ( function_exists( 'add_settings_error' ) ) {\n\t\t\tadd_settings_error(\n\t\t\t\tLWSD_OPTION,\n\t\t\t\t'lwsd_empty_message',\n\t\t\t\t__( 'The notice bar stays off until a message is entered.', 'lw-settings-demo' ),\n\t\t\t\t'warning'\n\t\t\t);\n\t\t}\n\t}\n\n\treturn $output;\n}\n\n\/**\n * Adds the page below Settings and loads its script only there.\n *\/\nfunction lwsd_add_settings_page() {\n\t$hook_suffix = add_options_page(\n\t\t__( 'Notice bar', 'lw-settings-demo' ),\n\t\t__( 'Notice bar', 'lw-settings-demo' ),\n\t\t'manage_options',\n\t\tLWSD_PAGE,\n\t\t'lwsd_render_settings_page'\n\t);\n\n\tif ( $hook_suffix ) {\n\t\tadd_action( 'load-' . $hook_suffix, 'lwsd_load_settings_page' );\n\t}\n}\nadd_action( 'admin_menu', 'lwsd_add_settings_page' );\n\n\/**\n * Runs only when the settings page is requested.\n *\/\nfunction lwsd_load_settings_page() {\n\tadd_action( 'admin_enqueue_scripts', 'lwsd_enqueue_admin_script' );\n}\n\n\/**\n * Enqueues the REST demo script.\n *\/\nfunction lwsd_enqueue_admin_script() {\n\twp_enqueue_script(\n\t\t'lwsd-settings-rest',\n\t\tplugins_url( 'js\/settings-rest.js', __FILE__ ),\n\t\tarray( 'wp-api-fetch', 'wp-i18n' ),\n\t\t'1.0.0',\n\t\tarray( 'in_footer' =&gt; true )\n\t);\n\twp_set_script_translations( 'lwsd-settings-rest', 'lw-settings-demo' );\n}\n\n\/**\n * Declares section and fields. Only needed on admin screens.\n *\/\nfunction lwsd_register_fields() {\n\tadd_settings_section(\n\t\t'lwsd_section_main',\n\t\t__( 'Display', 'lw-settings-demo' ),\n\t\t'lwsd_render_section',\n\t\tLWSD_PAGE\n\t);\n\n\tadd_settings_field(\n\t\t'lwsd_enabled',\n\t\t__( 'Show notice bar', 'lw-settings-demo' ),\n\t\t'lwsd_render_enabled_field',\n\t\tLWSD_PAGE,\n\t\t'lwsd_section_main',\n\t\tarray( 'label_for' =&gt; 'lwsd_enabled' )\n\t);\n\n\tadd_settings_field(\n\t\t'lwsd_message',\n\t\t__( 'Message', 'lw-settings-demo' ),\n\t\t'lwsd_render_message_field',\n\t\tLWSD_PAGE,\n\t\t'lwsd_section_main',\n\t\tarray( 'label_for' =&gt; 'lwsd_message' )\n\t);\n\n\tadd_settings_field(\n\t\t'lwsd_position',\n\t\t__( 'Position', 'lw-settings-demo' ),\n\t\t'lwsd_render_position_field',\n\t\tLWSD_PAGE,\n\t\t'lwsd_section_main',\n\t\tarray( 'label_for' =&gt; 'lwsd_position' )\n\t);\n}\nadd_action( 'admin_init', 'lwsd_register_fields' );\n\n\/**\n * Section intro.\n *\/\nfunction lwsd_render_section() {\n\techo '&lt;p&gt;' . esc_html__( 'All three values are stored together in one option.', 'lw-settings-demo' ) . '&lt;\/p&gt;';\n}\n\n\/**\n * Checkbox field.\n *\/\nfunction lwsd_render_enabled_field() {\n\t$settings = lwsd_get_settings();\n\n\tprintf(\n\t\t'&lt;input type=\"checkbox\" id=\"lwsd_enabled\" name=\"%1$s[enabled]\" value=\"1\" %2$s \/&gt;',\n\t\tesc_attr( LWSD_OPTION ),\n\t\tchecked( $settings['enabled'], true, false )\n\t);\n}\n\n\/**\n * Text field.\n *\/\nfunction lwsd_render_message_field() {\n\t$settings = lwsd_get_settings();\n\n\tprintf(\n\t\t'&lt;input type=\"text\" class=\"regular-text\" id=\"lwsd_message\" name=\"%1$s[message]\" value=\"%2$s\" maxlength=\"200\" \/&gt;',\n\t\tesc_attr( LWSD_OPTION ),\n\t\tesc_attr( $settings['message'] )\n\t);\n}\n\n\/**\n * Select field.\n *\/\nfunction lwsd_render_position_field() {\n\t$settings = lwsd_get_settings();\n\t$choices  = array(\n\t\t'top'    =&gt; __( 'Top of the page', 'lw-settings-demo' ),\n\t\t'bottom' =&gt; __( 'Bottom of the page', 'lw-settings-demo' ),\n\t);\n\n\tprintf( '&lt;select id=\"lwsd_position\" name=\"%s[position]\"&gt;', esc_attr( LWSD_OPTION ) );\n\n\tforeach ( $choices as $value =&gt; $label ) {\n\t\tprintf(\n\t\t\t'&lt;option value=\"%1$s\" %2$s&gt;%3$s&lt;\/option&gt;',\n\t\t\tesc_attr( $value ),\n\t\t\tselected( $settings['position'], $value, false ),\n\t\t\tesc_html( $label )\n\t\t);\n\t}\n\n\techo '&lt;\/select&gt;';\n}\n\n\/**\n * Settings page. The form posts to options.php, not to this page.\n *\/\nfunction lwsd_render_settings_page() {\n\tif ( ! current_user_can( 'manage_options' ) ) {\n\t\treturn;\n\t}\n\t?&gt;\n\t&lt;div class=\"wrap\"&gt;\n\t\t&lt;h1&gt;&lt;?php echo esc_html( get_admin_page_title() ); ?&gt;&lt;\/h1&gt;\n\t\t&lt;form action=\"options.php\" method=\"post\"&gt;\n\t\t\t&lt;?php\n\t\t\tsettings_fields( LWSD_GROUP );\n\t\t\tdo_settings_sections( LWSD_PAGE );\n\t\t\tsubmit_button();\n\t\t\t?&gt;\n\t\t&lt;\/form&gt;\n\t\t&lt;p&gt;\n\t\t\t&lt;button type=\"button\" class=\"button\" id=\"lwsd-rest-toggle\"&gt;&lt;?php esc_html_e( 'Toggle via REST API', 'lw-settings-demo' ); ?&gt;&lt;\/button&gt;\n\t\t\t&lt;span id=\"lwsd-rest-status\" aria-live=\"polite\"&gt;&lt;\/span&gt;\n\t\t&lt;\/p&gt;\n\t&lt;\/div&gt;\n\t&lt;?php\n}\n\n\/**\n * Prints the notice bar on the front end.\n *\n * @param string $position Where the calling hook prints: top or bottom.\n *\/\nfunction lwsd_print_notice( $position ) {\n\t$settings = lwsd_get_settings();\n\n\tif ( ! $settings['enabled'] || $position !== $settings['position'] || '' === $settings['message'] ) {\n\t\treturn;\n\t}\n\n\tprintf(\n\t\t'&lt;div class=\"lwsd-notice lwsd-notice-%1$s\" role=\"status\"&gt;%2$s&lt;\/div&gt;',\n\t\tesc_attr( $position ),\n\t\tesc_html( $settings['message'] )\n\t);\n}\n\n\/**\n * Top position, needs a theme that calls wp_body_open().\n *\/\nfunction lwsd_print_top_notice() {\n\tlwsd_print_notice( 'top' );\n}\nadd_action( 'wp_body_open', 'lwsd_print_top_notice' );\n\n\/**\n * Bottom position.\n *\/\nfunction lwsd_print_bottom_notice() {\n\tlwsd_print_notice( 'bottom' );\n}\nadd_action( 'wp_footer', 'lwsd_print_bottom_notice' );\n\n\/**\n * Creates the option with an explicit autoload value.\n *\/\nfunction lwsd_activate() {\n\t\/\/ Read on every front-end request, so autoload it on purpose.\n\tadd_option( LWSD_OPTION, lwsd_default_settings(), '', true );\n}\nregister_activation_hook( __FILE__, 'lwsd_activate' );<\/code><\/pre>\n<h3>Why register_setting runs on init<\/h3>\n<p>The Plugin Handbook example registers settings on <code>admin_init<\/code>. That hook fires in <code>wp-admin\/admin.php<\/code>, and a REST request to <code>\/wp-json\/<\/code> never loads that file. A setting registered only there works in the form but is missing from <code>\/wp\/v2\/settings<\/code>. Core registers its own settings on <code>rest_api_init<\/code>, and the Developer Blog example for React-based settings pages uses <code>init<\/code>. The example follows the latter: <code>init<\/code> covers <code>options.php<\/code>, the settings page, REST requests and the front end, where the registered default value also takes effect. Sections and fields, on the other hand, are pure admin layout and stay on <code>admin_init<\/code>.<\/p>\n<h3>One sanitize callback for two paths<\/h3>\n<p><code>register_setting()<\/code> attaches the callback to the filter <code>sanitize_option_lwsd_settings<\/code>. That filter is applied by <code>sanitize_option()<\/code>, which <code>update_option()<\/code> calls on every write, whether the value comes from <code>options.php<\/code> or the REST API. This has two consequences.<\/p>\n<p>First, <code>add_settings_error()<\/code> is defined in <code>wp-admin\/includes\/template.php<\/code>, which a normal REST request does not load. The <code>function_exists()<\/code> check therefore ensures that the function is available before calling it. Second, the callback must be idempotent: running it again on its own output must produce the same result. When the option is missing, <code>update_option()<\/code> sanitizes once and <code>add_option()<\/code> sanitizes again. Creating the row on activation avoids this double pass in normal operation, but the callback must not rely on that.<\/p>\n<h3>No settings_errors() call on the page<\/h3>\n<p>For pages below Settings, <code>admin-header.php<\/code> includes the file <code>options-head.php<\/code>, which calls <code>settings_errors()<\/code> on its own. An additional call in the page callback would print every message twice. The Handbook example uses a top-level menu page and therefore calls <code>settings_errors()<\/code> itself. One more detail from <code>options.php<\/code>: the message \u201cSettings saved.\u201d is only added when no other message is registered. The warning from the sanitize callback replaces it, even though the other values were stored.<\/p>\n<h2>What happens on save<\/h2>\n<p>The form posts to <code>options.php<\/code>, not to the settings page. The following steps come straight from the core source and apply to every page built on the Settings API.<\/p>\n<figure class=\"lw-diagram\">\n<img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/diagrams\/wp-settings-api-en.png?v=20260930\" width=\"1120\" height=\"580\" alt=\"From form field to wp_options: the Settings API storage path\"><figcaption>options.php checks capability, nonce and allowed list; REST joins at update_option(), where the sanitize callback runs<\/figcaption><\/figure>\n<ol>\n<li><code>settings_fields()<\/code> prints four hidden fields: <code>option_page<\/code> with the group name, <code>action<\/code> with the value <code>update<\/code>, the nonce <code>_wpnonce<\/code> for the action <code>lwsd_notice_bar-options<\/code> and <code>_wp_http_referer<\/code> with the address that <code>options.php<\/code> redirects back to after saving.<\/li>\n<li><code>options.php<\/code> determines the required capability: <code>manage_options<\/code>, adjustable through the filter <code>option_page_capability_{$option_page}<\/code>. Without it, the request ends with status 403.<\/li>\n<li><code>check_admin_referer( $option_page . '-options' )<\/code> verifies the nonce.<\/li>\n<li>The group has to be on the allowed list, which <code>register_setting()<\/code> fills and the filter <code>allowed_options<\/code> can extend. Otherwise <code>wp_die()<\/code> stops the request.<\/li>\n<li>For each registered option, the value is read from <code>$_POST<\/code>, trimmed if it is scalar, passed through <code>wp_unslash()<\/code> and handed to <code>update_option()<\/code>.<\/li>\n<li><code>update_option()<\/code> runs <code>sanitize_option()<\/code> and thus the callback, then the <code>pre_update_option<\/code> filters. If the value has not changed, nothing is written; otherwise the serialized value lands in the row.<\/li>\n<li>The collected messages are stored for 30 seconds in the transient <code>settings_errors<\/code>, followed by a redirect with <code>settings-updated=true<\/code>. After the redirect, <code>settings_errors()<\/code> reads the transient and prints the messages.<\/li>\n<\/ol>\n<p>Step two has a practical consequence. A plugin that shows its menu item to editors with the capability <code>edit_pages<\/code> fails on save as long as the filter <code>option_page_capability_lwsd_notice_bar<\/code> still requires <code>manage_options<\/code>. Menu, page callback and save have to agree on the same capability. Why a nonce alone never replaces the capability check is covered in detail in <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/sanitizing-escaping-nonces-capabilities\/\">a separate article of this series on sanitizing, escaping, nonces and capabilities<\/a>.<\/p>\n<h2>register_setting and the REST endpoint \/wp\/v2\/settings<\/h2>\n<p>By default, registered settings do not appear in the REST API. <code>show_in_rest<\/code> set to <code>true<\/code> is enough for scalar types. For <code>array<\/code> and <code>object<\/code>, the option needs a schema under <code>show_in_rest.schema<\/code>. For arrays without <code>items<\/code>, core raises a <code>_doing_it_wrong<\/code> notice (since 5.4); for objects, <code>properties<\/code> define which keys exist. The controller builds the final schema from <code>type<\/code>, <code>label<\/code> (as <code>title<\/code>), <code>description<\/code> and <code>default<\/code> and merges the schema from <code>show_in_rest<\/code> on top. Only the types <code>number<\/code>, <code>integer<\/code>, <code>string<\/code>, <code>boolean<\/code>, <code>array<\/code> and <code>object<\/code> are accepted; other settings are silently skipped.<\/p>\n<p>The controller in <code>class-wp-rest-settings-controller.php<\/code> answers several questions that the reference pages leave open:<\/p>\n<ul>\n<li><strong>Permission:<\/strong> reading and writing both require <code>manage_options<\/code>. The endpoint is not a way to hand settings to anonymous visitors; that takes a separate route with its own <code>permission_callback<\/code>.<\/li>\n<li><strong>Name:<\/strong> the key in the response equals the option name unless <code>show_in_rest<\/code> sets a different <code>name<\/code>.<\/li>\n<li><strong>Strict objects:<\/strong> the controller adds <code>additionalProperties: false<\/code> recursively to every object without an explicit rule. Unknown keys in a request are rejected. If the stored value contains keys the schema does not know, for example left over from an older plugin version, GET returns <code>null<\/code> for the whole setting.<\/li>\n<li><strong>Replace, not merge:<\/strong> a POST passes the submitted object unchanged to <code>update_option()<\/code>. Properties that are missing are not kept; in the example the sanitize callback fills them with defaults, so <code>enabled<\/code> silently turns into <code>false<\/code>.<\/li>\n<li><strong>null deletes:<\/strong> <code>null<\/code> as a value calls <code>delete_option()<\/code>, unless the stored value is invalid; in that case the controller refuses with <code>rest_invalid_stored_value<\/code> and status 500.<\/li>\n<li><strong>Response:<\/strong> after the update, the controller calls <code>get_item()<\/code> again. The response therefore shows the value after sanitizing, not the submitted one.<\/li>\n<\/ul>\n<h2>Reading and writing with apiFetch<\/h2>\n<p>The script lives in <code>js\/settings-rest.js<\/code> and is only loaded on the settings page: the plugin attaches <code>admin_enqueue_scripts<\/code> inside the <code>load-{$hook_suffix}<\/code> hook that <code>add_options_page()<\/code> returns. The dependency <code>wp-api-fetch<\/code> is enough for authentication. Core registers two inline scripts for this handle, one for the root URL of the REST API and one for the nonce middleware, so the request carries the <code>wp_rest<\/code> nonce matching the login cookie without any extra code.<\/p>\n<pre><code>\/* global wp *\/\n( function () {\n\t'use strict';\n\n\tconst { __ } = wp.i18n;\n\tconst button = document.getElementById( 'lwsd-rest-toggle' );\n\tconst status = document.getElementById( 'lwsd-rest-status' );\n\n\tif ( ! button || ! status ) {\n\t\treturn;\n\t}\n\n\tbutton.addEventListener( 'click', async () =&gt; {\n\t\tbutton.disabled = true;\n\n\t\ttry {\n\t\t\t\/\/ GET returns every setting registered with show_in_rest, keyed by name.\n\t\t\tconst settings = await wp.apiFetch( { path: '\/wp\/v2\/settings' } );\n\t\t\tconst current = settings.lwsd_settings;\n\n\t\t\tif ( ! current ) {\n\t\t\t\t\/\/ null: the stored value does not match the registered schema.\n\t\t\t\tstatus.textContent = __( 'Stored value does not match the schema.', 'lw-settings-demo' );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t\/\/ The endpoint replaces the whole object, so send every property.\n\t\t\tconst saved = await wp.apiFetch( {\n\t\t\t\tpath: '\/wp\/v2\/settings',\n\t\t\t\tmethod: 'POST',\n\t\t\t\tdata: {\n\t\t\t\t\tlwsd_settings: { ...current, enabled: ! current.enabled },\n\t\t\t\t},\n\t\t\t} );\n\n\t\t\t\/\/ The response already contains the sanitized value.\n\t\t\tstatus.textContent = saved.lwsd_settings.enabled\n\t\t\t\t? __( 'Notice bar is on.', 'lw-settings-demo' )\n\t\t\t\t: __( 'Notice bar is off.', 'lw-settings-demo' );\n\t\t} catch ( error ) {\n\t\t\tstatus.textContent = error.message || __( 'Request failed.', 'lw-settings-demo' );\n\t\t} finally {\n\t\t\tbutton.disabled = false;\n\t\t}\n\t} );\n} )();<\/code><\/pre>\n<p>The button reads the entire settings object, flips <code>enabled<\/code> and sends the object back complete. The response illustrates the point from the previous section: with an empty message field, the sanitize callback switches the notice bar off again, and the status line reports that the bar is off. The form above does not update itself and shows the new state only after a reload.<\/p>\n<p>The same pattern carries over to a settings page built with React. The WordPress Developer Blog shows a page based on <code>@wordpress\/components<\/code> that loads the option with <code>apiFetch( { path: '\/wp\/v2\/settings' } )<\/code> and saves it with a POST, registered on <code>init<\/code> with a schema. The PHP side stays the same: <code>register_setting()<\/code> with schema and sanitize callback remains the single place where type, default value and cleaning are defined. Only the form rendering and <code>options.php<\/code> drop out of the picture.<\/p>\n<h2>Limits and open points<\/h2>\n<ul>\n<li>The code was checked against the core source (branch 7.1 and trunk) and run on a WordPress 7.1.2 test installation with PHP 8.4: activation creates the option with autoload <code>on<\/code>, a first save through the form after deleting the option creates it with <code>auto<\/code>. Saving through the form and through <code>\/wp\/v2\/settings<\/code> left no entries in the debug log, and Plugin Check 2.1.0 only reported the missing <code>readme.txt<\/code>.<\/li>\n<li>How <code>auto<\/code> will be treated in future versions is open; the 6.6 dev note explicitly leaves room for a change.<\/li>\n<li>The plugin does not delete its option on uninstall. That belongs in <code>uninstall.php<\/code>, the topic of <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/\">a separate article in this series on the plugin lifecycle<\/a>.<\/li>\n<li>The top position depends on the theme calling <code>wp_body_open()<\/code>; otherwise only the bottom position works.<\/li>\n<li>Multisite network settings and <code>register_setting()<\/code> in combination with network admin pages were not examined.<\/li>\n<li>In trunk (7.2 alpha), <code>settings_errors()<\/code> has been changed to use <code>wp_admin_notice()<\/code>. That version is not released, so the article describes the behaviour of 7.1.<\/li>\n<\/ul>\n<div class=\"lw-faq\">\n<h2>Questions and answers<\/h2>\n<h3>Is register_setting() on admin_init enough when the setting is only used in the form?<\/h3>\n<p>For the form, yes: <code>options.php<\/code> runs through <code>wp-admin\/admin.php<\/code>, where <code>admin_init<\/code> fires. As soon as the setting is meant to appear in <code>\/wp\/v2\/settings<\/code>, however, it is missing, because a REST request never triggers <code>admin_init<\/code>. The registered default value for <code>get_option()<\/code> on the front end also only applies when the registration runs there. <code>init<\/code> covers all three cases.<\/p>\n<h3>Why does GET \/wp\/v2\/settings suddenly return null for an array or object option?<\/h3>\n<p>The controller validates the stored value against the schema and outputs <code>null<\/code> when validation fails. The most common cause is keys the schema does not know, for example from an earlier plugin version: objects without their own rule automatically receive <code>additionalProperties: false<\/code>. The fix is a migration of the stored value or an extended schema. In this state, a POST with <code>null<\/code> is rejected with <code>rest_invalid_stored_value<\/code>.<\/p>\n<h3>Can a single key of an object option be changed through \/wp\/v2\/settings?<\/h3>\n<p>No. The controller passes the submitted object unchanged to <code>update_option()<\/code> and thereby replaces the entire value. A client has to read the object first, change the key and send the complete object back. Real partial updates require a custom route with a <code>permission_callback<\/code>.<\/p>\n<h3>Is the nonce from settings_fields() enough to protect a settings page?<\/h3>\n<p>No. A WordPress nonce helps protect against CSRF. It is time-limited and tied to an action and user session, but does not prove that a request came from the plugin\u2019s own form or replace authentication or authorization. <code>options.php<\/code> checks permission separately, using <code>manage_options<\/code> or whatever the filter <code>option_page_capability_{$option_page}<\/code> returns. A plugin that lowers the capability of its menu item has to adjust that filter as well, otherwise saving fails.<\/p>\n<\/div>\n<div class=\"lw-quellen\">\n<h2>Sources<\/h2>\n<ul>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/settings\/settings-api\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Settings API<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/settings\/using-settings-api\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Using Settings API<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/settings\/custom-settings-page\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Custom Settings Page<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/settings\/options-api\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Options API<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/register_setting\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: register_setting()<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/settings_fields\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: settings_fields()<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/add_option\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: add_option()<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/wp_set_option_autoload\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: wp_set_option_autoload()<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/rest-api\/reference\/settings\/\" target=\"_blank\" rel=\"noopener noreferrer\">REST API Handbook: Settings<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/block-editor\/reference-guides\/packages\/packages-api-fetch\/\" target=\"_blank\" rel=\"noopener noreferrer\">Block Editor Handbook: @wordpress\/api-fetch<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/news\/2024\/03\/how-to-use-wordpress-react-components-for-plugin-pages\/\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress Developer Blog: How to use WordPress React components for plugin pages<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2016\/10\/26\/registering-your-settings-in-wordpress-4-7\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: Registering your settings in WordPress 4.7<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2023\/10\/17\/new-option-functions-in-6-4\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: New option functions in 6.4<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2024\/06\/18\/options-api-disabling-autoload-for-large-options\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: Options API: Disabling autoload for large options<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-admin\/options.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-admin\/options.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-admin\/options-head.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-admin\/options-head.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-includes\/option.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-includes\/option.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-includes\/rest-api\/endpoints\/class-wp-rest-settings-controller.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: class-wp-rest-settings-controller.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-admin\/includes\/plugin.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-admin\/includes\/plugin.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-admin\/includes\/template.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-admin\/includes\/template.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-includes\/script-loader.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop: wp-includes\/script-loader.php (branch 7.1)<\/a>, retrieved 29.09.2026<\/li>\n<li><a href=\"https:\/\/api.wordpress.org\/core\/version-check\/1.7\/\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress.org version check API (current release)<\/a>, retrieved 29.09.2026<\/li>\n<\/ul>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A complete plugin with one bundled option, a page under Settings and REST access via \/wp\/v2\/settings, plus the path every form field takes through options.php.<\/p>\n","protected":false},"author":1,"featured_media":20923,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[92642],"tags":[91093,91392,91219,91096],"class_list":["post-20872","post","type-post","status-publish","format-standard","hentry","category-tutorials-en-wordpress-plugins-tricks","tag-php","tag-rest-api","tag-tutorial","tag-wordpress"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.1 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Settings pages with the Settings API and register_setting | Lukas Wojcik<\/title>\n<meta name=\"description\" content=\"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \/wp\/v2\/settings.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Settings pages with the Settings API and register_setting | Lukas Wojcik\" \/>\n<meta property=\"og:description\" content=\"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \/wp\/v2\/settings.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/\" \/>\n<meta property=\"og:site_name\" content=\"Lukas Wojcik - Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-14T06:06:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"630\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Lukas Wojcik\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Lukas Wojcik\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"20 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/\"},\"author\":{\"name\":\"Lukas Wojcik\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"headline\":\"Settings pages with the Settings API and register_setting\",\"datePublished\":\"2026-10-14T06:06:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/\"},\"wordCount\":2371,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20872-settings-api-register-setting.png\",\"keywords\":[\"PHP\",\"REST API\",\"Tutorial\",\"WordPress\"],\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/\",\"name\":\"Settings pages with the Settings API and register_setting | Lukas Wojcik\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20872-settings-api-register-setting.png\",\"datePublished\":\"2026-10-14T06:06:00+00:00\",\"description\":\"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \\\/wp\\\/v2\\\/settings.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#primaryimage\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20872-settings-api-register-setting.png\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20872-settings-api-register-setting.png\",\"width\":1200,\"height\":630,\"caption\":\"Settings pages with the Settings API and register_setting\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/settings-api-register-setting\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Settings pages with the Settings API and register_setting\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\",\"name\":\"Lukas Wojcik - Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\",\"name\":\"Lukas Wojcik\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"width\":424,\"height\":636,\"caption\":\"Lukas Wojcik\"},\"logo\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\"},\"sameAs\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Settings pages with the Settings API and register_setting | Lukas Wojcik","description":"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \/wp\/v2\/settings.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/","og_locale":"en_US","og_type":"article","og_title":"Settings pages with the Settings API and register_setting | Lukas Wojcik","og_description":"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \/wp\/v2\/settings.","og_url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/","og_site_name":"Lukas Wojcik - Blog","article_published_time":"2026-10-14T06:06:00+00:00","og_image":[{"width":1200,"height":630,"url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png","type":"image\/png"}],"author":"Lukas Wojcik","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Lukas Wojcik","Est. reading time":"20 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#article","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/"},"author":{"name":"Lukas Wojcik","@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"headline":"Settings pages with the Settings API and register_setting","datePublished":"2026-10-14T06:06:00+00:00","mainEntityOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/"},"wordCount":2371,"commentCount":0,"publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png","keywords":["PHP","REST API","Tutorial","WordPress"],"articleSection":["Tutorials"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/","url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/","name":"Settings pages with the Settings API and register_setting | Lukas Wojcik","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#primaryimage"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png","datePublished":"2026-10-14T06:06:00+00:00","description":"Settings API step by step: register_setting with schema, one bundled option, deliberate autoload, options.php checks and apiFetch on \/wp\/v2\/settings.","breadcrumb":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#primaryimage","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20872-settings-api-register-setting.png","width":1200,"height":630,"caption":"Settings pages with the Settings API and register_setting"},{"@type":"BreadcrumbList","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/settings-api-register-setting\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.lukaswojcik.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Settings pages with the Settings API and register_setting"}]},{"@type":"WebSite","@id":"https:\/\/www.lukaswojcik.com\/blog\/#website","url":"https:\/\/www.lukaswojcik.com\/blog\/","name":"Lukas Wojcik - Blog","description":"","publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.lukaswojcik.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9","name":"Lukas Wojcik","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","width":424,"height":636,"caption":"Lukas Wojcik"},"logo":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg"},"sameAs":["https:\/\/www.lukaswojcik.com\/blog"]}]}},"_links":{"self":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20872","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/comments?post=20872"}],"version-history":[{"count":1,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20872\/revisions"}],"predecessor-version":[{"id":21838,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20872\/revisions\/21838"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media\/20923"}],"wp:attachment":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media?parent=20872"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/categories?post=20872"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/tags?post=20872"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}