WooCommerce stores commonly connect their product catalog to Google Merchant Center through a dedicated plugin. This is convenient, but it is not always the best choice.
A custom implementation may be preferable when a store needs:
- complete control over product identifiers;
- predictable XML output;
- fewer WordPress plugins;
- compatibility with an existing Merchant Center feed;
- custom product exclusion rules;
- controlled deployment through Git and CI/CD;
- easier debugging;
- preservation of historical product IDs.
This article explains how to implement a custom Google Merchant feed in WooCommerce using PHP, generate a static XML file, expose administrative controls, deploy the code in a containerized WordPress environment, and diagnose a common problem: the PHP files exist after deployment, but the WordPress administration menu disappears.
The examples are intentionally anonymized and can be adapted to most custom WooCommerce themes or site-specific plugins.
Why Use a Custom Google Merchant Feed?
An official WooCommerce integration is often appropriate for stores that want a guided setup and automatic synchronization.
However, a custom feed provides several technical advantages.
Full control over product IDs
The <g:id> field is one of the most important fields in a Merchant Center feed.
Changing product IDs during a migration may cause Google to treat existing products as new products. This can affect:
- product history;
- campaign associations;
- reporting continuity;
- approval status;
- performance data.
A custom implementation lets the developer reproduce the exact ID format used by an older feed.
For example:
<g:id>SKU-12345</g:id>
or:
<g:id>shop_product_12345</g:id>
The format matters less than consistency.
Transparent XML output
A static XML feed can be downloaded, inspected, validated, archived, and compared with previous versions.
This is considerably easier to debug than a background API synchronization that does not expose the complete outgoing payload.
Fewer WordPress dependencies
Each plugin adds:
- PHP code;
- database options;
- scheduled tasks;
- update requirements;
- compatibility risks;
- possible security exposure.
A small custom module may be easier to maintain when the required functionality is limited to catalog export.
Controlled feed generation
A custom feed can be generated:
- manually through WP-CLI;
- through WordPress Cron;
- through a server cron job;
- through a Kubernetes CronJob;
- after a successful deployment.
This makes feed generation part of the infrastructure rather than an uncontrolled frontend request.
Recommended Architecture
A robust implementation separates the feed generator from the administration interface.
For example:
wp-content/
└── themes/
└── custom-theme/
├── functions.php
└── inc/
└── modules/
├── 24-google-merchant-feed.php
└── 25-google-merchant-feed-admin.php
The first file contains the feed-generation logic.
The second file contains:
- the WordPress administration menu;
- enable or pause controls;
- manual generation actions;
- feed status information.
This separation makes the code easier to test and maintain.
Part 1: Loading Custom Theme Modules Correctly
Many custom themes use a module loader in functions.php.
A simple implementation looks like this:
<?php
defined( 'ABSPATH' ) || exit;
$custom_modules = array(
'/inc/modules/01-theme-setup.php',
'/inc/modules/02-assets.php',
'/inc/modules/03-woocommerce.php',
'/inc/modules/24-google-merchant-feed.php',
'/inc/modules/25-google-merchant-feed-admin.php',
);
foreach ( $custom_modules as $custom_module ) {
$custom_module_path = get_template_directory() . $custom_module;
if ( file_exists( $custom_module_path ) ) {
require_once $custom_module_path;
continue;
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log(
sprintf(
'Theme module not found: %s',
$custom_module_path
)
);
}
}
The order is important.
The administration module should be loaded after the main feed module because it may call functions defined by the generator.
Correct:
'/inc/modules/24-google-merchant-feed.php',
'/inc/modules/25-google-merchant-feed-admin.php',
Potentially incorrect:
'/inc/modules/25-google-merchant-feed-admin.php',
'/inc/modules/24-google-merchant-feed.php',
The second order can produce undefined-function errors if the administration module executes code before the generator has been loaded.
Part 2: Defining Feed Configuration
The feed generator should use constants or filters for environment-specific values.
For example:
if ( ! defined( 'CUSTOM_GMC_PUBLIC_BASE_URL' ) ) {
define( 'CUSTOM_GMC_PUBLIC_BASE_URL', home_url() );
}
if ( ! defined( 'CUSTOM_GMC_FEED_DIRECTORY' ) ) {
define( 'CUSTOM_GMC_FEED_DIRECTORY', 'google-merchant' );
}
if ( ! defined( 'CUSTOM_GMC_FEED_FILENAME' ) ) {
define( 'CUSTOM_GMC_FEED_FILENAME', 'products.xml' );
}
On an internal or staging website, the public production URL may be forced from wp-config.php:
define(
'CUSTOM_GMC_PUBLIC_BASE_URL',
'https://shop.example.com'
);
This allows the feed to be generated on an internal hostname while using production URLs inside the XML.
That is useful before launch, but the generated URLs will only become valid after the public domain serves the corresponding pages and images.
Part 3: Resolving the Feed File Path
The WordPress uploads directory is an appropriate location for the generated file because it is normally writable and persistent.
function custom_gmc_get_feed_paths(): array {
$uploads = wp_upload_dir();
if ( ! empty( $uploads['error'] ) ) {
throw new RuntimeException(
'Unable to resolve the WordPress uploads directory.'
);
}
$relative_directory = trim(
CUSTOM_GMC_FEED_DIRECTORY,
'/\\'
);
$directory_path = trailingslashit( $uploads['basedir'] )
. $relative_directory;
$directory_url = trailingslashit( $uploads['baseurl'] )
. $relative_directory;
return array(
'directory_path' => $directory_path,
'directory_url' => $directory_url,
'feed_path' => trailingslashit( $directory_path )
. CUSTOM_GMC_FEED_FILENAME,
'feed_url' => trailingslashit( $directory_url )
. CUSTOM_GMC_FEED_FILENAME,
);
}
The resulting file may be located at:
wp-content/uploads/google-merchant/products.xml
and available publicly at:
https://shop.example.com/wp-content/uploads/google-merchant/products.xml
Part 4: Creating the Feed Directory Safely
Before generating the XML file, ensure the directory exists.
function custom_gmc_ensure_feed_directory( string $directory ): void {
if ( is_dir( $directory ) ) {
return;
}
if ( ! wp_mkdir_p( $directory ) ) {
throw new RuntimeException(
sprintf(
'Unable to create feed directory: %s',
$directory
)
);
}
}
Avoid silently ignoring write failures. A failed feed generation should be visible in logs and command output.
Part 5: Querying WooCommerce Products
For large catalogs, avoid loading every product into memory at once.
A paginated query is safer:
function custom_gmc_get_product_page(
int $page,
int $limit = 100
): object {
return wc_get_products(
array(
'status' => 'publish',
'limit' => $limit,
'page' => $page,
'paginate' => true,
'orderby' => 'ID',
'order' => 'ASC',
'return' => 'objects',
)
);
}
The generator can then process products page by page:
$page = 1;
do {
$result = custom_gmc_get_product_page( $page );
foreach ( $result->products as $product ) {
// Process the product.
}
$page++;
} while ( $page <= $result->max_num_pages );
This avoids memory problems on stores containing thousands of products.
Part 6: Supporting Simple and Variable Products
Simple products produce one feed item.
Variable products normally produce one feed item for each purchasable variation.
function custom_gmc_expand_product(
WC_Product $product
): array {
if ( ! $product->is_type( 'variable' ) ) {
return array( $product );
}
$variations = array();
foreach ( $product->get_children() as $variation_id ) {
$variation = wc_get_product( $variation_id );
if ( ! $variation instanceof WC_Product_Variation ) {
continue;
}
if ( 'publish' !== get_post_status( $variation_id ) ) {
continue;
}
$variations[] = $variation;
}
return $variations;
}
Each variation should have a unique <g:id> and usually the same <g:item_group_id>.
Example:
<g:id>SHIRT-BLUE-M</g:id>
<g:item_group_id>SHIRT-001</g:item_group_id>
Part 7: Building Stable Merchant Product IDs
A custom meta field can be used to preserve IDs imported from an older feed.
function custom_gmc_get_product_id(
WC_Product $product,
?WC_Product $parent = null
): string {
$custom_id = trim(
(string) $product->get_meta(
'_custom_google_merchant_id',
true
)
);
if ( '' !== $custom_id ) {
return $custom_id;
}
$sku = trim( (string) $product->get_sku() );
if ( '' !== $sku ) {
return $sku;
}
if (
$product instanceof WC_Product_Variation
&& $parent instanceof WC_Product
) {
$parent_sku = trim( (string) $parent->get_sku() );
if ( '' !== $parent_sku ) {
return sprintf(
'%s-%d',
$parent_sku,
$product->get_id()
);
}
}
return (string) $product->get_id();
}
The value should also be filterable:
$merchant_id = apply_filters(
'custom_gmc_product_id',
$merchant_id,
$product,
$parent
);
A project-specific rule can then be added without editing the generator:
add_filter(
'custom_gmc_product_id',
function (
string $merchant_id,
WC_Product $product
): string {
return 'store_' . $product->get_id();
},
10,
2
);
Part 8: Mapping Product Availability
WooCommerce stock status must be converted into Merchant-compatible values.
function custom_gmc_get_availability(
WC_Product $product
): string {
if ( $product->is_in_stock() ) {
if ( $product->is_on_backorder( 1 ) ) {
return 'backorder';
}
return 'in_stock';
}
return 'out_of_stock';
}
Possible values commonly include:
in_stock
out_of_stock
backorder
preorder
Backorder and preorder products should have a meaningful availability date when required by the business logic.
function custom_gmc_get_availability_date(
WC_Product $product
): string {
$raw_date = trim(
(string) $product->get_meta(
'_custom_google_availability_date',
true
)
);
if ( '' === $raw_date ) {
return '';
}
$timestamp = strtotime( $raw_date );
if ( false === $timestamp ) {
return '';
}
return gmdate( 'c', $timestamp );
}
A conservative implementation may export a backordered product as out_of_stock when no valid date is available.
Part 9: Formatting Product Prices
The price must use a consistent decimal format and currency.
function custom_gmc_format_price(
string $price,
string $currency
): string {
$numeric_price = (float) wc_format_decimal( $price );
return sprintf(
'%.2f %s',
$numeric_price,
strtoupper( $currency )
);
}
Usage:
$currency = get_woocommerce_currency();
$regular_price = custom_gmc_format_price(
(string) $product->get_regular_price(),
$currency
);
For a sale price:
$sale_price = '';
if (
$product->is_on_sale()
&& '' !== $product->get_sale_price()
) {
$sale_price = custom_gmc_format_price(
(string) $product->get_sale_price(),
$currency
);
}
The value sent in the feed must match the price displayed on the landing page and during checkout.
Part 10: Reading Brand, GTIN, and MPN
Different stores save product identifiers in different metadata fields.
A flexible helper can check several possible keys.
function custom_gmc_get_first_meta_value(
WC_Product $product,
array $keys
): string {
foreach ( $keys as $key ) {
$value = trim(
(string) $product->get_meta(
$key,
true
)
);
if ( '' !== $value ) {
return $value;
}
}
return '';
}
Example brand lookup:
$brand = custom_gmc_get_first_meta_value(
$product,
array(
'_brand',
'brand',
'_product_brand',
)
);
Example GTIN lookup:
$gtin = custom_gmc_get_first_meta_value(
$product,
array(
'_gtin',
'_ean',
'ean',
'_barcode',
)
);
Example MPN lookup:
$mpn = custom_gmc_get_first_meta_value(
$product,
array(
'_mpn',
'mpn',
'_manufacturer_part_number',
)
);
If the store uses a product-brand taxonomy, it can be checked separately:
function custom_gmc_get_brand_from_taxonomy(
int $product_id
): string {
$terms = get_the_terms(
$product_id,
'product_brand'
);
if (
is_wp_error( $terms )
|| empty( $terms )
) {
return '';
}
return (string) $terms[0]->name;
}
Part 11: Generating Product URLs
The feed may be generated on an internal hostname but must reference the public store.
A URL-rewriting helper can replace the current WordPress origin with the configured public origin.
function custom_gmc_rewrite_public_url(
string $url
): string {
if ( '' === $url ) {
return '';
}
$internal_base = untrailingslashit( home_url() );
$public_base = untrailingslashit(
CUSTOM_GMC_PUBLIC_BASE_URL
);
if ( $internal_base === $public_base ) {
return $url;
}
if ( str_starts_with( $url, $internal_base ) ) {
return $public_base
. substr( $url, strlen( $internal_base ) );
}
return $url;
}
For a simple product:
$product_url = custom_gmc_rewrite_public_url(
get_permalink( $product->get_id() )
);
For a variation:
$product_url = $product->get_permalink();
$product_url = custom_gmc_rewrite_public_url(
$product_url
);
WooCommerce normally includes selected attributes in variation URLs.
Part 12: Retrieving Product Images
The main product image is required for most catalog items.
function custom_gmc_get_main_image(
WC_Product $product,
?WC_Product $parent = null
): string {
$image_id = $product->get_image_id();
if (
! $image_id
&& $parent instanceof WC_Product
) {
$image_id = $parent->get_image_id();
}
if ( ! $image_id ) {
return '';
}
$image_url = wp_get_attachment_image_url(
$image_id,
'full'
);
if ( ! is_string( $image_url ) ) {
return '';
}
return custom_gmc_rewrite_public_url(
$image_url
);
}
Additional gallery images can be exported as well:
function custom_gmc_get_additional_images(
WC_Product $product,
?WC_Product $parent = null
): array {
$source = $parent instanceof WC_Product
? $parent
: $product;
$image_urls = array();
foreach (
array_slice(
$source->get_gallery_image_ids(),
0,
10
) as $image_id
) {
$url = wp_get_attachment_image_url(
$image_id,
'full'
);
if ( is_string( $url ) && '' !== $url ) {
$image_urls[] = custom_gmc_rewrite_public_url(
$url
);
}
}
return array_values(
array_unique( $image_urls )
);
}
Part 13: Cleaning Titles and Descriptions
WordPress content may contain HTML, shortcodes, entities, and excessive whitespace.
function custom_gmc_clean_text(
string $text
): string {
$text = strip_shortcodes( $text );
$text = wp_strip_all_tags( $text, true );
$text = html_entity_decode(
$text,
ENT_QUOTES | ENT_HTML5,
'UTF-8'
);
$text = preg_replace(
'/\s+/u',
' ',
$text
);
return trim( (string) $text );
}
A fallback description can be generated when the product description is empty:
function custom_gmc_get_description(
WC_Product $product,
?WC_Product $parent = null
): string {
$description = $product->get_description();
if ( '' === trim( $description ) ) {
$description = $product->get_short_description();
}
if (
'' === trim( $description )
&& $parent instanceof WC_Product
) {
$description = $parent->get_description();
}
if (
'' === trim( $description )
&& $parent instanceof WC_Product
) {
$description = $parent->get_short_description();
}
return custom_gmc_clean_text( $description );
}
Part 14: Writing XML Safely
XMLWriter is preferable to manually concatenating XML strings.
It automatically escapes characters such as:
&
<
>
"
A basic feed header can be created as follows:
$xml = new XMLWriter();
$xml->openMemory();
$xml->startDocument( '1.0', 'UTF-8' );
$xml->setIndent( true );
$xml->startElement( 'rss' );
$xml->writeAttribute( 'version', '2.0' );
$xml->writeAttribute(
'xmlns:g',
'http://base.google.com/ns/1.0'
);
$xml->startElement( 'channel' );
$xml->writeElement(
'title',
get_bloginfo( 'name' ) . ' Product Feed'
);
$xml->writeElement(
'link',
CUSTOM_GMC_PUBLIC_BASE_URL
);
$xml->writeElement(
'description',
'WooCommerce product catalog'
);
A helper function can write namespaced elements:
function custom_gmc_write_element(
XMLWriter $xml,
string $name,
string $value
): void {
if ( '' === trim( $value ) ) {
return;
}
$xml->writeElementNs(
'g',
$name,
'http://base.google.com/ns/1.0',
$value
);
}
Example product item:
$xml->startElement( 'item' );
custom_gmc_write_element(
$xml,
'id',
$merchant_id
);
custom_gmc_write_element(
$xml,
'title',
$title
);
custom_gmc_write_element(
$xml,
'description',
$description
);
custom_gmc_write_element(
$xml,
'link',
$product_url
);
custom_gmc_write_element(
$xml,
'image_link',
$image_url
);
custom_gmc_write_element(
$xml,
'availability',
$availability
);
custom_gmc_write_element(
$xml,
'price',
$price
);
custom_gmc_write_element(
$xml,
'condition',
'new'
);
$xml->endElement();
Finally:
$xml->endElement(); // channel
$xml->endElement(); // rss
$xml->endDocument();
$contents = $xml->outputMemory();
Part 15: Writing the Feed Atomically
Never write directly over the active feed while it is being generated.
If generation fails halfway, Google may download a truncated XML file.
Use a temporary file and rename it only after successful generation.
function custom_gmc_atomic_write(
string $target_path,
string $contents
): void {
$temporary_path = $target_path
. '.tmp.'
. wp_generate_password( 12, false, false );
$bytes = file_put_contents(
$temporary_path,
$contents,
LOCK_EX
);
if ( false === $bytes ) {
throw new RuntimeException(
'Unable to write temporary feed file.'
);
}
if ( ! rename( $temporary_path, $target_path ) ) {
@unlink( $temporary_path );
throw new RuntimeException(
'Unable to replace the active feed file.'
);
}
}
This provides a simple transactional behavior:
Generate complete XML
↓
Write temporary file
↓
Validate result
↓
Rename temporary file
↓
Active feed updated
Part 16: Preventing Concurrent Generations
Two scheduled jobs should not generate the same feed simultaneously.
A lock file can prevent that.
function custom_gmc_acquire_lock(
string $directory
) {
$lock_path = trailingslashit( $directory )
. 'feed.lock';
$handle = fopen( $lock_path, 'c' );
if ( false === $handle ) {
throw new RuntimeException(
'Unable to open feed lock file.'
);
}
if ( ! flock( $handle, LOCK_EX | LOCK_NB ) ) {
fclose( $handle );
throw new RuntimeException(
'Another feed generation is already running.'
);
}
return $handle;
}
Release it when generation finishes:
function custom_gmc_release_lock( $handle ): void {
if ( is_resource( $handle ) ) {
flock( $handle, LOCK_UN );
fclose( $handle );
}
}
Use try and finally:
$lock_handle = null;
try {
$paths = custom_gmc_get_feed_paths();
custom_gmc_ensure_feed_directory(
$paths['directory_path']
);
$lock_handle = custom_gmc_acquire_lock(
$paths['directory_path']
);
// Generate feed.
} finally {
custom_gmc_release_lock( $lock_handle );
}
Part 17: Creating a Generation Report
A JSON report is useful for debugging products that were skipped.
Example structure:
{
"generated_at": "2026-07-30T10:00:00+00:00",
"products_exported": 1842,
"products_skipped": 17,
"warnings": [
{
"product_id": 1452,
"reason": "Missing product image"
},
{
"product_id": 2910,
"reason": "Missing price"
}
]
}
The report can be written next to the XML file:
function custom_gmc_write_report(
string $directory,
array $report
): void {
$report_path = trailingslashit( $directory )
. 'products-report.json';
$json = wp_json_encode(
$report,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
if ( false === $json ) {
throw new RuntimeException(
'Unable to encode feed report.'
);
}
custom_gmc_atomic_write(
$report_path,
$json
);
}
Part 18: Adding a WP-CLI Command
WP-CLI is ideal for manually generating and testing the feed.
if (
defined( 'WP_CLI' )
&& WP_CLI
) {
class Custom_GMC_CLI_Command {
/**
* Generates the Google Merchant feed.
*
* ## EXAMPLES
*
* wp custom merchant-feed generate
*/
public function generate(): void {
try {
$result = custom_gmc_generate_feed();
WP_CLI::success(
sprintf(
'Feed generated with %d products: %s',
$result['products_exported'],
$result['feed_path']
)
);
} catch ( Throwable $exception ) {
WP_CLI::error(
$exception->getMessage()
);
}
}
/**
* Shows the current feed status.
*/
public function status(): void {
$paths = custom_gmc_get_feed_paths();
if ( ! file_exists( $paths['feed_path'] ) ) {
WP_CLI::warning(
'The feed has not been generated.'
);
return;
}
WP_CLI::log(
sprintf(
'Feed path: %s',
$paths['feed_path']
)
);
WP_CLI::log(
sprintf(
'Feed URL: %s',
$paths['feed_url']
)
);
WP_CLI::log(
sprintf(
'Last modified: %s',
gmdate(
'c',
filemtime( $paths['feed_path'] )
)
)
);
WP_CLI::log(
sprintf(
'File size: %d bytes',
filesize( $paths['feed_path'] )
)
);
}
}
WP_CLI::add_command(
'custom merchant-feed',
'Custom_GMC_CLI_Command'
);
}
Usage:
wp custom merchant-feed generate --allow-root
Status check:
wp custom merchant-feed status --allow-root
Part 19: Adding a WordPress Administration Page
The administration module can register a submenu under WooCommerce.
function custom_gmc_register_admin_menu(): void {
add_submenu_page(
'woocommerce',
'Google Merchant Feed',
'Google Merchant Feed',
'manage_woocommerce',
'custom-google-merchant-feed',
'custom_gmc_render_admin_page'
);
}
add_action(
'admin_menu',
'custom_gmc_register_admin_menu'
);
A basic renderer:
function custom_gmc_render_admin_page(): void {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die(
esc_html__(
'You do not have permission to access this page.',
'custom-theme'
)
);
}
$paths = custom_gmc_get_feed_paths();
?>
<div class="wrap">
<h1>Google Merchant Feed</h1>
<table class="widefat striped">
<tbody>
<tr>
<th>Feed URL</th>
<td>
<code>
<?php
echo esc_html(
$paths['feed_url']
);
?>
</code>
</td>
</tr>
<tr>
<th>Feed status</th>
<td>
<?php if ( file_exists( $paths['feed_path'] ) ) : ?>
Available
<?php else : ?>
Not generated
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
</div>
<?php
}
Part 20: Adding a Pause Mode
A pause mode can prevent a feed from being published without deleting the configuration.
For example:
function custom_gmc_is_enabled(): bool {
return 'yes' === get_option(
'custom_gmc_enabled',
'no'
);
}
The generator can refuse to publish when paused:
if ( ! custom_gmc_is_enabled() ) {
throw new RuntimeException(
'Google Merchant feed generation is paused.'
);
}
Alternatively, the existing feed can remain available while scheduled regeneration is paused.
This is usually safer during migration because Merchant Center can continue downloading the last valid feed until the new site is ready.
A pause setting can be saved securely through an administration form:
function custom_gmc_handle_settings(): void {
if (
! isset( $_POST['custom_gmc_save_settings'] )
) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
check_admin_referer(
'custom_gmc_save_settings'
);
$enabled = isset( $_POST['custom_gmc_enabled'] )
? 'yes'
: 'no';
update_option(
'custom_gmc_enabled',
$enabled,
false
);
wp_safe_redirect(
add_query_arg(
array(
'page' => 'custom-google-merchant-feed',
'updated' => '1',
),
admin_url( 'admin.php' )
)
);
exit;
}
add_action(
'admin_init',
'custom_gmc_handle_settings'
);
Part 21: Deploying the Code Through Docker
A typical Dockerfile copies the custom WordPress code into the image:
FROM wordpress:php8.3-apache
COPY ./wordpress/wp-content/themes/custom-theme \
/var/www/html/wp-content/themes/custom-theme
COPY ./wordpress/wp-content/mu-plugins \
/var/www/html/wp-content/mu-plugins
RUN chown -R www-data:www-data \
/var/www/html/wp-content/themes/custom-theme \
/var/www/html/wp-content/mu-plugins
The exact paths depend on the repository structure.
After the CI/CD pipeline builds and pushes a new image, Kubernetes deploys the resulting tag:
registry.example.net/project/store:commit-hash
The tag should ideally be immutable.
Avoid relying exclusively on latest, because it makes troubleshooting more difficult.
Part 22: Persisting the Generated Feed in Kubernetes
The generated XML should be stored on a persistent volume.
A common setup mounts only the uploads directory:
volumeMounts:
- name: uploads
mountPath: /var/www/html/wp-content/uploads
This is appropriate because:
- the theme comes from the container image;
- uploaded media persists;
- the generated feed persists;
- a new pod does not remove the XML file.
Mounting the entire wp-content directory can create a problem.
For example:
volumeMounts:
- name: wordpress-content
mountPath: /var/www/html/wp-content
This may hide theme files copied into the Docker image. The mounted volume replaces the directory contents visible to the container.
A safer division is:
Container image:
- themes
- plugins
- mu-plugins
Persistent volume:
- uploads
Part 23: Generating the Feed With a Kubernetes CronJob
A Kubernetes CronJob can execute WP-CLI on a schedule.
The following is a conceptual example:
apiVersion: batch/v1
kind: CronJob
metadata:
name: merchant-feed-generator
spec:
schedule: "0 */4 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: feed-generator
image: registry.example.net/project/store:commit-hash
command:
- sh
- -lc
- >
wp custom merchant-feed generate
--path=/var/www/html
--allow-root
However, a standalone CronJob container also needs:
- access to the same database;
- access to the uploads volume;
- the same environment variables;
- the same WordPress configuration;
- the same application image.
An alternative is an external scheduled command that executes inside the active WordPress pod.
That approach is simpler but less independent.
A Realistic Deployment Problem: The Merchant Menu Disappears
A common issue appears after a pipeline:
- the deployment succeeds;
- the WordPress pod is healthy;
- both Merchant PHP files exist in the container;
- the Merchant administration menu is missing.
The first assumption is often that the files were not included in the Docker image.
That assumption may be wrong.
The files can exist on disk without being executed by WordPress.
How to Diagnose a Missing WordPress Module
Step 1: Verify the running image
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
get deployment storefront `
-o jsonpath="{.spec.template.spec.containers[0].image}"
This confirms which image was deployed.
Example result:
registry.example.net/storefront:45c6c8c0
Step 2: Find the WordPress pod
Do not assume kubectl exec deployment/... will always select the desired pod, especially when labels overlap.
A safer PowerShell example is:
$wpPod = (
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
get pods -o name |
Where-Object {
$_ -match '^pod/storefront-(?!mysql-)'
} |
Select-Object -First 1
) -replace '^pod/', ''
Verify it:
$wpPod
Step 3: Verify the container name
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
get pod $wpPod `
-o jsonpath="{.spec.containers[*].name}"
Step 4: Confirm the files exist
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
exec pod/$wpPod -c wordpress -- `
sh -c "ls -lah /var/www/html/wp-content/themes/custom-theme/inc/modules/ | grep -i merchant || true"
Expected output:
24-google-merchant-feed.php
25-google-merchant-feed-admin.php
This proves that:
- Git contained the files;
- the pipeline built them;
- the Docker image contains them;
- Kubernetes deployed them.
It does not prove that WordPress loaded them.
Step 5: Inspect the module loader
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
exec pod/$wpPod -c wordpress -- `
sh -c "grep -nE 'google-merchant|custom_modules|require_once' /var/www/html/wp-content/themes/custom-theme/functions.php"
A problematic result may show only:
12:$custom_modules = array(
38:foreach ( $custom_modules as $custom_module ) {
42:require_once $custom_module_path;
The output does not show the Merchant files.
This means the files exist, but they are missing from the explicit module list.
Step 6: Correct the module list
Add:
'/inc/modules/24-google-merchant-feed.php',
'/inc/modules/25-google-merchant-feed-admin.php',
to functions.php.
Then commit and deploy again:
git add wordpress/wp-content/themes/custom-theme/functions.php
git commit -m "Load Google Merchant feed modules"
git push
After the pipeline finishes, verify the deployed file:
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
exec pod/$wpPod -c wordpress -- `
grep -nE "24-google-merchant|25-google-merchant" `
/var/www/html/wp-content/themes/custom-theme/functions.php
Why the Files Disappeared From WordPress but Not From the Container
The files did not actually disappear.
They were present in the filesystem, but WordPress did not execute them.
This distinction is fundamental:
File exists in Git
≠
File exists in Docker image
≠
File exists in running container
≠
File is loaded by PHP
≠
WordPress hook is registered
≠
Menu is visible to the current user
Every layer must be verified separately.
The actual failure was in the application-loading layer.
The pipeline exposed the problem because the new container started from a clean image. A previous pod may have contained an older manually modified functions.php, or the local repository may have had changes that were never committed.
Common WP-CLI Quoting Problem in PowerShell
A command such as this may fail:
wp eval 'echo function_exists("some_function");'
When passed through PowerShell, kubectl, a shell, and WP-CLI, the quotes may be consumed at different layers.
An error like:
Undefined constant "google"
does not necessarily indicate a PHP application error. It can indicate that quotes were removed before the expression reached PHP.
A more reliable approach is to send the evaluation through sh -c:
kubectl `
--kubeconfig $kubeconfig `
--insecure-skip-tls-verify=true `
-n shop `
exec pod/$wpPod -c wordpress -- `
sh -c "wp eval `"echo function_exists('custom_gmc_register_admin_menu') ? 'LOADED' : 'NOT_LOADED';`" --allow-root"
Another option is to create a temporary PHP file and execute it through WP-CLI.
For complex evaluations, that is often easier than escaping multiple levels of quotes.
Improving the Theme Module Loader
An explicit module list is predictable, but it must be updated whenever a file is added or renamed.
There are two main approaches.
Option 1: Explicit list
$custom_modules = array(
'/inc/modules/01-theme-setup.php',
'/inc/modules/02-assets.php',
'/inc/modules/24-google-merchant-feed.php',
'/inc/modules/25-google-merchant-feed-admin.php',
);
Advantages:
- exact execution order;
- easy code review;
- unused files are not loaded accidentally;
- predictable dependencies.
Disadvantages:
- easy to forget a new file;
- file renaming requires loader changes.
Option 2: Automatic discovery
$module_files = glob(
get_template_directory()
. '/inc/modules/*.php'
);
if ( is_array( $module_files ) ) {
natsort( $module_files );
foreach ( $module_files as $module_file ) {
require_once $module_file;
}
}
Advantages:
- new files are loaded automatically;
- numbered filenames control order;
- fewer loader edits.
Disadvantages:
- temporary or backup PHP files may be loaded accidentally;
- every PHP file in the directory becomes executable;
- dependencies are less explicit.
For production stores, an explicit loader is generally safer.
A good compromise is to keep the explicit list and add an automated test that compares the files on disk with the modules declared in functions.php.
Adding a CI Test for Missing Modules
A simple PHP script can detect unregistered module files.
<?php
$theme_root = __DIR__
. '/wordpress/wp-content/themes/custom-theme';
$module_directory = $theme_root . '/inc/modules';
$functions_file = $theme_root . '/functions.php';
$functions_source = file_get_contents(
$functions_file
);
if ( false === $functions_source ) {
fwrite(
STDERR,
"Unable to read functions.php\n"
);
exit( 1 );
}
$module_files = glob(
$module_directory . '/*.php'
);
$missing = array();
foreach ( $module_files as $module_file ) {
$basename = basename( $module_file );
if (
false === strpos(
$functions_source,
$basename
)
) {
$missing[] = $basename;
}
}
if ( ! empty( $missing ) ) {
fwrite(
STDERR,
"Unregistered theme modules:\n"
);
foreach ( $missing as $file ) {
fwrite(
STDERR,
"- {$file}\n"
);
}
exit( 1 );
}
echo "All theme modules are registered.\n";
The pipeline can run:
php tests/check-theme-modules.php
This converts a runtime problem into a build-time failure.
Adding PHP Syntax Validation to CI
Every custom PHP file should be checked before the Docker image is built.
A shell-based validation:
find wordpress/wp-content/themes/custom-theme \
-type f \
-name '*.php' \
-print0 |
while IFS= read -r -d '' file; do
php -l "$file" || exit 1
done
This detects:
- missing semicolons;
- unmatched braces;
- parse errors;
- malformed PHP introduced during a merge.
For PowerShell:
Get-ChildItem `
".\wordpress\wp-content\themes\custom-theme" `
-Recurse `
-Filter "*.php" |
ForEach-Object {
php -l $_.FullName
if ( $LASTEXITCODE -ne 0 ) {
throw "PHP syntax check failed: $($_.FullName)"
}
}
Adding Runtime Health Checks
A pod can be running while an application module is missing.
Container health does not guarantee feature health.
A basic feature check can use WP-CLI:
wp eval "
echo function_exists(
'custom_gmc_generate_feed'
) ? 'OK' : 'MISSING';
" --allow-root
A deployment script can fail when the result is not OK.
For example:
RESULT="$(
wp eval "
echo function_exists(
'custom_gmc_generate_feed'
) ? 'OK' : 'MISSING';
" --allow-root
)"
if [ "$RESULT" != "OK" ]; then
echo "Merchant feed module is not loaded."
exit 1
fi
This checks the application, not just the filesystem.
Security Considerations
A custom feed should follow the same security standards as any other WordPress feature.
Escape administration output
Use:
esc_html()
esc_url()
esc_attr()
wp_kses_post()
depending on context.
Protect actions with nonces
wp_nonce_field(
'custom_gmc_generate_feed',
'custom_gmc_nonce'
);
Verify it:
check_admin_referer(
'custom_gmc_generate_feed',
'custom_gmc_nonce'
);
Check user capabilities
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Insufficient permissions.' );
}
Do not generate feeds through an unrestricted public endpoint
A URL such as:
/?generate-google-feed=1
should not execute an expensive catalog export without authentication.
Use WP-CLI, a protected administration action, or a scheduled server process.
Avoid exposing sensitive metadata
Only export fields required for product discovery.
Do not include:
- internal supplier costs;
- private stock notes;
- administrator comments;
- unpublished product data;
- database IDs that reveal sensitive relationships, unless needed as stable Merchant IDs.
Performance Considerations
A feed generator must account for catalog size.
Use pagination
Do not load thousands of full WooCommerce product objects at once.
Avoid unnecessary metadata queries
Repeated calls to custom fields can create significant database load.
Where practical, retrieve values once per product and reuse them.
Generate outside frontend traffic
WP-CLI or a CronJob avoids slowing down customer requests.
Write incrementally for very large catalogs
XMLWriter::openMemory() is convenient but stores the complete XML in memory.
For very large feeds, write directly to a temporary file:
$xml = new XMLWriter();
$xml->openURI( $temporary_path );
$xml->startDocument( '1.0', 'UTF-8' );
After successful completion, rename the temporary file over the active feed.
This is more memory-efficient.
Cache expensive transformations
Category mappings and taxonomy lookups can be cached during a single generation run.
Migration Strategy From an Existing Feed
When an existing Merchant Center account and feed are active, do not replace them blindly.
A safer migration is:
- download the old feed;
- identify the existing
<g:id>format; - compare SKU, product ID, GTIN, and variation logic;
- configure the custom generator to reproduce those IDs;
- generate the new feed internally;
- compare the number of products;
- compare random product samples;
- verify prices and availability;
- publish the new site;
- expose the new feed on the final domain;
- trigger a Merchant Center fetch;
- monitor diagnostics;
- pause or remove the old source only after validation.
A simple comparison script can extract IDs from two XML files.
<?php
function read_feed_ids( string $path ): array {
$xml = simplexml_load_file( $path );
if ( false === $xml ) {
throw new RuntimeException(
"Unable to read feed: {$path}"
);
}
$xml->registerXPathNamespace(
'g',
'http://base.google.com/ns/1.0'
);
$ids = array();
foreach ( $xml->channel->item as $item ) {
$google = $item->children(
'http://base.google.com/ns/1.0'
);
$id = trim( (string) $google->id );
if ( '' !== $id ) {
$ids[] = $id;
}
}
sort( $ids );
return array_values(
array_unique( $ids )
);
}
$old_ids = read_feed_ids( 'old-feed.xml' );
$new_ids = read_feed_ids( 'new-feed.xml' );
$missing_from_new = array_diff(
$old_ids,
$new_ids
);
$new_only = array_diff(
$new_ids,
$old_ids
);
echo 'Missing from new feed: '
. count( $missing_from_new )
. PHP_EOL;
echo 'New IDs: '
. count( $new_only )
. PHP_EOL;
This does not replace a full content comparison, but it immediately reveals ID discontinuity.
Custom Feed Versus Official Plugin
Neither approach is universally superior.
A custom implementation is suitable when:
- the organization has development resources;
- product IDs must remain stable;
- the existing source is a file feed;
- the catalog has custom business rules;
- deployment is managed through Git and CI/CD;
- plugin count should remain low;
- Google Ads configuration is handled separately.
An official plugin is suitable when:
- non-technical administrators manage the integration;
- rapid setup is more important than custom control;
- the store needs built-in account onboarding;
- automatic API synchronization is preferred;
- the organization does not want to maintain feed code;
- plugin-generated identifiers are acceptable.
The custom approach should not be selected merely to avoid installing one plugin. It is appropriate only when the project can maintain and test the code over time.
Final Lessons
The most important technical lesson is that deployment troubleshooting must be performed layer by layer.
A healthy pod does not prove that a WordPress feature is loaded.
A file visible inside the container does not prove that PHP executed it.
A PHP file loaded successfully does not prove that its WordPress hook ran.
A registered administration page does not prove that the current user has the required capability.
The correct troubleshooting sequence is:
Git repository
↓
CI pipeline
↓
Docker image
↓
Kubernetes deployment
↓
Running container
↓
Theme loader
↓
Included PHP file
↓
Registered WordPress hook
↓
User capability
↓
Visible administration page
In the case described here, the deployment was successful and the Merchant files existed in the running container. The administration menu disappeared because the newly renamed files had not been added to the explicit module list in functions.php.
The permanent solution was not another pipeline run. It was correcting the application loader and adding automated tests to prevent the same class of error from reaching production again.
A well-designed custom Google Merchant feed should therefore include more than XML generation. It should also include:
- stable identifiers;
- atomic file writes;
- locking;
- clear error reports;
- WP-CLI support;
- secure administration controls;
- persistent storage;
- syntax validation;
- module-registration checks;
- post-deployment feature verification.
That combination provides a maintainable, transparent, and production-ready alternative to a large third-party integration plugin.


