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
| Tabelle | Zweck |
|---|---|
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 (Felder, Rules, End-Kind), sort_order. |
formfunnel_edges | Gerichtete 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 (besuchte Node-IDs), answers_json, vollstaendiger UTM, inbox_status (new/qualified/contacted/converted/rejected), inbox_notes, assigned_to. |
formfunnel_email_routing | Regeln — 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_types | Statische Registry der 33 Field-Types mit default_config JSON. |
v_funnel_inbox, v_funnel_dropoff_30d, v_funnel_branches_30d | Analytics-Views (Sprint 6). |
formfunnel_nodes, formfunnel_edges, formfunnel_submissions und formfunnel_email_routing cascaden beim formfunnel_id-Delete.
Plugin-Registrierung (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 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.
| Methode | Action | Zweck |
|---|---|---|
GET | list | Alle Funnels der aktuellen Sprache. |
GET | get&id=X | Funnel-Detail mit Nodes, Edges, email_routings. |
GET | submissions | Paginierte Submissions mit Filter/Sort gegen v_funnel_inbox. |
GET | submission_get&id=X | Submission-Detail inkl. path_resolved (joined Node-Namen + Config). |
GET | analytics&funnel_id=X | Drop-off + Branch + Time-Series fuer die letzten 30 Tage. |
GET | field_types | Alle 33 Field-Type-Definitionen fuer den Field-Editor. |
GET | email_routings&funnel_id=X | Routing-Regeln eines Funnels. |
GET | assignable_users | Backend-User-Liste fuer den Inbox-Assign-Picker. |
POST | create | Neuen Funnel anlegen. |
POST | save | Atomarer Save — Funnel-Header + Nodes + Edges + Routings in einer Transaktion. Negative client_id bei neuen Nodes; die Response mappt sie auf echte DB-IDs. |
POST | delete | Cascade-Delete. |
POST | duplicate | Deep-Clone mit frischen IDs. |
POST | publish | Validiert 1 Step + 1 End + start_node_id, dann setzt 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 ueber ids[]. |
POST | email_routing_save | Ersetzt alle Regeln atomar. |
POST | preview | Server-seitige Preview-Step (Sprint 8a). |
Frontend (public, mit Rate-Limit + Origin-Gate)
| Pfad | Zweck |
|---|---|
POST /api/formfunnel/start | Session anlegen. Body: {funnel_id, source_url?, referer?, utm_*?}. Response: {session_uuid, funnel, node}. Rate-limit 30/IP/min. |
POST /api/formfunnel/step | Step-Antworten senden. Loest Conditions serverseitig via ConditionEvaluator auf, liefert den naechsten step/end-Node (Conditions werden geskippt). |
POST /api/formfunnel/complete | Final-Submit. Triggert EmailDispatcher::dispatchForSubmission() und den formfunnel.completed-Webhook. |
POST /api/formfunnel/abandon | Beacon-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.
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:
recipients/cc/bccgegen dieemail_marketing-Provider-Chain aufloesen.subjectundbody_htmlmit-Token-Substitution gegenanswers_jsonrendern.- Wenn
auto_reply_enabled = 1, eine Bestaetigung ans ersteemail-typisierte Antwort-Feld senden. - Webhook
formfunnel.completed(oderformfunnel.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-Validation —
FunnelSettingsModalblockiert den Toggle, ausser der Start-Step enthaelt ein Feld vom Typconsent. - DB-Writes pro Step — jedes
/step-Upsert persistiert Antworten informfunnel_submissions.answers_json. - Cron
process_abandoned.php— alle 5 min, markiertstatus='abandoned'undabandoned_at=NOW()fuer Sessions aelter alsabandon_after_minMinuten (Default 60). - Webhook + Email-Routing auf
abandoned— wie completed, aber nur Regeln mittrigger_event='abandoned'feuern. - Cron
cleanup_old_submissions.php— taeglich, loescht Submissions aelter als 30 Tage (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"}
]}}
]
}'];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_typeshinzufuegen (Migration),template/fields/{type}.vueablegen, Validator incomposables/useFieldValidator.tsregistrieren. DerFieldRenderer-Glob nimmt ihn automatisch auf. - Neuer End-Kind —
kind-Enum inEndNode.vue-Properties erweitern, Renderer-Branch inwidgets/funnel/template/components/ThankYouRenderer.vuehinzufuegen. - Neuer Email-Provider —
EmailProviderInterface::sendTransactional()in_public/extensions/core/backend/emailmarketing/providers/{Provider}.phpimplementieren. Der Dispatcher iteriert aktive Provider in Fallback-Reihenfolge. - Neuer Webhook-Event — in
bootstrap.php::webhookEventsregistrieren, viaWebhookDispatcher::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 INSERT — last_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
- Plugin-Anatomie — Install/Uninstall/Update-Lifecycle.
- API-Endpunkte —
apiEndpoints-Registrierungs-Form. - Webhook-Events —
webhookEvents-Registrierung. - Scheduled Tasks — Cron-Registrierung.
- Forms & Funnels — User-Doku — Editor-Workflow.