Developer documentation

OForms v1.1.0

A WordPress form builder with 18 field types, multi-step forms, conditional logic, workflow automation, analytics, and full data ownership — stored in custom database tables, not post meta.

WordPress plugin WP 6.0 · PHP 8.1+ Namespace: OForms\* v1.1.0
01 · Overview

What OForms does

Drag-and-drop form builder with 18 field types, multi-step flows, conditional logic, and workflow automation. All submissions stored in custom tables — no post meta, no JSON blobs in the options table.

🧱
18 field types
Text, email, file upload, rating, heading, HTML embed, hidden, step break, and more
📑
Multi-step forms
Step-break fields split the form; progress bar and per-step validation included
🔀
Conditional logic
Show/hide any field based on another field's value with 6 operators
⚙️
Workflows
Send email, webhook, tag entry, or subscribe to OMailer on submission. Delayed actions via queue.
📊
Analytics
CSS-only bar chart + daily breakdown table for any form over 7–90 day windows
🗄️
Custom DB tables
5 tables: forms, fields, entries, workflows, queue. Not post meta.
🎨
4 themes
Default, Classic, Minimal, Card — applied as CSS class on the wrapper
🛡️
reCAPTCHA v3
Server-side score verification (≥ 0.5 threshold), disabled in preview mode
02 · Installation

Getting installed

  • Upload the oforms folder to /wp-content/plugins/
  • Activate through Plugins → Installed Plugins
  • Navigate to OForms in the WP admin menu
  • Database tables are created automatically on first activation
ℹ️Requires WordPress 6.0+, PHP 8.1+, MySQL 5.7+ / MariaDB 10.3+.

Plugin constants

ConstantValue
OF_VERSION1.1.0
OF_FILEAbsolute path to oforms.php
OF_DIRAbsolute path to plugin directory (trailing slash)
OF_URLURL to plugin directory (trailing slash)
03 · Plugin Structure

Plugin structure

oforms/ ├── oforms.php Bootstrap, constants, autoloader ├── uninstall.php Drops all tables on uninstall ├── assets/ │ ├── css/ │ │ ├── of-admin-v2.css Admin UI (dark/light tokens) │ │ └── form-v1.css Frontend form styles + themes │ └── js/ │ ├── admin-v1.js Builder, workflows, preview, theme toggle │ └── form-v1.js Validation, multi-step, reCAPTCHA, AJAX ├── blocks/oforms-embed/ │ ├── block.json Gutenberg block manifest │ └── index.js SelectControl + server-side render ├── includes/ │ ├── Admin/ │ │ ├── AdminMenu.php WP menu, script enqueue, POST routing │ │ └── Controllers/ │ │ ├── AnalyticsController.php │ │ ├── EntryController.php │ │ ├── FormController.php │ │ ├── SettingsController.php │ │ └── WorkflowController.php │ ├── Core/ │ │ ├── DB.php Static query helpers (insert/update/delete/get_row) │ │ ├── Installer.php dbDelta table creation + version migration │ │ └── Plugin.php Boot sequence, block registration │ ├── Emails/EmailDispatcher.php Template variable replacement + wp_mail() │ ├── Entries/EntryRepository.php CRUD + CSV + stats + receipt │ ├── Forms/ │ │ ├── FormRepository.php Form + field CRUD │ │ ├── ShortcodeHandler.php [oform] shortcode + preview handler │ │ └── SubmissionHandler.php AJAX, honeypot, reCAPTCHA, workflow trigger │ ├── Workflows/ │ │ ├── Queue.php WP cron runner for delayed actions │ │ └── WorkflowEngine.php Condition eval + action dispatch │ └── API/RestController.php REST endpoints (read-only) └── templates/ ├── form-render.php Frontend form HTML └── admin/ layout-header, form-list/edit/templates, entry-list/view, workflow-list/edit, analytics, settings
04 · Core Classes

Core classes

OForms\Core\Plugin

Singleton. Entry point called from oforms.php. boot() loads the text domain, runs DB migration, registers shortcode/submission/queue/REST, registers the Gutenberg block, boots the admin menu if is_admin(), then fires do_action('of_loaded').

PHPPlugin::get_instance()->boot();

OForms\Core\DB

Static $wpdb wrapper. All methods prepend {prefix}of_ to the table name automatically.

PHPDB::insert('entries', ['form_id' => 1, 'data' => '{}']);
DB::update('entries', ['status' => 'read'], ['id' => 42]);
DB::delete('entries', ['id' => 42]);
DB::get_row('forms', ['id' => 1]);
DB::get_results('entries', ['form_id' => 1], 'id DESC');

OForms\Forms\FormRepository

PHP$repo = new FormRepository();
$repo->all(200);
$repo->get(int $id);
$repo->get_fields(int $id);
$repo->create(string $name, array $settings): int
$repo->update(int $id, string $name, array $settings): void
$repo->save_fields(int $form_id, array $fields): void  // replaces all fields
$repo->delete(int $id): void
$repo->duplicate(int $id): int  // clones form + fields, appends " (Copy)"

OForms\Entries\EntryRepository

PHP$repo = new EntryRepository();
$repo->get(int $id);
$repo->for_form(int $form_id, int $limit, int $offset, string $status, string $search): array
$repo->count_for_form(int $form_id, string $status, string $search): int
$repo->create(int $form_id, array $data): int|false
$repo->update_status(int $id, string $status): void  // new|read|starred|spam|trash
$repo->update_email_status(int $id, string $status): void
$repo->delete(int $id): bool
$repo->get_stats_by_day(int $form_id, int $days = 30): array  // [{day, total}, ...]
$repo->export_csv(int $form_id): void  // streams CSV and exits
05 · Admin Interface

Admin interface

Full-viewport overlay below the WP admin bar — position: fixed; top: 32px; left: 0; right: 0; bottom: 0. No shared WP sidebar space. Three-row sticky header.

RowHeightSticky offsetContents
Brand bar50pxtop: 0Logo, version badge, user avatar, dark/light toggle
Nav strip44pxtop: 50pxTab links: Forms, Entries, Workflows, Analytics, Settings
Context bar~40pxtop: 94pxBreadcrumb + page-specific action buttons

Theme toggle saves preference to localStorage as oforms_theme. Dark is the default. Toggles of-light class on #of-wrap.

TabURL paramController
Formstab=formsFormController
Entriestab=entriesEntryController
Workflowstab=workflowsWorkflowController
Analyticstab=analyticsAnalyticsController
Settingstab=settingsSettingsController
06 · Field Types

18 field types

Standard input fields
text
<input type="text">
email
<input type="email">
Validated client + server
phone
<input type="tel">
number
<input type="number">
min / max / step config
url
<input type="url">
Validates https?:// prefix
textarea
<textarea>
Vertically resizable
dropdown
<select>
Options from config.options[]
radio
<input type="radio"> group
checkbox
<input type="checkbox"> group
Multi-select → array
date
<input type="date">
Native date picker
time
<input type="time">
file
<input type="file">
config.accept restricts MIME
range
<input type="range">
+ live value output
rating
SVG star group (CSS-only)
config.stars, default 5
Layout / content fields
heading
<h2>, <h3>, or <h4>
config.heading_level
html
<div> with wp_kses_post()
config.html_content
hidden
<input type="hidden">
config.default = value
step_break
Not rendered
Divides form into steps; config.label = step tab label

Field config JSON

JSON · of_form_fields.config{
  "label":              "Your Name",
  "placeholder":        "e.g. Jane Smith",
  "default":            "",
  "required":           true,
  "validation_message": "Please enter your full name.",
  "options":            ["Option A", "Option B"],
  "min":                0,
  "max":                100,
  "step":               1,
  "accept":             "jpg,png,pdf",
  "max_size":           2097152,
  "stars":              5,
  "heading_level":      "h3",
  "html_content":       "<p>Some <strong>HTML</strong></p>",
  "condition": {
    "field":    "42",
    "operator": "equals",
    "value":    "yes"
  }
}
07 · Form Themes

Four visual themes

Set via settings.theme. Applied as .of-theme--{theme} on .of-wrapper.

default
Default
Clean white, purple focus ring
classic
Classic
Georgia serif, parchment background, stacked borderless inputs
minimal
Minimal
Underline-only borders, uppercase labels, ghost submit button
card
Card
Boxed white card with shadow, rounded inputs, sky-blue accents
08 · Shortcode & Block

Shortcode & Gutenberg block

Shortcode

[oform id="42"]

Renders the form, enqueues form-v1.css and form-v1.js, and localises ofData to JS with the form's settings. The only parameter is id (required, form ID).

Gutenberg block

Block name: oforms/embed — registered via blocks/oforms-embed/block.json. The editor shows a SelectControl dropdown of all forms. Output is server-side rendered via do_shortcode('[oform id="X"]'); the save() function returns null.

ℹ️The block editor script requires the ofBlockForms global (array of {id, name} objects), localised by PHP during enqueue_block_editor_assets.
09 · Multi-Step Forms

Multi-step forms

Add one or more step_break fields. Fields before the first break are step 0; fields between breaks are subsequent steps.

How it works

LayerBehaviour
PHP (form-render.php)Loops fields. On step_break: increments $current_step, records step label. Groups fields into $steps[$step_index][].
HTML outputSteps render as <div class="of-step" data-step="N"> — steps > 0 start hidden. Progress bar with fill width + labels shown above. Each step has a .of-step-nav with Back / Next (or Submit on the last step).
JS (form-v1.js)goToStep(n) hides all steps, shows step n, updates progress bar. Next calls validateStep() on visible fields only. Submit on the last step triggers full submission.

Progress bar classes

  • Active step label: .of-step-label--active
  • Completed steps: .of-step-label--done (green)
  • Fill width updates proportionally to current step
10 · Conditional Logic

Conditional logic

Any field can be shown or hidden based on another field's value. Set config.condition on the field to configure it.

JSON · config.condition{
  "field":    "42",
  "operator": "equals",
  "value":    "yes"
}

field is the field ID (not name). Rendered as data-condition='...' on the .of-field div.

Supported operators

OperatorLogic
equalsCase-insensitive exact match
not_equalsInverse of equals
containsSubstring match
not_containsInverse of contains
starts_withPrefix match
not_emptyField has any non-empty value

JS behaviour

On any change input event, all conditional fields are re-evaluated. Hidden fields get class .of-field--hidden and their inputs are disabled — preventing submission of hidden data. Validation also skips .of-field--hidden fields.

11 · Form Submissions

Submission flow

All submissions go through AJAX to admin-ajax.php?action=of_submit — available to both logged-in and logged-out users.

  • 1User submits form — AJAX POST to of_submit.
  • 2Honeypot check: _of_website field must be empty. Bots fill it; humans don't.
  • 3Nonce check: _of_nonce verified against of_submit_{form_id}.
  • 4reCAPTCHA check (if enabled): token POSTed to Google, score must be ≥ 0.5.
  • 5Field validation: required fields, email format, etc.
  • 6Entry saved via EntryRepository::create().
  • 7Workflows triggered via WorkflowEngine::process().
  • 8Response returned: { success: true, data: { message } } or { redirect: "..." } or { errors: { field_name: "msg" } }.

Entry data format

Stored in of_entries.data as JSON. Internal keys (prefixed with _) are hidden from the receipt and entry view but stored for workflow use.

JSON{
  "42": { "label": "Your Name",  "value": "Jane Smith" },
  "43": { "label": "Email",      "value": "jane@example.com" },
  "44": { "label": "Topics",     "value": ["Support", "Billing"] }
}
12 · Workflows

Workflow automation

Workflows run automatically after every successful submission. Each workflow has one condition and one or more actions. Delayed actions are queued via WP-Cron.

Condition types

TypeDescription
alwaysRuns on every submission regardless of field values
fieldRuns only when a specific field's value matches the condition (same 6 operators as conditional logic)

Action types

ActionConfig keysDescription
send_emailto, subject, bodySends via wp_mail(). Supports template variables: {all_fields}, {field_ID}, {form_name}, {date}, {site_name}
send_webhookurl, headers[]POSTs JSON payload with flat field labels and values + _entry_id, _source
tag_entrytagAppends a tag string to data._tags array in the database
subscribe_omailerlist_id, email_field_id, name_field_idCalls omailer_subscribe() if OMailer is active — silently no-ops if not

Delayed actions

Any action can have a delay_seconds value. Delayed actions are stored in of_queue and processed by Queue::register() via WP-Cron — by default every 5 minutes.

13 · Entries Management

Entries management

Status flow

Every entry moves through a lifecycle of statuses, shown as tabs across the top of the Entries list.

StatusBadgeMeaning
newNewFreshly submitted, unread
readReadViewed by admin
starredStarredMarked for attention
spamSpamMarked as spam
trashTrashSoft-deleted (still in DB)

Filtering, search & bulk actions

Filter by status using the tab links. Full-text search via LIKE %query% on the data column. Both can be combined. Bulk actions: Mark as Read, Mark as Spam, Delete (hard-delete from database).

14 · Analytics

Submission analytics

OForms → Analytics tab. Select a form and time range (7, 14, 30, or 90 days).

ComponentDescription
Stat cardsTotal submissions (all time) + submissions in the selected period
Bar chartCSS-only bar chart — one bar per day, proportional to the max daily count. Hover shows date and count.
Detail tableDaily breakdown in reverse chronological order

Data source: EntryRepository::get_stats_by_day(int $form_id, int $days) — single GROUP BY DATE(created_at) query returning [{day, total}, ...].

15 · reCAPTCHA v3

reCAPTCHA v3

Setup

  1. Go to OForms → Settings
  2. Enable reCAPTCHA v3
  3. Enter your Site Key and Secret Key from google.com/recaptcha

How it works

LayerBehaviour
FrontendGoogle reCAPTCHA v3 script loads. On submit: grecaptcha.execute(siteKey, { action: 'oforms_submit' }) is called. Returned token injected into hidden _of_recaptcha field before AJAX request.
BackendSubmissionHandler::verify_recaptcha() POSTs token to Google. If response.success === false or response.score < 0.5, submission is rejected.
Preview modereCAPTCHA is disabled — no site key passed to ofData — so forms can be tested without triggering Google scoring.
16 · CSV Export

CSV export

Available from Entries → Export CSV. Streamed directly to the browser via EntryController::export()EntryRepository::export_csv(). Limited to 10,000 rows per export.

HTTP headersContent-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="entries-form-42-2026-04-24.csv"

Columns: ID, Submitted At, Status, IP Address, Email Status, Data (pipe-separated Label: Value pairs).

18 · Form Preview

Form preview

The Preview button in the form editor opens a modal with a live <iframe> of the form — admin-only, nonce-protected.

/?of_preview={form_id}&of_nonce={nonce}

Handler: ShortcodeHandler::maybe_preview() — hooks into template_redirect. Checks $_GET['of_preview'], verifies manage_options capability and nonce (of_preview_{form_id}), renders a minimal standalone HTML page with the form and its CSS/JS, then calls exit.

💡reCAPTCHA is disabled in preview mode — no site key is passed to ofData — so you can test forms without Google scoring interference.
19 · Database Schema

Database schema

Five custom tables — all prefixed with {prefix}of_. Created on activation via dbDelta(). Migrations run on every boot if OF_VERSION has changed.

of_forms

ColumnTypeDescription
idBIGINT UNSIGNED AI PKForm ID
nameVARCHAR(200)Form display name
settingsLONGTEXTJSON: submit label, success message, theme, redirect, reCAPTCHA
created_atDATETIMECreation timestamp
updated_atDATETIME ON UPDATELast modified

of_form_fields

ColumnTypeDescription
idBIGINT UNSIGNED AI PKField ID
form_idBIGINT UNSIGNEDFK → of_forms.id
typeVARCHAR(50)Field type slug
configLONGTEXTJSON field config (label, options, required, condition, etc.)
sort_orderINT DEFAULT 0Display order

of_entries

ColumnTypeDescription
idBIGINT UNSIGNED AI PKEntry ID
form_idBIGINT UNSIGNEDFK → of_forms.id
dataLONGTEXTJSON: { field_id: { label, value } }
ip_addressVARCHAR(45)Submitter IP (CF, proxy-aware)
user_agentVARCHAR(500)Browser user agent
statusVARCHAR(20) DEFAULT 'new'new, read, starred, spam, trash
email_statusVARCHAR(20) DEFAULT 'pending'pending, sent, failed
created_atDATETIMESubmission timestamp

of_workflows & of_queue

TableKey columns
of_workflowsform_id, name, rules (JSON: condition + actions array), active (TINYINT)
of_queueworkflow_id, entry_id, action_data (JSON), status (pending/done/failed), scheduled_at
20 · REST API

REST API

Base namespace: oforms/v1. All endpoints are read-only and require manage_options permission.

MethodEndpointDescription
GET/oforms/v1/formsList all forms
GET/oforms/v1/forms/{id}Single form with fields
GET/oforms/v1/forms/{id}/entriesEntries for a form
GET/oforms/v1/entries/{id}Single entry
21 · Hooks & Filters

Hooks & filters

Actions

HookWhen firedArguments
of_loadedAfter all plugin components boot
of_submission_beforeBefore saving a new entry$form_id, $data
of_submission_afterAfter saving a new entry$form_id, $entry_id, $data
of_workflow_actionAfter each workflow action runs$type, $action, $entry_id, $data

Custom workflow action via hook

PHPadd_action('of_workflow_action', function($type, $action, $entry_id, $data) {
    if ($type !== 'my_custom_action') return;
    // do something with the entry data
}, 10, 4);

OMailer integration

OForms detects OMailer by checking if omailer_subscribe() is defined. No explicit plugin dependency — if OMailer is not active, the subscribe_omailer action silently no-ops.

PHP · OMailer function signatureomailer_subscribe(int $list_id, string $email, string $name = ''): void
22 · Settings Reference

Settings reference

Global settings (wp_options key: of_settings)

KeyTypeDescription
recaptcha_enabledboolEnable reCAPTCHA v3 globally
recaptcha_site_keystringGoogle reCAPTCHA site key
recaptcha_secret_keystringGoogle reCAPTCHA secret key

Per-form settings (of_forms.settings JSON)

KeyTypeDescription
submit_labelstringSubmit button text
success_messagestringShown after submission (HTML allowed)
success_typestringmessage or redirect
redirect_urlstringURL to redirect to on success
themestringdefault, classic, minimal, or card
_recaptcha_enabledboolOverride: enable reCAPTCHA for this form only
_recaptcha_site_keystringOverride: per-form reCAPTCHA key
23 · Extending OForms

Extending OForms

Adding a custom field type

  1. Add your type slug to the $field_types array in form-edit.php
  2. Add a rendering case in form-render.php (switch($type) block)
  3. Add a config UI case in appendField() in admin-v1.js
  4. Add serialisation logic in the form editor submit handler in admin-v1.js
  5. Add any validation logic in validateField() in form-v1.js

Adding a custom form theme

  1. Add your theme slug to $allowed in form-render.php
  2. Add .of-theme--{slug} CSS rules to form-v1.css
  3. Add the option to the theme <select> in form-edit.php

Adding a custom workflow action

  1. Add a case in WorkflowEngine::run_action()
  2. Add the option to the <select> in workflow-edit.php
  3. Handle the action config UI in appendAction() in admin-v1.js
  4. Handle serialisation in the workflow editor submit handler in admin-v1.js
24 · Security

Security model

ThreatMitigation
CSRF (admin)wp_nonce_field('of_admin') + check_admin_referer() on every admin POST
CSRF (frontend)Form submission includes _of_nonce verified with wp_verify_nonce()
Spam botsHoneypot field (_of_website, tabindex="-1", autocomplete="off") + optional reCAPTCHA v3
SQL injectionAll queries use $wpdb->prepare() with %d, %s placeholders
XSS (output)All output escaped with esc_html(), esc_attr(), esc_url(), wp_kses_post()
Privilege escalationEvery admin action checks current_user_can('manage_options')
File uploadsaccept attribute restricts MIME types client-side; server-side validation via WordPress media handling
Preview accessPreview URL requires valid nonce + manage_options capability
Receipt accessReceipt URL requires manage_options capability
reCAPTCHA bypassScore threshold of 0.5 enforced server-side; frontend token only
25 · Troubleshooting

Troubleshooting

Tables not created
Go to Settings → General and save — this triggers Installer::maybe_upgrade(). Alternatively, deactivate and reactivate the plugin.
Form not appearing
Confirm [oform id="X"] uses the correct form ID, the form has at least one active field, and jQuery is loaded by the theme.
Submissions not saving
Check the browser console for AJAX errors. Verify admin-ajax.php is accessible (some security plugins block it). Check the honeypot is not being filled by browser autofill — the field has tabindex="-1" and autocomplete="off".
reCAPTCHA rejecting all submissions
Verify site key and secret key match the registered domain in Google Console. Test with localhost — Google allows it for v3 but the score may be low. Confirm the reCAPTCHA v3 JS loads without CSP errors.
Workflows not firing
Check the workflow is set to Active. If a condition is set, verify the field ID matches a field on the same form. For delayed actions, confirm WP-Cron is functioning (wp cron event list via WP-CLI).
Preview shows a blank page
The preview URL requires the user to be logged in as admin. Verify the nonce has not expired (they last 24 hours). Confirm no caching plugin is intercepting the ?of_preview=X query string.
Analytics shows no data
Select a form from the dropdown. Confirm entries exist within the selected date range. Check the wp_of_entries table for rows with matching form_id.
Admin layout issues
The admin UI uses position: fixed to cover the viewport below the WP admin bar at top: 32px. If your admin bar height differs, update the top: 32px value in #of-wrap in of-admin-v2.css.
✦ Need help?

Got a question about OForms?

Reach out directly — Kenneth replies within 24 hours.