Skip to content

Repository files navigation

WordPress Settings Library

Simple, reusable WordPress settings library with support for collapsible field groups.

Requirements

  • PHP 8.0 or newer
  • ext-sodium or ext-openssl, only if you use encrypted fields — see Encryption. Neither is required for anything else.

Installation

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-settings

Scope your vendored copy

A 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.

Usage

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.

Construct Unconditionally — Do Not Gate Behind is_admin()

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()

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 Values

$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.

Built-In Logging

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 Logging tab with settings for enable/disable, destination, level, retention days, and auto-refresh
  • Log files in wp-content/uploads/<text-domain>-logs-<hash>/, guarded by an index.php and 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 Logging setting is saved
  • log_destination can write to the plugin log file or WordPress debug.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

Where log files live

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.

Field Types

Standard: text, email, url, number, color, textarea, checkbox, select, radio, password, hidden, sortable, dual_list, 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.

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.

Input Attributes

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.

Boolean Attributes

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 supported. Browsers omit disabled inputs from form submission and wp-admin/options.php writes every registered option from $_POST, so a disabled field would blank its own stored option on the next save. Use readonly: the input stays uneditable but still submits its current value.

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.

Delimited Lists

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.

Row Labels

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, and richtext (TinyMCE hides the textarea the id belongs to).

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.

Advanced Field Example

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).

Fieldset Field Example

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');

Sortable Field Example

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',
            ),
        ),
    )
);

Dual List Field Example

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.

Table Field Example

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.

Field Map Example

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'], ...]

Repeater Field Example

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.

'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.

Testing

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.

Encryption

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 a constant, then an environment variable of the same name, then the WordPress salts.

Key material

The constant 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:

  1. A defined PHP constant.
  2. An environment variable of the same name, which covers .env, Docker and hosting panels.
  3. LOGGED_IN_KEY / NONCE_KEY.

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-here

The library never reads or writes wp-config.php. A define() there works because the file is executed, not because it is parsed.

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() and WP_Setting::decrypt() log a warning and return the value unchanged rather than failing the request. Call WP_Setting_Encryption::is_available() to check up front.
  • WP_Setting_Encryption::encrypt() and decrypt() throw \RuntimeException on failure if you call them directly.

Detecting a failure

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.

Moving to a different key

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.

Settings Tables

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.

Row Storage

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.

Typed Columns

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 — the CREATE TABLE body, used verbatim. Without it, the adapter generates one, giving each mapped column longtext.
  • installfalse when the table already exists and you own its schema. Nothing is created, and install_storage() becomes a no-op.
  • id_column, status_column, data_column, created_column, updated_column — rename a column, or pass null to drop it. Only the id column is required. Dropping data_column makes the mapped columns the whole row; dropping created_column orders 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.

Conditional Visibility

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:

Operator Description
equals Field value equals the target value
not_equals Field value does not equal the target value
in Field value is one of the values in the target array
not_in Field value is not in the target array
empty Field value is empty
not_empty Field value is not empty

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.

Conditional Sections

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.

Autoloading

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.

About

Simple, reusable WordPress settings library with support for collapsible "advanced" field groups.

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages