Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/manpreetdev21/metafields-custom-fields/archive/refs/heads/main.zipField groups, meta boxes and a developer-friendly field API for WordPress.
- Version: 1.8.0
- Author: Manpreet Singh
- Requires PHP: 8.1
- Requires WordPress: 6.8
- License: GPL-2.0-or-later
- Text Domain:
metafields-custom-fields - Namespace:
WPCMB
Build status
| Phase | Scope | Status |
|---|---|---|
| 1 | Core architecture | ✅ Done |
| 2 | Admin UI | ✅ Done |
| 3 | Database | ✅ Done |
| 4 | Field API | ✅ Done |
| 5 | Field types | ✅ Done |
| 6 | Repeater | ✅ Done |
| 7 | Flexible content | ✅ Done |
| 8 | Frontend forms | ✅ Done |
| 9 | REST API | ✅ Done |
| 10 | Blocks | ✅ Done |
| 11 | Integrations | ✅ Done |
| 12 | Testing | ✅ Done |
Architecture
Four moving parts, nothing more:
metafields-custom-fields.php Bootstrap. The only procedural file.
└─ Container Lazy service container, shared instances, auto-wiring.
└─ Plugin Singleton. Owns the container, collects and boots modules.
└─ Module Abstract base. One per feature. boot() registers hooks.
└─ Bootable The contract every module implements.
└─ Installer Activation / deactivation. Static — runs outside the lifecycle.
Request flow
- WordPress loads
metafields-custom-fields.php, which defines constants and registers an autoloader (Composer's ifvendor/exists, a PSR-4 fallback otherwise). wpcmb()->init()hooksPlugin::boot()toplugins_loadedat priority 5 — early enough that other plugins can act onwpcmb/booted.boot()runs the module list through thewpcmb/modulesfilter, resolves each class from the container, skips any whoseis_enabled()returns false, and callsboot()on the rest.wpcmb/bootedfires. Add-ons build on top from here.
Why a container. Modules never construct their collaborators, they pull them from the container, so a field type in phase 5 can be swapped in tests or replaced by a site without editing plugin code. Auto-wiring keeps registration to zero for the common case: any Module subclass is constructed with the container automatically.
Why one singleton. Plugin only. WordPress gives one plugin lifecycle per request and hook callbacks need a stable target. Everything else is a plain object with an explicit dependency.
Why is_enabled(). Admin-only modules should not cost anything on front-end requests. The gate runs before boot(), so a skipped module registers no hooks and loads no assets.
Installation (development)
composer install # dev tooling + autoloader
composer dump-autoload -o # autoloader only
The plugin runs without Composer via the fallback autoloader in the bootstrap.
Where field groups live
Field groups are a private post type (wpcmb_field_group), not a custom table. WordPress already supplies the list table, search, pagination, sorting, revisions, autosave, trash, capabilities and WXR export for a post type; a bespoke schema would reimplement all of that and add a migration surface for no gain at field-group scale.
The whole group configuration — fields, location rules, display settings — is one JSON value in _wpcmb_config post meta. One meta value means one read, and it is registered through wp_post_revision_meta_keys so revisions cover the group as a unit.
Two derived rules follow from this:
- Active = published. A draft group is inactive. There is no second "active" flag to fall out of sync with the post status.
- Every capability maps to
manage_options(filter:wpcmb/capability). No role is modified, so nothing needs granting on activation or cleaning up on uninstall.
Where field values live
Also in native storage: post, term, user and comment meta, or prefixed options for options pages and widgets. There is no value table. A value is therefore already visible to WP_Query's meta_query, the core REST meta endpoints, WP-CLI, migration tools and a plain database export — with or without this plugin installed.
A value is stored under the field's own name as one row. Structured values (repeater rows, groups, flexible content) are stored whole rather than flattened into a row per sub value: one row means one read for a repeater of any size, and deleting it cannot orphan sub values. The trade-off is that sub values are not individually queryable by meta_query; if that is ever needed, mirror those sub fields into flat companion keys rather than flattening canonical storage.
Two details that are easy to get wrong and are covered by tests/values-check.php:
- Slashing.
update_metadata()runswp_unslash()on whatever it is given, soValuesslashes on the way in. Written raw, a value likeC:\Users\testloses its backslashes. Options are not unslashed and so are written as-is. - Unstored vs. empty. Reads distinguish "never saved" from "saved as an empty string", so a field's default applies only until it is first saved — not every time it is cleared.
Field names resolve to field definitions through an index over the cached groups, so no companion _key meta row is written and values stay readable when a group's location no longer matches.
Object references
Every entry point accepts loose identifiers and resolves them once through ObjectRef:
| Identifier | Resolves to |
|---|---|
| (nothing) | the current post |
42, '42', 'post_42', WP_Post |
post 42 |
'term_5', WP_Term |
term 5 |
'user_2', WP_User |
user 2 |
'comment_9', WP_Comment |
comment 9 |
'options', 'options_theme_settings' |
an options page |
Anything unrecognised resolves to an invalid reference that reads and writes nothing, rather than silently addressing post 0. The wpcmb/object_ref filter lets a custom location claim its own scheme.
Template functions
wpcmb_get_field( 'byline' ); // current post
wpcmb_get_field( 'byline', $post_id );
wpcmb_get_field( 'colour', 'term_5' );
wpcmb_get_field( 'api_key', 'options' );
wpcmb_the_field( 'byline' ); // echoed, escaped
wpcmb_get_fields( $post_id ); // every applicable value
wpcmb_has_field( 'byline', $post_id ); // stored? ignores defaults
wpcmb_update_field( 'byline', 'Manpreet', $post_id );
wpcmb_delete_field( 'byline', $post_id );
wpcmb_get_field_object( 'byline' ); // the definition
wpcmb_get_field_groups( $post_id ); // groups on this object
wpcmb_validate_values( $values, $post_id ); // errors keyed by field name
These are the only global functions the plugin defines — thin wrappers over container services, so themes get a short procedural API and the implementation stays swappable.
Registering groups in code
add_action( 'wpcmb/register_field_groups', function () {
wpcmb_register_field_group( array(
'key' => 'group_page_details',
'title' => 'Page Details',
'fields' => array(
array( 'name' => 'byline', 'label' => 'Byline', 'type' => 'text', 'default' => 'Staff' ),
),
'location' => array(
array( array( 'param' => 'post_type', 'operator' => '==', 'value' => 'page' ) ),
),
) );
} );
Registered groups are never written to the database, so they can live in version control. A stored group with the same key wins — which is what makes "export to PHP, keep editing in the admin" work. The hook fires lazily on the first group read, not on init, so a field read before init still sees them and a request that reads nothing pays nothing.
Location rules
Rules are an OR list of AND groups: the group shows when every rule in at least one rule group matches. Three deliberate behaviours:
- A group with no rules matches nothing. An unconfigured group stays out of the way instead of appearing site-wide.
- List-valued parameters mean "is one of". A user's roles, the taxonomies a post has terms in.
!=then means "is none of". - An inapplicable parameter never matches, negated or not. Otherwise "post type is not page" would start matching users, terms and comments.
Context computes parameter values on demand and memoises them. A typical rule set tests one or two parameters, and several of the others cost a term or template query — building a full description of the screen up front would run those queries to answer questions nobody asked.
Two rules that could never match
A rule saved with no value. A value control with a fixed set of choices is
a <select>, and a select always shows something: given a value that is not
one of its options — a rule just added, or one whose parameter has just
changed — the browser showed the first option while the stored value stayed
empty. The screen read Post Type is Posts, the summary agreed, and the group
saved as post_type == "", matched nothing and appeared on no screen at all.
The values are now reconciled before the state is written, which is the part that matters: doing it while the controls are drawn corrects the screen and leaves the saved value behind, because the state is synced to the form before the controls are built.
A group saved before this repairs itself the next time its editor is opened and saved.
comment matched the wrong screen and never the right one. The rule
offers post types, so "Comment is post" means the comments on posts of that
type — but it read the post type of the referenced object, which is only
set when the reference is a post. So it matched post edit screens, where it
was never meant to apply, and never matched a comment screen, where it was.
Both are covered by tests/integration.php, which now walks every location
rule parameter against a real object of its own kind and checks three things
for each: that it resolves, that a rule naming that value matches, and that a
rule naming something else does not.
Field types
50 types, 9 classes. Types are grouped by behaviour, not by name. Thirty single-line inputs differ by an HTML type attribute and a sanitizer — thirty classes would be thirty places to fix the same bug.
| Class | Types |
|---|---|
Input |
text, number, email, url, password, hidden, range, color, date, time, datetime, phone, slug, uuid, currency |
Textarea |
textarea |
Choice |
select, checkbox, radio, toggle, button_group, rating, country, state |
Media |
file, image, gallery, video, audio |
Relationship |
post_object, page_link, relationship, taxonomy, user |
Editor |
wysiwyg, code, json, html |
Link |
link |
Enhanced |
icon, map, signature, qr, barcode, embed, address |
Structure |
message, tab, accordion, group |
Add one with wpcmb/register_field_types; a custom type gets the wrapper, conditional logic, validation display, layout and a settings UI for free, because field types own only their control.
What is deliberately not bundled
Date, time, colour and number pickers are the browser's. Rich text is wp_editor(); code editing is wp_enqueue_code_editor(). Media is the WordPress media library, which already does upload, drag-drop, multi-select, alt text and image editing — a second one would not share the user's uploads. Country and state lists are short stubs that defer to WooCommerce or to wpcmb/field/choices, rather than carrying a second ISO table. Map tiles, QR and barcode encoders need a provider, so those fields render their stored text until a site supplies one through wpcmb/field/enhanced_config — better than a key-less map that silently shows nothing.
Rules that hold across every type
- Only offered values are stored. Choice and relationship fields intersect the submission with the options actually rendered, so a hand-built request cannot store a value the form never contained.
- Sanitizers are idempotent. A value is cleaned on the edit screen to validate it and again in the value pipeline on write. Non-idempotent sanitizers would progressively strip stored values.
- Validation runs before sanitizing. A sanitizer discards what it cannot make safe; validating afterwards would see a malformed email as an empty optional field and report nothing, so the user would lose the value with no explanation.
- Structural fields store nothing. Tabs, accordions and messages never create an empty meta row and are never validated.
- Only native controls' formats are accepted. A date field stores an ISO date or nothing — never free text from a crafted request. A signature stores a PNG data URI or nothing, so it cannot smuggle a scriptable SVG into a
src. - Escaping differs by type, on purpose.
wysiwygiswp_kses_post();htmlis verbatim only for users withunfiltered_html;codeandjsonare stored raw because this plugin never renders them as markup.
Repeater
Rows are one indexed array under the repeater's own name — the same whole-value storage everything structured uses. A repeater of any size is one meta row and one read.
Nesting needs no code. Sub fields render through the shared renderer, which owns input naming, so repeater → repeater → group produces correctly nested names without any type knowing how deep it is. Sanitizing recurses the same way.
New rows come from the server, not from a cloned template. A cloned wp_editor() is a dead editor: the markup is only half of what that control is. The wpcmb_repeater_row endpoint renders a fresh row exactly like the ones already on the page. Duplicating an existing row is the one case that clones, and it re-runs field initialisation afterwards.
Row order survives gaps. The browser posts rows keyed by their on-screen index, which has gaps once rows are removed. Keys are sorted numerically then discarded — a string sort would put row 100 before row 12.
Every row has every declared sub field, stored as null when absent, so a template can index into a row without checking each key first. Undeclared columns are dropped.
Reordering is keyboard-accessible. Move up/down buttons are the baseline; drag-to-reorder is the enhancement layered on top. Both call the same renumbering code.
CSV
Per-repeater import and export, opt-in via a field setting.
- Export takes what is on screen, including unsaved edits, and only columns with an honest single-cell representation — a nested repeater or gallery is skipped rather than flattened into something that cannot be imported back.
- Cells beginning
=,+,-or@are prefixed with a quote, so an exported=HYPERLINK(...)is text in a spreadsheet rather than a live formula. - Import appends and matches columns by header name, dropping unknown ones. Nothing is written until you save the screen — an import that stored directly would be an unreviewable, unundoable change.
The ceiling
Every row renders. Pagination is not bolted onto a single form post, because a paginated repeater must save per row — a per-row AJAX save endpoint and a concurrency story. Rows past collapse_after (default 10) start collapsed, which keeps the screen usable well past where the DOM alone would be the problem. Marked with a ponytail: comment.
Flexible content
Flexible extends Repeater, because that is what it is: a repeater whose rows differ in shape. Order, gaps, limits, nesting, row chrome, reordering and the server-rendered new row are inherited, not rewritten.
Each row records its layout under _layout, so a layout can be renamed, have its fields reordered, or gain a sibling without touching stored rows.
foreach ( wpcmb_get_field( 'sections' ) as $row ) {
switch ( $row['_layout'] ) {
case 'hero_banner':
echo esc_html( $row['heading'] );
break;
case 'cards':
foreach ( $row['cards'] as $card ) { /* … */ }
break;
}
}
Layouts carry an icon, a category (both used to group the picker) and an optional max uses, enforced on save rather than only in the picker.
Two rules about rows whose layout has gone:
- On screen, the row still renders, marked Unknown layout, with its values preserved in hidden inputs — so saving the page does not destroy data the user has not seen yet.
- On save, the row is dropped rather than coerced into another layout. Re-interpreting it would scramble its values into fields that were never meant to hold them.
The layout picker is a keyboard-reachable menu grouped by category, closing on Escape and on outside click. Collapsed rows show a live preview — the first non-empty text value — because a column of rows all saying "Row 3" is unnavigable.
Options pages
Set a group's location to Options Page is site-settings and the screen
appears by itself. No add_menu_page, no render callback, nothing in the
theme:
Location: Options Page is site-settings
↓
Admin menu → Site Settings (site-settings) ← created, rendered and saved
The rule is the only place the page is configured, so there is no second list to keep in step: delete the rule and the page goes with it. Two groups naming the same slug share one screen, each as its own panel.
Where it appears. A top-level menu entry directly below Settings, labelled with the group's title and the slug underneath it — a site can have several of these, built from whatever somebody typed into a rule, and "Site Options" alone says neither which plugin made it nor which slug it stores against. The page title carries the plugin's name for the same reason.
Reading the values needs no new API. An options page is an object like any
other, addressed by options_ plus the slug:
wpcmb_get_field( 'company_name', 'options_site-settings' );
wpcmb_get_fields( 'options_site-settings' );
Values are stored as options named wpcmb_{slug}_{field}, autoloaded off, so a
field called siteurl cannot collide with anything of WordPress's. They are
also available over REST at /wpcmb/v1/values/option/{slug}, behind
manage_options.
Everything else the plugin does still applies, because the page renders through the same renderer as a meta box: conditional logic, repeaters, flexible content, validation and the save gate all work here without knowing this screen exists. A required field on an options page blocks the save the same way it blocks a publish.
Two filters, for sites that want something different:
// Move one page under Settings, or anywhere else.
add_filter( 'wpcmb/options_page/args', function ( $args, $slug ) {
if ( 'site-settings' === $slug ) {
$args['parent'] = 'options-general.php';
}
return $args;
}, 10, 2 );
// Create a page no rule points at, and let the plugin render and save it.
add_filter( 'wpcmb/options_pages', function ( $pages ) {
$pages['imported'] = array( 'title' => 'Imported Settings', 'order' => 0 );
return $pages;
} );
wpcmb/options_page/saved fires after each save, with the slug, the submitted
values and any validation errors.
Front-end forms
[wpcmb_form group="group_contact0001"]
[wpcmb_form group="group_contact0001" action="post" post_type="post" guests="1" redirect="/thanks/"]
wpcmb_form( array( 'group' => 'group_contact0001', 'action' => 'post' ) );
wpcmb_form_shortcode( 'group_contact0001' ); // the shortcode string
The editor's Display Settings box shows both shortcodes, ready to copy.
What a bare shortcode does
[wpcmb_form group="group_abc123"]
That creates a draft post carrying the submitted values, from a signed-in visitor. Both halves of that used to be wrong:
actiondefaulted tovalues, which needs an object to store against, whileobjectdefaulted tonew, which is not an object. The pair described a form with nowhere to put anything: it rendered correctly and then answered "That could not be saved. Please try again." on submit, for everyone including administrators.actionnow defaults topost, which is the only thing a form can do without being told what it is editing. The combination that still cannot work says so when the form renders, rather than when somebody fills it in.- The editor offered that snippet under the words "render this group as a public form", but a form takes submissions from signed-in visitors only unless told otherwise — so the first thing a visitor saw was "You need to sign in to use this form." The editor now shows two snippets and says which is which.
For a form anybody may submit:
[wpcmb_form group="group_abc123" guests="1"]
guests stays off by default on purpose. A group key is not a secret, and
turning every pasted shortcode into an anonymous write endpoint is not a
default anyone should inherit. Uploads need an account regardless — see
wpcmb/form/allow_uploads.
Where submissions go
Each group used as a form gets its own post type for its submissions, named from the plugin's prefix and the group's own name:
Group "Contact" → wpcmb_contact_1f4a → Meta Boxes → Contact
One type per group rather than one for all of them: submissions to a contact form and to a job application have different fields, different columns and different people reading them, and a shared type mixes them into one list that suits neither. The four characters are the start of the group's key — a post type may be twenty characters and no more, so the name is cut to fit and the fragment keeps "Contact" and "Contact Us" apart. The readable name is in the labels, which is what anybody sees.
A type is registered once it holds something. Nothing needs registering for a submission to be stored — a post type is only a string in a row — so a group that has never been submitted adds no menu entry, and the first submission makes one appear.
Each submission records the group it came from and the page it was submitted from, and the edit screen shows the values read-only, labelled by the group's fields. Read-only on purpose: the group's location rules point at wherever its form is, not at this screen, so editable controls would offer a save that no rule would honour.
| Status | publish. The type is not viewable, so nothing is exposed — and leaving records as drafts labels every one of them "Draft" in a list where the word means nothing. A form pointed at real content still cannot publish it. |
| Author | Whoever was signed in, or nobody. The list shows A visitor for an anonymous submission. |
| Add New | Denied. Submissions arrive from forms; an Add New button offers an empty record with no form behind it. |
Override it per form with post_type="something_else", or globally with
wpcmb/submissions/post_type_args.
Repeaters on a public form
Repeater rows are rendered by the server, not cloned in the browser, because a
cloned wp_editor() is a dead editor. That means adding a row is a request,
and on the front end the caller is a visitor with no editing capability — so
the Add row button did nothing at all out there, and repeater.js was not
even loaded.
Both are fixed, and the request is authorised by the form's own signed configuration rather than by a capability:
| The request carries | Result |
|---|---|
| A valid signature, for a field of that group | The blank row |
| Nothing | 403 |
| A tampered signature | 403 |
| A valid signature, but a field from another group | 403 |
What a visitor can obtain this way is the blank markup of a field on a form the site chose to publish, which is already in the page they are looking at. CSV import and export stay behind the editing capability, so those two buttons are not drawn on a public form at all rather than being drawn and answering 403.
Fields are drawn by the same renderer the admin uses, so a field type behaves identically on both sides and nothing is implemented twice. The form is a real <form> with a real action: with JavaScript blocked it posts, redirects and reports its result normally. The script only adds submitting without a reload and showing errors in place.
The security model
The form's configuration — which group, what it creates, what status, where it redirects, who gets notified — travels with the submission in a signed field. Without that, any visitor could edit the markup and post back post_status=publish or their own notification address.
wp_hash()signs the payload against the site's own salts, so nothing needs storing and a signature is worthless on another site.- After verification the payload is intersected with the known defaults, so a correctly signed payload still cannot introduce settings the plugin does not define.
- A public form never publishes.
draftis the default and anything beyondpendingis refused unless the submitter canpublish_posts. - Guests are refused unless the form opts in with
guests="1". - Uploads require
upload_files. A public upload endpoint is a different security problem from a public form and should be a deliberate decision — override withwpcmb/form/allow_uploads. - Redirects go through
wp_validate_redirect(), so a form cannot bounce a visitor off-site.
Spam protection
No captcha, nothing for a person to prove:
- A honeypot field, moved off-screen with CSS rather than
hidden— form-filling scripts skip fields the browser reports as hidden.aria-hiddenandtabindex="-1"keep it away from screen readers and keyboard users. - A signed timestamp. Submissions faster than
MIN_SECONDSare refused, and the signature means the timestamp cannot simply be back-dated.
A rejected submission gets the same wording as a genuine failure. Telling a bot which check it failed is free help for whoever wrote it.
Template override
Copy templates/form.php to metafields-custom-fields/form.php in your theme, or filter wpcmb/form/template.
REST API
Most of it is core's. Fields are registered as typed meta with a JSON Schema, so values appear on the endpoints they belong to:
GET /wp/v2/pages/12?context=edit → { "meta": { "byline": "Manpreet", … } }
POST /wp/v2/pages/12 → { "meta": { "byline": "New" } }
That buys authentication, per-object permissions, request validation, type casting and self-documenting schema output — all maintained by WordPress. A bespoke value endpoint would reimplement every one of those and still not appear alongside the post it describes.
Which object types a group registers against comes from its own location rules, so a group targeting pages does not put its fields on posts or users.
Two routes remain, for the things core cannot know about:
| Route | Method | Permission |
|---|---|---|
/wpcmb/v1/field-groups |
GET | manage_options |
/wpcmb/v1/field-groups/{key} |
GET | manage_options |
/wpcmb/v1/values/{type}/{id} |
GET | can read that object |
/wpcmb/v1/values/{type}/{id} |
POST | can edit that object |
{type} is post, term, user, comment or option — the last covering options pages, which have no core object to hang meta off.
Permissions
Fields\Permissions is the single answer to "who may read or write these values", used by the edit screens, the front-end forms and REST alike. Values inherit the permissions of the object they belong to — a field on a post is readable by whoever may read that post and writable by whoever may edit it. The plugin never invents a looser rule.
Schema
Schemas are derived from field definitions, not hand-written, so adding a sub field to a repeater updates the endpoint's contract with it. OPTIONS on any route returns the full description — that is the documentation, and unlike a hand-maintained route list it cannot go stale.
- Types map through:
number→number,toggle→boolean,image→integer,checkbox/gallery→array,link/address/group→object. - Formats are declared (
email,uri,date-time,hex-color), so core rejects malformed values before plugin code runs. - Choice fields declare an
enum— including the empty string, or an optional field could never be cleared. - Repeaters describe their rows all the way down.
- Flexible content deliberately does not merge every layout's properties into one shape: that would let a row of one layout claim another's fields and still validate. The item schema constrains
_layoutto the known names and leaves the rest open. - Structural fields (message, tab, accordion) are never exposed — reads and writes see exactly the same set.
Blocks
Tick Register this group as a block in the field group editor and give it a name. The group becomes a Gutenberg block in a "Custom Meta Box" category, with an icon, description, keywords, chosen supports and optional inner blocks.
Values live in the block's own attributes, not in post meta. That is what makes a block portable: copy it to another post, another site or a synced pattern and its content travels with it. Meta would tie it to the object it was first placed on.
No build step. blocks.js is written against wp.element directly rather than JSX, so what ships is what runs and a bug is debuggable in the browser without a source map.
The editor UI is the same PHP-rendered form the admin uses, fetched over AJAX and injected into the block. A field type works in a block the moment it works anywhere else — repeaters, flexible content and conditional logic included — rather than needing a second implementation of fifty field types in React.
Rendering
render_callback only; nothing is saved into post content, so a template change takes effect on posts that already exist instead of invalidating them.
Template resolution, first match wins:
metafields-custom-fields/blocks/{block-name}.phpin the thememetafields-custom-fields/block.phpin the themetemplates/blocks/{block-name}.phpshipped by a plugintemplates/block.php— the generic fallback
So a new block renders something immediately rather than an empty region that looks broken. Or filter wpcmb/block/template.
Inside a block template, wpcmb_get_field( 'heading' ) returns that block's value — the wpcmb/value/pre_get filter serves the block's attributes for the duration of the render, so a block template reads like a theme template.
Two decisions worth knowing
- All values sit under one
dataattribute, not one attribute per field. Adding or renaming a field then does not invalidate blocks already saved in content. - A block name is never derived from the group title. The name is part of saved content; deriving it would mean renaming a group silently orphans every block already placed.
Attributes arrive from the editor and are cleaned by their own field types before anything is output — the same sanitizers the edit screens, forms and REST use, so a block cannot become a way to store what the other three would refuse.
Integrations
Every integration is dormant unless its host plugin is present — no host, no hooks, no loaded code.
Shortcodes
[wpcmb_field name="byline"]
[wpcmb_field name="byline" object="post_12" fallback="—"]
[wpcmb_field name="hero_link" key="url"]
[wpcmb_field name="tags" separator=" | "]
Output is escaped, and the visitor's permission to read the object is checked — a shortcode in a comment cannot leak a draft's values. Structured values (repeaters, groups) print the fallback rather than a serialised array, because there is no sensible one-line form and printing JSON into a page reads as a bug.
WooCommerce
Products and coupons are post types, so they already work through ordinary post-type location rules. Two things needed code:
- The
wc_product_typeandwc_order_statuslocation rules the builder already offered. - Orders under HPOS. With High-Performance Order Storage an order's meta lives in WooCommerce's own tables, not
wp_postmeta. Three new filters —wpcmb/storage/read,/write,/delete— let the integration claim those reads and writes and route them through the order CRUD.wc_get_order()returning false for a product is exactly the test that keeps products on post meta.
Field groups render in a Custom Fields product data tab and on the order screen.
Elementor and Bricks
Both builders can already read these values, because they are stored in native meta — the Phase 3 decision paying off. What they cannot do is apply a field type's formatting, so an image field hands them an attachment id and a link field hands them a serialised array. Both integrations expose the formatted value instead.
- Bricks —
{wpcmb_fieldname}dynamic data, registered through plain filters. No class of theirs is extended, so an internal signature change cannot break it. - Elementor — a dynamic tag in a "Custom Meta Box" group, offered for text and URL bindings. The tag class must extend an Elementor base class, so it lives in
elementor-tag.php— a filename that deliberately does not match its class name, excluded from Composer's classmap, and loaded by hand only once Elementor has registered. On a site without Elementor the autoloader cannot reach it, so mentioning the class name is a missing feature rather than a fatal error.
WP-CLI
wp wpcmb groups [--inactive] [--format=json]
wp wpcmb get byline 12 [--raw]
wp wpcmb update byline 12 "Manpreet Singh"
wp wpcmb update rows 12 '[{"title":"One"},{"title":"Two"}]'
wp wpcmb delete byline 12
wp wpcmb export [<key>...] [--file=groups.json]
wp wpcmb import groups.json [--activate]
Writes are validated and sanitized exactly as from an edit screen, so a script cannot store what the admin would refuse. Imports create new groups inactive unless --activate is passed.
The command surface is a separate class from the module that registers it: WP-CLI turns every public method of a command object into a subcommand, so registering the module itself would publish wp wpcmb boot and wp wpcmb is_enabled.
The interface
The plugin's own screens and the fields it renders on somebody else's screen are two different problems, and they are solved separately.
On the plugin's screens — overview, field groups, the group editor, tools and settings — the surface is ours end to end: cards on the admin canvas, one focus ring, two shadow levels, and colour spent only on state that matters. Settings is still the WordPress Settings API underneath, so it keeps the nonce, the capability check, the option writes and the saved notice; only its appearance is ours.
On a post, term, user or comment screen the plugin owns its own meta box and
nothing else. postbox_classes_* adds wpcmb-metabox to the boxes this plugin
registers, and the stylesheet dresses that class alone — a rounded card, a tinted
header, an accent hairline on the box being typed in. A core or third-party meta
box sitting beside it is untouched. In the block editor, where Gutenberg
deliberately flattens the meta box area into one panel, its rules win and the
boxes stay flat; that is the host's call to make, not ours.
Controls the plugin draws itself
| Control | Instead of |
|---|---|
.wpcmb-btn — select media, add row, clear a signature |
core's .button, which is a different height and shape from the inputs beside it |
| Row controls — drag, collapse, reorder, duplicate, remove | ☰ ▾ ↑ ↓ ⧉ × glyphs, each arriving at its own size and baseline |
| Multiple select — chips, search, checkboxes | a native listbox that needs a ctrl-click and hides what is chosen once it scrolls |
The row controls are inline SVG on a 16px grid at one stroke weight, inheriting
currentColor, so hover, danger and disabled are one rule each rather than one
per icon.
The multiple select
A native select[multiple] gives up its selection only to a ctrl-click and shows
what is chosen only while the chosen rows happen to be in view. enhanced.js
replaces the look of it, not the thing itself:
- the
<select>stays in the form, keeps itsname[]and its options, and remains the only source of truth; - every tick is written back to it and announced with
inputandchange, so validation, conditional logic and the save path read the control they always read; - if the script never runs — an error earlier on the page, JavaScript off — the native select is still there and still works.
Chips above the control show what is chosen and remove on click; the panel holds a search box and a checkbox per option.
Admin UI isolation
The plugin must never restyle WordPress or another plugin. That is a promise
no amount of care keeps on its own, so tests/ui-check.php enforces it and
runs with the rest of the suite:
| Rule | Enforced |
|---|---|
Every CSS selector starts with a wpcmb- class or #wpcmb- id |
✅ |
No core class restyled (.button, .notice, .postbox, .wrap, .form-table, .widefat, .submit, .wp-core-ui, .dashicons) |
✅ |
| No bare element selectors | ✅ |
Every data attribute prefixed data-wpcmb- |
✅ |
Scripts add nothing global but window.wpcmb |
✅ |
Motion respects prefers-reduced-motion |
✅ |
| Logical properties only, so RTL needs no second stylesheet | ✅ |
One deliberate deviation from the brief. It asks that every selector begin
with .wpcmb-admin or #wpcmb-app. That is right for the plugin's own screens
and wrong for the field styles, which render inside the post, term, user and
comment editors where the body carries no class of ours — requiring it there
would mean fields render unstyled. Any wpcmb- class in the first compound is
equally airtight, since it cannot match markup the plugin did not write, so
that is what the checker enforces.
RTL needs no separate stylesheet: layout uses margin-inline-*,
padding-inline-*, border-inline-* and inset-inline-* throughout, which
mirror automatically under dir="rtl".
Colour comes from --wp-admin-theme-color, so the plugin adopts whichever
admin colour scheme the user picked rather than imposing its own.
Security
Authorisation is the part of a plugin that holds until the day somebody adds
one more handler in a hurry, so tests/security-check.php enforces it with the
rest of the suite:
| Rule | Enforced |
|---|---|
| Every AJAX handler checks a nonce and a capability | ✅ |
| No endpoint treats "signed in" as permission | ✅ |
Every REST route has a permission callback that is not __return_true |
✅ |
| Term values use the taxonomy-aware capability | ✅ |
| Every write path verifies a nonce | ✅ |
| Field names cannot claim protected meta keys | ✅ |
| Option-page values are written to prefixed option names | ✅ |
The rules are worth reading for why they exist, because each is the trace of a way this could go wrong:
Signed in is not permission. wp_ajax_ runs for every user on the site,
subscribers included. A handler that verifies only the nonce is open to the
whole user table — and the field endpoints render a field's own labels, choices
and default values. They require edit_posts, filterable through
wpcmb/ajax/capability for a site that deliberately puts a repeater on a user
profile group its subscribers fill in themselves.
Values inherit the permissions of the object they belong to. One place,
Fields/Permissions.php, answers that for the edit screens, the front-end
forms and the REST routes, because a capability check living in three places is
one that will eventually disagree with itself. Terms use edit_term rather
than manage_categories: it is a meta capability, so WordPress maps it to
whatever capabilities the taxonomy was actually registered with.
A field name is a meta key. Names go straight to update_metadata(), and a
leading underscore is how WordPress and every other plugin mark meta as
internal. A field named _thumbnail_id would hand everyone who can edit the
object a writable path to a key something else owns — creating the field needs
manage_options, but filling it in needs only edit_post, and tha
This README is longer than the copy stored here. Read the rest on GitHub →