Skip to content

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

TablePurpose
formfunnelsFunnel header — name, slug, status (draft/published/archived), start_node_id, save_abandoned, abandon_after_min, settings_json, multilang via base_id + language_short.
formfunnel_nodesNodes — type (step/condition/end), position_x/y, config_json (fields, rules, end-kind), sort_order.
formfunnel_edgesDirected edges — from_node_id, to_node_id, branch_label (default/true/false), sort_order.
formfunnel_submissionsSessions — 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_routingRules — 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_typesStatic registry of 33 field types with default_config JSON.
v_funnel_inbox, v_funnel_dropoff_30d, v_funnel_branches_30dAnalytics views (Sprint 6).

formfunnel_nodes, formfunnel_edges, formfunnel_submissions and formfunnel_email_routing cascade on formfunnel_id delete.

Plugin registration (bootstrap.php)

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.

MethodActionPurpose
GETlistList all funnels for the current language.
GETget&id=XFunnel detail with nodes, edges, email_routings.
GETsubmissionsPaginated submissions with filter/sort against v_funnel_inbox.
GETsubmission_get&id=XSubmission detail incl. path_resolved (joined node names + config).
GETanalytics&funnel_id=XDrop-off + branch + time-series for last 30 days.
GETfield_typesAll 33 field-type definitions for the field editor.
GETemail_routings&funnel_id=XRouting rules for a funnel.
GETassignable_usersBackend-user list for the inbox assign-picker.
POSTcreateInsert a new funnel.
POSTsaveAtomic save — funnel header + nodes + edges + routings in a transaction. Negative client_id on new nodes; the response maps them to real DB IDs.
POSTdeleteCascade delete.
POSTduplicateDeep-clone with fresh IDs.
POSTpublishValidates 1 step + 1 end + start_node_id, then sets status.
POSTsubmission_updateUpdate inbox_status / inbox_notes / assigned_to.
POSTsubmission_deleteDSGVO single-delete.
POSTsubmissions_bulkBulk set_status / assign / delete over ids[].
POSTemail_routing_saveReplace all rules atomically.
POSTpreviewServer-side preview-step (Sprint 8a).

Frontend (public, with rate-limit + Origin-Gate)

PathPurpose
POST /api/formfunnel/startCreate session. Body: {funnel_id, source_url?, referer?, utm_*?}. Response: {session_uuid, funnel, node}. Rate-limited 30/IP/min.
POST /api/formfunnel/stepSubmit step answers. Resolves Conditions server-side via ConditionEvaluator, returns the next step/end node (skipping conditions).
POST /api/formfunnel/completeFinal submit. Triggers EmailDispatcher::dispatchForSubmission() and the formfunnel.completed webhook.
POST /api/formfunnel/abandonBeacon-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.

ts
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:

  1. Resolve recipients/cc/bcc against the email_marketing provider chain.
  2. Render subject and body_html with token substitution against answers_json.
  3. If auto_reply_enabled = 1, send a confirmation to the first email-typed answer.
  4. Fire webhook formfunnel.completed (or formfunnel.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 validationFunnelSettingsModal blocks the toggle unless the start step contains a consent-typed field.
  • DB writes per step — every /step upsert persists answers to formfunnel_submissions.answers_json.
  • Cron process_abandoned.php — every 5 min, marks status='abandoned' and abandoned_at=NOW() for sessions older than abandon_after_min minutes (default 60).
  • Webhook + email-routing on abandoned — same as completed, but only rules with trigger_event='abandoned' fire.
  • Cron cleanup_old_submissions.php — daily, deletes submissions older than 30 days (DSGVO retention).

Pagebuilder widget

php
$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 a template/fields/{type}.vue file, register a validator in composables/useFieldValidator.ts. The FieldRenderer glob picks it up automatically.
  • New end-kind — extend the kind enum in EndNode.vue's properties, add a renderer branch in widgets/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 via WebhookDispatcher::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 INSERTlast_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