Skip the admin UI. Ship your plugin.
A fast, standardised way to build WordPress settings pages. List your fields in a PHP array and WP Wireframe builds the whole admin page using WordPress react components. Same clean UI, same field behaviour, same patterns across every plugin you ship.
- One PHP array, full settings page — tabs, sections, and 20+ field types
- Standardised fields — every input looks and behaves the same across plugins and projects
- Native WordPress look — built on
@wordpress/componentsand@wordpress/admin-ui - Laravel-style API —
Settings::get(),Settings::bool(), dot notation - Validation & conditional fields — server-side rules and show/hide logic built right into the field config
- Multi-page, repeaters, import/export, i18n — included out of the box
- Zero JS build — pre-built React app ships with the package
An example of what the settings page looks like
Working starting points you can copy and adapt:
- Field Reference — kitchen-sink demo of every field type, plus an "Example" tab that reads like a real product settings page
- Newsletter Signup — config loaded from
config/settings.php - QuickChat — SaaS-style plugin with its entire config inline in one file
- PHP 8.1+
- WordPress 6.5+
WP Wireframe is a library, not a standalone plugin. Bundle a copy inside your own plugin's vendor/ folder.
Add the GitHub repo to your plugin's composer.json — no Packagist needed:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/tdrayson/wp-wireframe"
}
],
"require": {
"tdrayson/wp-wireframe": "^1.0"
}
}Then install:
composer installAnd require Composer's autoloader from your plugin's main file:
require_once __DIR__ . '/vendor/autoload.php';Composer resolves the latest tagged release. The compiled JS/CSS bundle ships with the package, so there's no build step on your side.
- Download the latest release zip and extract it so the folder sits at
your-plugin/vendor/wp-wireframe/. - Require the autoloader from your plugin's main file:
require_once __DIR__ . '/vendor/wp-wireframe/vendor/autoload.php';That's it — no separate plugin to install or activate. If multiple plugins each bundle their own copy, they share one class definition at runtime and boot independently under their own prefixes.
<?php
/**
* Plugin Name: My Plugin
* Description: A plugin with settings powered by WP Wireframe.
* Version: 1.0.0
*/
if (! defined('ABSPATH')) {
exit;
}
// Composer autoloader — use the manual path
// (vendor/wp-wireframe/vendor/autoload.php) if you installed without Composer.
require_once __DIR__ . '/vendor/autoload.php';
add_action('init', function () {
Wireframe\App::boot([
'prefix' => 'my-plugin',
'page_title' => __('My Plugin', 'my-plugin'),
'option_key' => 'my_plugin_settings',
'config' => __DIR__ . '/config/settings.php',
]);
});<?php
return [
'tabs' => [
[
'id' => 'general',
'title' => __('General', 'my-plugin'),
'sections' => [
[
'id' => 'main',
'title' => __('Main Settings', 'my-plugin'),
'description' => __('Configure the basics.', 'my-plugin'),
'fields' => [
[
'id' => 'site_name',
'type' => 'text',
'label' => __('Site Name', 'my-plugin'),
'required' => true,
'columns' => 6,
'args' => ['placeholder' => __('My Website', 'my-plugin')],
],
[
'id' => 'contact_email',
'type' => 'email',
'label' => __('Contact Email', 'my-plugin'),
'columns' => 6,
],
[
'id' => 'notifications',
'type' => 'toggle',
'label' => __('Enable Notifications', 'my-plugin'),
'description' => __('Send alerts when settings change.', 'my-plugin'),
'default' => true,
],
],
],
],
],
],
];Every Settings:: call takes your plugin's option_key as the first argument, so multiple plugins sharing the framework can't collide.
use Wireframe\Settings;
$name = Settings::get('my_plugin_settings', 'site_name', 'Default');
$email = Settings::string('my_plugin_settings', 'contact_email');
if (Settings::bool('my_plugin_settings', 'notifications')) {
// send notification...
}That's it. Activate your plugin and the settings page appears in the admin menu.
WP Wireframe lives inside your plugin as a bundled dependency:
my-plugin/
├── my-plugin.php # Boot call (~15 lines)
├── config/
│ └── settings.php # Field definitions (or pass inline)
└── vendor/
└── wp-wireframe/ # Extracted from the release zip
├── src/
└── vendor/ # rakit/validation
Wireframe\App::boot([
'prefix' => 'my-plugin', // Required. Derives slugs, hooks, handles.
'page_title' => 'My Plugin', // Admin menu label.
'option_key' => 'my_plugin_settings', // wp_options key for storage.
'config' => __DIR__ . '/config/settings.php', // Path to a config file — OR an inline array.
'version' => '1.0.0', // Plugin version.
'menu_icon' => 'dashicons-admin-generic',
'menu_position' => 80,
'capability' => 'manage_options',
]);Everything except prefix has a sensible default. config accepts either a PHP file path or an inline array — see Config Structure.
Register multiple admin pages from a single plugin:
Wireframe\App::boot([
'prefix' => 'my-plugin',
'option_key' => 'my_plugin',
'pages' => [
[
'page_title' => __('General', 'my-plugin'),
'config' => __DIR__ . '/config/general.php',
'menu_icon' => 'dashicons-admin-generic',
],
[
'page_title' => __('Advanced', 'my-plugin'),
'config' => __DIR__ . '/config/advanced.php',
'menu_icon' => 'dashicons-admin-tools',
],
],
]);Each page's config accepts either a file path or an inline array. Each page gets its own option key, REST endpoint, and admin menu entry.
Fields use a flat structure with promoted common keys. Type-specific settings go in args.
return [
'title' => 'Page Title', // Optional. Shown in the header.
'subtitle' => 'A short description', // Optional. Shown below the title.
'tabs' => [
[
'id' => 'tab_id',
'title' => 'Tab Label',
'sections' => [
[
'id' => 'section_id',
'title' => 'Section Title',
'description' => 'Helpful description text.',
'fields' => [
[
'id' => 'field_id',
'type' => 'text',
'label' => 'Field Label',
'description' => 'Help text below the field.',
'default' => '',
'required' => false,
'validation' => 'min:3|max:100',
'columns' => 6, // 1-12 grid columns
'conditions' => [ // Conditional visibility
'all' => [
['field' => 'other_field', 'operator' => 'truthy'],
],
],
'args' => [ // Type-specific settings
'placeholder' => 'Enter text',
],
],
],
],
],
],
],
];These are common to all field types:
| Key | Description | Default |
|---|---|---|
id |
Unique field identifier | Required |
type |
Field type | 'text' |
label |
Display label | '' |
description |
Help text below the field. Supports {field_id} tokens that interpolate live values (e.g. 'URL: https://example.com/{slug}'). |
'' |
default |
Default value | null |
required |
Whether the field must have a value | false |
validation |
Rakit validation rules string | '' |
columns |
Grid width (1-12) | 12 |
conditions |
Conditional visibility rules | null |
Settings unique to specific field types — placeholder, rows, options, multiple, subfields, mode, mime_types, etc.
Tabs and sections are optional:
// Simplest — just fields (no tabs, no sections)
return [
'fields' => [
['id' => 'name', 'type' => 'text', 'label' => 'Name'],
['id' => 'email', 'type' => 'email', 'label' => 'Email'],
],
];
// With sections, no tabs
return [
'sections' => [
[
'id' => 'main',
'title' => 'Main Settings',
'fields' => [
['id' => 'name', 'type' => 'text', 'label' => 'Name'],
],
],
],
];No tabs = no tab bar. No sections = fields render in a single card.
| Type | Description | Stored as |
|---|---|---|
text |
Single-line text input | string |
email |
Email with format validation | string |
url |
URL with format validation | string |
password |
Masked input | string |
textarea |
Multi-line text | string |
hidden |
Not rendered, round-trips a value | string |
| Type | Description | Stored as |
|---|---|---|
select |
Dropdown. Add 'multiple' => true in args for searchable tag input |
string or array |
radio |
Radio button group | string |
checkboxes |
Multiple checkboxes | array |
toggle |
On/off switch | bool |
checkbox |
Single checkbox | bool |
| Type | Description | Stored as |
|---|---|---|
number |
Numeric input with min/max/step | int or float |
range |
Slider with tooltip | int or float |
| Type | Description | Stored as |
|---|---|---|
date |
Date picker (dropdown calendar) | string (YYYY-MM-DD) |
time |
Time input (24h) | string (HH:MM) |
color |
Color swatch + picker dropdown | string (#hex) |
| Type | Description | Stored as |
|---|---|---|
editor |
TinyMCE WYSIWYG editor | string (HTML) |
code_editor |
CodeMirror with syntax highlighting | string |
Supported code modes: css, js, html, php, json, xml, sql
| Type | Description | Stored as |
|---|---|---|
file |
Media Library picker. Add 'multiple' => true in args for multi-select |
array (attachment IDs) |
| Type | Description | Stored as |
|---|---|---|
repeater |
Add/remove/reorder rows of subfields | array of objects |
| Type | Description | Stored as |
|---|---|---|
html |
Read-only display block (info, success, warning, error variants) | — |
export |
Download settings as JSON | — |
import |
Upload JSON to restore settings | — |
[
'id' => 'categories',
'type' => 'select',
'label' => 'Categories',
'default' => ['news'],
'columns' => 6,
'args' => [
'multiple' => true,
'options' => [
'news' => 'News',
'blog' => 'Blog',
'reviews' => 'Reviews',
],
],
],[
'id' => 'redirects',
'type' => 'repeater',
'label' => 'Redirects',
'args' => [
'sortable' => true,
'collapsible' => true,
'duplicate_row' => true,
'max_rows' => 50,
'add_label' => 'Add redirect',
'empty_message' => 'No redirects configured.',
'title_template' => '{from} → {status}',
'subfields' => [
[
'id' => 'from',
'type' => 'text',
'label' => 'From path',
'required' => true,
'columns' => 4,
],
[
'id' => 'to',
'type' => 'text',
'label' => 'To URL',
'required' => true,
'columns' => 4,
],
[
'id' => 'status',
'type' => 'select',
'label' => 'Status',
'default' => '301',
'columns' => 4,
'args' => [
'options' => [
'301' => '301 Permanent',
'302' => '302 Temporary',
],
],
],
],
],
],[
'id' => 'debug_mode',
'type' => 'toggle',
'label' => 'Debug Mode',
'default' => false,
],
[
'id' => 'log_level',
'type' => 'select',
'label' => 'Log Level',
'default' => 'error',
'columns' => 6,
'conditions' => [
'all' => [
['field' => 'debug_mode', 'operator' => 'truthy'],
],
],
'args' => [
'options' => [
'error' => 'Errors only',
'info' => 'Info & above',
'debug' => 'Everything',
],
],
],Supported condition operators: equals, not_equals, truthy, falsy, in, not_in, contains, not_contains, starts_with, ends_with, is_empty, is_not_empty, gt, gte, lt, lte, between
Combine with all (AND) or any (OR).
[
'id' => 'username',
'type' => 'text',
'label' => 'Username',
'required' => true,
'validation' => 'min:3|max:20|regex:/^[a-z0-9_]+$/',
],Built-in validation is automatic per field type (email format, URL format, numeric bounds, color hex, date/time format). Add custom rules via the validation key using rakit/validation syntax.
Fields use a 12-column grid. Set columns to control width:
['id' => 'field_a', 'type' => 'text', 'label' => 'Half width', 'columns' => 6],
['id' => 'field_b', 'type' => 'text', 'label' => 'Full width', 'columns' => 12],
['id' => 'field_c', 'type' => 'text', 'label' => 'Third width', 'columns' => 4],Programmatically manipulate a config array before passing it to App::boot():
use Wireframe\Config;
// Add a field after another
$config = Config::addFieldAfter('site_name', [
'id' => 'subtitle', 'type' => 'text', 'label' => 'Subtitle',
], $config);
// Add a field before another
$config = Config::addFieldBefore('email', [
'id' => 'prefix', 'type' => 'text', 'label' => 'Prefix',
], $config);
// Modify an existing field
$config = Config::modifyField('site_name', [
'required' => true,
'validation' => 'min:5',
], $config);
// Remove a field
$config = Config::removeField('tagline', $config);
// Add a section to a tab
$config = Config::addSection('general', [
'id' => 'social', 'title' => 'Social Links', 'fields' => [...],
], $config);
// Add or remove tabs
$config = Config::addTab(['id' => 'integrations', 'title' => 'Integrations', 'sections' => [...]], $config);
$config = Config::removeTab('advanced', $config);
$config = Config::removeSection('deprecated', $config);Ideal for building up an inline config you pass to App::boot(), or for loading a base config file and mutating it before boot:
$config = require __DIR__ . '/config/settings.php';
$config = Config::addFieldAfter('site_name', [
'id' => 'powered_by', 'type' => 'text', 'label' => 'Powered By',
], $config);
Wireframe\App::boot([
'prefix' => 'my-plugin',
'config' => $config,
]);Every Settings:: call takes your plugin's option_key as the first argument. This makes the facade multi-tenant-safe: many plugins can share the same framework install without stepping on each other's saved options.
use Wireframe\Settings;
$opt = 'my_plugin_settings'; // matches what you passed to App::boot()Settings::get($opt, 'field_id'); // any value, with dot notation
Settings::get($opt, 'field_id', 'fallback'); // with default
Settings::get($opt, 'repeater.0.subfield'); // dot notation into repeaters
// Type-safe getters
Settings::bool($opt, 'notifications'); // always bool
Settings::int($opt, 'max_posts'); // always int
Settings::float($opt, 'opacity'); // always float
Settings::string($opt, 'site_name'); // always string
Settings::array($opt, 'post_types'); // always array
Settings::json($opt, 'config_json'); // decoded JSONSettings::set($opt, 'site_name', 'New Name');
Settings::toggle($opt, 'notifications');
Settings::increment($opt, 'view_count');
Settings::decrement($opt, 'credits', 5);
Settings::push($opt, 'allowed_ips', '10.0.0.1');
Settings::forget($opt, 'api_key');Settings::has($opt, 'api_key'); // exists and not null
Settings::exists($opt); // any settings saved at all
Settings::filled($opt, 'site_name', 'fallback'); // non-empty or fallbackSettings::only($opt, 'site_name', 'email'); // just these keys
Settings::except($opt, 'api_key', 'password'); // everything except
Settings::all($opt); // raw saved values
Settings::resolved($opt); // merged with defaultsSettings::when($opt, 'api_key', fn($key) => initService($key));
Settings::transform($opt, 'color', fn($c) => ltrim($c, '#'));
Settings::getOrSet($opt, 'counter', 0);
Settings::pull($opt, 'temp_token'); // get and removePer-plugin hooks fire under your own prefix:
// After settings are saved
add_action('my-plugin/settings_saved', function (array $values, string $pageId) {
// $values = sanitized settings array
}, 10, 2);
// After settings are reset
add_action('my-plugin/settings_reset', function (string $pageId) {
// clear caches, etc.
});The field type registry is shared across every plugin, so its filter is global:
add_filter('wp-wireframe/field_types', function (array $types) {
$types['my_custom'] = MyCustomField::class;
return $types;
});Create a class extending BaseField:
use Wireframe\Framework\Fields\BaseField;
class MyCustomField extends BaseField
{
public static function type(): string
{
return 'my_custom';
}
public static function defaultRules(array $args): string
{
return 'required'; // Rakit validation rules
}
public static function sanitize(mixed $value, array $args): mixed
{
return sanitize_text_field($value);
}
public static function validate(mixed $value, array $args): ?string
{
// Return null if valid, or an error message string
return null;
}
}Register via filter:
add_filter('wp-wireframe/field_types', function (array $types) {
$types['my_custom'] = MyCustomField::class;
return $types;
});Settings are managed via REST endpoints:
| Method | Endpoint | Description |
|---|---|---|
GET |
/{prefix}/v1/settings/{pageId} |
Get config + values |
POST |
/{prefix}/v1/settings/{pageId} |
Validate, sanitize, save |
DELETE |
/{prefix}/v1/settings/{pageId} |
Reset to defaults |
All endpoints require manage_options capability (or your configured capability).
WP Wireframe ships its own translations for UI strings (Save, Reset, Cancel, etc.) under the wp-wireframe text domain — these load automatically.
Your plugin's config strings use your own text domain:
'label' => __('Site Name', 'my-plugin'),Load your plugin's text domain the usual WordPress way (or rely on WordPress's auto-loading if your .mo files follow the languages/{domain}-{locale}.mo convention). WP Wireframe does not do this for you.
To work on the package itself:
git clone https://github.com/tdrayson/wp-wireframe.git
cd wp-wireframe
composer install
npm install
npm run start # watch mode
npm run build # production build → src/assets/GPL-2.0-or-later
Built by Taylor Drayson.
Uses @wordpress/components, @wordpress/admin-ui, and rakit/validation.
