WP Clean Data Layer
WordPress plugin that standardizes GA4/GTM data layer output across WooCommerce, Gravity Forms, and Contact Form 7. Single schema enforced via a central EventPayload class. Async form and cart events handled via vanilla JS listeners.
by Medium & Message · github.com/d-voorhees/wp-clean-datalayer · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/d-voorhees/wp-clean-datalayer/archive/refs/heads/main.zipReadme
WP Clean Data Layer
Most WordPress GTM implementations break on timing. PHP-side hooks fire during AJAX requests that never trigger a full page load, so the data layer push happens where GTM cannot see it. The result is missed form conversions, phantom ecommerce events, and purchase data that disappears between the checkout page and the thank-you page. This plugin solves the timing problem for each integration individually, pushing a narrow set of GA4-aligned events with explicit handling for the async flows that break naive implementations.
For the full technical walkthrough of the AJAX timing problems and the integration-specific solutions, see the companion post: https://dvoorhees.com/2026/03/17/the-ajax-timing-problem-that-breaks-wordpress-gtm-tracking/
What it does
- Injects a GTM container and initializes
window.dataLayerbefore GTM loads, so no race condition exists between container initialization and event pushes. - Tracks Contact Form 7 submissions via the
wpcf7mailsentDOM event, with a cookie-based fallback for non-AJAX submissions. The PHPwpcf7_mail_senthook is retained only for that fallback path. - Tracks Gravity Forms submissions via
gform_confirmation_loaded, firing after the confirmation markup is injected so the user has seen the success state before tracking fires. Entry metadata is injected into the AJAX confirmation response via thegform_confirmationfilter. - Tracks WooCommerce ecommerce events aligned with the GA4 spec:
view_item,view_item_list,add_to_cart,remove_from_cart,view_cart,begin_checkout,add_payment_info,add_shipping_info,purchase. Add-to-cart uses WooCommerce'sadded_to_cartjQuery event with afetchobserver fallback for themes that bypass jQuery. - Handles redirect boundaries (purchase, login, signup) by queuing event payloads server-side and injecting them into the destination page HTML before GTM processes the container.
- Tracks authentication events (
login,sign_up,logout) and internal search with term and result count. - Provides a settings page to paste a GTM container ID and toggle event groups on or off.
- Exposes
wp_clean_datalayer_eventandwp_clean_datalayer_build_payloadfilters for modifying any event payload before it reaches the data layer.
The plugin requires no build step, no Composer, no npm, and no vendor directory. Copy the plugin folder into wp-content/plugins/ and activate.
Repository structure
wp-clean-datalayer/
├── wp-clean-datalayer.php Bootstrap and plugin header
├── uninstall.php Removes plugin options on delete
├── src/
│ ├── Integrations/ Auth, ContactForm7, GravityForms, Search, WooCommerce
│ ├── Support/ EventPayload value object
│ ├── DataLayer.php Central push manager
│ ├── Plugin.php Lifecycle and hook registration
│ └── Settings.php Admin settings page
├── assets/js/
│ ├── datalayer-core.js Data layer init and cookie fallback reader
│ ├── cf7-listener.js Contact Form 7 DOM event listener
│ ├── gforms-listener.js Gravity Forms AJAX confirmation listener
│ └── woocommerce-listener.js Add-to-cart and cart fragment listeners
├── tests/ PHPUnit tests for payload validation and data layer output
├── docs/
│ ├── event-reference.md Full payload field documentation for every event
│ ├── extending-events.md Filter usage examples
│ └── debugging-with-gtm-preview.md
├── readme.txt WordPress.org format readme
└── LICENSE GPL-2.0-or-later
Total source: roughly 1,400 lines of PHP and 260 lines of JavaScript across 14 source files.
Installation
- Clone or download this repository.
- Copy the
wp-clean-datalayerfolder towp-content/plugins/. - Activate the plugin in WordPress admin.
- Go to Settings, then Clean Data Layer.
- Paste your GTM container ID (format:
GTM-XXXXXXX). - Enable the event groups you need.
- In GTM, create triggers matching the event names in docs/event-reference.md.
Event reference
| Event | Trigger |
|---|---|
form_submit |
CF7 AJAX success, Gravity Forms AJAX confirmation, WordPress comment posted |
view_item |
Single product page load |
view_item_list |
Shop, category, or tag archive |
add_to_cart |
WooCommerce AJAX or standard add to cart |
remove_from_cart |
Cart item removed via AJAX |
view_cart |
Cart page load |
begin_checkout |
Checkout page load |
add_shipping_info |
Shipping method selected at checkout |
add_payment_info |
Payment method selected at checkout |
purchase |
Order thank-you page |
login |
Successful login (next page load) |
sign_up |
New user registration (next page load) |
logout |
User logout |
search |
Search results page |
Full payload schemas with sample JSON for every event: docs/event-reference.md
Extending events
Every event passes through the wp_clean_datalayer_event filter before it reaches the browser. For lower-level control before the event key is set, use wp_clean_datalayer_build_payload. Examples and patterns: docs/extending-events.md
Debugging
Open GTM Preview mode, submit a form or complete a test purchase, and check the Data Layer tab in the Summary timeline. If an event is missing, check the browser console for wpCleanDataLayerPush and confirm the relevant event group is enabled under Settings. Full checklist: docs/debugging-with-gtm-preview.md
Tests
composer install
composer test
PHPUnit covers data layer output structure and EventPayload validation. Tests run against mock WordPress hooks and DOM state, not live plugin instances. This approach keeps the test suite fast and credential-free while verifying that payload schemas match the GA4 spec.
Design decisions and tradeoffs
One listener strategy per integration. CF7, Gravity Forms, and WooCommerce each have different async lifecycles. A generic "intercept all AJAX" approach misses the confirmation-state timing that matters for accurate conversion tracking. The cost is maintaining a separate listener file per integration.
Schema enforcement via EventPayload. Every push goes through a value object that validates required fields before anything reaches the browser. This catches misconfigured payloads at the PHP layer rather than silently pushing malformed events that GA4 accepts without error.
Cookie fallback for non-AJAX form submissions. CF7 can be configured to skip AJAX. When it does, the PHP hook stores the payload in a short-lived cookie that datalayer-core.js reads on the next page load. The tradeoff is a one-pageload delay in tracking for that configuration, but the alternative is losing the conversion entirely.
No build step. The JavaScript files are plain, unminified, and deploy as-is. This keeps the plugin installable by copying a folder, with no toolchain to maintain and no compiled output to debug through.
What this does not do
This plugin does not manage consent. It pushes events to the data layer unconditionally; consent gating belongs in the GTM container configuration using Consent Mode or equivalent. It does not handle server-side GTM tagging or Meta Conversions API, which operate at a different layer. It does not support WooCommerce Blocks checkout, which uses a different JavaScript architecture than the classic checkout flow.
Compatibility
| Requirement | Version |
|---|---|
| WordPress | 6.0+ |
| PHP | 8.0+ |
| WooCommerce (ecommerce events) | 8.0+ |
| Contact Form 7 (optional) | Latest stable |
| Gravity Forms (optional) | Latest stable |
Tested with block and classic themes. WooCommerce AJAX cart listeners require jQuery when the theme uses WooCommerce's default cart fragments.
Why this exists
Built by Medium & Message as a reference implementation for GA4 and GTM instrumentation on WordPress. The patterns here come from solving the same timing problems across client implementations. The plugin is the generalized, public version of that work.
License
GPL-2.0-or-later. See LICENSE.