Basic analytics can tell an online retailer how many people visited a website. A proper ecommerce tracking implementation must answer much more valuable questions:
- Which products were viewed?
- Which products were added to the cart?
- How many users started checkout?
- Which payment and delivery methods were selected?
- Which orders were completed?
- What was the real value of each purchase?
- Which advertising campaigns generated profitable sales?
This article explains how we implemented a custom WooCommerce tracking architecture using:
- Google Tag Manager;
- Google Analytics 4;
- Google Ads;
- Google Consent Mode v2;
- a consent management platform;
- a custom WordPress must-use plugin;
- a dedicated JavaScript data-layer module.
All company names, domains, account IDs, order numbers and product identifiers in this article have been anonymized.
Why a simple Google Analytics script is not enough
Installing the standard GA4 script gives access to page views, sessions and some automatically measured interactions. However, it does not automatically understand the complete commercial structure of a WooCommerce store.
For effective advertising optimization, Google must receive structured ecommerce events such as:
view_item_list
select_item
view_item
add_to_cart
remove_from_cart
view_cart
begin_checkout
add_shipping_info
add_payment_info
purchase
Each event must contain structured parameters such as:
item_id
item_name
item_brand
item_category
price
quantity
currency
value
transaction_id
tax
shipping
Without these fields, Google Ads may know that a visitor reached the order confirmation page, but it may not know:
- which products were sold;
- how much revenue was generated;
- whether the same order was reported twice;
- whether the sale was worth more or less than another conversion.
That makes meaningful ROAS optimization difficult.
The final tracking architecture
The completed architecture followed this flow:
WooCommerce
↓
Custom WordPress MU-plugin
↓
Structured ecommerce dataLayer
↓
Google Tag Manager
├── Consent Management Platform
├── Google Analytics 4
└── Google Ads destinations
↓
GA4 purchase conversion imported into Google Ads
The custom integration was intentionally placed in a WordPress must-use plugin rather than the active theme.
This provided several benefits:
- it remained active when the theme changed;
- it could not be accidentally disabled from the WordPress plugins screen;
- it was versioned in Git;
- it could read configuration from environment variables;
- it worked correctly in containerized and reverse-proxy environments;
- tracking logic remained separate from presentation logic.
The PHP implementation was structured as a dedicated singleton class that initialized GTM, generated ecommerce data and exposed configuration to JavaScript.
Part 1: Building the WordPress MU-plugin
The plugin was stored using a structure similar to:
wp-content/
└── mu-plugins/
├── ecommerce-google-integrations.php
└── ecommerce-google-integrations/
└── assets/
└── ecommerce-data-layer.js
Reading configuration securely
The GTM container ID should not be hardcoded throughout the source code.
Instead, the plugin reads configuration from WordPress constants or environment variables:
define('ECOMMERCE_GTM_ID', 'GTM-XXXXXXX');
define('ECOMMERCE_TRACKING_ENABLED', true);
define(
'ECOMMERCE_ALLOWED_HOSTS',
'shop.example.com,www.shop.example.com'
);
define('ECOMMERCE_TRACKING_DEBUG', false);
A Kubernetes or Docker deployment can provide equivalent environment variables.
The GTM ID must also be validated:
private function get_gtm_id(): string {
$value = getenv('ECOMMERCE_GTM_ID') ?: '';
$value = strtoupper(trim($value));
return preg_match('/^GTM-[A-Z0-9]+$/', $value)
? $value
: '';
}
This prevents an unexpected or invalid value from being inserted into the page.
Restricting tracking by hostname
Development, staging and production traffic should not be mixed unintentionally.
A robust implementation verifies the request hostname before loading GTM:
private function is_allowed_host(): bool {
$host = $this->get_request_host();
return $host !== ''
&& in_array($host, $this->get_allowed_hosts(), true);
}
Because the website may run behind an ingress controller or reverse proxy, the implementation should check HTTP_X_FORWARDED_HOST before HTTP_HOST:
private function get_request_host(): string {
$host = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$forwarded = explode(
',',
(string) wp_unslash($_SERVER['HTTP_X_FORWARDED_HOST'])
);
$host = trim($forwarded[0]);
} elseif (!empty($_SERVER['HTTP_HOST'])) {
$host = (string) wp_unslash($_SERVER['HTTP_HOST']);
}
$host = strtolower(
preg_replace('/:\d+$/', '', $host)
);
return sanitize_text_field($host);
}
This is particularly important in Kubernetes deployments where the application may see an internal hostname while the visitor accesses a public reverse-proxy domain.
Loading Google Tag Manager correctly
A web GTM container requires two parts:
- the JavaScript bootstrap in the document head;
- the
noscriptiframe immediately after the opening body tag.
The head integration can be added with:
add_action(
'wp_head',
array($this, 'render_head_bootstrap'),
0
);
The body fallback can be added with:
add_action(
'wp_body_open',
array($this, 'render_gtm_noscript'),
0
);
The bootstrap also places a page-context event in the data layer before GTM starts:
$bootstrap = array(
'event' => 'site_page_context',
'page_type' => $page_context['page_type'],
'page_title' => $page_context['page_title'],
'content_id' => $page_context['content_id'],
'logged_in' => $page_context['logged_in'],
'user_role' => $page_context['user_role'],
'environment' => $page_context['environment'],
);
That produces a data-layer object similar to:
{
event: "site_page_context",
page_type: "product",
page_title: "Example product",
content_id: 1234,
logged_in: false,
user_role: "guest",
environment: "production"
}
This contextual information can later be used for reporting, debugging or GTM trigger conditions.
Part 2: Converting WooCommerce products into GA4 items
GA4 requires a predictable product structure.
A reusable PHP method can convert a WC_Product object into the GA4 item format:
private function format_product_item(
WC_Product $product,
int $quantity = 1,
string $list_name = ''
): array {
$sku = $product->get_sku();
$item = array(
'item_id' => $sku !== ''
? $sku
: (string) $product->get_id(),
'item_name' => wp_strip_all_tags(
$product->get_name()
),
'price' => $this->to_number(
$product->get_price()
),
'quantity' => max(1, $quantity),
);
return $item;
}
Product ID strategy
The preferred item_id is normally the SKU:
'item_id' => $product->get_sku()
If no SKU exists, the WooCommerce product ID can be used as a fallback.
The same identifier should be used consistently in:
- GA4;
- Google Ads;
- Merchant Center;
- product feeds;
- internal reporting.
Inconsistent identifiers make product-level attribution difficult.
Adding brand information
WooCommerce stores represent brands in different ways. A brand may be stored in:
pa_brand
pa_marca
product_brand
pwb-brand
A flexible implementation can check several known taxonomies:
$taxonomies = array(
'pa_brand',
'pa_marca',
'product_brand',
'pwb-brand'
);
foreach ($taxonomies as $taxonomy) {
if (!taxonomy_exists($taxonomy)) {
continue;
}
$terms = wc_get_product_terms(
$product_id,
$taxonomy,
array('fields' => 'names')
);
if (!is_wp_error($terms) && !empty($terms)) {
$item['item_brand'] = sanitize_text_field(
(string) reset($terms)
);
break;
}
}
Adding product categories
GA4 supports several category levels:
item_category
item_category2
item_category3
item_category4
item_category5
A simplified implementation can collect the product categories and map the first five values:
foreach (
array_slice($categories, 0, 5)
as $index => $category
) {
$key = $index === 0
? 'item_category'
: 'item_category' . ($index + 1);
$item[$key] = $category;
}
For more advanced stores, it may be better to build the actual parent-to-child taxonomy path instead of sorting terms only by parent ID.
Handling product variations
For variable products, the selected variation should be represented through:
item_id
item_variant
price
The JavaScript layer listens for WooCommerce’s found_variation event:
$('form.variations_form').on(
'found_variation',
function (event, variation) {
selectedVariation = clone(config.currentItem || {});
selectedVariation.item_id =
variation.sku ||
String(variation.variation_id || '');
selectedVariation.price =
numberValue(
variation.display_price,
selectedVariation.price || 0
);
selectedVariation.item_variant =
Object.keys(variation.attributes || {})
.map(function (key) {
return variation.attributes[key];
})
.filter(Boolean)
.join(' | ');
}
);
This ensures the event reports the product variation actually chosen by the customer rather than only the parent product.
Part 3: Generating server-assisted ecommerce events
Some ecommerce events can be prepared on the server because WordPress already knows which page is being rendered.
Examples include:
view_item
view_item_list
view_cart
begin_checkout
purchase
The plugin builds these events in PHP and passes them to JavaScript:
$config = array(
'enabled' => true,
'currency' => $this->get_currency(),
'currentItem' => $this->get_current_product_item(),
'cartContext' => $this->get_cart_context(),
'serverEvents' => $this->get_server_events(),
);
The configuration is made available before the JavaScript file:
wp_add_inline_script(
'ecommerce-data-layer',
'window.ecommerceTrackingData = '
. wp_json_encode($config)
. ';',
'before'
);
Sending a product-view event
A view_item event can be prepared as:
$events[] = array(
'event' => 'view_item',
'ecommerce' => array(
'currency' => $this->get_currency(),
'value' => $item['price'],
'items' => array($item),
),
);
The corresponding data layer becomes:
{
event: "view_item",
ecommerce: {
currency: "EUR",
value: 249.90,
items: [
{
item_id: "SKU-EXAMPLE",
item_name: "Example product",
price: 249.90,
quantity: 1
}
]
}
}
Part 4: JavaScript interaction tracking
Not every event can be generated reliably on the server.
Actions such as adding or removing products happen in the browser, frequently through AJAX.
The JavaScript module handles:
- product list clicks;
- add-to-cart clicks;
- WooCommerce AJAX add-to-cart completion;
- removal from cart;
- variation selection;
- shipping method selection;
- payment method selection.
The original data-layer module implemented product extraction, event generation and deduplication logic.
A single event-push function
A central function should clear the previous ecommerce object before sending the next one:
function pushEcommerceEvent(
eventName,
ecommerce,
dedupeKey
) {
if (!eventName || !ecommerce) {
return;
}
dataLayer.push({
ecommerce: null
});
dataLayer.push({
event: eventName,
ecommerce: ecommerce
});
}
Clearing ecommerce prevents values from an earlier event from leaking into a later one.
For example, without the reset, a view_item event could accidentally retain a coupon or transaction ID from a previous event in a single-page or AJAX-heavy session.
Tracking add-to-cart
The event payload is calculated from product price and quantity:
function trackAddToCart(item, quantity) {
if (!item) {
return;
}
var payloadItem = clone(item);
payloadItem.quantity = Math.max(
1,
quantity || payloadItem.quantity || 1
);
pushEcommerceEvent(
'add_to_cart',
{
currency: config.currency || 'EUR',
value:
numberValue(payloadItem.price, 0) *
payloadItem.quantity,
items: [payloadItem]
}
);
}
The implementation should support both:
- standard product-page buttons;
- WooCommerce AJAX buttons in product listings.
WooCommerce exposes an added_to_cart jQuery event:
$(document.body).on(
'added_to_cart',
function (event, fragments, cartHash, $button) {
var button = $button && $button.length
? $button.get(0)
: null;
var item = getItemFromElement(button);
trackAddToCart(
item,
parseInt(
button.getAttribute('data-quantity') || '1',
10
)
);
}
);
Preventing duplicate add-to-cart events
A common implementation error is sending add_to_cart twice:
- once when the button is clicked;
- once when WooCommerce emits
added_to_cart.
A short-lived in-memory deduplication map can prevent this:
var recentEvents = {};
function markRecent(key, ttlMs) {
var now = Date.now();
if (
recentEvents[key] &&
recentEvents[key] >= now
) {
return false;
}
recentEvents[key] = now + (ttlMs || 2000);
return true;
}
An event key may include:
var key =
'add:' +
String(item.item_id || '') +
':' +
quantity;
This is not a replacement for correct event design, but it protects against rapid duplicate listeners.
Part 5: Building the purchase event correctly
The purchase event is the most important ecommerce event.
It must include:
transaction_id
value
currency
tax
shipping
coupon
items
Validating the order
The order confirmation page should not be trusted based only on a URL parameter.
The plugin should validate:
- the WooCommerce order ID;
- the order object;
- the order key from the URL;
- the real order key stored in WooCommerce.
Example:
$order_id = absint(
get_query_var('order-received')
);
$order = $order_id
? wc_get_order($order_id)
: false;
$key = isset($_GET['key'])
? wc_clean(wp_unslash($_GET['key']))
: '';
if (
!$order instanceof WC_Order ||
$key === '' ||
!hash_equals($order->get_order_key(), $key)
) {
return null;
}
This prevents arbitrary order IDs from being used to generate purchase data.
Calculating the purchase value
An important issue discovered during testing was the difference between:
order total including tax
and:
GA4 ecommerce value
For consistent product-level reporting, the event value should equal:
sum of item price × item quantity
while tax and shipping are sent separately.
Correct calculation:
$items = array();
$items_value = 0.0;
foreach (
$order->get_items('line_item')
as $order_item
) {
if (
!$order_item instanceof
WC_Order_Item_Product
) {
continue;
}
$quantity = max(
1,
(int) $order_item->get_quantity()
);
$line_total = (float) $order_item->get_total();
$items_value += $line_total;
$price = $this->to_number(
$line_total / $quantity
);
// Build item data here.
}
The event should then use:
'ecommerce' => array(
'transaction_id' => (string) $order->get_order_number(),
'affiliation' => get_bloginfo('name'),
'value' => $this->to_number($items_value),
'tax' => $this->to_number(
$order->get_total_tax()
),
'shipping' => $this->to_number(
$order->get_shipping_total()
),
'currency' => $order->get_currency(),
'coupon' => implode(
',',
$order->get_coupon_codes()
),
'items' => $items,
),
Example:
{
event: "purchase",
ecommerce: {
transaction_id: "ORDER-10001",
value: 443.01,
tax: 93.04,
shipping: 0,
currency: "RON",
coupon: "",
items: [
{
item_id: "SKU-A",
item_name: "Example network device",
price: 129.78,
quantity: 2
},
{
item_id: "SKU-B",
item_name: "Example managed switch",
price: 183.45,
quantity: 1
}
]
}
}
The item calculation is:
129.78 × 2 = 259.56
183.45 × 1 = 183.45
Total value = 443.01
Tax is not added again to the ecommerce value because it is already sent through the separate tax parameter.
Purchase deduplication
Customers may reload the confirmation page, reopen it from browser history or revisit the URL from an email.
A browser-side safeguard can store the transaction ID:
if (
eventName === 'purchase' &&
ecommerce.transaction_id
) {
var purchaseKey =
'purchase_' +
String(ecommerce.transaction_id);
if (localStorage.getItem(purchaseKey)) {
return;
}
localStorage.setItem(
purchaseKey,
String(Date.now())
);
}
GA4 can also use the transaction ID to deduplicate purchase events, but local protection reduces unnecessary duplicate network requests.
For higher-value stores, stronger server-side deduplication should also be considered.
For example, WordPress can store an order meta flag after a server-side conversion has been successfully delivered:
$order->update_meta_data(
'_analytics_purchase_sent',
current_time('mysql')
);
$order->save();
Browser-only deduplication can be bypassed when:
- the customer uses another device;
- local storage is cleared;
- privacy mode creates a separate storage context;
- multiple browsers access the same confirmation URL.
Part 6: Consent Mode v2 and the CMP
A consent management platform should be the single source of truth for consent.
An early implementation included custom consent functions inside the ecommerce JavaScript, including:
readConsent
applyConsent
resetConsent
GSCGoogleConsent
That architecture became unnecessary once a dedicated CMP was introduced.
Running two consent systems simultaneously can create conflicting updates:
CMP consent update
+
custom gtag consent update
Possible consequences include:
- consent states being overwritten;
- Analytics firing unexpectedly;
- consent diagnostics reporting missing updates;
- duplicate consent cookies;
- difficult debugging.
The final design should therefore be:
Consent Management Platform
└── controls Google Consent Mode
Custom JavaScript
└── controls ecommerce events only
The CMP tag should use:
Consent Initialization – All Pages
Initial states should normally be:
analytics_storage: denied
ad_storage: denied
ad_user_data: denied
ad_personalization: denied
security_storage: granted
After the visitor accepts all categories:
analytics_storage: granted
ad_storage: granted
ad_user_data: granted
ad_personalization: granted
This separation of responsibilities makes the implementation easier to audit and maintain.
Part 7: Configuring Google Tag Manager
The GTM container needs at least three logical components.
1. Google tag
Example configuration:
Tag type:
Google tag
Tag ID:
G-XXXXXXXXXX
Trigger:
All permitted pages
On a staging environment, the trigger can be restricted with:
Page Hostname equals staging-shop.example.com
In production:
Page Hostname matches RegEx
^(www\.)?shop\.example\.com$
2. GA4 ecommerce event tag
Example:
Tag type:
Google Analytics: GA4 Event
Measurement ID:
G-XXXXXXXXXX
Event Name:
{{Event}}
The tag should send ecommerce data from the data layer.
A custom-event trigger can match:
^(view_item_list|select_item|view_item|add_to_cart|remove_from_cart|view_cart|begin_checkout|add_shipping_info|add_payment_info|purchase)$
3. Consent-management tag
The CMP should run on:
Consent Initialization – All Pages
It must execute before the Google tag and ecommerce event tags.
Part 8: Google Ads conversion strategy
There are two common ways to send purchases to Google Ads:
- direct Google Ads conversion tag;
- GA4 purchase event imported into Google Ads.
In this implementation, the existing Google Ads conversion used:
Source: Google Analytics 4
Event: purchase
Optimization: Primary
Count: Every
Attribution: Data-driven
Because the purchase was already imported from GA4, creating another direct Google Ads purchase tag would risk duplicate primary conversions.
The recommended structure was:
purchase
Primary conversion
Used for automated bidding
begin_checkout
Secondary conversion
Used for funnel analysis
add_to_cart
Secondary conversion
Used for funnel analysis
Google Ads should optimize primarily for actual sales, not merely for users who add a product to the cart.
Part 9: Testing the implementation
A tracking implementation is not complete until it has been tested at three levels.
Level 1: dataLayer
In GTM Preview, verify that the event contains:
{
event: "purchase",
ecommerce: {
transaction_id: "...",
value: 443.01,
currency: "RON",
tax: 93.04,
shipping: 0,
items: [...]
}
}
Level 2: GTM hit
Open:
Hits Sent
→ Purchase
Verify that the GA4 request contains:
Event Name: purchase
Transaction ID: expected order number
Value: expected product value
Tax: expected tax
Shipping: expected shipping
Currency: expected currency
Ecommerce Item: expected products
Seeing the data in the data layer is not enough. It must also be present in the outgoing Analytics hit.
Level 3: GA4 and Google Ads
Check:
GA4 DebugView
GA4 Realtime
GA4 Ecommerce purchases
Google Ads conversion diagnostics
A direct test order may appear in GA4 but not as an advertising conversion if it was not preceded by an eligible ad interaction. That is expected.
Recommended final test matrix
Before production launch, test:
| Scenario | Expected result |
|---|---|
| Simple product | Correct view_item and purchase item |
| Variable product | Selected variation ID and price |
| Quantity greater than one | Correct quantity and total value |
| Multiple products | All items included |
| Coupon | Discounted product value |
| Paid delivery | Correct shipping value |
| Cash on delivery | Purchase sent according to business policy |
| Online payment | Purchase sent only after valid order confirmation |
| Page reload | No duplicate purchase |
| Consent rejected | No optional storage |
| Consent accepted | Analytics and Ads storage granted |
| Staging hostname | Excluded or marked as staging |
| Production hostname | Normal tracking enabled |
Common mistakes to avoid
Using the order total as ecommerce value
This may include:
- tax;
- shipping;
- fees.
If these are also sent separately, reporting can become inconsistent.
Sending the same purchase to Ads twice
This often happens when:
- GA4 purchase is imported into Ads;
- a direct Google Ads purchase tag is also active;
- both conversions are marked as primary.
Loading GTM twice
Possible duplicate sources include:
- a theme integration;
- an analytics plugin;
- a custom MU-plugin;
- a consent plugin;
- a manually inserted header script.
Running two consent managers
Only one system should control:
analytics_storage
ad_storage
ad_user_data
ad_personalization
Testing only the data layer
The data layer may be correct while the GA4 tag fails to send some parameters. Always verify the outgoing hit.
Ignoring staging traffic
Test orders can distort:
- revenue;
- conversion rates;
- product reports;
- remarketing audiences;
- Google Ads optimization.
Use hostname restrictions, development traffic filters or a separate test property.
Final result
The finished integration produced a complete purchase event containing:
unique transaction ID
product-level revenue
tax
shipping
currency
SKU
product name
quantity
brand
category
The purchase reached GA4 correctly and could be imported into Google Ads as the primary sales conversion.
The main technical lessons were:
- Build a structured WooCommerce data layer instead of tracking page URLs.
- Keep tracking logic in a dedicated MU-plugin.
- Separate server-generated and browser-generated events.
- Calculate ecommerce value consistently.
- Use a unique transaction ID.
- Deduplicate purchase events.
- Let one CMP manage all consent states.
- Verify both the data layer and the outgoing Analytics request.
- Avoid duplicate Google Ads conversion sources.
- Test the complete funnel before optimizing advertising campaigns.
With this architecture, advertising campaigns can be optimized not simply for clicks or checkout visits, but for actual purchases and their real commercial value.


