Every WordPress GTM plugin I have used or audited makes the same architectural mistake. They hook PHP submission handlers and push to the data layer from the server side, assuming the push will reach GTM before the user sees a confirmation or the browser navigates away. That assumption is wrong for every major form plugin and for WooCommerce’s AJAX cart flow. The data layer fires in a context where GTM is not listening, and the conversion vanishes.

I built WP Clean Data Layer to fix this for the integrations I kept encountering across client sites: Contact Form 7, Gravity Forms, and WooCommerce ecommerce events. The plugin is small (roughly 1,400 lines of PHP and 260 lines of JavaScript), and the interesting part is not the code itself but the reason each integration needs a different solution to the same timing problem.

Schema is documented. Timing is where tracking breaks.

GA4’s event specification is well documented. The required fields for a purchase event or a form_submit event are not ambiguous. Most plugins get the schema right. What they get wrong is the moment the push happens.

GTM reads window.dataLayer on the page where the event fires. If a PHP hook pushes an event during an AJAX request, GTM is not re-initialized on the response, and inline scripts in the AJAX response body may not execute in a GTM-compatible context. If the event fires right before a redirect (checkout to thank-you page, login to dashboard), the push lands on a document that is about to be replaced. GTM processes events on the current page. There is no carryover.

Pushing everything from PHP hooks and hoping for the best does not hold up. Getting tracking right requires understanding the async lifecycle of each plugin and choosing the right client-side event to listen for.

What I tried first

Before building a plugin, I looked at what already existed. The GTM4WP plugin by Thomas Geiger is the most widely installed option and handles WooCommerce ecommerce tracking well. For sites that only need WooCommerce events, it is a reasonable choice. Where it fell short for me was form tracking. The plugin focuses on ecommerce and does not provide the same depth of integration for Contact Form 7 or Gravity Forms, which meant layering a second plugin for forms on top of GTM4WP and hoping the two did not conflict on event naming or data layer initialization order.

Google’s own Site Kit plugin sends pageview and default event data to GA4 directly through gtag.js, with no GTM container involved. It does not expose a data layer at all, which makes it useless for anyone who needs GTM triggers, custom event parameters, or ecommerce tracking beyond pageviews.

I also considered building the tracking outside a plugin entirely, using custom JavaScript in the theme or a GTM custom HTML tag. That works for a single site where you control the theme and the GTM container. It does not generalize across client sites with different themes, different form plugins, and different WooCommerce configurations. The value of a plugin here is that the timing logic is encapsulated and tested once, and every site gets the same behavior.

Contact Form 7: the DOM event is the only reliable signal

Hooking wpcf7_mail_sent in PHP and pushing to the data layer from the AJAX response does not work. The response is not a full page load. GTM does not re-initialize, and the push lands nowhere useful.

The wpcf7mailsent DOM event in JavaScript is the reliable signal. CF7 fires this event after the mail is sent successfully, and it fires in the browser context where GTM is already running. The listener reads event.detail.contactFormId and pushes a form_submit event with the form ID and title.

The complication is that CF7 can be configured to skip AJAX entirely. When a site uses CF7’s non-AJAX mode, the form submission is a standard POST, the page reloads, and the wpcf7mailsent event never fires. For that case, the plugin retains the PHP hook as a fallback: wpcf7_mail_sent stores the event payload in a short-lived cookie, and the core data layer script reads and clears that cookie on the next page load. The tradeoff is a one-pageload delay in tracking for non-AJAX submissions, but losing the conversion entirely is worse.

Gravity Forms: the confirmation is the real event

Gravity Forms surfaces a second problem underneath the timing problem. gform_confirmation_loaded, the client-side event that fires after the confirmation markup is injected, does not carry entry metadata by default. The form ID comes through, but the entry ID and form title do not, and both are useful for segmenting conversions by form variant later in GA4.

The timing half of the fix follows the same logic as Contact Form 7: listen client-side, not server-side. The PHP hook gform_after_submission fires during the AJAX request, before the confirmation message renders, so the user has not seen a success state and there is no reliable signal yet that tracking should fire. gform_confirmation_loaded fires after Gravity Forms injects the confirmation markup, at which point the user has seen the confirmation and GTM is running in the same browser context.

The metadata half required a separate fix: hooking the gform_confirmation filter on the PHP side to inject entry ID and form title into the confirmation response HTML as a data attribute, so the JavaScript listener reads them from the DOM rather than firing a second AJAX call to fetch entry details after the fact. That second call would have introduced its own timing risk, since it would need to complete before GTM processes the push.

One limitation carries forward from this design: if a Gravity Forms confirmation is configured to redirect to a separate page rather than render inline, the injected data attribute never reaches the browser, and the listener has nothing to read. That configuration needs the same transient-based, next-page-load pattern the plugin uses for purchase and login events, and it is not yet handled by the Gravity Forms integration specifically.

WooCommerce: three different timing problems in one integration

WooCommerce is the most complex integration because the ecommerce funnel crosses multiple pages and multiple interaction types. Of the three problems below, the purchase event is the hardest, because it is the only one where the data has to survive a full document change with no client-side event to hook at all.

Add-to-cart on most themes is AJAX. The PHP woocommerce_add_to_cart action runs server-side, but no page load occurs. The plugin listens for WooCommerce’s added_to_cart jQuery event, which fires after the cart fragments (the cached HTML snippets WooCommerce uses to update the cart widget without a full page reload) update. For themes that bypass jQuery’s AJAX handling, there is a fetch observer fallback that watches for requests to the WooCommerce add-to-cart endpoint. That fallback has a gap of its own: a theme that writes its own custom AJAX handler outside both jQuery and the standard fetch API, using raw XMLHttpRequest with a nonstandard endpoint, will not trigger either listener, and add-to-cart tracking silently stops working on that theme until someone notices the gap in GA4 reporting.

The purchase event has the opposite problem. The checkout page and the thank-you page are two different documents. Anything pushed to the data layer on the checkout page is gone when the thank-you page loads. The plugin handles this by queuing the purchase payload server-side (via the DataLayer::queue method) and injecting it into the thank-you page HTML before GTM’s container snippet. The push is already in window.dataLayer when GTM initializes, so there is no timing gap.

Login and signup follow the same pattern as purchase. Authentication redirects to a new page, so the plugin stores a short-lived transient during the authentication hook and outputs the event payload on the next page load.

The dead end: a generic AJAX interceptor

Before settling on per-integration listeners, I spent time on what seemed like a cleaner approach: a generic JavaScript layer that would intercept all AJAX responses, detect form submissions by response content, and push events automatically. The appeal was obvious. One piece of code covering all form plugins without needing to know their internal event names or confirmation flows.

It failed for two reasons. First, detecting “this AJAX response was a successful form submission” from the response body alone is unreliable. CF7 returns JSON with a status field. Gravity Forms returns HTML confirmation markup. Other form plugins return different structures. Parsing all of them generically meant maintaining a brittle detection layer that was more complex than the per-integration listeners it was supposed to replace.

Second, and more fundamentally, the generic approach could not distinguish between a successful submission and an intermediate step. Gravity Forms multi-step forms fire AJAX requests between steps. WooCommerce fires AJAX requests for coupon application, shipping calculation, and cart updates. A generic interceptor would need negative rules for every interaction type that is not a conversion, and that list grows every time a plugin updates its AJAX behavior.

Per-integration listeners are more code to maintain, but each one runs 20 to 40 lines and tests in isolation against a single mock event. The generic interceptor, by the time it handled CF7’s JSON responses, Gravity Forms’ HTML confirmations, and WooCommerce’s various AJAX shapes with negative rules for every non-conversion interaction, had grown past 150 lines and still missed cases. The maintenance cost of the per-integration approach is predictable and roughly constant; the generic approach’s maintenance cost would have grown with every plugin update to any of the three integrations.

Schema enforcement as a design choice

Every data layer push routes through a central EventPayload value object, and that constraint turned out to be one of the most useful decisions in the plugin. Each integration builds a payload, passes it to DataLayer, and DataLayer validates the required fields against the GA4 spec before pushing. If a required field is missing, the push fails at the PHP layer with a logged error rather than silently sending a malformed event to GA4.

GA4 accepts almost anything you push to it. That flexibility is useful during development and dangerous in production. A purchase event with no transaction_id will show up in GA4 reports as a conversion with no associated revenue. A form submission with no form_id will count but will be impossible to segment. The schema enforcement layer catches these before they become reporting problems.

What changed

The plugin runs on client sites where I previously had to diagnose and fix tracking gaps on a per-site basis. The pattern that used to take a few hours of per-site debugging (figure out which form plugin, find the right client-side event, write the listener, test in GTM Preview) is now a plugin activation and a settings toggle. The event reference in the docs gives the GTM trigger configuration for every event, so the setup on the GTM side is documented rather than tribal knowledge.

The transferable part

The lesson from this project is that “data layer” problems are almost never about the data itself. They are about timing, and timing is integration-specific in a way that resists a general solution. “When does this plugin consider a form successfully submitted, from the browser’s perspective” has a different answer for every plugin: a DOM event for CF7, a confirmation-loaded event for Gravity Forms, a cart fragment update for WooCommerce. Anyone instrumenting a new WordPress plugin for GA4 should start by finding that plugin’s specific answer to that question, rather than assuming a hook name that sounds like the right one actually fires at the right time.

The code is at github.com/d-voorhees/wp-clean-datalayer.