PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / docs / hooks-and-events.md

hooks-and-events.md in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification trunk, at docs/hooks-and-events.md

711 lines 27.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 # Developer Documentation: Hooks, Filters & Events
2
3 > **Plugin:** Double Opt-In for Contact Form 7 & Avada
4 > **Since:** 4.0.0 (Event System), 3.2.2 (getFormData on confirm)
5 > **Last updated:** 2026-02-10
6
7 This document is the complete reference for integrating with the Double Opt-In plugin.
8 There are two ways to hook into the plugin lifecycle:
9
10 1. **WordPress Hooks** (`add_action` / `add_filter`) -- backward-compatible, works like any WP hook.
11 2. **Typed Events** (via `EventDispatcherInterface`) -- introduced in 4.0, strongly typed, auto-completed by your IDE.
12
13 Both approaches work side by side. For new code we recommend the typed event system.
14
15 ---
16
17 ## Table of Contents
18
19 - [](#quick-start-examplesQuick Start Examples](#quick-start-examples](#quick-start-examples)
20 - [](#lifecycle-hooksLifecycle Hooks (do_action)](#lifecycle-hooks](#lifecycle-hooks)
21 - [](#mail-hooksMail Hooks (do_action)](#mail-hooks](#mail-hooks)
22 - [](#follow-up-hooksFollow-up Hooks](#follow-up-hooks](#follow-up-hooks)
23 - [](#integration-hooksIntegration Hooks (do_action)](#integration-hooks](#integration-hooks)
24 - [](#filtersFilters (apply_filters)](#filters](#filters)
25 - [](#typed-eventsTyped Events](#typed-events](#typed-events)
26 - [](#lifecycle-eventsLifecycle Events](#lifecycle-events](#lifecycle-events)
27 - [](#form-eventsForm Events](#form-events](#form-events)
28 - [](#mail-eventsMail Events](#mail-events](#mail-events)
29 - [](#integration-eventsIntegration Events](#integration-events](#integration-events)
30 - [](#migration-guideMigration Guide: Legacy to Events](#migration-guide](#migration-guide)
31
32 ---
33
34 ## Quick Start Examples
35
36 ### After Opt-In Confirmation (Legacy Hook)
37
38 ```php
39 add_action( 'f12_cf7_doubleoptin_after_confirm', function ( string $hash, $optIn ) {
40 $formData = maybe_unserialize( $optIn->get_content() );
41 $email = $optIn->get_email();
42 $formId = $optIn->get_cf_form_id();
43
44 // Example: subscribe to newsletter
45 my_newsletter_subscribe( $email, $formData['your-name'] ?? '' );
46 }, 10, 2 );
47 ```
48
49 ### After Opt-In Confirmation (Typed Event)
50
51 ```php
52 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
53 use Forge12\DoubleOptIn\Container\Container;
54 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
55
56 add_action( 'plugins_loaded', function () {
57 $container = Container::getInstance();
58 $dispatcher = $container->get( EventDispatcherInterface::class );
59
60 $dispatcher->addListener(
61 OptInConfirmedEvent::class,
62 function ( OptInConfirmedEvent $event ) {
63 $formData = $event->getFormData();
64 $email = $event->getEmail();
65 $formId = $event->getFormId();
66
67 my_newsletter_subscribe( $email, $formData['your-name'] ?? '' );
68 }
69 );
70 } );
71 ```
72
73 ---
74
75 ## Lifecycle Hooks
76
77 ### `f12_cf7_doubleoptin_before_confirm`
78
79 Fires **before** the opt-in record is marked as confirmed in the database.
80
81 | Parameter | Type | Description |
82 |-----------|------|-------------|
83 | `$hash` | `string` | The opt-in hash from the confirmation link |
84 | `$optIn` | `OptIn` | The opt-in record (not yet confirmed) |
85
86 ```php
87 add_action( 'f12_cf7_doubleoptin_before_confirm', function ( $hash, $optIn ) {
88 // Example: log the confirmation attempt
89 error_log( "Confirm attempt for: " . $optIn->get_email() );
90 }, 10, 2 );
91 ```
92
93 ### `f12_cf7_doubleoptin_after_confirm`
94
95 Fires **after** the opt-in record has been confirmed and saved in the database.
96
97 | Parameter | Type | Description |
98 |-----------|------|-------------|
99 | `$hash` | `string` | The opt-in hash |
100 | `$optIn` | `OptIn` | The confirmed opt-in record |
101
102 ```php
103 add_action( 'f12_cf7_doubleoptin_after_confirm', function ( $hash, $optIn ) {
104 $formData = maybe_unserialize( $optIn->get_content() );
105
106 // Access individual form fields
107 $name = $formData['your-name'] ?? '';
108 $email = $formData['your-email'] ?? '';
109 $phone = $formData['your-phone'] ?? '';
110
111 // Example: create a WooCommerce customer, sync to CRM, etc.
112 }, 10, 2 );
113 ```
114
115 ### `f12_cf7_doubleoptin_already_confirmed`
116
117 Fires when a user clicks a confirmation link that has already been used.
118
119 | Parameter | Type | Description |
120 |-----------|------|-------------|
121 | `$hash` | `string` | The opt-in hash |
122 | `$optIn` | `OptIn` | The already-confirmed opt-in record |
123
124 ### `f12_cf7_doubleoptin_token_expired`
125
126 Fires when a confirmation link has expired (based on `token_expiry_hours` setting).
127
128 | Parameter | Type | Description |
129 |-----------|------|-------------|
130 | `$hash` | `string` | The opt-in hash |
131 | `$optIn` | `OptIn` | The expired opt-in record |
132
133 ### `f12_cf7_doubleoptin_sent`
134
135 Fires when a new opt-in record is created and the confirmation email is sent.
136
137 | Parameter | Type | Description |
138 |-----------|------|-------------|
139 | `$form` | `mixed` | The form object (CF7 or Avada) |
140 | `$formId` | `int` | The form ID |
141
142 ### `f12_cf7_doubleoptin_creation_failed`
143
144 Fires when an opt-in record could not be saved to the database.
145
146 | Parameter | Type | Description |
147 |-----------|------|-------------|
148 | `$formId` | `int` | The form ID |
149 | `$recipient` | `string` | The recipient email |
150
151 ### `f12_cf7_doubleoptin_rate_limited`
152
153 Fires when a submission is blocked by rate limiting.
154
155 | Parameter | Type | Description |
156 |-----------|------|-------------|
157 | `$type` | `string` | `'ip'` or `'email'` |
158 | `$identifier` | `string` | The IP address or email that was rate-limited |
159 | `$formId` | `int` | The form ID |
160
161 ### `f12_cf7_doubleoptin_recipient_invalid`
162
163 Fires when recipient email validation fails (e.g. MX check in Pro).
164
165 | Parameter | Type | Description |
166 |-----------|------|-------------|
167 | `$recipient` | `string` | The rejected email |
168 | `$formId` | `int` | The form ID |
169 | `$errorMsg` | `string` | The validation error message |
170
171 ### `f12_cf7_doubleoptin_consent_not_given`
172
173 Fires when a submission is rejected because the form's configured
174 acceptance field was not confirmed. Since 5.4.0 this fires for every
175 integration; before that only for those extending `AbstractFormIntegration`.
176
177 | Parameter | Type | Description |
178 |-----------|------|-------------|
179 | `$formId` | `int` | The form ID |
180 | `$consentField` | `string` | The configured acceptance field |
181
182 ### `f12_doi_consent_field_unknown`
183
184 **Since 5.4.0.** Fires when the configured acceptance field cannot be
185 found on the form at all — renamed or deleted in the form builder. The
186 submission is **accepted**; this is the deliberate safety valve, so that a
187 settings mistake cannot take a site's registrations offline. The same
188 condition is reported under Tools → Site Health.
189
190 | Parameter | Type | Description |
191 |-----------|------|-------------|
192 | `$formId` | `int` | The form ID |
193 | `$consentField` | `string` | The configured field that could not be found |
194 | `$integration` | `string` | The integration identifier (`cf7`, `elementor`, …) |
195
196 ---
197
198 ## Mail Hooks
199
200 ### `f12_cf7_doubleoptin_before_send_default_mail`
201
202 Fires before the original form mail is sent after opt-in confirmation. Spam protection (reCAPTCHA, CF7 Captcha) is temporarily disabled at this point.
203
204 | Parameter | Type | Description |
205 |-----------|------|-------------|
206 | `$optIn` | `OptIn` | The confirmed opt-in record |
207
208 ### `f12_cf7_doubleoptin_trigger_default_mail`
209
210 Legacy trigger for the mail sending. Since 5.6.0 the core no longer fires it for opt-ins whose integration has a follow-up adapter (CF7, Elementor, Avada, WPForms, Gravity Forms) — their follow-up actions run through the follow-up coordinator instead. Firing it yourself is still safe: every listener checks the opt-in's integration, and managed opt-ins go through the coordinator, so actions that already ran are not repeated.
211
212 | Parameter | Type | Description |
213 |-----------|------|-------------|
214 | `$optIn` | `OptIn` | The confirmed opt-in record |
215
216 ### `f12_cf7_doubleoptin_after_send_default_mail`
217
218 Fires after the original form mail has been sent. Spam protection is re-enabled at this point. It fires after the follow-up attempt whatever its outcome — do not treat it as proof that the mail went out; read the follow-up status instead (see below).
219
220 | Parameter | Type | Description |
221 |-----------|------|-------------|
222 | `$optIn` | `OptIn` | The confirmed opt-in record |
223
224 ---
225
226 ## Follow-up Hooks
227
228 A confirmed opt-in is not proof that the form's own actions (stored entry, notification mails, webhooks) ran. Since 5.6.0 each of them is a *follow-up action* with its own recorded status (`pending`, `running`, `succeeded`, `failed_retryable`, `failed_permanent`, `unknown`, `skipped`), shown in the opt-in detail view and written to the audit log (type `follow_up`). Only actions that demonstrably did not run are retried automatically; `unknown` is never retried without an administrator's explicit decision.
229
230 ### `f12_doi_register_follow_up_adapters`
231
232 Register a follow-up adapter for a form integration that is not part of the Double Opt-In family. Fires once, on first use.
233
234 | Parameter | Type | Description |
235 |-----------|------|-------------|
236 | `$registry` | `FollowUpAdapterRegistry` | Call `register( FollowUpAdapterInterface $adapter )` |
237
238 ```php
239 add_action( 'f12_doi_register_follow_up_adapters', function ( $registry ) {
240 $registry->register( new My_Form_FollowUp_Adapter() );
241 } );
242 ```
243
244 The adapter plans one action per side effect (`planActions()`), executes the claimed ones and returns a `FollowUpResult` per action (`execute()`), and releases resources once everything is done (`onSettled()`). Return `FollowUpResult::unknown()` whenever you cannot tell whether a side effect happened.
245
246 ### `f12_doi_follow_up_backoff` (filter)
247
248 Delays in seconds between automatic retries of actions that demonstrably did not run (e.g. the internal request never reached the server). The number of entries is the maximum number of automatic retries. Default `[60, 300, 1800]`; `[]` disables automatic retries. A manual retry from the admin starts a fresh budget: the schedule applies again from its first entry.
249
250 ```php
251 add_filter( 'f12_doi_follow_up_backoff', fn() => array( 120, 600 ) );
252 ```
253
254 Addon-specific (Elementor Forms addon): `f12_doi_elementor_replay_skip_validators` (validator classes) and `f12_doi_elementor_replay_skip_field_validators` (field types) name spam validators that are skipped for the ticket-authorised post-confirmation replay only — for a third-party CAPTCHA whose token cannot be verified twice.
255
256 ---
257
258 ## Integration Hooks
259
260 ### `f12_cf7_doubleoptin_register_integrations`
261
262 Fires during plugin initialization. Use this to register your own form integration.
263
264 | Parameter | Type | Description |
265 |-----------|------|-------------|
266 | `$registry` | `FormIntegrationRegistry` | The integration registry |
267 | `$container` | `Container` | The service container |
268
269 ```php
270 add_action( 'f12_cf7_doubleoptin_register_integrations', function ( $registry, $container ) {
271 $registry->register( new MyCustomFormIntegration( $container->get( LoggerInterface::class ) ) );
272 }, 10, 2 );
273 ```
274
275 ### `f12_cf7_doubleoptin_integration_registered`
276
277 Fires after a form integration has been registered.
278
279 | Parameter | Type | Description |
280 |-----------|------|-------------|
281 | `$integration` | `FormIntegrationInterface` | The registered integration |
282 | `$identifier` | `string` | The integration identifier (e.g. `cf7`, `avada`) |
283
284 ### `f12_cf7_doubleoptin_integrations_initialized`
285
286 Fires after all form integrations have been initialized.
287
288 | Parameter | Type | Description |
289 |-----------|------|-------------|
290 | `$registry` | `FormIntegrationRegistry` | The registry with all integrations |
291
292 ### `f12_cf7_doubleoptin_register_event_listeners`
293
294 Fires during event system setup. Register your typed event listeners here.
295
296 | Parameter | Type | Description |
297 |-----------|------|-------------|
298 | `$dispatcher` | `EventDispatcherInterface` | The event dispatcher |
299 | `$hookBridge` | `WordPressHookBridge` | The WordPress hook bridge |
300
301 ```php
302 add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher, $hookBridge ) {
303 $dispatcher->addListener(
304 \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class,
305 function ( $event ) {
306 // your logic
307 }
308 );
309 }, 10, 2 );
310 ```
311
312 ---
313
314 ## Filters
315
316 ### Form & Submission Filters
317
318 | Filter | Parameters | Return | Description |
319 |--------|-----------|--------|-------------|
320 | `f12_cf7_doubleoptin_add_request_parameter` | `$fields` (array) | `array` | Modify submitted form fields before saving to database |
321 | `f12_cf7_doubleoptin_skip_option` | `$skip` (bool), `$formId`, `$fields`, `$type` | `bool` | Return `true` to skip opt-in creation for this submission |
322 | `f12_cf7_doubleoptin_show_validation_error` | `$show` (bool), `$error` (OptInError, since 5.6.2), `$formId` (int, since 5.6.2) | `bool` | Whether the form shows the reason for a refused submission instead of its own success message (default: `false`). A refused consent (`consent_not_given`) is always shown and never reaches this filter (since 5.6.2) |
323 | `f12_cf7_doubleoptin_enable_error_notification` | `$enable` (bool) | `bool` | Return `false` to not load the frontend error toast at all (default: `true`, since 4.2.0) |
324 | `f12_cf7_doubleoptin_error_message` | `$message` (string), `$error` (OptInError), `$formId` (int) | `string` | Customize the error message per error code (since 4.2.0) |
325 | `f12_cf7_doubleoptin_validate_recipient` | `$valid` (bool), `$recipient`, `$formData` | `bool\|string` | Validate recipient email; return error string to reject |
326 | `f12_cf7_doubleoptin_send_default_mail` | `$send` (bool), `$formId` | `bool` | Whether to send the original form mail after confirmation |
327 | `f12_doi_enforce_consent_gate` | `$enforce` (bool), `$formId` (int), `$integration` (string) | `bool` | Return `false` to accept a submission whose configured acceptance field was not confirmed. The opt-in is then stored with a consent text nobody agreed to, so this is an escape hatch for an unforeseen edge case, not a setting (since 5.4.0) |
328
329 ### Mail Filters
330
331 | Filter | Parameters | Return | Description |
332 |--------|-----------|--------|-------------|
333 | `f12_cf7_doubleoptin_body` | `$body` (string) | `string` | Modify the opt-in confirmation email body |
334 | `f12-cf7-doubleoptin-cf7-args` | `$args` (array) | `array` | Modify mail arguments (subject, body, headers, attachments) |
335 | `f12_cf7_doubleoptin_files_mail_1` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the first confirmation mail |
336 | `f12_cf7_doubleoptin_files_mail_2` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the second confirmation mail |
337 | `f12_cf7_doubleoptin_allowed_mime_types` | `$mimeTypes` (array) | `array` | Modify allowed MIME types for file uploads |
338
339 ### Settings Filters
340
341 | Filter | Parameters | Return | Description |
342 |--------|-----------|--------|-------------|
343 | `f12_cf7_doubleoptin_save_form` | `$data` (array) | `array` | Modify form settings before saving |
344 | `f12_cf7_doubleoptin_metadata_cf7` | `$metadata` (array) | `array` | Modify CF7 form metadata |
345 | `f12_cf7_doubleoptin_metadata_avada` | `$metadata` (array) | `array` | Modify Avada form metadata |
346 | `f12_doi_form_settings_data` | `$formData`, `$formId` | `array` | Modify form settings data before sending to frontend |
347 | `f12_doi_form_settings_before_save` | `$settings`, `$storageId`, `$settingsData` | `FormSettingsDTO` | Modify FormSettingsDTO before saving |
348 | `f12_doi_settings_dto_from_array` | `$dto`, `$data` | `FormSettingsDTO` | Modify DTO when creating from array |
349 | `f12_doi_settings_dto_to_array` | `$array`, `$dto` | `array` | Modify array representation of DTO |
350 | `f12_doi_is_pro_active` | `$isActive` (bool) | `bool` | Whether the Pro version is active |
351 | `f12_cf7_doubleoptin_use_new_integration_system` | `$use` (bool) | `bool` | Enable/disable the new integration system |
352
353 ### Filter Examples
354
355 ```php
356 // Skip opt-in for specific forms
357 add_filter( 'f12_cf7_doubleoptin_skip_option', function ( $skip, $formId, $fields, $type ) {
358 if ( $formId === 42 ) {
359 return true; // skip opt-in for form #42
360 }
361 return $skip;
362 }, 10, 4 );
363
364 // Add custom fields to the stored data
365 add_filter( 'f12_cf7_doubleoptin_add_request_parameter', function ( $fields ) {
366 $fields['custom-tracking-id'] = uniqid( 'track_' );
367 return $fields;
368 } );
369
370 // Custom recipient validation
371 add_filter( 'f12_cf7_doubleoptin_validate_recipient', function ( $valid, $recipient, $formData ) {
372 if ( str_ends_with( $recipient, '@blocked-domain.com' ) ) {
373 return 'This email domain is not accepted.';
374 }
375 return $valid;
376 }, 10, 3 );
377
378 // Enable error display for users (default: false)
379 add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' );
380
381 // Customize error messages per error code
382 add_filter( 'f12_cf7_doubleoptin_error_message', function ( $message, $error, $formId ) {
383 if ( $error->getCode() === 'rate_limit_ip' ) {
384 return 'Please wait a few minutes before trying again.';
385 }
386 return $message;
387 }, 10, 3 );
388 ```
389
390 ---
391
392 ## Universal Error Notification System
393
394 > **Since:** 4.2.0
395
396 The plugin provides a form-plugin-agnostic error notification system that works
397 with **all** integrations (CF7, Avada, Gravity Forms, WPForms, Elementor, and
398 any future integration) without requiring integration-specific error handling code.
399
400 ### How it works
401
402 1. When `createOptIn()` fails, an `OptInError` is stored in a short-lived transient
403 keyed by the client's IP + User-Agent (TTL: 60 seconds).
404 2. A small frontend JS (loaded on every frontend page unless
405 `f12_cf7_doubleoptin_enable_error_notification` returns `false`) listens for
406 form submission events from all supported plugins.
407 3. After form submission, the JS calls the AJAX endpoint
408 `doi_check_submission_error` to check for a stored error.
409 4. If an error exists, a toast notification is displayed. The transient is
410 deleted after retrieval (one-time read). When the error is shown to the
411 visitor (see below), the form plugin's success message is hidden and the
412 toast stays until closed.
413
414 ### Showing the error in the form
415
416 By default a refused submission is reported by the toast only; the form plugin
417 still shows its own success message. To show the reason in the form instead —
418 CF7 aborts with the message and keeps the input, Elementor answers with an
419 error, WPForms and Gravity Forms hide their confirmation — add this to your
420 theme's `functions.php`:
421
422 ```php
423 add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' );
424
425 // Or per error code (arguments since 5.6.2):
426 add_filter( 'f12_cf7_doubleoptin_show_validation_error', function ( $show, $error, $formId ) {
427 return $error->getCode() === 'unique_email_duplicate' ? true : $show;
428 }, 10, 3 );
429 ```
430
431 **A refused consent is always shown** (since 5.6.2). It is the one refusal the
432 visitor caused and can fix — tick the box — and before 5.6.2 the form said
433 "sent" while no mail was ever going to come. The MX Validator, Domain Blocklist
434 and Unique Email add-ons switch the filter on for all errors while active.
435
436 ### Error codes
437
438 | Code | Constant | Default message |
439 |------|----------|----------------|
440 | `submission_cancelled` | `OptInError::SUBMISSION_CANCELLED` | The form submission has been cancelled. |
441 | `no_recipient` | `OptInError::NO_RECIPIENT` | No valid email address was found. |
442 | `rate_limit_ip` | `OptInError::RATE_LIMIT_IP` | Too many requests. Please try again later. |
443 | `rate_limit_email` | `OptInError::RATE_LIMIT_EMAIL` | Too many requests for this email address. Please try again later. |
444 | `recipient_invalid` | `OptInError::RECIPIENT_INVALID` | The email address could not be verified. |
445 | `save_failed` | `OptInError::SAVE_FAILED` | An error occurred. Please try again. |
446
447 ### Programmatic access
448
449 ```php
450 use Forge12\DoubleOptIn\Integration\AbstractFormIntegration;
451
452 // After a form submission, retrieve the last error (same request only)
453 $error = AbstractFormIntegration::getLastError();
454 if ( $error ) {
455 $code = $error->getCode(); // e.g. 'rate_limit_ip'
456 $message = $error->getMessage(); // translated message
457 $context = $error->getContext(); // ['ip' => '...', 'form_id' => 42]
458 }
459 ```
460
461 ### CSS customization
462
463 The notification uses the class `.doi-error-notification`. Override styles in your
464 theme to match your design:
465
466 ```css
467 .doi-error-notification__content {
468 border-left-color: #cc0000; /* custom accent color */
469 }
470 ```
471
472 ---
473
474 ## Typed Events
475
476 All events extend `Forge12\DoubleOptIn\EventSystem\Event` and are dispatched via `EventDispatcherInterface`.
477
478 ### Lifecycle Events
479
480 #### `OptInCreatedEvent`
481
482 Dispatched when a new opt-in record is created.
483
484 | Method | Return | Description |
485 |--------|--------|-------------|
486 | `getOptInId()` | `int` | The database record ID |
487 | `getFormId()` | `int` | The form ID |
488 | `getFormType()` | `string` | `'cf7'`, `'avada'`, etc. |
489 | `getEmail()` | `string` | The subscriber email |
490 | `getHash()` | `string` | The opt-in hash |
491 | `getFormData()` | `array` | Submitted form fields |
492
493 **WordPress hook:** `f12_cf7_doubleoptin_created` (auto-bridged)
494
495 #### `OptInConfirmedEvent`
496
497 Dispatched when an opt-in is confirmed via the confirmation link.
498
499 | Method | Return | Description |
500 |--------|--------|-------------|
501 | `getOptInId()` | `int` | The database record ID |
502 | `getHash()` | `string` | The opt-in hash |
503 | `getEmail()` | `string` | The subscriber email |
504 | `getConfirmedIp()` | `string` | IP address that confirmed |
505 | `getFormId()` | `int` | The original form ID |
506 | `getFormData()` | `array` | Submitted form fields (since 3.2.2) |
507
508 **WordPress hook:** `f12_cf7_doubleoptin_after_confirm` (manually bridged, not auto-bridged, to preserve `($hash, $optIn)` signature)
509
510 ```php
511 $dispatcher->addListener( OptInConfirmedEvent::class, function ( OptInConfirmedEvent $event ) {
512 $data = $event->getFormData();
513 // ['your-name' => 'John Doe', 'your-email' => '[email protected]', ...]
514 } );
515 ```
516
517 #### `OptInDeletedEvent`
518
519 Dispatched when an opt-in record is deleted.
520
521 | Method | Return | Description |
522 |--------|--------|-------------|
523 | `getHash()` | `string` | The opt-in hash |
524 | `getEmail()` | `string` | The subscriber email |
525 | `getDeletedBy()` | `string` | `'admin'`, `'cron'`, or `'user'` |
526 | `getRowsDeleted()` | `int` | Number of rows deleted |
527
528 **WordPress hook:** `f12_cf7_doubleoptin_deleted`
529
530 #### `OptInExpiredEvent`
531
532 Dispatched during cleanup when expired records are removed.
533
534 | Method | Return | Description |
535 |--------|--------|-------------|
536 | `getCleanupType()` | `string` | `'confirmed'` or `'unconfirmed'` |
537 | `getRowsDeleted()` | `int` | Number of records deleted |
538 | `getThreshold()` | `DateTimeImmutable` | The cutoff date |
539
540 **WordPress hook:** `f12_cf7_doubleoptin_expired`
541
542 ---
543
544 ### Form Events
545
546 #### `FormSubmittedEvent`
547
548 Dispatched when a form is submitted (before opt-in is created).
549
550 | Method | Return | Description |
551 |--------|--------|-------------|
552 | `getFormId()` | `int` | The form ID |
553 | `getFormType()` | `string` | The form type |
554 | `getPostedData()` | `array` | Submitted form data |
555 | `getUploadedFiles()` | `array` | Uploaded files |
556 | `getFormUrl()` | `string` | Page URL where form was submitted |
557 | `shouldCreateOptIn()` | `bool` | Whether opt-in will be created |
558 | `skipOptInCreation($reason)` | `void` | Cancel opt-in creation |
559
560 **WordPress hook:** `f12_cf7_doubleoptin_form_submitted`
561
562 ```php
563 $dispatcher->addListener( FormSubmittedEvent::class, function ( FormSubmittedEvent $event ) {
564 // Skip opt-in for logged-in admins
565 if ( current_user_can( 'manage_options' ) ) {
566 $event->skipOptInCreation( 'Admin user, no opt-in needed' );
567 }
568 } );
569 ```
570
571 #### `FormValidatedEvent`
572
573 Dispatched after form validation is complete.
574
575 | Method | Return | Description |
576 |--------|--------|-------------|
577 | `getFormId()` | `int` | The form ID |
578 | `getFormType()` | `string` | The form type |
579 | `isValid()` | `bool` | Whether validation passed |
580 | `getRecipientEmail()` | `string` | The extracted email |
581 | `getErrors()` | `array` | Validation errors |
582
583 **WordPress hook:** `f12_cf7_doubleoptin_form_validated`
584
585 ---
586
587 ### Mail Events
588
589 #### `MailPreparingEvent`
590
591 Dispatched before the opt-in confirmation email is sent. All properties are **mutable**.
592
593 | Method | Return | Description |
594 |--------|--------|-------------|
595 | `getOptInId()` | `int` | The opt-in record ID |
596 | `getRecipient()` / `setRecipient()` | `string` | Recipient email |
597 | `getSubject()` / `setSubject()` | `string` | Email subject |
598 | `getBody()` / `setBody()` | `string` | Email body (HTML) |
599 | `getSender()` / `setSender()` | `string` | Sender email |
600 | `getSenderName()` / `setSenderName()` | `string` | Sender display name |
601 | `getHeaders()` / `addHeader()` | `array` | Email headers |
602 | `getAttachments()` / `addAttachment()` | `array` | File attachments |
603 | `shouldSend()` / `cancelSending()` | `bool` | Cancel sending |
604
605 **WordPress hook:** `f12_cf7_doubleoptin_mail_preparing`
606
607 ```php
608 $dispatcher->addListener( MailPreparingEvent::class, function ( MailPreparingEvent $event ) {
609 $event->setSubject( 'Custom: ' . $event->getSubject() )
610 ->addHeader( 'X-Custom-Header: my-value' );
611 } );
612 ```
613
614 #### `MailSentEvent`
615
616 Dispatched after a mail has been sent (or failed).
617
618 | Method | Return | Description |
619 |--------|--------|-------------|
620 | `getOptInId()` | `int` | The opt-in record ID |
621 | `getRecipient()` | `string` | Recipient email |
622 | `getSubject()` | `string` | Email subject |
623 | `wasSuccessful()` | `bool` | Whether sending succeeded |
624 | `getMailType()` | `string` | `'optin'` or `'confirmation'` |
625
626 **WordPress hook:** `f12_cf7_doubleoptin_mail_sent`
627
628 #### `ReminderSentEvent`
629
630 Dispatched after a reminder email is sent (Pro feature).
631
632 | Method | Return | Description |
633 |--------|--------|-------------|
634 | `getOptInId()` | `int` | The opt-in record ID |
635 | `getRecipient()` | `string` | Recipient email |
636 | `getSubject()` | `string` | Email subject |
637 | `wasSuccessful()` | `bool` | Whether sending succeeded |
638 | `getTrigger()` | `string` | `'cron'` or `'manual'` |
639
640 **WordPress hook:** `f12_cf7_doubleoptin_reminder_sent`
641
642 ---
643
644 ### Integration Events
645
646 #### `FormSubmissionEvent`
647
648 Dispatched when a form integration processes a submission. Allows modifying form data or cancelling the opt-in.
649
650 | Method | Return | Description |
651 |--------|--------|-------------|
652 | `getFormData()` / `setFormData()` | `FormDataInterface` | The normalized form data |
653 | `getIntegrationId()` | `string` | e.g. `'cf7'`, `'avada'` |
654 | `getFormId()` | `int` | The form ID |
655 | `shouldSkipOptIn()` | `bool` | Whether to skip opt-in |
656 | `skipOptIn($reason)` | `void` | Cancel opt-in creation |
657 | `getField($key, $default)` | `mixed` | Get a single form field |
658 | `hasField($key)` | `bool` | Check if field exists |
659
660 **WordPress hook:** `f12_cf7_doubleoptin_form_submission`
661
662 #### `IntegrationRegisteredEvent`
663
664 Dispatched when a form integration is registered with the system.
665
666 | Method | Return | Description |
667 |--------|--------|-------------|
668 | `getIntegrationId()` | `string` | The integration identifier |
669 | `getName()` | `string` | The display name |
670 | `isAvailable()` | `bool` | Whether the integration is available |
671
672 **WordPress hook:** `f12_cf7_doubleoptin_integration_registered`
673
674 ---
675
676 ## Migration Guide
677
678 ### Legacy Hook to Typed Event
679
680 **Before (Legacy):**
681 ```php
682 add_action( 'f12_cf7_doubleoptin_after_confirm', function ( $hash, $optIn ) {
683 $email = $optIn->get_email();
684 $formData = maybe_unserialize( $optIn->get_content() );
685 my_sync( $email, $formData );
686 }, 10, 2 );
687 ```
688
689 **After (Typed Event):**
690 ```php
691 add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher ) {
692 $dispatcher->addListener(
693 \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class,
694 function ( \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent $event ) {
695 my_sync( $event->getEmail(), $event->getFormData() );
696 }
697 );
698 }, 10, 1 );
699 ```
700
701 **Benefits of Typed Events:**
702 - Full IDE autocompletion and type safety
703 - `getFormData()` returns a clean array (no `maybe_unserialize` needed)
704 - Events can be stopped with `$event->stopPropagation()`
705 - Priority control via `addListener( ..., $priority )`
706 - No dependency on the internal `OptIn` class
707
708 ### Both approaches work simultaneously
709
710 The legacy `add_action('f12_cf7_doubleoptin_after_confirm', ...)` hook and the typed `OptInConfirmedEvent` listener are **not** mutually exclusive. Both fire during the same confirmation process. You can migrate incrementally.
711