Skip to content

Forms & Funnels Plugin

Das formfunnel-Plugin orchestriert mehrstufige Lead-Qualifizierungs-Funnels: einen gerichteten azyklischen Graphen aus step-, condition- und end-Nodes mit conditional Routing, Path-Merging, regelbasiertem Email-Versand, Abandoned-Form-Tracking und einer zentralen Inbox. Diese Seite dokumentiert die Plugin-Internals; den Editor-Workflow findest du in der User-Doku.

Verzeichnisstruktur

_public/extensions/core/backend/formfunnel/
├── bootstrap.php                  # formfunnel_BackendPlugin (PFLICHT)
├── api/
│   ├── backend/formfunnel/
│   │   └── model.php              # Backend-Admin-API (Auth Pflicht)
│   └── formfunnel/
│       ├── start/model.php        # POST  — Session anlegen
│       ├── step/model.php         # POST  — Step-Antworten senden
│       ├── complete/model.php     # POST  — Final-Submit
│       └── abandon/model.php      # POST  — Beacon-API beim Tab-Close
├── lib/
│   ├── ConditionEvaluator.php     # 15 Operatoren, AND/OR/Groups
│   ├── SubmissionHelper.php       # Session-UUID, UTM-Pickup, Rate-Limit
│   ├── EmailDispatcher.php        # Provider-pluggbarer Transactional-Sender
│   └── FieldTypeRegistry.php      # 33 Field-Type-Definitionen
├── layout/
│   ├── index.vue                  # 4-Tab-Admin-Layout (Funnels/Editor/Inbox/Analytics)
│   ├── editor/
│   │   ├── FlowEditor.vue         # 3-Spalten-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 mit Bulk-Aktionen
│       └── 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-Klasse (dataModel())
├── scheduledTasks/
│   ├── process_abandoned.php      # alle 5 min
│   └── cleanup_old_submissions.php # taeglich
└── migrations/                    # 5 SQL-Files (001 init, 002 views, 003 seed, 004 indices, 099 demo)

Datenbank-Schema

TabelleZweck
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 (Felder, Rules, End-Kind), sort_order.
formfunnel_edgesGerichtete 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 (besuchte Node-IDs), answers_json, vollstaendiger UTM, inbox_status (new/qualified/contacted/converted/rejected), inbox_notes, assigned_to.
formfunnel_email_routingRegeln — condition_json (oder NULL fuer Catch-all), recipients/cc/bcc, subject, body_html, auto_reply_*, trigger_event (completed/abandoned), sort_order, is_active.
formfunnel_field_typesStatische Registry der 33 Field-Types mit default_config JSON.
v_funnel_inbox, v_funnel_dropoff_30d, v_funnel_branches_30dAnalytics-Views (Sprint 6).

formfunnel_nodes, formfunnel_edges, formfunnel_submissions und formfunnel_email_routing cascaden beim formfunnel_id-Delete.

Plugin-Registrierung (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 und pagebuilder_widgets sind JSON-encodierte Strings, keine PHP-Arrays. Achte auf die Array-Brackets im Source.

API-Endpunkte

Backend (Admin, Auth Pflicht)

/api/backend/formfunnel?action=... dispatched via match ueber den action-Query-Parameter.

MethodeActionZweck
GETlistAlle Funnels der aktuellen Sprache.
GETget&id=XFunnel-Detail mit Nodes, Edges, email_routings.
GETsubmissionsPaginierte Submissions mit Filter/Sort gegen v_funnel_inbox.
GETsubmission_get&id=XSubmission-Detail inkl. path_resolved (joined Node-Namen + Config).
GETanalytics&funnel_id=XDrop-off + Branch + Time-Series fuer die letzten 30 Tage.
GETfield_typesAlle 33 Field-Type-Definitionen fuer den Field-Editor.
GETemail_routings&funnel_id=XRouting-Regeln eines Funnels.
GETassignable_usersBackend-User-Liste fuer den Inbox-Assign-Picker.
POSTcreateNeuen Funnel anlegen.
POSTsaveAtomarer Save — Funnel-Header + Nodes + Edges + Routings in einer Transaktion. Negative client_id bei neuen Nodes; die Response mappt sie auf echte DB-IDs.
POSTdeleteCascade-Delete.
POSTduplicateDeep-Clone mit frischen IDs.
POSTpublishValidiert 1 Step + 1 End + start_node_id, dann setzt Status.
POSTsubmission_updateUpdate inbox_status / inbox_notes / assigned_to.
POSTsubmission_deleteDSGVO-Single-Delete.
POSTsubmissions_bulkBulk set_status / assign / delete ueber ids[].
POSTemail_routing_saveErsetzt alle Regeln atomar.
POSTpreviewServer-seitige Preview-Step (Sprint 8a).

Frontend (public, mit Rate-Limit + Origin-Gate)

PfadZweck
POST /api/formfunnel/startSession anlegen. Body: {funnel_id, source_url?, referer?, utm_*?}. Response: {session_uuid, funnel, node}. Rate-limit 30/IP/min.
POST /api/formfunnel/stepStep-Antworten senden. Loest Conditions serverseitig via ConditionEvaluator auf, liefert den naechsten step/end-Node (Conditions werden geskippt).
POST /api/formfunnel/completeFinal-Submit. Triggert EmailDispatcher::dispatchForSubmission() und den formfunnel.completed-Webhook.
POST /api/formfunnel/abandonBeacon-API-Endpunkt fuer den Tab-Close. Markiert die Session nur als abandoned wenn save_abandoned=1.

Lifecycle

              ┌──────────────────────────┐
              │  POST /formfunnel/start  │
              │   INSERT submission      │
              │   status = in_progress   │
              │   path = [start_id]      │
              │   answers = {}           │
              └─────────────┬────────────┘


              ┌──────────────────────────┐
              │  POST /formfunnel/step   │  ← N-mal
              │   merge answers          │
              │   append node to path    │
              │   ConditionEvaluator     │
              │   next = step or end     │
              └─────────────┬────────────┘

            ┌───────────────┴────────────────┐
            ▼                                ▼
   ┌─────────────────┐              ┌──────────────────┐
   │ /complete       │              │ /abandon (beacon) │
   │ status=completed│              │ status=abandoned  │
   │ EmailDispatcher │              │ (nur wenn         │
   │ webhook         │              │  save_abandoned)  │
   └─────────────────┘              └──────────────────┘

ConditionEvaluator

Identische PHP- und TypeScript-Implementation — lib/ConditionEvaluator.php und _theme/vue-base/app/composables/useConditionEvaluator.ts. Gleiche JSON-Form, gleiche Operator-Semantik. Das Frontend nutzt sie fuer Live-Preview / Path-Trace im Editor; das Backend nutzt sie fuer das tatsaechliche Step-Routing und die Email-Rule-Evaluation.

ts
type ConditionDefinition = {
  operator?: 'AND' | 'OR'
  rules?: Array<{ field: string; op: ConditionOperator; value?: unknown }>
  groups?: ConditionDefinition[]
}

15 Operatoren: 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 oder leere Rules evaluieren zu true (Catch-all).

EmailDispatcher und Routing

Regeln werden top-down ausgewertet, erste Match gewinnt. Catch-all (condition_json IS NULL) ist die letzte Fallback-Stufe. Pro Regel:

  1. recipients/cc/bcc gegen die email_marketing-Provider-Chain aufloesen.
  2. subject und body_html mit -Token-Substitution gegen answers_json rendern.
  3. Wenn auto_reply_enabled = 1, eine Bestaetigung ans erste email-typisierte Antwort-Feld senden.
  4. Webhook formfunnel.completed (oder formfunnel.abandoned) mit Submission-Payload feuern.

Provider-Reihenfolge: Brevo → Mailchimp → SMTP-Fallback. Das EmailProviderInterface wurde in Sprint 7a um sendTransactional() erweitert.

Abandoned-Form-Tracking

save_abandoned = 1 auf dem Funnel aktiviert:

  • Editor-ValidationFunnelSettingsModal blockiert den Toggle, ausser der Start-Step enthaelt ein Feld vom Typ consent.
  • DB-Writes pro Step — jedes /step-Upsert persistiert Antworten in formfunnel_submissions.answers_json.
  • Cron process_abandoned.php — alle 5 min, markiert status='abandoned' und abandoned_at=NOW() fuer Sessions aelter als abandon_after_min Minuten (Default 60).
  • Webhook + Email-Routing auf abandoned — wie completed, aber nur Regeln mit trigger_event='abandoned' feuern.
  • Cron cleanup_old_submissions.php — taeglich, loescht Submissions aelter als 30 Tage (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"}
          ]}}
    ]
}'];

Das Widget ruft getWidgetContent(), um die volle Funnel-Definition in widget.content zu laden, und laesst den FunnelRenderer.vue clientseitig durch die Steps wandern. Step-Routing laeuft per /formfunnel/step-Roundtrip, damit Conditions gegen die jeweils neuesten Antworten ausgewertet werden.

Erweiterungspunkte

  • Neuer Field-Type — Row in formfunnel_field_types hinzufuegen (Migration), template/fields/{type}.vue ablegen, Validator in composables/useFieldValidator.ts registrieren. Der FieldRenderer-Glob nimmt ihn automatisch auf.
  • Neuer End-Kindkind-Enum in EndNode.vue-Properties erweitern, Renderer-Branch in widgets/funnel/template/components/ThankYouRenderer.vue hinzufuegen.
  • Neuer Email-ProviderEmailProviderInterface::sendTransactional() in _public/extensions/core/backend/emailmarketing/providers/{Provider}.php implementieren. Der Dispatcher iteriert aktive Provider in Fallback-Reihenfolge.
  • Neuer Webhook-Event — in bootstrap.php::webhookEvents registrieren, via WebhookDispatcher::dispatch('formfunnel.your_event', $payload) feuern.

Haeufige Fehler

Public-API-Klassennamen mit Bindestrich liefern 401. URL formfunnel/my-action → Klasse formfunnel_my_action, nicht formfunnel_my-action. Fehlt die Konvertierung, schlaegt publicMethods still fehl und der Request fliesst durch die API-Key-Validation. Siehe apiBaseController::loadApiEndpoint().

insert_id() liefert 0 nach einer Transaktion. Nutze insert_id() direkt nach dem INSERTlast_insert_id() existiert in diesem CMS nicht. Beachte: verschachtelte INSERT-Calls innerhalb von START TRANSACTION setzen ihn zurueck; cache den Wert in einer lokalen Variable.

condition_json wird als String, nicht als JSON gespeichert.model.php::saveFunnel() erwartet condition_json als PHP-Array — json_encode() laeuft serverseitig. Ein bereits encodeter String wird doppelt escaped.

Funnel speichert, aber Conditions matchen serverseitig nie. Pruef, dass die Field-IDs in condition_json.rules[].field exakt mit den Field-IDs in config_json.fields[].id des Steps uebereinstimmen. Trailing Whitespace und Case-Differenzen schlagen still fehl.

Siehe auch