OForms v1.2.0
A WordPress form builder with 23 field types, multi-step forms, conditional logic, an 11-action workflow engine, async email queue, webhook subscriptions, CRM integrations, REST API with key auth, and full data ownership across 10 custom database tables.
What OForms does
Drag-and-drop form builder with 23 field types, multi-step flows, conditional logic, and an 11-action workflow engine. All submissions stored across 10 custom tables.
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.2.0 |
OF_PLUGIN_SLUG | oforms |
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 |
23 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 |
ends_with | Suffix match |
not_empty | Field has any non-empty value |
is_empty | Field is blank or unset |
greater_than | Numeric comparison (cast as float) |
less_than | Numeric comparison (cast as float) |
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"] }
}
Save & continue, abandonment capture
Two AJAX endpoints persist in-progress work without creating an entry. Both stored in wp_of_partial_entries with a 7-day TTL.
Save & continue
Endpoint: admin-ajax.php?action=of_save_partial (logged-in or nopriv). Body: form_id, resume_token (optional), current_step, field_data (JSON). Returns the resume token. Resuming via ?of_resume=token rehydrates the form.
Abandonment capture
Endpoint: admin-ajax.php?action=of_log_abandonment. Fires on visibilitychange when the user leaves with at least one field touched. Stored with type = 'abandoned' for funnel analysis. Both partial and abandoned rows are pruned by the of_process_queue cron handler when expires_at passes.
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 10 operators as conditional logic) |
Action types (11)
| Action | Config keys | Description |
|---|---|---|
send_email | to, subject, body | Pushes onto wp_of_email_queue. Supports template variables: {all_fields}, {field_ID}, {form_name}, {date}, {site_name} |
send_webhook | url, headers[] | POSTs JSON payload with field labels + values, signed with HMAC-SHA256 if a secret is set |
tag_entry | tag | Appends a tag string to data._tags array in the entry row |
subscribe_omailer | list_id, email_field_id, name_field_id | Calls omailer_subscribe() if OMailer is active; silently no-ops if not |
create_wp_user | email_field_id, role, username_field_id | Creates a WordPress user from form values; sends standard new-user email |
create_post | post_type, title_field_id, content_field_id, status | Creates a CPT or post with mapped field values |
send_slack | webhook_url, channel, text | Posts to a Slack incoming webhook URL with merge-tag rendered text |
send_discord | webhook_url, content, username | Posts to a Discord webhook with rendered content |
update_entry_field | field_id, value | Mutates a value on the just-created entry (useful for normalisation) |
duplicate_to_form | target_form_id, field_map | Copies the entry into another form, mapping field IDs through field_map |
crm_subscribe | provider, list_id, field_map | Routes the entry through CrmManager (Mailchimp / HubSpot / Brevo / ActiveCampaign / ConvertKit) |
Delayed actions
Any action can have a delay_seconds value. Delayed actions are stored in wp_of_queue and processed by the of_process_queue cron event, which fires every minute via the of_every_minute custom schedule. Failed actions retry up to 5 times with exponential backoff.
Async email queue
When email_async is true, notifications are pushed onto wp_of_email_queue instead of sent inline. Median submission latency drops below 40ms because the response no longer blocks on SMTP.
- Cron schedule: custom
of_every_minute, firesof_process_email_queue - Rate limit: 30 emails per minute (constant
RATE_PER_MINUTEinEmailQueue) - Retry policy: max 3 attempts before status flips to
failed - SMTP detection:
SmtpDetectorwarns in Settings if no SMTP plugin is active so transactional mail does not silently route through PHPmail() - Filters:
of_email_before_sendmutates args;of_send_admin_notification/of_send_user_confirmationshort-circuit per submission
Webhook subscriptions
Persistent webhook endpoints managed under Settings or via REST. Stored in wp_of_webhook_subscriptions. Each subscription can be scoped to a single form (form_id) or fire across all forms (form_id = 0).
Signing
If a secret is set on the subscription, the dispatcher computes hash_hmac('sha256', $payload, $secret) and sends it in the X-OForms-Signature header. Receivers should reject any payload that fails to verify with hash_equals().
REST management
CRUD via /wp-json/of/v1/webhooks. Test a wired endpoint with POST /webhooks/{id}/test; the dispatcher fires a synthetic payload and returns the receiver's response code and latency.
CRM integrations
Workflow action crm_subscribe routes entries through CrmManager. Five providers ship in-tree, all extending CrmIntegration:
OForms\Integrations\MailchimpOForms\Integrations\HubSpotOForms\Integrations\BrevoOForms\Integrations\ActiveCampaignOForms\Integrations\ConvertKit
Register a custom provider with add_action('of_register_crm_integrations', fn($mgr) => $mgr::register(new MyCrm()));. Each integration declares its credential fields, which render automatically in Settings.
Starter templates
Six prebuilt templates one click away under Forms → New from template. Source: OForms\Forms\FormTemplates::all().
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 with 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), a 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(), which 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
Ten custom tables, all prefixed with {prefix}of_. Created on activation via dbDelta(). Schema version stored in of_db_version; migrations run automatically when OF_VERSION changes (e.g. adds country + device_type columns to of_entries when upgrading).
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 (proxy-aware) |
country | VARCHAR(2) | ISO country derived during analytics rollup |
device_type | VARCHAR(10) | desktop / mobile / tablet |
user_agent | TEXT | Raw browser UA |
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), attempts, scheduled_at, processed_at |
of_partial_entries
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AI PK | Partial entry ID |
form_id | BIGINT UNSIGNED | FK → of_forms.id |
token | VARCHAR(64) UNIQUE | Resume URL token |
data | LONGTEXT | JSON of fields completed so far |
step | SMALLINT UNSIGNED | Current multi-step index |
type | VARCHAR(20) | partial (Save & continue) or abandoned |
expires_at | DATETIME | Default 7 days; pruned by cron |
of_webhook_subscriptions
| Column | Type | Description |
|---|---|---|
form_id | BIGINT UNSIGNED | 0 = all forms; otherwise scoped to one form |
name | VARCHAR(255) | Display name (e.g. "Zapier: New leads") |
url | VARCHAR(2083) | HTTPS endpoint that receives POSTs |
secret | VARCHAR(255) | Optional HMAC-SHA256 signing secret |
events | VARCHAR(255) | Comma list, default submission |
active | TINYINT(1) | Toggle without deleting |
of_views
One row per form impression for conversion analytics: form_id, variant (a / b for split tests), session_id, ip_address, device_type, country, created_at. Joined with of_entries by form_id + date window to compute conversion rate.
of_email_queue
| Column | Type | Description |
|---|---|---|
entry_id | BIGINT UNSIGNED | Source entry (0 if not entry-related) |
form_id | BIGINT UNSIGNED | Source form |
recipient / subject / body | VARCHAR / LONGTEXT | Email payload |
headers / attachments | TEXT | Newline-separated |
status | VARCHAR(20) | pending, sent, failed |
attempts | TINYINT UNSIGNED | Max 3, then marked failed |
scheduled_at / sent_at | DATETIME | Cron flushes at 30 messages / minute |
of_api_keys
Authenticates REST calls without a logged-in admin cookie. Columns: name, api_key (UNIQUE, prefixed ofk_), created_at, last_used. Rotate keys from Settings; revoking deletes the row.
REST API
Base namespace: of/v1. Read and write endpoints. Authenticate either with a logged-in admin cookie (manage_options) or an API key passed as X-OForms-Key: ofk_... header. Keys are managed in Settings and stored in wp_of_api_keys.
| Method | Endpoint | Description |
|---|---|---|
| GET | /of/v1/forms | List all forms |
| GET | /of/v1/forms/{id}/fields | Field schema for a form |
| GET | /of/v1/forms/{id}/entries | Paginated entries for a form |
| GET | /of/v1/forms/{id}/entries/export | Streamed CSV / JSON export (limit, offset, status, search args) |
| GET | /of/v1/forms/{id}/workflows | Workflows attached to a form |
| POST | /of/v1/forms/{id}/submit | Public submission endpoint, honeypot + reCAPTCHA enforced |
| GET / PUT / DELETE | /of/v1/entries/{id} | Read, update status, or delete a single entry |
| GET | /of/v1/submissions/recent | Recent entries across every form for dashboards |
| GET | /of/v1/analytics/{form_id} | Conversion analytics with view + entry rollups |
| GET / POST | /of/v1/webhooks | List or create webhook subscriptions |
| GET / PUT / PATCH / DELETE | /of/v1/webhooks/{id} | Manage a single webhook subscription |
| POST | /of/v1/webhooks/{id}/test | Fire a test payload at the endpoint |
| GET / POST | /of/v1/api-keys | List or generate API keys |
| DELETE | /of/v1/api-keys/{id} | Revoke an API key |
REST API keys
Authenticate REST calls without an admin cookie. Keys live in wp_of_api_keys with name, api_key, created_at, last_used.
- Create:
POST /wp-json/of/v1/api-keyswith{ "name": "zapier" }returns the key once; store it server-side - Use: pass header
X-OForms-Key: ofk_live_...on any request - Audit:
last_usedupdates on every authenticated call so leaks show up immediately - Revoke:
DELETE /wp-json/of/v1/api-keys/{id}or remove from Settings
Hooks & filters
Actions
| Hook | When fired | Arguments |
|---|---|---|
of_loaded | After all plugin components boot | (none) |
of_hooks_registered | After the Hooks class registers extension points | (none) |
of_entry_created | After an entry row is inserted into wp_of_entries | $entry_id, $form_id, $data |
of_workflow_action | After each workflow action runs | $type, $action, $entry_id, $data |
of_run_crm_action | Routes crm_subscribe actions through CrmManager | $action, $entry_id, $data |
of_register_crm_integrations | Lets addons register a custom CRM provider | $manager (CrmManager) |
of_process_queue | Cron event for workflow queue (every minute) | (none) |
of_process_email_queue | Cron event for the async email queue | (none) |
Filters
| Hook | Purpose | Default |
|---|---|---|
of_pre_submit_validate | Reject a submission with custom errors before save | WP_Error |
of_field_types | Add custom field types to the builder | 23 built-in types |
of_workflow_actions | Register custom workflow action types | 11 built-in actions |
of_analytics_sections | Inject custom panels into the Analytics tab | Default rollups |
of_send_admin_notification | Skip admin email per submission | true |
of_send_user_confirmation | Skip user confirmation email | true |
of_email_before_send | Mutate wp_mail args before dispatch | Args array |
of_email_brand_color | Brand colour used in HTML email template | #1A1040 |
of_address_country_list | Supply / filter the ISO 3166 country list | [] (host site provides) |
of_is_pro | Unlock Pro features without a license server | defined('OF_PRO_VERSION') |
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 |
|---|---|---|
from_name | string | Default From-name for outgoing emails |
from_email | string | Default From-address for outgoing emails |
disable_css | bool | Skip enqueuing form-v1.css for theme-driven styling |
honeypot_enabled | bool | Enable the _of_website honeypot field on every form |
recaptcha_enabled | bool | Enable reCAPTCHA v3 globally |
recaptcha_site_key | string | Google reCAPTCHA site key |
recaptcha_secret_key | string | Google reCAPTCHA secret key |
email_async | bool | Push notifications onto wp_of_email_queue instead of inline send |
data_retention_days | int | Days to keep entries before GdprManager purges them (0 = never) |
Other wp_options keys
| Key | Type | Description |
|---|---|---|
of_db_version | string | Tracks last-installed schema version; mismatch triggers Installer::install() |
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.

