GTM4WP changelog
Every change in GTM4WP 2.x, newest first. The date next to each version is the day it became available on wordpress.org.
Changes in 1.x and earlier versions are on the GTM4WP 1.x changelog page. The same list is also kept in CHANGELOG.md on GitHub.
Last updated: 5 October 2026
Versions: 2.0
In testing: 2.1.0-beta2
Pre-release published on GitHub on 5 October 2026. Download it from the GitHub release page and test it on a staging site, not on a live site.
- Changed: for developers,
ContainerCode::header_top()always prints its block; the never-used argument that made it return the block instead is gone. - Changed: the container loader requests
gtm.jsoverhttps://instead of the protocol-relative//form the plugin has emitted since 1.x, matching the snippet Google publishes today. Only a site still served over plain http sees a difference; the<noscript>iframe already usedhttps://. - Fixed: the plugin no longer buffers every WooCommerce template part on every page. That buffering only fed the classic "Products" widget, whose tracking had been dead since 2017. If a GTM trigger filters on an
item_list_nameending in "(widget)", remove that condition. - Fixed: the description of the WooCommerce "Order data in data layer" setting no longer says it works independently of ecommerce tracking. It needs "Track e-commerce", and still writes
orderDatawhen the purchase event is not sent again. - Fixed: a settings import file with a checkbox written as the text "false" switched that option on; it now means off, as it does when saving the settings screen.
- Fixed: a WooCommerce checkout total that is not a finite number no longer breaks the inline checkout script;
gtm4wp_checkout_valueisnullthen. - Fixed: hardened how the plugin's REST endpoints answer requests made from other pages. Details are withheld until the change reaches the 2.0 line.
- Changed: with the cache-safe data layer on, the WooCommerce customer and cart data are re-read only when the cart fragment changed, not on every change to the page.
- Added: an optional "Output values in the default language" setting (Page variables → Content & engagement data):
pageTitle,pageCategory,pageAttributes,pagePostTermsandpagePrimaryCategoryreport the master language, so Google Analytics combines reports across translations. Needs WPML, Polylang or thegtm4wp_master_language_post_id/gtm4wp_master_language_term_idfilters (the three settings below: post filter only). Off by default (experimental). Thanks to @loran750 (#145). - Added: an optional "Report products in the default language" setting (WooCommerce → Product data): the whole GA4 item,
item_idincluded, reports the master language, so one product combines across its translations. Review any product feed or dynamic-remarketing setup keyed on the translated id before switching it on. Off by default (experimental) (#145). - Added: an optional "Report downloads in the default language" setting (Easy Digital Downloads → Product data), the EDD counterpart of the WooCommerce option above and with the same caveat about setups keyed on the translated
item_id. Off by default (experimental) (#145). - Added: an optional "Report the form name in the default language" setting (Contact Form 7), so
form_namecarries the master-language title and submissions of one form combine across languages. Works for forms translated as separate entries. Off by default (experimental) (#145). - Changed:
pagePostTermsandpagePrimaryCategoryNamereport term names as they were typed, the same string the e-commerce items have carried since 2.0.2. A GTM trigger that matched the encoded form on either variable needs the plain text now; the slug variables are unchanged. - Changed:
siteSearchTermcarries the search term as typed. The classic data layer used to HTML-encode it (&,") while the cache-safe one did not; a GTM trigger matching the encoded form needs the plain text now. - Changed:
pagePostTermsis omitted when the post has no terms and no reported meta, instead of being an empty list, so a trigger testing the variable's presence no longer fires on such pages. - Changed: the Contact Form 7 tracker script is no longer loaded while Contact Form 7 is not installed.
- Changed: for developers,
gtm4wp_datalayer_push()returnsfalsefor a$js_beforeor$js_afterargument that is not a string, as documented, instead of printingArrayinto the page; and the consent mode flag override filter also accepts the stringsgrantedanddenied. - Changed: on a single site
siteIDandsiteNamecarry the site's own id and name instead of 0 and an empty string, and only the variable whose option is on is reported. - Changed:
visitorEmail,visitorEmailHash,visitorUsernameandvisitorRegistrationDateare omitted for a logged-out visitor or an empty value, andvisitorIPwhen no address can be determined, instead of being reported empty.visitorEmailHashis now lower-cased and normalised the way Google matches user-provided data, so it changes for mixed-case and Gmail addresses. - Changed:
pageTitlecarries the title as the visitor reads it; WordPress's own encoding of ampersands, quotes and dashes (&,’) is decoded. A GTM trigger matching the encoded form needs the plain text now. - Changed: with trusted proxy addresses configured, the Cloudflare country code is read only for requests that arrived through one of them, so add Cloudflare's IP ranges to the list; one admin notice asks for the list while either proxy header is read without one.
geoCloudflareCountryCodeonly ever carries Cloudflare's two-letter form (plusXXandT1); anything else is omitted. - Changed: a
gtm4wp_admin_page_capabilitycallback returning something other than a capability name is reported through_doing_it_wrong()and the defaultmanage_optionsapplies, instead of locking every administrator out silently. Returndo_not_allowto deny everyone. - Changed: the Axeptio project ID field looks up the cookie versions 400 ms after typing stops instead of on every keystroke.
- Added: a Services for agencies and freelancers section on the settings screen, with no options, introducing the services the developers of GTM4WP offer to agencies and freelancers and linking to them on gtm4wp.com; the link is left out when a site removes the documentation links with
gtm4wp_admin_doc_url. - Removed: the
$gtp4wp_plugin_url,$gtp4wp_plugin_basenameand$gtp4wp_script_pathglobals, deprecated in 2.0 as announced. Third-party code still reading them usesplugin_dir_url( GTM4WP_PLUGIN_FILE ),plugin_basename( GTM4WP_PLUGIN_FILE )andplugin_dir_url( GTM4WP_PLUGIN_FILE ) . 'build/'instead. - Removed: the WebToffee GDPR Cookie Consent (v2.x) integration, deprecated in 2.0. WebToffee v3.x and later connect to Google Tag Manager on their own, so upgrade WebToffee if a site still runs v2.x. The stored setting is deleted on upgrade, and the
cookie_consent_updateandcookie_consent_<category>events it pushed stop; review GTM triggers built on them. - Removed: the "YouTube video events" option, deprecated in 2.0 as announced. Use Google Tag Manager's built-in YouTube Video trigger with its "Add JavaScript API support to all YouTube videos" setting on, since the plugin no longer adds
enablejsapito YouTube embeds. Triggers on the plugin's YouTubegtm4wp.media*events stop firing; thegtm4wp_youtubefilter is gone too. - Removed:
orderData.customer.billing.emailhash, deprecated since 1.20. GTM variables still reading it switch toorderData.customer.billing.email_hash, which carries the same value.
Easy Digital Downloads
- Added: Easy Digital Downloads integration (EDD 3.0+, beta) as its own settings section, covering the classic shortcodes and the EDD blocks with the full GA4 event set from
view_itemtopurchase. Purchases resolve through EDD's own payment-key chain and are deduplicated three ways. The purchase-status default includes Pending and Processing, because offsite gateways return buyers before the order completes. - Added: Easy Digital Downloads settings mirroring the WooCommerce ones:
orderDatain the same key names, persistent list attribution, Enhanced Conversionsuser_data(phone via EDD's field or the newgtm4wp_edd_order_phonefilter) and reliable purchase tracking; the cache-safe data layer also delivers the customer, the cart and missed purchases. Customer identity reaches only a visitor EDD would show the receipt to. - Added: for developers, the Easy Digital Downloads filters
gtm4wp_eec_edd_cart_item,gtm4wp_eec_edd_order_item,gtm4wp_eec_edd_order_data,gtm4wp_edd_purchase_datalayer,gtm4wp_edd_datalayer_on_pageloadandgtm4wp_edd_purchase_trackable_statuses.
Google service accounts
- Added: a Google service accounts section on the settings screen (experimental): upload, label, test and delete the JSON key of a Google Cloud service account, several side by side. The Data Manager destinations below authenticate with one. Keys are stored encrypted, never shown again and removed on uninstall; changing the wp-config.php security keys makes them unreadable, and a notice then names the accounts to upload again.
Google Data Manager
- Added: a Google Data Manager section on the settings screen (experimental), where each destination row names a GA4 property ID, a measurement ID and the service account authenticating to it. A per-row Test button sends a validation-only request, so a missing permission or mistyped ID is reported before anything is saved. Repeated failures raise an admin notice.
- Added: attribution capture (experimental, off by default) on the Data Manager's Attribution capture tab: each new order stores what a later server-side event needs to be matched to it in Google Analytics – the Analytics client and session IDs, the Google Ads click IDs and the consent state. The IDs come from the official
gtag('get', ...)API, so a GA4 tag has to fire in your container. Storing is consent-gated in both directions, andgtm4wp_gdm_order_consentoverrides the recorded state. - Added: server-side refund events (experimental): a refund issued in the store admin is sent to each Data Manager destination as a GA4
refundevent with amount, items, shipping and tax, matched by transaction ID and client ID. Google Analytics itself processes only the first of several refunds on one order; reported to Google, no plugin update needed once fixed. - Changed: the settings screen shows Unsaved changes next to the Save button while anything is waiting, and the browser asks before a tab with unsaved edits is closed or reloaded.
- Changed: removing a row from a settings table now asks first: the trash icon becomes a Remove/Cancel pair for that row. A row nobody has typed into is still removed straight away.
- Added: a Recent sends list under the destinations table: what was sent to Google from the server and what became of it, deliberate skips and their reason included, with a switch that hides everything Google applied. Failed or fixable refunds can be queued again, one row or all; the list also reports to Tools → Site Health.
- Added: a "Require consent before sending" setting (experimental) deciding which orders need analytics storage granted before anything about them is sent: buyers in the EEA, the UK and Switzerland by billing country (the default), every order, or never. Where the gate applies and consent was denied or was never captured, nothing is sent and the reason is recorded.
Site Health
- Added: Tools → Site Health reports the whole plugin: option states, containers, placement, data layer name and wp-config overrides (no keys, addresses or visitor data), plus status tests for the configuration, Google service account keys, Data Manager sending and an allowlist blocking every tag; on multisite, who can change the container. Modules report through
SiteHealthInfoInterface/SiteHealthTestsInterface.
AI assistants (WordPress Abilities API)
- Added: the plugin registers abilities with the WordPress Abilities API (WordPress 6.9+; nothing changes below that), so an AI assistant connected through the WordPress MCP Adapter or another client can read the configuration, help work out why tracking is not firing, and change a setting once you have confirmed it. Eleven abilities, six of them read-only, all requiring the settings capability and returning no keys and no visitor data.
gtm4wp_abilities_enabledandgtm4wp_abilities_allow_writeswitch the surface off or keep it read-only. Experimental. - Changed: the admin notices about a missing container ID, an incomplete environment configuration, a malformed
GTM4WP_HARDCODED_*constant, a visitor IP header with no trusted proxies and an unusable data layer variable name now carry a separate "Open the setting" link after the message. The same checks feedgtm4wp/get-status, so an assistant and the screen report the same problems.
2.0.5 · 1 October 2026
- Fixed: the data layer initialisation block no longer contains the word
gtag. Since 2.0.3, a JavaScript delay plugin withgtagon its keyword list, such as Flying Scripts, delayed the whole block and the browser console showeddataLayer is not defined. The Google tag developer ID is still set. - Fixed: a page with no data layer variables to report no longer pushes an empty array into the data layer;
dataLayer_contentis then an empty object and is not pushed. Content that is pushed is always an object, never an array, which GTM would read as a command.
Read more: GTM4WP 2.0.5 is out
2.0.4 · 29 September 2026
- Fixed: on the classic WooCommerce checkout, an error in the plugin's checkout step tracking could stop the order from being submitted the normal way, so a gateway that adds card details in the browser, such as Stripe, rejected it. Tracking errors can no longer interrupt the checkout or a variation selection, and still show in the browser console. (#472)
Read more: GTM4WP 2.0.4 is out
2.0.3 · 24 September 2026
- Fixed: on a store that displays prices including tax, every cart line in
view_cart,begin_checkoutand the cart content carried adiscountequal to the line's tax, with no coupon or sale involved, so GA4 reported a share of the revenue as a discount on every order. The per-item discount is the gap between the line subtotal and the line total, and on such a store the tax is added to both before comparing them, but the total side read a key WooCommerce never writes on a cart item, so only the subtotal side gained the tax and the difference between the two was exactly the tax. The total side now reads the key WooCommerce does write, and an undiscounted line carries nodiscountagain, on every WooCommerce version the plugin supports. Stores displaying prices excluding tax were never affected, and neither was thepurchaseevent. (#470) - Fixed: on a store whose product page runs the newer WooCommerce blocks (the ones built on the WordPress Interactivity API), an add to cart that the store refused – out of stock, or stopped by the block's own validation – could still be reported a moment later. The plugin holds such an add back until WooCommerce confirms it, and that confirmation carries no product, so a related-products or grid add clicked within the next ten seconds released the held-back one as well and
add_to_cartfired twice for a single item. A list add now supersedes whatever the product form still had waiting, so only the item that really reached the cart is reported. - Fixed: on the same stores, the cart read the plugin makes over the WooCommerce Store API when the cart changes could report every item as removed when the request came back with something other than a cart – a security or caching layer answering a logged-in request with a page instead of the cart data, for instance. Such an answer is now treated as no reading at all, so at worst one change goes unreported rather than a
remove_from_cartbeing invented for the whole cart. - Fixed: a purchase was never reported when the customer reached the order received page while the order was still Pending payment, and "Reliable purchase tracking" did not recover it. This is the normal sequence with a payment provider that confirms the payment through a webhook, the WooCommerce Stripe Gateway with webhooks enabled for instance: the customer is redirected back a moment before the confirmation arrives, the plugin sees a status that is not in "Order statuses that trigger the purchase event" and withholds the event, and the change to Processing then happens in the provider's own request, which the plugin cannot connect to the customer's browser. The order was left unflagged, so a later visit to the same order received URL reported the purchase, but nothing else did. With "Reliable purchase tracking" on, such an order is now remembered in the customer's session and checked again on the pages they view next: the purchase is reported once, on the first page view after the status has become one of the tracked statuses, and the order is flagged as tracked at that moment, not before. The wait ends by itself when the order is cancelled, refunded or fails, or once the order is older than the "Maximum order age" (30 minutes by default, and a day at most when that limit is turned off). Pending payment itself is still not counted as a sale, the first load of the order received page still carries
orderDataand still withholds the purchase, and every existing duplicate guard applies unchanged. The same recovery works with the cache-safe data layer, where the browser asks the plugin again on each page view until the order is ready or the wait ends. - Fixed: with a custom data layer variable name, the Google Consent Mode default block pushed its defaults to
dataLayerinstead of the configured variable, so the container never received them and the consent defaults did not apply. The block now uses the configured name and stays out of WP Rocket's combined JavaScript. - Fixed: on a subdomain multisite, or any site defining
COOKIE_DOMAIN, the cache-safe data layer could not clear its event cookie after a one-shot event was delivered, so every later page view made a needless request. The cookie is now host-only. - Fixed: a login or registration event was lost when the visitor's next request was a REST call (the WooCommerce Store API, a headless front end) rather than a page view.
- Fixed: a user account created by a logged-in administrator through the REST API or an admin app fired
gtm4wp.userRegisteredin the administrator's own browser instead of nowhere. - Fixed: the form interaction and Contact Form 7 events reported a hidden
actionfield instead of the form's URL when the form contained a field namedaction,idortarget. - Fixed: the browser, OS and device data script reported all three signals when a page optimiser removed its inline configuration, ignoring which of them were enabled. It now reports nothing in that case.
- Fixed: with the cache-safe data layer, a logged-in visitor served a cached page kept requesting their visitor data on every page view after WordPress refused the page's nonce. When WordPress itself reports the rejected nonce, the data is requested once more anonymously and that page stops asking.
- Updated: tested with WooCommerce 11.1.2.
- Added: the data layer initialisation block carries the plugin's Google tag developer ID,
gtag('set', 'developer_id.dNGJiYT', true), so Google can tell which platform installed the tag. It identifies GTM4WP only and adds nothing about the site or its visitors.
Read more: GTM4WP 2.0.3 is out
2.0.2 · 15 September 2026
- Updated: tested with WooCommerce 11.1.
- Fixed: a product category or brand name containing an ampersand reached the data layer as
Shirts & Tiesrather thanShirts & Ties, and that is the literal string GA4 reported. WordPress encodes a term name when it is saved, so what the plugin read back was the encoded form; it is decoded once now, at the point the name is read, and the value still reaches the page through the same JSON encoder as before, so nothing about the escaping of the script changes. - Fixed:
item_id,skuand the dynamic remarketingidare now always strings, on every product. A product with no SKU falls back to its numeric id, and that fallback was written into the data layer as a JSON number while the same fields are strings on every product that has a SKU. A Google Tag Manager trigger comparing one of those fields cannot be written for both cases at once, so this completes the change 2.0 started when it stopped turning identifiers into numbers. Check your GTM setup if a trigger or variable comparesitem_id,skuoridagainst a number on products without a SKU. - Fixed: the
begin_checkoutevent was missing on stores where WooCommerce reports the checkout page as the cart page as well. WooCommerce answers that question from several sources, so a plugin or a theme definingWOOCOMMERCE_CARTor answering thewoocommerce_is_cartfilter while the checkout renders, or a cart shortcode left in the checkout page content, makes both answers true on the same page. The data layer asked the cart question first, so such a checkout page pushedview_cartandbegin_checkoutnever fired anywhere, while the block tracker resolved the very same page as the checkout and fired the checkout steps on it. Both halves of the plugin now decide the checkout first, so a page reported as both is treated as the checkout page, and the two can no longer disagree about the page they are on. The order-received page is recognized ahead of both on both halves as well: on such a store the thank-you page used to be taken for the cart page by the block tracker, which loaded there in its cart context in place of the regular tracker. - Fixed:
add_to_cartandremove_from_cartnever fired on a store whose product and cart blocks are built on the WordPress Interactivity API, which is the newer form of the WooCommerce blocks. Two separate causes, both fixed. On a product page WooCommerce renders the Add to Cart + Options block without thecartclass it puts on the classic form, and the plugin looked for exactly that class before reading the product data, so a click was never reported even though the button carried the classes it expects. That page now reports the add once WooCommerce confirms that the item really reached the cart, so an add refused for being out of stock or by the block's own validation still reports nothing. Elsewhere, those blocks keep the cart in a private store that the plugin must not read, so the cart is read back from the WooCommerce Store API whenever WooCommerce announces that it changed, and the difference is reported asadd_to_cartorremove_from_cartexactly as on a store using the older blocks. The extra request is only made for a visitor who already has a cart, and only when the cart actually changes. A variable product added from such a page is covered as well: the interactive form publishes no variation data the plugin could read, and the parent product's price would be the wrong number to report, so the event is completed from the cart line the add creates, which carries the finished item for the variation itself. - Fixed: a
GTM4WP_HARDCODED_*value in wp-config.php that ends in a newline character – typically an untrimmed file read feeding thedefine()– is now rejected and named in the existing admin notice, like any other malformed value. Previously agtm_auth/gtm_previewvalue with a trailing newline passed validation, the newline reached the container loader script and broke the whole block, so the container silently did not load with nothing pointing at wp-config. A container ID with a trailing newline used to be silently repaired and kept working; it is now reported the same way instead – remove the stray newline from thedefine()and the override applies again. - Fixed: a page whose author cannot be resolved no longer takes the whole page down with a critical error when PublishPress Authors is active. PublishPress reports such an author as no author at all, which happens when the author's user account has been deleted, and the plugin passed that straight into the code reading the author name and ID, where it ended in a fatal error. The WooCommerce My Account page is the one most likely to hit it, since it is often the page nobody keeps an author on. An author that cannot be resolved is now skipped, so the page renders and the author variables are simply left out of the data layer, and a post that has one real author next to an unresolvable one is treated as having a single author.
Read more: GTM4WP 2.0.2 is out
2.0.1 · 3 September 2026
- Fixed: on a block-based store, opening the Cart page pushed
add_shipping_infoandadd_payment_infointo the data layer with no interaction, and both events then fired again on the Checkout page. The block tracker told the two pages apart by the presence of the WooCommerce payment data store, which WooCommerce registers on the Cart page as well; the Cart and Checkout pages now each receive their own context and the checkout-step events fire only on the Checkout page. (#463) - Fixed: a blank settings screen no longer stays silent about why it is blank. The screen is built in the browser by a JavaScript file loaded from the plugin folder (
build/admin.js), and the EasyPrivacy filter list blocks everything under that folder, so an administrator running an ad or privacy blocker with that list opened the settings page and found it empty, with nothing anywhere saying why. The page now carries a static, server-rendered notice that no blocker can remove: it stays invisible while the settings app starts normally and appears after a few seconds only when the app never started, naming the most likely cause and the way out (pause the blocker for the admin area of the site, or add an exception for it). The notice also covers every other reason the script fails to run, an incomplete upload or disabled JavaScript included.
Read more: GTM4WP 2.0.1 is out
2.0 · 1 September 2026
Major rewrite of the plugin – please read the announcement post on gtm4wp.com before upgrading.
Architecture & requirements
- Changed: complete object-oriented rewrite. Every feature is now a module that third-party plugins can extend through the
gtm4wp_register_modulesaction. All public template functions (gtm4wp_the_gtm_tag()etc.), filter/action names, wp-config constants and thegtm4wp-optionsstorage key are unchanged, so existing integrations keep working. - Changed: minimum requirements raised to PHP 8.0 and WordPress 6.3. Supported up to WordPress 7.1.
- Updated: frontend scripts now load with the
deferstrategy where possible. - Changed: the main plugin file now refuses to run when it is requested directly instead of loaded by WordPress, matching every other PHP file in the plugin.
- Deprecated: the
$gtp4wp_plugin_url,$gtp4wp_plugin_basenameand$gtp4wp_script_pathglobal variables (note thegtp4wp_spelling, a typo inherited from 1.x). Nothing inside the plugin reads them; they are set only for third-party code written against 1.x. They still work in this version but will be removed in GTM4WP 2.1. Useplugin_dir_url( GTM4WP_PLUGIN_FILE ),plugin_basename( GTM4WP_PLUGIN_FILE )andplugin_dir_url( GTM4WP_PLUGIN_FILE ) . 'build/'instead, all of which work on admin and frontend requests alike. Note that$gtp4wp_script_pathalready resolves tobuild/rather than the 1.xdist/js/, so any code building a script URL from it needs updating in any case. Unlike a deprecated function, a global variable cannot raise a deprecation notice, so this entry is the only warning there is.
Settings screen
- Added: modern React-based settings screen with left pane navigation, tabbed option groups, option search and inline per-field validation. The screen is usable on a phone: the title and the "Export settings" / "Import settings" / "Save changes" buttons stack instead of running off the edge of the screen; the list of settings sections becomes a search field and a dropdown pinned to the top of the screen, so switching section no longer means scrolling past a full screen of section names to reach the settings; a row of option group tabs that is wider than the screen scrolls sideways with each tab on one line, instead of being squeezed into columns of single words with the last tabs unreachable; and the container table turns into one card per container with every column labelled above its own field, instead of six columns crammed into the width of a phone. The selected option group tab is also kept in view now on any screen size, so opening a bookmark or an admin notice link no longer leaves the tab strip pointing at the wrong tab.
- Added: admin notices now link straight to the option they are about. Clicking through opens the module, switches to the tab holding that setting and highlights it, instead of dropping you on the settings screen to work out for yourself that, say, the trusted proxy addresses live under Page variables → Visitor data.
- Added: the settings screen keeps its address bar up to date, so any module and tab can be bookmarked or sent to a colleague and reopens exactly where you left it. The back button still simply leaves the settings screen; it does not turn into a tab stepper.
- Added: a help link on every option. Each settings section now carries a documentation link in its header, and every one of the plugin's 110 options has a "?" icon next to it that opens the part of gtm4wp.com describing that particular setting – what it changes in the data layer, what it is set to before you touch it, and when it is worth changing. Previously the documentation existed but nothing on the settings screen pointed at it, so finding the page for, say, "Only track orders younger than" meant searching the site for it. The links open in a new tab and never change a setting when clicked. Three sections that had no documentation page at all – the Google Tag Manager container settings, AMP and tag restrictions – have one now. Plugins that add their own modules can point their options at their own documentation, either by giving a full URL in their settings schema or through the new
gtm4wp_admin_doc_urlfilter. - Added: export & import of the plugin settings (Google Tag Manager settings screen, next to "Save changes"). "Export settings" downloads all of your GTM4WP options as a JSON file; "Import settings" reads such a file back on another site (or the same one). The import is treated as untrusted: every value in the file is run back through the exact same per-field sanitizers as a normal save before anything is stored – unknown keys are dropped, every value is normalized to the field's expected type, oversized files are refused and the file is only ever parsed with
json_decode()(neverunserialize/eval), so a hand-edited or malicious file cannot inject unsafe data. Both endpoints require the settings capability and a nonce. The value set is schema driven, so every current and future option is covered automatically.
Google Tag Manager container & tag restrictions
- Added: every Google Tag Manager container ID now has its own environment parameters (
gtm_auth/gtm_preview), custom domain and custom path, managed in a data table (newgtm-containersoption). Existing settings are migrated automatically; the flat 1.x option keys are kept in sync for third-party code and downgrades. - Changed: with environment parameters configured, all containers are loaded now (1.x only loaded the first container in that case). Only the hard-coded wp-config environment constants still limit output to the first container.
- Added: per-container "Omit container ID" option in the container table. When a custom path is set (server side GTM), turning it on drops the container ID from the loader URL (
gtm.js?'+dlinstead ofgtm.js?id='+i+dl) for setups where the container is selected by its path. - Added: a kill switch to stop the Google Tag Manager container from loading on a cloned or staging copy of a site without deactivating the plugin. A new "Only output the container on production environments" option (Google Tag Manager container → Advanced) emits the container only when WordPress reports the environment type as "production" (set
WP_ENVIRONMENT_TYPEon your non-production copies). BecauseWP_ENVIRONMENT_TYPElives inwp-config.phpor the server config and is invisible from the admin, the option's description reports the environment type WordPress actually returns on this site and whether the container would therefore be loaded or suppressed – including the common trap that an unsetWP_ENVIRONMENT_TYPEfalls back to "production", so the option silently keeps loading the container. For host-based control from an mu-plugin orwp-config.php, the newgtm4wp_output_containerfilter (defaulttrue) can veto the container from PHP. In both cases the data layer stays active and only the container<script>/<noscript>is suppressed – exactly like the "Off" placement. Off by default (experimental). - Changed: a malformed
GTM4WP_HARDCODED_GTM_ENV_AUTHorGTM4WP_HARDCODED_GTM_ENV_PREVIEWvalue inwp-config.phpis now rejected instead of being written into every container's loader URL, and the settings screen shows a warning naming the constant that is wrong. Previously only the hard-coded container ID was checked, so a typo in the environment constants was applied as-is and there was nothing anywhere to point at the cause. The accepted formats are the same ones the container table enforces (gtm_previewlooks likeenv-3). The settings screen also shows what a valid hard-coded constant does: the part of the container table it fixes is displayed read-only with the values that are actually running – a single environment column, or the whole table when the constants also decide which containers are loaded – and the field description names the constant you have to edit. Your own stored container setup is kept untouched behind it and is used again as soon as you remove the constants fromwp-config.php; 1.x showed the hard-coded container ID in a read-only field but wrote that value into your saved options on every save. A rejected constant changes nothing at output time, so it leaves the table editable. - Fixed: the dataLayer variable name option (Google Tag Manager container → Advanced) accepted names that break every script the plugin writes. The field allowed a hyphen, but JavaScript reads a hyphen as the subtraction operator, so a name like
my-layerwas saved without complaint and then written to the page asvar my-layer = my-layer || [];– a syntax error that took the whole GTM4WP script block with it. The container itself still loaded, so the setup looked connected on the surface while no data layer content was ever pushed: no page variables, no e-commerce events, no media events. The field now accepts exactly what JavaScript accepts as a variable name (a letter,_or$, followed by letters, digits,_or$– and$is newly allowed, it was rejected before). A name stored under an older version that does not qualify is ignored in favour of the defaultdataLayerso the container keeps working, and the settings screen names the rejected value so you can correct it. - Changed: the tag restriction list is written to the data layer under the key names Google documents,
gtm.allowlistandgtm.blocklist. The plugin used the oldergtm.whitelist/gtm.blacklistnames, which appear nowhere in Google's current documentation for this feature – there is no statement that they are supported, deprecated, or anything else. Google Tag Manager still reads both pairs (it checks the documented name first and falls back to the old one), so nothing was broken by this on its own; the point is that an undocumented key name would fail silently if it were ever dropped, with every tag running unrestricted while the settings screen still showed the restriction in place. Check your GTM setup if you built a data layer variable, trigger or custom template ongtm.whitelistorgtm.blacklist– those keys are no longer written; readgtm.allowlist/gtm.blocklistinstead. The restriction settings themselves are unchanged and need no attention. - Updated: the tag restriction settings now use Google's current vocabulary. The two restriction modes are called "Blocklist selected entities" and "Allowlist selected entities" (they were "Blacklist" and "Whitelist"), matching both Google's own documentation and the
gtm.blocklist/gtm.allowlistkeys the plugin writes. Your saved mode and your selected entities are untouched – only the labels changed. The restrictable entities are also no longer one long list: they are grouped into collapsible Tags, Triggers, Variables and Entity groups sections, each showing how many of its entries you have selected, and the tab holding them is now called "Entities" – it was called "Tags", although it always held the triggers and variables too. - Fixed: turning on the tag restriction list in blocklist mode (called "blacklist" before this version) blocked every tag, trigger and variable in your container instead of only the ones you selected. The plugin wrote both restriction keys on every page and left the unselected one as an empty list – and an empty allowlist is not "no allowlist" to Google Tag Manager, it is an allowlist that permits nothing, so everything was blocked. The failure is silent and looks like the restriction simply working very aggressively, which is why it went unnoticed: nothing errors, and the setting warns that it can affect your tag deployment anyway. Allowlist mode was not affected. Only the key for the mode you selected is written now. If you turned tag restrictions on in blocklist mode and your tags stopped firing, this was why – the restriction list itself was always correct and needs no changes.
- Updated: tag restriction entity list refreshed from Google's restriction documentation (added the Google tag / GA4 tags and the Google Analytics Settings variable, removed Universal Analytics). Mouseflow stays restrictable – Google still documents it.
- Added: the tag restriction list can now restrict sandboxed scripts (custom tag/variable templates) through Google Tag Manager's
sandboxedScriptsgroup class. 1.x had a "Custom tag/variable templates" checkbox for this, but it was never emitted to the container, so it had no effect; it is now a proper, working entry in the restriction list. - Fixed: the "User roles to exclude" option (Google Tag Manager container → Advanced) now leaves out the whole container code for an excluded user role, not only half of it. It removed the container
<script>from the page head, but the<noscript>iframe placed after the opening body tag was still written out – and that iframe is precisely what loads the container when JavaScript does not run, so an excluded user was still counted on such a request (a prefetcher, a crawler or a browser with scripting disabled). The<noscript>part is now suppressed together with the<script>part, and the browser console warning that explains why the container is missing is shown in both places. The data layer stays active for excluded users exactly as before. - Fixed: the browser console warnings that explain why the container code is missing are no longer broken JavaScript. When the container is suppressed – placement set to "Off", the production-only kill switch, or an excluded user role – GTM4WP writes a short
console.warnnote into the page saying so. In the<noscript>part of the container code that note was passed through WordPress' HTML sanitizer without undoing its ampersand encoding, so&&arrived in the browser as&&and the whole warning block was a syntax error: no explanation appeared, and a JavaScript error was reported instead. The warnings now run as intended, and the container's<noscript>iframe URL keeps its correct&-encoded form. - Fixed: the red "To start using Google Tag Manager for WordPress, please enter your GTM ID" notice is no longer shown when Container code placement (Google Tag Manager container → General) is set to "Off". That placement means data layer only – the container code is deliberately left out and the container is loaded by your own code – so there is no container ID to enter, and nothing you could change on the settings screen would clear the notice. Dismissing it was the only way out, and that silenced it for the one admin user who clicked, on that browser. For every other placement the notice is unchanged, because a missing container ID there really does mean nothing is being tracked.
- Fixed: the
type="text/javascript"attribute is no longer stripped from the plugin's<head>script block on themes that do not declare HTML5 support. That block is sanitized against an attribute allow-list which did not listtype, so on such a theme the attribute the plugin had just added was removed again on its way to the page. The container's own<script>tags were never affected. - Changed: the CSS permission the container's hidden
<noscript>iframe needs from WordPress' HTML sanitizer is now applied only while GTM4WP sanitizes its own container markup, instead of for the whole page load. The container code that reaches the page is byte-for-byte unchanged. Everywhere else, WordPress' own inline-style rules apply – so if adisplayorvisibilitydeclaration somewhere in your content used to survive on this site, set it from your theme's stylesheet instead. - Fixed: a single value that PHP cannot convert to JSON no longer takes the whole script block with it. The data layer, the purchase event, the additional data layer pushes, the checkout totals, the duplicate purchase guard and the settings screen each wrote a value into a script by pasting the conversion result straight in, and when that conversion fails PHP substitutes an empty string – which leaves behind a line such as
var dataLayer_content = ;that a browser refuses to parse. The result was that the entire block failed, taking the data layer, the container snippet inside it and everything after it down, rather than losing the one value. Conversion fails for a handful of values a plugin can put into the data layer through GTM4WP's filters, among them the numeric results of a division by zero and anything nested more deeply than PHP's limit. Now the affected value is left out and the rest of the block is emitted normally; a purchase whose event could not be written is no longer marked as tracked either, so the next page view tries again instead of losing the order. - Fixed: numeric-looking text values are no longer converted into numbers in the data layer. The main data layer JSON was encoded with PHP's
JSON_NUMERIC_CHECKflag, which coerced every numeric-looking string anywhere in the structure into a JSON number: a SKU like000035180lost its leading zeros incartContentwhile the same product insideecommerce.items(built on a path without the flag) kept the correct string, order numbers, postcodes and phone numbers changed type, and custom values added through thegtm4wp_compile_datalayerfilter were altered too – as reported on the wordpress.org support forum. Identifier-like values now always reach Google Tag Manager as unmodified strings, while genuinely numeric values (prices, cart/order totals, quantities, counts, ids – including the Unix-timestamp page variablespagePostDateUnix/pageModifiedDateUnix, the post counts and the author ids) are typed as real numbers at their source, so GA4 and Meta still receive numericprice/value. Check your GTM setup if it compares one of these against a number:orderData.attributes.order_number, numeric SKUs initem_id/sku, numeric-looking postcodes/phone numbers and zero-padded date parts (pagePostDateMonthis now"07", not7) arrive as strings after this fix. GTM's "greater/less than" trigger comparisons keep working (they compare numerically), but an "equals" comparison against the unpadded number (e.g.7) must be updated to the padded string (07).
Cache-safe data layer
- Added: a new experimental "Cache-safe data layer" setting (issue #398). On sites with full-page caching (LiteSpeed, WP Rocket, Varnish, Cloudflare APO) the HTML built for one visitor is served to everyone, so any visitor-specific value baked into the data layer would leak – the classic case being a logged-in editor's page cached with their email/username/role and then served to anonymous visitors. With this on, no visitor or session data is rendered into cacheable HTML at all; every such value is instead delivered client-side under the same data layer variable names, so your Google Tag Manager tags keep working.
- The values the browser can compute itself – the search term and the referring page (
siteSearchTerm,siteSearchFrom) – are pushed client-side directly, which also removes their reflected-XSS surface. - The visitor IP, Cloudflare country and logged-in-user data (login state, role, email + hash, registration date, username, id) come from a new first-party session endpoint that returns only the current request's own data (it takes no user/session id, so one visitor can never request another's) with no-cache headers. The IP/country are fetched once per session and cached in the browser, the user data only when the login state changed, so an anonymous visitor on a cached page never fetches and never receives user data.
- The WooCommerce customer and cart blocks ride the cart-fragments response WooCommerce already refreshes on every cart change, so GTM4WP makes no request of its own for them. It does make sure that response actually happens: WooCommerce only loads its cart-refresh script for the classic "Cart" widget, and not even there on the cart and checkout pages (the block Mini-Cart never loads it), so on most stores the customer and cart variables would simply never arrive on a normal page view – GTM4WP therefore loads that script itself while this mode is on. On a store that was not loading it before, this means one extra WooCommerce cart-refresh request per browser tab (per tab, because that is how WooCommerce caches the response; an already-loaded tab makes none on later page views, and a visitor who has blocked browser storage makes one per page view). That request is only made for a visitor who already has a cart or is logged in. WooCommerce's cart-refresh script has no empty-cart shortcut of its own, so without that condition a visitor who had never touched the shop would pay the request to be told their cart is empty. Nothing arrives late because of it: the moment they do add something, WooCommerce's own add-to-cart response carries the data in. Nothing changes on a store that already shows a mini-cart anywhere, which is the majority.
- Because these values now arrive after the page view instead of inside it, point the tags that read them at a Custom Event trigger. There is one event per group of variables, so the event name alone tells you what arrived and no trigger condition is needed:
gtm4wp.visitorDatacarries the visitor and logged-in-user variables (siteSearchTerm,siteSearchFrom,visitorIP,geoCloudflareCountryCode,visitorLoginState,visitorType,visitorEmail,visitorEmailHash,visitorRegistrationDate,visitorUsername,visitorId),gtm4wp.customerDatathe WooCommercecustomer*variables andgtm4wp.cartDatathecartContentvariable. If you need to react to more than one, use a "Some Custom Events" trigger matchinggtm4wp\.(visitor|customer|cart)Data. - Each of the three fires only when its own data changed and only when the option that produces it is enabled, so changing a cart quantity fires
gtm4wp.cartDataalone and editing a billing field on the checkout firesgtm4wp.customerDataalone. Two things to read correctly: an empty cart is still sent (cartContentwith an empty item list – that is how you see the cart being emptied), so a missinggtm4wp.cartDatameans the cart content option is off or the cart has not been read yet, not that the cart is empty; andgtm4wp.visitorDatais skipped entirely on a page where none of its variables apply, so a tag firing on the two WooCommerce events must not assume avisitor*variable is already set. Whengtm4wp.visitorDatadoes fire it always comes first, so reading its variables from a later event does work. - The two WooCommerce one-shot events – the
add_to_cartfired when a product is restored to the cart (the cart "Undo") and the reliable-purchase fallback that recovers apurchasewhen the order-received page was missed (a custom/redirect thank-you page) – must fire exactly once, so they are delivered differently: when one is queued GTM4WP sets a short-lived event cookie, and only then does the browser fetch it (resolving the order/re-add from this session, never from a URL parameter), fire the event once and clear the cookie. An anonymous visitor on a cached page, who never has the cookie, never fetches. - The purchase fallback reuses the existing de-duplication – the same
gtm4wp_orderid_trackedbrowser guard (keyed on the order number) the order-received page writes, plus the server_ga_trackedorder flag, which the browser sets with a single authenticatedPOSTbeacon after a fallback delivery (the order id taken only from the buyer's own session, never the request body). A fallback fire and a real order-received purchase for the same order can therefore never both count, on the same device or across devices; the re-add is de-duped on a per-event token. - The "Do not flag orders as being tracked" option is honoured end to end (no marker, no beacon, no flag), and all request-header values and order numbers round-trip hex-encoded so a hostile value can never break out.
- The confirmation beacon only accepts a request that came from a page on your own site: it verifies both a WordPress REST nonce and the request origin. The nonce on its own would not be enough, because it is the same value for every logged-out visitor and so cannot tell your checkout page apart from a third-party one; the origin is the part a foreign page cannot fake. It is read only from the request's own headers, never from a parameter the caller could add to the URL.
- None of the plugin's REST routes can be read from another website. WordPress by default tells any site that asks that it may read a REST response using the visitor's own cookies; GTM4WP withdraws that permission for its own routes only – other plugins are untouched, and your own pages are unaffected. This covers every GTM4WP REST route, including the settings routes, whether or not the cache-safe data layer is switched on, and it holds however the route address is capitalised, because WordPress answers a REST route regardless of its letter case.
- Off by default (experimental); when off, the data layer is exactly as before.
- The values the browser can compute itself – the search term and the referring page (
Page variables
- Added: new Page variables options in a "Content & engagement data" group, useful for behavior tracking and GA4 content grouping:
- Content word count (
pageContentWordCount) and estimated reading time (pageReadingTime, adjustable with thegtm4wp_reading_time_wpmfilter). Both count words correctly in every language, including scripts PHP's own word counter does not recognise (Cyrillic, Greek, Hebrew, Arabic) and languages that do not separate words with spaces (Chinese, Japanese, Korean), where each character counts as one word. - Last modified date (
pageModifiedDateand thepageModifiedDate*family) and content age in days (pageContentAgeDays) - Comment count and status (
pageCommentCount,pageCommentStatus) - Page template (
pageTemplate), featured image presence (pageHasFeaturedImage), page hierarchy (pageParentID,pageDepth) and sticky flag (pagePostSticky) - Primary category (
pagePrimaryCategory,pagePrimaryCategoryName) detected from Yoast SEO / Rank Math with a first-category fallback, overridable with thegtm4wp_primary_category_term_idfilter - Page language (
pageLanguage) detected from WPML / Polylang with a site-locale fallback, overridable with thegtm4wp_page_languagefilter
- Content word count (
- Added: PublishPress Authors support for the Page variables author data. On sites that use PublishPress Authors, a post can have several authors (co-authors and guest authors) – important for E-E-A-T. When PublishPress Authors is active,
pagePostAuthor/pagePostAuthorIDare sourced from it – including for a post with a single guest author, which would otherwise report the WordPress user who created the post – and when such a post has more than one author, GTM4WP also outputspagePostAuthors(the list of author names) andpagePostAuthorIDs(the list of author IDs; guest authors use a negative id). The single-value variables stay for back-compat and point at the primary (first) author. The two arrays are filterable viagtm4wp_page_post_authorsandgtm4wp_page_post_author_ids. Uses the existing "Post author name" / "Post author ID" options as the on/off switches; when PublishPress Authors is not active, behavior is unchanged. - Added: a separate "Post custom fields (meta)" option (Page variables → Post data). Until now the "Post Terms" option did two very different things: it added the post's taxonomy values and published every custom field whose name does not start with an underscore – together with its value – into the data layer of the public page, even though the option only ever mentioned taxonomies. Since custom fields are where plugins and themes keep their own data (Advanced Custom Fields stores its values this way), that could put internal notes, ids, prices or contact details on a page any visitor can read, without the site owner ever being told. The two are now separate opt-ins with the custom-field one spelling out exactly what it publishes. Almost nothing changes for an existing site: if you had "Post Terms" enabled, the new option is turned on for you during the upgrade, so the same custom fields keep arriving under
pagePostTerms.meta. The only differences are the packed-value and shape fixes described further down. Turn the new option off if you did not intend to publish your custom fields. A "Post custom fields – publish only these keys" box under it lets you name the handful of fields your container actually needs, so a field added later by a plugin, a theme or an editor cannot start appearing on your public pages on its own; leave it empty to keep publishing everything as before. Individual keys can still be excluded in code with thegtm4wp_post_meta_in_datalayerfilter. - Added: an optional "Include parent categories in the category list" setting (Page variables → Post data). By default the
pageCategorydata layer variable lists only the categories directly assigned to the current post or archive. With this on, the parent (ancestor) categories of each category are also added – immediate parent first, up to the top-level category – and the list is de-duplicated. The option is greyed out in the settings screen while its parent option, "Category list of current post/archive", is turned off (any option can now declare such a dependency). Off by default, so the current output is unchanged until you enable it. Thanks to @twentyfortysix for the original patch (#220). - Changed: browser, OS and device data is now collected in the browser using User-Agent Client Hints and pushed as a
gtm4wp.deviceDataevent (replaces the bundled WhichBrowser library; Safari and Firefox expose less detail). - Fixed: custom fields holding a structured value (a list or a nested structure rather than plain text) reached the
pagePostTerms.metadata layer variable as an unreadable packed string such asa:8:{s:8:"metadata";...}. WordPress hands back the raw database column when all custom fields of a post are read at once, so anything a plugin stored that way – an SEO plugin's schema template, an Advanced Custom Fields repeater – arrived packed. No Google Tag Manager variable could read such a value, while the whole internal structure the plugin keeps behind that field was still published on the public page and added to the weight of every page view. Those values are now skipped; plain-text custom fields are unaffected. Two shape details follow from that: a custom field that stored several values of which only one is plain text now arrives as that single value rather than as a one-item list, so what your container receives no longer depends on what was dropped; and a post with no publishable custom fields at all leavespagePostTerms.metaout of the data layer entirely instead of sending an empty one. Check your Google Tag Manager setup if a variable or trigger expectspagePostTerms.metato always be present. Custom fields are now also checked with the WordPressis_protected_meta()function in addition to the leading-underscore rule, so a field that a plugin or your own site marks as protected is held back as well – previously only names starting with_were, which is why fields from some SEO plugins were published while the equivalents from others were not. The underscore rule still applies on its own, so a field whose name starts with_is never published even on a site whose code declares that field unprotected. - Fixed: the
postFormatdata layer variable now contains the actual post format (aside,gallery,video, …) of the current post. Since its introduction it sent an empty string for every post that had a format andstandardotherwise, so it could never tell formats apart; posts without a format still reportstandard. - Fixed: repeated PHP warnings (
Attempt to read property "post_author" on null) on sites where a theme or plugin leaves the global post object unset on a singular page. Every post-derived page variable (post type, category/tag lists, author data, dates, term list, word count and reading time, content age, comment data, page template, featured image flag, page hierarchy, sticky flag, primary category, post id and post format) is now simply omitted on such a request instead of being emitted with a placeholder value, and thepostCountOnPage/postCountTotalvariables are likewise omitted when the main query global is unavailable. The same now applies on an author archive:pagePostAuthor/pagePostAuthorIDare omitted when the author object is not set up, instead of being sent as an empty name and the author id0. As reported on the wordpress.org support forum against 1.22.3. - Fixed: JavaScript variables added through the
gtm4wp_add_global_vars_arrayfilter now keep their type and their text. Anullvalue was rendered asfalse, an empty array asfalseand the float0.0asfalse, so a tag reading such a variable saw the wrong value. Separately, a string value was escaped for an HTML attribute rather than for a script, so a",<or>in it reached your tags as the text",<or>– while the very same string inside an array arrived correctly. Both now use identical encoding, so a value is delivered the same way however it is supplied. A value the encoder cannot represent at all (text that is not valid UTF-8) now falls back tonullinstead of producing a broken declaration that made the whole head script block a syntax error – taking the data layer initialization down with it. The variable name was already checked this way; the value now is too. - Added: a "Visitor IP – Trusted proxy addresses" setting, next to the existing custom-header option. An HTTP header is sent by the visitor, so on its own it is a claim rather than a fact, and with no list of trusted upstreams the plugin has no way to tell your infrastructure's entry in that header apart from the visitor's. List the addresses or CIDR ranges of your reverse proxy, load balancer or CDN there and it can – an
X-Forwarded-Forlist is then read from the right, skipping your own hops, and a single-value header such asCF-Connecting-IPis only used when the request really did arrive through one of those addresses. Nothing changes for an existing site until you fill it in: the header is read exactly as before, and an admin notice points out that the value cannot be verified yet. If your site is not behind a proxy or CDN, leave the field empty and do not set a custom header at all. A range that covers every address (0.0.0.0/0or::/0) is rejected rather than stored, because it would declare the whole internet a trusted proxy – which is the situation the setting exists to end, and it would also silence the notice that warns you about it. Both this field and the custom-header field are greyed out while the "Visitor IP" variable itself is off. - Fixed: option descriptions on the settings screen (Page variables) that did not match what the option actually sends. Building a Google Tag Manager variable needs three things – the subject, the name of the data layer variable it lands in, and the form the values take – and several descriptions gave only the first. "Category list of current post/archive" said it sends the category names, while
pageCategoryhas always contained the category slugs (news-and-events, notNews and Events), so a trigger written against the description could never match; "Tags of current post" named neither its variable (pageAttributes, notpageTags) nor the slug form; "Post date" said it adds 4 data layer variables, while it has added 9 since 1.15. Now corrected in the same way: "Post Format" (postFormat, the format slug,standardwhen none), "Primary category" (two variables –pagePrimaryCategoryholds the slug andpagePrimaryCategoryNamethe display name), "Post author ID" and "Post author name" (pagePostAuthorID/pagePostAuthor, plus thepagePostAuthorIDs/pagePostAuthorslists that appear on a multi-author post), "Logged in user role" (visitorType, role slugs, comma separated for several roles, andvisitor-logged-outfor a visitor who is not logged in) and "Logged in status" (visitorLoginState, eitherlogged-inorlogged-out). All descriptions now match what is sent; no data layer value changed.
WooCommerce
Cart & Checkout blocks
- Added: WooCommerce Cart & Checkout block support. The React-based Cart & Checkout blocks (now the WooCommerce default) never fire the classic jQuery events, so
add_to_cart,remove_from_cart,add_shipping_infoandadd_payment_infowere previously lost on block-based stores. GTM4WP now exposes its GA4 item data on the WooCommerce Store API (extensions.gtm4wp.item) and reads thewc/store/cart,wc/store/checkoutandwc/store/paymentdata stores to fire those events. On block Cart/Checkout pages the block tracker loads and the classic tracker is skipped, so nothing is counted twice;view_cart,begin_checkoutandpurchasecontinue to fire server-side. Because the item price comes from the server as a real number, values stay correct for zero-decimal (e.g. JPY) currencies. - Added: block tracking now also covers the Mini-Cart, Product Collection and cart cross-sells blocks. Removing an item (or changing its quantity) in the Mini-Cart drawer now fires
remove_from_cart– on a block-based store the block tracker rides along on every page in a "mini-cart" mode that reports removals only, so the classic tracker keeps sole ownership ofadd_to_cartand nothing is counted twice. The Product Collection grid (the current WooCommerce default, which fires none of the classic product-loop hooks) now reportsview_item_listandselect_item, with a friendly list name for every collection WooCommerce ships (Product catalog, Sale, Best selling, Top rated, New, Featured, Related, Upsells, Cross-sells, Hand-picked, By category, By tag, By brand and Cart contents). The four that replace a legacy product grid block report the same list as that block did, so moving a page over to Product Collection does not start a new list in your reports. The Cart block's cross-sell products now reportview_item_listandselect_itemtoo, and like every other tracking path their GA4 items carry none of the plugin's internal bookkeeping keys.
Purchase tracking
- Added: new "Reliable purchase tracking" option (WooCommerce → Purchase tracking) for the most common cause of a missing
purchaseevent. When the customer lands on a heavily customized thank-you page, or on the order-pay page instead of the order received page, the purchase event is now emitted on the next page they view in the same browser session. The placed order is remembered server-side (onwoocommerce_payment_complete, the order-status change andwoocommerce_thankyou) and de-duplicated with the existing order-tracked flag, browser cookie and order-age guards, so it is never counted twice – and a page view that already carries the purchase event never arms the fallback in the first place, so the two cannot fire together on the same thank-you page even when the "Do not flag orders as being tracked" testing option has switched those guards off. Off by default (experimental). It cannot capture orders where the buyer pays via an asynchronous gateway and never returns to the site – that case needs server-side tracking. - Added: new "Order statuses that trigger the purchase event" setting. The purchase now fires at order placement for the configured statuses (default: Processing, On hold, Completed), so Cash on Delivery (Processing) and bank transfer (On hold) orders are tracked at checkout even though payment clears later. Filterable via the new
gtm4wp_purchase_trackable_statusesfilter. - Added: new "Custom order received (thank-you) page" option that fires the purchase event on a bespoke confirmation page – resolving the order from the current session – for themes or plugins that do not use the standard WooCommerce order received endpoint.
- Added: GA4 / Google Ads Enhanced Conversions user data is now included on the
purchaseevent – SHA-256 hashed email, phone and name plus the plaintext address components Google expects – built from the order so guest checkouts are covered too. The phone number is converted to E.164 against the order's billing country before hashing, as Google requires, and is left out entirely when it cannot be placed in E.164 with confidence rather than sent as a hash that could never match. Opt-in through the existing "Customer data in data layer" option. - Added: the
purchaseevent now also carriescustomer_type, thenew/returningstring that Google's GA4 e-commerce reference documents. The existingnew_customerboolean is unchanged and still sent – Google names the same idea differently on two surfaces (Google Ads customer acquisition readsnew_customer, GA4 readscustomer_type), so both are needed and neither replaces the other. Nothing to change in your GTM setup unless you want to use the new variable. - Added: new "Transaction ID prefix" option (WooCommerce → Purchase tracking). The text entered here is prepended to the
transaction_idsent with thepurchaseevent, for example to tell several stores apart in one GA4 property or to match the order id format of another system. Empty by default, so the plain WooCommerce order number is sent exactly as before. Only thepurchaseevent is affected: the order number in theorderDatavariable and the duplicate tracking guards of the plugin keep using the raw order number. - Changed: the purchase event is no longer sent for failed or still-pending orders by default (previously any order except a failed one that reached the thank-you page was tracked). Add the relevant status to the new "Order statuses that trigger the purchase event" setting if you need the old behavior.
- Changed: on the order received page, the customer details in the
orderDatavariable, thenew_customer/customer_typesignals and the Enhanced Conversionsuser_datablock on thepurchaseevent are now included only when WooCommerce itself would show that order to the visitor viewing the page. Thepurchaseevent, the order totals and the items are unchanged and still fire exactly as before, so nothing is lost for a buyer arriving from checkout. WooCommerce's ownwoocommerce_order_received_verify_known_shoppers,woocommerce_order_email_verification_grace_periodandwoocommerce_order_email_verification_requiredfilters are all honoured, so a store that has relaxed any of these checks keeps the matching behavior here – and a callback you already have receives the same arguments WooCommerce passes it, so it keeps working unchanged. The decision follows your WooCommerce version: where WooCommerce has these visitor checks, the plugin mirrors them (asking WooCommerce's own helper where one exists, deriving the same answer from the order age and your filters where it does not), and on older releases without any such check – below WooCommerce 7.9, which shows the order to anyone opening the confirmation link – the data layer keeps matching the page and nothing is withheld. A custom thank-you page is unaffected: it resolves the order from the buyer's own session, so its customer data is always complete. - Fixed: the "Do not flag orders as being tracked" WooCommerce option now also skips the browser-side duplicate guard. Previously it only disabled the server
_ga_trackedorder meta, while thegtm4wp_orderid_trackedcookie / localStorage entry was still written – so the same order could not be re-tested in the same browser without clearing storage. With the option on, no order-tracked state is written anywhere.
Item data & extensibility filters
- Added: every tracked product list item now carries an
item_list_idnext toitem_list_name, so GA4 list reports can key on a stable id. Each list reports its own id – including WooCommerce's product grid blocks (Handpicked Products, Newest Products, On Sale and the rest), where every grid on a page is a separate list rather than all of them sharing one. For the lists the plugin names itself the id is a fixed identifier that does not change with the site language, so a multilingual store reports one list instead of one per translation; a list named after a widget title still gets its id from that title. Third-party code can supply its own id via theGTM4WP_WPFILTER_EEC_PRODUCT_ARRAYfilter. - Added: new opt-in WooCommerce option "Persist product list attribution across the funnel" (WooCommerce → Product data). When a visitor clicks a product in a list – a classic product loop, a product grid or Product Collection block, or the Cart block's cross-sells – the plugin remembers which list it was (
item_list_name/item_list_id) in a first-party cookie and carries that attribution onto the laterview_item,add_to_cart,view_cart,begin_checkout,add_shipping_info,add_payment_infoandpurchaseevents, so GA4 can attribute the whole funnel back to the originating list. Off by default – enable it only if you are NOT already doing the same with custom JavaScript in Google Tag Manager, otherwise the attribution would be set twice. The product page keeps working behind a full-page cache: theview_itemevent is still rendered by the server, but the list name is filled in by the browser, so the cached HTML stays the same for every visitor. This covers the simple-product and variable-parentview_item, a variation selected on the product page, and WooCommerce Quick View. The cookie holds up to the 20 most recently seen products and evicts the least recently seen one when it is full – and it stays within the size a browser accepts for a single cookie, so a store whose list names are long simply remembers fewer products instead of losing the attribution altogether. - Added: GA4 e-commerce items now carry a per-item
discountwherever it can be computed – cart,view_cart,begin_checkoutandpurchaseitems report the per-unit discount whenever a coupon or sale reduced the line (on the same tax basis as the item price). Undiscounted lines carry nodiscountkey. Per-item coupon codes are still not emitted (WooCommerce does not map coupons to individual lines); attach them via thegtm4wp_eec_product_array/gtm4wp_eec_order_itemfilters if you need them. - Added: the
view_itemevent now sends an explicitquantityof 1 on its item, for GA4-spec completeness (both the simple-product server event and the variable-product client event). - Added: a new
gtm4wp_eec_item_affiliationfilter to set the GA4 item-levelaffiliation(the storefront/marketplace an item was sold through). It is empty by default – WooCommerce has no native value – so the item payload stays free of emptyaffiliationstrings unless you supply one. - Added: a new
gtm4wp_eec_item_with_sourcefilter for enriching a GA4 e-commerce item with custom data taken from the raw source object it was built from – the WooCommerce cart item (on the cart, mini-cart, checkout, re-add and Cart/Checkout-block paths) or theWC_Order_Item(on the purchase path), both of which can carry custom meta that never lives on theWC_Product/variation. Alongside the usual item array and placement context, the filter receives a new third argument: that source object (ornullwhere there is no per-line source, e.g. a product-detail page or a product list). Read the meta you need and copy only those fields onto the item – the source object itself is never merged into the item array, so your GA4 events are not bloated with data you did not ask for. Thanks to @migueldamota for the request (#324). - Changed: the
gtm4wp_eec_product_arrayfilter is now deprecated in favor of the newgtm4wp_eec_item_with_sourcefilter (which additionally receives the source cart/order item). It keeps working unchanged – it still receives the same two arguments and still runs before the new filter, so both filters can modify the item – and only raises a deprecation notice whenWP_DEBUGis enabled, once per request rather than once per product. Existing integrations need no change. - Changed: server-pushed e-commerce events now serialize the
eventkey before theecommerceobject, matching what the client-side events already emit. Key order is irrelevant to Google Tag Manager / GA4; this is a consistency-only change with no effect on tracking.
Tracking behavior & compatibility
- Added: new opt-in CheckoutWC compatibility option (WooCommerce → Advanced). CheckoutWC replaces the WooCommerce checkout with its own multi-step template, so the classic markers the tracker relies on are not reliably present and the
add_shipping_info/add_payment_infoevents were missed. With this on, the tracker also binds those steps to CheckoutWC's owncfw_step_changedevent (the step is deduplicated with the classic checkout, so nothing is counted twice). Off by default (experimental) – enable it only on stores that use CheckoutWC. - Added: the WooCommerce add-to-cart tracking logic is now exposed as reusable JavaScript functions –
window.gtm4wp_track_single_add_to_cart( button, form )(product-detail page: variable, grouped and simple products) andwindow.gtm4wp_track_list_add_to_cart( button )(product lists and the[add_to_cart]shortcode). A theme that handles Add to Cart with its own AJAX – and callse.preventDefault()on the button, which suppresses the built-in click tracking – can now fire the event from its success handler in a couple of lines instead of copying the tracker. - Added: the WooCommerce e-commerce tracker logs every event it pushes to the browser console, honoring the site-wide "Do not use console.log() messages on the frontend" option (nothing is logged when that option is turned on).
- Changed: clarified the "Set maximum timeout for select_item event" option (WooCommerce → Advanced) so it explains that a value of 0 opens product links immediately without waiting for GTM – the select_item event is still sent, but the click is no longer delayed. Useful when product-list links feel slow to open (e.g. a consent tool blocks GTM so the callback never returns).
Fixes
- Fixed: the browser-side duplicate-purchase guard now works on stores whose order numbers are not the plain order id (sequential or prefixed order numbers). The guard cookie holds the order number, but the server read it as an integer and compared it to the order id, so on those stores that leg of the de-duplication never matched. The same order number is now used consistently everywhere the guard is written or read.
- Fixed: cart and checkout pages no longer risk a PHP memory exhaustion under some WooCommerce versions.
wc_get_price_to_display()was called once per cart item (and again for each item's remove-link data), which became very expensive on certain WooCommerce releases. Cart, mini-cart and checkout item prices are now taken from the line totals WooCommerce has already calculated, and the display-price call is skipped whenever a price is already supplied (the purchase path passed one that was then discarded). - Fixed: when "Exclude tax from revenue" is enabled, per-item prices on the
purchaseevent are now also reported excluding tax. Previously the transactionvaluewas tax-exclusive while the item prices followed the shop's display setting (often tax-inclusive), so GA4 item-level revenue (product performance) did not reconcile with the transaction total (sales performance). - Fixed: variable subscription products now keep their variant data in ecommerce tracking. Variations were detected by an exact
variationproduct-type match, but WooCommerce Subscriptions variations reportsubscription_variation, so theiritem_variant,item_group_idand parent-deriveditem_category/item_brandwere dropped (most visibly on thepurchaseevent). Variations are now detected structurally (anyWC_Product_Variation), covering subscriptions and similar extensions. - Fixed: the dynamic-remarketing "Product ID prefix" is now kept on variations. When a variation was selected on a variable product page, the browser swapped in the variation id and dropped the configured prefix from the
idfield used for Google/Meta catalog matching; the prefix is now re-applied to the variation'sid(the unprefixeditem_idis unchanged). - Fixed: adding a product to the cart no longer forces a full page reload on stores using WooCommerce's blockified Add to Cart + Options block (block themes, WooCommerce 10.9+). With e-commerce tracking enabled, the plugin printed a hidden input field into the add-to-cart form, and WooCommerce deliberately renders a classic full-page POST form instead of the interactive one when a plugin prints form fields into it – so the interactive add to cart was disabled on every product page while GTM4WP was active. The product data now travels in a hidden span's data attribute (the same pattern product lists already use), which WooCommerce's check ignores. Tracking itself is unchanged: classic (non-block) product templates keep working exactly as before, and the tracker still reads the old hidden input as a fallback so cached pages keep tracking through the upgrade. As reported on GitHub (#462).
- Fixed: a product data payload that cannot be written as JSON no longer breaks the product page's tracking scripts. When a plugin hooked into GTM4WP's product data filters puts a value into the product array that PHP cannot convert to JSON (or replaces the array with nothing at all), the product detail page's markup carries an empty or useless payload – and the browser-side tracker then either raised a script error in the add-to-cart click and variation-selection handlers or pushed a meaningless
add_to_cartevent with no product and no value. Both paths now skip the tracking event for that product – and only that – exactly like the product list and cart trackers already did with the same broken payload. - Fixed: the
[add_to_cart]shortcode button now fires anadd_to_cartevent. A standalone shortcode button is rendered outside a product loop, so it never received the hidden product-data markup that product-list items get; the GA4 item data is now attached to the button itself so a click can be tracked. Product lists are unaffected — they already carry the data. - Fixed: the product-page
add_to_cartevent is no longer fired when the browser blocks the add-to-cart form submit because a required field is empty (e.g. a required Product Add-ons field). The click now respects the form's HTML5 validity, so a rejected add no longer produces a falseadd_to_cart. - Fixed:
add_to_cartandremove_from_cartnow always reportquantityas a number, and report it the same way on every surface. Three symptoms of one cause: a product form with no quantity field at all – some themes and product add-on plugins render none – emittedquantity: nullandvalue: 0; the cart page reported a string where the mini-cart reported a number for the very same product; and a cart line set to zero fired a removal event on the cart page while the mini-cart correctly suppressed it. Every quantity now goes through one parser, so the type and the zero handling are identical everywhere. Check your GTM setup if a trigger or variable comparesquantityagainst a string. - Fixed: the WooCommerce Quick View popup now pushes its product data into the data layer variable you configured. It was the one place in the plugin still using the default
dataLayername directly, so on a site that renamed the data layer the Quick View event was silently dropped – or pushed into whatever other tool owns that name on the page. - Fixed: the "Set maximum timeout for select_item event" option (WooCommerce → Advanced) now actually takes effect. The value reached the page correctly, but the tracker looked it up under the wrong name and never found it, so every product-list click used the built-in 2000 ms wait for Google Tag Manager to call back, whatever you had configured. Most visibly, setting it to 0 to stop product links feeling slow to open – the documented remedy when a consent tool blocks GTM so the callback never returns – changed nothing at all. Stores that left the setting at its default of 2000 behave exactly as before.
- Fixed: the WooCommerce and form-interaction trackers no longer fire their events twice when their script is loaded a second time on the same page – by an AJAX navigation, or by a page builder that duplicates the script handle. Both re-registered their event listeners, and the WooCommerce tracker additionally reset its own "already reported" memory, which let
add_payment_infoandadd_shipping_infofire again on checkout even once the listeners were under control. The media and Contact Form 7 trackers already had this protection; every tracker now has it. - Fixed: Gmail addresses carrying a
+tag now hash to the address Google actually matches. Google folds[email protected]down to[email protected]before hashing, but the plugin removed only the dots, so every customer who used a tagged gmail.com or googlemail.com address produced a hash that could never match – inorderData.customer.billing.email_hash, incustomerBillingEmailHashand in the enhanced-conversions user data. The+and everything after it is now dropped, for those two domains only: anywhere else a tagged address is a real, separate mailbox and is left exactly as the customer typed it. The domain test is stricter too, so an address at a domain that merely begins withgmail.comis no longer folded. Check your GTM setup if it reads any of the email hash fields: the value changes for tagged Google addresses. - Fixed: the hashed phone number in
orderData.customer.billing.phone_hashis now converted to E.164 (+36201234567) before hashing, which is the format Google matches on. Previously the number was only lowercased and stripped of spaces, so anything a customer typed the way they dial it –06 20 123 4567,(201) 555-0123– produced a hash Google could never match, and the enhanced conversion was simply left unattributed with no error shown anywhere. The dialling rules of the order's billing country come from a table generated from Google's own numbering plan data covering all 245 territories, so a leading zero is removed only where that country really dials one: Italian landlines keep theirs, as do six other territories where the zero is part of the number rather than a dialling prefix. The table also carries what a number of each country looks like, which is what lets the plugin tell apart the two ways the same digits can be meant:34 612 345 678on a Spanish order is an international number with the+left off, while391 234 5678on an Italian one is an ordinary Italian mobile that happens to start with Italy's own calling code. Getting that wrong is silent, so it is worth saying plainly which way it used to fall – in Spain, Portugal, Greece, Denmark, Czechia and every other country that dials no trunk prefix, a number typed with the country code but no+had that code added a second time; and in Kazakhstan, whose mobile numbers start with its own calling code, the bare form lost a digit. Numbers written with the trunk digit in brackets (+49 (0) 30 12345678, the usual form in the German speaking countries and the Netherlands) and numbers carrying an extension (0121 234 5678 x22) are handled as well, and numbers from Jersey, Guernsey and the Isle of Man are no longer discarded outright. When a number still cannot be placed in E.164 with confidence – no country on the order, or too few digits to be a real number – the key is sent empty rather than carrying an unmatchable hash. Check your GTM setup if it readsphone_hash: the value changes for every customer who did not type their number in international format.
Media events
- Added: media player tracking for eight more players, each as its own opt-in option under Media events → Media players (experimental): Dailymotion, Mixcloud, Cloudflare Stream, Wistia, JW Player, VideoPress, Spotify and Twitch. Each fires the same
gtm4wp.media*events (ready, state change, playback percentage, player event) as the existing players and also populates Google Tag Manager's built-in Video variables. Notes: Dailymotion tracking rebuilds each embed with Dailymotion's current player, because Dailymotion no longer lets a script listen to a video it did not create itself. The video keeps the size WordPress gave it, and its events report the real video title and channel name; if the player cannot be built for any reason, whether the library fails to load or the player itself refuses to start, the original embed is put back exactly as it was and simply goes untracked, so a tracking problem never costs you the video. An optional "Dailymotion player ID" setting picks which player configuration is loaded, taken from the Players tab of a Dailymotion Studio account, and removes Dailymotion's "initialized without a player id" console warning. Leave it empty to use Dailymotion's default player, which needs no account. A periodic playback update is the only signal the Spotify embed exposes, so play/pause/finished states are derived from it, and because the player reports no title either, the track title is read from the embed on the page – when the embed carries none, it is looked up once from Spotify's public oEmbed endpoint. The Cloudflare Stream player reports no title either, so its events take the title from the embed when it carries one, and otherwise report the video's Cloudflare UID. VideoPress reports the playback position and the video duration on separate browser messages, and sends neither one alongside a play, pause or finished signal, so the tracker keeps the last value it was given for each video and fills in every event from those; its player-ready event consequently fires when the duration first arrives, once per video, and a fullscreen change is reported as a player event. Twitch reports current time and duration for videos (VODs) only, not live streams. - Added: tracking for native HTML5
<video>and<audio>players, as its own opt-in option under Media events → Media players (experimental). Fires the samegtm4wp.media*events as the other players – ready (carrying the real media duration), state changes, and playback percentage – and also tracks buffering plus Picture-in-Picture and fullscreen changes on video. Each event also populates Google Tag Manager's built-in Video variables. A local media file carries no title of its own, so the events identify it by its file name, taken without any query string the URL carries: a?ver=cache buster or a signed CDN token would otherwise make the same file a different video in your reports every time it changes. The full URL, parameters included, is reported separately. - Added: an optional "Track dynamically inserted players" setting (Media events → Advanced). The media trackers wire up players that are on the page when it loads; with this on they also track players inserted after load – opened in a popup/lightbox or loaded via AJAX – which previously went untracked. It works by watching the page for new embeds with a single shared
MutationObserver(one for all providers, scanning only the nodes each change adds, not the whole document), then running the exact same wiring a player gets at page load; a player whose SDK replaces the embed element with its own iframe (e.g. Spotify) is recognised as already wired, so each player is wired exactly once. Off by default (experimental): the observer has a small per-DOM-change cost, so enable it only if your site injects media players at runtime. The Wistia tracker already handled runtime insertion natively and is unchanged. Thanks for the request (#3). - Added: every media event now fills all eight of Google Tag Manager's built-in Video variables, the Video Visible one included. This covers all four
gtm4wp.media*events – not only the state change and playback percentage, which are the two with a Google Tag Manager counterpart, but the player-ready and player-event pushes as well. Those two report an empty Video Status, because Google Tag Manager has no status for them; every other Video variable is filled as usual, so a Custom Event trigger on any media event can read the video's title, URL, duration, position and percentage. (The one exception isgtm4wp.mediaApiReady, which fires when the player's script finishes loading, before any player exists to describe.) Video Visible reports whether the player was actually on screen at the moment of the event: a player scrolled out of view reportsfalse, and so does one playing in a background tab or a minimised window, while a player even partly on screen reportstrue. Google publishes no minimum visible percentage for this variable, so no threshold is applied. Two cases cannot be detected by any browser API and follow the page instead: a window fully covered by another window, and a video popped out into Picture-in-Picture. It is measured for every tracked player, including the ones whose embed the player's own script replaces (Spotify) and the one heard from only through browser messages (VideoPress); when a player cannot be located on the page the variable is left unset rather than guessed, so a Custom Event trigger comparing it againsttruebehaves the same as it would with GTM's own YouTube trigger. - Changed: Vimeo media tracking is promoted from experimental to stable
- Updated: Vimeo tracker modernized against the Player SDK – tracks playback-rate, quality, fullscreen and Picture-in-Picture changes, maps buffering to Google Tag Manager's built-in video status, fires the start event on real playback (the
playingevent), and initializes reliably when loaded after the page is parsed (defer/async or late injection) - Changed: SoundCloud media tracking is promoted from experimental to stable
- Updated: SoundCloud tracker hardened – bails out gracefully when the SoundCloud Widget API is blocked (consent manager, ad blocker, network error), still initializes when the script loads after the page is parsed (defer/async or late injection), and now reports the correct track metadata for playlist / multi-track widgets
- Deprecated: the "YouTube video events" option – Google Tag Manager now ships a native YouTube Video trigger, so YouTube tracking should be migrated to it; the plugin continues to populate GTM's built-in Video variables for the other players.
- Changed: a player's third-party script is now requested only on pages that actually contain one of its embeds. Enabling a player used to load its script on every page of the site, whether or not any page used that player – with every player enabled that was 288 KB fetched from seven different providers on every single page view, and it also handed those providers each visitor's IP address and browser details on pages with no player at all. Each tracker now fetches its own script, and only after it finds a matching embed on the finished page. That check also covers players the page's stored content cannot be searched for: ones placed in a widget, a block template, a page builder or a shortcode's output, and ones opened later in a popup. Tracking behaviour is otherwise unchanged. If you use a consent plugin, this arrangement works in your favour and needs no setup at all. A tracker asks its provider for the player library only after it finds that provider's embed on the page, and it recognises the embed by the provider's own domain. A consent plugin that holds the embed back until the visitor agrees therefore leaves the tracker nothing to find, so the provider is never contacted; when the visitor agrees and the embed appears, tracking picks it up. No rule naming GTM4WP is needed for any of that. What did change is that the page no longer contains a script tag naming the provider's domain, so a rule written against that domain alone has nothing to match. Three levers cover the cases where you would rather decide it yourself. Blocking or dequeuing one player's own script handle (
gtm4wp-vimeo,gtm4wp-twitchand so on) stops that provider's request, because it stops the tracker that would make it. GTM4WP also ships a script whose only job is to be blocked:gtm4wp-media-gate.js, a real enqueued script that gives a consent plugin a single ordinary script tag to recognise. Block that tag and no media provider is contacted at all, while the trackers still run and still report players already on the page. That one has to be blocked rather than dequeued, because the trackers depend on it and WordPress prints a dependency whether or not it was dequeued. It is only added for the players that actually fetch something: HTML5 media, Wistia, JW Player and VideoPress each use a player already present on the page, so a site running only those never receives the extra script. The same decision is available server-side through the newgtm4wp_media_sdk_blockedfilter. The levers also combine safely: blocking one player's own tag never weakens the wider ones, so a site using the filter or the gate keeps its all-providers decision intact even when a consent rule additionally refuses a single player. - Fixed: the YouTube tracker now loads for every YouTube embed on the page. It previously loaded only when the post's own stored content held the legacy embed block or a hand-written
<iframe>, so a video placed in a widget, a block template, a page builder, a reusable block or a shortcode's output was never tracked, and on an archive page only the first post was examined. The tracker now decides from the finished page instead. - Fixed: the YouTube embed URL could receive a malformed query string (
?&enablejsapi=1) when the embed carried no existing query parameters. It could also receive a brokenorigin=value – together with a PHP warning on every YouTube embed on the page – on a site whose WordPress Address setting is not a full URL the plugin can read a scheme and a host out of (a strayexample.cominstead ofhttps://example.com, which a botched migration can leave behind). Whenever a usable origin cannot be built from the site address, for that reason or any other, the embed is now left exactly as WordPress produced it, so the video still plays; fix the WordPress Address setting to get the playback events back. - Fixed: a media embed whose URL carries a
#fragment (a#t=30start-time anchor, for example) is now tracked under the right video. The fragment stayed attached to the video ID the tracker reads out of the embed URL, so every YouTube and Vimeo event reported an ID and a URL that matched no video. On YouTube it could also stop the tracking completely, because the player API parameters were then appended after the fragment, where YouTube does not read them. - Fixed: media players that report a zero or unknown duration – a live stream, or an HTML5 element that has not loaded its metadata yet – no longer emit every playback-percentage milestone at once.
Consent mode & consent tools
- Added: native Axeptio consent management platform (CMP) integration, as an "Axeptio" tab in the "Consent mode & consent tools" settings section (alongside Cookiebot and WebToffee, which now each have their own tab). GTM4WP loads the Axeptio SDK directly (no separate Axeptio plugin required); the cookies version is picked from a list fetched live from your Axeptio project, falling back to manual entry when that list cannot be loaded. When its "Google Consent Mode v2" option is enabled, Axeptio fires both the consent
defaultandupdatecommands and GTM4WP suppresses its own consent default so it is never sent twice, and every consent change is pushed to the data layer as agtm4wp.axeptioConsentUpdateevent. Newgtm4wp_axeptio_consent_mode_defaultfilter to adjust the default consent state. - Added: new opt-in CookieYes consent bridge (Consent mode & consent tools → CookieYes). When enabled, GTM4WP listens for CookieYes' documented consent banner action API events (
cookieyes_consent_updateandcookieyes_banner_load) and pushes acookie_consent_updatedata layer event carrying the accepted/rejected categories, giving your container a defined consent signal to sequence tags on. It does not defer or buffer any events – the correct fix for the ordering of e-commerce events versus consent is Google Consent Mode v2, which gates tags regardless of data layer push order. Off by default (experimental). - Deprecated: the "WebToffee GDPR Cookie Consent (v2.x)" integration – it only targets the long-outdated v2.x product line. WebToffee v3.x and above integrate with Google Tag Manager natively, so the option is unnecessary there; upgrade the WebToffee plugin instead. The option is now flagged as deprecated in the settings screen.
Contact Form 7
- Updated: Contact Form 7 integration modernized against the current CF7 DOM events. Three new events are tracked –
gtm4wp.contactForm7BeforeSubmit(before validation),gtm4wp.contactForm7Unaccepted(acceptance/terms checkbox not ticked) andgtm4wp.contactForm7Aborted(submission aborted) – and every CF7 data layer push now also carries the form'sunittag,containerpostid,localeandstatus(the existingformidandinputsfields are unchanged). File inputs are reported by their file name instead of a raw File object. The tracker also guards against registering its data layer events twice when the script runs more than once (e.g. re-injected by a page builder or after an AJAX navigation). - Updated: every Contact Form 7 data layer push now also carries the human-readable form name (
formname), sourced from adata-gtm4wp-form-nameattribute added to the rendered form (the CF7 DOM events only expose the numeric form ID). - Added: "Submitted field values in the data layer" option for the Contact Form 7 integration (
integrate-wpcf7-inputs). Defaults to "Full" (the existing behavior); can be set to "Field names only" or "None" to keep submitted personal data out of the data layer. - Added: "Also push GA4 recommended events" option for the Contact Form 7 integration (
integrate-wpcf7-ga4events, off by default). When enabled, the tracker also pushes the Google Analytics 4 recommended form events –form_start(on first field interaction),form_submit(on submit) andgenerate_lead(on a successful send) – each withform_id,form_nameandform_destination, alongside the existinggtm4wp.contactForm7*events. Following the GA4 Enhanced Measurement definition,form_submitfires on every submission attempt, including one Contact Form 7 rejects, soform_submitandgenerate_leadalso carry aform_statusparameter holding the CF7 status (mail_sent,validation_failed,spamand so on) – use it in your container to separate accepted submissions from rejected ones. Both are pushed from the CF7wpcf7submitevent, soform_submitalways precedes thegenerate_leadit produced.
User events
- Added: "Only report fields the visitor filled in" option for the form fill events (
event-form-move-filled-only, off by default, beta). With it enabled,gtm4wp.formElementLeaveis pushed only for a field that was empty when the visitor entered it and holds a value when they leave it, so tabbing through a form without typing no longer produces a leave event for every field along the way.gtm4wp.formElementEnteris never filtered and keeps firing for every field, so "fields touched" stays measurable next to "fields filled in". Text inputs, textareas and dropdowns are checked this way, and a whitespace-only value counts as empty; checkboxes, radio buttons, buttons,meterandprogressare always reported, because they carry no empty state. Two consequences worth knowing before you switch it on: a field that was already filled in when the visitor entered it (one your theme pre-fills, or a dropdown whose options all carry a value) reports no leave event when they leave it unchanged, and neither does a field the visitor clears – the option reports the moment a field gets filled in, not the value it happens to hold. - Added: both form fill events now also fill Google Tag Manager's built-in Form variables, so a Custom Event trigger on
gtm4wp.formElementEnterorgtm4wp.formElementLeavecan read the form with Form ID, Form Classes, Form URL and Form Target instead of only through Data Layer Variables. The existingformID,formNameandformClasskeys are unchanged and keep working. Two things worth knowing: Google publishes no built-in Form Name variable, so the form's name attribute stays available on theformNamekey and is read with a Data Layer Variable as before; and the keys behind these built-ins are the same ones the Clicks category reads, so on these two events Click ID and Click Classes resolve to the form as well. The built-in values follow what Google's own form submission event carries rather than the plugin's readable placeholders: an absent attribute arrives as an empty string, Form URL is the form's action resolved to an absolute URL (the current page when the form has no action), and a field that belongs to no form at all sends none of these keys rather than empty ones.
AMP
- Fixed: AMP integration now works in the AMP plugin's Standard, Transitional and Reader (theme) modes – previously the GTM amp-analytics tag was only emitted in the AMP plugin's deprecated Legacy Reader mode, so enabling the AMP Container ID produced no tracking on modern AMP setups. Migrated to the AMP plugin's
amp_analytics_entriesAPI (which also auto-loads the correctamp-analyticscomponent script) and to the currentamp_is_request()function (replacing the deprecatedis_amp_endpoint()). - Fixed: AMP data layer injection – 1.x checked a never-populated global and never injected the data layer into AMP pages.
- Fixed: the standard GTM container
<script>is no longer emitted on AMP pages (it was invalid AMP and stripped by the AMP sanitizer anyway); the data layer is still compiled so the amp-analytics integration keeps its values. - Changed: the AMP container ID setting is promoted from experimental to stable.
Removed
- Removed: weather and geo data features (ipstack.com / OpenWeatherMap integrations).
- Removed: scroll tracking feature – use Google Tag Manager's built-in Scroll Depth trigger instead. The
GTM4WP_OPTION_SCROLLER_*constants remain in place for backward compatibility.
Read more: GTM4WP 2.0.0 is out

