Skip to main content
Forms in UTB Product Builder are not written in PHP or HTML templates. Each form is described by a JSON array of field objects stored in product post meta (_utb_form_config) or in a certificate record’s form_config_json column. The form renderer reads this array and generates the HTML, JavaScript event bindings, and AJAX handlers at runtime.

JSON configuration schema

Each element in the fields array is a field object. Common properties:
{
  "id": "cep_tipo_documento",
  "name": "cep_tipo_documento",
  "type": "select",
  "label": "Tipo de documento",
  "required": true,
  "options": {
    "cc": "Cédula de Ciudadanía",
    "ce": "Cédula de Extranjería",
    "ti": "Tarjeta de Identidad",
    "pasaporte": "Pasaporte"
  }
}

Supported field types

TypeDescription
textStandard text input
emailEmail input with browser validation
telTelephone input
numberNumeric input; supports min, max
selectDropdown; static options via options object
textareaMulti-line text area
checkboxSingle checkbox
fileFile upload; supports accept and max_size (KB)
hiddenHidden input (e.g., computed price)
heading / sectionVisual section separator with an <h3> label
noticeInformational callout with icon, title, and message
system_program_selectorSpecial: loads program options from DataSourceManager
system_certificate_selectorSpecial: loads certificate options from DataSourceManager

Validation attributes

Text-based fields support inline HTML validation:
{
  "id": "utb_documento",
  "type": "text",
  "min": 7,
  "max": 10,
  "pattern": "^[0-9]{7,10}$"
}
These map directly to the HTML attributes minlength, maxlength, and pattern.

Logic Binding system

The Logic Binding system is the mechanism that makes forms dynamic without per-form JavaScript. Instead of writing custom $('#field').on('change', ...) handlers, you declare the rule as a JSON object on the field that should respond. The generic frontend script (form-builder.js) reads all logic_binding objects on page load, then constructs event listeners dynamically.

Declarative binding example

The following field only appears when utb_campo_a equals "ESTUDIANTE":
{
  "id": "utb_campo_b",
  "type": "text",
  "logic_binding": {
    "action": "show",
    "target_field": "utb_campo_a",
    "operator": "equals",
    "value": "ESTUDIANTE"
  }
}

Logic binding properties

PropertyDescription
actionWhat to do when the rule matches: show or hide
target_fieldThe id of the field whose value is observed
operatorComparison to apply: equals, not_equals, contains, empty, not_empty
valueThe value to compare against

Special binding values

Beyond the generic declarative format, some fields use named binding strings that trigger specialized rendering logic:
Binding stringEffect
cert_selectorField renders the certificate <select> with AJAX reload on tipo/nivel change
cert_qtyField is the quantity input; hidden by default, shown only when the selected certificate has qty_enabled = 1
cert_programField renders the academic program <select> filtered by nivel
cep_programField renders the CEP program selector with real-time price display

How the form renderer works

1

FormConfigManager resolves the JSON

FormConfigManager::get_config(int $product_id, string $flow_id) checks, in order:
  1. Product post meta key _utb_form_config (JSON string)
  2. Linked certificate record’s form_config_json column via _utb_linked_cert_id meta
  3. The flow’s own get_default_config() method
2

Flow calls render_dynamic_form

The concrete flow receives the resolved config array and iterates $config['fields']. For each field object it calls its internal render_field() method.
3

render_field dispatches on type and binding

The renderer checks logic_binding and type to decide which HTML block to output. Special types such as system_program_selector delegate to dedicated private methods that query DataSourceManager.
4

Custom CSS is injected

If $config['custom_css'] is set, a scoped <style> block is output inside the form wrapper before the fields.
5

Frontend script binds logic

The flow’s enqueue_assets() method loads the flow-specific JS file and injects a localized data object (AJAX URL, nonce, config values) via wp_localize_script.

dynamic_select AJAX loading pattern

For certificate and program selectors, the form renders an initial <select> on page load. When the user changes a filter field (tipo, nivel, formato), the frontend fires an AJAX request to reload dependent options. CertificadosFlow example — reloading certificates after tipo/nivel change:
// AJAX action registered in get_ajax_endpoints()
public function get_ajax_endpoints(): array {
    return [
        'utb_get_certs' => 'ajax_get_certs',
        'utb_cert_price' => 'ajax_get_price',
    ];
}

// Handler
public function ajax_get_certs(): void {
    $tipo  = $this->sanitize_text($_POST['tipo']  ?? '');
    $nivel = $this->sanitize_text($_POST['nivel'] ?? '');

    $certs = $this->certs_repo->get_by_tipo_and_nivel($tipo, $nivel);
    $this->ajax_success(['certs' => $result]);
}
The JavaScript receives the certs array and re-populates the <select> without a page reload.

Real-time price updates

For CEP programs, price changes occur entirely client-side. Each <option> in the program selector carries a data-precio attribute populated from wp_utb_cep_programs.precio. The frontend reads this attribute on change and updates the price display without an AJAX round-trip. For Academic Certificates, price depends on three variables: certificate ID, delivery format, and academic level. These are resolved server-side:
// AJAX action: utb_cert_price
public function ajax_get_price(): void {
    $cert_id = $this->sanitize_int($_POST['cert_id'] ?? 0);
    $formato = $this->sanitize_text($_POST['formato'] ?? 'digital');
    $nivel   = $this->sanitize_text($_POST['nivel']   ?? '');
    $qty     = max(1, $this->sanitize_int($_POST['qty'] ?? 1));

    $price_unit  = $this->prices_repo->get_price($cert_id, $formato, $nivel);
    $price_total = $price_unit * $qty;

    $this->ajax_success([
        'price'      => $price_unit,
        'price_unit' => $price_unit,
        'price_total' => $price_total,
        'formatted'  => wc_price($price_total),
    ]);
}
The response includes a WooCommerce-formatted price string (formatted) that the frontend injects directly into the DOM.

Server-side validation flow

Validation happens in two stages: Stage 1 — WooCommerce add-to-cart hook AbstractFlow::validate_add_to_cart is bound to woocommerce_add_to_cart_validation. It calls the concrete flow’s validate_cart_data(array $post_data), which returns true or an error string. On error, wc_add_notice() is called and the add-to-cart action is blocked. Stage 2 — RulesEngine (dynamic validation) For fields that have API-backed validation rules configured from the dashboard, CertificadosFlow::validate_cart_data delegates to RulesEngine:
$rules_engine = new \UTB\ProductBuilder\Logic\RulesEngine();

foreach ($config['fields'] as $field) {
    // Skip hidden fields
    if (!$rules_engine->should_show_field($field, $post_data)) {
        continue;
    }

    $errors = $rules_engine->validate_field($field, $value);
    if (!empty($errors)) {
        return $errors[0];
    }
}
should_show_field evaluates logic_binding rules using the submitted POST data so that hidden (non-visible) fields are not validated. This keeps validation consistent with what the user actually sees.
Fields that are not currently visible due to a logic_binding rule are skipped during server-side validation. This prevents errors on fields the user had no opportunity to fill in.

CEP discount validation

The CEPFlow applies an additional AJAX-driven validation step before the user can add to cart. When the user enters their ID number, the frontend calls the cep_calculate_discount action:
  1. The server checks identity against Banner via CEPRolesValidator.
  2. If found, it compares the submitted names against the Banner record to prevent impersonation.
  3. If roles are found, CEPDiscountCalculator queries the discount table and returns descuento_porcentaje, precio_con_descuento, and rol_detectado.
  4. The result is cached in a WordPress Transient for 15 minutes keyed by md5(document + program + period + names_hash).
  5. The discount data is serialized to JSON and stored in a hidden input cep_discount_data, which is validated again server-side during validate_cart_data.
The server re-validates that the discount’s programa_codigo matches the selected program at add-to-cart time. A client-side-only discount applied to a different program will be rejected.

Build docs developers (and LLMs) love