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.
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.
Getting installed
- Upload the
oformsfolder 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
Plugin constants
| Constant | Value |
|---|---|
OF_VERSION | 1.1.0 |
OF_FILE | Absolute path to oforms.php |
OF_DIR | Absolute path to plugin directory (trailing slash) |
OF_URL | URL to plugin directory (trailing slash) |
Plugin structure
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
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.
| Row | Height | Sticky offset | Contents |
|---|---|---|---|
| Brand bar | 50px | top: 0 | Logo, version badge, user avatar, dark/light toggle |
| Nav strip | 44px | top: 50px | Tab links: Forms, Entries, Workflows, Analytics, Settings |
| Context bar | ~40px | top: 94px | Breadcrumb + page-specific action buttons |
Theme toggle saves preference to localStorage as oforms_theme. Dark is the default. Toggles of-light class on #of-wrap.
| Tab | URL param | Controller |
|---|---|---|
| Forms | tab=forms | FormController |
| Entries | tab=entries | EntryController |
| Workflows | tab=workflows | WorkflowController |
| Analytics | tab=analytics | AnalyticsController |
| Settings | tab=settings | SettingsController |
18 field types
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"
}
}
Four visual themes
Set via settings.theme. Applied as .of-theme--{theme} on .of-wrapper.
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.
ofBlockForms global (array of {id, name} objects), localised by PHP during enqueue_block_editor_assets.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
| Layer | Behaviour |
|---|---|
| PHP (form-render.php) | Loops fields. On step_break: increments $current_step, records step label. Groups fields into $steps[$step_index][]. |
| HTML output | Steps 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
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
| Operator | Logic |
|---|---|
equals | Case-insensitive exact match |
not_equals | Inverse of equals |
contains | Substring match |
not_contains | Inverse of contains |
starts_with | Prefix match |
not_empty | Field 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.
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_websitefield must be empty. Bots fill it; humans don't. - 3Nonce check:
_of_nonceverified againstof_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"] }
}
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
| Type | Description |
|---|---|
always | Runs on every submission regardless of field values |
field | Runs only when a specific field's value matches the condition (same 6 operators as conditional logic) |
Action types
| Action | Config keys | Description |
|---|---|---|
send_email | to, subject, body | Sends via wp_mail(). Supports template variables: {all_fields}, {field_ID}, {form_name}, {date}, {site_name} |
send_webhook | url, headers[] | POSTs JSON payload with flat field labels and values + _entry_id, _source |
tag_entry | tag | Appends a tag string to data._tags array in the database |
subscribe_omailer | list_id, email_field_id, name_field_id | Calls 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.
Entries management
Status flow
Every entry moves through a lifecycle of statuses, shown as tabs across the top of the Entries list.
| Status | Badge | Meaning |
|---|---|---|
new | New | Freshly submitted, unread |
read | Read | Viewed by admin |
starred | Starred | Marked for attention |
spam | Spam | Marked as spam |
trash | Trash | Soft-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).
Submission analytics
OForms → Analytics tab. Select a form and time range (7, 14, 30, or 90 days).
| Component | Description |
|---|---|
| Stat cards | Total submissions (all time) + submissions in the selected period |
| Bar chart | CSS-only bar chart — one bar per day, proportional to the max daily count. Hover shows date and count. |
| Detail table | Daily 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}, ...].
reCAPTCHA v3
Setup
- Go to OForms → Settings
- Enable reCAPTCHA v3
- Enter your Site Key and Secret Key from google.com/recaptcha
How it works
| Layer | Behaviour |
|---|---|
| Frontend | Google 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. |
| Backend | SubmissionHandler::verify_recaptcha() POSTs token to Google. If response.success === false or response.score < 0.5, submission is rejected. |
| Preview mode | reCAPTCHA is disabled — no site key passed to ofData — so forms can be tested without triggering Google scoring. |
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).
Print receipt
Each entry has a Print Receipt button in its detail view. Opens a standalone printable HTML page in a new tab.
| Detail | Value |
|---|---|
| URL format | admin.php?page=oforms&tab=entries&action=receipt&entry_id=N&form_id=N |
| Required capability | manage_options |
| Print button | Calls window.print(). Hidden by @media print CSS for clean output. |
| Internal fields | Fields whose label starts with _ are excluded from the receipt. |
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.
ofData — so you can test forms without Google scoring interference.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
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AI PK | Form ID |
name | VARCHAR(200) | Form display name |
settings | LONGTEXT | JSON: submit label, success message, theme, redirect, reCAPTCHA |
created_at | DATETIME | Creation timestamp |
updated_at | DATETIME ON UPDATE | Last modified |
of_form_fields
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AI PK | Field ID |
form_id | BIGINT UNSIGNED | FK → of_forms.id |
type | VARCHAR(50) | Field type slug |
config | LONGTEXT | JSON field config (label, options, required, condition, etc.) |
sort_order | INT DEFAULT 0 | Display order |
of_entries
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AI PK | Entry ID |
form_id | BIGINT UNSIGNED | FK → of_forms.id |
data | LONGTEXT | JSON: { field_id: { label, value } } |
ip_address | VARCHAR(45) | Submitter IP (CF, proxy-aware) |
user_agent | VARCHAR(500) | Browser user agent |
status | VARCHAR(20) DEFAULT 'new' | new, read, starred, spam, trash |
email_status | VARCHAR(20) DEFAULT 'pending' | pending, sent, failed |
created_at | DATETIME | Submission timestamp |
of_workflows & of_queue
| Table | Key columns |
|---|---|
of_workflows | form_id, name, rules (JSON: condition + actions array), active (TINYINT) |
of_queue | workflow_id, entry_id, action_data (JSON), status (pending/done/failed), scheduled_at |
REST API
Base namespace: oforms/v1. All endpoints are read-only and require manage_options permission.
| Method | Endpoint | Description |
|---|---|---|
| GET | /oforms/v1/forms | List all forms |
| GET | /oforms/v1/forms/{id} | Single form with fields |
| GET | /oforms/v1/forms/{id}/entries | Entries for a form |
| GET | /oforms/v1/entries/{id} | Single entry |
Hooks & filters
Actions
| Hook | When fired | Arguments |
|---|---|---|
of_loaded | After all plugin components boot | — |
of_submission_before | Before saving a new entry | $form_id, $data |
of_submission_after | After saving a new entry | $form_id, $entry_id, $data |
of_workflow_action | After 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
Settings reference
Global settings (wp_options key: of_settings)
| Key | Type | Description |
|---|---|---|
recaptcha_enabled | bool | Enable reCAPTCHA v3 globally |
recaptcha_site_key | string | Google reCAPTCHA site key |
recaptcha_secret_key | string | Google reCAPTCHA secret key |
Per-form settings (of_forms.settings JSON)
| Key | Type | Description |
|---|---|---|
submit_label | string | Submit button text |
success_message | string | Shown after submission (HTML allowed) |
success_type | string | message or redirect |
redirect_url | string | URL to redirect to on success |
theme | string | default, classic, minimal, or card |
_recaptcha_enabled | bool | Override: enable reCAPTCHA for this form only |
_recaptcha_site_key | string | Override: per-form reCAPTCHA key |
Extending OForms
Adding a custom field type
- Add your type slug to the
$field_typesarray inform-edit.php - Add a rendering case in
form-render.php(switch($type)block) - Add a config UI case in
appendField()inadmin-v1.js - Add serialisation logic in the form editor submit handler in
admin-v1.js - Add any validation logic in
validateField()inform-v1.js
Adding a custom form theme
- Add your theme slug to
$allowedinform-render.php - Add
.of-theme--{slug}CSS rules toform-v1.css - Add the option to the theme
<select>inform-edit.php
Adding a custom workflow action
- Add a
caseinWorkflowEngine::run_action() - Add the option to the
<select>inworkflow-edit.php - Handle the action config UI in
appendAction()inadmin-v1.js - Handle serialisation in the workflow editor submit handler in
admin-v1.js
Security model
| Threat | Mitigation |
|---|---|
| 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 bots | Honeypot field (_of_website, tabindex="-1", autocomplete="off") + optional reCAPTCHA v3 |
| SQL injection | All queries use $wpdb->prepare() with %d, %s placeholders |
| XSS (output) | All output escaped with esc_html(), esc_attr(), esc_url(), wp_kses_post() |
| Privilege escalation | Every admin action checks current_user_can('manage_options') |
| File uploads | accept attribute restricts MIME types client-side; server-side validation via WordPress media handling |
| Preview access | Preview URL requires valid nonce + manage_options capability |
| Receipt access | Receipt URL requires manage_options capability |
| reCAPTCHA bypass | Score threshold of 0.5 enforced server-side; frontend token only |
Troubleshooting
Installer::maybe_upgrade(). Alternatively, deactivate and reactivate the plugin.[oform id="X"] uses the correct form ID, the form has at least one active field, and jQuery is loaded by the theme.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".localhost — Google allows it for v3 but the score may be low. Confirm the reCAPTCHA v3 JS loads without CSP errors.wp cron event list via WP-CLI).?of_preview=X query string.wp_of_entries table for rows with matching form_id.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.Got a question about OForms?
Reach out directly — Kenneth replies within 24 hours.
