Skip to content
You are reading the docs for ShopClass 6.4.1, still in development. Read the stable docs.

Settings pages

Writing a settings page used to mean writing everything yourself: a view, a <form>, a controller action, a CSRF check, a capability check, a Params::getParam() per field, validation, osc_set_preference() per field, a flash message and a redirect. Every plugin wrote its own version, and each one was a place to get the CSRF check or the escaping wrong.

Declare the page instead. Core owns all of it.

osc_register_settings_page('acme.delivery', array(
'title' => __('Delivery', 'acme'),
'menu' => 'plugins',
'fields' => array(
array('type' => 'checkbox', 'name' => 'enabled', 'label' => __('Offer delivery', 'acme')),
array(
'type' => 'number',
'name' => 'radius_km',
'label' => __('Radius (km)', 'acme'),
'default' => 10,
'depends' => 'enabled',
'required' => true,
),
),
));

Register it while your plugin loads. A theme registers from functions.php on the init hook, once translations are ready. Its menu entry is added at the same time.

That is the whole page. It:

  • appears under Plugins
  • renders with the admin’s own field markup
  • refuses a POST without a valid CSRF token
  • hides and un-requires Radius while Offer delivery is off
  • saves to t_preference under the section acme.delivery
  • redirects with a flash message

For a complete plugin built this way, see the Test Payments plugin in Payment gateways.

Read the values back anywhere:

if (osc_settings_value('acme.delivery', 'enabled')) {
$radius = (int) osc_settings_value('acme.delivery', 'radius_km');
}

osc_settings_value() needs the page registered in that request, because defaults come from the declaration. A page registered only in the admin reads its values on the front end with osc_get_preference($name, $pageId) instead.

The array form above and the builder describe the same page. Use whichever reads better. The builder pays off once a page has more than a handful of fields: each field’s options sit on the field itself, not in a nested array.

use mindstellar\admin\ui\FormSpec;
(new FormSpec('acme.delivery'))
->title(__('Delivery', 'acme'))
->menu('plugins')
->checkbox('enabled', __('Offer delivery', 'acme'))
->number('radius_km', __('Radius (km)', 'acme'))
->default(10)
->dependsOn('enabled')
->required()
->register();

menu is one of settings, plugins, appearance, tools, items, users, pages, stats. Pass '' for a page with no menu entry: one reached from a link you put somewhere else. osc_settings_page_url('acme.delivery') gives you its URL.

text, email, url, tel, number, color, secret, textarea, richtext, select, radio, checkbox, hidden, image (see Images), and custom for markup core does not own.

Keys every field takes:

Key What it does
default Value used until something is saved
required Rejected as empty on save
sanitize callable(mixed $value): mixed, run before validation
validate callable(mixed $value, array $field): ?string: the error, or null
depends Another field on this page. While that field is off, this one is hidden, is not required, and its posted value is discarded
depends_value With depends: a string or list of strings. The field is on while the master’s value is one of them. Only for a select or radio master, and each must be one of its option keys
translate text/textarea/richtext only: one control per enabled locale
locales With translate: the locales to expand over, as code => name, instead of every enabled one
collect callable(array $field): mixed: how the submission becomes this field’s value, for a control core cannot read by name. It replaces the read, the trim and the purify
purify false to store markup as submitted (see below)
column The key to store under, when it is not the field’s own name
persist false to store nowhere, or a callable returning what the key takes: null leaves it as it was, FormSpec::WRITE_NULL stores NULL (an empty string on a preference page)
write_only The control never shows what is stored

depends is decided again on the server. The browser hiding the row is only a convenience: the save discards the value regardless of what was posted, so a hand-crafted request cannot set a field the form never showed.

To follow one value of a select or radio, pass it as the second argument:

->select('wm_type', __('Watermark', 'acme'), array('none' => __('None', 'acme'), 'text' => __('Text', 'acme')))
->text('wm_text', __('Watermark text', 'acme'))->dependsOn('wm_type', 'text')

text, textarea, tel, color and hidden have every tag removed on save. That matches what a hand-written screen reading the same field through Params::getParam() has always stored. Declare 'purify' => false for a field that holds markup or code on purpose.

richtext is different: its value goes through osc_sanitize_html(), which keeps safe formatting instead of stripping every tag: a body is written as markup, and stripping it would empty what the admin just formatted. 'purify' => false on a richtext field stores it exactly as submitted.

This rule covers what is stripped, not what is escaped. Print a stored value through osc_esc_html() or osc_esc_js() as you always would.

A secret field must say whether the admin can read it back (an API key) or must never see it again (a password). The type alone does not say which, so write_only is required on it: registration fails without it.

An image field uploads a picture, such as a logo:

(new FormSpec('folio'))
->title(__('Folio', 'folio'))
->menu('appearance')
->image('logo', __('Logo', 'folio'))
->register();

Read its URL back anywhere, front end included:

$logo = osc_settings_image_url('folio', 'logo'); // '' when none is stored
$thumb = osc_settings_image_url('folio', 'logo', 'thumbnail'); // or 'preview'

The page shows the current image, a file picker and a Remove image box, and posts multipart on its own. The file goes through the same image pipeline as listing photos: it must be a real image, it is scaled down to fit (never padded or enlarged), and it is offloaded when remote storage is on. What gets stored is the image’s resource id.

  • A new upload replaces the stored image, and the old one is deleted once the new id is saved. Saving without a file keeps the stored image.
  • A file that is not an image, or is too large, refuses the save with a field error. The stored image stays. The limit is the site’s maximum upload size, or max_kb on the field: ->image('logo', $label)->set('max_kb', 512).
  • required is met by an image already stored.
  • While a depends master is off, the posted file and the remove box are ignored. The stored image is kept.
  • Uploads and deletes run only for a signed-in admin allowed on the page.
  • An image field cannot take default, sanitize, validate, persist or write_only. It cannot be another field’s depends master. It needs a preference page, not a table store.

osc_settings_image_url() works even when the page is not registered in the current request: it then reads the preference logo in the section folio directly. A page that sets its own section or column must be registered for this to work.

A page can write one row of a table rather than one preference per field:

(new FormSpec('acme.route'))
->title(__('Route', 'acme'))
->store('acme_route', 'pk_i_id') // unprefixed; core applies DB_TABLE_PREFIX
->text('s_label', __('Label', 'acme'))
->register();

The row is addressed by an integer key supplied by your controller, never taken from the request. No key inserts a row, and a key that is not a positive integer is refused. This is why the generic controller does not serve a table-backed page: it needs a controller of yours, one that supplies a row id it has already checked this admin may edit.

A column holds one value, so a translated field goes to the entity’s locale table instead. That is one row per locale, keyed by the entity’s id and the locale code, in the column the field is named after:

(new FormSpec('acme.route'))
->title(__('Route', 'acme'))
->store('acme_route', 'pk_i_id')
->translateTable('acme_route_description', 'fk_i_route_id')
->text('s_label', __('Label', 'acme'))->translate()
->register();

A locale with no row yet gets one. So enabling a locale after the entity was saved does not lose what is typed on its new tab. Without translateTable() a translated field on a table store is refused at registration.

Four hooks, in the order they run:

Hook Kind When
admin_form_before_save filter After validation, before the write. Return the values to store
admin_form_after_save action After a successful write, with the row id
settings_page_saved action After that, for listeners that only care that it saved
admin_form_save_failed action Instead of the above, when the save was refused

Every hook gets the values with secret fields removed, so a listener on someone else’s page cannot read a password out of a payload it did not ask for.

For an effect that belongs to one page, not to anyone listening, declare it on the page instead. It runs once, only after a successful save, and last, after admin_form_after_save, so it sees whatever a listener made of the values:

->onAfterSave(static function (array $values, $id) {
Acme\Cache::flush();
})

This is your own page’s code, not an arbitrary listener, so it gets the values as stored, secret fields included.

The action row counts what has changed since the page loaded. It stays quiet on a form nobody has touched, and shows “3 unsaved changes” on one somebody has. It follows the page as you scroll, so Save stays reachable on a long screen.

A save that changes nothing reports exactly that, instead of claiming success: the store writes only the values that actually differ.

One example already in core: Listings → Locations is hand-rolled. It offers two hooks so a plugin can extend it without owning the page:

Hook Kind When
admin_locations_row_actions filter Building a row’s actions cell. Receives $actions (array keyed by name, starting with edit), $level (country, region or city) and $row (that row’s data). Return the array with your entry added; each value is raw, already-escaped HTML
admin_locations_drawer_fields action Rendering the add/edit drawer, after the built-in fields. Receives $level and $record (null when adding, the row’s data when editing)

A declaration gives you the CSRF check, the capability check, the escaping, the depends handling and the redirect: all written once in core. Move an existing screen the next time you touch it. The Test Payments plugin in Payment gateways shows the result.

Some screens are not a settings form at all: a list with its own actions, a dialog, a panel inside another page. Do not hand-write the markup for those either. Core exposes the same field renderers the declared path uses, so your screen looks like the rest of the admin, and keeps looking that way when the admin theme changes.

osc_admin_form_open(array('page' => 'plugins', 'action' => 'my_save'));
osc_admin_form_section(__('Delivery'));
osc_admin_text(array(
'name' => 'sender_name',
'label' => __('Sender name'),
'value' => $current,
'help' => __('Shown on every outgoing message.'),
));
osc_admin_checkbox(array(
'name' => 'notify',
'label' => __('Email me on each order'),
'checked' => $notify,
));
osc_admin_form_close(array(
array('label' => __('Save'), 'type' => 'submit', 'variant' => 'primary'),
));

osc_admin_form_open() writes the CSRF token for you. A GET form never gets one.

Function What it does
osc_admin_field($spec) One labelled field. Everything below is sugar over it, so this is the only name you must depend on.
osc_admin_text() · osc_admin_number() · osc_admin_select() · osc_admin_textarea() · osc_admin_checkbox() · osc_admin_radio_group() · osc_admin_secret() One field of that type.
osc_admin_tree_picker() The category / location picker.
osc_admin_form_open() · osc_admin_form_close() The form element, the hidden route, the CSRF token, and the submit row.
osc_admin_form_section($title) A titled group of fields.
osc_admin_form_row_open($label) · osc_admin_form_row_close() One labelled row holding several controls.
osc_admin_page_head($title, $actions) The screen title with its action buttons.
osc_admin_action_section() An intro, a status block, and buttons, no form.

osc_admin_field() takes type, name, label, value, help, options, prefix, suffix, width, required, disabled, id and attrs. Pass 'row' => false for the control on its own. Pass 'type' => 'custom' with a render callable to put your own markup inside a normal row.

If the screen edits one record, not a list of preferences (a main column, a rail showing the record’s state, one Save at the foot), build it from the editor components instead: Admin editors.