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 / addon-api.md

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

314 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 # Double Opt-In Addon API
2
3 **Stability:** `4.3.0` (introduced). Covered by semver from this version forward — see §7 Deprecation Policy.
4
5 **Audience:** developers building an addon plugin that extends the Double Opt-In core.
6
7 ---
8
9 ## 1. What the API is for
10
11 The Double Opt-In Core plugin (`double-opt-in`) provides the foundation and the Contact Form 7 integration. Every other feature — other form systems (Elementor, Gravity Forms, WPForms, Avada), reminder emails, analytics, validators, GDPR exports — lives in its own addon plugin. An addon registers itself with the Core at runtime and extends it through a small, stable set of extension surfaces.
12
13 An addon MUST NOT touch Core internals directly. It interacts only through:
14
15 1. **The public interfaces** documented here (`AddonInterface`, `AddonLicenseRegistryInterface`, `FormIntegrationInterface`, `MigrationInterface`).
16 2. **The EventDispatcher** for lifecycle and domain events.
17 3. **Documented WordPress hooks** (actions and filters listed below).
18
19 Anything else — direct class references to classes marked `@internal`, private properties reached via reflection, undocumented hooks — is unsupported and may break between Core minor releases.
20
21 ---
22
23 ## 2. Minimum viable addon
24
25 A complete working addon is ~30 lines of PHP plus a plugin header.
26
27 ### 2.1 Plugin file
28
29 ```php
30 <?php
31 /**
32 * Plugin Name: Double Opt-In — Example Addon
33 * Description: Does something useful after opt-in confirm.
34 * Version: 1.0.0
35 * Requires at least: 6.0
36 * Requires PHP: 7.4
37 * Requires Plugins: double-opt-in
38 * Author: Your Name
39 * Text Domain: double-opt-in-example
40 */
41
42 if ( ! defined( 'ABSPATH' ) ) exit;
43
44 require_once __DIR__ . '/vendor/autoload.php'; // or your own autoload
45
46 add_action( 'f12_cf7_doubleoptin_register_addons', function ( $registry, $container ) {
47 $licenseRegistry = $container->get(
48 \Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface::class
49 );
50 $registry->register( new \Example\Addon\ExampleAddon( $licenseRegistry ) );
51 }, 10, 2 );
52 ```
53
54 ### 2.2 Addon class
55
56 ```php
57 namespace Example\Addon;
58
59 use Forge12\DoubleOptIn\Addon\AddonInterface;
60 use Forge12\DoubleOptIn\Container\ContainerInterface;
61 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
62 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
63 use Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface;
64
65 final class ExampleAddon implements AddonInterface {
66
67 public function __construct(
68 private AddonLicenseRegistryInterface $licenseRegistry
69 ) {}
70
71 public function getId(): string { return 'example'; }
72 public function getName(): string { return 'Example'; }
73 public function getVersion(): string { return '1.0.0'; }
74 public function getCoreVersionRequirement(): string { return '^4.3'; }
75 public function getCapabilities(): array { return [ 'example.feature' ]; }
76
77 public function isAvailable(): bool {
78 return $this->licenseRegistry->isLicensed( 'example' );
79 }
80
81 public function boot( ContainerInterface $container ): void {
82 $dispatcher = $container->get( EventDispatcherInterface::class );
83 $dispatcher->listen( OptInConfirmedEvent::class, function ( $event ) {
84 error_log( 'Opt-in confirmed: ' . $event->getOptIn()->get_email() );
85 } );
86 }
87 }
88 ```
89
90 That's the whole contract. Every other addon in the ecosystem (Reminder, Analytics, Validators, Form integrations…) is structurally identical.
91
92 ---
93
94 ## 3. Lifecycle
95
96 Request timeline, from `plugins_loaded` onward:
97
98 ```
99 plugins_loaded priority 10
100 ├── Core plugin instantiates
101 ├── DI Container boots all service providers
102 │ ├── LicensingServiceProvider registers AddonLicenseRegistry
103 │ ├── MigrationServiceProvider registers MigrationRegistry
104 │ └── AddonServiceProvider registers AddonRegistry and schedules
105 │ the addon-registration hook on plugins_loaded:20
106 └── Core fires action `f12_cf7_doubleoptin_init`
107 └── Paid bundle plugins (e.g. Pro) validate licenses and grant
108 entitlements via AddonLicenseRegistryInterface::grant()
109
110 plugins_loaded priority 20
111 └── Core fires action `f12_cf7_doubleoptin_register_addons`
112 ├── Each addon's registration callback runs:
113 │ registry->register( new MyAddon( $licenseRegistry ) )
114 └── AddonRegistry::bootAll() runs:
115 for each addon where isAvailable() === true
116 and getCoreVersionRequirement() matches F12_DOI_CORE_API_VERSION:
117 addon->boot( $container )
118
119 admin_init priority 20
120 └── MigrationRegistry::runPending() applies any unapplied migrations
121 ```
122
123 Three invariants you can rely on:
124
125 1. When `boot()` is called, every Core service provider has already registered and booted. All Core services the addon resolves from the container are usable.
126 2. When `boot()` is called, every other registered, available addon has been created but not necessarily booted yet. Do not assume another addon's capabilities are already wired up inside your own boot — listen for events instead.
127 3. `boot()` may be called only once per request per addon. Do not schedule long-running or I/O-bound work inline. Use `wp_schedule_single_event()` or event listeners.
128
129 ---
130
131 ## 4. Public interfaces
132
133 ### 4.1 `AddonInterface`
134
135 Namespace: `Forge12\DoubleOptIn\Addon\AddonInterface`
136
137 | Method | Contract |
138 |---|---|
139 | `getId(): string` | Lowercase, kebab-case, stable across versions. Primary key for license matching and the AddonRegistry. |
140 | `getName(): string` | Human-readable, translatable. Used in admin UI. |
141 | `getVersion(): string` | Addon's own semver version. Must match the addon's plugin header. |
142 | `getCoreVersionRequirement(): string` | Semver constraint against `F12_DOI_CORE_API_VERSION`. See §5. |
143 | `isAvailable(): bool` | Called before boot. Return false to skip boot when prerequisites (license, third-party plugin, PHP extension) are not met. |
144 | `boot(ContainerInterface): void` | One-time bootstrap: register services, attach event listeners, register WordPress hooks. |
145 | `getCapabilities(): array` | Flat string list advertising features. Used by other addons / Core for capability-based feature detection. |
146
147 ### 4.2 `AddonLicenseRegistryInterface`
148
149 Namespace: `Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface`
150
151 Addons are **consumers only** of this interface. They call `isLicensed($addonId)` to decide availability. License providers (the Pro bundle, or a future per-addon license plugin) are the only callers of `grant()` / `revoke()`.
152
153 | Method | For addons |
154 |---|---|
155 | `isLicensed(string $addonId): bool` | Primary availability check. |
156 | `getSource(string $addonId): ?string` | Optional: find out *who* granted the entitlement ("pro-bundle", etc.) — useful for admin UI. |
157 | `getLicensedAddons(): array` | Rarely useful to individual addons. |
158 | `grant(...)`, `revoke(...)` | For license providers only. An addon that calls these is wrong. |
159
160 ### 4.3 `FormIntegrationInterface`
161
162 Namespace: `Forge12\DoubleOptIn\Integration\FormIntegrationInterface`
163
164 Implement when your addon integrates a specific form plugin (Elementor, WPForms, Gravity Forms, Avada, or a third-party form system). Extend `AbstractFormIntegration` to reduce boilerplate.
165
166 Register in your addon's `boot()`:
167
168 ```php
169 public function boot( ContainerInterface $container ): void {
170 $logger = $container->get( LoggerInterface::class );
171 $registry = $container->get( FormIntegrationRegistry::class );
172
173 $registry->register( new MyFormIntegration( $logger ) );
174 }
175 ```
176
177 Form-integration addons must also check the third-party plugin is active in `isAvailable()`:
178
179 ```php
180 public function isAvailable(): bool {
181 return $this->licenseRegistry->isLicensed( $this->getId() )
182 && class_exists( 'MyFormPlugin\\Main' );
183 }
184 ```
185
186 ### 4.4 `MigrationInterface`
187
188 Namespace: `Forge12\DoubleOptIn\Migration\MigrationInterface`
189
190 Addons that need their own tables or (rarely) extend the shared schema register migrations in their `boot()`:
191
192 ```php
193 public function boot( ContainerInterface $container ): void {
194 $migrations = $container->get( MigrationRegistry::class );
195 $migrations->register( new Create_Stats_Cache_Table_20260515() );
196 }
197 ```
198
199 Rules:
200
201 - Migrations are **immutable once shipped**. Never edit a released migration. If you need to change something, ship a new forward-only migration.
202 - Migration IDs are globally unique. Convention: `{owner}_{yyyymmdd}_{short_slug}`. Core reserves the `core_*` prefix; addons must namespace by their addon ID.
203 - Addons **must not** ALTER the shared `{wp_prefix}f12_cf7_doubleoptin` table. If your addon genuinely needs a new column there, submit a PR to Core adding a Core migration and bump your `getCoreVersionRequirement()` to require the Core release that ships it.
204 - Addons may freely create tables prefixed `{wp_prefix}f12_doi_{addon_id}_*`, use post-meta (key namespace: `_f12_doi_{addon_id}_*`), and use options (namespace: `f12_doi_{addon_id}_*`).
205
206 ---
207
208 ## 5. Versioning
209
210 Two distinct versions exist:
211
212 | Constant | Meaning |
213 |---|---|
214 | `FORGE12_OPTIN_VERSION` | Plugin marketing version. Changes with every release. Don't depend on this. |
215 | `F12_DOI_CORE_API_VERSION` | Addon API version. Bumps **only** on breaking changes to any `@api`-tagged surface. Your addon depends on this. |
216
217 Addons declare their requirement via `getCoreVersionRequirement()`, which is checked by `AddonRegistry::bootAll()` against `F12_DOI_CORE_API_VERSION` using `SemverConstraint::matches()`. Addons whose requirement is not met are skipped with a logged warning; they are not crashed, they simply do not boot.
218
219 Supported constraint grammar:
220
221 - `^X.Y` — caret: `>=X.Y.0 <(X+1).0.0`
222 - `~X.Y` — tilde: `>=X.Y.0 <X.(Y+1).0`
223 - `>=X.Y[.Z]`, `<=X.Y[.Z]`, `>X.Y[.Z]`, `<X.Y[.Z]`, `=X.Y[.Z]`, or bare `X.Y[.Z]` (exact)
224
225 OR-clauses, wildcards, and pre-release suffixes are intentionally unsupported. If an addon needs them, the API has changed and you probably want a new major.
226
227 ---
228
229 ## 6. Events
230
231 The Core's `EventDispatcher` is the preferred cross-addon communication channel. Listen for these lifecycle events rather than coupling to class names of other addons.
232
233 | Event | Payload | Fired When |
234 |---|---|---|
235 | `Events\Lifecycle\OptInCreatedEvent` | `OptIn $optIn` | After opt-in row is created, before confirmation mail is sent. |
236 | `Events\Lifecycle\OptInConfirmedEvent` | `OptIn $optIn` | User clicks the confirmation link and the opt-in is marked confirmed. |
237 | `Events\Lifecycle\OptInExpiredEvent` | `OptIn $optIn` | An expiry sweep removes an unconfirmed opt-in past its TTL. |
238 | `Events\Lifecycle\OptInDeletedEvent` | `int $optInId`, `array $snapshot` | Opt-in record is being deleted; snapshot is its last known state. |
239 | `Events\Mail\MailSentEvent` | `string $recipient`, `string $subject`, `bool $success` | Any mail sent by Core or an addon via the shared mail path. |
240 | `Events\Form\FormSubmittedEvent` | `FormDataInterface $data` | A form integration has received and validated a submission. |
241 | `Events\Integration\IntegrationRegisteredEvent` | `FormIntegrationInterface $integration` | New form integration joined the FormIntegrationRegistry. |
242
243 Events are dispatched synchronously. Addons MUST NOT perform I/O-bound work inside listeners. Offload via `wp_schedule_single_event()` or use a background-job pattern.
244
245 ---
246
247 ## 7. Deprecation policy
248
249 Anything tagged `@api` in a PHPDoc class or method docblock is covered by this policy. Anything tagged `@internal`, or anything not tagged at all, is implementation detail.
250
251 Breaking changes to `@api` surface follow this process:
252
253 1. **Minor release N**: the affected method/class is kept, annotated `@deprecated since N+reason`, and emits a `_doing_it_wrong()` runtime notice when called (only in `WP_DEBUG` mode, to avoid spamming production logs).
254 2. **Minor release N+1** (minimum — Core may wait longer): the deprecated item remains; warning continues.
255 3. **Major release** following: removal allowed.
256
257 That is, addons that declare `^X.Y` where X is the current major are guaranteed their API will work until the next X+1 release, with at least one full minor release of advance warning.
258
259 Items labelled `@internal` may be removed in any release without warning.
260
261 ---
262
263 ## 8. Hooks (actions / filters)
264
265 Minimum documented set. Every hook below is `@api`-stable from Core API 4.3.0.
266
267 ### Actions
268
269 | Hook | When | Use case |
270 |---|---|---|
271 | `f12_cf7_doubleoptin_register_addons` | plugins_loaded:20 | Register your addon with the AddonRegistry. |
272 | `f12_cf7_doubleoptin_register_integrations` | Core boot, inside IntegrationServiceProvider | Register a form integration directly with the FormIntegrationRegistry (alternative to registering inside your addon's `boot()`). |
273 | `f12_cf7_doubleoptin_init` | Core constructor | Pro-bundle-style plugins instantiate themselves here. |
274 | `f12_cf7_doubleoptin_integrations_initialized` | `init:5` | All form integrations' `registerHooks()` have been called. |
275
276 ### Filters
277
278 | Filter | Use case |
279 |---|---|
280 | `f12_doi_settings_dto_from_array` | Extend the central settings DTO with addon-specific fields. |
281 | `f12_doi_form_settings_before_save` | Validate / mutate form-level settings on save. |
282 | `f12_cf7_doubleoptin_body` | Mutate email body after placeholder replacement. |
283 | `f12_cf7_doubleoptin_template_body` | Render a custom email template by template key. |
284
285 Additional hooks exist in the legacy surface (`docs/hooks-and-events.md`); they are **not** `@api`-tagged and may be replaced during Phase 2 extraction.
286
287 ---
288
289 ## 9. Things that will trip you up
290
291 - **Do not `require_once` other addons' files.** They may be deactivated or missing. Use the AddonRegistry for detection (`$registry->has('other-addon')`) and the EventDispatcher for communication.
292 - **Do not call `f12_doi_pro_is_unlocked()`.** It is a deprecated Pro-bundle alias. Use `AddonLicenseRegistryInterface::isLicensed($yourAddonId)`.
293 - **Do not persist addon state in the shared `f12_cf7_doubleoptin` table.** Use your own tables (via `MigrationInterface`) or namespaced options/post-meta.
294 - **Do not assume load order of addons.** If addon A depends on a service registered by addon B, listen for events or resolve from the container at use-time rather than at boot-time.
295 - **Do not hold references to the Container.** Resolve what you need in `boot()`, register listeners, and let the listeners resolve at dispatch time. Holding the container leaks it into places that don't expect it.
296
297 ---
298
299 ## 10. Example addons
300
301 The best reference implementations live in the `double-opt-in-pro` bundle plugin alongside this documentation:
302
303 - Service-only addon: `core/ReminderAddon.php`
304 - Validator addon: `core/MxValidatorAddon.php`
305 - Form-integration addon: `core/WPFormsAddon.php`
306 - Form-integration with legacy bridge: `core/ElementorAddon.php`
307 - Cross-form-adapter addon: `core/UserRegistrationAddon.php`
308
309 Copy one as a starting point, rename, adjust `getId()` and `getCapabilities()`, and replace the body of `boot()` with your feature's wiring.
310
311 ---
312
313 *Documentation version: 1.0 — shipped with Core API 4.3.0.*
314