Forms & Funnels Plugin
The formfunnel plugin orchestrates multi-step lead-qualification funnels: a directed acyclic graph of step, condition and end nodes with conditional routing, path-merging, per-rule email dispatch, abandoned-form tracking and a centralised inbox. This page documents the plugin internals; for the editor workflow see the user-facing guide.
Directory structure
_public/extensions/core/backend/formfunnel/
├── bootstrap.php # formfunnel_BackendPlugin (REQUIRED)
├── api/
│ ├── backend/formfunnel/
│ │ └── model.php # Backend admin API (auth required)
│ └── formfunnel/
│ ├── start/model.php # POST — create session
│ ├── step/model.php # POST — submit a step's answers
│ ├── complete/model.php # POST — final submit
│ └── abandon/model.php # POST — Beacon-API on tab close
├── lib/
│ ├── ConditionEvaluator.php # 15 operators, AND/OR/groups
│ ├── SubmissionHelper.php # session UUID, UTM pickup, rate-limit
│ ├── EmailDispatcher.php # provider-pluggable transactional sender
│ └── FieldTypeRegistry.php # 33 field-type definitions
├── layout/
│ ├── index.vue # 4-tab admin layout (Funnels/Editor/Inbox/Analytics)
│ ├── editor/
│ │ ├── FlowEditor.vue # 3-column Vue-Flow canvas
│ │ ├── PreviewModal.vue # Live / Path-Trace / Validate
│ │ ├── FunnelSettingsModal.vue
│ │ ├── EmailRoutingTab.vue
│ │ ├── PropertiesPanel.vue
│ │ ├── nodes/ # StepNode / ConditionNode / EndNode
│ │ └── routing/ # ConditionRulesEditor / EmailPillInput / EmailRoutingModal
│ └── inbox/
│ ├── SubmissionList.vue # PrimeVue DataTable with bulk-actions
│ └── SubmissionDetail.vue # Drill-down modal
├── widgets/funnel/
│ ├── bootstrap.php # funnel_PageBuilderPlugin
│ └── template/
│ ├── index.vue # FunnelRenderer container
│ ├── components/ # StepRenderer / ProgressBar / ThankYouRenderer
│ └── fields/ # 33 field-type Vue components
├── script/submissions/csvExport.php # csvExport class (dataModel())
├── scheduledTasks/
│ ├── process_abandoned.php # every 5 min
│ └── cleanup_old_submissions.php # daily
└── migrations/ # 5 SQL files (001 init, 002 views, 003 seed, 004 indices, 099 demo)Database schema
| Table | Purpose |
|---|---|
formfunnels | Funnel header — name, slug, status (draft/published/archived), start_node_id, save_abandoned, abandon_after_min, settings_json, multilang via base_id + language_short. |
formfunnel_nodes | Nodes — type (step/condition/end), position_x/y, config_json (fields, rules, end-kind), sort_order. |
formfunnel_edges | Directed edges — from_node_id, to_node_id, branch_label (default/true/false), sort_order. |
formfunnel_submissions | Sessions — session_uuid, client_hash, status (in_progress/completed/abandoned), path_json (visited node-ids), answers_json, full UTM, inbox_status (new/qualified/contacted/converted/rejected), inbox_notes, assigned_to. |
formfunnel_email_routing | Rules — condition_json (or NULL for catch-all), recipients/cc/bcc, subject, body_html, auto_reply_*, trigger_event (completed/abandoned), sort_order, is_active. |
formfunnel_field_types | Static registry of 33 field types with default_config JSON. |
v_funnel_inbox, v_funnel_dropoff_30d, v_funnel_branches_30d | Analytics views (Sprint 6). |
formfunnel_nodes, formfunnel_edges, formfunnel_submissions and formfunnel_email_routing cascade on formfunnel_id delete.
Plugin registration (bootstrap.php)
class formfunnel_BackendPlugin extends install_controller
{
public function install()
{
$this->url_rewrites_backend = ["formfunnel"];
$this->content_construct = ["template"];
$this->backend_widget_base = ["core/backend/formfunnel"];
$this->icon = "fa-light fa-filter-list";
$this->menu_group = 2;
$this->apiEndpoints = [
'{"modelPath":"backend/formfunnel/model.php","endpoint":"backend/formfunnel"}',
'{"modelPath":"formfunnel/start/model.php","endpoint":"formfunnel/start"}',
'{"modelPath":"formfunnel/step/model.php","endpoint":"formfunnel/step"}',
'{"modelPath":"formfunnel/complete/model.php","endpoint":"formfunnel/complete"}',
'{"modelPath":"formfunnel/abandon/model.php","endpoint":"formfunnel/abandon"}',
];
$this->webhookEvents = [
'{"name":"formfunnel.started","type":"manual"}',
'{"name":"formfunnel.step_completed","type":"manual"}',
'{"name":"formfunnel.completed","type":"manual"}',
'{"name":"formfunnel.abandoned","type":"manual"}',
];
$this->scheduledTasks = [
'{"name":"...abandoned-marker","script":"process_abandoned.php","interval":300}',
'{"name":"...cleanup","script":"cleanup_old_submissions.php","interval":86400}',
];
$this->pagebuilder_widgets = [/* widget_menu JSON */];
}
}WARNING
apiEndpoints, webhookEvents, scheduledTasks and pagebuilder_widgets entries are JSON-encoded strings, not PHP arrays. Mind the array brackets in the source.
API endpoints
Backend (admin, auth required)
/api/backend/formfunnel?action=... dispatches via match on the action query parameter.
| Method | Action | Purpose |
|---|---|---|
GET | list | List all funnels for the current language. |
GET | get&id=X | Funnel detail with nodes, edges, email_routings. |
GET | submissions | Paginated submissions with filter/sort against v_funnel_inbox. |
GET | submission_get&id=X | Submission detail incl. path_resolved (joined node names + config). |
GET | analytics&funnel_id=X | Drop-off + branch + time-series for last 30 days. |
GET | field_types | All 33 field-type definitions for the field editor. |
GET | email_routings&funnel_id=X | Routing rules for a funnel. |
GET | assignable_users | Backend-user list for the inbox assign-picker. |
POST | create | Insert a new funnel. |
POST | save | Atomic save — funnel header + nodes + edges + routings in a transaction. Negative client_id on new nodes; the response maps them to real DB IDs. |
POST | delete | Cascade delete. |
POST | duplicate | Deep-clone with fresh IDs. |
POST | publish | Validates 1 step + 1 end + start_node_id, then sets status. |
POST | submission_update | Update inbox_status / inbox_notes / assigned_to. |
POST | submission_delete | DSGVO single-delete. |
POST | submissions_bulk | Bulk set_status / assign / delete over ids[]. |
POST | email_routing_save | Replace all rules atomically. |
POST | preview | Server-side preview-step (Sprint 8a). |
Frontend (public, with rate-limit + Origin-Gate)
| Path | Purpose |
|---|---|
POST /api/formfunnel/start | Create session. Body: {funnel_id, source_url?, referer?, utm_*?}. Response: {session_uuid, funnel, node}. Rate-limited 30/IP/min. |
POST /api/formfunnel/step | Submit step answers. Resolves Conditions server-side via ConditionEvaluator, returns the next step/end node (skipping conditions). |
POST /api/formfunnel/complete | Final submit. Triggers EmailDispatcher::dispatchForSubmission() and the formfunnel.completed webhook. |
POST /api/formfunnel/abandon | Beacon-API endpoint for tab-close. Marks the session as abandoned only if save_abandoned=1. |
Lifecycle
┌──────────────────────────┐
│ POST /formfunnel/start │
│ INSERT submission │
│ status = in_progress │
│ path = [start_id] │
│ answers = {} │
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ POST /formfunnel/step │ ← N times
│ merge answers │
│ append node to path │
│ ConditionEvaluator │
│ next = step or end │
└─────────────┬────────────┘
│
┌───────────────┴────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ /complete │ │ /abandon (beacon) │
│ status=completed│ │ status=abandoned │
│ EmailDispatcher │ │ (only if │
│ webhook │ │ save_abandoned) │
└─────────────────┘ └──────────────────┘ConditionEvaluator
Identical PHP and TypeScript implementation — lib/ConditionEvaluator.php and _theme/vue-base/app/composables/useConditionEvaluator.ts. Same JSON shape, same operator semantics. The frontend uses it for the editor's Live-Preview / Path-Trace; the backend uses it for actual step-routing and email-rule evaluation.
type ConditionDefinition = {
operator?: 'AND' | 'OR'
rules?: Array<{ field: string; op: ConditionOperator; value?: unknown }>
groups?: ConditionDefinition[]
}15 operators: equals, not_equals, greater_than, less_than, greater_or_equal, less_or_equal, contains, not_contains, starts_with, ends_with, regex_match, in, not_in, is_empty, is_not_empty.
null or empty rules evaluate to true (catch-all).
EmailDispatcher and Routing
Rules are evaluated top-down, first match wins. Catch-all (condition_json IS NULL) is the last-resort fallback. Per rule:
- Resolve
recipients/cc/bccagainst theemail_marketingprovider chain. - Render
subjectandbody_htmlwithtoken substitution againstanswers_json. - If
auto_reply_enabled = 1, send a confirmation to the firstemail-typed answer. - Fire webhook
formfunnel.completed(orformfunnel.abandoned) with submission payload.
Provider order: Brevo → Mailchimp → SMTP fallback. The EmailProviderInterface was extended with sendTransactional() in Sprint 7a.
Abandoned-form tracking
save_abandoned = 1 on the funnel turns on:
- Editor validation —
FunnelSettingsModalblocks the toggle unless the start step contains aconsent-typed field. - DB writes per step — every
/stepupsert persists answers toformfunnel_submissions.answers_json. - Cron
process_abandoned.php— every 5 min, marksstatus='abandoned'andabandoned_at=NOW()for sessions older thanabandon_after_minminutes (default 60). - Webhook + email-routing on
abandoned— same as completed, but only rules withtrigger_event='abandoned'fire. - Cron
cleanup_old_submissions.php— daily, deletes submissions older than 30 days (DSGVO retention).
Pagebuilder widget
$this->pagebuilder_widgets = ['{
"widget_title": "Forms & Funnel",
"widget_template": "funnel",
"widget_menu": [
{ "row": "title", "type": "select", "title": "Funnel waehlen",
"select_value": { "table": "formfunnels", "value": "id", "title": "name" } },
{ "row": "subtitle", "type": "select", "title": "Anzeige-Modus",
"select_value": { "options": [
{"value":"inline","title":"Inline auf der Seite"},
{"value":"modal","title":"Modal-Overlay"},
{"value":"fullscreen","title":"Fullscreen-Takeover"}
]}},
{ "row": "text", "type": "select", "title": "Trigger",
"select_value": { "options": [
{"value":"button_click","title":"Button-Klick"},
{"value":"scroll_50","title":"50% Scroll-Tiefe"},
{"value":"time_5s","title":"Nach 5 Sekunden"},
{"value":"exit_intent","title":"Bei Exit-Intent"}
]}}
]
}'];The widget calls getWidgetContent() to load the full funnel definition into widget.content and lets the FunnelRenderer.vue walk through the steps client-side. Step-routing happens via /formfunnel/step round-trips so Conditions can be evaluated against the latest answers.
Extension points
- New field type — add a row to
formfunnel_field_types(migration), drop atemplate/fields/{type}.vuefile, register a validator incomposables/useFieldValidator.ts. TheFieldRendererglob picks it up automatically. - New end-kind — extend the
kindenum inEndNode.vue's properties, add a renderer branch inwidgets/funnel/template/components/ThankYouRenderer.vue. - New email provider — implement
EmailProviderInterface::sendTransactional()in_public/extensions/core/backend/emailmarketing/providers/{Provider}.php. The dispatcher iterates active providers in fallback order. - New webhook event — register in
bootstrap.php::webhookEvents, dispatch viaWebhookDispatcher::dispatch('formfunnel.your_event', $payload).
Common issues
Public API class names with dashes return 401. URL formfunnel/my-action → class formfunnel_my_action, not formfunnel_my-action. Missing the conversion lets publicMethods silently fail and routes the request through API-key validation. See apiBaseController::loadApiEndpoint().
insert_id() returns 0 after a transaction. Use insert_id() immediately after the INSERT — last_insert_id() does not exist in this CMS. Mind that nested INSERT calls inside START TRANSACTION reset it; cache the value in a local variable.
condition_json saved as string, not JSON. The model.php::saveFunnel() expects condition_json as a PHP array — json_encode() runs server-side. Sending an already-encoded string double-escapes the value.
Funnel saves but Conditions never match server-side. Check that field IDs in condition_json.rules[].field exactly match field IDs in the step's config_json.fields[].id. Trailing whitespace and case differences silently fail.
See also
- Plugin Anatomy — install/uninstall/update lifecycle.
- API Endpoints —
apiEndpointsregistration shape. - Webhook Events —
webhookEventsregistration. - Scheduled Tasks — cron registration.
- Forms & Funnels — User Guide — editor workflow.