Skip to main content
UTB Product Builder drives its frontend forms from a JSON field array stored in product meta (_utb_form_config). Instead of hardcoding HTML, each flow reads this array at render time and generates the form dynamically. This means you can add, reorder, or change fields without touching PHP.

Configuration structure

A form configuration is a flat JSON array. Each element is a field descriptor — an object with a type key that determines what gets rendered and what other properties are valid.
[
  {
    "id": "section_personal",
    "type": "heading",
    "label": "Personal information"
  },
  {
    "id": "applicant_name",
    "name": "applicant_name",
    "type": "text",
    "label": "Full name",
    "required": true,
    "placeholder": "Enter your full name"
  },
  {
    "id": "contact_email",
    "name": "contact_email",
    "type": "email",
    "label": "Email address",
    "required": true
  }
]

Common field properties

PropertyTypeDescription
idstringUnique identifier within the form. Used for targeting by logic_binding.
namestringThe HTML name attribute and the key in $_POST.
typestringField type — see the full list below.
labelstringVisible label text.
requiredbooleanIf true, the field is marked required and server-side validation enforces it.
placeholderstringInput placeholder text.
descriptionstringHelper text rendered below the input.
css_classstringAdditional CSS class applied to the field wrapper.
minnumberMinimum character length (minlength attribute).
maxnumberMaximum character length (maxlength attribute).
patternstringHTML pattern attribute for client-side regex validation.
animationstringAdds utb-anim-{value} CSS class for entrance animations.
logic_bindingobjectConditional visibility rule — see Logic binding.

Field types

text

Single-line text input. Also handles email, tel, and number subtypes via the type property directly.

email

Single-line input with type="email". Browser and server-side format validation applied.

select

Dropdown with static options. Provide an options object: { "value": "Label" }.

dynamic_select

Dropdown populated at runtime from a registered data source. Use system_source_id and optional filters — see Dynamic selects.

textarea

Multi-line text area.

checkbox

Single checkbox. Value posted is 1 when checked.

file

File upload input. Supports accept (MIME/extension list) and max_size (kilobytes).

heading

Non-input section divider with a visible label. Renders an <h3> heading.

notice

Informational callout block with icon, title, and message (HTML allowed). Supports style: info, warning, success, or error.

system_program_selector

Special internal type used by CEPFlow. Renders the program dropdown with price labels and discount banners. Use system_source_id to override the data source.

Static select example

{
  "id": "document_type",
  "name": "document_type",
  "type": "select",
  "label": "Document type",
  "required": true,
  "options": {
    "cc": "Cédula de Ciudadanía",
    "ce": "Cédula de Extranjería",
    "ti": "Tarjeta de Identidad",
    "pasaporte": "Pasaporte"
  }
}

file field example

{
  "id": "identity_doc",
  "name": "identity_doc",
  "type": "file",
  "label": "Attach identity document",
  "required": true,
  "accept": ".pdf,.jpg,.jpeg,.png,.doc,.docx",
  "max_size": 2048
}

Logic binding

The logic_binding object makes a field conditionally visible based on the current value of another field. The frontend JavaScript reads this at render time and attaches event listeners automatically — no custom JS required.
{
  "id": "student_id_field",
  "name": "student_id",
  "type": "text",
  "label": "Student ID",
  "logic_binding": {
    "action": "show",
    "target_field": "applicant_type",
    "operator": "equals",
    "value": "STUDENT"
  }
}
In this example, student_id_field is hidden by default and only shown when the field with id applicant_type has the value STUDENT.

logic_binding properties

logic_binding is processed by the frontend JavaScript (form-builder.js). It controls client-side field visibility.
PropertyValuesDescription
actionshowAction to take when the condition is true. The field is hidden otherwise.
target_fieldstringThe id of the controlling field.
operatorequalsComparison operator used by the frontend JS.
valuestringThe value the controlling field must have for the condition to be true.
For server-side conditional logic (used during RulesEngine::validate_form()), fields use a conditions array instead:
{
  "id": "passport_country",
  "name": "passport_country",
  "type": "text",
  "label": "Country of issue",
  "conditions": [
    {
      "field": "document_type",
      "operator": "equals",
      "value": "pasaporte"
    }
  ]
}
Server-side conditions support these operators: equals, not_equals, contains, greater_than, less_than.
You can use logic_binding to implement step-style forms — show additional fields only after the user selects a programme, chooses a document type, and so on.

Dynamic selects

A dynamic_select (or system_program_selector) field loads its options from a registered data source managed by DataSourceManager. Data sources are stored in the wp_utb_data_sources database table and can be one of four types:

db_table

Reads rows from a WordPress database table. Configure table, id_column, label_column, and value_column.

api

Fetches and caches options from an external HTTP endpoint. Configure endpoint, method, response_path, label_field, and value_field.

custom_query

Executes a raw SQL query. Use {{filter:column}} placeholders to inject runtime filter values.

taxonomy

Loads terms from any registered WordPress taxonomy, including WooCommerce product attributes (pa_*).
Reference a data source in a field descriptor:
{
  "id": "program_selector",
  "name": "cep_programa",
  "type": "system_program_selector",
  "label": "Program",
  "required": true,
  "system_source_id": "utb_cep_programs",
  "filters": {
    "nivel": "pregrado"
  },
  "description": "Select the continuing education program you want to enroll in."
}

Pricing rules in schema

Pricing logic does not live in the field schema itself — it lives in calculate_dynamic_price() on the flow class. The schema fields supply the values (programme code, academic level, format, etc.) that are stored as cart metadata by prepare_cart_metadata(). Your flow then reads those metadata keys to compute the price. See Dynamic pricing for full details.

FormConfigManager

UTB\ProductBuilder\Data\FormConfigManager is the class that loads the active configuration for a given product and flow combination. It tries sources in this order:
  1. Direct product metaget_post_meta($product_id, '_utb_form_config', true). If the meta contains valid JSON, it is used as-is.
  2. Linked certificate metaget_post_meta($product_id, '_utb_linked_cert_id', true). If a linked certificate has form_config_json in its database row, that is decoded and returned.
  3. Flow default config — Calls $flow->get_default_config() if the flow implements that method.
$manager = new \UTB\ProductBuilder\Data\FormConfigManager();
$config  = $manager->get_config($product_id, $flow_id);
// $config = [ 'id' => ..., 'custom_css' => ..., 'fields' => [...] ]

Server-side validation with RulesEngine

UTB\ProductBuilder\Logic\RulesEngine provides three public static methods you can call from a flow’s validate_cart_data() or an AJAX handler to validate form data against the field schema.

validate_field(array $field_config, mixed $value, array $context = []): array

Validates a single field value. Returns ['valid' => bool, 'message' => string].
use UTB\ProductBuilder\Logic\RulesEngine;

$field = ['type' => 'email', 'label' => 'Email', 'required' => true];
$result = RulesEngine::validate_field($field, $value, $context);

if (!$result['valid']) {
    return $result['message']; // Return error to validate_cart_data()
}
Validation order:
  1. Required check (if required: true and value is empty → fails)
  2. Basic type check (email, number)
  3. API rule check (if validation_rule_id is set; calls the configured API via ApiConnectionManager)

validate_form(array $form_config, array $form_data, array $context = []): array

Validates all fields in a form config array at once. Returns ['valid' => bool, 'errors' => array]. Fields that fail the conditions check are skipped automatically.
$config  = (new \UTB\ProductBuilder\Data\FormConfigManager())->get_config($product_id, $flow_id);
$result  = RulesEngine::validate_form($config['fields'], $_POST, ['product_id' => $product_id]);

if (!$result['valid']) {
    // $result['errors'] is a ['field_name' => 'error message'] map
    return implode(' | ', $result['errors']);
}

should_show_field(array $field_config, array $all_values): bool

Returns true if a field should be shown given the current form values. Uses the conditions array on the field descriptor.
$show = RulesEngine::should_show_field($field, $_POST);

Saving a schema programmatically

Save a field array directly to a product’s meta:
$fields = [
    [
        'id'       => 'section_info',
        'type'     => 'heading',
        'label'    => 'Applicant information',
    ],
    [
        'id'       => 'applicant_name',
        'name'     => 'applicant_name',
        'type'     => 'text',
        'label'    => 'Full name',
        'required' => true,
    ],
    [
        'id'      => 'contact_email',
        'name'    => 'contact_email',
        'type'    => 'email',
        'label'   => 'Email address',
        'required' => true,
    ],
];

update_post_meta($product_id, '_utb_form_config', wp_json_encode($fields));
Always use wp_json_encode() when writing JSON to post meta so special characters are escaped correctly. Read it back with json_decode($json, true) and check json_last_error() === JSON_ERROR_NONE before trusting the result.

Complete form configuration example

The following is a representative schema combining most field types and a logic_binding condition:
[
  {
    "id": "notice_intro",
    "type": "notice",
    "style": "info",
    "icon": "ℹ️",
    "title": "Important",
    "message": "Complete the form exactly as your identity document shows."
  },
  {
    "id": "section_personal",
    "type": "heading",
    "label": "Personal information"
  },
  {
    "id": "first_name",
    "name": "first_name",
    "type": "text",
    "label": "First name",
    "required": true
  },
  {
    "id": "document_type",
    "name": "document_type",
    "type": "select",
    "label": "Document type",
    "required": true,
    "options": {
      "cc": "Cédula de Ciudadanía",
      "pasaporte": "Passport"
    }
  },
  {
    "id": "passport_country",
    "name": "passport_country",
    "type": "text",
    "label": "Country of issue",
    "required": true,
    "logic_binding": {
      "action": "show",
      "target_field": "document_type",
      "operator": "equals",
      "value": "pasaporte"
    }
  },
  {
    "id": "section_program",
    "type": "heading",
    "label": "Program selection"
  },
  {
    "id": "program_selector",
    "name": "cep_programa",
    "type": "system_program_selector",
    "label": "Program",
    "required": true,
    "system_source_id": "utb_cep_programs"
  },
  {
    "id": "identity_upload",
    "name": "identity_upload",
    "type": "file",
    "label": "Attach identity document",
    "required": true,
    "accept": ".pdf,.jpg,.jpeg,.png",
    "max_size": 2048
  }
]

Build docs developers (and LLMs) love