Simple, reusable WordPress settings library with support for collapsible field groups.
- PHP 8.0 or newer
ext-sodiumorext-openssl, only if you use encrypted fields — see Encryption. Neither is required for anything else.
Add the GitHub repository to your project's composer.json:
{
"repositories": [
{ "name": "bgoewert/wp-settings", "type": "vcs", "url": "https://github.com/bgoewert/wp-settings.git" }
]
}Then require the package:
composer require bgoewert/wp-settingsA plugin that ships this library in its own vendor/ must rename the namespace, with php-scoper or Mozart. Two plugins vendoring it unscoped on one site is unsupported, and it fails quietly rather than loudly: the class_exists() guards mean one copy wins, both plugins get its classes, and both share one WP_Setting::$text_domain. Whichever plugin constructs its WP_Settings subclass last owns every get(), set() and register_setting() — the other's fields register under the wrong prefix and its reads resolve the wrong options. Autoload order also decides which version both plugins run.
The library reports the collision with _doing_it_wrong() on admin_init, naming the path and version of each copy, but it cannot fix it. The same requirement applies to any library a plugin vendors — Guzzle and the AWS SDK carry it too.
use BGoewert\WP_Settings\WP_Settings;
use BGoewert\WP_Settings\WP_Setting;
class My_Settings extends WP_Settings
{
public function __construct()
{
if (!function_exists('get_plugin_data')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_data = get_plugin_data(MY_PLUGIN_FILE, false, false);
// Parent first. It is what sets WP_Setting::$text_domain, and every
// WP_Setting fixes its option slug from that static at construction —
// build the fields above this line and they register unprefixed keys
// that WP_Setting::get() will never find.
parent::__construct($plugin_data);
$this->sections = array(
array(
'name' => 'General Settings',
'slug' => 'general_settings',
'tab' => 'general',
'tab_name' => 'General',
'callback' => '__return_false',
),
);
// Alternatively, use array keys as slugs (v2.7.0+):
// $this->sections = array(
// 'general_settings' => array(
// 'name' => 'General Settings',
// 'tab' => 'general',
// 'tab_name' => 'General',
// 'callback' => '__return_false',
// ),
// );
$this->settings = array(
'my_option' => new WP_Setting(
'my_option', // option slug
'My Option', // title
'select', // type
'general', // page/tab
'general_settings',// section
'400px', // width
'Choose a value.', // description
false, // required
'default', // default value
null, // custom render callback
array(
'sanitize_callback' => 'sanitize_text_field',
'options' => array(
'option_a' => 'Option A',
'option_b' => 'Option B',
),
)
),
);
}
}
new My_Settings();Tab labels default to ucwords(tab) but you can override the display label per tab with tab_name.
The tab strip renders only when there are two or more tabs, so a page whose sections all share one tab gets no nav.
A section also accepts conditions, which shows it only for certain values of another field — see Conditional Visibility.
The page is registered under Settings and requires manage_options. Both come from the plugin data, so a page that belongs somewhere else says so instead of overriding admin_menu():
parent::__construct(array(
'Name' => 'Support Portal',
'TextDomain' => 'seiler-support-portal',
'Parent' => 'edit.php?post_type=seiler-support',
'Capability' => 'edit_posts',
));Parent is any parent slug add_submenu_page() accepts; Capability gates both the menu entry and the page render. Log viewing and clearing still require manage_options.
Overriding admin_menu() to move the page is what these keys replace. The override leaves the library's submenu page hook empty, and the hook is what the stylesheet, the scripts and every screen check key off — the password field's Show button renders and does nothing, with no error. A page whose hook is empty at admin_enqueue_scripts now reports it with _doing_it_wrong() (visible under WP_DEBUG).
Always construct your WP_Settings subclass unconditionally, as in the example above (new My_Settings(); at file scope) — never gate it behind if ( is_admin() ). The class's own admin_init hook is already inert outside wp-admin regardless of when it's registered, so gating construction yourself buys nothing there. What it does break: WP_Setting::$text_domain is only ever set as a side effect of that construction, and WP_Setting::get()/::set() need it set on every request — frontend, REST, WP-CLI — not just admin ones. Gate the construction and those calls silently fall through to reading/writing an unprefixed, nonexistent option key, always returning your hardcoded default instead of the admin-configured value, with no error or warning to indicate why.
If you have a genuine reason to avoid constructing the full WP_Settings object outside of wp-admin (e.g. a subclass constructor with its own non-admin-safe side effects), call WP_Setting::set_text_domain( $domain ) instead — see below.
WP_Setting::set_text_domain( string $domain ): void sets WP_Setting::$text_domain directly, normalizing hyphens to underscores exactly as WP_Settings::__construct() does — without requiring a WP_Settings subclass at all:
use BGoewert\WP_Settings\WP_Setting;
WP_Setting::set_text_domain('my-plugin');
$value = WP_Setting::get('my_option', 'default');Calling WP_Setting::get() or ::set() while $text_domain is still unset triggers a _doing_it_wrong() notice (visible under WP_DEBUG, silent in production) naming the likely cause and fix; the call still returns its existing fallback value either way.
$default_value (the ninth constructor argument) is what the option starts life with, not what the field falls back to when empty. add_option() seeds the option row with it and register_setting() declares it, so a setting nobody has configured shows it — and a setting saved empty stays empty, which is what makes an optional field clearable at all. Pass 'reset_button' => true in $args on a text, textarea or richtext field to render a Reset to Default button beside it, so restoring the default is something the admin chooses rather than something the field does to them.
WP_Setting::get( $setting, $default ) follows get_option(): the second argument applies only when the option row is absent, so an empty saved value comes back empty. Use ?: at the call site if consuming code needs its own fallback.
You can opt into a built-in Logging tab with plugin log files, retention settings, and an admin log viewer.
class My_Settings extends WP_Settings
{
public function __construct()
{
if (!function_exists('get_plugin_data')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_data = get_plugin_data(MY_PLUGIN_FILE, false, false);
// logging() before the parent, fields after it: the parent reads the
// logging config to decide whether to build a logger, and it is also
// what sets the text domain each WP_Setting slugs itself from.
$this->logging(array(
'plugin_dir_path' => plugin_dir_path(MY_PLUGIN_FILE),
'retention_days_default' => 14,
'default_level' => 'error',
));
parent::__construct($plugin_data);
$this->sections = array(
'general_settings' => array(
'name' => 'General Settings',
'tab' => 'general',
'callback' => '__return_false',
),
);
$this->settings = array(
'my_option' => new WP_Setting('my_option', 'My Option', 'text', 'general', 'general_settings'),
);
}
}What it adds:
- A
Loggingtab with settings for enable/disable, destination, level, retention days, and auto-refresh - Log files in
wp-content/uploads/<text-domain>-logs-<hash>/, guarded by anindex.phpand a deny-all.htaccess - Daily file rotation using
<text-domain>-YYYY-MM-DD.log - A built-in viewer for plugin log files with refresh and clear actions
Notes:
- Logging is disabled by default until the
Enable Loggingsetting is saved log_destinationcan write to the plugin log file or WordPressdebug.log- The built-in viewer only displays plugin log files, not WordPress
debug.log - Crypto failure logging records only generic operation metadata, not encrypted or decrypted values
The directory name carries a 16-character wp_hash() suffix derived from the text domain and the site's salts. Uploads is not reliably outside the web root and neither was the old <plugin-dir>/logs, so the suffix — not the location — is what stops an unauthenticated visitor fetching a log by guessing the text domain and a date (#15). The .htaccess covers Apache only; on nginx the unguessable name is the whole of the protection. Rotating the site's salts changes the suffix, which orphans existing files until retention prunes them.
Log files written by 3.x into <plugin-dir>/logs are moved into the new directory on the first write or log-viewer load. Anything the move cannot claim — a file the web user cannot rename, or a file the consumer put there itself — stays put and gets the guards written alongside it instead; a directory left holding nothing but those guards is removed.
Pass log_dir to override the location entirely, e.g. a path above the web root:
$this->logging(array(
'plugin_dir_path' => plugin_dir_path(MY_PLUGIN_FILE),
'log_dir' => dirname(ABSPATH) . '/logs/my-plugin',
));The directory is created with wp_mkdir_p(), so it honours FS_CHMOD_DIR (0755 by default). If it cannot be created or written, file logging turns off for the rest of the request after one error_log() line rather than retrying per entry.
Standard: text, email, url, number, color, textarea, checkbox, select, radio, password, hidden, sortable, dual_list, media, status, table, field_map
Color: Renders <input type="color">. The default sanitize callback accepts only what that control submits — #rgb or #rrggbb — and rejects anything else (including rgb(), hsl() and named colors) by storing false, the same way url and email reject invalid input. Pass your own sanitize_callback if you need to accept other CSS color syntaxes; WP_Setting::sanitize_color() and WP_Setting::is_valid_hex_color() are public if you want to build on them.
Pass 'palette' => 'theme' to suggest the theme.json palette (plus colors added in the Site Editor), or 'palette' => array('#c8102e', '#fdba4d') for your own. The colors render as a <datalist> the input's list points at, which Chrome, Edge and Safari show as swatches and Firefox on Windows uses to fill the system dialog's Custom colors; Firefox on macOS and Android ignores it. Colors that are not hex, such as var() references, are skipped. An explicit list arg wins over palette. A custom render callback can call WP_Setting::color_palette_datalist($id, $palette) and point its own input's list at $id.
Advanced: Collapsible <details> section containing child settings.
Fieldset: Visual grouping of child settings with <fieldset> element.
Both container types (advanced and fieldset) render each child through that child's own field renderer — the same path a top-level field uses — so a child can be any supported type, including repeater, field_map, radio, richtext, sortable, or a nested advanced/fieldset. A child's custom callback (if provided) is invoked with its args, exactly as at top level. Top-level containers span the full width of the settings table.
Table: Embeds a WP_Settings_Table instance within a section alongside other settings.
Field Map: Dynamic add/remove rows for mapping source fields to destination fields.
Repeater: Dynamic add/remove rows of child controls, stored in row order.
Dual List: Two listboxes — Available on the left, chosen on the right — with buttons to move options across and to order the chosen side. Stores the chosen side as an ordered array of option keys.
Media: A media library picker — Choose and Remove buttons over the wp.media modal, with a preview. Stores the attachment id.
Status: A display-only row — the title and a derived reading, such as a connection state, as text. Stores nothing.
Text-like fields (text, email, url, number, password) render these $args keys directly onto the <input>: min, max, step, pattern, minlength, maxlength, size, autocomplete, list.
new WP_Setting(
'cache_duration', 'Cache Duration (seconds)', 'number', 'settings', 'section',
'10ch', 'How long to cache API responses.', false, 3600, null,
array('min' => 60, 'step' => 60)
);Empty strings, null and non-scalar values are skipped; 0 is rendered (min="0").
These are browser-side validation hints only. Saving still goes through the field's sanitize callback, and the default number callback rejects non-numerics but does not enforce min/max — supply a custom sanitize_callback if you need a hard bound.
list names the id of a <datalist> the consumer renders — the library emits the attribute and nothing else, because the suggestions usually come from a remote lookup whose fetching, caching and failure handling belong outside a field definition. Unlike a select, the admin can still type a value the list doesn't contain, which is the point: a stale or failed fetch degrades to a plain text field.
new WP_Setting(
'sf_field', 'Salesforce Field', 'text', 'settings', 'section',
'30ch', 'Pick a field, or paste an id the lookup did not return.', false, null, null,
array('list' => 'sf_field_names')
);textarea fields accept rows, class and placeholder the same way; the value-based attributes above (including list, which does nothing on a <textarea>) are ignored there.
readonly is a boolean attribute, so it is rendered by presence: pass anything truthy and the bare attribute is emitted, pass anything falsy (false, 0, '0', '', null) or omit the key and nothing is emitted. It is never rendered as readonly="…", because a browser treats readonly="0" as read-only just like readonly.
new WP_Setting(
'quote_email', 'Quote Email', 'text', 'settings', 'section',
'30ch', 'Managed by the active quote plugin.', false, null, null,
array('readonly' => $quote_plugin_active)
);Both text-like fields and textarea accept readonly.
disabled is not passed through as an attribute: browsers omit disabled inputs from form submission and wp-admin/options.php writes every registered option from $_POST, so it would blank the stored option on the next save. Use the disabled arg below, or readonly where an input should stay focusable.
A setting that does nothing while a dependency is missing should look inert, not just say so in its description. 'disabled' => true renders the field's controls inside a <fieldset disabled>, which disables every control a type renders — a checkbox, a select, a repeater's buttons — and keeps the stored value on every save:
new WP_Setting(
'hide_quote_screens', 'Hide Customer Quote Screens', 'checkbox', 'settings', 'section',
null, 'Requires YITH Request a Quote to be active.', false, null, null,
array('disabled' => !defined('YITH_YWRAQ_VERSION'))
);The field's registered sanitizer answers with the stored value, so the null options.php writes for the missing control leaves the option alone, a forged POST cannot change it, and the field's own sanitize_callback does not run. A save through the library skips the field. Nothing round-trips through a hidden input, so the stored value is never printed twice.
disabled applies to option-backed fields: a table column or an advanced/fieldset container ignores it, so declare it on each child instead. A type driven by script — richtext, sortable, dual_list — can still react to input a disabled fieldset does not block, but its value is kept all the same.
A read-only field is uneditable in the browser only. A crafted POST can still change the value, so keep validating in sanitize_callback if the value must not change.
A single input often stands in for a list — tags, SKU prefixes, allowed domains, roles. 'delimiter' on a text or textarea field makes that shape first-class: the value is split on the delimiter, trimmed, emptied entries dropped, each part run through sanitize_text_field(), and stored as a list<string>. WP_Setting::get() hands the array straight back.
new WP_Setting(
'rental_tags', 'Rental Tags', 'text', 'products', 'rental_button',
'300px', 'Comma-separated.', false, 'rental', null,
array('delimiter' => ',')
);The stored list renders back into the one input joined on the delimiter plus a space (rental, demo), because that is what an admin types and the split trims it back off. A delimiter that is already whitespace — "\n" for one item per line in a textarea — joins verbatim. A string default_value is normalized to a list at construction, so the option row is seeded in the shape the field reads back.
Only text and textarea honour it, and an explicit sanitize_callback still wins: delimiter is shorthand for the common case, not a competing mechanism. Omit the key and the field is a plain string end to end, exactly as before.
Without it, storing an array in a string-typed setting is a silent data loss — the sanitizer registered with register_setting() runs on every writer, sanitize_text_field() turns the array into '', and the write reports success while the field re-renders empty. sanitize_text() and sanitize_textarea() now raise _doing_it_wrong() when handed an array, so the misuse surfaces at the first save.
A credential that is generated rather than typed — a shared secret, a signing key — needs its Generate or Rotate button next to the field it fills. 'actions' renders one button per entry with the field, each posting to admin-post.php:
new WP_Setting(
'inbound_secret', 'Inbound Secret', 'password', 'settings', 'portal',
null, null, false, null, null,
array('actions' => array(
array('label' => 'Generate', 'action' => 'my_plugin_generate_secret'),
array('label' => 'Rotate', 'action' => 'my_plugin_rotate_secret', 'capability' => 'manage_network_options'),
))
);
add_action('admin_post_my_plugin_generate_secret', function () {
WP_Setting::set('inbound_secret', wp_generate_password(64, false), true);
});The library renders the button, the form and its nonce, and owns both ends of the request: before your admin_post_{action} handler runs it verifies the nonce (check_admin_referer( $action )) and the capability (default manage_options, and a user without it sees no button), and after it returns the admin is redirected back to the page they pressed it on. The handler only does the work. To report an outcome, redirect yourself with a query arg and exit — the library's redirect runs last, so yours wins. The request also carries setting, the field's slug, for a handler shared between fields.
Forms cannot nest, so each action's form is printed in the admin footer and its button reaches it through the form attribute. The button therefore never submits the settings form, and Enter in the field still saves the settings. Each button's accessible name is its label plus the field title — "Generate Inbound Secret" — so two fields' Generate buttons stay distinct.
On a text-like input (text, password, email, url, number and other single-line types) the buttons sit beside the input, after a password field's Show button; pass 'actions_position' => 'below' to put them in a paragraph under the description instead. Every other type renders them below, since a button beside a textarea or a list reads as unrelated to it.
action becomes a hook name, so it takes letters, digits, _ and - only; an entry without a label or with any other action is dropped.
A control that answers in place — a preview that renders a table, a run that reports a count — has no use for a form post and a redirect. Declare it with render instead of action: the callable is given the field and returns the control's markup, which is printed in declared order among the other buttons. The library adds no form, nonce or hook for it; your script binds the control and your Ajax handler checks its own nonce and capability. capability (default manage_options) still hides it from a user without it.
'actions' => array(
array('label' => 'Test connection', 'action' => 'my_plugin_test_connection'),
array('render' => fn(WP_Setting $field) => sprintf(
'<button type="button" class="button" data-my-plugin-run="preview" data-target="%s">Preview</button>',
esc_attr($field->slug)
)),
),The markup passes through the same kses list as the rest of the field, where a <button> keeps type, class, id, value, form, aria-label, aria-controls, aria-expanded and any data-* attribute.
A connection state or a last-sync time is read, not set, but its Test connection button belongs beside it. A status field renders value as text in the row and takes actions like any other field:
new WP_Setting(
'connection', 'Connection', 'status', 'settings', 'portal',
null, null, false, null, null,
array(
'value' => fn() => my_plugin_connection_label(),
'actions' => array(
array('label' => 'Test connection', 'action' => 'my_plugin_test_connection'),
),
)
);value is a string, or a callable called when the row renders — not on every admin_init, so a remote check only runs on the page that shows it. It passes through the same kses list as a description, so <code>, <span> and links survive. The buttons sit beside the value unless actions_position is below.
Nothing is stored: the field seeds no option, registers nothing with register_setting(), and a save skips it. The row heading is plain text rather than a <label>, since there is no control to name.
WordPress renders each settings row as <tr><th>title</th><td>field</td></tr>, and do_settings_fields() wraps that <th> text in <label for="…"> only when the field declares label_for in its $args. Fields set it for you, pointing at the control's own id (the field slug), so the visible title is the control's accessible name — without it every control on the screen has a heading assistive technology cannot associate with its input (WCAG 1.3.1, 4.1.2; axe label, and select-name for selects).
This applies to every type rendered as one control carrying the slug: the text-like inputs, textarea, select, checkbox, and any custom input type. It is skipped for the types listed in WP_Setting::UNLABELABLE_TYPES, where no single control answers to the slug and a label would be an orphan — radio (one wrapped label per option), sortable, table, field_map, repeater (a control per row), advanced, fieldset (children label themselves), hidden, richtext (TinyMCE hides the textarea the id belongs to), media (the value is a hidden input; its Choose and Remove buttons carry the field title instead), and status (text, not a control).
To point the row label somewhere else, pass your own label_for; to suppress it, pass an empty string:
new WP_Setting(
'api_mode', 'API Mode', 'text', 'settings', 'section',
null, null, false, null, null,
array('label_for' => '') // opt out of the generated <label for>
);Container children follow the same rule: advanced and fieldset bind each child's heading to that child's control when the child is labelable, and render a plain heading when it is not. Use WP_Setting::renders_labelable_control() if you build headings yourself.
repeater rows have no heading to bind to — a column <th> is not an accessible name for a control, and neither axe nor forms mode treats it as one. Each cell is named aria-label="{column label}, row {n}" instead, falling back to the child's name when it declares no label. Rows added or removed in the browser are renumbered to match, so the name always tracks the visible row position.
use BGoewert\WP_Settings\WP_Setting;
$child1 = new WP_Setting('sync_prices', 'Sync Prices', 'checkbox', 'settings', 'section', '500px', 'Enable price sync.', false, 'yes');
$child2 = new WP_Setting('filter_field', 'Filter Field', 'text', 'settings', 'section', '500px', 'Field name for filtering.', false, '');
$advanced = new WP_Setting(
'advanced_settings', 'Advanced Settings', 'advanced', 'settings', 'section',
'500px', 'Configure advanced options.', false, '', null, array('children' => array($child1, $child2))
);
// Or expanded by default:
$expanded = new WP_Setting(
'field_mapping', 'Field Mapping', 'advanced', 'settings', 'section',
null, 'Map source fields to destination fields.', false, '', null,
array('children' => array($child1, $child2), 'collapsed' => false)
);Renders as collapsible <details> section. Set 'collapsed' => false to expand by default (defaults to true if not specified).
use BGoewert\WP_Settings\WP_Setting;
// A repeater grouped under a fieldset legend.
$filters = new WP_Setting(
'sync_filters', 'Sync Filters', 'repeater', 'settings', 'section',
null, 'One condition per row.', false, null, null,
array('children' => array(
array('name' => 'field', 'label' => 'Salesforce Field', 'type' => 'text'),
array('name' => 'values', 'label' => 'Values', 'type' => 'textarea'),
))
);
$fieldset = new WP_Setting(
'filter_group', 'Sync Filters', 'fieldset', 'settings', 'section',
null, 'Filter which products sync.', false, null, null,
array(
'children' => array($filters),
'hide_child_labels' => true,
)
);Set 'hide_child_labels' => true to drop each child's label column and let the control span the full width — useful when a child's title merely repeats the fieldset legend (e.g. a lone repeater). Omit it (the default) to keep per-child labels.
Hiding the label is a layout choice only. A labelable child still gets its title as a <label class="screen-reader-text">, so the control keeps its accessible name — the <legend> names the group, not a child whose title differs from it. Children that already label themselves (a checkbox with a description) are left alone so the control keeps exactly one label.
Hidden fields: Store values without rendering table rows.
$hidden = new WP_Setting('internal_setting', '', 'hidden', 'settings', 'section', '', '', false, 'value');new WP_Setting(
'featured_order',
'Featured Order',
'sortable',
'general',
'general_settings',
null,
'Drag or enter a number to reorder items.',
false,
array('item_b', 'item_a'),
null,
array(
'options' => array(
'item_a' => 'Item A',
'item_b' => 'Item B',
'item_c' => 'Item C',
),
)
);With badges and custom classes:
new WP_Setting(
'field_order',
'Field Order',
'sortable',
'general',
'general_settings',
null,
'Drag or enter a number to reorder fields.',
false,
array('first_name', 'last_name', 'custom_field'),
null,
array(
'options' => array(
'first_name' => 'First Name',
'last_name' => 'Last Name',
'custom_field' => 'Custom Field',
),
'item_meta' => array(
'first_name' => array(
'badge' => 'Default',
'badge_class' => 'default',
'class' => 'default-field',
),
'last_name' => array(
'badge' => 'Default',
'badge_class' => 'default',
'class' => 'default-field',
),
'custom_field' => array(
'badge' => 'Custom',
'badge_class' => 'custom',
'class' => 'custom-field',
),
),
)
);Which items appear, and in what order, is one decision. Expressing it as a sortable for order plus a checkbox per item for visibility makes it two settings that can disagree — and an item left out of the list is simply off.
new WP_Setting(
'attendee_columns',
__( 'Attendee Columns', 'my-plugin' ),
'dual_list',
'exports',
'exports',
null,
__( 'Move columns into Displayed to show them, and order them there.', 'my-plugin' ),
false,
array( 'primary_info', 'ticket' ), // default: the chosen side
null,
array(
'options' => array( 'primary_info' => 'Attendee', 'ticket' => 'Ticket', 'email' => 'Email' ),
'available_label' => __( 'Available', 'my-plugin' ),
'chosen_label' => __( 'Displayed', 'my-plugin' ),
'size' => 8,
)
);The value is a list<string> of option keys in the chosen order. Unlike sortable, whose membership is fixed and which merges every option back in, an option left out here stays out — so sortable is the right field when only the order varies, and dual_list when membership does too.
The lists are the interface, not the storage. The chosen list — not a selection inside it — is the value, so it is mirrored into hidden inputs on every change. One of those is an empty sentinel, which keeps the field present in $_POST when nothing is chosen — otherwise "display nothing" would silently keep the previous selection.
Items are dragged within the chosen list to order them, or across to move them, and every move is also a button: ↑/↓ reorder, →/← move across, and double-clicking an item moves it. The buttons show arrows and carry their full name — Add to Attendee Columns — for assistive technology, because a page with two dual lists otherwise has four buttons all called "Add". Saving sanitizes against the declared option keys and preserves the arranged order.
Each side is a ul[role=listbox] of li[role=option], not a select[multiple]: an <option> fires no drag events in Firefox or Safari, so a select cannot be dragged at all. That means the selection model is the library's own, following the WAI-ARIA listbox pattern — click, ctrl/cmd-click and shift-click select, arrows move the active item with shift extending, space toggles, and Enter moves the selection to the other list. Consumer browser tests should drive the field by clicking .wps-dual-list-item[data-key="…"] rather than with a selectOption call.
size is the number of rows a side shows before it scrolls, as it was on the <select> it replaced; it becomes the list's height.
A logo, an email masthead or a default og:image is a choice from the media library, not a URL someone pastes in.
new WP_Setting(
'email_logo',
__( 'Email Logo', 'my-plugin' ),
'media',
'email',
'branding',
null,
__( 'Shown at the top of every notification.', 'my-plugin' ),
false,
null,
null,
array(
'mime_types' => 'image', // default; a family, an exact type, or a list of either
'size' => 'medium', // default; the registered size the preview renders
)
);
// Reading it back:
$url = WP_Setting::get_attachment_url( 'email_logo', 'full' );The value is the attachment id, as an int, or '' when nothing is chosen. An id survives a domain change and a CDN move where a URL does not, which is why core stores custom_logo the same way. WP_Setting::get_attachment_url( $name, $size = 'full' ) turns it back into a URL — the image at that size, or the file URL for a non-image — and returns '' when nothing is chosen or the attachment is gone.
mime_types takes a family (image, which accepts image/png), an exact type (application/pdf), or an array of either; an empty array accepts any attachment. It filters the modal's library and is checked again on save, so an id of the wrong type, one that is not an attachment, or one whose attachment was deleted saves as '' rather than as a broken image. A stored id whose attachment has since been deleted renders as no choice, and the next save clears it.
The preview's alt text is the attachment's own, falling back to its title — the field title says what the slot is for, not what fills it. A non-image previews as its file name. wp_enqueue_media() and the field's script load only on a page that has a media field, including one inside an advanced container.
use BGoewert\WP_Settings\WP_Setting;
use BGoewert\WP_Settings\WP_Settings_Table;
// Create a table instance
$my_table = new WP_Settings_Table(array(
'id' => 'items',
'tab' => 'general',
'option' => 'items',
'title' => 'Item Management',
'description' => 'Manage your items.',
'columns' => array(
array('key' => 'name', 'label' => 'Name', 'field' => 'name'),
array('key' => 'value', 'label' => 'Value', 'field' => 'value'),
),
'fields' => array(
new WP_Setting('name', 'Item Name', 'text', 'general', 'items_section'),
new WP_Setting('value', 'Item Value', 'number', 'general', 'items_section'),
),
));
// Embed the table in a section alongside other settings
new WP_Setting(
'items_table',
'Items',
'table',
'general',
'general_settings',
null,
'Configure your items below.',
false,
null,
null,
array(
'table' => $my_table,
)
);This allows you to place tables within sections, rendered alongside regular settings fields.
use BGoewert\WP_Settings\WP_Setting;
new WP_Setting(
'field_mapping',
'Field Mapping',
'field_map',
'settings',
'section',
null,
'Map source fields to destination fields.',
false,
null,
null,
array(
'options' => array(
'first_name' => 'First Name',
'last_name' => 'Last Name',
'email' => 'Email Address',
'phone' => 'Phone Number',
),
)
);The field_map type provides dynamic add/remove rows where users can:
- Select a source field from dropdown (left side)
- Enter a destination field name in text input (right side)
- Add/remove mapping rows as needed
- Map multiple source fields to different destinations (useful for combining values)
Stored as array: [['key' => 'first_name', 'value' => 'FirstName'], ['key' => 'email', 'value' => 'Email'], ...]
The destination input takes the input attributes, so 'list' => 'my-field-names' attaches a <datalist> to every row, including rows added in the browser. autocomplete also reaches the custom-value textarea, so 'autocomplete' => 'off' stops browsers offering saved names and addresses in either.
new WP_Setting(
'attendee_fields',
'Attendee Fields',
'repeater',
'settings',
'section',
null,
'Questions each registrant answers, in the order they are asked.',
false,
null,
null,
array(
'reorder' => true,
'numbered_rows' => true,
'children' => array(
array('name' => 'label', 'label' => 'Label', 'type' => 'text'),
array('name' => 'type', 'label' => 'Type', 'type' => 'select', 'options' => array(
'text' => 'Text',
'email' => 'Email',
)),
),
)
);Rows are stored in the order they appear: [['label' => 'Name', 'type' => 'text'], ...]. Each child takes name, label, type (text-like, textarea or select), plus options for a select, placeholder, width, and preserve_percent_encoded. A text-like child also takes the input attributes, so 'list' => 'my-field-names' attaches a <datalist> to every row, including rows added in the browser.
'reorder' => true adds an up/down button pair to every row, which is the affordance a keyboard reaches without a pointer. The move that would take a row nowhere is disabled, each button is named Move row {n} up/down from the row's position, and the position, the cell names and the visible counter are all rebuilt after a move the same way they are after an add or remove. 'numbered_rows' => true shows the counter; both default off, and a repeater that asks for neither renders exactly as before.
Three suites, three things they can prove.
| Suite | Command | Needs |
|---|---|---|
| Unit | composer test |
Nothing — the library runs against the WordPress function stubs in tests/bootstrap.php |
| Integration | composer test:integration |
A booted WordPress (ddev start) |
| E2E | bun run test:e2e |
The same ddev site, plus bunx playwright install chromium |
| Mutation | composer test:mutation |
A coverage driver on the host (pcov or Xdebug) |
ddev start downloads WordPress into .local/wp (gitignored, so core never lands in the repo), installs it as admin/admin, and links tests/harness/wp-settings-harness.php in as an mu-plugin. That harness registers the settings page both the integration and e2e suites drive, at Settings → Wp Settings Harness.
composer test:mutation runs Infection over src/ against the unit suite, mutating operators and return values to see which changes the tests fail to notice. Infection finds a PHPUnit config by fixed name, so it picks up phpunit.xml.dist and never touches the integration config, whose bootstrap needs a live wp-load.php. minMsi sits below the observed score deliberately — PHPUnit randomises test order, so which test covers a given mutant shifts between runs. Raise the gate as coverage improves; do not lower it to make a run pass.
The split is about what each layer can reach. The unit suite calls a sanitizer directly. The integration suite is the only one with a real sanitize_option_{$option} filter, which is where register_setting() hangs the sanitizer and where every writer — including ones that never touch this library — picks it up. The e2e suite is the only one that executes the admin page's JavaScript, so it is the only place a bug in what the Reset to Default script assigns can show up at all.
Declare 'encrypted' => true on a field and its value is stored ciphered:
WP_Setting::make( 'api_token', 'API Token', 'password', 'general', 'keys', [ 'encrypted' => true ] );Sanitization still runs first, then the value is encrypted on save and decrypted on render. Say it once at registration and a read and a write cannot disagree about a setting — the per-call WP_Setting::set( $name, $value, true ) / get( $name, false, true ) flags remain for values that aren't fields, but a forgotten flag there writes a secret in plaintext and nothing fails.
The key and nonce are resolved from an environment variable, then a constant of the same name, then the WordPress salts.
The names are derived from your text domain — MY_PLUGIN_KEY and MY_PLUGIN_NONCE for the text domain my-plugin. Resolution stops at the first source that has a value:
- An environment variable, which covers
.env, Docker and hosting panels. - A defined PHP constant of the same name.
LOGGED_IN_KEY/NONCE_KEY.
The environment comes first because that is where a deploy puts the value; a define() is usually hand-placed and can go stale. When both are set to different values, the environment variable is used, the conflict is logged once, and administrators see a notice naming it until one is removed or the two match. On Bedrock, config/application.php typically copies .env into constants, so the two hold the same value and the order makes no difference.
The salts are the default and need no setup. Define your own only if you rotate salts — a rotation makes every value encrypted under them unreadable. Generate one with openssl rand -base64 32, then either:
define( 'MY_PLUGIN_KEY', 'base64-value-here' );MY_PLUGIN_KEY=base64-value-hereThe library never reads key material from wp-config.php. A define() there works because the file is executed, not because it is parsed. The one write is the migration below, on an admin's request.
Two backends are supported, chosen automatically:
| Backend | Cipher | Stored format |
|---|---|---|
ext-openssl (preferred) |
AES-256-GCM | wps.aesgcm.v2: + key fingerprint + base64(iv . tag . ciphertext) |
ext-sodium (fallback) |
XSalsa20-Poly1305 (sodium_crypto_secretbox) |
base64(nonce . ciphertext) |
openssl is the default writer: WordPress leans on it for HTTPS, whereas sodium is only bundled with PHP and still has to be enabled at build time (--with-sodium), so it is routinely absent from minimal and cross-compiled builds. The openssl path also derives a fresh IV per value, where the sodium path reuses the configured nonce.
Reading dispatches on the payload's own format, not on this preference, so values written by earlier versions keep decrypting untouched.
Notes:
- A value written with sodium cannot be read on a build without sodium. Decrypt and re-save it before migrating to such a host.
- With neither extension present,
WP_Setting::encrypt()andWP_Setting::decrypt()log a warning and return the value unchanged rather than failing the request. CallWP_Setting_Encryption::is_available()to check up front. WP_Setting_Encryption::encrypt()anddecrypt()throw\RuntimeExceptionon failure if you call them directly.
Because decrypt() hands back what it was given, a returned ciphertext is indistinguishable from a plaintext that decrypted fine. Use WP_Setting::try_decrypt() / try_encrypt() when the failure itself matters — same key/nonce derivation and logging, but it throws \RuntimeException instead of degrading:
try {
$token = WP_Setting::try_decrypt( WP_Setting::get( 'api_token' ) );
} catch ( \RuntimeException $e ) {
// "your encryption keys changed; re-save the secret", not "check your credentials"
$e->getPrevious(); // the underlying failure, which may be an \Error
}try_encrypt() is the one to use where storing an unencrypted secret is unacceptable — encrypt()'s fallback persists plaintext.
An openssl payload carries a fingerprint of the key that wrote it, so a value encrypted under a key the site no longer resolves is reported as a key change rather than a bad credential. WP_Setting::try_decrypt() sets the exception code to WP_Setting::CRYPT_KEY_CHANGED in that case, and WP_Setting::decrypt_failure_message() returns the message to show the admin.
Sunsetting your own key constant in favour of the salts — or moving between constants — leaves stored values encrypted under the old one. Re-encrypt them from an upgrade hook, while the old key is still available:
WP_Setting::rewrap_encrypted( [ 'api_token', 'webhook_secret' ], MY_PLUGIN_LEGACY_KEY, MY_PLUGIN_LEGACY_NONCE );The names are yours to supply — the library has no registry of which settings are encrypted. Each comes back as rewrapped, current, empty or failed; a value that will not decrypt under the legacy key is left untouched, so the pass is safe to repeat. Downgrading the library after a rewrap is not supported: versions before 4.8.0 do not read the fingerprinted format.
Versions before 4.0.0 generated MY_PLUGIN_KEY and MY_PLUGIN_NONCE and wrote them into wp-config.php, and a defined constant still wins over the salts. While either is defined, the settings page shows administrators a Migrate to WordPress salts notice; there is nothing to call.
The button re-encrypts every password field and every field declared encrypted under the salts, stages the result, and deletes the two define() lines. When wp-config.php is not writable, DISALLOW_FILE_MODS is set, or a line is not in the form the old versions wrote, the notice shows the lines to delete instead. Stored values stay as they are until a request loads without the constants and the staged values decrypt under its key, so the site keeps working in between. Afterward, rotating LOGGED_IN_KEY or NONCE_KEY means re-entering those fields.
Not offered on multisite, where wp-config.php is shared by every site but only the current site's values would move. Fields inside settings tables are not covered; re-enter them after removing the lines.
Use WP_Settings_Table to create a reusable table + modal editor, stored by default as a single option array.
use BGoewert\WP_Settings\WP_Settings_Table;
$this->tables = array(
new WP_Settings_Table(
array(
'id' => 'fees',
'tab' => 'fees',
'option' => 'fees',
'title' => 'Fee Management',
'description' => 'Create and manage fees.',
'status_key' => 'enabled',
'statuses' => array(
'enabled' => array('label' => 'Enabled'),
'disabled' => array('label' => 'Disabled'),
),
'columns' => array(
array('key' => 'status', 'label' => 'Status', 'type' => 'status'),
array('key' => 'name', 'label' => 'Name', 'field' => 'name'),
array('key' => 'type', 'label' => 'Type', 'field' => 'type'),
array('key' => 'amount', 'label' => 'Amount', 'field' => 'amount'),
),
'fields' => array(
new WP_Setting('name', 'Fee Name', 'text', 'fees', 'fees_section'),
new WP_Setting('type', 'Fee Type', 'select', 'fees', 'fees_section', null, null, false, null, null, array(
'options' => array(
'percentage' => 'Percentage',
'fixed' => 'Fixed Amount',
),
)),
new WP_Setting('amount', 'Amount', 'number', 'fees', 'fees_section'),
new WP_Setting('enabled', 'Enabled', 'checkbox', 'fees', 'fees_section'),
),
)
),
);Tables render in the specified tab, support AJAX CRUD, bulk actions, inline status toggles, and a non-JS fallback form.
A table renders above its tab's settings form. Pass 'position' => 'after' to render it below the form instead, so a long table does not push the tab's fields out of reach. A table cannot sit inside a section callback, because its row actions are forms of their own and would nest in the settings form.
Rows live in one option by default, which is the right shape for a table an admin edits one row at a time. It is the wrong shape for a table something else writes: the option API has no per-key write, so two requests arriving together each read the same array and the second write drops the first row.
Pass 'storage' => 'table' to keep each row in its own database row instead. A save is one INSERT ... ON DUPLICATE KEY UPDATE and a delete is one DELETE, so concurrent writes to different rows cannot lose each other, and a lookup by id stops loading every row into PHP.
new WP_Settings_Table(
array(
'id' => 'accounts',
'tab' => 'accounts',
'option' => 'accounts',
'storage' => 'table',
// ...
)
);The schema is created on first use, guarded by a stored version so the check costs one autoloaded option read. Call $table->install_storage() from your activation hook to create it up front. Switching an existing table over does not migrate the option — read the old option and feed the rows in yourself.
storage also accepts any object implementing WP_Settings_Table_Storage, for rows that belong somewhere else entirely.
The default schema keeps the whole row as JSON in a data column, which means SQL cannot reach the fields inside it — no WHERE recipients LIKE, no DELETE ... WHERE created < %s for retention. Construct the adapter yourself with a column map to give a field its own column, and pass the instance as storage.
new WP_Settings_Table_Custom_Table_Storage('plugin_mail_log', 'enabled', array(
'columns' => array('recipients', 'subject', 'created'),
'schema' => "row_id varchar(191) NOT NULL,
recipients text NOT NULL,
subject varchar(255) NOT NULL DEFAULT '',
created datetime NOT NULL,
data longtext NOT NULL,
PRIMARY KEY (row_id),
KEY created (created)",
));columns is a list of row keys used as column names, or a row key => column name map when the two differ. Mapped fields are written to and read from their own column; everything else keeps going into data, so an existing table gains columns without losing rows. A value that is not a scalar stays in data — a column holds one value.
The rest of the shape is configurable with the same argument array:
schema— theCREATE TABLEbody, used verbatim. Without it, the adapter generates one, giving each mapped columnlongtext.install—falsewhen the table already exists and you own its schema. Nothing is created, andinstall_storage()becomes a no-op.id_column,status_column,data_column,created_column,updated_column— rename a column, or passnullto drop it. Only the id column is required. Droppingdata_columnmakes the mapped columns the whole row; droppingcreated_columnorders rows by id.
Naming the status key as a mapped column drops the mirrored status column, since the status then has a real one of its own.
Fields can be conditionally shown/hidden based on other field values using the conditions key in the args array. This works for both regular settings forms and WP_Settings_Table modals.
new WP_Setting(
'salesforce_oid',
'Organization ID',
'text',
'feeds',
'feeds_section',
null,
'Your Salesforce Organization ID',
false,
null,
null,
array(
'conditions' => array(
array(
'field' => 'connection_type',
'operator' => 'in',
'value' => array('salesforce_lead', 'salesforce_case'),
),
),
)
)Supported operators:
Multiple conditions are combined with AND logic (all must be true for the field to be visible).
field names the controlling field the way it was declared — the shorthand name, not the prefixed option slug — though the slug is accepted too.
A section takes the same conditions key, with the same shape and the same operators. Use it when a whole group of fields belongs to one choice: repeating the condition on every field in the group hides the rows but leaves the heading above an empty table.
$this->sections = array(
'vimeo_settings' => array(
'name' => 'Vimeo',
'tab' => 'general',
'callback' => '__return_false',
'conditions' => array(
array('field' => 'provider', 'operator' => 'equals', 'value' => 'vimeo'),
),
),
);The section's heading and its form-table are wrapped in one div.wps-section-wrapper[data-section="{slug}"], so both hide together. The wrapper comes from add_settings_section()'s before_section/after_section args, which require WordPress 5.3 or newer.
Visibility is presentation only, for a section as for a field: a hidden field is still on the page and still submits its value.
WordPress stores options in the wp_options table, which has an autoload column. Options marked for autoloading are fetched in a single query on every page load. Autoloading too many options — especially large ones — degrades site performance.
Set autoload via the constructor's 12th argument or the autoload key in $args:
// Via dedicated param (recommended for clarity)
new WP_Setting(
'license_key', 'License Key', 'text', 'general', 'general_settings',
null, null, false, null, null, array(), true // autoload = true
);
// Via args key
new WP_Setting(
'sync_log', 'Sync Log', 'textarea', 'general', 'general_settings',
null, null, false, null, null, array('autoload' => false)
);When to autoload (true):
- Options read on the frontend (e.g. license status, global feature flags, API base URLs)
- Options accessed on every admin page (e.g. plugin-wide preferences)
When NOT to autoload (false):
- Options only read on specific admin pages (e.g. per-page settings, API credentials, logs)
- Large values like serialized arrays, HTML blobs, or cached remote data
- Options accessed via
WP_Setting::get()in a targeted context
When null (default), WordPress decides — which defaults to autoloading in most WP versions, so prefer explicitly setting false for admin-only options.
