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/manpreetdev-custom-fields-meta-boxes/archive/refs/heads/main.zipField groups, meta boxes and a developer-friendly field API for WordPress.
- Version: 1.17.0
- Author: Manpreet Singh
- Requires PHP: 8.1
- Requires WordPress: 6.8
- License: GPL-2.0-or-later
- Text Domain:
manpreetdev-custom-fields-meta-boxes - 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:
manpreetdev-custom-fields-meta-boxes.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
manpreetdev-custom-fields-meta-boxes.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.
The row loop
wpcmb_have_rows( 'slides' ); // another row coming?
wpcmb_the_row(); // move onto it
wpcmb_get_row(); // the current row, unmoved
wpcmb_get_sub_field( 'heading' ); // a value from it
wpcmb_the_sub_field( 'heading' ); // echoed, escaped
wpcmb_get_sub_field_object( 'heading' ); // the sub field's definition
wpcmb_get_row_layout(); // flexible content's layout
wpcmb_get_row_index(); // position, counting from one
wpcmb_count_rows(); // rows in this loop
wpcmb_reset_rows(); // after a break
wpcmb_add_row( 'slides', $row, $post_id );
wpcmb_update_row( 'slides', 1, $row, $post_id );
wpcmb_delete_row( 'slides', 1, $post_id );
if ( wpcmb_have_rows( 'slides' ) ) {
while ( wpcmb_have_rows( 'slides' ) ) {
wpcmb_the_row();
echo '<h2>' . esc_html( wpcmb_get_sub_field( 'heading' ) ) . '</h2>';
while ( wpcmb_have_rows( 'bullets' ) ) { // nested, no identifier
wpcmb_the_row();
wpcmb_the_sub_field( 'text' );
}
}
}
A repeater value is already a list of rows, so foreach ( wpcmb_get_field( 'slides' ) as $row ) works and is shorter for a flat repeater. The loop exists for the two things a foreach cannot do:
- Reach a sub field's definition. A sub field is not in the top-level name index, so
wpcmb_get_field_object()cannot find one —wpcmb_get_sub_field_object()is the only way to a sub field's label, choices or default. It is also why a sub field added to a repeater after its rows were saved reads as its default rather than as nothing. - Walk a nested repeater without nesting the loops by hand. A loop opened while another is open belongs to the row it was opened inside, so the identifier is only passed at the top.
wpcmb_have_rows() looks without moving, which is what makes the if around the while safe — it does not eat the first row. Moving is wpcmb_the_row()'s job, so a while body that never calls it never ends. That is ACF's contract, deliberately, because a site moving across has this spelling in every template it owns.
After breaking out of a loop part way through, call wpcmb_reset_rows(). The pointer is still mid-list otherwise. A loop left open on one object will not answer for another, so an archive template cannot render the previous post's rows, but it will resume mid-list for the same one.
The writers count rows from one, as the loop reports them, and go through the same sanitizing an edit screen does — so wpcmb_add_row() cannot store what the admin would refuse, and it rewrites the rows already there as they were stored rather than as they were formatted.
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.
Field group settings
| Setting | What it does |
|---|---|
| Position | Which meta box context the group renders in: after the content, in the side column, or in the advanced area. |
| Style | A standard meta box, or seamless — no box, just the fields. |
| Label placement | Labels above the fields, or beside them. |
| Instruction placement | A field's instructions below its control, or above it. Below is the default, because that is where they have always been. |
| Order | Sorts the group against others on the same screen, and names the page when two groups share one options page. |
| Description | A sentence above the fields. |
| Display title | The heading on the edit screen, when it should differ from the group's own title. A group can be Product — pricing where it is administered and Pricing where it is filled in. |
| Show in REST API | Off keeps the group's fields out of the REST API, and out of the block editor with it — register_meta() is what puts them in both. On for every group saved before this setting existed, so nothing disappeared from an API somebody was already calling. |
| Hide on screen | Removes core elements from the edit screen: permalink, content editor, excerpt, custom fields, discussion, comments, revisions, slug, author, format, page attributes, featured image, categories, tags, trackbacks. |
| Options page | Menu location, capability and icon for the page an options_page rule creates. See Options pages. |
Hide on screen is two mechanisms. Most of those elements are meta boxes,
and are removed with remove_meta_box(). The permalink row and the content
editor are part of the edit form itself, so they are hidden by a class this
plugin puts on the body — which is why those two work in the classic editor
and not in the block editor, where the equivalents are core's own panels.
Those two options had been in the list since the beginning and had never
hidden anything: remove_meta_box() was being called with an id that does not
exist, and failing silently.
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, separator, 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.
Per-type settings
Each type declares its own settings and the field editor builds the UI from that declaration, so adding a setting is a PHP change and nothing else. Which tab a setting appears under is part of the declaration.
| Type | Settings |
|---|---|
| text, password, phone, slug | maxlength, minlength, pattern, prepend, append |
| number, range, currency | min, max, step, prepend, append |
| date, time, datetime | return format |
| textarea | rows, maxlength, new lines |
| select, country, state | choices, multiple, min, max, empty choice on/off, empty label, return format |
| checkbox | choices, multiple, min, max, layout, select all, other values, return format |
| radio, button_group | choices, layout, return format |
| rating | maximum rating |
| toggle, true_false | toggle label, on label, off label |
| file, video, audio | library, upload type, file types, min size, max size, preview size, return format |
| image, gallery | the above, plus min and max width and height |
| gallery | the above, plus min and max items |
| post_object, page_link, relationship | post types, multiple, min, max, empty choice, return format |
| taxonomy | taxonomy, assign the terms, load from the post's terms, and the above |
| user | roles, multiple, min, max, empty choice, return format |
| wysiwyg | rows, toolbar, tabs, media buttons, return format |
| code, json, html | rows, return format, decoded array |
| link | return format |
| embed | return format, width, height |
| icon | icon set |
| repeater | min, max, layout, button label, row label, collapse after, CSV |
| flexible | min, max, button label, collapse after, per-layout min and max |
| message | message |
| accordion | open by default |
File and image limits are rules, not hints. The media picker is filtered by
them as a convenience, and every one is checked again on save — because a value
can also arrive from a public form, from the REST API, or from code, none of
which open a picker. A size written bare is kilobytes, which is how the media
library writes its own limits; 2MB and 500 KB work too.
Upload type is picked from a list the site keeps in Settings, so "what this site accepts" is answered once rather than retyped into every field. Allowed file types is still there as the escape hatch for anything the list does not cover, and overrides it. Either way a field accepts its own kind of file: an image field takes images whether or not anybody filled the setting in.
"Other values" is offered on a checkbox list only. What is typed joins the list of values, so a stored value reads the same whether it was ticked or typed, and it comes back as a normal ticked choice next time. On a radio or a dropdown the box and the control would fight over one value, which needs script to arbitrate and leaves the field unusable if that script does not load.
A taxonomy field can own the post's terms. With assign the terms on, a
save calls wp_set_object_terms() as well as storing the value, replacing
whatever that taxonomy held. With load from the post's terms on, the field
shows what the post actually has rather than what the field last stored. Both
are off by default, because a taxonomy field is often a second, editorial list
that has nothing to do with the post's real terms.
Settings this plugin does not offer, and why
- A date display format, and a first day of the week. The date, time and colour pickers are the browser's own, and a browser draws them in the viewer's locale. There is no format to set without bundling a picker.
- A relationship filter bar, and featured-image thumbnails in it. Both belong to a two-column relationship UI this plugin does not have; its relationship field is a select, and the searchable multiple select already covers finding one item among many.
- A map centre, zoom and height. The map field is a coordinate pair, an address and a link out — deliberately not a tile canvas — so there is nothing to centre, zoom or resize.
Extending a type's rules
Two hooks on FieldType carry what a sanitizer cannot see:
// Reject a value this type cannot accept. Runs after the generic checks —
// required, length, range, pattern — and for every value on its way to
// storage, whatever it arrived from.
public function validate( mixed $value, array $field ): string;
// Act on the object a value was just written to, or read one from it.
public function on_save( mixed $value, array $field, mixed $ref ): void;
public function on_load( mixed $value, array $field, mixed $ref ): mixed;
The media field's file limits use the first; the taxonomy field's term
assignment uses the other two. A registered third-party type gets all three,
because they are reached through wpcmb/validate and the value pipeline
rather than from any one caller.
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.
The admin menu
Meta Boxes
├── Overview
├── Field Groups
├── Settings
├── Tools
├── Contact form ← a form's submissions, one entry per form
│ (wpcmb_contact_f_8a21)
└── Site Options ← an options page placed in this menu
(site-settings)
The order is fixed, and sorted once after everything has registered. It
has to be: a post type registers on init, an admin page on admin_menu, and
a form's submissions type does not exist at all until its first submission
arrives — so left alone, this menu rearranged itself as a site was used, and
the entry somebody clicked yesterday was somewhere else today. The fixed
screens come first and the things a site makes come last, so the menu grows at
the bottom rather than in the middle.
Core has no filter for the order of items inside a menu — custom_menu_order
and menu_order reorder top-level menus only — so Menu::order_submenu()
sorts $submenu['wpcmb'] directly. It only ever reorders entries already
there.
Forms and options pages name themselves twice: the title, and underneath
it the identifier — a form's post type, an options page's slug. Two forms can
be called the same thing, and the identifier is both what tells them apart and
what a query or a wpcmb_get_field() call would use.
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. By default 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.
The group's Options Page settings change all three. Menu location moves the page under the plugin's own Meta Boxes menu, or under Settings, Tools, Appearance, Posts, Media or Users, instead of giving it a menu of its own; Capability decides who may open and save it; Menu icon takes a Dashicon name or an image URL, for a top-level entry.
The capability is one setting, not two. Lowering it would otherwise put the
menu item in front of an editor and then refuse them at the door, because the
default rule for an options object is manage_options — so the permission
check consults the page's own capability for the pages this plugin created,
and leaves every other options object on the default rule. A page set to
edit_pages is a page an editor can actually use.
The group that names the page decides its menu. A second group sharing the same slug contributes its fields, not its menu — one page cannot sit in two places.
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 the
page's own capability.
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.
The two filters are still there, for whatever the settings do not cover:
// 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 manpreetdev-custom-fields-meta-boxes/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 c
This README is longer than the copy stored here. Read the rest on GitHub →