Field types for WordPress — repeater, flexible-content, clone (with a pipe-shorthand textarea fallback), plus a full set of scalar leaf types: text, textarea, wysiwyg, email, url, date, time, color, select, image, post_object, group. MIT, no ACF dependency.
stagehand_register_field('event_schedule', [
'post_types' => ['event'],
'sub_fields' => [
['name' => 'time', 'type' => 'text', 'placeholder' => '13:30'],
['name' => 'label', 'type' => 'text'],
['name' => 'note', 'type' => 'textarea'],
],
]);…and an editor sees both a visual repeater UI and this textarea:
13:30 | Doors Open | Bring your ID
14:00 | Cast Greeting
15:00 | Main Performance | Seoul / Taipei / Taichung
18:30 | Encore
19:00 | Curtain Call
Either side writes to the same postmeta. Toggle with one click.
ACF Pro charges $59/year for repeaters. SCF (the official ACF fork) ships the visual UI but not the paste-friendly fallback. Most "free" repeater plugins are still drag-only and skip the leaf field types you need to build real product UI without ACF.
Stagehand productizes two ideas in one plugin:
- Visual ↔ pipe-shorthand toggle for container fields (repeater,
flexible-content, clone) — editors get the click-to-add-row UI when they
want it and a
A | B | C-per-line textarea when they don't. - A full set of scalar leaf types (v0.2.0) so a theme can declare its entire field schema through Stagehand without falling back to ACF or hand-rolled metaboxes for the simple cases.
ACF Pro repeater → $59/yr, drag UI only, ACF SDK lock-in
SCF repeater → free, drag UI only, ACF-compatible API
Meta Box repeater → free core / paid pro, drag UI only
Stagehand → MIT, drag UI + paste textarea + scalar leaf types,
standalone
- Paste a Notion table into a textarea and it parses row-by-row. No more clicking Add Row five times.
- Paste shorthand into the FIRST visual cell and Stagehand auto-explodes it into proper rows on the spot.
- Mobile editing — typing
13:30 | Doors Openon a phone is faster than three taps, three keyboards, three save toasts. - Diff-friendly — the postmeta envelope stores rows as a versioned array, but the shorthand layer means PRs and content-review screenshots are pure text.
- JS-optional — every field renders without the admin JS bundle. Good for reduced-motion preferences, slow connections, or paranoid editors.
Requires WordPress 6.5+ and PHP 8.1+. No build step and no runtime Composer dependencies — Composer is only used for the test suite.
# zip-install or:
git clone https://github.com/rennerdo30/wp-stagehand wp-content/plugins/stagehandActivate, then in functions.php:
add_action('stagehand_register_fields', function () {
stagehand_register_field('event_schedule', [
'post_types' => ['event'],
'label' => 'Schedule',
'sub_fields' => [
['name' => 'time', 'type' => 'text', 'placeholder' => '13:30'],
['name' => 'label', 'type' => 'text'],
['name' => 'note', 'type' => 'textarea'],
],
'display_mode' => 'both', // 'visual' | 'shorthand' | 'both'
]);
});Render in templates:
$rows = stagehand_get_rows(get_the_ID(), 'event_schedule');
foreach ($rows as $row) {
printf('<li><time>%s</time> <strong>%s</strong> <em>%s</em></li>',
esc_html($row['time']),
esc_html($row['label']),
esc_html($row['note'])
);
}Read scalars the same way:
$intro = stagehand_get_value(get_the_ID(), 'page_intro'); // string
$logo_id = stagehand_get_value(get_the_ID(), 'partner_logo'); // int (attachment ID)
$related = stagehand_get_value(get_the_ID(), 'related_events'); // int[] (post IDs)| Type | What it does |
|---|---|
repeater |
N rows of M sub-fields. Rendered as the visual UI, the pipe-shorthand textarea, or both. |
flexible_content |
N rows, each picks one of K layouts. Each layout has its own sub-fields. |
clone |
Reuse a field def by name (e.g. shared contact block). |
| Type | Stored as | return modes |
|---|---|---|
text |
string | value (default) |
textarea |
string | value |
wysiwyg |
string (HTML) | value |
email |
string | value |
url |
string | value |
date |
string (Y-m-d) |
value |
time |
string (H:i) |
value |
color |
string (#rrggbb) |
value |
select |
string | value |
image |
int (attachment ID) | value (ID), url, array |
post_object |
int or int[] (post IDs) | value (ID/IDs), post (WP_Post[]) |
group |
array<string, mixed> | array |
stagehand_get_value() resolves to the format declared by the field's
return key — pass 'return' => 'url' on an image field to read it as an
upload URL, 'return' => 'post' on a post_object multi to read WP_Post[],
etc.
document := line ( NEWLINE line )*
line := blank | row
blank := WS*
row := cell ( ' | ' cell )*
cell := ( CHAR | escape )*
escape := '\\|' | '\\n' | '\\\\'
CHAR := any character except '\n'
Rules:
- Delimiter is exactly
|— space-pipe-space. A bare|survives. - Backslash escapes:
\|→|,\n→ newline (in cell),\\→\. - Empty lines skipped. Whitespace-only lines too.
- Missing trailing columns → empty strings.
- Extra columns beyond the field definition are dropped silently.
- Whitespace at cell edges is trimmed.
The parser is pure PHP and unit-tested:
composer install
composer test ┌─────────────────────────────────────────────────┐
│ functions.php → stagehand_register_field(...) │
└────────────────────────┬────────────────────────┘
│
┌────────▼────────┐
│ FieldRegistry │ in-memory store
└────────┬────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌───────▼───────┐
│ Visual │ toggle │ Shorthand │ parse │ PipeShorthand │
│ rows UI │ ◄────────► │ textarea │ ◄─────► │ Parser │
└────┬────┘ └──────┬──────┘ └───────────────┘
│ │
└────────────┬───────────┘
│ on save_post
┌──────▼──────┐
│ PostMeta │ → wp_postmeta._stagehand_<field>
│ Writer │ containers: { v: 1, rows: [...] }
│ │ scalars: { v: 1, value: ... }
└──────┬──────┘
│
┌──────▼─────────────────────────┐
│ stagehand_get_rows / _value() │
└────────────────────────────────┘
stagehand_register_field(string $name, array $definition): void
stagehand_get_rows(int $post_id, string $field_name): array
stagehand_get_value(int $post_id, string $field_name, mixed $default = null): mixed
stagehand_get_field(string $name): ?array
stagehand_parse_shorthand(string $text, array $sub_fields): arrayHook for registering fields — the FieldRegistry instance is passed as the
first argument, so you can either call the procedural helper or talk to the
registry directly:
do_action('stagehand_register_fields', $registry);Stagehand adds a read-only top-level Stagehand menu in wp-admin
(manage_options only). It reports the plugin version, how many fields are
registered, the container/scalar split, which field types are in use, and which
post types they attach to — plus a table of every registered field with its
type, label, and sub-field count. There is nothing to configure there; field
definitions live in code.
Every field is stored under _stagehand_<field_name> as a versioned envelope.
Containers (repeater, flexible_content, clone):
[
'v' => 1,
'rows' => [
['time' => '13:30', 'label' => 'Doors Open', 'note' => 'Bring ID'],
['time' => '14:00', 'label' => 'Cast Greeting', 'note' => ''],
],
]Scalars (everything else):
[
'v' => 1,
'value' => 'Hello world', // or int, int[], or array<string,mixed> for `group`
]stagehand_get_value() reads through this resolution chain so themes
migrating off ACF surface their existing data without a rewrite:
_stagehand_<field>v1 envelope (canonical Stagehand storage)._stagehand_<field>raw scalar (pre-envelope, future-proof).<field>flat postmeta — the shape ACF writes by default. Stagehand reads it transparently sostagehand_get_value($id, 'page_intro')keeps working on posts that were saved before the theme was ported off ACF.
Future versions will migrate based on v.
- No conditional logic. Field visibility is post-type-scoped only;
there's no "show field X when field Y equals Z". Most sites can model
conditional UX with
flexible_contentinstead. - No location rules beyond
post_types. Term-edit screens, user profiles, and options pages aren't supported yet — Stagehand registers metaboxes againstadd_meta_box()only. - No revisions integration. Postmeta written by Stagehand isn't attached to WP revisions; rolling back a post does not roll back its Stagehand fields.
- No translation hooks. Each field stores one value per post. Pair with Triptych or another multilingual layer if you need per-language values.
MIT © 2026 Renner — https://renner.dev