GTM4WP – A Google Tag Manager (GTM) plugin for WordPress
GTM4WP – A Google Tag Manager (GTM) plugin for WordPress
Description
Google Tag Manager (GTM) is Google’s free tool for everyone to manage and deploy analytics and marketing tags as well as other code snippets
using an intuitive web UI. To learn more about this tool, visit the official website.
This plugin places the GTM container code snippets onto your WordPress website so that you do not need to add them manually.
Multiple containers are also supported, and each container can have its own environment parameters, custom domain and custom path.
The plugin complements your GTM setup by pushing page meta data and user information into the so-called data layer.
Google’s official help pages include more details about the data layer.
PHP 8.0 and WordPress 6.3 are required to use this plugin.
GTM container code placement
The original GTM container code is divided into two parts:
The first part is a javascript code snippet that is added to the <head> section of every page of the website.
This part is critical to enable all features of GTM, and this plugin helps to place this part
correctly on your site.
The second part is an iframe snippet that acts as a failsafe/fallback should users’ JavaScript be disabled.
Google recommends – for best performance – to place this code snippet directly after the opening <body> tag on each page.
Albeit not ideal, it will work when placed lower in the code. This plugin provides a code placement option for the second code snippet.
If your WordPress theme is compatible with the additions of WordPress 5.2, then this plugin will place this second snippet in the right place.
Users of the Genesis theme, GeneratePress theme, Elementor, Oxygen Builder and Beaver Builder Theme will also have it placed correctly.
To utilize this, set the compatibility mode in the plugin options to off.
All other users can place this second code snippet using custom PHP code (the “Manually coded” option), or select the so-called “Footer” option
to add the code lower on the page (this is not the recommended way, but it will work).
Basic data included
- post/page titles
- post/page dates
- post/page category slugs
- post/page tag slugs
- post/page author ID and name
- post/page ID
- post types
- post format
- post count on the current page + in the current category/tag/taxonomy
- custom terms associated with any post type
- logged in status
- logged in user role
- logged in user ID (to track cross device behavior in Google Analytics)
- logged in user email address (both unhashed and SHA256 hashed values to be used with tracking)
- logged in user creation date
- site search data
- site name and id (for WordPress multisite instances)
- IP address of the visitor (please obtain the visitor’s explicit consent before using this)
- post author data from PublishPress Authors, including co-authors and guest authors
Content & engagement data
Optional page variables that help with behavior tracking and Google Analytics 4 content grouping:
- content word count and estimated reading time
- last modified date and content age in days
- comment count and comment status
- page template, featured image presence, page hierarchy and sticky flag
- primary category, detected from Yoast SEO or Rank Math
- page language, detected from WPML or Polylang
Browser / OS / Device data
- browser data (name, version, engine)
- OS data (name, version)
- device data (type, manufacturer, model)
Data is collected in the browser using User-Agent Client Hints and pushed as a gtm4wp.deviceData event.
Note that Safari and Firefox expose less detail than Chromium based browsers.
Media player events
Track users’ interactions with any embedded media:
- YouTube
- Vimeo
- Soundcloud
- HTML5 audio and video
- Dailymotion
- Mixcloud
- Cloudflare Stream
- Wistia
- JW Player
- VideoPress
- Spotify
- Twitch
You can choose to fire data layer events when the media player loads, when the media is played, when it is paused or stopped,
and optionally when the user reaches 10, 20, 30, …, 90, 100% of the media duration. Each event also populates Google Tag
Manager’s built-in Video variables (Video Status, Video URL, Video Title, Video Provider, Video Duration, Video Current Time,
Video Percent, Video Visible).
Tracking is supported for embedded media using the built-in oEmbed feature of WordPress, as well as most other media plugins
and copy/pasted embed codes. Players inserted into the page after it has loaded (for example in a popup, a lightbox or via
AJAX) can also be tracked by turning on the optional “Track dynamically inserted players” setting.
Tag restrictions: allowlist & blocklist Tag Manager tags, triggers and variables
To increase website security, you have the option to allowlist or blocklist tags, triggers and variables.
You can prevent specific tags from firing, or prevent the use of certain variable types, regardless of your GTM setup.
If the Google account associated with your GTM account is compromised, an attacker could easily
execute malware on your website without accessing its code on your hosting server. By blocklisting custom HTML tags,
custom JavaScript variables and sandboxed scripts (custom tag and variable templates), you can secure the Tag Manager container.
Integration
Google Tag Manager for WordPress integrates with several popular plugins. More integrations to come!
- Contact Form 7: fire an event when a form is submitted with any result (mail sent, mail failed, spam detected, invalid input, submission aborted or terms not accepted). Optionally push the Google Analytics 4 recommended form events (form_start, form_submit, generate_lead) as well
- WooCommerce:
- Implementation of GA4 E-commerce
- Support for the Cart, Checkout, Mini-Cart, Product Collection and cross-sell blocks, not only the classic shortcode based pages
- Enhanced Conversions user data on the purchase event for Google Ads
- Compatibility with High Performance Order Storage (HPOS)
- Does not support promotions, since WooCommerce does not have such a feature (yet)
- Does not support refunds
- CheckoutWC: optional support for its multi step checkout template
- PublishPress Authors: co-author and guest author data in the page variables
- AMP: load your AMP container on the AMP version of your pages
- Google Consent Mode v2: fire the “default” command with specific consent flags to integrate with non-certified Consent Management Platforms (CMPs) and plugins
- Cookiebot: use automatic cookie blocking mode if needed
- Axeptio: load the Axeptio SDK and push every consent change into the data layer
- CookieYes: push a data layer event whenever the visitor’s consent changes
Server side containers
If you are using a server side container,
you can enter your custom domain name and custom path to load gtm.js from there. Both can be set per container, so you can mix
server side and standard containers on the same site.
Cache-safe data layer
(experimental, off by default)
On sites using a full page cache (LiteSpeed, WP Rocket, Varnish, Cloudflare APO), the HTML generated for one visitor is served
to everyone else as well. Any visitor specific value written into the data layer would therefore leak to other visitors – the
classic example being a page cached while an editor was logged in, then served to anonymous visitors with that editor’s email
address and role still in the data layer.
When this option is enabled, no visitor or session data is written into cacheable HTML at all. Those values are delivered in the
browser instead, under the same data layer variable names, so your existing Google Tag Manager setup keeps working.
Export & import your settings
You can export all plugin settings into a JSON file and import them on another site, which makes it easy to roll out the same
configuration across several websites. Imported files are treated as untrusted and every value is validated before it is stored.
Exclude specific user roles from being tracked
You can set which user roles need to be excluded from tracking when a user with that role visits the frontend. This will completely disable the container code for that user.
Staging and development sites
The container can be limited to production environments only, so a cloned or staging copy of your site does not send data into
your production Google Tag Manager container. This relies on the WP_ENVIRONMENT_TYPE setting of WordPress.
For developers
Version 2.0 is a complete object oriented rewrite. Every feature is a module, and third party plugins can register their own
modules through the gtm4wp_register_modules action. All public template functions, filter and action names, wp-config constants
and the option storage key of the 1.x versions are unchanged, so existing integrations keep working.
Installation
- Upload
duracelltomi-google-tag-manager-for-wordpressto the/wp-content/plugins/directory - Activate the plugin through the ‘Plugins’ menu in WordPress
- Go to Settings / Google Tag Manager and enter your Google Tag Manager container ID and set additional options
Faq
Tutorials for various Google Tag Manager settings and implementation are available on the plugin’s website:
https://gtm4wp.com/setup-gtm4wp-features
PayPal and some other 3rd party payment gateways do not redirect users back to your website after a successful transaction by default.
They offer a route back for your customer, but it can happen that users close the browser before arriving at your thank you page
(also known as the order received page). This means that neither Google Analytics tags nor any other tags have a chance to fire.
Enable auto-return in your payment gateway settings. This will instruct the gateway to show a quick info page after payment
and redirect the user back to your site. This will improve the accuracy and frequency of tracked transactions.
This can happen if you are using a 3rd party plugin that alters the default order received page in a way that does not use
WooCommerce’s integration hooks. Either stop using that plugin, or ask its authors to mimic the behavior of the default order
received page more closely by supporting the woocommerce is_order_received_page function and the woocommerce_thankyou action.
Since version 2.0 the plugin also offers two settings that work around this without changing the 3rd party plugin: “Custom order
received (thank-you) page” fires the purchase event on a bespoke confirmation page, and “Reliable purchase tracking” emits a
missed purchase event on the next page the customer views in the same browser session. Both are de-duplicated, so an order is
never counted twice.
Although Google recommends blocklisting tags and variables using classes, people struggle to know
which tags and variables get affected. Therefore I opted for individual tags and variables rather than classes
on the tag restriction tabs.
Regarding variables: ensure they are not part of any critical tags, as blocklisting such variables will render those tags useless.
Google Tag Manager supports basic scroll depth tracking based on percentage or pixels natively. Add a Scroll Depth
trigger in your container and use it to fire your Google Analytics 4 and/or Google Ads remarketing/conversion tags.
(The plugin’s own scroll tracking feature was removed in 2.0 in favor of this built-in GTM functionality.)
Yes, this is a real risk with any full page cache, and it is not specific to this plugin: the HTML generated for one visitor is
stored and served to everyone else. If visitor specific values (email address, user role, IP address) are written into the data
layer, the cached copy will contain the values of whoever happened to trigger the caching.
Turn on the “Cache-safe data layer” option to avoid this. With it enabled, no visitor or session data is written into cacheable
HTML; those values are delivered in the browser instead, under the same data layer variable names.
Each tracking feature (WooCommerce, each media player, Contact Form 7, device data, etc.) is its own small
JavaScript file, and the plugin loads only the files a page actually needs – the YouTube tracker, for instance,
loads only on pages that embed a YouTube video, and with the “defer” strategy so it never blocks rendering.
Those files are already minified by the plugin’s build.
Merging several files into one is intentionally left to a caching / performance plugin (WP Rocket, Autoptimize,
LiteSpeed Cache, etc.), which can combine scripts across your whole site and in a way that suits your hosting and
HTTP setup. On modern HTTP/2 hosting, many small conditionally-loaded files usually perform as well as one combined
file. (1.x combined its own scripts; 2.0 delegates this.)
Reviews
A GTM power tool!
By Eurisko (vhmc41) on October 29, 2025
We've been using GTM4WP for all of our clients websites and it's been super reliable. The eCommerce tracking integration has been a huge time saver for us.
Huge thank you for this plugin Thomas Geiger!
Critical Warning: Failed on All Core Function
By jaurioldm17 on August 13, 2025
I am writing this review to save other developers and site owners the immense amount of time I have just lost. I needed to implement standard GA4 e-commerce tracking on a professional WordPress/WooCommerce site. Based on its popularity and countless recommendations, I chose GTM4WP as the solution to generate the required dataLayer.
Unfortunately, after days of intensive, professional-level debugging, I discovered that the plugin was completely non-functional in my specific—yet very common—environment.
The plugin failed at its two most essential tasks:
- GTM Snippet Injection: The plugin's most basic feature—injecting the GTM container script into the website's <head> and <body>—did not work at all. The script was simply not present. I had to bypass this functionality entirely and inject the code manually using another method (Code Snippets) just to get our GTM container to load.
- E-commerce dataLayer Generation (The Deal-Breaker): This is the primary reason anyone uses this plugin. After successfully loading our GTM container manually, I ran a full test purchase. By analyzing the GTM debug mode and the site's dataLayer object, I can confirm that GTM4WP completely failed to generate the purchase event and its corresponding ecommerce object on the WooCommerce order confirmation page. It produced absolutely nothing.
My Technical Environment (Please read this before you install):
To help others, here is the stack where the plugin failed. This is likely the source of the conflict:
- CMS: WordPress
- E-commerce: WooCommerce
- Theme: Hello Elementor (using a child theme)
- Page Builder: Elementor Pro
- Key Detail: I am using Elementor Pro to customize the WooCommerce page templates, including the "Thank You" / "Order Received" page.
Conclusion:
My conclusion is that GTM4WP is fundamentally incompatible with themes or page builders that override default WooCommerce templates—a standard practice for virtually any custom-designed website today. Because it cannot handle this common scenario, the plugin proved to be entirely useless for my project.
While it may work on simple sites with basic, unmodified themes, it is not a reliable solution for professional, customized builds. The time and effort wasted diagnosing this incompatibility were substantial. I cannot recommend this plugin and strongly advise users with a similar tech stack to seek a manual implementation from the start.
Thought this was beginner friendly. Is not.
By wheelchair on August 2, 2025
Thought this was beginner friendly. Is not.
Plugin com problema
By rbneuro on March 10, 2025
Em vários sites que o plugin está instalado, após alguma atualização, o menu mobile de alguns sites não abre.
Poor Support Experience
By Shubham Verma (s4shubhamverma) on December 2, 2024
I’m disappointed with the support for this plugin. I submitted a question over a week ago, but I have yet to receive any response from the support team.
While the plugin itself may work fine, having reliable support is critical, especially when issues arise. Unfortunately, the lack of responsiveness has made it difficult to trust this plugin for long-term use.
I hope the team improves their support response time in the future.
GTM has stopped pushing data to contact form 7 version 6.0
By AnDurugkar on November 19, 2024
Hi Thomas , I have noticed that gtm4wp.contactForm7Submitted is not getting the data when I updated the contact form 7 to version 6.0, I reverted the version back to 5.9.7 and it started working again!, will this helpfull?
I was trying to make GTM work with Litespeed cache plugin for hours
By quentintaran on October 20, 2024
I was stuck for hours trying to tweak the litespeed cache plugin so it'd work with Google Tag Manager. The solution was simple: use gtm4wp.
For some reason, this plugin avoids the conflicts with litespeed cache (allowing your tags to fire). I'm so happy this works because my site really needs the performance boost from litespeed while being able to track results.
A huge thank you to the developper!
Works well. Needs UI improvements
By Skylabb (skylabb) on September 26, 2024
Good plugin to integrate GTM. The UI can use some polishing up.
no support and breaks page
By mcdeth on July 29, 2024
Support is nonexistent, enabling tracking post pages will break your site if it's not a post not woocommerce page. Excluding administrator from tracking doesn't work neither.
Literally a nightmare with no responses from plugin author
By kirstylmarks on March 27, 2024
Plugin Author,... Maybe start responding to the plethora of questions regarding issues with your plugin in terms of the latest updates and i may just improve my rating. Otherwise this has been a nightmare across all our key conversions
Changelog
2.0.1
- Fixed: on a block-based store, opening the Cart page pushed add_shipping_info and add_payment_info into 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.
- Fixed: a blank settings screen no longer stays silent about why it is blank. Some ad and privacy blocker filter lists block everything under the plugin folder, including the file that builds the settings screen in the browser, which left the settings page empty with no explanation. A static notice now appears after a few seconds whenever the settings app could not start, explaining the most likely cause and the workaround (pause the blocker for the admin area of the site, or add an exception for it).
2.0.0
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. Off by default (experimental); when off, the data layer is exactly as before. The rest of this group describes how it works when you turn it on.
- 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.
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), counted correctly in every language including Cyrillic, Greek, Hebrew, Arabic and the space-less scripts Chinese, Japanese and Korean; 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. - 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: 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 …





