TAW FrameworkTAW Core

TAW Core Tools

Overview of the developer tools TAW Core exposes for the TAW Theme, including CLI commands, runtime APIs, forms, mail, and debugging helpers.

What counts as a tool in TAW Core

TAW Core tools are the developer-facing building blocks the theme uses: command-line utilities and runtime PHP APIs.

These tools fall into two groups:

  • CLI commands — Symfony Console commands exposed through the bin/taw script in your theme. Use them to generate and manage blocks and other theme artifacts.

  • Runtime APIs and helpers — PHP classes, functions, and configuration points that run inside WordPress, such as theme boot configuration, performance tuning, block registration, admin UI engines, config-driven forms, mail templates, and debugging helpers.

TAW Theme builds on top of these TAW Core primitives. When you configure the theme or call theme helpers, you are usually driving these lower-level tools under the hood.

How TAW Core boots into the theme

The theme template boots TAW Core via the TAW\Core\Theme::boot() entrypoint. You can then adjust performance behavior with Theme::performance([...]).

This boot process wires TAW Core tools into WordPress so that CLI commands, block registration, metaboxes, and other helpers are available within your theme.

Metabox

TAW Core metaboxes accept a fields configuration array, and each field defines a type that the TAW\Core\Metabox\Metabox engine knows how to render, validate, and save.

The canonical implementation lives in the TAW\Core\Metabox\Metabox class (src/Core/Metabox/Metabox.php), which switches on the field type and handles rendering and saving.

Common field options

Most field types support a shared set of options you can mix and match as needed:

  • id — required unique key used for saving and retrieving the meta value.

  • label — human-readable label shown next to the field in the metabox UI.

  • description — help text shown below the field for additional guidance.

  • required — boolean flag to mark the field as required in the UI/validation layer.

  • width — layout hint for field width within the metabox row.

  • placeholder — placeholder text for text-like inputs.

  • conditions — show/hide logic based on other field values, with field, value, and operator keys.

  • readonly — boolean flag that renders the field as non-interactive text (with a lock icon next to its label) instead of an editable control, for values an external process (a sync pipeline, a computed value) authoritatively owns.

Use these common options alongside the type-specific options below.

readonly is enforced on both ends, not just visually: no <input>/<select>/<textarea> is rendered at all (nothing for devtools to re-enable and submit), and Metabox::save() skips the field entirely — a forged $_POST key is ignored regardless. It composes rather than being its own field type: on a group field it propagates to every sub-field, and on a repeater field it disables Add/Remove/reorder for the whole row list (not just the sub-field values), propagating readonly to every sub-field in every row. Not yet enforced by VisualEditorEndpoint or the fields:set/seo:inject CLI commands — those are separate write paths that don't consult this flag.

Supported field types

Each field below uses a type value that maps directly to the metabox renderer.

A full metabox fields configuration looks like this:

new Metabox([
    'id'     => 'taw_hero',
    'title'  => 'Hero Section',
    'screens' => ['page'],
    'fields' => [
        // Layout with widths
        [
            'id' => 'heading',     
            'label' => 'Heading',     
            'type' => 'text',     
            'width' => '50', 
            'required' => true
        ],
        [
            'id' => 'subheading',  
            'label' => 'Subheading',  
            'type' => 'text',     
            'width' => '50'
        ],

        // Rich text
        [
            'id' => 'body',        
            'label' => 'Body',        
            'type' => 'wysiwyg', 
            'rows' => 6
        ],

        // Select with options
        [
            'id' => 'style',
            'label' => 'Style',
            'type' => 'select',
            'options' => ['light' => 'Light', 'dark' => 'Dark']
        ],

        // Toggle
        [
            'id' => 'show_cta',    
            'label' => 'Show CTA',    
            'type' => 'checkbox'
        ],

        // Color picker
        [
            'id' => 'bg_color',    
            'label' => 'Background',  
            'type' => 'color', 
            'default' => '#ffffff'
        ],

        // Range slider
        [
            'id' => 'min_height',
            'label' => 'Min Height',
            'type' => 'range',
            'min' => 400,
            'max' => 900,
            'step' => 50,
            'unit' => 'px',
            'default' => 600
        ],

        // Image
        [
            'id' => 'image',       
            'label' => 'Background Image', 
            'type' => 'image'
        ],

        // Post selector (single)
        [
            'id' => 'featured_post', 
            'label' => 'Featured Post', 
            'type' => 'post_select', 
            'post_type' => 'post'
        ],

        // Post selector (multi, with max)
        [
            'id' => 'related',
            'label' => 'Related Posts',
            'type' => 'post_select',
            'post_type' => 'post',
            'multiple' => true,
            'max' => 3
        ],
    ],
]);

Conditional fields

Fields can show or hide based on other field values. Conditions are evaluated live in the admin UI using Alpine.js, and also server-side during save.

'fields' => [
    ['id' => 'show_cta',   'label' => 'Show CTA',    'type' => 'checkbox'],
    ['id' => 'cta_text',   'label' => 'CTA Text',    'type' => 'text',
     'conditions' => [
         ['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
     ]],
    ['id' => 'cta_url',    'label' => 'CTA URL',     'type' => 'url',
     'conditions' => [
         ['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
     ]],
],

Supported operators: ==, !=, contains, empty, !empty

All conditions in the array use AND logic — every condition must pass for the field to show.

Tabbed fields

Group fields into tabs using the tabs key. Each tab references field IDs from the fields array.

new Metabox([
    'id'     => 'taw_hero',
    'title'  => 'Hero Section',
    'screens' => ['page'],
    'fields' => [
        ['id' => 'heading',   'label' => 'Heading',   'type' => 'text'],
        ['id' => 'image',     'label' => 'Image',     'type' => 'image'],
        ['id' => 'bg_color',  'label' => 'Background','type' => 'color'],
        ['id' => 'show_cta',  'label' => 'Show CTA',  'type' => 'checkbox'],
        ['id' => 'cta_text',  'label' => 'CTA Text',  'type' => 'text'],
    ],
    'tabs' => [
        ['id' => 'content', 'label' => 'Content', 'fields' => ['heading', 'image']],
        ['id' => 'design',  'label' => 'Design',  'fields' => ['bg_color']],
        ['id' => 'cta',     'label' => 'CTA',     'fields' => ['show_cta', 'cta_text']],
    ],
]);

Other Metabox config options

OptionDefaultDescription
screens['page']Post types, page slugs, or page template filenames to attach to — accepts an array
context'normal'Position: 'normal', 'side', 'advanced'
priority'high'Order: 'high', 'default', 'low'
prefix'_taw_'Meta key prefix applied to all field IDs
icon(none)SVG string — displayed as the metabox icon
show_on(none)callable(WP_Post): bool — return false to hide the metabox

Metabox Retrieval API

Use these static helpers inside getData() or anywhere in your templates.

use TAW\Core\Metabox\Metabox;

// Plain text / any scalar value
$heading = Metabox::get($postId, 'hero_heading');

// Checkbox → boolean (saves as '1'/'0', returns bool)
$showCta = Metabox::get_bool($postId, 'show_cta');

// Image attachment ID → URL
$imageUrl = Metabox::get_image_url($postId, 'hero_image', 'large');

// Color with fallback
$bgColor = Metabox::get_color($postId, 'bg_color', '#ffffff');

// post_select → array of post IDs (works for single and multi)
$featuredId  = Metabox::get_posts($postId, 'featured_post')[0] ?? null;
$relatedIds  = Metabox::get_posts($postId, 'related_posts');

// repeater → array of rows, each an associative array
$teamMembers = Metabox::get_repeater($postId, 'team_members');
foreach ($teamMembers as $member) {
    echo esc_html($member['name'] ?? '');
    echo esc_html($member['role'] ?? '');
}

Forms

TAW\Core\Form\Form is a config-driven frontend form builder that handles CSRF protection, honeypot spam filtering, rate limiting, optional Cloudflare Turnstile bot verification, field validation, AJAX submission (no page reload), email delivery, and automatic submission persistence.

Registration and rendering

Forms must be registered before templates load. The correct place is inside the block's boot() method, wrapped in add_action('init', ...) so translation functions are safe. Display the form in your template with Form::display().

use TAW\Core\Form\Form;

// In your MetaBlock::boot():
public static function boot(): void
{
    add_action('init', static function () {
        Form::register([
            'id'           => 'contact',
            'submit_label' => 'Send Message',
            'messages'     => ['success' => "Thanks! We'll be in touch."],
            'email' => [
                'to_self'   => ['subject' => 'New contact',      'template' => 'contact-self'],
                'to_client' => ['subject' => 'Got your message', 'template' => 'contact-client'],
            ],
            'fields' => [
                ['id' => 'name',    'label' => 'Name',    'type' => 'text',     'required' => true],
                ['id' => 'email',   'label' => 'Email',   'type' => 'email',    'required' => true],
                ['id' => 'message', 'label' => 'Message', 'type' => 'textarea', 'required' => true],
            ],
        ]);
    });
}

// In the block's index.php template:
Form::display('contact');

When both email.to_self.template and email.to_client.template are set, delivery uses Mailer + MailTemplate. Otherwise Form falls back to plain-text wp_mail().

Input field types

TypeDescription
textSingle-line text
emailEmail address — validated with is_email()
telPhone number
urlURL
numberNumeric input
textareaMulti-line text; accepts rows (default 4)
selectDropdown; pass options as ['value' => 'Label']
radioRadio group; pass options; accepts layout ('horizontal' default / 'vertical')
checkboxBoolean toggle; value is '1' when checked
checkbox_groupMultiple checkboxes; pass options; accepts layout; stored as comma-separated string
dateNative date picker; accepts min_date and max_date (ISO format YYYY-MM-DD)

Any other value (e.g. password, hidden) is passed straight through as the HTML type attribute.

Security

Every form has CSRF (nonce) protection and honeypot spam filtering by default — no configuration needed.

Rate limiting is on by default too: 5 attempts per 60 seconds, per IP, per form — backed by WP transients, no external cache (Redis/Memcached) required. Checked before the nonce check, since a flooding script doesn't need a valid nonce to cause load.

Form::register([
    'id' => 'contact',
    'rate_limit' => ['max' => 3, 'window' => 120], // override the default
    // 'rate_limit' => false, // or disable entirely
    'fields' => [...],
]);

Cloudflare Turnstile is opt-in bot verification. Site/secret keys are PHP constants defined in wp-config.php — the same pattern as database credentials, never a metabox/OptionsPage field, since those are readable via the REST API by anyone with edit_posts.

// wp-config.php
define('TAW_TURNSTILE_SITE_KEY', '0x...');
define('TAW_TURNSTILE_SECRET_KEY', '0x...');
Form::register([
    'id' => 'contact',
    'turnstile' => true,
    'fields' => [...],
]);

Get real keys from the Cloudflare Turnstile dashboard. If a form opts in but keys aren't configured yet, the widget silently doesn't render and no verification runs — a WP_DEBUG-only notice flags the misconfiguration to developers, not visitors. Turnstile::verify() fails closed on any network error or malformed response from Cloudflare's API.

Field validation rules, beyond required:

RuleApplies toEffect
min_lengthtext-like fieldsRejects values shorter than N characters
max_lengthtext-like fieldsRejects values longer than N characters
pattern (+ pattern_message)text-like fieldsPHP regex, matched against the whole value
min / maxnumber fieldsNumeric range
['id' => 'name',  'type' => 'text', 'min_length' => 2, 'max_length' => 80],
['id' => 'phone', 'type' => 'tel',  'pattern' => '[0-9+ ()-]{7,20}', 'pattern_message' => 'Enter a valid phone number.'],
['id' => 'guests','type' => 'number', 'min' => 1, 'max' => 20],

These render as native HTML minlength/maxlength/pattern/min/max attributes for client-side UX, but the authoritative check always runs server-side — HTML attributes are trivially removable from the DOM. An empty, non-required field never fails these checks.

Custom per-field error messages — every rule (required, the built-in email format check, min_length, max_length, pattern, min, max) accepts a {rule}_message override; falls back to a generic default (with the field's label interpolated) when not set:

['id' => 'name',  'type' => 'text',  'required' => true, 'required_message' => 'Please tell us your name.'],
['id' => 'email', 'type' => 'email', 'required' => true, 'email_message' => 'That doesn\'t look like a real email address.'],
['id' => 'age',   'type' => 'number', 'min' => 18, 'min_message' => 'You must be 18 or older.'],

Form-level default messages — to set validation copy once for an entire form (e.g. translating every rule for a non-English site) instead of repeating a {rule}_message on every field, pass a top-level messages entry per rule. Precedence: field-level {rule}_message > form-level messages.{rule} > built-in English default. required/min_length/max_length/pattern/min/max templates take the same sprintf() placeholders as the built-in defaults (field label as %s/%1$s, the rule's numeric bound as %2$d/%2$s); email takes no placeholders.

Form::register([
    'id' => 'contact',
    'messages' => [
        'required'   => '%s es obligatorio.',
        'email'      => 'Correo electrónico no válido.',
        'min_length' => '%1$s debe tener al menos %2$d caracteres.',
    ],
    'fields' => [...],
]);

Multi-column layout

Fields live inside a 12-column CSS grid. Use the width key (as a percentage) to control how many columns a field spans. On mobile all fields collapse to full width.

'fields' => [
    ['id' => 'name',    'type' => 'text',     'label' => 'Name',    'width' => 50],
    ['id' => 'company', 'type' => 'text',     'label' => 'Company', 'width' => 50],
    ['id' => 'phone',   'type' => 'tel',      'label' => 'Phone',   'width' => 33],
    ['id' => 'email',   'type' => 'email',    'label' => 'Email',   'width' => 67],
    ['id' => 'message', 'type' => 'textarea', 'label' => 'Message', 'width' => 100],
],
width valueGrid span
≤ 253 / 12 columns
≤ 334 / 12 columns
≤ 506 / 12 columns
≤ 678 / 12 columns
≤ 759 / 12 columns
> 75 or omitted12 / 12 columns (full width)

Structural field types

Structural fields are cosmetic only — they have no id, no validation, and produce no submission data.

['type' => 'heading', 'label' => '1. Personal Data', 'subtitle' => 'General identification'],
['type' => 'divider'],
['type' => 'html', 'content' => '<p class="text-sm text-gray-500">All fields marked * are required.</p>'],
TypeDescription
headingDark section banner with label and optional subtitle
dividerHorizontal rule (<hr>)
htmlRaw HTML via content key — rendered with wp_kses_post

All fields (including structural) accept width (percentage) for column placement.

Conditional fields

Fields can show or hide based on other field values. Conditions are evaluated in the browser and re-enforced on the server — hidden fields are excluded from validation and submission data regardless of client-side state.

By default all conditions use AND logic. Add 'relation' => 'any' to switch to OR:

// AND logic (default) — show 'cta_text' only when 'show_cta' is checked
['id' => 'show_cta',  'label' => 'Show CTA',  'type' => 'checkbox'],
['id' => 'cta_text',  'label' => 'CTA Text',  'type' => 'text',
 'conditions' => [
     ['field' => 'show_cta', 'operator' => '==', 'value' => '1'],
 ]],

// OR logic — show 'spouse_name' when married OR cohabiting
['id' => 'spouse_name', 'label' => 'Spouse / Partner name', 'type' => 'text',
 'conditions' => [
     'relation' => 'any',
     'rules'    => [
         ['field' => 'estado_civil', 'operator' => '==', 'value' => 'married'],
         ['field' => 'estado_civil', 'operator' => '==', 'value' => 'cohabiting'],
     ],
 ]],

Supported operators: ==, !=, >, <, >=, <=, contains

Multi-step forms

Replace the top-level fields key with steps. Each step has a title (shown in a numbered indicator) and its own fields array. All field types, widths, and conditions work identically inside steps.

Form::register([
    'id'           => 'application',
    'submit_label' => 'Submit',
    'next_label'   => 'Continue',   // optional; default "Next"
    'prev_label'   => 'Back',       // optional; default "Back"
    'messages'     => ['success' => 'Your form has been received.'],
    'steps' => [
        [
            'title'  => 'Personal Info',
            'fields' => [
                ['type' => 'heading', 'label' => '1. General Data'],
                ['id' => 'nombre',    'label' => 'Name',  'type' => 'text',  'required' => true, 'width' => 50],
                ['id' => 'email',     'label' => 'Email', 'type' => 'email', 'required' => true, 'width' => 50],
            ],
        ],
        [
            'title'  => 'Declaration',
            'fields' => [
                ['type' => 'html', 'content' => '<p>I declare that all information provided is true.</p>'],
                ['id' => 'confirm', 'label' => 'I confirm', 'type' => 'checkbox', 'required' => true],
            ],
        ],
    ],
]);

Next validates required fields in the current step before advancing. Back navigates without validation. Submit only appears on the last step. All fields from all steps are submitted together in a single AJAX request; if server validation fails, the form auto-navigates back to the step containing the first failing field.

Submission persistence

TAW\Core\Form\SubmissionsHandler is wired up automatically by Theme::boot() — no manual instantiation needed. Every successful submission is saved as a taw_submission CPT entry viewable in WP Admin → Submissions.

Configure the webhook endpoint and HMAC secret under Settings → Form Webhook in the WordPress admin. TAW Core signs outbound submission payloads with HMAC-SHA256 so your receiver can verify authenticity.

Per-form webhooks — each form can fire to its own webhook target instead of one shared site-wide URL, resolved in precedence order:

  1. An admin-configured override for that specific form (Settings → Form Webhook page's Per-Form Webhooks table — one row per registered form).
  2. A code-level default set via the form's own webhook config key:
    Form::register([
        'id'      => 'contact',
        'webhook' => ['url' => 'https://n8n.example.com/webhook/contact', 'secret' => 'optional-hmac-secret'],
        'fields'  => [...],
    ]);
    
  3. The Default Webhook at the top of the same settings page — the site-wide fallback for any form with neither of the above.

A form with none of the three configured just doesn't fire a webhook — the submission is still saved to the taw_submission CPT either way.

Payload shape:

{
    "event":        "new_submission",
    "form_id":      "contact",
    "post_id":      142,
    "submitted_at": "2026-02-07T12:30:00+00:00",
    "site_url":     "https://example.com",
    "page_url":     "https://example.com/contact",
    "ip":           "203.0.113.42",
    "data":         { "name": "Jane Doe", "email": "jane@example.com", "message": "Hello!" }
}

page_url — the full URL of the page the form was actually submitted from — is captured server-side at render time, not read from the request's Referer header (which browsers and privacy tools can strip). The same registered form is often embedded on several different pages; this is what lets a downstream automation tell those submissions apart without needing a separate form_id per page.

Customizing the payload:

// In the theme's inc/customizations.php:
add_filter('taw_form_webhook_payload', function (array $payload, string $formId, int $postId, array $data) {
    // Route the same form's submissions to different n8n destinations
    // depending on which section of the site they came from.
    $path = wp_parse_url($payload['page_url'], PHP_URL_PATH) ?? '';

    if (str_contains($path, '/financiera/')) {
        $payload['destination'] = 'financiera-sheet';
    } elseif (str_contains($path, '/fideicomisos/')) {
        $payload['destination'] = 'fideicomisos-sheet';
    }

    return $payload;
}, 10, 4);

taw_form_webhook_payload runs before the HMAC signature is computed, so the signature always covers exactly what's sent — filter freely without breaking signature verification on the receiving end.

Email

TAW\Support\EmailConfig::useEmailit() routes all wp_mail() calls — form submissions, password resets, WooCommerce order emails, anything else that goes through wp_mail() — through Emailit's API instead of the site's default mail transport.

This is a per-site, opt-in, paid add-on — not every client site needs it. Gate the call on a defined('EMAILIT_API_KEY') check so it's a true no-op on sites that don't define the constant.

// In the theme's inc/customizations.php, before Theme::boot():
use TAW\Support\EmailConfig;

if (defined('EMAILIT_API_KEY')) {
    EmailConfig::useEmailit(
        apiKey:   EMAILIT_API_KEY,
        from:     defined('EMAILIT_FROM_EMAIL') ? EMAILIT_FROM_EMAIL : get_bloginfo('admin_email'),
        fromName: defined('EMAILIT_FROM_NAME') ? EMAILIT_FROM_NAME : '',
    );
}

Requires the official SDK, installed only on sites that use it:

composer require emailit/emailit-php

EMAILIT_API_KEY (and optionally EMAILIT_FROM_EMAIL / EMAILIT_FROM_NAME) are site-specific secrets that belong in that site's wp-config.php — never commit them into the theme repo. If the SDK isn't installed, the API key is empty, or the Emailit API call throws, EmailConfig falls back to normal wp_mail() transparently rather than silently dropping the email.

Options page

TAW\Core\OptionsPage\OptionsPage provides site-wide settings stored in wp_options, using the same field config format as metaboxes. Configure it in inc/options.php.

new OptionsPage([
    'id'         => 'taw_settings',
    'title'      => 'TAW Settings',
    'menu_title' => 'TAW Settings',
    'capability' => 'manage_options',
    'icon'       => 'dashicons-screenoptions',
    'position'   => 2,
    'fields'     => [
        ['id' => 'company_name',  'label' => 'Company Name',  'type' => 'text', 'width' => '33.33'],
        ['id' => 'company_phone', 'label' => 'Phone Number',  'type' => 'text', 'width' => '33.33'],
        ['id' => 'company_email', 'label' => 'Email Address', 'type' => 'text', 'width' => '33.33'],
        ['id' => 'footer_text',   'label' => 'Footer Text',   'type' => 'textarea'],
        ['id' => 'logo',          'label' => 'Logo',          'type' => 'image'],
    ],
    'tabs' => [
        ['label' => 'General', 'fields' => ['company_name', 'company_phone', 'company_email']],
        ['label' => 'Footer',  'fields' => ['footer_text']],
    ],
]);

Options Page reuses the exact TAW\Core\Metabox\Metabox field renderer, so every field type behaves identically to its Metabox counterpart — the only difference is that values are read back with OptionsPage::get() instead of Metabox::get(). The same tabbed layout, width grid spans, validation, conditions logic, and readonly enforcement from metaboxes all apply.

If you're registering this from inc/options.php in a Theme::bootstrapFullSite() scaffold, translated field labels (__('Phone', 'taw-theme')) are safe to use as-is — bootstrapFullSite() (taw/core v1.16.67+) defers both the textdomain load and inc/options.php's own require to after_setup_theme, specifically to avoid WordPress 6.7+'s _load_textdomain_just_in_time notice. Don't call load_theme_textdomain() yourself in inc/customizations.php — it's already handled.

Supported field types

Each field below uses a type value that maps directly to the Options Page renderer.

Other OptionsPage config options

OptionDefaultDescription
id(required)Unique slug used as the admin menu page slug and the settings group name
titleValue of idPage heading shown in the WordPress admin content area
menu_titleValue of titleLabel shown in the WordPress admin sidebar menu
capability'manage_options'WordPress capability required to view and save the page
prefix'_taw_'Option name prefix applied to all field IDs
icon'dashicons-admin-generic'Dashicon or SVG string used for the top-level admin menu icon
position(none)Menu order position passed to add_menu_page()
fields[]Top-level field definitions, same format as Metabox
tabs[]Optional tabbed layout — same format as Metabox tabs

Retrieval

use TAW\Core\OptionsPage\OptionsPage;

$phone = OptionsPage::get('company_phone');
$logo  = OptionsPage::get_image_url('logo', 'medium');

TAW\Core\Menu\Menu wraps WordPress nav menus into a typed tree, giving you full control over markup without wp_nav_menu().

use TAW\Core\Menu\Menu;

$menu = Menu::get('primary');
if ($menu && $menu->hasItems()) {
    foreach ($menu->items() as $item) {
        echo '<a href="' . esc_url($item->url()) . '"';
        if ($item->openInNewTab()) echo ' target="_blank" rel="noopener"';
        echo '>' . esc_html($item->title()) . '</a>';

        if ($item->hasChildren()) {
            foreach ($item->children() as $child) {
                // render child item
            }
        }
    }
}
MethodReturnsDescription
Menu::get($location)?MenuLoad a menu by its registered location slug
$menu->items()MenuItem[]Root-level items
$menu->hasItems()bool
$menu->name()stringThe menu name set in WordPress admin
MethodReturnsDescription
title()stringMenu item label
url()stringDestination URL
target()string'_self' or '_blank'
openInNewTab()boolTrue when target is _blank
hasChildren()bool
children()MenuItem[]Direct child items
isActive()boolCurrent page matches this item
isActiveParent()boolA child of this item is the current page
isActiveAncestor()boolA descendant of this item is the current page
isInActiveTrail()boolThis item or any ancestor/descendant is the current page
classes()string[]Custom classes only (WP auto-classes filtered out)
wpClasses()string[]All classes including WP's auto-generated ones
objectType()stringObject type ('page', 'post', 'custom', etc.)
objectId()intThe underlying post/term ID
description()stringItem description set in WordPress menu editor
wpPost()WP_PostThe raw WP menu item object

Menus (primary, footer, etc.) are registered via register_nav_menus() in functions.php. Assign menus to locations in WordPress Admin → Appearance → Menus.

REST API

TAW Core registers the following REST endpoints automatically via Theme::boot():

MethodEndpointPurpose
GET/taw/v1/search-postsPost search powering post_select fields. Requires edit_posts.
POST/taw/v1/visual-editor/saveSave Visual Editor changes. Requires edit_posts.
GET/taw/v1/visual-editor/fieldsLoad all registered fields and current values for the editor panel. Requires edit_posts.
GET/taw/v1/iconsLucide icon search powering the icon field type. Requires edit_posts. Only registered when Lucide::enable() was called — see Icon System.

search-posts

TAW\Core\Rest\SearchEndpoints powers the post_select metabox field.

Example request:

GET /wp-json/taw/v1/search-posts?s=hero&post_type=page&per_page=5
Authorization: Cookie (requires edit_posts capability)

Example responses:

[
  {
    "id": 42,
    "title": "Home",
    "post_type": "page",
    "status": "publish",
    "date": "2025-01-15T10:30:00",
    "edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit",
    "permalink": "https://example.com/",
    "thumbnail": "https://example.com/wp-content/uploads/hero.jpg"
  }
]

Query parameters

query
sstring

Search string. Omit to return the most recent posts.

query
post_typestring

Post type(s) to search — comma-separated for multiple (e.g. post,page). Defaults to post. Passing page is handled correctly even though WordPress treats it as a special case internally.

query
per_pageinteger

Results per page. Accepts 150. Defaults to 10.

query
excludestring

Comma-separated post IDs to exclude from results.

icons

TAW\Core\Rest\IconsEndpoint powers the icon metabox field's wp-admin picker. Only registered when Lucide::enable() has been called.

Example request:

GET /wp-json/taw/v1/icons?search=arrow&per_page=20
Authorization: Cookie (requires edit_posts capability)

Example response:

[
  { "name": "arrow-right", "svg": "<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>" },
  { "name": "arrow-left", "svg": "<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>" }
]
query
per_pageinteger

Results per page. Accepts 1120. Defaults to 60.

Visual Editor

TAW\Core\Editor\VisualEditor provides inline admin editing on the frontend. It is opt-in — you must explicitly enable it before calling Theme::boot(). In taw-theme scaffolds using Theme::bootstrapFullSite(), functions.php is framework-owned, so this goes in inc/customizations.php instead, which bootstrapFullSite() guarantees loads before boot():

// inc/customizations.php
use TAW\Core\Editor\VisualEditor;

VisualEditor::enable();

Once enabled, authenticated users with edit_posts capability see an Edit Visually button in the WordPress admin bar. Appending ?taw_visual_edit=1 to any URL also activates the editing shell.

What works automatically

No template changes are required for the core experience:

  • All MetaBlock sections are wrapped in a clickable container (data-taw-block-section) with hover and active outlines.
  • Clicking a section on the page opens its fields in the editor panel.
  • Typing in a panel text field updates the matching text on the page in real time (content-matching heuristic — works when the field value appears as a discrete text node).
  • The panel shows only the blocks queued for the current page via BlockRegistry::queue().

All registered metabox fields appear in the editor panel automatically. Add 'editor' => false to a field definition to exclude it from the panel.

Changes are saved via POST /wp-json/taw/v1/visual-editor/save using the same sanitization pipeline as metaboxes.

Optional template annotations

Add annotations to make inline editing precise and to enable "Edit inline on page" mode:

// Wrap a value so it's directly clickable on the page
<?= Editor::field($data['headline'], 'hero', 'headline', 'h2') ?>

// Add data attributes to an existing element (e.g. <img>)
<img <?= Editor::attrs('hero', 'hero_image') ?> src="...">

Without annotations the panel still shows and saves all fields, and live preview works via content matching. Annotations give the editor a direct DOM reference, making live updates exact.

Mail

Use TAW\Core\Mail\MailTemplate to work with HTML or MJML templates from your theme and TAW\Core\Mail\Mailer to send emails with variable replacement.

Pre-compiled HTML templates live at mails/html/{name}.html (used in production). MJML source files live at mails/{name}.mjml and are compiled at runtime via spatie/mjml-php during development. Each template supports {{variable_name}} placeholders.

A minimal example of sending a templated email:

use TAW\Core\Mail\Mailer;

$sent = (new Mailer())
    ->to('support@acme-agency.test')
    ->subject('New contact form submission')
    ->template('contact')          // → mails/html/contact.html (prod) or mails/contact.mjml (dev)
    ->setVariables([
        'name'    => 'Jane Chen',
        'email'   => 'jane@acme.com',
        'message' => 'I would like to discuss a new project.',
    ])
    ->send();

if (! $sent) {
    // Handle failed mail transport (log, retry, etc.).
}

Mailer uses the underlying wp_mail() transport. Make sure your WordPress site is configured with a working mail provider (SMTP or transactional service) so test and production emails are delivered reliably.

TAW\Core\Mail\MailTemplate compiles templates from your theme, performs {{var}} replacement, and is responsible for producing the final HTML payload that Mailer sends.

Admin entrypoints and testing

TAW Core adds admin screens to help you operate and test these modules without writing ad-hoc scripts.

  • Tools → Test Emails — register MailTester in functions.php to get a page for sending test emails using your templates and variables to verify layout and delivery in your environment:

    (new \TAW\Core\Mail\MailTester())->register();
    
  • Settings → Form Webhook — configures the webhook URL and HMAC secret that SubmissionsHandler uses when posting saved submissions to an external endpoint.

Use these screens to confirm templates render as expected and form submissions reach your downstream systems before wiring them into live flows.

Icon System

TAW Core vendors the full Lucide icon set (~1,750 icons) directly inside the package — resources/icons/lucide/ (SVGs) plus resources/icons/lucide-index.json (a searchable name/keyword index). The wp-admin icon picker reads only these local files, so it never makes a network call. Re-vendor the set with php bin/taw icons:sync whenever Lucide ships new icons.

The icon picker is opt-in. Call this once, in inc/customizations.php, before Theme::boot():

TAW\Core\Icons\Lucide::enable();

Without it, an icon field renders an inline notice instead of the picker.

Using the icon field type

Declare it exactly like any other Metabox or OptionsPage field — see the Icon entry above for the full declaration/usage example. The stored value is a bare icon name (e.g. house), sanitized with sanitize_key().

Rendering icons in templates

Lucide::render() needs no enable() call — the same relationship Svg::register() (upload support) has to Svg::inline()/Svg::render() (template output). It reads the vendored SVG and merges class, attr, and title onto the root element.

use TAW\Core\Icons\Lucide;

echo Lucide::render('arrow-right', [
    'class' => 'w-5 h-5 text-blue-600',
    'title' => 'Next',
]);

Lucide's SVGs use stroke="currentColor", so CSS/Tailwind text-color utilities control icon color for free. Lucide::render() returns an empty string for an unknown or malformed name — safe to echo unconditionally.

Icon search REST endpoint

GET taw/v1/icons?search=&per_page= powers the wp-admin picker (see REST API below) and is only registered once Lucide::enable() has been called. Requires edit_posts plus a valid wp_rest nonce, same as the other TAW REST routes.

Media Folders

Nestable Media Library folders, built on a single hierarchical taxonomy (taw_media_folder) registered on attachment with show_in_rest enabled. That one flag gives the whole feature its REST layer for free, straight from WordPress core — there's no custom REST endpoint class:

  • Full folder (term) CRUD, including re-nesting via parent, at wp/v2/taw_media_folder.
  • A taw_media_folder query param on the existing wp/v2/media route, for filtering by folder and for reassigning a file's folder (PATCH wp/v2/media/<id>).

Opt-in at the framework level, but on by default in the taw-theme scaffold's inc/customizations.php:

TAW\Core\Media\MediaFolders::enable();

Remove that line for a site that doesn't need folder organization. Only the upload_files capability is required — not manage_options.

Three admin surfaces

File-size sorting reads a dedicated _taw_media_filesize postmeta value, since WordPress core only stores file size nested inside a serialized metadata array that SQL can't ORDER BY directly. It's populated on every new upload and backfilled once for pre-existing attachments the first time anyone sorts by size.

A folder's position in the tree is its only "category" — one folder per attachment (wp_set_object_terms()), with no separate tagging layer on top.

Helpers

TAW Core ships static helper classes for common operations, all PSR-4 autoloaded under TAW\Helpers\.

Framework paths (TAW\Helpers\Framework)

Resolve absolute paths and public URLs relative to either the taw/core package itself or your theme root. Useful for referencing packaged assets without hard-coding paths.

use TAW\Helpers\Framework;

// Absolute filesystem path within taw-core
Framework::path('assets/admin.css');

// Public URL within taw-core
Framework::url('assets/admin.css');

// Absolute filesystem path within the active theme
Framework::themePath('resources/');

// Public URL within the active theme
Framework::themeUrl('resources/');

Debug utilities (TAW\Helpers\Dump)

Formatted debug helpers intended for local and development environments only. Do not leave these calls in production code.

use TAW\Helpers\Dump;

// Die and dump — outputs a formatted value and halts execution
Dump::dd($value);

// Log — writes a formatted value to the error log without halting
Dump::log($value);