WP Manifestindependent plugin directory
manifest / content / manpreetdev-custom-fields-meta-boxes

Manpreetdev Custom Fields and Meta Boxes

Field groups, meta boxes and a developer-friendly field API for WordPress.

★ 0stars

Install

Download source zip No release zip yet

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.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

Field 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

  1. WordPress loads manpreetdev-custom-fields-meta-boxes.php, which defines constants and registers an autoloader (Composer's if vendor/ exists, a PSR-4 fallback otherwise).
  2. wpcmb()->init() hooks Plugin::boot() to plugins_loaded at priority 5 — early enough that other plugins can act on wpcmb/booted.
  3. boot() runs the module list through the wpcmb/modules filter, resolves each class from the container, skips any whose is_enabled() returns false, and calls boot() on the rest.
  4. wpcmb/booted fires. 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() runs wp_unslash() on whatever it is given, so Values slashes on the way in. Written raw, a value like C:\Users\test loses 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:

  1. 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.
  2. 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. wysiwyg is wp_kses_post(); html is verbatim only for users with unfiltered_html; code and json are 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:

  • action defaulted to values, which needs an object to store against, while object defaulted to new, 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. action now defaults to post, 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. draft is the default and anything beyond pending is refused unless the submitter can publish_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 with wpcmb/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-hidden and tabindex="-1" keep it away from screen readers and keyboard users.
  • A signed timestamp. Submissions faster than MIN_SECONDS are 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 →