Commit 0b0f8d41423 for woocommerce

commit 0b0f8d414237f62b49bb8799146091e3f7cfbb1c
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Wed Apr 22 11:03:02 2026 +0200

    Introduce the dual code + GraphQL API for WooCommerce (#63772)

diff --git a/plugins/woocommerce/.distignore b/plugins/woocommerce/.distignore
index 52942c00671..c74f49cc915 100644
--- a/plugins/woocommerce/.distignore
+++ b/plugins/woocommerce/.distignore
@@ -48,3 +48,4 @@ webpack.config.js
 phpstan.neon
 phpstan-baseline.neon
 /php-stubs/
+/src/Internal/Api/DesignTime/
diff --git a/plugins/woocommerce/changelog/pr-63772 b/plugins/woocommerce/changelog/pr-63772
new file mode 100644
index 00000000000..50618f26ab6
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63772
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Introduce the dual code + GraphQL API for WooCommerce
diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json
index a4227a86f01..58fd47b50c0 100644
--- a/plugins/woocommerce/composer.json
+++ b/plugins/woocommerce/composer.json
@@ -53,6 +53,7 @@
 		"composer/installers": "^1.9",
 		"maxmind-db/reader": "^1.11",
 		"opis/json-schema": "*",
+		"webonyx/graphql-php": "^15.31",
 		"woocommerce/action-scheduler": "3.9.3",
 		"woocommerce/blueprint": "*",
 		"woocommerce/email-editor": "*",
@@ -92,7 +93,8 @@
 	"autoload": {
 		"exclude-from-classmap": [
 			"includes/legacy",
-			"includes/libraries"
+			"includes/libraries",
+			"src/Internal/Api/DesignTime"
 		],
 		"classmap": [
 			"includes/rest-api"
diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock
index d1942913dd6..f8d804eac2e 100644
--- a/plugins/woocommerce/composer.lock
+++ b/plugins/woocommerce/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "c9dcd2cbeb75aa25b1b43c237292c908",
+    "content-hash": "b8069038c6b35a5f88ea33f3b60e9c08",
     "packages": [
         {
             "name": "automattic/block-delimiter",
@@ -1068,6 +1068,86 @@
             },
             "time": "2021-05-22T15:57:08+00:00"
         },
+        {
+            "name": "webonyx/graphql-php",
+            "version": "v15.32.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webonyx/graphql-php.git",
+                "reference": "e8f77f81dbe5de75551137955dd0fd3f779235cf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/e8f77f81dbe5de75551137955dd0fd3f779235cf",
+                "reference": "e8f77f81dbe5de75551137955dd0fd3f779235cf",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "php": "^7.4 || ^8"
+            },
+            "require-dev": {
+                "amphp/amp": "^2.6 || ^3",
+                "amphp/http-server": "^2.1 || ^3",
+                "dms/phpunit-arraysubset-asserts": "dev-master",
+                "ergebnis/composer-normalize": "^2.28",
+                "friendsofphp/php-cs-fixer": "3.95.1",
+                "mll-lab/php-cs-fixer-config": "5.13.0",
+                "nyholm/psr7": "^1.5",
+                "phpbench/phpbench": "^1.2",
+                "phpstan/extension-installer": "^1.1",
+                "phpstan/phpstan": "2.1.46",
+                "phpstan/phpstan-phpunit": "2.0.16",
+                "phpstan/phpstan-strict-rules": "2.0.10",
+                "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
+                "psr/http-message": "^1 || ^2",
+                "react/http": "^1.6",
+                "react/promise": "^2.0 || ^3.0",
+                "rector/rector": "^2.0",
+                "symfony/polyfill-php81": "^1.23",
+                "symfony/var-exporter": "^5 || ^6 || ^7 || ^8",
+                "thecodingmachine/safe": "^1.3 || ^2 || ^3",
+                "ticketswap/phpstan-error-formatter": "1.3.0"
+            },
+            "suggest": {
+                "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)",
+                "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
+                "psr/http-message": "To use standard GraphQL server",
+                "react/promise": "To leverage async resolving on React PHP platform"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GraphQL\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A PHP port of GraphQL reference implementation",
+            "homepage": "https://github.com/webonyx/graphql-php",
+            "keywords": [
+                "api",
+                "graphql"
+            ],
+            "support": {
+                "issues": "https://github.com/webonyx/graphql-php/issues",
+                "source": "https://github.com/webonyx/graphql-php/tree/v15.32.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/spawnia",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/webonyx-graphql-php",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2026-04-21T09:42:39+00:00"
+        },
         {
             "name": "woocommerce/action-scheduler",
             "version": "3.9.3",
@@ -5596,5 +5676,5 @@
     "platform-overrides": {
         "php": "7.4"
     },
-    "plugin-api-version": "2.9.0"
+    "plugin-api-version": "2.6.0"
 }
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 151caa536bd..68b0ee30a36 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -412,6 +412,9 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\ProductFilters\MainQueryController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductFilters\CacheController::class )->register();

+		// Code+GraphQL API.
+		Automattic\WooCommerce\Internal\Api\Main::register();
+
 		// Integration point between legacy reports and orders APIs (the reports caches invalidation focused).
 		\WC_Admin_Reports::register_orders_hook_handlers();
 	}
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index fdd81b01c8d..72f7d2e774e 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -22,6 +22,8 @@
 		"build:project:copy-assets:email-editor": "rsync -avhW --checksum --delete --quiet ../../packages/php/email-editor/src/ packages/email-editor/src",
 		"build:project:copy-assets:blueprint": "rsync -avhW --checksum --delete --quiet ../../packages/php/blueprint/src/ packages/blueprint/src",
 		"build:project:actualize-translation-domains": "wireit",
+		"build:api": "php src/Internal/Api/DesignTime/Scripts/build-api.php",
+		"build:api:check": "php src/Internal/Api/DesignTime/Scripts/check-api-staleness.php",
 		"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
 		"update:php": "XDEBUG_MODE=off composer update --quiet",
 		"env:destroy": "pnpm wp-env destroy",
@@ -99,6 +101,9 @@
 		"wp-env": "wp-env"
 	},
 	"lint-staged": {
+		"src/Api/**/*.php": [
+			"php src/Internal/Api/DesignTime/Scripts/check-api-staleness.php"
+		],
 		"*.php": [
 			"php -d display_errors=1 -l",
 			"composer run-script lint-staged"
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 342ad0d8cbc..8a2c570e4b8 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -55,6 +55,48 @@

 	<rule ref="PHPCompatibility">
 		<exclude-pattern>tests/</exclude-pattern>
+		<exclude-pattern>src/Api/</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/</exclude-pattern>
+	</rule>
+
+	<!-- The Code API and its infrastructure require PHP 8.1+. CI runs on PHP 7.4,
+	     so Generic.PHP.Syntax (which shells out to `php -l`) flags every enum,
+	     constructor promotion, named argument, and union type as a parse error. -->
+	<rule ref="Generic.PHP.Syntax">
+		<exclude-pattern>src/Api/</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/</exclude-pattern>
+	</rule>
+
+	<!-- PHP 8.0 `mixed` type hint is valid but not recognized by the Squiz sniff -->
+	<rule ref="Squiz.Commenting.FunctionComment.InvalidTypeHint">
+		<exclude-pattern>src/Api/</exclude-pattern>
+	</rule>
+
+	<!-- tax_query / meta_query are intentional for product filtering -->
+	<rule ref="WordPress.DB.SlowDBQuery">
+		<exclude-pattern>src/Api/</exclude-pattern>
+	</rule>
+
+	<!-- API public classes: suppress variable comments where #[Description] attributes serve as documentation -->
+	<rule ref="Squiz.Commenting.VariableComment.Missing">
+		<exclude-pattern>src/Api/Types/</exclude-pattern>
+		<exclude-pattern>src/Api/InputTypes/</exclude-pattern>
+		<exclude-pattern>src/Api/Pagination/</exclude-pattern>
+	</rule>
+
+	<!-- Cursor-based pagination legitimately uses base64 for opaque cursors -->
+	<rule ref="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode">
+		<exclude-pattern>src/Api/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode">
+		<exclude-pattern>src/Api/</exclude-pattern>
+	</rule>
+
+	<!-- Reserved keyword parameter names ($type, $default, $class, $parent) are
+	     unavoidable in attribute definitions, autoloader closures, and generated code. -->
+	<rule ref="Universal.NamingConventions.NoReservedKeywordParameterNames">
+		<exclude-pattern>src/Api/Attributes/Parameter.php</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/</exclude-pattern>
 	</rule>

 	<rule ref="Suin.Classes.PSR4">
@@ -161,6 +203,117 @@
 		<exclude-pattern>tests/php/</exclude-pattern>
 	</rule>

+	<!-- LongConditionClosingComment wants `//end if` / `//end foreach` markers on
+	     long blocks. Archaic convention from legacy WP code that's not used in
+	     modern PHP 8.1+ code paths like the Code API and its infrastructure. -->
+	<rule ref="Squiz.Commenting.LongConditionClosingComment">
+		<exclude-pattern>src/Api/</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/</exclude-pattern>
+	</rule>
+
+	<!-- Autogenerated API code: suppress rules for generated files -->
+	<rule ref="Generic.Commenting">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+	<rule ref="Squiz.Commenting">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.Security.EscapeOutput">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.WP.AlternativeFunctions">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+	<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/GraphQLController.php</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/QueryInfoExtractor.php</exclude-pattern>
+	</rule>
+
+	<!-- API build scripts use empty if/elseif for intentional no-ops (e.g. enum
+	     properties need no conversion) with a comment explaining why. -->
+	<rule ref="Generic.CodeAnalysis.EmptyStatement">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+
+	<!-- Autogenerated code: suppress additional rules -->
+	<rule ref="Generic.PHP.Syntax">
+		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+	</rule>
+
+	<!-- API templates: suppress rules that don't apply to PHP templates -->
+	<rule ref="Generic.PHP.RequireStrictTypes">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="PSR12.Files.FileHeader">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="Generic.Commenting">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="Squiz.Commenting">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DiscouragedPHPFunctions.serialize_var_export">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.CodeAnalysis.AssignmentInTernaryCondition">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DontExtract">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.Security.EscapeOutput">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.YodaConditions">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DevelopmentFunctions">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="Generic.WhiteSpace.ScopeIndent">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.WP.GlobalVariablesOverride">
+		<exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+	</rule>
+
+	<!-- API build scripts: suppress WordPress-specific rules (CLI-only code) -->
+	<rule ref="WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.WP.AlternativeFunctions">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.Security.EscapeOutput">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.YodaConditions">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DontExtract">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WordPress.PHP.DevelopmentFunctions">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="Generic.Commenting">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="Squiz.Commenting">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="Universal.NamingConventions.NoReservedKeywordParameterNames">
+		<exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+	</rule>
+	<rule ref="WooCommerce.Functions.InternalInjectionMethod">
+		<exclude-pattern>src/Internal/Api/DesignTime/</exclude-pattern>
+	</rule>
+
 	<!-- Temporary -->
 	<rule ref="Universal.Arrays.DisallowShortArraySyntax.Found">
 		<exclude-pattern>src/Blocks/</exclude-pattern>
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index 7f89cdd47b6..cf81175dfb3 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -14,6 +14,14 @@ parameters:
 		# Matches the prior test implementation; GeoIP relies on data files.
 		- includes/class-wc-geo-ip.php
 		- includes/react-admin/feature-config.php (?)
+		# The Code API (src/Api/) and its infrastructure (src/Internal/Api/)
+		# require PHP 8.1+ and use enums, named arguments, constructor
+		# property promotion, union types, and `mixed` throughout — all of
+		# which the global phpVersion: 70400 setting cannot parse or resolve.
+		# Infrastructure files also reference src/Api/ classes that PHPStan
+		# can't discover once the public API dir is excluded.
+		- src/Api/
+		- src/Internal/Api/
 	bootstrapFiles:
 		- vendor/autoload.php
 	scanDirectories:
diff --git a/plugins/woocommerce/src/Api/ApiException.php b/plugins/woocommerce/src/Api/ApiException.php
new file mode 100644
index 00000000000..1b0d58816fd
--- /dev/null
+++ b/plugins/woocommerce/src/Api/ApiException.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Exception for API errors with error codes and extensions.
+ */
+class ApiException extends \RuntimeException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param string      $error_code The machine-readable error code.
+	 * @param array       $extensions Additional error metadata.
+	 * @param int         $status_code The HTTP status code.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message,
+		private readonly string $error_code = 'INTERNAL_ERROR',
+		private readonly array $extensions = array(),
+		int $status_code = 500,
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, $status_code, $previous );
+	}
+
+	/**
+	 * Get the machine-readable error code.
+	 *
+	 * @return string
+	 */
+	public function getErrorCode(): string {
+		return $this->error_code;
+	}
+
+	/**
+	 * Get the additional error metadata.
+	 *
+	 * @return array
+	 */
+	public function getExtensions(): array {
+		return $this->extensions;
+	}
+
+	/**
+	 * Get the HTTP status code.
+	 *
+	 * @return int
+	 */
+	public function getStatusCode(): int {
+		return $this->getCode();
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ArrayOf.php b/plugins/woocommerce/src/Api/Attributes/ArrayOf.php
new file mode 100644
index 00000000000..dd58ea81058
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ArrayOf.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares the element type for an array-typed property or return value.
+ *
+ * PHP arrays are untyped, so the builder cannot infer the element type via
+ * reflection. Apply this attribute to tell the builder what GraphQL list type
+ * to generate (e.g. `[Int!]`, `[String!]`).
+ *
+ * Example: `#[ArrayOf('int')]` on a `array $product_ids` property produces
+ * the GraphQL type `[Int!]!`.
+ */
+#[Attribute]
+final class ArrayOf {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $type A scalar name ('int', 'string', 'float', 'bool') or
+	 *                     a fully-qualified class name for output/enum types.
+	 */
+	public function __construct(
+		public readonly string $type,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php b/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php
new file mode 100644
index 00000000000..ae37a02ed95
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a query's return type as a Relay-style connection of the given node type.
+ *
+ * Applied to the `execute()` method of a query class that returns a `Connection`.
+ * The builder uses this to generate the corresponding connection and edge GraphQL
+ * types (e.g. `CouponConnection`, `CouponEdge`) and to wire the correct return
+ * type in the schema.
+ */
+#[Attribute]
+final class ConnectionOf {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $type The fully-qualified class name of the node type
+	 *                     (e.g. `Coupon::class`).
+	 */
+	public function __construct(
+		public readonly string $type,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Deprecated.php b/plugins/woocommerce/src/Api/Attributes/Deprecated.php
new file mode 100644
index 00000000000..5cf48e0af38
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Deprecated.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a field or enum value as deprecated in the GraphQL schema.
+ *
+ * Deprecated elements remain functional but are flagged with a deprecation
+ * reason in introspection, signaling to API consumers that they should
+ * migrate to an alternative.
+ */
+#[Attribute]
+final class Deprecated {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $reason A human-readable explanation of why the element is
+	 *                       deprecated and what to use instead.
+	 */
+	public function __construct(
+		public readonly string $reason,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Description.php b/plugins/woocommerce/src/Api/Attributes/Description.php
new file mode 100644
index 00000000000..1cc20cf6672
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Description.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Provides a human-readable description for the annotated element.
+ *
+ * Can be applied to classes (types, queries, mutations, enums), properties, or
+ * parameters. The text is exposed as the "description" field in the generated
+ * GraphQL schema and is visible in tools like GraphiQL.
+ */
+#[Attribute]
+final class Description {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $description The text to expose as the GraphQL description.
+	 */
+	public function __construct(
+		public readonly string $description,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Ignore.php b/plugins/woocommerce/src/Api/Attributes/Ignore.php
new file mode 100644
index 00000000000..df09b650e6a
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Ignore.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Tells the builder to skip the annotated element entirely.
+ *
+ * Apply to a class to exclude it from API discovery (e.g. helper classes that
+ * live in a scanned namespace but are not part of the API), or to a property
+ * to omit it from the generated GraphQL type.
+ */
+#[Attribute]
+final class Ignore {
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Name.php b/plugins/woocommerce/src/Api/Attributes/Name.php
new file mode 100644
index 00000000000..a0c5f3d325b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Name.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Overrides the GraphQL name derived from the PHP class or property name.
+ *
+ * By default the builder converts PHP names to GraphQL conventions automatically.
+ * Use this attribute when you need a specific GraphQL name that differs from
+ * the default conversion (e.g. a legacy name for backwards compatibility).
+ */
+#[Attribute]
+final class Name {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $name The exact name to use in the GraphQL schema.
+	 */
+	public function __construct(
+		public readonly string $name,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Parameter.php b/plugins/woocommerce/src/Api/Attributes/Parameter.php
new file mode 100644
index 00000000000..c6d7ec15c5c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Parameter.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares an explicit GraphQL argument for a query or mutation.
+ *
+ * Use this when the argument cannot be inferred from the `execute()` method
+ * signature — for example, when a parameter needs a specific GraphQL type,
+ * nullability, or default that differs from what reflection would produce.
+ * This attribute is repeatable: apply it once per argument.
+ */
+#[Attribute( Attribute::TARGET_ALL | Attribute::IS_REPEATABLE )]
+final class Parameter {
+	/**
+	 * Whether a default value was provided.
+	 *
+	 * @var bool
+	 */
+	public readonly bool $has_default;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string $name        The GraphQL argument name (not needed when unrolling).
+	 * @param string $type        The PHP type name ('int', 'string', 'float', 'bool')
+	 *                            or a fully-qualified class name for complex types.
+	 * @param bool   $nullable    Whether the argument accepts null.
+	 * @param bool   $array       Whether the argument is a list (e.g. `[Int!]`).
+	 * @param mixed  $default     The default value if the argument is omitted.
+	 * @param string $description Human-readable description for the schema.
+	 * @param bool   $has_default Set to true to explicitly indicate a default is
+	 *                            provided (needed when the default value is null).
+	 * @param bool   $unroll      When true, the class given in $type is expanded into
+	 *                            individual GraphQL arguments (one per public property).
+	 */
+	public function __construct(
+		public readonly string $name = '',
+		public readonly string $type = '',
+		public readonly bool $nullable = false,
+		public readonly bool $array = false,
+		public readonly mixed $default = null,
+		public readonly string $description = '',
+		bool $has_default = false,
+		public readonly bool $unroll = false,
+	) {
+		// We need a separate flag because null could be a valid default value.
+		// Callers pass has_default: true when they supply a default, or we infer
+		// it from the default value being non-null.
+		$this->has_default = $has_default || null !== $default;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php b/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php
new file mode 100644
index 00000000000..0ee3816d420
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Adds a description to a query/mutation argument without overriding its type.
+ *
+ * Sets the description for a query/mutation argument. Can be used both for
+ * arguments inferred from the `execute()` method signature and for arguments
+ * declared via #[Parameter]. However, a parameter must not have a description
+ * in both #[Parameter] and #[ParameterDescription] — that is a build error.
+ * This attribute is repeatable: apply it once per argument that needs a
+ * description.
+ */
+#[Attribute( Attribute::TARGET_ALL | Attribute::IS_REPEATABLE )]
+final class ParameterDescription {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $name        The argument name (must match the `execute()`
+	 *                            parameter name).
+	 * @param string $description Human-readable description for the schema.
+	 */
+	public function __construct(
+		public readonly string $name,
+		public readonly string $description,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
new file mode 100644
index 00000000000..ef9b5d51257
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a query or mutation as publicly accessible without authentication.
+ *
+ * When present, the generated resolver skips all capability checks, allowing
+ * any user (including unauthenticated visitors) to execute the operation.
+ *
+ * Mutually exclusive with #[RequiredCapability] on the same class.
+ */
+#[Attribute( Attribute::TARGET_CLASS )]
+final class PublicAccess {
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
new file mode 100644
index 00000000000..27459014277
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares a WordPress capability required to execute a query or mutation.
+ *
+ * The generated resolver checks `current_user_can()` for every declared
+ * capability before invoking the command. If any check fails, an
+ * UNAUTHORIZED error is returned. This attribute is repeatable: apply it
+ * multiple times to require several capabilities.
+ *
+ * Mutually exclusive with #[PublicAccess] on the same class.
+ */
+#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS )]
+final class RequiredCapability {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $capability A WordPress capability slug
+	 *                           (e.g. 'manage_woocommerce').
+	 */
+	public function __construct(
+		public readonly string $capability,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ReturnType.php b/plugins/woocommerce/src/Api/Attributes/ReturnType.php
new file mode 100644
index 00000000000..e2eae114d37
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ReturnType.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares the GraphQL return type of execute() when it returns an interface.
+ *
+ * Since PHP cannot type-hint a trait, the execute() method uses `object` as its
+ * return type and this attribute tells the builder which interface type to use
+ * in the schema. The GraphQL engine then uses the interface's `resolveType`
+ * callback to determine the concrete type at runtime.
+ */
+#[Attribute( Attribute::TARGET_METHOD )]
+final class ReturnType {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $type The fully-qualified class name of the interface trait
+	 *                     (e.g. `ApiObject::class`).
+	 */
+	public function __construct(
+		public readonly string $type,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ScalarType.php b/plugins/woocommerce/src/Api/Attributes/ScalarType.php
new file mode 100644
index 00000000000..074b759179e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ScalarType.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Overrides the GraphQL type for a property with a custom scalar.
+ *
+ * By default the builder maps PHP types to built-in GraphQL scalars (String,
+ * Int, Float, Boolean). Use this attribute when a property should use a custom
+ * scalar type instead, such as `DateTime`.
+ *
+ * Example: `#[ScalarType(DateTime::class)]` on a `?string $date_created`
+ * property produces the GraphQL type `DateTime` instead of `String`.
+ */
+#[Attribute]
+final class ScalarType {
+	/**
+	 * Constructor.
+	 *
+	 * @param string $type The fully-qualified class name of the custom scalar
+	 *                     (e.g. `DateTime::class`).
+	 */
+	public function __construct(
+		public readonly string $type,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Unroll.php b/plugins/woocommerce/src/Api/Attributes/Unroll.php
new file mode 100644
index 00000000000..478ee9b79a0
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Unroll.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Expands a class's properties into individual flat GraphQL arguments.
+ *
+ * When applied to a class, any `execute()` parameter of that type is
+ * automatically unrolled. When applied to a specific `execute()` parameter,
+ * only that usage is unrolled.
+ *
+ * Each public property of the target class becomes a separate GraphQL argument.
+ * Properties marked with #[Ignore] are skipped, and #[Description] on
+ * properties is forwarded to the generated argument descriptions.
+ *
+ * The generated resolver constructs the original class via its constructor,
+ * passing the individual argument values as named parameters.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PARAMETER )]
+final class Unroll {
+}
diff --git a/plugins/woocommerce/src/Api/AuthorizationException.php b/plugins/woocommerce/src/Api/AuthorizationException.php
new file mode 100644
index 00000000000..d8cc405d2b4
--- /dev/null
+++ b/plugins/woocommerce/src/Api/AuthorizationException.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown from an authorize() method to deny access with a custom error message.
+ *
+ * Uses a fixed UNAUTHORIZED error code and 401 status. The message defaults to
+ * a generic denial but can be overridden for more specific feedback.
+ */
+class AuthorizationException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message  The error message.
+	 * @param ?\Throwable $previous The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'You do not have permission to perform this action.',
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'UNAUTHORIZED', array(), 401, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php b/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php
new file mode 100644
index 00000000000..106cc6e2001
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The publication status of a coupon.' )]
+enum CouponStatus: string {
+	#[Description( 'The coupon is published and active.' )]
+	case Published = 'publish';
+
+	#[Description( 'The coupon is a draft.' )]
+	case Draft = 'draft';
+
+	#[Description( 'The coupon is pending review.' )]
+	case Pending = 'pending';
+
+	#[Description( 'The coupon is privately published.' )]
+	case Private = 'private';
+
+	#[Description( 'The coupon is scheduled to be published in the future.' )]
+	case Future = 'future';
+
+	#[Description( 'The coupon is in the trash.' )]
+	case Trash = 'trash';
+
+	#[Description( 'The coupon status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.' )]
+	case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php b/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php
new file mode 100644
index 00000000000..6ac7e353b90
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The type of discount for a coupon.' )]
+enum DiscountType: string {
+	#[Description( 'A percentage discount.' )]
+	case Percent = 'percent';
+
+	#[Description( 'A fixed amount discount applied to the cart.' )]
+	case FixedCart = 'fixed_cart';
+
+	#[Description( 'A fixed amount discount applied to each eligible product.' )]
+	case FixedProduct = 'fixed_product';
+
+	#[Description( 'The discount type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_discount_type for the underlying value.' )]
+	case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php b/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php
new file mode 100644
index 00000000000..0c64e7bb2d0
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+#[Description( 'The publication status of a product.' )]
+enum ProductStatus: string {
+	#[Description( 'The product is a draft.' )]
+	case Draft = 'draft';
+
+	#[Description( 'The product is pending review.' )]
+	case Pending = 'pending';
+
+	#[Name( 'ACTIVE' )]
+	#[Description( 'The product is published and visible.' )]
+	case Published = 'publish';
+
+	#[Description( 'The product is privately published.' )]
+	case Private = 'private';
+
+	#[Description( 'The product is scheduled to be published in the future.' )]
+	case Future = 'future';
+
+	#[Deprecated( 'Trashed products should be excluded via status filter.' )]
+	#[Description( 'The product is in the trash.' )]
+	case Trash = 'trash';
+
+	#[Description( 'The product status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.' )]
+	case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/ProductType.php b/plugins/woocommerce/src/Api/Enums/Products/ProductType.php
new file mode 100644
index 00000000000..32a94190596
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/ProductType.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The type of a WooCommerce product.' )]
+enum ProductType: string {
+	#[Description( 'A simple product.' )]
+	case Simple = 'simple';
+
+	#[Description( 'A grouped product.' )]
+	case Grouped = 'grouped';
+
+	#[Description( 'An external/affiliate product.' )]
+	case External = 'external';
+
+	#[Description( 'A variable product with variations.' )]
+	case Variable = 'variable';
+
+	#[Description( 'A product variation.' )]
+	case Variation = 'variation';
+
+	#[Description( 'The product type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_product_type for the underlying value.' )]
+	case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php b/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php
new file mode 100644
index 00000000000..8e1a6c3ebc7
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The stock status of a product.' )]
+enum StockStatus: int {
+	#[Description( 'The product is in stock.' )]
+	case InStock = 1;
+
+	#[Description( 'The product is out of stock.' )]
+	case OutOfStock = 2;
+
+	#[Description( 'The product is on backorder.' )]
+	case OnBackorder = 3;
+
+	#[Description( 'The stock status is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_stock_status for the underlying value.' )]
+	case Other = 4;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php b/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php
new file mode 100644
index 00000000000..1ec7ea7c38f
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for creating a coupon.
+ */
+#[Description( 'Data required to create a new coupon.' )]
+class CreateCouponInput {
+	use TracksProvidedFields;
+
+	#[Description( 'The coupon code.' )]
+	public string $code;
+
+	#[Description( 'The coupon description.' )]
+	public ?string $description = null;
+
+	#[Description( 'The type of discount.' )]
+	public ?DiscountType $discount_type = null;
+
+	#[Description( 'The discount amount.' )]
+	public ?float $amount = null;
+
+	#[Description( 'The coupon status.' )]
+	public ?CouponStatus $status = null;
+
+	#[Description( 'The date the coupon expires (ISO 8601).' )]
+	public ?string $date_expires = null;
+
+	#[Description( 'Whether the coupon can only be used alone.' )]
+	public ?bool $individual_use = null;
+
+	#[Description( 'Product IDs the coupon can be applied to.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $product_ids = null;
+
+	#[Description( 'Product IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $excluded_product_ids = null;
+
+	#[Description( 'Maximum number of times the coupon can be used in total.' )]
+	public ?int $usage_limit = null;
+
+	#[Description( 'Maximum number of times the coupon can be used per customer.' )]
+	public ?int $usage_limit_per_user = null;
+
+	#[Description( 'Maximum number of items the coupon can be applied to.' )]
+	public ?int $limit_usage_to_x_items = null;
+
+	#[Description( 'Whether the coupon grants free shipping.' )]
+	public ?bool $free_shipping = null;
+
+	#[Description( 'Product category IDs the coupon applies to.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $product_categories = null;
+
+	#[Description( 'Product category IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $excluded_product_categories = null;
+
+	#[Description( 'Whether the coupon excludes items on sale.' )]
+	public ?bool $exclude_sale_items = null;
+
+	#[Description( 'Minimum order amount required to use the coupon.' )]
+	public ?float $minimum_amount = null;
+
+	#[Description( 'Maximum order amount allowed to use the coupon.' )]
+	public ?float $maximum_amount = null;
+
+	#[Description( 'Email addresses that can use this coupon.' )]
+	#[ArrayOf( 'string' )]
+	public ?array $email_restrictions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php b/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php
new file mode 100644
index 00000000000..587ec9e0764
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for updating a coupon.
+ */
+#[Description( 'Data for updating an existing coupon. All fields are optional.' )]
+class UpdateCouponInput {
+	use TracksProvidedFields;
+
+	#[Description( 'The ID of the coupon to update.' )]
+	public int $id;
+
+	#[Description( 'The coupon code.' )]
+	public ?string $code = null;
+
+	#[Description( 'The coupon description.' )]
+	public ?string $description = null;
+
+	#[Description( 'The type of discount.' )]
+	public ?DiscountType $discount_type = null;
+
+	#[Description( 'The discount amount.' )]
+	public ?float $amount = null;
+
+	#[Description( 'The coupon status.' )]
+	public ?CouponStatus $status = null;
+
+	#[Description( 'The date the coupon expires (ISO 8601).' )]
+	public ?string $date_expires = null;
+
+	#[Description( 'Whether the coupon can only be used alone.' )]
+	public ?bool $individual_use = null;
+
+	#[Description( 'Product IDs the coupon can be applied to.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $product_ids = null;
+
+	#[Description( 'Product IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $excluded_product_ids = null;
+
+	#[Description( 'Maximum number of times the coupon can be used in total.' )]
+	public ?int $usage_limit = null;
+
+	#[Description( 'Maximum number of times the coupon can be used per customer.' )]
+	public ?int $usage_limit_per_user = null;
+
+	#[Description( 'Maximum number of items the coupon can be applied to.' )]
+	public ?int $limit_usage_to_x_items = null;
+
+	#[Description( 'Whether the coupon grants free shipping.' )]
+	public ?bool $free_shipping = null;
+
+	#[Description( 'Product category IDs the coupon applies to.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $product_categories = null;
+
+	#[Description( 'Product category IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public ?array $excluded_product_categories = null;
+
+	#[Description( 'Whether the coupon excludes items on sale.' )]
+	public ?bool $exclude_sale_items = null;
+
+	#[Description( 'Minimum order amount required to use the coupon.' )]
+	public ?float $minimum_amount = null;
+
+	#[Description( 'Maximum order amount allowed to use the coupon.' )]
+	public ?float $maximum_amount = null;
+
+	#[Description( 'Email addresses that can use this coupon.' )]
+	#[ArrayOf( 'string' )]
+	public ?array $email_restrictions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php
new file mode 100644
index 00000000000..5dca72068ac
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Shared fields for product creation and update input types.
+ */
+abstract class BaseProductInput {
+	use TracksProvidedFields;
+
+	#[Description( 'The product slug.' )]
+	public ?string $slug = null;
+
+	#[Description( 'The product SKU.' )]
+	public ?string $sku = null;
+
+	#[Description( 'The full product description.' )]
+	public ?string $description = null;
+
+	#[Description( 'The short product description.' )]
+	public ?string $short_description = null;
+
+	#[Description( 'The product status.' )]
+	public ?ProductStatus $status = null;
+
+	#[Description( 'The product type.' )]
+	public ?ProductType $product_type = null;
+
+	#[Description( 'The regular price.' )]
+	public ?float $regular_price = null;
+
+	#[Description( 'The sale price.' )]
+	public ?float $sale_price = null;
+
+	#[Description( 'Whether to manage stock.' )]
+	public ?bool $manage_stock = null;
+
+	#[Description( 'The number of items in stock.' )]
+	public ?int $stock_quantity = null;
+
+	#[Description( 'The product dimensions.' )]
+	public ?DimensionsInput $dimensions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php
new file mode 100644
index 00000000000..b115190532b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Input type for creating a product.
+ */
+#[Description( 'Data required to create a new product.' )]
+class CreateProductInput extends BaseProductInput {
+	#[Description( 'The product name.' )]
+	public string $name;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php
new file mode 100644
index 00000000000..f1b50053374
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for product dimensions.
+ */
+#[Description( 'Physical dimensions and weight for a product.' )]
+class DimensionsInput {
+	use TracksProvidedFields;
+
+	#[Description( 'The product length.' )]
+	public ?float $length = null;
+
+	#[Description( 'The product width.' )]
+	public ?float $width = null;
+
+	#[Description( 'The product height.' )]
+	public ?float $height = null;
+
+	#[Description( 'The product weight.' )]
+	public ?float $weight = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php
new file mode 100644
index 00000000000..0b9f0802e4c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for filtering products.
+ *
+ * Used with parameter-level #[Unroll] to expand fields as direct query arguments.
+ * Uses constructor promotion so the builder can instantiate it via named arguments.
+ */
+#[Description( 'Filter criteria for listing products.' )]
+class ProductFilterInput {
+	use TracksProvidedFields;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param ?ProductStatus $status       Filter by product status.
+	 * @param ?StockStatus   $stock_status Filter by stock status.
+	 * @param ?string        $search       Search products by keyword.
+	 */
+	public function __construct(
+		#[Description( 'Filter by product status.' )]
+		public readonly ?ProductStatus $status = null,
+		#[Description( 'Filter by stock status.' )]
+		public readonly ?StockStatus $stock_status = null,
+		#[Description( 'Search products by keyword.' )]
+		public readonly ?string $search = null,
+	) {
+	}
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php
new file mode 100644
index 00000000000..4f0e13f5b3c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Input type for updating a product.
+ */
+#[Description( 'Data for updating an existing product.' )]
+class UpdateProductInput extends BaseProductInput {
+	#[Description( 'The ID of the product to update.' )]
+	public int $id;
+
+	#[Description( 'The product name.' )]
+	public ?string $name = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php b/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php
new file mode 100644
index 00000000000..fdb8beb78fc
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes;
+
+/**
+ * Trait for input types to track which fields were explicitly provided in the GraphQL request.
+ *
+ * This allows mutations to distinguish between a field being missing (don't change it)
+ * and explicitly set to null (clear it).
+ */
+trait TracksProvidedFields {
+	/**
+	 * Fields that were explicitly provided in the input.
+	 *
+	 * Using an underscore prefix to keep it invisible to the ApiBuilder
+	 * (which only scans public properties for GraphQL fields).
+	 *
+	 * @var array<string, true>
+	 */
+	protected array $provided_fields = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase -- internal tracking array
+
+	/**
+	 * Mark a field as explicitly provided in the input.
+	 *
+	 * @param string $field The field name.
+	 */
+	public function mark_provided( string $field ): void {
+		$this->provided_fields[ $field ] = true;
+	}
+
+	/**
+	 * Check whether a field was explicitly provided in the input.
+	 *
+	 * @param string $field The field name.
+	 * @return bool
+	 */
+	public function was_provided( string $field ): bool {
+		return isset( $this->provided_fields[ $field ] );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php b/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php
new file mode 100644
index 00000000000..18254488856
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Interface trait for objects that have a numeric ID.
+ */
+#[Description( 'An object with a numeric ID.' )]
+trait ObjectWithId {
+	/**
+	 * The unique numeric identifier.
+	 *
+	 * @var int
+	 */
+	#[Description( 'The unique numeric identifier.' )]
+	public int $id;
+}
diff --git a/plugins/woocommerce/src/Api/Interfaces/Product.php b/plugins/woocommerce/src/Api/Interfaces/Product.php
new file mode 100644
index 00000000000..a4a3f1443c8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Interfaces/Product.php
@@ -0,0 +1,212 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+use Automattic\WooCommerce\Api\Types\Products\ProductDimensions;
+use Automattic\WooCommerce\Api\Types\Products\ProductAttribute;
+use Automattic\WooCommerce\Api\Types\Products\ProductImage;
+use Automattic\WooCommerce\Api\Types\Products\ProductReview;
+
+/**
+ * Interface trait for WooCommerce products.
+ *
+ * Defines the common fields shared by all product types.
+ */
+#[Name( 'Product' )]
+#[Description( 'A WooCommerce product.' )]
+trait Product {
+	use ObjectWithId;
+
+	/**
+	 * The product name.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The product name.' )]
+	public string $name;
+
+	/**
+	 * The product slug.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The product slug.' )]
+	public string $slug;
+
+	/**
+	 * The product SKU.
+	 *
+	 * @var ?string
+	 */
+	#[Description( 'The product SKU.' )]
+	public ?string $sku;
+
+	/**
+	 * The full product description.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The full product description.' )]
+	public string $description;
+
+	/**
+	 * The short product description.
+	 *
+	 * @var string
+	 */
+	#[Deprecated( 'Use description instead.' )]
+	#[Description( 'The short product description.' )]
+	public string $short_description;
+
+	/**
+	 * The product status.
+	 *
+	 * @var ProductStatus
+	 */
+	#[Description( 'The product status.' )]
+	public ProductStatus $status;
+
+	/**
+	 * The raw status as stored in WordPress. Useful when status is OTHER.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).' )]
+	public string $raw_status;
+
+	/**
+	 * The product type.
+	 *
+	 * @var ProductType
+	 */
+	#[Description( 'The product type.' )]
+	public ProductType $product_type;
+
+	/**
+	 * The raw product type as stored in WooCommerce. Useful when product_type is OTHER.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).' )]
+	public string $raw_product_type;
+
+	/**
+	 * The regular price of the product. Null when not set.
+	 *
+	 * @var ?string
+	 */
+	#[Description( 'The regular price of the product. Null when not set.' )]
+	#[Parameter( name: 'formatted', type: 'bool', default: true, description: 'Whether to apply currency formatting.' )]
+	public ?string $regular_price;
+
+	/**
+	 * The sale price of the product.
+	 *
+	 * @var ?string
+	 */
+	#[Description( 'The sale price of the product.' )]
+	#[Parameter( name: 'formatted', type: 'bool', default: true )]
+	#[ParameterDescription( name: 'formatted', description: 'When true, returns price with currency symbol.' )]
+	public ?string $sale_price;
+
+	/**
+	 * The stock status of the product.
+	 *
+	 * @var StockStatus
+	 */
+	#[Description( 'The stock status of the product.' )]
+	public StockStatus $stock_status;
+
+	/**
+	 * The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER.
+	 *
+	 * @var string
+	 */
+	#[Description( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).' )]
+	public string $raw_stock_status;
+
+	/**
+	 * The number of items in stock.
+	 *
+	 * @var ?int
+	 */
+	#[Description( 'The number of items in stock.' )]
+	public ?int $stock_quantity;
+
+	/**
+	 * The product dimensions.
+	 *
+	 * @var ?ProductDimensions
+	 */
+	#[Description( 'The product dimensions.' )]
+	public ?ProductDimensions $dimensions;
+
+	/**
+	 * The product images.
+	 *
+	 * @var ProductImage[]
+	 */
+	#[Description( 'The product images.' )]
+	#[ArrayOf( ProductImage::class )]
+	public array $images;
+
+	/**
+	 * The product attributes.
+	 *
+	 * @var ProductAttribute[]
+	 */
+	#[Description( 'The product attributes.' )]
+	#[ArrayOf( ProductAttribute::class )]
+	public array $attributes;
+
+	/**
+	 * Customer reviews for this product.
+	 *
+	 * @var Connection
+	 */
+	#[Description( 'Customer reviews for this product.' )]
+	#[ConnectionOf( ProductReview::class )]
+	public Connection $reviews;
+
+	/**
+	 * The date the product was created.
+	 *
+	 * @var ?string
+	 */
+	#[Description( 'The date the product was created.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_created;
+
+	/**
+	 * The date the product was last modified.
+	 *
+	 * @var ?string
+	 */
+	#[Description( 'The date the product was last modified.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_modified;
+
+	/**
+	 * Internal notes (ignored in schema).
+	 *
+	 * @var ?string
+	 */
+	#[Ignore]
+	public ?string $internal_notes;
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php
new file mode 100644
index 00000000000..d5f60c351db
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Mutation to create a new coupon.
+ */
+#[Description( 'Create a new coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class CreateCoupon {
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param CreateCouponInput $input The coupon creation data.
+	 * @return Coupon
+	 */
+	public function execute(
+		#[Description( 'Data for the new coupon.' )]
+		CreateCouponInput $input,
+	): Coupon {
+		$wc_coupon = new \WC_Coupon();
+		$wc_coupon->set_code( $input->code );
+
+		foreach ( array( 'description', 'amount', 'date_expires', 'individual_use', 'product_ids', 'excluded_product_ids', 'usage_limit', 'usage_limit_per_user', 'limit_usage_to_x_items', 'free_shipping', 'product_categories', 'excluded_product_categories', 'exclude_sale_items', 'minimum_amount', 'maximum_amount', 'email_restrictions' ) as $field ) {
+			if ( null !== $input->$field ) {
+				$wc_coupon->{"set_{$field}"}( $input->$field );
+			}
+		}
+
+		if ( null !== $input->discount_type ) {
+			$wc_coupon->set_discount_type( $input->discount_type->value );
+		}
+		if ( null !== $input->status ) {
+			$wc_coupon->set_status( $input->status->value );
+		}
+
+		$wc_coupon->save();
+
+		return CouponMapper::from_wc_coupon( $wc_coupon );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php
new file mode 100644
index 00000000000..fbe77a0189d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Types\Coupons\DeleteCouponResult;
+
+/**
+ * Mutation to delete a coupon.
+ */
+#[Description( 'Delete a coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class DeleteCoupon {
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param int  $id    The coupon ID.
+	 * @param bool $force Whether to permanently delete.
+	 * @return DeleteCouponResult
+	 * @throws ApiException When the coupon is not found.
+	 */
+	public function execute(
+		#[Description( 'The ID of the coupon to delete.' )]
+		int $id,
+		#[Description( 'Whether to permanently delete the coupon (bypass trash).' )]
+		bool $force = false,
+	): DeleteCouponResult {
+		$wc_coupon = new \WC_Coupon( $id );
+
+		if ( ! $wc_coupon->get_id() ) {
+			throw new ApiException( 'Coupon not found.', 'NOT_FOUND', status_code: 404 );
+		}
+
+		// Capture the raw return value. A `(bool)` cast would coerce
+		// filter-originated `WP_Error` objects to `true`, reporting failure
+		// as success; we need to detect that case explicitly and surface
+		// the underlying error instead.
+		$deleted = $wc_coupon->delete( $force );
+
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+		if ( $deleted instanceof \WP_Error ) {
+			throw new ApiException(
+				$deleted->get_error_message(),
+				'INTERNAL_ERROR',
+				status_code: 500,
+			);
+		}
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+		$result          = new DeleteCouponResult();
+		$result->id      = $id;
+		$result->deleted = true === $deleted;
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php
new file mode 100644
index 00000000000..6ee4f522f28
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Mutation to update an existing coupon.
+ */
+#[Description( 'Update an existing coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class UpdateCoupon {
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param UpdateCouponInput $input The fields to update.
+	 * @return Coupon
+	 * @throws ApiException When the coupon is not found.
+	 */
+	public function execute(
+		#[Description( 'The fields to update.' )]
+		UpdateCouponInput $input,
+	): Coupon {
+		$wc_coupon = new \WC_Coupon( $input->id );
+
+		if ( ! $wc_coupon->get_id() ) {
+			throw new ApiException( 'Coupon not found.', 'NOT_FOUND', status_code: 404 );
+		}
+
+		foreach ( array( 'code', 'description', 'amount', 'date_expires', 'individual_use', 'product_ids', 'excluded_product_ids', 'usage_limit', 'usage_limit_per_user', 'limit_usage_to_x_items', 'free_shipping', 'product_categories', 'excluded_product_categories', 'exclude_sale_items', 'minimum_amount', 'maximum_amount', 'email_restrictions' ) as $field ) {
+			if ( $input->was_provided( $field ) ) {
+				$wc_coupon->{"set_{$field}"}( $input->$field );
+			}
+		}
+
+		// Nullable enums: only invoke the setter when the client supplied a
+		// non-null value. An explicit null means "ignore this field" here —
+		// WC_Coupon's enum setters don't accept null and would fall back to
+		// their defaults (e.g. 'fixed_cart' for discount_type), silently
+		// overwriting whatever is already on the coupon.
+		if ( $input->was_provided( 'discount_type' ) && null !== $input->discount_type ) {
+			$wc_coupon->set_discount_type( $input->discount_type->value );
+		}
+		if ( $input->was_provided( 'status' ) && null !== $input->status ) {
+			$wc_coupon->set_status( $input->status->value );
+		}
+
+		$wc_coupon->save();
+
+		return CouponMapper::from_wc_coupon( $wc_coupon );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php
new file mode 100644
index 00000000000..13cb0574277
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Traits\RequiresManageWoocommerce;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+use Automattic\WooCommerce\Api\Utils\Products\ProductRepository;
+
+/**
+ * Mutation to create a new product.
+ *
+ * Demonstrates: DI via init(), inherited capability (trait), ApiException with extensions.
+ */
+#[Description( 'Create a new product.' )]
+#[RequiredCapability( 'edit_products' )]
+class CreateProduct {
+	use RequiresManageWoocommerce;
+
+	/**
+	 * The product repository.
+	 *
+	 * @var ProductRepository
+	 */
+	private ProductRepository $repository;
+
+	/**
+	 * Inject dependencies via the DI container.
+	 *
+	 * @internal
+	 *
+	 * @param ProductRepository $repository The product repository.
+	 */
+	final public function init( ProductRepository $repository ): void {
+		$this->repository = $repository;
+	}
+
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param CreateProductInput $input The product creation data.
+	 * @return object
+	 * @throws ApiException When validation fails.
+	 */
+	#[ReturnType( Product::class )]
+	public function execute(
+		#[Description( 'Data for the new product.' )]
+		CreateProductInput $input,
+	): object {
+		// Best-effort duplicate-name check. There is an inherent TOCTOU race
+		// here: two nearly-simultaneous requests with the same name can both
+		// pass this check and both succeed in creating the product, because
+		// wp_posts.post_title is not a unique column in the schema and WP
+		// offers no portable atomic "reserve name" primitive. Locking via
+		// wp_cache_add() would help only on sites with a persistent object
+		// cache (Redis/Memcached), so we do not rely on it here. If strict
+		// uniqueness is ever required, callers should enforce it at a
+		// higher layer (e.g. a mutex around the REST handler) rather than
+		// assume the API guarantees it.
+		$existing = new \WP_Query(
+			array(
+				'post_type'   => 'product',
+				'title'       => $input->name,
+				'post_status' => array( 'publish', 'draft', 'pending', 'private' ),
+				'fields'      => 'ids',
+			)
+		);
+
+		if ( $existing->found_posts > 0 ) {
+			throw new ApiException(
+				'A product with this name already exists.',
+				'VALIDATION_ERROR',
+				array( 'field' => 'name' ),
+				422,
+			);
+		}
+
+		$wc_product = new \WC_Product();
+		$wc_product->set_name( $input->name );
+
+		foreach ( array( 'slug', 'sku', 'description', 'short_description', 'manage_stock', 'stock_quantity' ) as $field ) {
+			if ( null !== $input->$field ) {
+				$wc_product->{"set_{$field}"}( $input->$field );
+			}
+		}
+
+		foreach ( array( 'regular_price', 'sale_price' ) as $field ) {
+			if ( null !== $input->$field ) {
+				$wc_product->{"set_{$field}"}( (string) $input->$field );
+			}
+		}
+
+		if ( null !== $input->status ) {
+			$wc_product->set_status( $input->status->value );
+		}
+
+		if ( null !== $input->dimensions ) {
+			foreach ( array( 'length', 'width', 'height', 'weight' ) as $field ) {
+				if ( null !== $input->dimensions->$field ) {
+					$wc_product->{"set_{$field}"}( (string) $input->dimensions->$field );
+				}
+			}
+		}
+
+		$this->repository->save( $wc_product );
+
+		return ProductMapper::from_wc_product( $wc_product );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php
new file mode 100644
index 00000000000..270d238c2d3
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Mutation to delete a product.
+ *
+ * Demonstrates: mutation returning bool.
+ */
+#[Description( 'Delete a product.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class DeleteProduct {
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param int  $id    The product ID.
+	 * @param bool $force Whether to permanently delete (bypass trash).
+	 * @return bool Whether the product was deleted.
+	 * @throws ApiException When the product is not found.
+	 */
+	public function execute(
+		#[Description( 'The ID of the product to delete.' )]
+		int $id,
+		#[Description( 'Whether to permanently delete the product (bypass trash).' )]
+		bool $force = false,
+	): bool {
+		$wc_product = wc_get_product( $id );
+
+		if ( ! $wc_product instanceof \WC_Product ) {
+			throw new ApiException( 'Product not found.', 'NOT_FOUND', status_code: 404 );
+		}
+
+		// Capture the raw return value. A `(bool)` cast would coerce
+		// filter-originated `WP_Error` objects to `true`, reporting failure
+		// as success; we need to detect that case explicitly and surface
+		// the underlying error instead.
+		$deleted = $wc_product->delete( $force );
+
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+		if ( $deleted instanceof \WP_Error ) {
+			throw new ApiException(
+				$deleted->get_error_message(),
+				'INTERNAL_ERROR',
+				status_code: 500,
+			);
+		}
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+		return true === $deleted;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php
new file mode 100644
index 00000000000..0ae5880949d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Mutation to update an existing product.
+ */
+#[Description( 'Update an existing product.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class UpdateProduct {
+	/**
+	 * Execute the mutation.
+	 *
+	 * @param UpdateProductInput $input The fields to update.
+	 * @return object
+	 * @throws ApiException When the product is not found.
+	 */
+	#[ReturnType( Product::class )]
+	public function execute(
+		#[Description( 'The fields to update.' )]
+		UpdateProductInput $input,
+	): object {
+		$wc_product = wc_get_product( $input->id );
+
+		if ( ! $wc_product instanceof \WC_Product ) {
+			throw new ApiException( 'Product not found.', 'NOT_FOUND', status_code: 404 );
+		}
+
+		foreach ( array( 'name', 'slug', 'sku', 'description', 'short_description', 'manage_stock', 'stock_quantity' ) as $field ) {
+			if ( $input->was_provided( $field ) ) {
+				$wc_product->{"set_{$field}"}( $input->$field );
+			}
+		}
+
+		foreach ( array( 'regular_price', 'sale_price' ) as $field ) {
+			if ( $input->was_provided( $field ) ) {
+				$wc_product->{"set_{$field}"}( null !== $input->$field ? (string) $input->$field : '' );
+			}
+		}
+
+		// Nullable enum: only invoke the setter when the client supplied a
+		// non-null value. An explicit null means "ignore this field" here —
+		// WC_Product's set_status doesn't accept null and would fall back
+		// to a default, silently overwriting whatever is already on the
+		// product.
+		if ( $input->was_provided( 'status' ) && null !== $input->status ) {
+			$wc_product->set_status( $input->status->value );
+		}
+
+		if ( $input->was_provided( 'dimensions' ) ) {
+			foreach ( array( 'length', 'width', 'height', 'weight' ) as $field ) {
+				if ( $input->dimensions->was_provided( $field ) ) {
+					$wc_product->{"set_{$field}"}( null !== $input->dimensions->$field ? (string) $input->dimensions->$field : '' );
+				}
+			}
+		}
+
+		$wc_product->save();
+
+		return ProductMapper::from_wc_product( $wc_product );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/Connection.php b/plugins/woocommerce/src/Api/Pagination/Connection.php
new file mode 100644
index 00000000000..de81735d929
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/Connection.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Represents a Relay-style paginated connection.
+ */
+class Connection {
+	/**
+	 * Connection edges wrapping each node with its cursor.
+	 *
+	 * @var Edge[]
+	 */
+	public array $edges;
+
+	/**
+	 * The raw nodes without cursor wrappers.
+	 *
+	 * @var object[]
+	 */
+	public array $nodes;
+
+	public PageInfo $page_info;
+
+	public int $total_count;
+
+	/**
+	 * Whether this connection has already been sliced.
+	 *
+	 * When true, subsequent calls to slice() return $this immediately,
+	 * preventing double-slicing when both the command class and the
+	 * auto-generated resolver call slice().
+	 *
+	 * @var bool
+	 */
+	private bool $sliced = false;
+
+	/**
+	 * Create a pre-sliced connection for the performance path.
+	 *
+	 * Use this when the DB query already applied pagination limits,
+	 * so no further slicing is needed.
+	 *
+	 * @param Edge[]   $edges       The already-paginated edges.
+	 * @param PageInfo $page_info   The pagination info.
+	 * @param int      $total_count The total count before pagination.
+	 * @return self A Connection marked as already sliced.
+	 */
+	public static function pre_sliced( array $edges, PageInfo $page_info, int $total_count ): self {
+		$connection              = new self();
+		$connection->edges       = $edges;
+		$connection->nodes       = array_map( fn( Edge $e ) => $e->node, $edges );
+		$connection->page_info   = $page_info;
+		$connection->total_count = $total_count;
+		$connection->sliced      = true;
+
+		return $connection;
+	}
+
+	/**
+	 * Return a new Connection sliced according to the given pagination args.
+	 *
+	 * Applies the Relay cursor-based pagination algorithm: first narrow by
+	 * after/before cursors, then take first N or last N from the remainder.
+	 *
+	 * @param array $args Pagination arguments with keys: first, last, after, before.
+	 * @return self A new Connection with sliced edges/nodes and updated page_info.
+	 */
+	public function slice( array $args ): self {
+		if ( $this->sliced ) {
+			return $this;
+		}
+
+		// Enforce the same 0..MAX_PAGE_SIZE bounds that PaginationParams
+		// applies to root queries. Without this, nested connection fields
+		// (e.g. `variations(first: 1000)`) would slip past the cap because
+		// the generated resolver passes raw GraphQL args straight in.
+		PaginationParams::validate_args( $args );
+
+		$first  = $args['first'] ?? null;
+		$last   = $args['last'] ?? null;
+		$after  = $args['after'] ?? null;
+		$before = $args['before'] ?? null;
+
+		// No pagination requested — return as-is.
+		if ( null === $first && null === $last && null === $after && null === $before ) {
+			return $this;
+		}
+
+		$edges = $this->edges;
+
+		// Narrow by "after" cursor.
+		if ( null !== $after ) {
+			$found = false;
+			foreach ( $edges as $i => $edge ) {
+				if ( $edge->cursor === $after ) {
+					$edges = array_slice( $edges, $i + 1 );
+					$found = true;
+					break;
+				}
+			}
+			if ( ! $found ) {
+				$edges = array();
+			}
+		}
+
+		// Narrow by "before" cursor.
+		if ( null !== $before ) {
+			$filtered = array();
+			foreach ( $edges as $edge ) {
+				if ( $edge->cursor === $before ) {
+					break;
+				}
+				$filtered[] = $edge;
+			}
+			$edges = $filtered;
+		}
+
+		$total_after_cursors = count( $edges );
+
+		// Apply first/last.
+		if ( null !== $first && $first >= 0 ) {
+			$edges = array_slice( $edges, 0, $first );
+		}
+		if ( null !== $last && $last >= 0 ) {
+			$edges = array_slice( $edges, max( 0, count( $edges ) - $last ) );
+		}
+
+		// Build the sliced connection.
+		$connection              = new self();
+		$connection->edges       = array_values( $edges );
+		$connection->nodes       = array_map( fn( Edge $e ) => $e->node, $edges );
+		$connection->total_count = $this->total_count;
+		$connection->sliced      = true;
+
+		$page_info                    = new PageInfo();
+		$page_info->start_cursor      = ! empty( $edges ) ? $edges[0]->cursor : null;
+		$page_info->end_cursor        = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+		$page_info->has_next_page     = null !== $first ? count( $edges ) < $total_after_cursors : $this->page_info->has_next_page;
+		$page_info->has_previous_page = null !== $last ? count( $edges ) < $total_after_cursors : ( null !== $after );
+		$connection->page_info        = $page_info;
+
+		return $connection;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/Edge.php b/plugins/woocommerce/src/Api/Pagination/Edge.php
new file mode 100644
index 00000000000..8141f707aa3
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/Edge.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Represents an edge in a Relay-style connection.
+ */
+class Edge {
+	public string $cursor;
+
+	public object $node;
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php b/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php
new file mode 100644
index 00000000000..c543a1e918d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+use Automattic\WooCommerce\Api\ApiException;
+
+/**
+ * WP_Query ID-cursor pagination helper.
+ *
+ * Implements cursor-based pagination on the posts ID column by hooking
+ * `posts_where` and reading two custom query vars:
+ *
+ * - `wc_api_after_id`  — emit `AND ID > X` in the SQL WHERE clause.
+ * - `wc_api_before_id` — emit `AND ID < X`.
+ *
+ * Resolvers set whichever of those vars they need on their WP_Query args
+ * and call {@see self::ensure_registered()} once before running the query.
+ * The filter registers itself lazily on first use and short-circuits for
+ * any query that doesn't set these vars, so it's safe to leave in place
+ * for the rest of the request.
+ */
+class IdCursorFilter {
+
+	/**
+	 * Query var for the exclusive lower-bound ID (`ID > X`).
+	 */
+	public const AFTER_ID = 'wc_api_after_id';
+
+	/**
+	 * Query var for the exclusive upper-bound ID (`ID < X`).
+	 */
+	public const BEFORE_ID = 'wc_api_before_id';
+
+	/**
+	 * Whether the posts_where hook is currently registered for this request.
+	 *
+	 * @var bool
+	 */
+	private static bool $registered = false;
+
+	/**
+	 * Register the posts_where filter on first call; no-op thereafter.
+	 *
+	 * The filter is a no-op for queries that don't set the cursor query
+	 * vars, so leaving it registered for the remainder of the request is
+	 * harmless — and it means resolvers never need to clean up after
+	 * themselves, which is how the previous add/remove dance leaked.
+	 */
+	public static function ensure_registered(): void {
+		if ( self::$registered ) {
+			return;
+		}
+		add_filter( 'posts_where', array( self::class, 'apply' ), 10, 2 );
+		self::$registered = true;
+	}
+
+	/**
+	 * Filter callback for `posts_where`. Appends cursor conditions when the
+	 * corresponding query vars are set on the WP_Query; returns the input
+	 * clause unchanged otherwise.
+	 *
+	 * @param string    $where SQL WHERE clause being built.
+	 * @param \WP_Query $query The WP_Query being prepared.
+	 * @return string The modified WHERE clause.
+	 */
+	public static function apply( string $where, \WP_Query $query ): string {
+		$after  = (int) $query->get( self::AFTER_ID );
+		$before = (int) $query->get( self::BEFORE_ID );
+
+		if ( $after <= 0 && $before <= 0 ) {
+			return $where;
+		}
+
+		global $wpdb;
+		if ( $after > 0 ) {
+			$where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $after );
+		}
+		if ( $before > 0 ) {
+			$where .= $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $before );
+		}
+		return $where;
+	}
+
+	/**
+	 * Decode a base64-encoded ID cursor into a positive integer.
+	 *
+	 * Resolvers encode cursors via `base64_encode( (string) $id )` on the
+	 * way out; this is the symmetric decode. `base64_decode(..., true)`
+	 * returns false for malformed input, which `(int)` casts to 0 and
+	 * {@see self::apply()} would silently treat as "no cursor" — leaving
+	 * clients with unfiltered results instead of a clear error. Validate
+	 * explicitly and throw INVALID_ARGUMENT → HTTP 400 on any bad input.
+	 *
+	 * @param string $cursor The client-supplied cursor string.
+	 * @param string $name   Which cursor argument (`after` / `before`), for error messages.
+	 * @return int The decoded positive integer ID.
+	 * @throws ApiException When the cursor isn't a valid base64-encoded positive integer.
+	 */
+	public static function decode_id_cursor( string $cursor, string $name ): int {
+		$raw = base64_decode( $cursor, true );
+		if ( false === $raw || ! ctype_digit( $raw ) || (int) $raw <= 0 ) {
+			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+			throw new ApiException(
+				sprintf( 'Invalid `%s` cursor.', $name ),
+				'INVALID_ARGUMENT',
+				status_code: 400,
+			);
+			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+		}
+		return (int) $raw;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/PageInfo.php b/plugins/woocommerce/src/Api/Pagination/PageInfo.php
new file mode 100644
index 00000000000..e69419aa897
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/PageInfo.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Pagination metadata for a connection.
+ */
+class PageInfo {
+	public bool $has_next_page;
+
+	public bool $has_previous_page;
+
+	public ?string $start_cursor;
+
+	public ?string $end_cursor;
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/PaginationParams.php b/plugins/woocommerce/src/Api/Pagination/PaginationParams.php
new file mode 100644
index 00000000000..6391e5ce6c9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/PaginationParams.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+
+/**
+ * Standard pagination parameters for connection queries.
+ *
+ * Because this class carries #[Unroll], whenever it is used as an execute()
+ * parameter the builder expands its properties into individual GraphQL arguments.
+ */
+#[Unroll]
+class PaginationParams {
+	/**
+	 * Maximum number of items a client may request in a single page.
+	 *
+	 * Requests with `first` or `last` above this value are rejected with an
+	 * INVALID_ARGUMENT error, matching the behavior of common GraphQL APIs
+	 * (e.g. GitHub's 100-item cap).
+	 */
+	public const MAX_PAGE_SIZE = 100;
+
+	/**
+	 * Page size used when neither `first` nor `last` is provided.
+	 */
+	public const DEFAULT_PAGE_SIZE = 100;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param ?int    $first  Return the first N results.
+	 * @param ?int    $last   Return the last N results.
+	 * @param ?string $after  Return results after this cursor.
+	 * @param ?string $before Return results before this cursor.
+	 *
+	 * @throws \InvalidArgumentException When `first` or `last` is negative or exceeds MAX_PAGE_SIZE.
+	 */
+	public function __construct(
+		#[Description( 'Return the first N results. Must be between 0 and ' . self::MAX_PAGE_SIZE . '.' )]
+		public readonly ?int $first = null,
+		#[Description( 'Return the last N results. Must be between 0 and ' . self::MAX_PAGE_SIZE . '.' )]
+		public readonly ?int $last = null,
+		#[Description( 'Return results after this cursor.' )]
+		public readonly ?string $after = null,
+		#[Description( 'Return results before this cursor.' )]
+		public readonly ?string $before = null,
+	) {
+		self::validate_limit( 'first', $first );
+		self::validate_limit( 'last', $last );
+	}
+
+	/**
+	 * The page size to use when no explicit `first` or `last` is provided.
+	 *
+	 * Exposed as a method (not just the constant) so the default can become
+	 * configurable — e.g. via a filter or store option — without requiring
+	 * call-site changes.
+	 *
+	 * @return int
+	 */
+	public static function get_default_page_size(): int {
+		return self::DEFAULT_PAGE_SIZE;
+	}
+
+	/**
+	 * Validate pagination limits on a raw args array without constructing a
+	 * full PaginationParams instance.
+	 *
+	 * Intended for call sites that take raw GraphQL args (like nested
+	 * connection resolvers) and forward them to Connection::slice(). The
+	 * constructor already runs the same checks for root queries that build
+	 * a PaginationParams via #[Unroll], so this keeps both paths in sync.
+	 *
+	 * @param array $args Raw args with optional `first` / `last` keys.
+	 *
+	 * @throws \InvalidArgumentException When either limit is negative or above MAX_PAGE_SIZE.
+	 */
+	public static function validate_args( array $args ): void {
+		self::validate_limit( 'first', $args['first'] ?? null );
+		self::validate_limit( 'last', $args['last'] ?? null );
+	}
+
+	/**
+	 * Validate a `first` / `last` argument.
+	 *
+	 * @param string $name  The argument name, for the error message.
+	 * @param ?int   $value The value to validate.
+	 *
+	 * @throws \InvalidArgumentException When the value is out of range.
+	 */
+	private static function validate_limit( string $name, ?int $value ): void {
+		if ( null === $value ) {
+			return;
+		}
+
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output; serialized as JSON in the GraphQL error response.
+		if ( $value < 0 ) {
+			throw new \InvalidArgumentException(
+				sprintf( 'Argument `%s` must be zero or greater.', $name )
+			);
+		}
+
+		if ( $value > self::MAX_PAGE_SIZE ) {
+			throw new \InvalidArgumentException(
+				sprintf(
+					'Argument `%s` exceeds the maximum page size of %d.',
+					$name,
+					self::MAX_PAGE_SIZE
+				)
+			);
+		}
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php b/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php
new file mode 100644
index 00000000000..4f011350ec5
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+
+#[Name( 'coupon' )]
+#[Description( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.' )]
+/**
+ * Query to retrieve a single coupon.
+ */
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class GetCoupon {
+	/**
+	 * Retrieve a coupon by ID or code.
+	 *
+	 * @param ?int    $id   The coupon ID.
+	 * @param ?string $code The coupon code.
+	 * @return ?Coupon
+	 * @throws \InvalidArgumentException When neither or both arguments are provided.
+	 */
+	public function execute(
+		#[Description( 'The ID of the coupon to retrieve.' )]
+		?int $id = null,
+		#[Description( 'The coupon code to look up.' )]
+		?string $code = null,
+	): ?Coupon {
+		if ( ( null === $id ) === ( null === $code ) ) {
+			throw new \InvalidArgumentException( 'Exactly one of "id" or "code" must be provided.' );
+		}
+
+		$wc_coupon = new \WC_Coupon( $id ?? $code );
+
+		if ( ! $wc_coupon->get_id() ) {
+			return null;
+		}
+
+		return CouponMapper::from_wc_coupon( $wc_coupon );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php b/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php
new file mode 100644
index 00000000000..c1f6cc5a87e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+
+#[Name( 'coupons' )]
+#[Description( 'List coupons with cursor-based pagination.' )]
+/**
+ * Query to list coupons with cursor-based pagination.
+ */
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class ListCoupons {
+	/**
+	 * List coupons with optional filtering and pagination.
+	 *
+	 * @param PaginationParams $pagination The pagination parameters.
+	 * @param ?CouponStatus    $status     Optional status filter.
+	 * @return Connection
+	 */
+	#[ConnectionOf( Coupon::class )]
+	public function execute(
+		PaginationParams $pagination,
+		#[Description( 'Filter by coupon status.' )]
+		?CouponStatus $status = null,
+	): Connection {
+		$first  = $pagination->first;
+		$last   = $pagination->last;
+		$after  = $pagination->after;
+		$before = $pagination->before;
+		$limit  = $first ?? $last ?? PaginationParams::get_default_page_size();
+
+		// Use WP_Query for the count and a filtered query for cursor-based
+		// pagination. We only need `found_posts` (which comes from the
+		// SQL_CALC_FOUND_ROWS query WP runs alongside the main SELECT), so
+		// the main SELECT fetches only one row — posts_per_page => -1 would
+		// materialize every ID just to throw it away.
+		$count_args  = array(
+			'post_type'      => 'shop_coupon',
+			'posts_per_page' => 1,
+			'fields'         => 'ids',
+			'post_status'    => $status?->value ?? 'any',
+		);
+		$count_query = new \WP_Query( $count_args );
+		$total_count = $count_query->found_posts;
+
+		// Fetch posts with cursor filtering via post__in or meta_query workaround.
+		// For simplicity, we use direct ID-based filtering.
+		$posts_query_args = array(
+			'post_type'      => 'shop_coupon',
+			'posts_per_page' => $limit + 1,
+			'orderby'        => 'ID',
+			'order'          => null !== $last ? 'DESC' : 'ASC',
+			'post_status'    => $status?->value ?? 'any',
+		);
+
+		if ( null !== $after ) {
+			$posts_query_args[ IdCursorFilter::AFTER_ID ] = IdCursorFilter::decode_id_cursor( $after, 'after' );
+		}
+		if ( null !== $before ) {
+			$posts_query_args[ IdCursorFilter::BEFORE_ID ] = IdCursorFilter::decode_id_cursor( $before, 'before' );
+		}
+		IdCursorFilter::ensure_registered();
+
+		$query = new \WP_Query( $posts_query_args );
+		$posts = $query->posts;
+
+		// Determine pagination.
+		$has_extra = count( $posts ) > $limit;
+		if ( $has_extra ) {
+			$posts = array_slice( $posts, 0, $limit );
+		}
+
+		// If we fetched in DESC order for $last, reverse to get ascending order.
+		if ( null !== $last ) {
+			$posts = array_reverse( $posts );
+		}
+
+		// Build edges and nodes.
+		$edges = array();
+		$nodes = array();
+		foreach ( $posts as $post ) {
+			$wc_coupon = new \WC_Coupon( $post->ID );
+			$coupon    = CouponMapper::from_wc_coupon( $wc_coupon );
+
+			$edge         = new Edge();
+			$edge->cursor = base64_encode( (string) $coupon->id );
+			$edge->node   = $coupon;
+
+			$edges[] = $edge;
+			$nodes[] = $coupon;
+		}
+
+		$page_info = new PageInfo();
+		// Relay semantics for backward pagination (`last`, `before`): the
+		// returned window ends just before `$before`, so items after the
+		// window exist whenever `$before` was supplied — not whenever
+		// `$after` was. `has_previous_page` in the backward case is driven
+		// by the "did we fetch limit+1?" sentinel (`$has_extra`).
+		$page_info->has_next_page     = null !== $last ? ( null !== $before ) : $has_extra;
+		$page_info->has_previous_page = null !== $last ? $has_extra : ( null !== $after );
+		$page_info->start_cursor      = ! empty( $edges ) ? $edges[0]->cursor : null;
+		$page_info->end_cursor        = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+		$connection              = new Connection();
+		$connection->edges       = $edges;
+		$connection->nodes       = $nodes;
+		$connection->page_info   = $page_info;
+		$connection->total_count = $total_count;
+
+		return $connection;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
new file mode 100644
index 00000000000..e6b4d35f804
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\AuthorizationException;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Query to retrieve a single product by ID.
+ *
+ * Demonstrates: authorize(), $_query_info, AuthorizationException.
+ *
+ * Authorization logic: admins (manage_woocommerce) can read any product,
+ * non-admin users can only read their own products.
+ */
+#[Name( 'product' )]
+#[Description( 'Retrieve a single product by ID.' )]
+#[RequiredCapability( 'read_product' )]
+class GetProduct {
+	/**
+	 * Authorize access to a specific product.
+	 *
+	 * Admins can read any product. Non-admin users can only read products
+	 * they authored themselves.
+	 *
+	 * Every inaccessible case throws `AuthorizationException('Product not
+	 * found.')` — whether the ID doesn't exist, points at a non-product
+	 * post type, or points at a product the caller doesn't own. This
+	 * prevents callers from enumerating product IDs vs non-product post
+	 * IDs via the response they get back (which would otherwise be "not
+	 * found" vs "no permission").
+	 *
+	 * @param int  $id              The product ID.
+	 * @param bool $_preauthorized  Whether the declared capability check passed.
+	 * @return bool Whether the current user can read this product.
+	 * @throws AuthorizationException When the product is not accessible.
+	 */
+	public function authorize( int $id, bool $_preauthorized ): bool {
+		// Reject non-positive IDs up front. `get_post( 0 )` inside a
+		// WordPress loop returns `$GLOBALS['post']` (not null), so a bare
+		// `get_post( $id )` below would accidentally operate on whatever
+		// global post was set upstream of this request.
+		if ( $id <= 0 ) {
+			throw new AuthorizationException( 'Product not found.' );
+		}
+
+		$post = get_post( $id );
+
+		if ( ! $post || 'product' !== $post->post_type ) {
+			throw new AuthorizationException( 'Product not found.' );
+		}
+
+		// Honor the declared #[RequiredCapability] (read_product).
+		if ( $_preauthorized ) {
+			return true;
+		}
+
+		// `manage_woocommerce` is the canonical "admin sees everything"
+		// capability in WooCommerce. The declared #[RequiredCapability]
+		// pre-authorizes on `read_product` (the read-level post-type cap,
+		// which is what the schema advertises), but an admin whose cap set
+		// grants `manage_woocommerce` without `read_product` would
+		// otherwise fall through to the ownership check and get "Product
+		// not found" for any product they don't own — contrary to the
+		// documented admin-can-see-everything contract.
+		if ( current_user_can( 'manage_woocommerce' ) ) {
+			return true;
+		}
+
+		// Non-admin users can only read their own products. Throw the same
+		// "not found" exception rather than returning false — a distinct
+		// "you don't have permission" error here would tell the caller
+		// that the ID is a product (just not theirs), leaking the
+		// product-ID space vs the rest of the post-ID space.
+		//
+		// Reject guest users explicitly: get_current_user_id() returns 0
+		// for unauthenticated callers, and products created via WP-CLI,
+		// imports, or programmatic inserts without an author can have
+		// post_author = 0 — a bare `!==` check would mis-grant access to
+		// anonymous callers for those products.
+		$current_user_id = get_current_user_id();
+		if ( 0 === $current_user_id || $current_user_id !== (int) $post->post_author ) {
+			throw new AuthorizationException( 'Product not found.' );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Retrieve a product by ID.
+	 *
+	 * @param int    $id          The product ID.
+	 * @param ?array $_query_info Unified query info tree from the GraphQL request.
+	 * @return ?object
+	 */
+	#[ReturnType( Product::class )]
+	public function execute(
+		#[Description( 'The ID of the product to retrieve.' )]
+		int $id,
+		?array $_query_info = null,
+	): ?object {
+		// Mirrors the guard in authorize(): never pass a non-positive ID to
+		// wc_get_product(). authorize() would normally reject these first,
+		// but a future caller path might invoke execute() directly.
+		if ( $id <= 0 ) {
+			return null;
+		}
+
+		$wc_product = wc_get_product( $id );
+
+		if ( ! $wc_product instanceof \WC_Product ) {
+			return null;
+		}
+
+		return ProductMapper::from_wc_product( $wc_product, $_query_info );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php b/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php
new file mode 100644
index 00000000000..b67849e8d88
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php
@@ -0,0 +1,226 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Query to list products with cursor-based pagination.
+ *
+ * Demonstrates: #[Unroll] on parameter, enum as direct param, multiple capabilities.
+ */
+#[Name( 'products' )]
+#[Description( 'List products with cursor-based pagination and optional filtering.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+#[RequiredCapability( 'edit_products' )]
+class ListProducts {
+	/**
+	 * List products with optional filtering and pagination.
+	 *
+	 * @param PaginationParams   $pagination   The pagination parameters.
+	 * @param ProductFilterInput $filters      Filter criteria (unrolled to flat args).
+	 * @param ?ProductType       $product_type Optional product type filter.
+	 * @param ?array             $_query_info  Unified query info tree from the GraphQL request.
+	 * @return Connection
+	 * @throws ApiException When an unsupported `stock_status` filter value is passed.
+	 */
+	#[ConnectionOf( Product::class )]
+	public function execute(
+		PaginationParams $pagination,
+		#[Unroll]
+		ProductFilterInput $filters,
+		#[Description( 'Filter by product type.' )]
+		?ProductType $product_type = null,
+		?array $_query_info = null,
+	): Connection {
+		$first  = $pagination->first;
+		$last   = $pagination->last;
+		$after  = $pagination->after;
+		$before = $pagination->before;
+		$limit  = $first ?? $last ?? PaginationParams::get_default_page_size();
+
+		$query_args = array(
+			'post_type'      => 'product',
+			'posts_per_page' => $limit + 1,
+			'orderby'        => 'ID',
+			'order'          => null !== $last ? 'DESC' : 'ASC',
+			'post_status'    => $filters->status?->value ?? 'any',
+		);
+
+		// Product type filter via taxonomy. `ProductType::Other` is the
+		// output-only signal for "stored product_type doesn't match any
+		// known standard" (typically plugin-added types), mirroring how
+		// `StockStatus::Other` is handled for the meta-query path above.
+		// Map it to NOT IN the standard slugs rather than the literal
+		// 'other' term, which wouldn't match anything.
+		if ( null !== $product_type ) {
+			if ( ProductType::Other === $product_type ) {
+				$query_args['tax_query'] = array(
+					array(
+						'taxonomy' => 'product_type',
+						'field'    => 'slug',
+						'terms'    => array_values(
+							array_filter(
+								array_map(
+									static fn( ProductType $t ): string => $t->value,
+									ProductType::cases()
+								),
+								static fn( string $slug ): bool => ProductType::Other->value !== $slug
+							)
+						),
+						'operator' => 'NOT IN',
+					),
+				);
+			} else {
+				$query_args['tax_query'] = array(
+					array(
+						'taxonomy' => 'product_type',
+						'field'    => 'slug',
+						'terms'    => $product_type->value,
+					),
+				);
+			}
+		}
+
+		// Stock status filter via meta. `StockStatus::Other` means "stored
+		// _stock_status isn't one of the three standard WooCommerce values"
+		// (typically a plugin-added custom status), so it maps to NOT IN
+		// those three. `default` throws INVALID_ARGUMENT so any future
+		// enum case added without updating this match fails loudly with a
+		// clean 400 instead of a PHP-level UnhandledMatchError → HTTP 500.
+		if ( null !== $filters->stock_status ) {
+			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+			$meta_clause = match ( $filters->stock_status ) {
+				StockStatus::InStock     => array(
+					'key'   => '_stock_status',
+					'value' => 'instock',
+				),
+				StockStatus::OutOfStock  => array(
+					'key'   => '_stock_status',
+					'value' => 'outofstock',
+				),
+				StockStatus::OnBackorder => array(
+					'key'   => '_stock_status',
+					'value' => 'onbackorder',
+				),
+				StockStatus::Other       => array(
+					'key'     => '_stock_status',
+					'value'   => array( 'instock', 'outofstock', 'onbackorder' ),
+					'compare' => 'NOT IN',
+				),
+				default                  => throw new ApiException(
+					sprintf( 'Unsupported stock_status filter value: %s.', $filters->stock_status->name ),
+					'INVALID_ARGUMENT',
+					status_code: 400,
+				),
+			};
+			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			$query_args['meta_query'] = array( $meta_clause );
+		}
+
+		// Search filter.
+		if ( null !== $filters->search ) {
+			$query_args['s'] = $filters->search;
+		}
+
+		// Total count query. Derive from $query_args — which already has
+		// the tax_query / meta_query / search clauses applied — *before*
+		// we set cursor query vars on it. Building $count_args from scratch
+		// with only post_status would drop every user filter and report the
+		// count of "all products in that status" instead of "all products
+		// matching the filters", making Relay consumers' "X of Y" wrong.
+		// Only `found_posts` is read, so posts_per_page => 1 keeps the
+		// underlying SELECT cheap.
+		$count_args                   = $query_args;
+		$count_args['posts_per_page'] = 1;
+		$count_args['fields']         = 'ids';
+		$count_query                  = new \WP_Query( $count_args );
+		$total_count                  = $count_query->found_posts;
+
+		// Cursor-based filtering via IdCursorFilter (see class docblock).
+		if ( null !== $after ) {
+			$query_args[ IdCursorFilter::AFTER_ID ] = IdCursorFilter::decode_id_cursor( $after, 'after' );
+		}
+		if ( null !== $before ) {
+			$query_args[ IdCursorFilter::BEFORE_ID ] = IdCursorFilter::decode_id_cursor( $before, 'before' );
+		}
+		IdCursorFilter::ensure_registered();
+
+		$query = new \WP_Query( $query_args );
+		$posts = $query->posts;
+
+		// Determine pagination.
+		$has_extra = count( $posts ) > $limit;
+		if ( $has_extra ) {
+			$posts = array_slice( $posts, 0, $limit );
+		}
+
+		if ( null !== $last ) {
+			$posts = array_reverse( $posts );
+		}
+
+		// Narrow $_query_info to the per-node selection so each mapped
+		// product only fetches the subtrees the client actually asked for
+		// under `nodes { ... }` / `edges { node { ... } }`. Without this,
+		// ProductMapper::populate_common_fields() hits its null-$query_info
+		// fallback and runs build_reviews() (plus its count query) for
+		// every product on the page — N+1 on reviews even when no client
+		// selected them.
+		$node_query_info = ProductMapper::connection_node_info( $_query_info );
+
+		// Build edges and nodes.
+		$edges = array();
+		$nodes = array();
+		foreach ( $posts as $post ) {
+			$wc_product = wc_get_product( $post->ID );
+			if ( ! $wc_product instanceof \WC_Product ) {
+				continue;
+			}
+
+			$product = ProductMapper::from_wc_product( $wc_product, $node_query_info );
+
+			$edge         = new Edge();
+			$edge->cursor = base64_encode( (string) $product->id );
+			$edge->node   = $product;
+
+			$edges[] = $edge;
+			$nodes[] = $product;
+		}
+
+		$page_info = new PageInfo();
+		// Relay semantics for backward pagination (`last`, `before`): the
+		// returned window ends just before `$before`, so items after the
+		// window exist whenever `$before` was supplied — not whenever
+		// `$after` was. `has_previous_page` in the backward case is driven
+		// by the "did we fetch limit+1?" sentinel (`$has_extra`).
+		$page_info->has_next_page     = null !== $last ? ( null !== $before ) : $has_extra;
+		$page_info->has_previous_page = null !== $last ? $has_extra : ( null !== $after );
+		$page_info->start_cursor      = ! empty( $edges ) ? $edges[0]->cursor : null;
+		$page_info->end_cursor        = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+		$connection              = new Connection();
+		$connection->edges       = $edges;
+		$connection->nodes       = $nodes;
+		$connection->page_info   = $page_info;
+		$connection->total_count = $total_count;
+
+		return $connection;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/README.md b/plugins/woocommerce/src/Api/README.md
new file mode 100644
index 00000000000..14a74710990
--- /dev/null
+++ b/plugins/woocommerce/src/Api/README.md
@@ -0,0 +1,7 @@
+# Important: Experimental feature
+
+All the code in this directory (`Automattic\WooCommerce\Api` namespace and nested namespaces) is part of [an experimental feature](https://github.com/woocommerce/woocommerce/pull/63772). The code could (and probably will) get backwards-incompatible changes, or even be completely removed, in future releases of WooCommerce.
+
+Feel free to experiment in testing or staging environments, but **DO NOT** use this code in released extensions or in production environments.
+
+Also as a reminder, **ALL** the code that's inside the `Automattic\WooCommerce\Internal` namespace and nested namespaces, or that's annotated with `@internal`, is for exclusive usage of WooCommerce core and must **NEVER** be used in extensions or otherwise in production environments.
diff --git a/plugins/woocommerce/src/Api/Scalars/DateTime.php b/plugins/woocommerce/src/Api/Scalars/DateTime.php
new file mode 100644
index 00000000000..a393643e22b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Scalars/DateTime.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Scalars;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Custom scalar for ISO 8601 date/time values.
+ */
+#[Description( 'An ISO 8601 encoded date and time string.' )]
+class DateTime {
+	/**
+	 * Serialize a PHP value to the scalar's transport format.
+	 *
+	 * @param mixed $value The value to serialize.
+	 * @return string
+	 */
+	public static function serialize( mixed $value ): string {
+		if ( $value instanceof \DateTimeInterface ) {
+			return $value->format( \DateTimeInterface::ATOM );
+		}
+		return (string) $value;
+	}
+
+	/**
+	 * Parse a value received from a client (variable or literal).
+	 *
+	 * @param string $value The raw string value from the client.
+	 * @return \DateTimeImmutable
+	 * @throws \InvalidArgumentException When the value cannot be parsed as an ISO 8601 date/time string.
+	 */
+	public static function parse( string $value ): \DateTimeImmutable {
+		try {
+			return new \DateTimeImmutable( $value );
+		} catch ( \Exception $e ) {
+			// PHP 8.3+ throws \DateMalformedStringException; earlier versions
+			// throw a plain \Exception. Both extend \Exception, so a single
+			// catch captures them.
+			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output; serialized as JSON in the GraphQL error response.
+			throw new \InvalidArgumentException(
+				sprintf( 'Invalid ISO 8601 date/time: %s', $e->getMessage() ),
+				0,
+				$e
+			);
+			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php b/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php
new file mode 100644
index 00000000000..61d925f4292
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Traits;
+
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Trait that grants the manage_woocommerce capability requirement.
+ *
+ * Classes using this trait inherit the capability via the builder's
+ * resolve_capabilities() method, which inspects traits for attributes.
+ */
+#[RequiredCapability( 'manage_woocommerce' )]
+trait RequiresManageWoocommerce {
+}
diff --git a/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php b/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php
new file mode 100644
index 00000000000..e7cc1fa6d39
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php
@@ -0,0 +1,105 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\Interfaces\ObjectWithId;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+
+/**
+ * Output type representing a WooCommerce coupon.
+ */
+#[Description( 'Represents a WooCommerce discount coupon.' )]
+class Coupon {
+	use ObjectWithId;
+
+	#[Description( 'The coupon code.' )]
+	public string $code;
+
+	#[Description( 'The coupon description.' )]
+	public string $description;
+
+	#[Description( 'The type of discount.' )]
+	public DiscountType $discount_type;
+
+	#[Description( 'The raw discount type as stored in WooCommerce. Useful when discount_type is OTHER (e.g. plugin-added types like recurring_percent or sign_up_fee).' )]
+	public string $raw_discount_type;
+
+	#[Description( 'The discount amount.' )]
+	public float $amount;
+
+	#[Description( 'The coupon status.' )]
+	public CouponStatus $status;
+
+	#[Description( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).' )]
+	public string $raw_status;
+
+	#[Description( 'The date the coupon was created.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_created;
+
+	#[Description( 'The date the coupon was last modified.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_modified;
+
+	#[Description( 'The date the coupon expires.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_expires;
+
+	#[Description( 'The number of times the coupon has been used.' )]
+	public int $usage_count;
+
+	#[Description( 'Whether the coupon can only be used alone.' )]
+	public bool $individual_use;
+
+	#[Description( 'Product IDs the coupon can be applied to.' )]
+	#[ArrayOf( 'int' )]
+	public array $product_ids;
+
+	#[Description( 'Product IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public array $excluded_product_ids;
+
+	#[Description( 'Maximum number of times the coupon can be used in total.' )]
+	public int $usage_limit;
+
+	#[Description( 'Maximum number of times the coupon can be used per customer.' )]
+	public int $usage_limit_per_user;
+
+	#[Description( 'Maximum number of items the coupon can be applied to.' )]
+	public ?int $limit_usage_to_x_items;
+
+	#[Description( 'Whether the coupon grants free shipping.' )]
+	public bool $free_shipping;
+
+	#[Description( 'Product category IDs the coupon applies to.' )]
+	#[ArrayOf( 'int' )]
+	public array $product_categories;
+
+	#[Description( 'Product category IDs excluded from the coupon.' )]
+	#[ArrayOf( 'int' )]
+	public array $excluded_product_categories;
+
+	#[Description( 'Whether the coupon excludes items on sale.' )]
+	public bool $exclude_sale_items;
+
+	#[Description( 'Minimum order amount required to use the coupon.' )]
+	public float $minimum_amount;
+
+	#[Description( 'Maximum order amount allowed to use the coupon.' )]
+	public float $maximum_amount;
+
+	#[Description( 'Email addresses that can use this coupon.' )]
+	#[ArrayOf( 'string' )]
+	public array $email_restrictions;
+
+	#[Description( 'Email addresses of customers who have used this coupon.' )]
+	#[ArrayOf( 'string' )]
+	public array $used_by;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php b/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php
new file mode 100644
index 00000000000..e1c75c2048f
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Result of a coupon deletion operation.
+ */
+#[Description( 'The result of deleting a coupon.' )]
+class DeleteCouponResult {
+	#[Description( 'The ID of the deleted coupon.' )]
+	public int $id;
+
+	#[Description( 'Whether the coupon was permanently deleted.' )]
+	public bool $deleted;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php b/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php
new file mode 100644
index 00000000000..65e0a01c0b8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing an external/affiliate product.
+ */
+#[Description( 'An external/affiliate product.' )]
+class ExternalProduct {
+	use Product;
+
+	#[Description( 'The external product URL.' )]
+	public ?string $product_url;
+
+	#[Description( 'The text for the external product button.' )]
+	public ?string $button_text;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php b/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php
new file mode 100644
index 00000000000..afb3950112a
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a product attribute definition.
+ */
+#[Description( 'A product attribute.' )]
+class ProductAttribute {
+	#[Description( 'The attribute display name.' )]
+	public string $name;
+
+	#[Description( 'The attribute taxonomy or key name.' )]
+	public string $slug;
+
+	#[Description( 'The available attribute values.' )]
+	#[ArrayOf( 'string' )]
+	public array $options;
+
+	#[Description( 'The display order position.' )]
+	public int $position;
+
+	#[Description( 'Whether the attribute is visible on the product page.' )]
+	public bool $visible;
+
+	#[Description( 'Whether the attribute is used for variations.' )]
+	public bool $variation;
+
+	#[Description( 'Whether the attribute is a global taxonomy attribute.' )]
+	public bool $is_taxonomy;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php b/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php
new file mode 100644
index 00000000000..fcef48187c9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing product physical dimensions.
+ */
+#[Description( 'Physical dimensions and weight of a product.' )]
+class ProductDimensions {
+	#[Description( 'The product length.' )]
+	public ?float $length;
+
+	#[Description( 'The product width.' )]
+	public ?float $width;
+
+	#[Description( 'The product height.' )]
+	public ?float $height;
+
+	#[Description( 'The product weight.' )]
+	public ?float $weight;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductImage.php b/plugins/woocommerce/src/Api/Types/Products/ProductImage.php
new file mode 100644
index 00000000000..9dfae3d203e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductImage.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a product image.
+ */
+#[Description( 'Represents a product image.' )]
+class ProductImage {
+	#[Description( 'The image attachment ID.' )]
+	public int $id;
+
+	#[Description( 'The image URL.' )]
+	public string $url;
+
+	#[Description( 'The image alt text.' )]
+	public string $alt;
+
+	#[Description( 'The image display position.' )]
+	public int $position;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductReview.php b/plugins/woocommerce/src/Api/Types/Products/ProductReview.php
new file mode 100644
index 00000000000..c4e63cb1427
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductReview.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+
+/**
+ * Output type representing a product review.
+ */
+#[Description( 'Represents a customer review for a product.' )]
+class ProductReview {
+	#[Description( 'The review ID.' )]
+	public int $id;
+
+	#[Description( 'The product ID this review belongs to.' )]
+	public int $product_id;
+
+	#[Description( 'The reviewer name.' )]
+	public string $reviewer;
+
+	#[Description( 'The review content.' )]
+	public string $review;
+
+	#[Description( 'The review rating (1-5).' )]
+	public int $rating;
+
+	#[Description( 'The date the review was created.' )]
+	#[ScalarType( DateTime::class )]
+	public ?string $date_created;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php b/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php
new file mode 100644
index 00000000000..dffd11e5961
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing a product variation.
+ */
+#[Description( 'A product variation.' )]
+class ProductVariation {
+	use Product;
+
+	#[Description( 'The parent variable product ID.' )]
+	public int $parent_id;
+
+	#[Description( 'The selected attribute values for this variation.' )]
+	#[ArrayOf( SelectedAttribute::class )]
+	public array $selected_attributes;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php b/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php
new file mode 100644
index 00000000000..6c5e3d05d0c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a single attribute selection on a variation.
+ */
+#[Description( 'A selected attribute value on a product variation.' )]
+class SelectedAttribute {
+	#[Description( 'The attribute name or slug.' )]
+	public string $name;
+
+	#[Description( 'The selected attribute value.' )]
+	public string $value;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php b/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php
new file mode 100644
index 00000000000..0baebc7069b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing a simple WooCommerce product.
+ */
+#[Description( 'A simple WooCommerce product.' )]
+class SimpleProduct {
+	use Product;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php b/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php
new file mode 100644
index 00000000000..2984efb2143
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+
+/**
+ * Output type representing a variable product with variations.
+ */
+#[Description( 'A variable product with variations.' )]
+class VariableProduct {
+	use Product;
+
+	#[Description( 'The product variations.' )]
+	#[ConnectionOf( ProductVariation::class )]
+	#[Parameter( type: PaginationParams::class )]
+	public Connection $variations;
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php b/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php
new file mode 100644
index 00000000000..898f8e7efb8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Coupons;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Maps a WC_Coupon to the Coupon DTO.
+ */
+class CouponMapper {
+	/**
+	 * Map a WC_Coupon to the Coupon DTO.
+	 *
+	 * @param \WC_Coupon $wc_coupon The WooCommerce coupon object.
+	 * @return Coupon
+	 */
+	public static function from_wc_coupon( \WC_Coupon $wc_coupon ): Coupon {
+		$coupon = new Coupon();
+
+		$raw_discount_type = (string) $wc_coupon->get_discount_type();
+		$raw_status        = (string) $wc_coupon->get_status();
+
+		$coupon->id                          = $wc_coupon->get_id();
+		$coupon->code                        = $wc_coupon->get_code();
+		$coupon->description                 = $wc_coupon->get_description();
+		$coupon->discount_type               = DiscountType::tryFrom( $raw_discount_type ) ?? DiscountType::Other;
+		$coupon->raw_discount_type           = $raw_discount_type;
+		$coupon->amount                      = (float) $wc_coupon->get_amount();
+		$coupon->status                      = '' === $raw_status
+			? CouponStatus::Draft
+			: ( CouponStatus::tryFrom( $raw_status ) ?? CouponStatus::Other );
+		$coupon->raw_status                  = $raw_status;
+		$coupon->date_created                = $wc_coupon->get_date_created()?->format( \DateTimeInterface::ATOM );
+		$coupon->date_modified               = $wc_coupon->get_date_modified()?->format( \DateTimeInterface::ATOM );
+		$coupon->date_expires                = $wc_coupon->get_date_expires()?->format( \DateTimeInterface::ATOM );
+		$coupon->usage_count                 = $wc_coupon->get_usage_count();
+		$coupon->individual_use              = $wc_coupon->get_individual_use();
+		$coupon->product_ids                 = $wc_coupon->get_product_ids();
+		$coupon->excluded_product_ids        = $wc_coupon->get_excluded_product_ids();
+		$coupon->usage_limit                 = $wc_coupon->get_usage_limit();
+		$coupon->usage_limit_per_user        = $wc_coupon->get_usage_limit_per_user();
+		$coupon->limit_usage_to_x_items      = $wc_coupon->get_limit_usage_to_x_items();
+		$coupon->free_shipping               = $wc_coupon->get_free_shipping();
+		$coupon->product_categories          = $wc_coupon->get_product_categories();
+		$coupon->excluded_product_categories = $wc_coupon->get_excluded_product_categories();
+		$coupon->exclude_sale_items          = $wc_coupon->get_exclude_sale_items();
+		$coupon->minimum_amount              = (float) $wc_coupon->get_minimum_amount();
+		$coupon->maximum_amount              = (float) $wc_coupon->get_maximum_amount();
+		$coupon->email_restrictions          = $wc_coupon->get_email_restrictions();
+		$coupon->used_by                     = $wc_coupon->get_used_by();
+
+		return $coupon;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php b/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php
new file mode 100644
index 00000000000..04c4bebc265
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php
@@ -0,0 +1,564 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Products;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Types\Products\ExternalProduct;
+use Automattic\WooCommerce\Api\Types\Products\ProductAttribute;
+use Automattic\WooCommerce\Api\Types\Products\ProductDimensions;
+use Automattic\WooCommerce\Api\Types\Products\ProductImage;
+use Automattic\WooCommerce\Api\Types\Products\ProductReview;
+use Automattic\WooCommerce\Api\Types\Products\ProductVariation;
+use Automattic\WooCommerce\Api\Types\Products\SelectedAttribute;
+use Automattic\WooCommerce\Api\Types\Products\SimpleProduct;
+use Automattic\WooCommerce\Api\Types\Products\VariableProduct;
+
+/**
+ * Maps a WC_Product to the appropriate product DTO.
+ */
+class ProductMapper {
+	/**
+	 * Map a WC_Product to the appropriate product DTO based on its type.
+	 *
+	 * @param \WC_Product $wc_product The WooCommerce product object.
+	 * @param ?array      $query_info Unified query info tree from the GraphQL request.
+	 * @return object
+	 */
+	public static function from_wc_product(
+		\WC_Product $wc_product,
+		?array $query_info = null,
+	): object {
+		$product = match ( $wc_product->get_type() ) {
+			'external'  => self::build_external_product( $wc_product ),
+			'variable'  => self::build_variable_product( $wc_product, $query_info ),
+			'variation' => self::build_product_variation( $wc_product ),
+			default     => new SimpleProduct(),
+		};
+
+		self::populate_common_fields( $product, $wc_product, $query_info );
+
+		return $product;
+	}
+
+	/**
+	 * Build an ExternalProduct with type-specific fields.
+	 *
+	 * @param \WC_Product $wc_product The external product.
+	 * @return ExternalProduct
+	 */
+	private static function build_external_product( \WC_Product $wc_product ): ExternalProduct {
+		$product = new ExternalProduct();
+
+		$url                  = $wc_product->get_product_url();
+		$product->product_url = ! empty( $url ) ? $url : null;
+		$text                 = $wc_product->get_button_text();
+		$product->button_text = ! empty( $text ) ? $text : null;
+
+		return $product;
+	}
+
+	/**
+	 * Build a VariableProduct with type-specific fields.
+	 *
+	 * @param \WC_Product $wc_product The variable product.
+	 * @param ?array      $query_info Unified query info tree from the GraphQL request.
+	 * @return VariableProduct
+	 */
+	private static function build_variable_product( \WC_Product $wc_product, ?array $query_info = null ): VariableProduct {
+		$product = new VariableProduct();
+
+		$child_ids   = $wc_product->get_children();
+		$total_count = count( $child_ids );
+
+		// Extract the per-variation selection and pagination args from
+		// $query_info up front. Narrowing $query_info keeps recursive
+		// from_wc_product() calls from fetching subtrees the client didn't
+		// request (e.g. reviews for every variation).
+		$variations_info      = $query_info['...VariableProduct']['variations']
+			?? $query_info['variations']
+			?? null;
+		$variation_query_info = self::connection_node_info( $variations_info );
+		$pagination_args      = $variations_info['__args'] ?? array();
+
+		// Slice the ID window *before* mapping: otherwise `variations(first: 1)`
+		// on a product with N variations would prime+map all N just to slice
+		// the result down afterwards. The resolver-level validation at
+		// Connection::slice() is now bypassed (we're building a pre-sliced
+		// connection), so call validate_args() explicitly to keep the 0..
+		// MAX_PAGE_SIZE bounds enforced.
+		PaginationParams::validate_args( $pagination_args );
+		$page = self::slice_variation_ids( $child_ids, $pagination_args );
+
+		// Prime post + meta caches for only the paged subset.
+		if ( ! empty( $page['ids'] ) ) {
+			_prime_post_caches( $page['ids'] );
+		}
+
+		$edges = array();
+		$nodes = array();
+		foreach ( $page['ids'] as $child_id ) {
+			$child_product = wc_get_product( $child_id );
+			if ( ! $child_product ) {
+				continue;
+			}
+
+			$variation = self::from_wc_product( $child_product, $variation_query_info );
+
+			$edge         = new Edge();
+			$edge->cursor = base64_encode( (string) $child_id );
+			$edge->node   = $variation;
+
+			$edges[] = $edge;
+			$nodes[] = $variation;
+		}
+
+		$page_info                    = new PageInfo();
+		$page_info->has_next_page     = $page['has_next_page'];
+		$page_info->has_previous_page = $page['has_previous_page'];
+		$page_info->start_cursor      = ! empty( $edges ) ? $edges[0]->cursor : null;
+		$page_info->end_cursor        = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+		// total_count reflects the full variation set, not the paged one —
+		// consistent with how the root list resolvers compute it.
+		$product->variations = Connection::pre_sliced( $edges, $page_info, $total_count );
+
+		return $product;
+	}
+
+	/**
+	 * Compute a Relay cursor page against a list of variation IDs.
+	 *
+	 * Mirrors the logic in {@see Connection::slice()} but operates on raw
+	 * IDs so the caller can page-down *before* calling `wc_get_product()`
+	 * + `from_wc_product()` on each child. Returns the paged IDs and the
+	 * corresponding `has_next_page` / `has_previous_page` flags in Relay
+	 * semantics.
+	 *
+	 * @param int[] $child_ids  Full variation ID list, in menu_order.
+	 * @param array $args       `{first?, last?, after?, before?}` raw GraphQL args.
+	 * @return array{ids: int[], has_next_page: bool, has_previous_page: bool}
+	 */
+	private static function slice_variation_ids( array $child_ids, array $args ): array {
+		$first  = $args['first'] ?? null;
+		$last   = $args['last'] ?? null;
+		$after  = $args['after'] ?? null;
+		$before = $args['before'] ?? null;
+
+		// No pagination requested — return the full list as-is.
+		if ( null === $first && null === $last && null === $after && null === $before ) {
+			return array(
+				'ids'               => array_values( $child_ids ),
+				'has_next_page'     => false,
+				'has_previous_page' => false,
+			);
+		}
+
+		// Narrow by `after`: drop IDs up to and including the cursor position.
+		if ( null !== $after ) {
+			$after_id  = IdCursorFilter::decode_id_cursor( $after, 'after' );
+			$idx       = array_search( $after_id, $child_ids, true );
+			$child_ids = false !== $idx ? array_slice( $child_ids, $idx + 1 ) : array();
+		}
+
+		// Narrow by `before`: drop IDs from the cursor position onward.
+		if ( null !== $before ) {
+			$before_id = IdCursorFilter::decode_id_cursor( $before, 'before' );
+			$idx       = array_search( $before_id, $child_ids, true );
+			if ( false !== $idx ) {
+				$child_ids = array_slice( $child_ids, 0, $idx );
+			}
+		}
+
+		$total_after_cursors = count( $child_ids );
+
+		// Apply first/last limits.
+		if ( null !== $first && $first >= 0 ) {
+			$child_ids = array_slice( $child_ids, 0, $first );
+		}
+		if ( null !== $last && $last >= 0 ) {
+			$child_ids = array_slice( $child_ids, max( 0, count( $child_ids ) - $last ) );
+		}
+
+		// Relay semantics for the forward / backward branches match what
+		// ListProducts / ListCoupons use at the root level.
+		return array(
+			'ids'               => array_values( $child_ids ),
+			'has_next_page'     =>
+				null !== $first ? count( $child_ids ) < $total_after_cursors : ( null !== $before ),
+			'has_previous_page' =>
+				null !== $last ? count( $child_ids ) < $total_after_cursors : ( null !== $after ),
+		);
+	}
+
+	/**
+	 * Build a ProductVariation with type-specific fields.
+	 *
+	 * @param \WC_Product $wc_product The variation product.
+	 * @return ProductVariation
+	 */
+	private static function build_product_variation( \WC_Product $wc_product ): ProductVariation {
+		$product            = new ProductVariation();
+		$product->parent_id = $wc_product->get_parent_id();
+
+		$selected_attributes = array();
+		foreach ( $wc_product->get_attributes() as $taxonomy => $value ) {
+			$attr       = new SelectedAttribute();
+			$attr->name = $taxonomy;
+
+			// For taxonomy attributes, resolve the slug to a human-readable term name.
+			if ( taxonomy_exists( $taxonomy ) && ! empty( $value ) ) {
+				$term = get_term_by( 'slug', $value, $taxonomy );
+				if ( $term && ! is_wp_error( $term ) ) {
+					$attr->value = $term->name;
+				} else {
+					$attr->value = $value;
+				}
+			} else {
+				$attr->value = $value;
+			}
+
+			$selected_attributes[] = $attr;
+		}
+		$product->selected_attributes = $selected_attributes;
+
+		return $product;
+	}
+
+	/**
+	 * Populate the common fields shared by all product types.
+	 *
+	 * @param object      $product    The product DTO to populate.
+	 * @param \WC_Product $wc_product The WooCommerce product object.
+	 * @param ?array      $query_info Unified query info tree from the GraphQL request.
+	 */
+	private static function populate_common_fields(
+		object $product,
+		\WC_Product $wc_product,
+		?array $query_info,
+	): void {
+		$raw_status       = (string) $wc_product->get_status();
+		$raw_product_type = (string) $wc_product->get_type();
+
+		$product->id                = $wc_product->get_id();
+		$product->name              = $wc_product->get_name();
+		$product->slug              = $wc_product->get_slug();
+		$sku                        = $wc_product->get_sku();
+		$product->sku               = '' !== $sku ? $sku : null;
+		$product->description       = $wc_product->get_description();
+		$product->short_description = $wc_product->get_short_description();
+		$product->status            = ProductStatus::tryFrom( $raw_status ) ?? ProductStatus::Other;
+		$product->raw_status        = $raw_status;
+		$product->product_type      = ProductType::tryFrom( $raw_product_type ) ?? ProductType::Other;
+		$product->raw_product_type  = $raw_product_type;
+
+		// Price fields support a "formatted" argument for currency display.
+		// An empty stored value means "not set" and is surfaced as null —
+		// without this, wc_price( (float) '' ) would render as "$0.00" and
+		// be indistinguishable from a genuinely-zero price.
+		$format_regular = $query_info['regular_price']['__args']['formatted'] ?? true;
+		$raw_regular    = $wc_product->get_regular_price();
+		if ( '' === $raw_regular ) {
+			$product->regular_price = null;
+		} else {
+			$product->regular_price = $format_regular
+				? wc_price( (float) $raw_regular )
+				: $raw_regular;
+		}
+
+		$format_sale = $query_info['sale_price']['__args']['formatted'] ?? true;
+		$raw_sale    = $wc_product->get_sale_price();
+		if ( '' === $raw_sale ) {
+			$product->sale_price = null;
+		} else {
+			$product->sale_price = $format_sale
+				? wc_price( (float) $raw_sale )
+				: $raw_sale;
+		}
+
+		$raw_stock_status          = (string) $wc_product->get_stock_status();
+		$product->stock_status     = self::map_stock_status( $raw_stock_status );
+		$product->raw_stock_status = $raw_stock_status;
+		$product->stock_quantity   = $wc_product->get_stock_quantity();
+
+		// Nested output type: dimensions.
+		$product->dimensions = self::build_dimensions( $wc_product );
+
+		// Array of objects: images.
+		$product->images = self::build_images( $wc_product );
+
+		// Array of objects: attributes.
+		$product->attributes = self::build_attributes( $wc_product );
+
+		// Sub-collection connection: reviews.
+		// Only populate if explicitly requested (optimization via $query_info).
+		if ( null === $query_info || array_key_exists( 'reviews', $query_info ) ) {
+			$product->reviews = self::build_reviews( $wc_product->get_id() );
+		} else {
+			$product->reviews = self::empty_connection();
+		}
+
+		$product->date_created  = $wc_product->get_date_created()?->format( \DateTimeInterface::ATOM );
+		$product->date_modified = $wc_product->get_date_modified()?->format( \DateTimeInterface::ATOM );
+
+		// Ignored field — set to null; it won't appear in the schema.
+		$product->internal_notes = null;
+	}
+
+	/**
+	 * Map WooCommerce stock status string to the int-backed StockStatus enum.
+	 *
+	 * @param string $wc_status The WC stock status string.
+	 * @return StockStatus
+	 */
+	private static function map_stock_status( string $wc_status ): StockStatus {
+		return match ( $wc_status ) {
+			'instock'     => StockStatus::InStock,
+			'outofstock'  => StockStatus::OutOfStock,
+			'onbackorder' => StockStatus::OnBackorder,
+			default       => StockStatus::Other,
+		};
+	}
+
+	/**
+	 * Build product dimensions from a WC_Product.
+	 *
+	 * @param \WC_Product $wc_product The product.
+	 * @return ?ProductDimensions
+	 */
+	private static function build_dimensions( \WC_Product $wc_product ): ?ProductDimensions {
+		$length = $wc_product->get_length();
+		$width  = $wc_product->get_width();
+		$height = $wc_product->get_height();
+		$weight = $wc_product->get_weight();
+
+		if ( '' === $length && '' === $width && '' === $height && '' === $weight ) {
+			return null;
+		}
+
+		$dims         = new ProductDimensions();
+		$dims->length = '' !== $length ? (float) $length : null;
+		$dims->width  = '' !== $width ? (float) $width : null;
+		$dims->height = '' !== $height ? (float) $height : null;
+		$dims->weight = '' !== $weight ? (float) $weight : null;
+
+		return $dims;
+	}
+
+	/**
+	 * Build product images from a WC_Product.
+	 *
+	 * @param \WC_Product $wc_product The product.
+	 * @return ProductImage[]
+	 */
+	private static function build_images( \WC_Product $wc_product ): array {
+		$images   = array();
+		$position = 0;
+
+		// Include the featured image first.
+		$featured_id = $wc_product->get_image_id();
+		if ( $featured_id ) {
+			$image = self::build_image( (int) $featured_id, $position );
+			if ( null !== $image ) {
+				$images[] = $image;
+				++$position;
+			}
+		}
+
+		// Then gallery images.
+		foreach ( $wc_product->get_gallery_image_ids() as $image_id ) {
+			$image = self::build_image( (int) $image_id, $position );
+			if ( null !== $image ) {
+				$images[] = $image;
+				++$position;
+			}
+		}
+
+		return $images;
+	}
+
+	/**
+	 * Build product attributes from a WC_Product.
+	 *
+	 * For variations, attributes are simple key→value pairs (handled by selected_attributes),
+	 * so this returns an empty array. For other product types, it returns full attribute definitions.
+	 *
+	 * @param \WC_Product $wc_product The product.
+	 * @return ProductAttribute[]
+	 */
+	private static function build_attributes( \WC_Product $wc_product ): array {
+		// Variations store attributes as simple string values, not WC_Product_Attribute objects.
+		if ( 'variation' === $wc_product->get_type() ) {
+			return array();
+		}
+
+		$attributes = array();
+		foreach ( $wc_product->get_attributes() as $wc_attr ) {
+			if ( ! $wc_attr instanceof \WC_Product_Attribute ) {
+				continue;
+			}
+
+			$attr       = new ProductAttribute();
+			$attr->slug = $wc_attr->get_name();
+
+			if ( $wc_attr->is_taxonomy() ) {
+				$attr->name    = wc_attribute_label( $wc_attr->get_name() );
+				$attr->options = array_map(
+					function ( $term ) {
+						return $term->name;
+					},
+					$wc_attr->get_terms() ? $wc_attr->get_terms() : array()
+				);
+			} else {
+				$attr->name    = $wc_attr->get_name();
+				$attr->options = $wc_attr->get_options();
+			}
+
+			$attr->position    = $wc_attr->get_position();
+			$attr->visible     = $wc_attr->get_visible();
+			$attr->variation   = $wc_attr->get_variation();
+			$attr->is_taxonomy = $wc_attr->is_taxonomy();
+
+			$attributes[] = $attr;
+		}//end foreach
+
+		return $attributes;
+	}
+
+	/**
+	 * Build a single ProductImage from an attachment ID.
+	 *
+	 * @param int $attachment_id The WordPress attachment ID.
+	 * @param int $position      The display position.
+	 * @return ?ProductImage
+	 */
+	private static function build_image( int $attachment_id, int $position ): ?ProductImage {
+		$url = wp_get_attachment_url( $attachment_id );
+		if ( ! $url ) {
+			return null;
+		}
+
+		$image           = new ProductImage();
+		$image->id       = $attachment_id;
+		$image->url      = $url;
+		$alt             = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
+		$image->alt      = ! empty( $alt ) ? $alt : '';
+		$image->position = $position;
+
+		return $image;
+	}
+
+	/**
+	 * Build a reviews connection for a product.
+	 *
+	 * @param int $product_id The product ID.
+	 * @return Connection
+	 */
+	private static function build_reviews( int $product_id ): Connection {
+		$base_args = array(
+			'post_id' => $product_id,
+			'type'    => 'review',
+			'status'  => 'approve',
+		);
+
+		// Separate count query: otherwise `total_count` would be the page
+		// size (capped at 10) instead of the real review total.
+		$total_count = (int) get_comments( $base_args + array( 'count' => true ) );
+
+		$comments = get_comments(
+			$base_args + array(
+				'orderby' => 'comment_date',
+				'order'   => 'DESC',
+				'number'  => 10,
+			)
+		);
+
+		$edges = array();
+		$nodes = array();
+
+		foreach ( $comments as $comment ) {
+			$review               = new ProductReview();
+			$review->id           = (int) $comment->comment_ID;
+			$review->product_id   = $product_id;
+			$review->reviewer     = $comment->comment_author;
+			$review->review       = $comment->comment_content;
+			$review->rating       = (int) get_comment_meta( $comment->comment_ID, 'rating', true );
+			$review->date_created = $comment->comment_date_gmt
+				? ( new \DateTimeImmutable( $comment->comment_date_gmt, new \DateTimeZone( 'UTC' ) ) )->format( \DateTimeInterface::ATOM )
+				: null;
+
+			$edge         = new Edge();
+			$edge->cursor = base64_encode( (string) $review->id );
+			$edge->node   = $review;
+
+			$edges[] = $edge;
+			$nodes[] = $review;
+		}
+
+		$page_info                    = new PageInfo();
+		$page_info->has_next_page     = $total_count > count( $comments );
+		$page_info->has_previous_page = false;
+		$page_info->start_cursor      = ! empty( $edges ) ? $edges[0]->cursor : null;
+		$page_info->end_cursor        = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+		$connection              = new Connection();
+		$connection->edges       = $edges;
+		$connection->nodes       = $nodes;
+		$connection->page_info   = $page_info;
+		$connection->total_count = $total_count;
+
+		return $connection;
+	}
+
+	/**
+	 * Extract the per-node selection from a connection's query_info entry.
+	 *
+	 * Connections can be queried via `nodes { ... }` (the plain form) or
+	 * `edges { node { ... } }` (Relay form); clients may use either or both.
+	 * The per-node selection is what gets forwarded to the recursive
+	 * mapper call so each node is built with the right sub-fields.
+	 *
+	 * @param ?array $connection_info The query_info entry for the connection (e.g. `$query_info['variations']`).
+	 * @return ?array The merged per-node selection, or null when the caller didn't request any node fields.
+	 */
+	public static function connection_node_info( ?array $connection_info ): ?array {
+		if ( null === $connection_info ) {
+			return null;
+		}
+		$nodes = is_array( $connection_info['nodes'] ?? null ) ? $connection_info['nodes'] : array();
+		$edge  = is_array( $connection_info['edges']['node'] ?? null ) ? $connection_info['edges']['node'] : array();
+		if ( empty( $nodes ) && empty( $edge ) ) {
+			return null;
+		}
+		return array_merge( $edge, $nodes );
+	}
+
+	/**
+	 * Return an empty connection (for skipped sub-collections).
+	 *
+	 * @return Connection
+	 */
+	private static function empty_connection(): Connection {
+		$page_info                    = new PageInfo();
+		$page_info->has_next_page     = false;
+		$page_info->has_previous_page = false;
+		$page_info->start_cursor      = null;
+		$page_info->end_cursor        = null;
+
+		$connection              = new Connection();
+		$connection->edges       = array();
+		$connection->nodes       = array();
+		$connection->page_info   = $page_info;
+		$connection->total_count = 0;
+
+		return $connection;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php b/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php
new file mode 100644
index 00000000000..99e8a641ff9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Products;
+
+/**
+ * Repository for product persistence operations.
+ *
+ * Designed to be injected via the DI container into commands
+ * that need to load or save products.
+ */
+class ProductRepository {
+	/**
+	 * Find a product by ID.
+	 *
+	 * @param int $id The product ID.
+	 * @return ?\WC_Product The product, or null if not found.
+	 */
+	public function find( int $id ): ?\WC_Product {
+		$product = wc_get_product( $id );
+		return $product instanceof \WC_Product ? $product : null;
+	}
+
+	/**
+	 * Save a product.
+	 *
+	 * @param \WC_Product $product The product to save.
+	 */
+	public function save( \WC_Product $product ): void {
+		$product->save();
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
new file mode 100644
index 00000000000..3bf7a736ea3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
@@ -0,0 +1,128 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\CreateCoupon as CreateCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\CreateCoupon as CreateCouponInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class CreateCoupon {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( CouponType::get() ),
+			'description' => __( 'Create a new coupon.', 'woocommerce' ),
+			'args'        => array(
+				'input' => array(
+					'type'        => Type::nonNull( CreateCouponInput::get() ),
+					'description' => __( 'Data for the new coupon.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+
+		$command = wc_get_container()->get( CreateCouponCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'input', $args ) ) {
+			$execute_args['input'] = self::convert_create_coupon_input( $args['input'] );
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	private static function convert_create_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput();
+
+		if ( array_key_exists( 'code', $data ) ) {
+			$input->mark_provided( 'code' );
+				$input->code = $data['code'];
+		}
+		if ( array_key_exists( 'description', $data ) ) {
+			$input->mark_provided( 'description' );
+					$input->description = $data['description'];
+		}
+		if ( array_key_exists( 'discount_type', $data ) ) {
+			$input->mark_provided( 'discount_type' );
+					$input->discount_type = $data['discount_type'];
+		}
+		if ( array_key_exists( 'amount', $data ) ) {
+			$input->mark_provided( 'amount' );
+					$input->amount = $data['amount'];
+		}
+		if ( array_key_exists( 'status', $data ) ) {
+			$input->mark_provided( 'status' );
+					$input->status = $data['status'];
+		}
+		if ( array_key_exists( 'date_expires', $data ) ) {
+			$input->mark_provided( 'date_expires' );
+					$input->date_expires = $data['date_expires'];
+		}
+		if ( array_key_exists( 'individual_use', $data ) ) {
+			$input->mark_provided( 'individual_use' );
+					$input->individual_use = $data['individual_use'];
+		}
+		if ( array_key_exists( 'product_ids', $data ) ) {
+			$input->mark_provided( 'product_ids' );
+					$input->product_ids = $data['product_ids'];
+		}
+		if ( array_key_exists( 'excluded_product_ids', $data ) ) {
+			$input->mark_provided( 'excluded_product_ids' );
+					$input->excluded_product_ids = $data['excluded_product_ids'];
+		}
+		if ( array_key_exists( 'usage_limit', $data ) ) {
+			$input->mark_provided( 'usage_limit' );
+					$input->usage_limit = $data['usage_limit'];
+		}
+		if ( array_key_exists( 'usage_limit_per_user', $data ) ) {
+			$input->mark_provided( 'usage_limit_per_user' );
+					$input->usage_limit_per_user = $data['usage_limit_per_user'];
+		}
+		if ( array_key_exists( 'limit_usage_to_x_items', $data ) ) {
+			$input->mark_provided( 'limit_usage_to_x_items' );
+					$input->limit_usage_to_x_items = $data['limit_usage_to_x_items'];
+		}
+		if ( array_key_exists( 'free_shipping', $data ) ) {
+			$input->mark_provided( 'free_shipping' );
+					$input->free_shipping = $data['free_shipping'];
+		}
+		if ( array_key_exists( 'product_categories', $data ) ) {
+			$input->mark_provided( 'product_categories' );
+					$input->product_categories = $data['product_categories'];
+		}
+		if ( array_key_exists( 'excluded_product_categories', $data ) ) {
+			$input->mark_provided( 'excluded_product_categories' );
+					$input->excluded_product_categories = $data['excluded_product_categories'];
+		}
+		if ( array_key_exists( 'exclude_sale_items', $data ) ) {
+			$input->mark_provided( 'exclude_sale_items' );
+					$input->exclude_sale_items = $data['exclude_sale_items'];
+		}
+		if ( array_key_exists( 'minimum_amount', $data ) ) {
+			$input->mark_provided( 'minimum_amount' );
+					$input->minimum_amount = $data['minimum_amount'];
+		}
+		if ( array_key_exists( 'maximum_amount', $data ) ) {
+			$input->mark_provided( 'maximum_amount' );
+					$input->maximum_amount = $data['maximum_amount'];
+		}
+		if ( array_key_exists( 'email_restrictions', $data ) ) {
+			$input->mark_provided( 'email_restrictions' );
+					$input->email_restrictions = $data['email_restrictions'];
+		}
+
+		return $input;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
new file mode 100644
index 00000000000..cce22433600
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
@@ -0,0 +1,123 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\CreateProduct as CreateProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\CreateProduct as CreateProductInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class CreateProduct {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( ProductInterface::get() ),
+			'description' => __( 'Create a new product.', 'woocommerce' ),
+			'args'        => array(
+				'input' => array(
+					'type'        => Type::nonNull( CreateProductInput::get() ),
+					'description' => __( 'Data for the new product.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'edit_products' );
+
+		$command = wc_get_container()->get( CreateProductCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'input', $args ) ) {
+			$execute_args['input'] = self::convert_create_product_input( $args['input'] );
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();
+
+		if ( array_key_exists( 'length', $data ) ) {
+			$input->mark_provided( 'length' );
+				$input->length = $data['length'];
+		}
+		if ( array_key_exists( 'width', $data ) ) {
+			$input->mark_provided( 'width' );
+					$input->width = $data['width'];
+		}
+		if ( array_key_exists( 'height', $data ) ) {
+			$input->mark_provided( 'height' );
+					$input->height = $data['height'];
+		}
+		if ( array_key_exists( 'weight', $data ) ) {
+			$input->mark_provided( 'weight' );
+					$input->weight = $data['weight'];
+		}
+
+		return $input;
+	}
+
+	private static function convert_create_product_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput();
+
+		if ( array_key_exists( 'name', $data ) ) {
+			$input->mark_provided( 'name' );
+				$input->name = $data['name'];
+		}
+		if ( array_key_exists( 'slug', $data ) ) {
+			$input->mark_provided( 'slug' );
+					$input->slug = $data['slug'];
+		}
+		if ( array_key_exists( 'sku', $data ) ) {
+			$input->mark_provided( 'sku' );
+					$input->sku = $data['sku'];
+		}
+		if ( array_key_exists( 'description', $data ) ) {
+			$input->mark_provided( 'description' );
+					$input->description = $data['description'];
+		}
+		if ( array_key_exists( 'short_description', $data ) ) {
+			$input->mark_provided( 'short_description' );
+					$input->short_description = $data['short_description'];
+		}
+		if ( array_key_exists( 'status', $data ) ) {
+			$input->mark_provided( 'status' );
+					$input->status = $data['status'];
+		}
+		if ( array_key_exists( 'product_type', $data ) ) {
+			$input->mark_provided( 'product_type' );
+					$input->product_type = $data['product_type'];
+		}
+		if ( array_key_exists( 'regular_price', $data ) ) {
+			$input->mark_provided( 'regular_price' );
+					$input->regular_price = $data['regular_price'];
+		}
+		if ( array_key_exists( 'sale_price', $data ) ) {
+			$input->mark_provided( 'sale_price' );
+					$input->sale_price = $data['sale_price'];
+		}
+		if ( array_key_exists( 'manage_stock', $data ) ) {
+			$input->mark_provided( 'manage_stock' );
+					$input->manage_stock = $data['manage_stock'];
+		}
+		if ( array_key_exists( 'stock_quantity', $data ) ) {
+			$input->mark_provided( 'stock_quantity' );
+					$input->stock_quantity = $data['stock_quantity'];
+		}
+		if ( array_key_exists( 'dimensions', $data ) ) {
+			$input->mark_provided( 'dimensions' );
+					$input->dimensions = null !== $data['dimensions'] ? self::convert_dimensions_input( $data['dimensions'] ) : null;
+		}
+
+		return $input;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
new file mode 100644
index 00000000000..68a812df341
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\DeleteCoupon as DeleteCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\DeleteCouponResult as DeleteCouponResultType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class DeleteCoupon {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( DeleteCouponResultType::get() ),
+			'description' => __( 'Delete a coupon.', 'woocommerce' ),
+			'args'        => array(
+				'id'    => array(
+					'type'        => Type::nonNull( Type::int() ),
+					'description' => __( 'The ID of the coupon to delete.', 'woocommerce' ),
+				),
+				'force' => array(
+					'type'         => Type::nonNull( Type::boolean() ),
+					'description'  => __( 'Whether to permanently delete the coupon (bypass trash).', 'woocommerce' ),
+					'defaultValue' => false,
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+
+		$command = wc_get_container()->get( DeleteCouponCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'id', $args ) ) {
+			$execute_args['id'] = $args['id'];
+		}
+		if ( array_key_exists( 'force', $args ) ) {
+			$execute_args['force'] = $args['force'];
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
new file mode 100644
index 00000000000..202eaab89c8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\DeleteProduct as DeleteProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class DeleteProduct {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull(
+				new \GraphQL\Type\Definition\ObjectType(
+					array(
+						'name'   => 'DeleteProductResult',
+						'fields' => array(
+							'result' => array( 'type' => Type::nonNull( Type::boolean() ) ),
+						),
+					)
+				)
+			),
+			'description' => __( 'Delete a product.', 'woocommerce' ),
+			'args'        => array(
+				'id'    => array(
+					'type'        => Type::nonNull( Type::int() ),
+					'description' => __( 'The ID of the product to delete.', 'woocommerce' ),
+				),
+				'force' => array(
+					'type'         => Type::nonNull( Type::boolean() ),
+					'description'  => __( 'Whether to permanently delete the product (bypass trash).', 'woocommerce' ),
+					'defaultValue' => false,
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+
+		$command = wc_get_container()->get( DeleteProductCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'id', $args ) ) {
+			$execute_args['id'] = $args['id'];
+		}
+		if ( array_key_exists( 'force', $args ) ) {
+			$execute_args['force'] = $args['force'];
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return array( 'result' => $result );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
new file mode 100644
index 00000000000..73dc05ab0a4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
@@ -0,0 +1,132 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\UpdateCoupon as UpdateCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\UpdateCoupon as UpdateCouponInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class UpdateCoupon {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( CouponType::get() ),
+			'description' => __( 'Update an existing coupon.', 'woocommerce' ),
+			'args'        => array(
+				'input' => array(
+					'type'        => Type::nonNull( UpdateCouponInput::get() ),
+					'description' => __( 'The fields to update.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+
+		$command = wc_get_container()->get( UpdateCouponCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'input', $args ) ) {
+			$execute_args['input'] = self::convert_update_coupon_input( $args['input'] );
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	private static function convert_update_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput();
+
+		if ( array_key_exists( 'id', $data ) ) {
+			$input->mark_provided( 'id' );
+				$input->id = $data['id'];
+		}
+		if ( array_key_exists( 'code', $data ) ) {
+			$input->mark_provided( 'code' );
+					$input->code = $data['code'];
+		}
+		if ( array_key_exists( 'description', $data ) ) {
+			$input->mark_provided( 'description' );
+					$input->description = $data['description'];
+		}
+		if ( array_key_exists( 'discount_type', $data ) ) {
+			$input->mark_provided( 'discount_type' );
+					$input->discount_type = $data['discount_type'];
+		}
+		if ( array_key_exists( 'amount', $data ) ) {
+			$input->mark_provided( 'amount' );
+					$input->amount = $data['amount'];
+		}
+		if ( array_key_exists( 'status', $data ) ) {
+			$input->mark_provided( 'status' );
+					$input->status = $data['status'];
+		}
+		if ( array_key_exists( 'date_expires', $data ) ) {
+			$input->mark_provided( 'date_expires' );
+					$input->date_expires = $data['date_expires'];
+		}
+		if ( array_key_exists( 'individual_use', $data ) ) {
+			$input->mark_provided( 'individual_use' );
+					$input->individual_use = $data['individual_use'];
+		}
+		if ( array_key_exists( 'product_ids', $data ) ) {
+			$input->mark_provided( 'product_ids' );
+					$input->product_ids = $data['product_ids'];
+		}
+		if ( array_key_exists( 'excluded_product_ids', $data ) ) {
+			$input->mark_provided( 'excluded_product_ids' );
+					$input->excluded_product_ids = $data['excluded_product_ids'];
+		}
+		if ( array_key_exists( 'usage_limit', $data ) ) {
+			$input->mark_provided( 'usage_limit' );
+					$input->usage_limit = $data['usage_limit'];
+		}
+		if ( array_key_exists( 'usage_limit_per_user', $data ) ) {
+			$input->mark_provided( 'usage_limit_per_user' );
+					$input->usage_limit_per_user = $data['usage_limit_per_user'];
+		}
+		if ( array_key_exists( 'limit_usage_to_x_items', $data ) ) {
+			$input->mark_provided( 'limit_usage_to_x_items' );
+					$input->limit_usage_to_x_items = $data['limit_usage_to_x_items'];
+		}
+		if ( array_key_exists( 'free_shipping', $data ) ) {
+			$input->mark_provided( 'free_shipping' );
+					$input->free_shipping = $data['free_shipping'];
+		}
+		if ( array_key_exists( 'product_categories', $data ) ) {
+			$input->mark_provided( 'product_categories' );
+					$input->product_categories = $data['product_categories'];
+		}
+		if ( array_key_exists( 'excluded_product_categories', $data ) ) {
+			$input->mark_provided( 'excluded_product_categories' );
+					$input->excluded_product_categories = $data['excluded_product_categories'];
+		}
+		if ( array_key_exists( 'exclude_sale_items', $data ) ) {
+			$input->mark_provided( 'exclude_sale_items' );
+					$input->exclude_sale_items = $data['exclude_sale_items'];
+		}
+		if ( array_key_exists( 'minimum_amount', $data ) ) {
+			$input->mark_provided( 'minimum_amount' );
+					$input->minimum_amount = $data['minimum_amount'];
+		}
+		if ( array_key_exists( 'maximum_amount', $data ) ) {
+			$input->mark_provided( 'maximum_amount' );
+					$input->maximum_amount = $data['maximum_amount'];
+		}
+		if ( array_key_exists( 'email_restrictions', $data ) ) {
+			$input->mark_provided( 'email_restrictions' );
+					$input->email_restrictions = $data['email_restrictions'];
+		}
+
+		return $input;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
new file mode 100644
index 00000000000..c8706bc73f2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
@@ -0,0 +1,127 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\UpdateProduct as UpdateProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\UpdateProduct as UpdateProductInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class UpdateProduct {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( ProductInterface::get() ),
+			'description' => __( 'Update an existing product.', 'woocommerce' ),
+			'args'        => array(
+				'input' => array(
+					'type'        => Type::nonNull( UpdateProductInput::get() ),
+					'description' => __( 'The fields to update.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+
+		$command = wc_get_container()->get( UpdateProductCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'input', $args ) ) {
+			$execute_args['input'] = self::convert_update_product_input( $args['input'] );
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();
+
+		if ( array_key_exists( 'length', $data ) ) {
+			$input->mark_provided( 'length' );
+				$input->length = $data['length'];
+		}
+		if ( array_key_exists( 'width', $data ) ) {
+			$input->mark_provided( 'width' );
+					$input->width = $data['width'];
+		}
+		if ( array_key_exists( 'height', $data ) ) {
+			$input->mark_provided( 'height' );
+					$input->height = $data['height'];
+		}
+		if ( array_key_exists( 'weight', $data ) ) {
+			$input->mark_provided( 'weight' );
+					$input->weight = $data['weight'];
+		}
+
+		return $input;
+	}
+
+	private static function convert_update_product_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput {
+		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput();
+
+		if ( array_key_exists( 'id', $data ) ) {
+			$input->mark_provided( 'id' );
+				$input->id = $data['id'];
+		}
+		if ( array_key_exists( 'name', $data ) ) {
+			$input->mark_provided( 'name' );
+					$input->name = $data['name'];
+		}
+		if ( array_key_exists( 'slug', $data ) ) {
+			$input->mark_provided( 'slug' );
+					$input->slug = $data['slug'];
+		}
+		if ( array_key_exists( 'sku', $data ) ) {
+			$input->mark_provided( 'sku' );
+					$input->sku = $data['sku'];
+		}
+		if ( array_key_exists( 'description', $data ) ) {
+			$input->mark_provided( 'description' );
+					$input->description = $data['description'];
+		}
+		if ( array_key_exists( 'short_description', $data ) ) {
+			$input->mark_provided( 'short_description' );
+					$input->short_description = $data['short_description'];
+		}
+		if ( array_key_exists( 'status', $data ) ) {
+			$input->mark_provided( 'status' );
+					$input->status = $data['status'];
+		}
+		if ( array_key_exists( 'product_type', $data ) ) {
+			$input->mark_provided( 'product_type' );
+					$input->product_type = $data['product_type'];
+		}
+		if ( array_key_exists( 'regular_price', $data ) ) {
+			$input->mark_provided( 'regular_price' );
+					$input->regular_price = $data['regular_price'];
+		}
+		if ( array_key_exists( 'sale_price', $data ) ) {
+			$input->mark_provided( 'sale_price' );
+					$input->sale_price = $data['sale_price'];
+		}
+		if ( array_key_exists( 'manage_stock', $data ) ) {
+			$input->mark_provided( 'manage_stock' );
+					$input->manage_stock = $data['manage_stock'];
+		}
+		if ( array_key_exists( 'stock_quantity', $data ) ) {
+			$input->mark_provided( 'stock_quantity' );
+					$input->stock_quantity = $data['stock_quantity'];
+		}
+		if ( array_key_exists( 'dimensions', $data ) ) {
+			$input->mark_provided( 'dimensions' );
+					$input->dimensions = null !== $data['dimensions'] ? self::convert_dimensions_input( $data['dimensions'] ) : null;
+		}
+
+		return $input;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
new file mode 100644
index 00000000000..3be3f63b5ea
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
@@ -0,0 +1,53 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Coupons\GetCoupon as GetCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class GetCoupon {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => CouponType::get(),
+			'description' => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
+			'args'        => array(
+				'id'   => array(
+					'type'         => Type::int(),
+					'description'  => __( 'The ID of the coupon to retrieve.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'code' => array(
+					'type'         => Type::string(),
+					'description'  => __( 'The coupon code to look up.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'read_private_shop_coupons' );
+
+		$command = wc_get_container()->get( GetCouponCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'id', $args ) ) {
+			$execute_args['id'] = $args['id'];
+		}
+		if ( array_key_exists( 'code', $args ) ) {
+			$execute_args['code'] = $args['code'];
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
new file mode 100644
index 00000000000..74c9abd2459
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Products\GetProduct as GetProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class GetProduct {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => ProductInterface::get(),
+			'description' => __( 'Retrieve a single product by ID.', 'woocommerce' ),
+			'args'        => array(
+				'id' => array(
+					'type'        => Type::nonNull( Type::int() ),
+					'description' => __( 'The ID of the product to retrieve.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		$command = wc_get_container()->get( GetProductCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'id', $args ) ) {
+			$execute_args['id'] = $args['id'];
+		}
+		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+
+		if ( ! Utils::authorize_command(
+			$command,
+			array(
+				'id'             => $execute_args['id'],
+				'_preauthorized' => current_user_can( 'read_product' ),
+			)
+		) ) {
+			throw new \GraphQL\Error\Error(
+				'You do not have permission to perform this action.',
+				extensions: array( 'code' => 'UNAUTHORIZED' )
+			);
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
new file mode 100644
index 00000000000..31ba564f3bc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
@@ -0,0 +1,68 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Coupons\ListCoupons as ListCouponsCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\CouponConnection as CouponConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class ListCoupons {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( CouponConnectionType::get() ),
+			'description' => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
+			'args'        => array(
+				'first'  => array(
+					'type'         => Type::int(),
+					'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'last'   => array(
+					'type'         => Type::int(),
+					'description'  => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'after'  => array(
+					'type'         => Type::string(),
+					'description'  => __( 'Return results after this cursor.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'before' => array(
+					'type'         => Type::string(),
+					'description'  => __( 'Return results before this cursor.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'status' => array(
+					'type'         => CouponStatusType::get(),
+					'description'  => __( 'Filter by coupon status.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+			),
+			'complexity'  => Utils::complexity_from_pagination( ... ),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'read_private_shop_coupons' );
+
+		$command = wc_get_container()->get( ListCouponsCommand::class );
+
+		$execute_args               = array();
+		$execute_args['pagination'] = Utils::create_pagination_params( $args );
+		if ( array_key_exists( 'status', $args ) ) {
+			$execute_args['status'] = $args['status'];
+		}
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
new file mode 100644
index 00000000000..288c0a7fb0c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
@@ -0,0 +1,94 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Products\ListProducts as ListProductsCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductConnection as ProductConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class ListProducts {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( ProductConnectionType::get() ),
+			'description' => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
+			'args'        => array(
+				'first'        => array(
+					'type'         => Type::int(),
+					'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'last'         => array(
+					'type'         => Type::int(),
+					'description'  => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'after'        => array(
+					'type'         => Type::string(),
+					'description'  => __( 'Return results after this cursor.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'before'       => array(
+					'type'         => Type::string(),
+					'description'  => __( 'Return results before this cursor.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'status'       => array(
+					'type'         => ProductStatusType::get(),
+					'description'  => __( 'Filter by product status.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'stock_status' => array(
+					'type'         => StockStatusType::get(),
+					'description'  => __( 'Filter by stock status.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'search'       => array(
+					'type'         => Type::string(),
+					'description'  => __( 'Search products by keyword.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+				'product_type' => array(
+					'type'         => ProductTypeType::get(),
+					'description'  => __( 'Filter by product type.', 'woocommerce' ),
+					'defaultValue' => null,
+				),
+			),
+			'complexity'  => Utils::complexity_from_pagination( ... ),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		Utils::check_current_user_can( 'manage_woocommerce' );
+		Utils::check_current_user_can( 'edit_products' );
+
+		$command = wc_get_container()->get( ListProductsCommand::class );
+
+		$execute_args               = array();
+		$execute_args['pagination'] = Utils::create_pagination_params( $args );
+		$execute_args['filters']    = Utils::create_input(
+			fn() => new \Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput(
+				status: $args['status'],
+				stock_status: $args['stock_status'],
+				search: $args['search'] ?? null,
+			)
+		);
+		if ( array_key_exists( 'product_type', $args ) ) {
+			$execute_args['product_type'] = $args['product_type'];
+		}
+		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+
+		$result = Utils::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php
new file mode 100644
index 00000000000..9fa17254a17
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus as CouponStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class CouponStatus {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name'        => 'CouponStatus',
+					'description' => __( 'The publication status of a coupon.', 'woocommerce' ),
+					'values'      => array(
+						'PUBLISHED' => array(
+							'value'       => CouponStatusEnum::Published,
+							'description' => __( 'The coupon is published and active.', 'woocommerce' ),
+						),
+						'DRAFT'     => array(
+							'value'       => CouponStatusEnum::Draft,
+							'description' => __( 'The coupon is a draft.', 'woocommerce' ),
+						),
+						'PENDING'   => array(
+							'value'       => CouponStatusEnum::Pending,
+							'description' => __( 'The coupon is pending review.', 'woocommerce' ),
+						),
+						'PRIVATE'   => array(
+							'value'       => CouponStatusEnum::Private,
+							'description' => __( 'The coupon is privately published.', 'woocommerce' ),
+						),
+						'FUTURE'    => array(
+							'value'       => CouponStatusEnum::Future,
+							'description' => __( 'The coupon is scheduled to be published in the future.', 'woocommerce' ),
+						),
+						'TRASH'     => array(
+							'value'       => CouponStatusEnum::Trash,
+							'description' => __( 'The coupon is in the trash.', 'woocommerce' ),
+						),
+						'OTHER'     => array(
+							'value'       => CouponStatusEnum::Other,
+							'description' => __( 'The coupon status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php
new file mode 100644
index 00000000000..2b105555b3c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType as DiscountTypeEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class DiscountType {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name'        => 'DiscountType',
+					'description' => __( 'The type of discount for a coupon.', 'woocommerce' ),
+					'values'      => array(
+						'PERCENT'       => array(
+							'value'       => DiscountTypeEnum::Percent,
+							'description' => __( 'A percentage discount.', 'woocommerce' ),
+						),
+						'FIXED_CART'    => array(
+							'value'       => DiscountTypeEnum::FixedCart,
+							'description' => __( 'A fixed amount discount applied to the cart.', 'woocommerce' ),
+						),
+						'FIXED_PRODUCT' => array(
+							'value'       => DiscountTypeEnum::FixedProduct,
+							'description' => __( 'A fixed amount discount applied to each eligible product.', 'woocommerce' ),
+						),
+						'OTHER'         => array(
+							'value'       => DiscountTypeEnum::Other,
+							'description' => __( 'The discount type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_discount_type for the underlying value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php
new file mode 100644
index 00000000000..ca81d4016a0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus as ProductStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class ProductStatus {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name'        => 'ProductStatus',
+					'description' => __( 'The publication status of a product.', 'woocommerce' ),
+					'values'      => array(
+						'DRAFT'   => array(
+							'value'       => ProductStatusEnum::Draft,
+							'description' => __( 'The product is a draft.', 'woocommerce' ),
+						),
+						'PENDING' => array(
+							'value'       => ProductStatusEnum::Pending,
+							'description' => __( 'The product is pending review.', 'woocommerce' ),
+						),
+						'ACTIVE'  => array(
+							'value'       => ProductStatusEnum::Published,
+							'description' => __( 'The product is published and visible.', 'woocommerce' ),
+						),
+						'PRIVATE' => array(
+							'value'       => ProductStatusEnum::Private,
+							'description' => __( 'The product is privately published.', 'woocommerce' ),
+						),
+						'FUTURE'  => array(
+							'value'       => ProductStatusEnum::Future,
+							'description' => __( 'The product is scheduled to be published in the future.', 'woocommerce' ),
+						),
+						'TRASH'   => array(
+							'value'             => ProductStatusEnum::Trash,
+							'description'       => __( 'The product is in the trash.', 'woocommerce' ),
+							'deprecationReason' => 'Trashed products should be excluded via status filter.',
+						),
+						'OTHER'   => array(
+							'value'       => ProductStatusEnum::Other,
+							'description' => __( 'The product status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php
new file mode 100644
index 00000000000..c24684d12e7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php
@@ -0,0 +1,51 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductType as ProductTypeEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class ProductType {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name'        => 'ProductType',
+					'description' => __( 'The type of a WooCommerce product.', 'woocommerce' ),
+					'values'      => array(
+						'SIMPLE'    => array(
+							'value'       => ProductTypeEnum::Simple,
+							'description' => __( 'A simple product.', 'woocommerce' ),
+						),
+						'GROUPED'   => array(
+							'value'       => ProductTypeEnum::Grouped,
+							'description' => __( 'A grouped product.', 'woocommerce' ),
+						),
+						'EXTERNAL'  => array(
+							'value'       => ProductTypeEnum::External,
+							'description' => __( 'An external/affiliate product.', 'woocommerce' ),
+						),
+						'VARIABLE'  => array(
+							'value'       => ProductTypeEnum::Variable,
+							'description' => __( 'A variable product with variations.', 'woocommerce' ),
+						),
+						'VARIATION' => array(
+							'value'       => ProductTypeEnum::Variation,
+							'description' => __( 'A product variation.', 'woocommerce' ),
+						),
+						'OTHER'     => array(
+							'value'       => ProductTypeEnum::Other,
+							'description' => __( 'The product type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_product_type for the underlying value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php
new file mode 100644
index 00000000000..a28c18b46ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus as StockStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class StockStatus {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name'        => 'StockStatus',
+					'description' => __( 'The stock status of a product.', 'woocommerce' ),
+					'values'      => array(
+						'IN_STOCK'     => array(
+							'value'       => StockStatusEnum::InStock,
+							'description' => __( 'The product is in stock.', 'woocommerce' ),
+						),
+						'OUT_OF_STOCK' => array(
+							'value'       => StockStatusEnum::OutOfStock,
+							'description' => __( 'The product is out of stock.', 'woocommerce' ),
+						),
+						'ON_BACKORDER' => array(
+							'value'       => StockStatusEnum::OnBackorder,
+							'description' => __( 'The product is on backorder.', 'woocommerce' ),
+						),
+						'OTHER'        => array(
+							'value'       => StockStatusEnum::Other,
+							'description' => __( 'The stock status is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_stock_status for the underlying value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php
new file mode 100644
index 00000000000..85df973eaee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php
@@ -0,0 +1,105 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CreateCoupon {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'CreateCouponInput',
+					'description' => __( 'Data required to create a new coupon.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'code'                        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The coupon code.', 'woocommerce' ),
+						),
+						'description'                 => array(
+							'type'        => Type::string(),
+							'description' => __( 'The coupon description.', 'woocommerce' ),
+						),
+						'discount_type'               => array(
+							'type'        => DiscountTypeType::get(),
+							'description' => __( 'The type of discount.', 'woocommerce' ),
+						),
+						'amount'                      => array(
+							'type'        => Type::float(),
+							'description' => __( 'The discount amount.', 'woocommerce' ),
+						),
+						'status'                      => array(
+							'type'        => CouponStatusType::get(),
+							'description' => __( 'The coupon status.', 'woocommerce' ),
+						),
+						'date_expires'                => array(
+							'type'        => Type::string(),
+							'description' => __( 'The date the coupon expires (ISO 8601).', 'woocommerce' ),
+						),
+						'individual_use'              => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+						),
+						'product_ids'                 => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+						),
+						'excluded_product_ids'        => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'usage_limit'                 => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+						),
+						'usage_limit_per_user'        => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+						),
+						'limit_usage_to_x_items'      => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+						),
+						'free_shipping'               => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+						),
+						'product_categories'          => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+						),
+						'excluded_product_categories' => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'exclude_sale_items'          => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+						),
+						'minimum_amount'              => array(
+							'type'        => Type::float(),
+							'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+						),
+						'maximum_amount'              => array(
+							'type'        => Type::float(),
+							'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+						),
+						'email_restrictions'          => array(
+							'type'        => Type::listOf( Type::nonNull( Type::string() ) ),
+							'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php
new file mode 100644
index 00000000000..e2b5306964b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php
@@ -0,0 +1,78 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\Dimensions as DimensionsInput;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CreateProduct {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'CreateProductInput',
+					'description' => __( 'Data required to create a new product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'name'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'        => Type::string(),
+							'description' => __( 'The short product description.', 'woocommerce' ),
+						),
+						'status'            => array(
+							'type'        => ProductStatusType::get(),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => ProductTypeType::get(),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::float(),
+							'description' => __( 'The regular price.', 'woocommerce' ),
+						),
+						'sale_price'        => array(
+							'type'        => Type::float(),
+							'description' => __( 'The sale price.', 'woocommerce' ),
+						),
+						'manage_stock'      => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether to manage stock.', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => DimensionsInput::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php
new file mode 100644
index 00000000000..fa2b8018763
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class Dimensions {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'DimensionsInput',
+					'description' => __( 'Physical dimensions and weight for a product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'length' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product length.', 'woocommerce' ),
+						),
+						'width'  => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product width.', 'woocommerce' ),
+						),
+						'height' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product height.', 'woocommerce' ),
+						),
+						'weight' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product weight.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php
new file mode 100644
index 00000000000..a7c039c5d1d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductFilter {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'ProductFilterInput',
+					'description' => __( 'Filter criteria for listing products.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'status'       => array(
+							'type'        => ProductStatusType::get(),
+							'description' => __( 'Filter by product status.', 'woocommerce' ),
+						),
+						'stock_status' => array(
+							'type'        => StockStatusType::get(),
+							'description' => __( 'Filter by stock status.', 'woocommerce' ),
+						),
+						'search'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'Search products by keyword.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php
new file mode 100644
index 00000000000..e03a703981a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php
@@ -0,0 +1,109 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class UpdateCoupon {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'UpdateCouponInput',
+					'description' => __( 'Data for updating an existing coupon. All fields are optional.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id'                          => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The ID of the coupon to update.', 'woocommerce' ),
+						),
+						'code'                        => array(
+							'type'        => Type::string(),
+							'description' => __( 'The coupon code.', 'woocommerce' ),
+						),
+						'description'                 => array(
+							'type'        => Type::string(),
+							'description' => __( 'The coupon description.', 'woocommerce' ),
+						),
+						'discount_type'               => array(
+							'type'        => DiscountTypeType::get(),
+							'description' => __( 'The type of discount.', 'woocommerce' ),
+						),
+						'amount'                      => array(
+							'type'        => Type::float(),
+							'description' => __( 'The discount amount.', 'woocommerce' ),
+						),
+						'status'                      => array(
+							'type'        => CouponStatusType::get(),
+							'description' => __( 'The coupon status.', 'woocommerce' ),
+						),
+						'date_expires'                => array(
+							'type'        => Type::string(),
+							'description' => __( 'The date the coupon expires (ISO 8601).', 'woocommerce' ),
+						),
+						'individual_use'              => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+						),
+						'product_ids'                 => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+						),
+						'excluded_product_ids'        => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'usage_limit'                 => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+						),
+						'usage_limit_per_user'        => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+						),
+						'limit_usage_to_x_items'      => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+						),
+						'free_shipping'               => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+						),
+						'product_categories'          => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+						),
+						'excluded_product_categories' => array(
+							'type'        => Type::listOf( Type::nonNull( Type::int() ) ),
+							'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'exclude_sale_items'          => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+						),
+						'minimum_amount'              => array(
+							'type'        => Type::float(),
+							'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+						),
+						'maximum_amount'              => array(
+							'type'        => Type::float(),
+							'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+						),
+						'email_restrictions'          => array(
+							'type'        => Type::listOf( Type::nonNull( Type::string() ) ),
+							'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php
new file mode 100644
index 00000000000..765a0083c2d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\Dimensions as DimensionsInput;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class UpdateProduct {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name'        => 'UpdateProductInput',
+					'description' => __( 'Data for updating an existing product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id'                => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The ID of the product to update.', 'woocommerce' ),
+						),
+						'name'              => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'        => Type::string(),
+							'description' => __( 'The short product description.', 'woocommerce' ),
+						),
+						'status'            => array(
+							'type'        => ProductStatusType::get(),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => ProductTypeType::get(),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::float(),
+							'description' => __( 'The regular price.', 'woocommerce' ),
+						),
+						'sale_price'        => array(
+							'type'        => Type::float(),
+							'description' => __( 'The sale price.', 'woocommerce' ),
+						),
+						'manage_stock'      => array(
+							'type'        => Type::boolean(),
+							'description' => __( 'Whether to manage stock.', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => DimensionsInput::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php
new file mode 100644
index 00000000000..3b0cbb3fb3e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class ObjectWithId {
+	private static ?InterfaceType $instance = null;
+
+	public static function get(): InterfaceType {
+		if ( null === self::$instance ) {
+			self::$instance = new InterfaceType(
+				array(
+					'name'        => 'ObjectWithId',
+					'description' => __( 'An object with a numeric ID.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id' => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+					'resolveType' => function ( $value ) {
+						$class = get_class( $value );
+						$map = array(
+							'Automattic\WooCommerce\Api\Types\Coupons\Coupon' => CouponType::get(),
+						);
+						return $map[ $class ] ?? null;
+					},
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php
new file mode 100644
index 00000000000..713d01ba311
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php
@@ -0,0 +1,148 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ExternalProduct as ExternalProductType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\VariableProduct as VariableProductType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SimpleProduct as SimpleProductType;
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class Product {
+	private static ?InterfaceType $instance = null;
+
+	public static function get(): InterfaceType {
+		if ( null === self::$instance ) {
+			self::$instance = new InterfaceType(
+				array(
+					'name'        => 'Product',
+					'description' => __( 'A WooCommerce product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'name'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'              => Type::nonNull( Type::string() ),
+							'description'       => __( 'The short product description.', 'woocommerce' ),
+							'deprecationReason' => 'Use description instead.',
+						),
+						'status'            => array(
+							'type'        => Type::nonNull( ProductStatusType::get() ),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'raw_status'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => Type::nonNull( ProductTypeType::get() ),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'raw_product_type'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::string(),
+							'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+								),
+							),
+						),
+						'sale_price'        => array(
+							'type'        => Type::string(),
+							'description' => __( 'The sale price of the product.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+								),
+							),
+						),
+						'stock_status'      => array(
+							'type'        => Type::nonNull( StockStatusType::get() ),
+							'description' => __( 'The stock status of the product.', 'woocommerce' ),
+						),
+						'raw_stock_status'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => ProductDimensions::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+						'images'            => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+							'description' => __( 'The product images.', 'woocommerce' ),
+						),
+						'attributes'        => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+							'description' => __( 'The product attributes.', 'woocommerce' ),
+						),
+						'reviews'           => array(
+							'type'        => Type::nonNull( ProductReviewConnectionType::get() ),
+							'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+						),
+						'date_created'      => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was created.', 'woocommerce' ),
+						),
+						'date_modified'     => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+						),
+						'id'                => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+					'resolveType' => function ( $value ) {
+						$class = get_class( $value );
+						$map = array(
+							'Automattic\WooCommerce\Api\Types\Products\ProductVariation' => ProductVariationType::get(),
+							'Automattic\WooCommerce\Api\Types\Products\ExternalProduct' => ExternalProductType::get(),
+							'Automattic\WooCommerce\Api\Types\Products\VariableProduct' => VariableProductType::get(),
+							'Automattic\WooCommerce\Api\Types\Products\SimpleProduct' => SimpleProductType::get(),
+						);
+						return $map[ $class ] ?? null;
+					},
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php
new file mode 100644
index 00000000000..5828b0f4bb7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\ObjectWithId as ObjectWithIdInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class Coupon {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'Coupon',
+					'description' => __( 'Represents a WooCommerce discount coupon.', 'woocommerce' ),
+					'interfaces'  => fn() => array(
+						ObjectWithIdInterface::get(),
+					),
+					'fields'      => fn() => array(
+						'code'                        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The coupon code.', 'woocommerce' ),
+						),
+						'description'                 => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The coupon description.', 'woocommerce' ),
+						),
+						'discount_type'               => array(
+							'type'        => Type::nonNull( DiscountTypeType::get() ),
+							'description' => __( 'The type of discount.', 'woocommerce' ),
+						),
+						'raw_discount_type'           => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw discount type as stored in WooCommerce. Useful when discount_type is OTHER (e.g. plugin-added types like recurring_percent or sign_up_fee).', 'woocommerce' ),
+						),
+						'amount'                      => array(
+							'type'        => Type::nonNull( Type::float() ),
+							'description' => __( 'The discount amount.', 'woocommerce' ),
+						),
+						'status'                      => array(
+							'type'        => Type::nonNull( CouponStatusType::get() ),
+							'description' => __( 'The coupon status.', 'woocommerce' ),
+						),
+						'raw_status'                  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'date_created'                => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the coupon was created.', 'woocommerce' ),
+						),
+						'date_modified'               => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the coupon was last modified.', 'woocommerce' ),
+						),
+						'date_expires'                => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the coupon expires.', 'woocommerce' ),
+						),
+						'usage_count'                 => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The number of times the coupon has been used.', 'woocommerce' ),
+						),
+						'individual_use'              => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+						),
+						'product_ids'                 => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+							'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+						),
+						'excluded_product_ids'        => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+							'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'usage_limit'                 => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+						),
+						'usage_limit_per_user'        => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+						),
+						'limit_usage_to_x_items'      => array(
+							'type'        => Type::int(),
+							'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+						),
+						'free_shipping'               => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+						),
+						'product_categories'          => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+							'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+						),
+						'excluded_product_categories' => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+							'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+						),
+						'exclude_sale_items'          => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+						),
+						'minimum_amount'              => array(
+							'type'        => Type::nonNull( Type::float() ),
+							'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+						),
+						'maximum_amount'              => array(
+							'type'        => Type::nonNull( Type::float() ),
+							'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+						),
+						'email_restrictions'          => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+							'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+						),
+						'used_by'                     => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+							'description' => __( 'Email addresses of customers who have used this coupon.', 'woocommerce' ),
+						),
+						'id'                          => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php
new file mode 100644
index 00000000000..e71fad9b810
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class DeleteCouponResult {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'DeleteCouponResult',
+					'description' => __( 'The result of deleting a coupon.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id'      => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The ID of the deleted coupon.', 'woocommerce' ),
+						),
+						'deleted' => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the coupon was permanently deleted.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php
new file mode 100644
index 00000000000..c9a3c0e3e05
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php
@@ -0,0 +1,146 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ExternalProduct {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ExternalProduct',
+					'description' => __( 'An external/affiliate product.', 'woocommerce' ),
+					'interfaces'  => fn() => array(
+						ProductInterface::get(),
+					),
+					'fields'      => fn() => array(
+						'product_url'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'The external product URL.', 'woocommerce' ),
+						),
+						'button_text'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'The text for the external product button.', 'woocommerce' ),
+						),
+						'name'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'              => Type::nonNull( Type::string() ),
+							'description'       => __( 'The short product description.', 'woocommerce' ),
+							'deprecationReason' => 'Use description instead.',
+						),
+						'status'            => array(
+							'type'        => Type::nonNull( ProductStatusType::get() ),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'raw_status'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => Type::nonNull( ProductTypeType::get() ),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'raw_product_type'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::string(),
+							'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+								),
+							),
+						),
+						'sale_price'        => array(
+							'type'        => Type::string(),
+							'description' => __( 'The sale price of the product.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+								),
+							),
+						),
+						'stock_status'      => array(
+							'type'        => Type::nonNull( StockStatusType::get() ),
+							'description' => __( 'The stock status of the product.', 'woocommerce' ),
+						),
+						'raw_stock_status'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => ProductDimensions::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+						'images'            => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+							'description' => __( 'The product images.', 'woocommerce' ),
+						),
+						'attributes'        => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+							'description' => __( 'The product attributes.', 'woocommerce' ),
+						),
+						'reviews'           => array(
+							'type'        => Type::nonNull( ProductReviewConnectionType::get() ),
+							'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+						),
+						'date_created'      => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was created.', 'woocommerce' ),
+						),
+						'date_modified'     => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+						),
+						'id'                => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php
new file mode 100644
index 00000000000..d0ed3df49d3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductAttribute {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductAttribute',
+					'description' => __( 'A product attribute.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'name'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The attribute display name.', 'woocommerce' ),
+						),
+						'slug'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The attribute taxonomy or key name.', 'woocommerce' ),
+						),
+						'options'     => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+							'description' => __( 'The available attribute values.', 'woocommerce' ),
+						),
+						'position'    => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The display order position.', 'woocommerce' ),
+						),
+						'visible'     => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the attribute is visible on the product page.', 'woocommerce' ),
+						),
+						'variation'   => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the attribute is used for variations.', 'woocommerce' ),
+						),
+						'is_taxonomy' => array(
+							'type'        => Type::nonNull( Type::boolean() ),
+							'description' => __( 'Whether the attribute is a global taxonomy attribute.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php
new file mode 100644
index 00000000000..78ecacf7e16
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductDimensions {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductDimensions',
+					'description' => __( 'Physical dimensions and weight of a product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'length' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product length.', 'woocommerce' ),
+						),
+						'width'  => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product width.', 'woocommerce' ),
+						),
+						'height' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product height.', 'woocommerce' ),
+						),
+						'weight' => array(
+							'type'        => Type::float(),
+							'description' => __( 'The product weight.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php
new file mode 100644
index 00000000000..4131b2975ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductImage {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductImage',
+					'description' => __( 'Represents a product image.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id'       => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The image attachment ID.', 'woocommerce' ),
+						),
+						'url'      => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The image URL.', 'woocommerce' ),
+						),
+						'alt'      => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The image alt text.', 'woocommerce' ),
+						),
+						'position' => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The image display position.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php
new file mode 100644
index 00000000000..c1e865ec835
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReview {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductReview',
+					'description' => __( 'Represents a customer review for a product.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'id'           => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The review ID.', 'woocommerce' ),
+						),
+						'product_id'   => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The product ID this review belongs to.', 'woocommerce' ),
+						),
+						'reviewer'     => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The reviewer name.', 'woocommerce' ),
+						),
+						'review'       => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The review content.', 'woocommerce' ),
+						),
+						'rating'       => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The review rating (1-5).', 'woocommerce' ),
+						),
+						'date_created' => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the review was created.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php
new file mode 100644
index 00000000000..e1a36ab38c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php
@@ -0,0 +1,147 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SelectedAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariation {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductVariation',
+					'description' => __( 'A product variation.', 'woocommerce' ),
+					'interfaces'  => fn() => array(
+						ProductInterface::get(),
+					),
+					'fields'      => fn() => array(
+						'parent_id'           => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The parent variable product ID.', 'woocommerce' ),
+						),
+						'selected_attributes' => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( SelectedAttribute::get() ) ) ),
+							'description' => __( 'The selected attribute values for this variation.', 'woocommerce' ),
+						),
+						'name'                => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'                => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'                 => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'         => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description'   => array(
+							'type'              => Type::nonNull( Type::string() ),
+							'description'       => __( 'The short product description.', 'woocommerce' ),
+							'deprecationReason' => 'Use description instead.',
+						),
+						'status'              => array(
+							'type'        => Type::nonNull( ProductStatusType::get() ),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'raw_status'          => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'product_type'        => array(
+							'type'        => Type::nonNull( ProductTypeType::get() ),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'raw_product_type'    => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+						),
+						'regular_price'       => array(
+							'type'        => Type::string(),
+							'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+								),
+							),
+						),
+						'sale_price'          => array(
+							'type'        => Type::string(),
+							'description' => __( 'The sale price of the product.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+								),
+							),
+						),
+						'stock_status'        => array(
+							'type'        => Type::nonNull( StockStatusType::get() ),
+							'description' => __( 'The stock status of the product.', 'woocommerce' ),
+						),
+						'raw_stock_status'    => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+						),
+						'stock_quantity'      => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'          => array(
+							'type'        => ProductDimensions::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+						'images'              => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+							'description' => __( 'The product images.', 'woocommerce' ),
+						),
+						'attributes'          => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+							'description' => __( 'The product attributes.', 'woocommerce' ),
+						),
+						'reviews'             => array(
+							'type'        => Type::nonNull( ProductReviewConnectionType::get() ),
+							'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+						),
+						'date_created'        => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was created.', 'woocommerce' ),
+						),
+						'date_modified'       => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+						),
+						'id'                  => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php
new file mode 100644
index 00000000000..2bec5ace603
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class SelectedAttribute {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'SelectedAttribute',
+					'description' => __( 'A selected attribute value on a product variation.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'name'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The attribute name or slug.', 'woocommerce' ),
+						),
+						'value' => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The selected attribute value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php
new file mode 100644
index 00000000000..00d45b97576
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class SimpleProduct {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'SimpleProduct',
+					'description' => __( 'A simple WooCommerce product.', 'woocommerce' ),
+					'interfaces'  => fn() => array(
+						ProductInterface::get(),
+					),
+					'fields'      => fn() => array(
+						'name'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'              => Type::nonNull( Type::string() ),
+							'description'       => __( 'The short product description.', 'woocommerce' ),
+							'deprecationReason' => 'Use description instead.',
+						),
+						'status'            => array(
+							'type'        => Type::nonNull( ProductStatusType::get() ),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'raw_status'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => Type::nonNull( ProductTypeType::get() ),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'raw_product_type'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::string(),
+							'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+								),
+							),
+						),
+						'sale_price'        => array(
+							'type'        => Type::string(),
+							'description' => __( 'The sale price of the product.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+								),
+							),
+						),
+						'stock_status'      => array(
+							'type'        => Type::nonNull( StockStatusType::get() ),
+							'description' => __( 'The stock status of the product.', 'woocommerce' ),
+						),
+						'raw_stock_status'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => ProductDimensions::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+						'images'            => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+							'description' => __( 'The product images.', 'woocommerce' ),
+						),
+						'attributes'        => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+							'description' => __( 'The product attributes.', 'woocommerce' ),
+						),
+						'reviews'           => array(
+							'type'        => Type::nonNull( ProductReviewConnectionType::get() ),
+							'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+						),
+						'date_created'      => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was created.', 'woocommerce' ),
+						),
+						'date_modified'     => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+						),
+						'id'                => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php
new file mode 100644
index 00000000000..c9519c8e7a1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php
@@ -0,0 +1,169 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductVariationConnection as ProductVariationConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class VariableProduct {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'VariableProduct',
+					'description' => __( 'A variable product with variations.', 'woocommerce' ),
+					'interfaces'  => fn() => array(
+						ProductInterface::get(),
+					),
+					'fields'      => fn() => array(
+						'variations'        => array(
+							'type'        => Type::nonNull( ProductVariationConnectionType::get() ),
+							'description' => __( 'The product variations.', 'woocommerce' ),
+							'args'        => array(
+								'first'  => array(
+									'type'         => Type::int(),
+									'defaultValue' => null,
+									'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
+								),
+								'last'   => array(
+									'type'         => Type::int(),
+									'defaultValue' => null,
+									'description'  => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
+								),
+								'after'  => array(
+									'type'         => Type::string(),
+									'defaultValue' => null,
+									'description'  => __( 'Return results after this cursor.', 'woocommerce' ),
+								),
+								'before' => array(
+									'type'         => Type::string(),
+									'defaultValue' => null,
+									'description'  => __( 'Return results before this cursor.', 'woocommerce' ),
+								),
+							),
+							'complexity'  => Utils::complexity_from_pagination( ... ),
+							'resolve'     => fn( $parent, array $args ): Connection => Utils::translate_exceptions( fn() => $parent->variations->slice( $args ) ),
+						),
+						'name'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product name.', 'woocommerce' ),
+						),
+						'slug'              => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The product slug.', 'woocommerce' ),
+						),
+						'sku'               => array(
+							'type'        => Type::string(),
+							'description' => __( 'The product SKU.', 'woocommerce' ),
+						),
+						'description'       => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The full product description.', 'woocommerce' ),
+						),
+						'short_description' => array(
+							'type'              => Type::nonNull( Type::string() ),
+							'description'       => __( 'The short product description.', 'woocommerce' ),
+							'deprecationReason' => 'Use description instead.',
+						),
+						'status'            => array(
+							'type'        => Type::nonNull( ProductStatusType::get() ),
+							'description' => __( 'The product status.', 'woocommerce' ),
+						),
+						'raw_status'        => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+						),
+						'product_type'      => array(
+							'type'        => Type::nonNull( ProductTypeType::get() ),
+							'description' => __( 'The product type.', 'woocommerce' ),
+						),
+						'raw_product_type'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+						),
+						'regular_price'     => array(
+							'type'        => Type::string(),
+							'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+								),
+							),
+						),
+						'sale_price'        => array(
+							'type'        => Type::string(),
+							'description' => __( 'The sale price of the product.', 'woocommerce' ),
+							'args'        => array(
+								'formatted' => array(
+									'type'         => Type::boolean(),
+									'defaultValue' => true,
+									'description'  => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+								),
+							),
+						),
+						'stock_status'      => array(
+							'type'        => Type::nonNull( StockStatusType::get() ),
+							'description' => __( 'The stock status of the product.', 'woocommerce' ),
+						),
+						'raw_stock_status'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+						),
+						'stock_quantity'    => array(
+							'type'        => Type::int(),
+							'description' => __( 'The number of items in stock.', 'woocommerce' ),
+						),
+						'dimensions'        => array(
+							'type'        => ProductDimensions::get(),
+							'description' => __( 'The product dimensions.', 'woocommerce' ),
+						),
+						'images'            => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+							'description' => __( 'The product images.', 'woocommerce' ),
+						),
+						'attributes'        => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+							'description' => __( 'The product attributes.', 'woocommerce' ),
+						),
+						'reviews'           => array(
+							'type'        => Type::nonNull( ProductReviewConnectionType::get() ),
+							'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+						),
+						'date_created'      => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was created.', 'woocommerce' ),
+						),
+						'date_modified'     => array(
+							'type'        => DateTimeType::get(),
+							'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+						),
+						'id'                => array(
+							'type'        => Type::nonNull( Type::int() ),
+							'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php
new file mode 100644
index 00000000000..0b26171b4eb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CouponConnection {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'CouponConnection',
+					'description' => __( 'A connection to a list of Coupon items.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'edges'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										CouponEdge::get()
+									)
+								)
+							),
+						),
+						'nodes'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										CouponType::get()
+									)
+								)
+							),
+						),
+						'page_info'   => array(
+							'type' => Type::nonNull( PageInfo::get() ),
+						),
+						'total_count' => array(
+							'type' => Type::nonNull( Type::int() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php
new file mode 100644
index 00000000000..71435aec5de
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CouponEdge {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'CouponEdge',
+					'fields' => fn() => array(
+						'cursor' => array(
+							'type' => Type::nonNull( Type::string() ),
+						),
+						'node'   => array(
+							'type' => Type::nonNull( CouponType::get() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php
new file mode 100644
index 00000000000..821ed70c2bf
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class PageInfo {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'PageInfo',
+					'fields' => array(
+						'has_next_page'     => array(
+							'type' => Type::nonNull( Type::boolean() ),
+						),
+						'has_previous_page' => array(
+							'type' => Type::nonNull( Type::boolean() ),
+						),
+						'start_cursor'      => array(
+							'type' => Type::string(),
+						),
+						'end_cursor'        => array(
+							'type' => Type::string(),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php
new file mode 100644
index 00000000000..c37c757f2f3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductConnection {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductConnection',
+					'description' => __( 'A connection to a list of Product items.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'edges'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductEdge::get()
+									)
+								)
+							),
+						),
+						'nodes'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductType::get()
+									)
+								)
+							),
+						),
+						'page_info'   => array(
+							'type' => Type::nonNull( PageInfo::get() ),
+						),
+						'total_count' => array(
+							'type' => Type::nonNull( Type::int() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php
new file mode 100644
index 00000000000..097ad21a2b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductEdge {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'ProductEdge',
+					'fields' => fn() => array(
+						'cursor' => array(
+							'type' => Type::nonNull( Type::string() ),
+						),
+						'node'   => array(
+							'type' => Type::nonNull( ProductType::get() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php
new file mode 100644
index 00000000000..86e38a7c4c1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductReview as ProductReviewType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReviewConnection {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductReviewConnection',
+					'description' => __( 'A connection to a list of ProductReview items.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'edges'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductReviewEdge::get()
+									)
+								)
+							),
+						),
+						'nodes'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductReviewType::get()
+									)
+								)
+							),
+						),
+						'page_info'   => array(
+							'type' => Type::nonNull( PageInfo::get() ),
+						),
+						'total_count' => array(
+							'type' => Type::nonNull( Type::int() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php
new file mode 100644
index 00000000000..ed955e17b2c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductReview as ProductReviewType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReviewEdge {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'ProductReviewEdge',
+					'fields' => fn() => array(
+						'cursor' => array(
+							'type' => Type::nonNull( Type::string() ),
+						),
+						'node'   => array(
+							'type' => Type::nonNull( ProductReviewType::get() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php
new file mode 100644
index 00000000000..3cfd78864d5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariationConnection {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'        => 'ProductVariationConnection',
+					'description' => __( 'A connection to a list of ProductVariation items.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'edges'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductVariationEdge::get()
+									)
+								)
+							),
+						),
+						'nodes'       => array(
+							'type' => Type::nonNull(
+								Type::listOf(
+									Type::nonNull(
+										ProductVariationType::get()
+									)
+								)
+							),
+						),
+						'page_info'   => array(
+							'type' => Type::nonNull( PageInfo::get() ),
+						),
+						'total_count' => array(
+							'type' => Type::nonNull( Type::int() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php
new file mode 100644
index 00000000000..7f87da82f72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariationEdge {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'ProductVariationEdge',
+					'fields' => fn() => array(
+						'cursor' => array(
+							'type' => Type::nonNull( Type::string() ),
+						),
+						'node'   => array(
+							'type' => Type::nonNull( ProductVariationType::get() ),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php
new file mode 100644
index 00000000000..4bc6c3ea798
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php
@@ -0,0 +1,45 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars;
+
+use Automattic\WooCommerce\Api\Scalars\DateTime as DateTimeScalar;
+use GraphQL\Type\Definition\CustomScalarType;
+
+class DateTime {
+	private static ?CustomScalarType $instance = null;
+
+	public static function get(): CustomScalarType {
+		if ( null === self::$instance ) {
+			self::$instance = new CustomScalarType(
+				array(
+					'name'         => 'DateTime',
+					'description'  => __( 'An ISO 8601 encoded date and time string.', 'woocommerce' ),
+					'serialize'    => fn( $value ) => DateTimeScalar::serialize( $value ),
+					'parseValue'   => function ( $value ) {
+						try {
+							return DateTimeScalar::parse( $value );
+						} catch ( \InvalidArgumentException $e ) {
+							throw new \GraphQL\Error\Error( $e->getMessage() );
+						}
+					},
+					'parseLiteral' => function ( $value_node, ?array $variables = null ) {
+						if ( $value_node instanceof \GraphQL\Language\AST\StringValueNode ) {
+							try {
+								return DateTimeScalar::parse( $value_node->value );
+							} catch ( \InvalidArgumentException $e ) {
+								throw new \GraphQL\Error\Error( $e->getMessage() );
+							}
+						}
+						throw new \GraphQL\Error\Error(
+							'DateTime must be a string, got: ' . $value_node->kind
+						);
+					},
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php
new file mode 100644
index 00000000000..ce9b639d5ec
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php
@@ -0,0 +1,37 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\CreateProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\UpdateProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\DeleteProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\DeleteCoupon;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\CreateCoupon;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\UpdateCoupon;
+use GraphQL\Type\Definition\ObjectType;
+
+class RootMutationType {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'Mutation',
+					'fields' => fn() => array(
+						'createProduct' => CreateProduct::get_field_definition(),
+						'updateProduct' => UpdateProduct::get_field_definition(),
+						'deleteProduct' => DeleteProduct::get_field_definition(),
+						'deleteCoupon'  => DeleteCoupon::get_field_definition(),
+						'createCoupon'  => CreateCoupon::get_field_definition(),
+						'updateCoupon'  => UpdateCoupon::get_field_definition(),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
new file mode 100644
index 00000000000..b2a8dde5585
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListProducts;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetCoupon;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListCoupons;
+use GraphQL\Type\Definition\ObjectType;
+
+class RootQueryType {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'Query',
+					'fields' => fn() => array(
+						'products' => ListProducts::get_field_definition(),
+						'product'  => GetProduct::get_field_definition(),
+						'coupon'   => GetCoupon::get_field_definition(),
+						'coupons'  => ListCoupons::get_field_definition(),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php
new file mode 100644
index 00000000000..c8ae533c2a3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ExternalProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\VariableProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SimpleProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon;
+
+class TypeRegistry {
+	/**
+	 * Return all concrete types that implement interfaces.
+	 *
+	 * Pass this to the Schema 'types' config so that inline fragments
+	 * (e.g. `... on VariableProduct`) are resolvable.
+	 *
+	 * @return array
+	 */
+	public static function get_interface_implementors(): array {
+		return array(
+			ProductVariation::get(),
+			ExternalProduct::get(),
+			VariableProduct::get(),
+			SimpleProduct::get(),
+			Coupon::get(),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
new file mode 100644
index 00000000000..e6f606063e3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -0,0 +1 @@
+2026-04-21T07:16:03+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
new file mode 100644
index 00000000000..36b96d0138b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -0,0 +1,1699 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api\DesignTime\Scripts;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+
+/**
+ * Scans the public API classes and generates the GraphQL schema and resolver code.
+ */
+class ApiBuilder {
+	private const API_DIR                 = __DIR__ . '/../../../../Api';
+	private const AUTOGENERATED_DIR       = __DIR__ . '/../../Autogenerated';
+	private const TEMPLATES_DIR           = __DIR__ . '/../Templates';
+	private const API_NAMESPACE           = 'Automattic\\WooCommerce\\Api';
+	private const AUTOGENERATED_NAMESPACE = 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated';
+
+	/** @var array<string, array{class: \ReflectionClass|\ReflectionEnum, kind: string, ignored: bool}> */
+	private array $classes = array();
+
+	/** @var array<string, string> Map of PHP FQCN => GraphQL name */
+	private array $graphql_names = array();
+
+	/** @var string[] Errors collected during validation */
+	private array $errors = array();
+
+	/** @var string[] Warnings collected during build */
+	private array $warnings = array();
+
+	/** @var array Discovered connections: ['node_type' => FQCN, 'source' => string] */
+	private array $connections = array();
+
+	/** @var array<string, string[]> Map of interface trait FQCN => list of output type FQCNs that use it */
+	private array $interface_implementors = array();
+
+	// Counters for summary.
+	private int $query_count      = 0;
+	private int $mutation_count   = 0;
+	private int $type_count       = 0;
+	private int $input_type_count = 0;
+	private int $enum_count       = 0;
+	private int $scalar_count     = 0;
+	private int $interface_count  = 0;
+
+	public function build( bool $skip_linter = false ): void {
+		echo "Scanning src/Api/ for code API classes...\n";
+
+		$this->discover();
+		$this->validate();
+
+		if ( ! empty( $this->errors ) ) {
+			fwrite( STDERR, "Build failed with errors:\n" );
+			foreach ( $this->errors as $error ) {
+				fwrite( STDERR, "  - {$error}\n" );
+			}
+			exit( 1 );
+		}
+
+		$this->wipe_autogenerated();
+		$this->create_directory_structure();
+		$this->generate();
+		if ( ! $skip_linter ) {
+			echo "Applying linter to generated files...\n";
+			$this->format_with_phpcbf( self::AUTOGENERATED_DIR );
+		}
+		$this->write_timestamp();
+
+		// Regenerate autoloader.
+		$wc_dir = realpath( __DIR__ . '/../../../../..' );
+		echo "Regenerating autoloader...\n";
+		exec( 'composer dump-autoload --working-dir=' . escapeshellarg( $wc_dir ) . ' 2>&1', $output, $code );
+		if ( $code !== 0 ) {
+			echo 'Warning: composer dump-autoload failed: ' . implode( "\n", $output ) . "\n";
+		}
+
+		// Print summary.
+		echo "\n=== Build Complete ===\n";
+		echo "  Queries:     {$this->query_count}\n";
+		echo "  Mutations:   {$this->mutation_count}\n";
+		echo "  Types:       {$this->type_count}\n";
+		echo "  Input Types: {$this->input_type_count}\n";
+		echo "  Enums:       {$this->enum_count}\n";
+		echo "  Scalars:     {$this->scalar_count}\n";
+		echo "  Interfaces:  {$this->interface_count}\n";
+		echo '  Connections: ' . count( $this->connections ) . "\n";
+
+		if ( ! empty( $this->warnings ) ) {
+			echo "\nWarnings:\n";
+			foreach ( $this->warnings as $warning ) {
+				echo "  - {$warning}\n";
+			}
+		}
+	}
+
+	// ========================================================================
+	// Discovery
+	// ========================================================================
+
+	private function discover(): void {
+		$iterator = new \RecursiveIteratorIterator(
+			new \RecursiveDirectoryIterator( self::API_DIR, \FilesystemIterator::SKIP_DOTS )
+		);
+
+		foreach ( $iterator as $file ) {
+			if ( $file->getExtension() !== 'php' ) {
+				continue;
+			}
+
+			$fqcn = $this->file_to_fqcn( $file->getPathname() );
+			if ( $fqcn === null ) {
+				continue;
+			}
+
+			$kind = $this->classify_by_namespace( $fqcn );
+			if ( $kind === null || $kind === 'attribute' || $kind === 'exception' ) {
+				continue;
+			}
+
+			try {
+				if ( enum_exists( $fqcn ) ) {
+					$ref = new \ReflectionEnum( $fqcn );
+				} else {
+					$ref = new \ReflectionClass( $fqcn );
+				}
+			} catch ( \ReflectionException $e ) {
+				$this->warnings[] = "Could not reflect {$fqcn}: {$e->getMessage()}";
+				continue;
+			}
+
+			$ignored = ! empty( $ref->getAttributes( Ignore::class ) );
+
+			// Abstract classes are base classes, not concrete API
+			// endpoints or types — skip them automatically.
+			if ( ! $ignored && $ref instanceof \ReflectionClass && $ref->isAbstract() ) {
+				$ignored = true;
+			}
+
+			// Traits outside Interfaces/ are helper mixins (e.g.
+			// TracksProvidedFields), not concrete types — skip them so they
+			// don't end up emitted as empty-field InputObjectTypes. Traits
+			// in Interfaces/ model GraphQL interfaces and are legitimate.
+			if ( ! $ignored && $ref instanceof \ReflectionClass && $ref->isTrait() && 'interface' !== $kind ) {
+				$ignored = true;
+			}
+
+			$this->classes[ $fqcn ] = array(
+				'class'   => $ref,
+				'kind'    => $kind,
+				'ignored' => $ignored,
+			);
+
+			// Compute GraphQL name.
+			$name_attr    = $ref->getAttributes( Name::class );
+			$graphql_name = ! empty( $name_attr )
+				? $name_attr[0]->newInstance()->name
+				: $ref->getShortName();
+
+			$this->graphql_names[ $fqcn ] = $graphql_name;
+		}
+
+		echo '  Found ' . count( $this->classes ) . " classes.\n";
+	}
+
+	private function file_to_fqcn( string $filepath ): ?string {
+		$rel = str_replace( realpath( self::API_DIR ) . '/', '', realpath( $filepath ) );
+		$rel = str_replace( '.php', '', $rel );
+		$rel = str_replace( '/', '\\', $rel );
+		return self::API_NAMESPACE . '\\' . $rel;
+	}
+
+	private function classify_by_namespace( string $fqcn ): ?string {
+		$relative = substr( $fqcn, strlen( self::API_NAMESPACE ) + 1 );
+		$parts    = explode( '\\', $relative );
+		$top_dir  = $parts[0];
+
+		return match ( $top_dir ) {
+			'Queries'    => 'query',
+			'Mutations'  => 'mutation',
+			'Types'      => 'type',
+			'InputTypes' => 'input_type',
+			'Enums'      => 'enum',
+			'Interfaces' => 'interface',
+			'Scalars'    => 'scalar',
+			'Pagination' => 'pagination',
+			'Attributes' => 'attribute',
+			default      => $fqcn === self::API_NAMESPACE . '\\ApiException' ? 'exception' : null,
+		};
+	}
+
+	// ========================================================================
+	// Validation
+	// ========================================================================
+
+	private function validate(): void {
+		foreach ( $this->classes as $fqcn => $info ) {
+			if ( $info['ignored'] ) {
+				continue;
+			}
+
+			$ref  = $info['class'];
+			$kind = $info['kind'];
+
+			match ( $kind ) {
+				'query', 'mutation' => $this->validate_command( $fqcn, $ref ),
+				'type'              => $this->validate_output_type( $fqcn, $ref ),
+				'input_type'        => $this->validate_input_type( $fqcn, $ref ),
+				'enum'              => $this->validate_enum( $fqcn, $ref ),
+				'scalar'            => $this->validate_scalar( $fqcn, $ref ),
+				'interface'         => $this->validate_interface( $fqcn, $ref ),
+				default             => null,
+			};
+		}
+	}
+
+	private function validate_command( string $fqcn, \ReflectionClass $ref ): void {
+		// Must have execute method.
+		if ( ! $ref->hasMethod( 'execute' ) ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" must have an execute method.";
+			return;
+		}
+
+		// Authorization check: must have RequiredCapability, PublicAccess, or a non-ignored authorize() method.
+		// A direct attribute on the class takes precedence over inherited ones.
+		$auth          = $this->resolve_authorization( $ref );
+		$has_authorize = $ref->hasMethod( 'authorize' )
+			&& empty( $ref->getMethod( 'authorize' )->getAttributes( Ignore::class ) );
+
+		if ( null === $auth['error'] && empty( $auth['caps'] ) && ! $auth['public'] && ! $has_authorize ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has no RequiredCapability attribute (directly or inherited), no PublicAccess attribute, and no authorize() method.";
+		}
+
+		if ( null !== $auth['error'] ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" {$auth['error']}";
+		}
+
+		$this->check_for_ignored_auth_attribute( $fqcn, $ref );
+
+		// ReturnType attribute validation.
+		$execute_method   = $ref->getMethod( 'execute' );
+		$return_type      = $execute_method->getReturnType();
+		$return_type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+		$return_type_attr = $execute_method->getAttributes( ReturnType::class );
+
+		if ( 'object' === $return_type_name && empty( $return_type_attr ) ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" returns 'object' but has no #[ReturnType] attribute on execute().";
+		}
+
+		if ( ! empty( $return_type_attr ) && 'object' !== $return_type_name ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has #[ReturnType] on execute() but does not return 'object'.";
+		}
+
+		if ( ! empty( $return_type_attr ) ) {
+			$rt_class = $return_type_attr[0]->newInstance()->type;
+			$rt_info  = $this->get_class_info( $rt_class );
+			if ( null === $rt_info || 'interface' !== $rt_info['kind'] ) {
+				$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\": #[ReturnType] references '{$rt_class}' which is not a known interface.";
+			}
+		}
+	}
+
+	/**
+	 * Warn when a class declares both an authorization attribute and an
+	 * authorize() method directly on itself without opting into composition
+	 * via the $_preauthorized infrastructure parameter. In that configuration
+	 * the attribute is silently ignored, which is almost always a bug.
+	 *
+	 * Inherited attributes paired with a direct authorize() are intentional
+	 * (the documented override mechanism) and are not flagged.
+	 *
+	 * @param string           $fqcn The class fully-qualified name.
+	 * @param \ReflectionClass $ref  The reflection of the class.
+	 */
+	private function check_for_ignored_auth_attribute( string $fqcn, \ReflectionClass $ref ): void {
+		$has_direct_cap    = ! empty( $ref->getAttributes( RequiredCapability::class ) );
+		$has_direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+
+		if ( ! $has_direct_cap && ! $has_direct_public ) {
+			return;
+		}
+
+		if ( ! $ref->hasMethod( 'authorize' ) ) {
+			return;
+		}
+
+		$authorize_method = $ref->getMethod( 'authorize' );
+		if ( ! empty( $authorize_method->getAttributes( Ignore::class ) ) ) {
+			return;
+		}
+
+		if ( $authorize_method->getDeclaringClass()->getName() !== $fqcn ) {
+			// authorize() is inherited; overriding the attribute is intentional.
+			return;
+		}
+
+		foreach ( $authorize_method->getParameters() as $p ) {
+			if ( '_preauthorized' === $p->getName() ) {
+				// Developer opted into composition.
+				return;
+			}
+		}
+
+		$this->warnings[] = sprintf(
+			'Query/Mutation "%s" declares an authorization attribute and an authorize() method on the same class; the attribute has no effect. Add a `bool $_preauthorized` parameter to authorize() to compose the two.',
+			$ref->getShortName()
+		);
+	}
+
+	private function validate_output_type( string $fqcn, \ReflectionClass $ref ): void {
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+			$this->validate_property_type( $prop, 'output', $ref->getShortName() );
+		}
+	}
+
+	private function validate_input_type( string $fqcn, \ReflectionClass $ref ): void {
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+			$this->validate_property_type( $prop, 'input', $ref->getShortName() );
+		}
+	}
+
+	private function validate_property_type( \ReflectionProperty $prop, string $context, string $class_name ): void {
+		$type = $prop->getType();
+		if ( $type === null ) {
+			$this->errors[] = "Property \"{$class_name}::\${$prop->getName()}\" must have a type declaration.";
+			return;
+		}
+
+		if ( $type instanceof \ReflectionNamedType && $type->getName() === 'array' ) {
+			if ( empty( $prop->getAttributes( ArrayOf::class ) ) && empty( $prop->getAttributes( ConnectionOf::class ) ) ) {
+				$this->errors[] = "Property \"{$class_name}::\${$prop->getName()}\" is typed as array but has no #[ArrayOf] attribute.";
+			}
+		}
+	}
+
+	private function validate_enum( string $fqcn, \ReflectionEnum $ref ): void {
+		if ( ! $ref->isBacked() ) {
+			$this->errors[] = "Enum \"{$ref->getShortName()}\" must be a backed enum (string or int).";
+		}
+	}
+
+	private function validate_scalar( string $fqcn, \ReflectionClass $ref ): void {
+		if ( ! $ref->hasMethod( 'serialize' ) || ! $ref->hasMethod( 'parse' ) ) {
+			$this->errors[] = "Scalar \"{$ref->getShortName()}\" must have static serialize and parse methods.";
+		}
+	}
+
+	private function validate_interface( string $fqcn, \ReflectionClass $ref ): void {
+		if ( ! $ref->isTrait() ) {
+			$this->errors[] = "Interface \"{$ref->getShortName()}\" must be a trait.";
+			return;
+		}
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+			$this->validate_property_type( $prop, 'output', $ref->getShortName() );
+		}
+	}
+
+	/**
+	 * Resolve the authorization strategy for a query/mutation class.
+	 *
+	 * A direct attribute on the class itself takes precedence over inherited ones.
+	 * Having both RequiredCapability and PublicAccess on the same class is an error,
+	 * but a derived class may override an inherited attribute of the other kind.
+	 *
+	 * @param \ReflectionClass $ref The class to inspect.
+	 * @return array{caps: string[], public: bool, error: ?string}
+	 */
+	private function resolve_authorization( \ReflectionClass $ref ): array {
+		$direct_caps   = array();
+		$direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+
+		foreach ( $ref->getAttributes( RequiredCapability::class ) as $attr ) {
+			$direct_caps[] = $attr->newInstance()->capability;
+		}
+
+		// Same class has both — always an error.
+		if ( ! empty( $direct_caps ) && $direct_public ) {
+			return array(
+				'caps'   => array(),
+				'public' => false,
+				'error'  => 'cannot have both RequiredCapability and PublicAccess.',
+			);
+		}
+
+		// Direct attribute found — use it, ignore inherited.
+		if ( ! empty( $direct_caps ) || $direct_public ) {
+			return array(
+				'caps'   => array_unique( $direct_caps ),
+				'public' => $direct_public,
+				'error'  => null,
+			);
+		}
+
+		// No direct attribute — inherit from parents, traits, and interfaces.
+		$inherited_caps   = array();
+		$inherited_public = false;
+		$sources          = array_merge(
+			$ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+			$ref->getTraits(),
+			$ref->getInterfaces(),
+		);
+
+		foreach ( $sources as $source ) {
+			foreach ( $source->getAttributes( RequiredCapability::class ) as $attr ) {
+				$inherited_caps[] = $attr->newInstance()->capability;
+			}
+			if ( ! empty( $source->getAttributes( PublicAccess::class ) ) ) {
+				$inherited_public = true;
+			}
+		}
+
+		return array(
+			'caps'   => array_unique( $inherited_caps ),
+			'public' => $inherited_public,
+			'error'  => null,
+		);
+	}
+
+	// ========================================================================
+	// Generation
+	// ========================================================================
+
+	private function wipe_autogenerated(): void {
+		if ( is_dir( self::AUTOGENERATED_DIR ) ) {
+			$this->rmdir_recursive( self::AUTOGENERATED_DIR );
+		}
+	}
+
+	private function create_directory_structure(): void {
+		$dirs = array(
+			self::AUTOGENERATED_DIR,
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Output',
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Input',
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Enums',
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Interfaces',
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Scalars',
+			self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination',
+			self::AUTOGENERATED_DIR . '/GraphQLQueries',
+			self::AUTOGENERATED_DIR . '/GraphQLMutations',
+		);
+
+		foreach ( $dirs as $dir ) {
+			if ( ! is_dir( $dir ) ) {
+				mkdir( $dir, 0755, true );
+			}
+		}
+	}
+
+	private function generate(): void {
+		$queries      = array();
+		$mutations    = array();
+		$interfaces   = array();
+		$output_types = array();
+
+		// Collect interface trait FQCNs for lookup.
+		$interface_fqcns = array();
+		foreach ( $this->classes as $fqcn => $info ) {
+			if ( ! $info['ignored'] && $info['kind'] === 'interface' ) {
+				$interface_fqcns[ $fqcn ] = true;
+			}
+		}
+
+		// Scan output types to build interface_implementors map.
+		foreach ( $this->classes as $fqcn => $info ) {
+			if ( $info['ignored'] || $info['kind'] !== 'type' ) {
+				continue;
+			}
+			foreach ( $info['class']->getTraits() as $trait ) {
+				$trait_fqcn = $trait->getName();
+				if ( isset( $interface_fqcns[ $trait_fqcn ] ) ) {
+					$this->interface_implementors[ $trait_fqcn ][] = $fqcn;
+				}
+			}
+		}
+
+		foreach ( $this->classes as $fqcn => $info ) {
+			if ( $info['ignored'] || $info['kind'] === 'pagination' ) {
+				continue;
+			}
+
+			$ref  = $info['class'];
+			$kind = $info['kind'];
+
+			match ( $kind ) {
+				'enum'       => $this->generate_enum( $fqcn, $ref ),
+				'scalar'     => $this->generate_scalar( $fqcn, $ref ),
+				'interface'  => $this->generate_interface( $fqcn, $ref ),
+				'type'       => $this->generate_output_type( $fqcn, $ref ),
+				'input_type' => $this->generate_input_type( $fqcn, $ref ),
+				'query'      => $queries[ $fqcn ]   = $ref,
+				'mutation'   => $mutations[ $fqcn ] = $ref,
+				default      => null,
+			};
+		}
+
+		// Pre-scan connections from queries/mutations (ConnectionOf on execute methods).
+		$this->discover_connections( $queries, $mutations );
+
+		// Generate connections (PageInfo first since connections reference it).
+		$this->generate_page_info();
+		foreach ( $this->connections as $conn ) {
+			$this->generate_connection( $conn['node_type'] );
+		}
+
+		// Generate resolvers.
+		foreach ( $queries as $fqcn => $ref ) {
+			$this->generate_resolver( $fqcn, $ref, 'query' );
+		}
+		foreach ( $mutations as $fqcn => $ref ) {
+			$this->generate_resolver( $fqcn, $ref, 'mutation' );
+		}
+
+		// Generate root types and type registry.
+		$this->generate_root_query_type( $queries );
+		$this->generate_root_mutation_type( $mutations );
+		$this->generate_type_registry();
+	}
+
+	private function discover_connections( array $queries, array $mutations ): void {
+		foreach ( array_merge( $queries, $mutations ) as $fqcn => $ref ) {
+			if ( ! $ref->hasMethod( 'execute' ) ) {
+				continue;
+			}
+			$method    = $ref->getMethod( 'execute' );
+			$conn_attr = $method->getAttributes( ConnectionOf::class );
+			if ( ! empty( $conn_attr ) ) {
+				$node_type                       = $conn_attr[0]->newInstance()->type;
+				$this->connections[ $node_type ] = array(
+					'node_type' => $node_type,
+					'source'    => $ref->getShortName() . '::execute()',
+				);
+			}
+		}
+	}
+
+	// ------ Enum ------
+
+	private function generate_enum( string $fqcn, \ReflectionEnum $ref ): void {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+		$description  = $this->get_description( $ref );
+		$enum_alias   = $ref->getShortName() . 'Enum';
+
+		$values = array();
+		foreach ( $ref->getCases() as $case ) {
+			$case_name_attr = $case->getAttributes( Name::class );
+			$gql_case_name  = ! empty( $case_name_attr )
+				? $case_name_attr[0]->newInstance()->name
+				: $this->to_screaming_snake_case( $case->getName() );
+
+			$deprecation = $case->getAttributes( Deprecated::class );
+
+			$values[] = array(
+				'graphql_name'       => $gql_case_name,
+				'case_name'          => $case->getName(),
+				'description'        => $this->get_description( $case ),
+				'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+			);
+		}
+
+		$code = $this->render_template(
+			'EnumTypeTemplate.php',
+			array(
+				'namespace'    => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Enums',
+				'class_name'   => $ref->getShortName(),
+				'graphql_name' => $graphql_name,
+				'description'  => $description,
+				'enum_fqcn'    => $fqcn,
+				'enum_alias'   => $enum_alias,
+				'values'       => $values,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Enums/' . $ref->getShortName() . '.php';
+		file_put_contents( $path, $code );
+		++$this->enum_count;
+	}
+
+	// ------ Scalar ------
+
+	private function generate_scalar( string $fqcn, \ReflectionClass $ref ): void {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+		$description  = $this->get_description( $ref );
+		$scalar_alias = $ref->getShortName() . 'Scalar';
+
+		$code = $this->render_template(
+			'ScalarTypeTemplate.php',
+			array(
+				'namespace'    => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Scalars',
+				'class_name'   => $ref->getShortName(),
+				'graphql_name' => $graphql_name,
+				'description'  => $description,
+				'scalar_fqcn'  => $fqcn,
+				'scalar_alias' => $scalar_alias,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Scalars/' . $ref->getShortName() . '.php';
+		file_put_contents( $path, $code );
+		++$this->scalar_count;
+	}
+
+	// ------ Interface ------
+
+	private function generate_interface( string $fqcn, \ReflectionClass $ref ): void {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+		$description  = $this->get_description( $ref );
+		$use_stmts    = array();
+		$fields       = array();
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+
+			$field = $this->build_field_definition( $prop, 'output', $use_stmts );
+			if ( $field !== null ) {
+				$fields[] = $field;
+			}
+		}
+
+		// Build resolveType map: PHP FQCN => generated ObjectType class alias.
+		$type_map = array();
+		foreach ( $this->interface_implementors[ $fqcn ] ?? array() as $impl_fqcn ) {
+			$impl_short  = ( new \ReflectionClass( $impl_fqcn ) )->getShortName();
+			$alias       = $impl_short . 'Type';
+			$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$impl_short} as {$alias}";
+			$type_map[]  = array(
+				'fqcn'  => $impl_fqcn,
+				'alias' => $alias,
+			);
+		}
+
+		$code = $this->render_template(
+			'InterfaceTypeTemplate.php',
+			array(
+				'namespace'      => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Interfaces',
+				'class_name'     => $ref->getShortName(),
+				'graphql_name'   => $graphql_name,
+				'description'    => $description,
+				'use_statements' => array_unique( $use_stmts ),
+				'fields'         => $fields,
+				'type_map'       => $type_map,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Interfaces/' . $ref->getShortName() . '.php';
+		file_put_contents( $path, $code );
+		++$this->interface_count;
+	}
+
+	// ------ Output Type ------
+
+	private function generate_output_type( string $fqcn, \ReflectionClass $ref ): void {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+		$description  = $this->get_description( $ref );
+		$use_stmts    = array();
+		$interfaces   = array();
+		$fields       = array();
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+
+			$field = $this->build_field_definition( $prop, 'output', $use_stmts );
+			if ( $field !== null ) {
+				$fields[] = $field;
+			}
+		}
+
+		// Wire interfaces: check if any traits on this class are discovered interfaces.
+		foreach ( $ref->getTraits() as $trait ) {
+			$trait_fqcn = $trait->getName();
+			$trait_info = $this->get_class_info( $trait_fqcn );
+			if ( $trait_info !== null && $trait_info['kind'] === 'interface' ) {
+				$iface_short  = $trait->getShortName();
+				$alias        = $iface_short . 'Interface';
+				$use_stmts[]  = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Interfaces\\{$iface_short} as {$alias}";
+				$interfaces[] = array( 'alias' => $alias );
+			}
+		}
+
+		$code = $this->render_template(
+			'ObjectTypeTemplate.php',
+			array(
+				'namespace'      => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Output',
+				'class_name'     => $ref->getShortName(),
+				'graphql_name'   => $graphql_name,
+				'description'    => $description,
+				'use_statements' => array_unique( $use_stmts ),
+				'interfaces'     => $interfaces,
+				'fields'         => $fields,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Output/' . $ref->getShortName() . '.php';
+		file_put_contents( $path, $code );
+		++$this->type_count;
+	}
+
+	// ------ Input Type ------
+
+	private function generate_input_type( string $fqcn, \ReflectionClass $ref ): void {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+
+		// Strip "Input" suffix for generated class name, but keep it for GraphQL name.
+		$gen_class_name = $ref->getShortName();
+		if ( str_ends_with( $gen_class_name, 'Input' ) ) {
+			$gen_class_name = substr( $gen_class_name, 0, -5 );
+		}
+
+		$description = $this->get_description( $ref );
+		$use_stmts   = array();
+		$fields      = array();
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+
+			$field = $this->build_field_definition( $prop, 'input', $use_stmts );
+			if ( $field !== null ) {
+				$fields[] = $field;
+			}
+		}
+
+		$code = $this->render_template(
+			'InputObjectTypeTemplate.php',
+			array(
+				'namespace'      => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Input',
+				'class_name'     => $gen_class_name,
+				'graphql_name'   => $graphql_name,
+				'description'    => $description,
+				'use_statements' => array_unique( $use_stmts ),
+				'fields'         => $fields,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Input/' . $gen_class_name . '.php';
+		file_put_contents( $path, $code );
+		++$this->input_type_count;
+	}
+
+	// ------ Resolver ------
+
+	private function generate_resolver( string $fqcn, \ReflectionClass $ref, string $kind ): void {
+		$graphql_name  = $this->graphql_names[ $fqcn ];
+		$description   = $this->get_description( $ref );
+		$command_alias = $ref->getShortName() . 'Command';
+		$use_stmts     = array();
+
+		$execute_method = $ref->getMethod( 'execute' );
+		$params         = $execute_method->getParameters();
+
+		// Determine return type.
+		$return_type       = $execute_method->getReturnType();
+		$connection_of     = $execute_method->getAttributes( ConnectionOf::class );
+		$has_connection_of = ! empty( $connection_of );
+
+		$return_type_expr = $this->get_return_type_expr( $execute_method, $use_stmts );
+
+		// Detect scalar return types (bool, int, float, string) to wrap in a result object.
+		$return_type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+		$scalar_return    = in_array( $return_type_name, array( 'bool', 'int', 'float', 'string' ), true );
+
+		// Build args and execute params.
+		$args             = array();
+		$execute_params   = array();
+		$input_converters = array();
+
+		foreach ( $params as $param ) {
+			$param_name = $param->getName();
+
+			// Infrastructure parameters.
+			if ( $param_name === '_query_info' ) {
+				$execute_params[] = array(
+					'name'              => $param_name,
+					'conversion'        => null,
+					'is_infrastructure' => true,
+				);
+				continue;
+			}
+
+			$param_type = $param->getType();
+			$type_name  = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+
+			// Unroll: expand each property of the class into a separate GraphQL arg.
+			if ( $this->should_unroll( $param, $type_name ) ) {
+				$unroll = $this->build_unroll_info( $type_name, $use_stmts );
+				foreach ( $unroll['args'] as $uarg ) {
+					$args[] = $uarg;
+				}
+				$execute_params[] = array(
+					'name'              => $param_name,
+					'conversion'        => null,
+					'is_infrastructure' => false,
+					'unroll'            => $unroll,
+				);
+				continue;
+			}
+
+			$arg_type_expr = $this->php_type_to_graphql_expr( $type_name, $param_type?->allowsNull() ?? false, $param, $use_stmts );
+
+			$param_description = $this->get_param_description( $param );
+
+			$arg_entry = array(
+				'name'        => $param_name,
+				'type_expr'   => $arg_type_expr,
+				'description' => $param_description,
+				'has_default' => $param->isDefaultValueAvailable(),
+				'default'     => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+			);
+			$args[]    = $arg_entry;
+
+			// Determine conversion for execute params.
+			$conversion = null;
+			$input_info = $this->get_class_info( $type_name );
+
+			if ( $input_info !== null && $input_info['kind'] === 'input_type' ) {
+				// Input type: needs conversion.
+				$converter_name = 'convert_' . $this->pascal_to_snake_case( ( new \ReflectionClass( $type_name ) )->getShortName() );
+				$conversion     = "self::{$converter_name}( \$args['{$param_name}'] )";
+
+				// Build converter if not already done.
+				if ( ! isset( $input_converters[ $type_name ] ) ) {
+					$input_converters[ $type_name ] = $this->build_input_converter( $type_name, $input_converters );
+				}
+			} elseif ( $input_info !== null && $input_info['kind'] === 'enum' ) {
+				// GraphQL engine already resolves enum input values to PHP enum instances,
+				// so no ::from() conversion is needed — just assign the value directly.
+			}
+
+			$execute_params[] = array(
+				'name'              => $param_name,
+				'conversion'        => $conversion,
+				'is_infrastructure' => false,
+			);
+		}
+
+		// Authorization: check for authorize() method.
+		$authorize_param_names = null;
+		$has_preauthorized     = false;
+		if ( $ref->hasMethod( 'authorize' ) ) {
+			$authorize_method  = $ref->getMethod( 'authorize' );
+			$authorize_ignored = ! empty( $authorize_method->getAttributes( Ignore::class ) );
+
+			if ( ! $authorize_ignored ) {
+				$validated             = $this->validate_authorize_method( $fqcn, $execute_method, $authorize_method );
+				$authorize_param_names = $validated['domain_params'];
+				$has_preauthorized     = $validated['has_preauthorized'];
+			}
+		}
+
+		// Resolve the attribute-declared authorization. When authorize() is
+		// present, these values are only used to compute the $_preauthorized
+		// flag passed to the method; check_capabilities() is not generated
+		// in that case (authorize() is the sole guard).
+		$auth          = $this->resolve_authorization( $ref );
+		$caps          = $auth['caps'];
+		$public_access = $auth['public'];
+
+		// Build the PHP expression that evaluates to the $_preauthorized
+		// boolean at runtime. Only meaningful when authorize() declares the
+		// $_preauthorized parameter; otherwise left at 'false' and unused.
+		$preauthorized_expr = 'false';
+		if ( $has_preauthorized ) {
+			if ( $public_access ) {
+				$preauthorized_expr = 'true';
+			} elseif ( ! empty( $caps ) ) {
+				$preauthorized_expr = implode(
+					' && ',
+					array_map(
+						fn( $cap ) => sprintf( "current_user_can( '%s' )", addslashes( $cap ) ),
+						$caps
+					)
+				);
+			}
+		}
+
+		$dir_name  = $kind === 'query' ? 'GraphQLQueries' : 'GraphQLMutations';
+		$namespace = self::AUTOGENERATED_NAMESPACE . '\\' . $dir_name;
+
+		$code = $this->render_template(
+			'QueryResolverTemplate.php',
+			array(
+				'namespace'             => $namespace,
+				'class_name'            => $ref->getShortName(),
+				'graphql_name'          => $graphql_name,
+				'description'           => $description,
+				'command_fqcn'          => $fqcn,
+				'command_alias'         => $command_alias,
+				'return_type_expr'      => $return_type_expr,
+				'use_statements'        => array_unique( $use_stmts ),
+				'args'                  => $args,
+				'capabilities'          => $caps,
+				'public_access'         => $public_access,
+				'has_connection_of'     => $has_connection_of,
+				'connection_type_alias' => '',
+				'execute_params'        => $execute_params,
+				'input_converters'      => array_values( $input_converters ),
+				'authorize_param_names' => $authorize_param_names,
+				'has_preauthorized'     => $has_preauthorized,
+				'preauthorized_expr'    => $preauthorized_expr,
+				'scalar_return'         => $scalar_return,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . "/{$dir_name}/" . $ref->getShortName() . '.php';
+		file_put_contents( $path, $code );
+
+		if ( $kind === 'query' ) {
+			++$this->query_count;
+		} else {
+			++$this->mutation_count;
+		}
+	}
+
+	/**
+	 * Validate that an authorize() method's parameters are a subset of execute().
+	 *
+	 * In addition to the domain parameters (which must appear in execute()),
+	 * authorize() may declare a single optional infrastructure parameter
+	 * `bool $_preauthorized`. When present, the generated resolver passes
+	 * `true` if the attribute-declared authorization would have granted access.
+	 *
+	 * @return array{domain_params: string[], has_preauthorized: bool}
+	 */
+	private function validate_authorize_method(
+		string $fqcn,
+		\ReflectionMethod $execute_method,
+		\ReflectionMethod $authorize_method,
+	): array {
+		$execute_params = array();
+		foreach ( $execute_method->getParameters() as $p ) {
+			$type                            = $p->getType();
+			$execute_params[ $p->getName() ] = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+		}
+
+		$domain_params     = array();
+		$has_preauthorized = false;
+
+		foreach ( $authorize_method->getParameters() as $p ) {
+			$name      = $p->getName();
+			$type      = $p->getType();
+			$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+
+			if ( '_preauthorized' === $name ) {
+				if ( 'bool' !== $type_name ) {
+					$this->errors[] = "{$fqcn}: authorize() parameter \$_preauthorized must be typed as bool.";
+					continue;
+				}
+				$has_preauthorized = true;
+				continue;
+			}
+
+			if ( ! array_key_exists( $name, $execute_params ) ) {
+				$this->errors[] = "{$fqcn}: authorize() parameter \${$name} does not exist in execute().";
+				continue;
+			}
+
+			if ( $execute_params[ $name ] !== $type_name ) {
+				$this->errors[] = "{$fqcn}: authorize() parameter \${$name} has type {$type_name}, but execute() has {$execute_params[$name]}.";
+				continue;
+			}
+
+			$domain_params[] = $name;
+		}
+
+		return array(
+			'domain_params'     => $domain_params,
+			'has_preauthorized' => $has_preauthorized,
+		);
+	}
+
+	/**
+	 * Whether a parameter should be unrolled into flat GraphQL args.
+	 */
+	private function should_unroll( \ReflectionParameter $param, string $type_name ): bool {
+		// Attribute on the parameter itself.
+		if ( ! empty( $param->getAttributes( Unroll::class ) ) ) {
+			return true;
+		}
+
+		// Attribute on the class.
+		if ( class_exists( $type_name ) ) {
+			$ref = new \ReflectionClass( $type_name );
+			if ( ! empty( $ref->getAttributes( Unroll::class ) ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Whether a Parameter attribute should be unrolled into flat GraphQL args.
+	 */
+	private function should_unroll_parameter( Parameter $param ): bool {
+		if ( $param->unroll ) {
+			return true;
+		}
+
+		if ( class_exists( $param->type ) ) {
+			$ref = new \ReflectionClass( $param->type );
+			if ( ! empty( $ref->getAttributes( Unroll::class ) ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Build unroll metadata: the list of GraphQL args and constructor properties
+	 * derived from the public properties of the given class.
+	 */
+	private function build_unroll_info( string $fqcn, array &$use_stmts ): array {
+		$ref        = new \ReflectionClass( $fqcn );
+		$args       = array();
+		$properties = array();
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+				continue;
+			}
+
+			$prop_name = $prop->getName();
+			$type      = $prop->getType();
+			$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+			$nullable  = $type?->allowsNull() ?? false;
+
+			// Description from attribute.
+			$desc_attrs  = $prop->getAttributes( Description::class );
+			$description = ! empty( $desc_attrs ) ? $desc_attrs[0]->newInstance()->description : '';
+
+			// Default value.
+			$has_default = $prop->hasDefaultValue();
+			$default     = $has_default ? $prop->getDefaultValue() : null;
+
+			// If promoted, the default may come from the constructor parameter.
+			if ( ! $has_default && $prop->isPromoted() ) {
+				$ctor = $ref->getConstructor();
+				if ( $ctor !== null ) {
+					foreach ( $ctor->getParameters() as $ctor_param ) {
+						if ( $ctor_param->getName() === $prop_name && $ctor_param->isDefaultValueAvailable() ) {
+							$has_default = true;
+							$default     = $ctor_param->getDefaultValue();
+							break;
+						}
+					}
+				}
+			}
+
+			// GraphQL type expression.
+			$type_expr = $this->php_type_to_graphql_expr( $type_name, $nullable, $prop, $use_stmts );
+
+			$args[] = array(
+				'name'        => $prop_name,
+				'type_expr'   => $type_expr,
+				'description' => $description,
+				'has_default' => $has_default,
+				'default'     => $default,
+			);
+
+			// Value expression for the constructor call.
+			$class_info = $this->get_class_info( $type_name );
+			if ( $class_info !== null && $class_info['kind'] === 'enum' ) {
+				// GraphQL engine already resolves enum input values to PHP enum instances,
+				// so no ::from() conversion is needed — just assign the value directly.
+				$value_expr = "\$args['{$prop_name}']";
+			} else {
+				$value_expr = "\$args['{$prop_name}']";
+				if ( $has_default ) {
+					$value_expr .= ' ?? ' . var_export( $default, true );
+				}
+			}
+
+			$properties[] = array(
+				'name'       => $prop_name,
+				'value_expr' => $value_expr,
+			);
+		}
+
+		return array(
+			'fqcn'       => $fqcn,
+			'args'       => $args,
+			'properties' => $properties,
+		);
+	}
+
+	private function build_input_converter( string $input_fqcn, array &$input_converters ): array {
+		$ref         = new \ReflectionClass( $input_fqcn );
+		$method_name = 'convert_' . $this->pascal_to_snake_case( $ref->getShortName() );
+		$properties  = array();
+
+		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+			$type      = $prop->getType();
+			$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+
+			$conversion = null;
+			$class_info = $this->get_class_info( $type_name );
+
+			if ( $class_info !== null && $class_info['kind'] === 'enum' ) {
+				// GraphQL engine already resolves enum input values to PHP enum instances,
+				// so no ::from() conversion is needed — just assign the value directly.
+			} elseif ( $class_info !== null && $class_info['kind'] === 'input_type' ) {
+				$nested_short  = ( new \ReflectionClass( $type_name ) )->getShortName();
+				$nested_method = 'convert_' . $this->pascal_to_snake_case( $nested_short );
+				$prop_name     = $prop->getName();
+
+				if ( $type->allowsNull() ) {
+					$conversion = "null !== \$data['{$prop_name}'] ? self::{$nested_method}( \$data['{$prop_name}'] ) : null";
+				} else {
+					$conversion = "self::{$nested_method}( \$data['{$prop_name}'] )";
+				}
+
+				// Recursively build the nested converter if not already registered.
+				if ( ! isset( $input_converters[ $type_name ] ) ) {
+					$input_converters[ $type_name ] = $this->build_input_converter( $type_name, $input_converters );
+				}
+			}
+
+			$properties[] = array(
+				'name'       => $prop->getName(),
+				'conversion' => $conversion,
+			);
+		}
+
+		return array(
+			'method_name' => $method_name,
+			'input_fqcn'  => $input_fqcn,
+			'input_class' => $ref->getShortName(),
+			'properties'  => $properties,
+		);
+	}
+
+	// ------ Connection ------
+
+	private function generate_connection( string $node_type_fqcn ): void {
+		$node_ref  = $this->classes[ $node_type_fqcn ]['class'] ?? new \ReflectionClass( $node_type_fqcn );
+		$node_name = $node_ref->getShortName();
+
+		$namespace             = self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Pagination';
+		$node_type_class       = $node_name;
+		$node_info             = $this->get_class_info( $node_type_fqcn );
+		$node_type_namespace   = ( null !== $node_info && 'interface' === $node_info['kind'] )
+			? self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Interfaces'
+			: self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Output';
+		$node_type_alias       = $node_name . 'Type';
+		$connection_class_name = $node_name . 'Connection';
+		$edge_class_name       = $node_name . 'Edge';
+
+		$connection_code = $this->generate_connection_code( $namespace, $node_type_class, $node_type_namespace, $node_type_alias, $connection_class_name, $edge_class_name );
+		$edge_code       = $this->generate_edge_code( $namespace, $node_type_class, $node_type_namespace, $node_type_alias, $edge_class_name );
+
+		$connection_path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/' . $connection_class_name . '.php';
+		file_put_contents( $connection_path, $connection_code );
+
+		$edge_path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/' . $edge_class_name . '.php';
+		file_put_contents( $edge_path, $edge_code );
+	}
+
+	private function generate_connection_code( string $namespace, string $node_type_class, string $node_type_namespace, string $node_type_alias, string $connection_class_name, string $edge_class_name ): string {
+		$code  = "<?php\n\n";
+		$code .= "declare(strict_types=1);\n\n";
+		$code .= "// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.\n\n";
+		$code .= "namespace {$namespace};\n\n";
+		$code .= "use {$node_type_namespace}\\{$node_type_class} as {$node_type_alias};\n";
+		$code .= "use GraphQL\\Type\\Definition\\ObjectType;\n";
+		$code .= "use GraphQL\\Type\\Definition\\Type;\n\n";
+		$code .= "class {$connection_class_name} {\n";
+		$code .= "\tprivate static ?ObjectType \$instance = null;\n\n";
+		$code .= "\tpublic static function get(): ObjectType {\n";
+		$code .= "\t\tif ( null === self::\$instance ) {\n";
+		$code .= "\t\t\tself::\$instance = new ObjectType(\n";
+		$code .= "\t\t\t\tarray(\n";
+		$code .= "\t\t\t\t\t'name'        => '{$connection_class_name}',\n";
+		$code .= "\t\t\t\t\t'description' => __( 'A connection to a list of {$node_type_class} items.', 'woocommerce' ),\n";
+		$code .= "\t\t\t\t\t'fields'      => fn() => array(\n";
+		$code .= "\t\t\t\t\t\t'edges'       => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::listOf( Type::nonNull(\n";
+		$code .= "\t\t\t\t\t\t\t\t{$edge_class_name}::get()\n";
+		$code .= "\t\t\t\t\t\t\t) ) ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t\t'nodes'       => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::listOf( Type::nonNull(\n";
+		$code .= "\t\t\t\t\t\t\t\t{$node_type_alias}::get()\n";
+		$code .= "\t\t\t\t\t\t\t) ) ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t\t'page_info'   => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( PageInfo::get() ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t\t'total_count' => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::int() ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t)\n";
+		$code .= "\t\t\t);\n";
+		$code .= "\t\t}\n";
+		$code .= "\t\treturn self::\$instance;\n";
+		$code .= "\t}\n";
+		$code .= "}\n";
+
+		return $code;
+	}
+
+	private function generate_edge_code( string $namespace, string $node_type_class, string $node_type_namespace, string $node_type_alias, string $edge_class_name ): string {
+		$code  = "<?php\n\n";
+		$code .= "declare(strict_types=1);\n\n";
+		$code .= "// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.\n\n";
+		$code .= "namespace {$namespace};\n\n";
+		$code .= "use {$node_type_namespace}\\{$node_type_class} as {$node_type_alias};\n";
+		$code .= "use GraphQL\\Type\\Definition\\ObjectType;\n";
+		$code .= "use GraphQL\\Type\\Definition\\Type;\n\n";
+		$code .= "class {$edge_class_name} {\n";
+		$code .= "\tprivate static ?ObjectType \$instance = null;\n\n";
+		$code .= "\tpublic static function get(): ObjectType {\n";
+		$code .= "\t\tif ( null === self::\$instance ) {\n";
+		$code .= "\t\t\tself::\$instance = new ObjectType(\n";
+		$code .= "\t\t\t\tarray(\n";
+		$code .= "\t\t\t\t\t'name'   => '{$edge_class_name}',\n";
+		$code .= "\t\t\t\t\t'fields' => fn() => array(\n";
+		$code .= "\t\t\t\t\t\t'cursor' => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::string() ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t\t'node'   => array(\n";
+		$code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( {$node_type_alias}::get() ),\n";
+		$code .= "\t\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t\t),\n";
+		$code .= "\t\t\t\t)\n";
+		$code .= "\t\t\t);\n";
+		$code .= "\t\t}\n";
+		$code .= "\t\treturn self::\$instance;\n";
+		$code .= "\t}\n";
+		$code .= "}\n";
+
+		return $code;
+	}
+
+	private function generate_page_info(): void {
+		$code = $this->render_template(
+			'PageInfoTypeTemplate.php',
+			array(
+				'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Pagination',
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/PageInfo.php';
+		file_put_contents( $path, $code );
+	}
+
+	// ------ Root Types ------
+
+	private function generate_root_query_type( array $queries ): void {
+		$query_data = array();
+		foreach ( $queries as $fqcn => $ref ) {
+			$query_data[] = array(
+				'class_name'   => $ref->getShortName(),
+				'fqcn'         => self::AUTOGENERATED_NAMESPACE . '\\GraphQLQueries\\' . $ref->getShortName(),
+				'graphql_name' => $this->root_field_name( $fqcn, $ref ),
+			);
+		}
+
+		$code = $this->render_template(
+			'RootQueryTypeTemplate.php',
+			array(
+				'namespace' => self::AUTOGENERATED_NAMESPACE,
+				'queries'   => $query_data,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/RootQueryType.php';
+		file_put_contents( $path, $code );
+	}
+
+	private function generate_root_mutation_type( array $mutations ): void {
+		$mutation_data = array();
+		foreach ( $mutations as $fqcn => $ref ) {
+			$mutation_data[] = array(
+				'class_name'   => $ref->getShortName(),
+				'fqcn'         => self::AUTOGENERATED_NAMESPACE . '\\GraphQLMutations\\' . $ref->getShortName(),
+				'graphql_name' => $this->root_field_name( $fqcn, $ref ),
+			);
+		}
+
+		$code = $this->render_template(
+			'RootMutationTypeTemplate.php',
+			array(
+				'namespace' => self::AUTOGENERATED_NAMESPACE,
+				'mutations' => $mutation_data,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/RootMutationType.php';
+		file_put_contents( $path, $code );
+	}
+
+	private function generate_type_registry(): void {
+		$types = array();
+		foreach ( $this->interface_implementors as $implementors ) {
+			foreach ( $implementors as $impl_fqcn ) {
+				$short   = ( new \ReflectionClass( $impl_fqcn ) )->getShortName();
+				$types[] = array(
+					'short_name' => $short,
+					'fqcn'       => self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}",
+				);
+			}
+		}
+
+		$code = $this->render_template(
+			'TypeRegistryTemplate.php',
+			array(
+				'namespace' => self::AUTOGENERATED_NAMESPACE,
+				'types'     => $types,
+			)
+		);
+
+		$path = self::AUTOGENERATED_DIR . '/TypeRegistry.php';
+		file_put_contents( $path, $code );
+	}
+
+	// ========================================================================
+	// Helpers
+	// ========================================================================
+
+	private function write_timestamp(): void {
+		file_put_contents(
+			self::AUTOGENERATED_DIR . '/api_generation_date.txt',
+			gmdate( 'c' )
+		);
+	}
+
+	private function render_template( string $template_name, array $vars ): string {
+		extract( $vars );
+		ob_start();
+		require self::TEMPLATES_DIR . '/' . $template_name;
+		return ob_get_clean();
+	}
+
+	private function get_description( \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase $ref ): string {
+		$attrs = $ref->getAttributes( Description::class );
+		return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+	}
+
+	private function get_param_description( \ReflectionParameter $param ): string {
+		$attrs = $param->getAttributes( Description::class );
+		return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+	}
+
+	private function get_class_info( string $class_name ): ?array {
+		return $this->classes[ $class_name ] ?? null;
+	}
+
+	private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts ): ?array {
+		$type        = $prop->getType();
+		$type_name   = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+		$nullable    = $type?->allowsNull() ?? false;
+		$has_default = $prop->hasDefaultValue();
+
+		// For input types, nullable or has-default fields are optional (no nonNull wrapper).
+		$is_optional = $context === 'input' && ( $nullable || $has_default );
+
+		$type_expr = $this->php_type_to_graphql_expr( $type_name, $nullable || $is_optional, $prop, $use_stmts );
+
+		$description = '';
+		$desc_attrs  = $prop->getAttributes( Description::class );
+		if ( ! empty( $desc_attrs ) ) {
+			$description = $desc_attrs[0]->newInstance()->description;
+		}
+
+		$deprecation = $prop->getAttributes( Deprecated::class );
+
+		// Field arguments (only for output types).
+		$args = array();
+		if ( $context === 'output' ) {
+			$param_attrs = $prop->getAttributes( Parameter::class );
+			foreach ( $param_attrs as $pa ) {
+				$param_inst = $pa->newInstance();
+
+				if ( $this->should_unroll_parameter( $param_inst ) ) {
+					$unroll_info = $this->build_unroll_info( $param_inst->type, $use_stmts );
+					foreach ( $unroll_info['args'] as $uarg ) {
+						$args[] = $uarg;
+					}
+					continue;
+				}
+
+				$arg_type_expr = $this->param_type_to_graphql_expr( $param_inst, $use_stmts );
+				$arg_entry     = array(
+					'name'        => $param_inst->name,
+					'type_expr'   => $arg_type_expr,
+					'description' => $param_inst->description,
+				);
+				if ( $param_inst->has_default ) {
+					$arg_entry['default'] = $param_inst->default;
+				}
+				$args[] = $arg_entry;
+			}
+
+			// Merge ParameterDescription.
+			$pd_attrs = $prop->getAttributes( ParameterDescription::class );
+			foreach ( $pd_attrs as $pda ) {
+				$pd_inst = $pda->newInstance();
+				foreach ( $args as &$arg ) {
+					if ( $arg['name'] === $pd_inst->name ) {
+						if ( ! empty( $arg['description'] ) ) {
+							$this->errors[] = "Property \"{$prop->getDeclaringClass()->getShortName()}::\${$prop->getName()}\": parameter \"{$pd_inst->name}\" has a description in both #[Parameter] and #[ParameterDescription].";
+						}
+						$arg['description'] = $pd_inst->description;
+					}
+				}
+			}
+		}
+
+		// Flag connection fields that have pagination args so the template
+		// can generate a resolve callback that slices the connection.
+		$is_connection        = $type_name === Connection::class;
+		$has_pagination       = $is_connection && ! empty( $args );
+		$paginated_connection = $context === 'output' && $has_pagination;
+
+		return array(
+			'name'                 => $prop->getName(),
+			'type_expr'            => $type_expr,
+			'description'          => $description,
+			'args'                 => $args,
+			'deprecation_reason'   => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+			'paginated_connection' => $paginated_connection,
+		);
+	}
+
+	private function php_type_to_graphql_expr( string $type_name, bool $nullable, \ReflectionProperty|\ReflectionParameter $context, array &$use_stmts ): string {
+		// Check for ScalarType attribute.
+		$scalar_attr = $context->getAttributes( ScalarType::class );
+		if ( ! empty( $scalar_attr ) ) {
+			$scalar_class = $scalar_attr[0]->newInstance()->type;
+			$scalar_short = ( new \ReflectionClass( $scalar_class ) )->getShortName();
+			$alias        = $scalar_short . 'Type';
+			$use_stmts[]  = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Scalars\\{$scalar_short} as {$alias}";
+			$expr         = "{$alias}::get()";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Check for ArrayOf attribute.
+		$array_of_attr = $context->getAttributes( ArrayOf::class );
+		if ( ! empty( $array_of_attr ) && $type_name === 'array' ) {
+			$item_type = $array_of_attr[0]->newInstance()->type;
+			$item_expr = $this->type_string_to_graphql_expr( $item_type, $use_stmts );
+			$expr      = "Type::listOf(Type::nonNull({$item_expr}))";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Check for ConnectionOf attribute.
+		$conn_attr = $context->getAttributes( ConnectionOf::class );
+		if ( ! empty( $conn_attr ) ) {
+			$node_type   = $conn_attr[0]->newInstance()->type;
+			$node_short  = ( new \ReflectionClass( $node_type ) )->getShortName();
+			$conn_alias  = $node_short . 'ConnectionType';
+			$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Pagination\\{$node_short}Connection as {$conn_alias}";
+
+			// Register the connection for generation.
+			$this->connections[ $node_type ] = array(
+				'node_type' => $node_type,
+				'source'    => $context instanceof \ReflectionProperty ? $context->getDeclaringClass()->getShortName() . '::$' . $context->getName() : 'return type',
+			);
+
+			$expr = "{$conn_alias}::get()";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Primitive types.
+		$primitive = match ( $type_name ) {
+			'int'    => 'Type::int()',
+			'float'  => 'Type::float()',
+			'string' => 'Type::string()',
+			'bool'   => 'Type::boolean()',
+			default  => null,
+		};
+
+		if ( $primitive !== null ) {
+			return $nullable ? $primitive : "Type::nonNull({$primitive})";
+		}
+
+		// Enum or type reference.
+		$class_info = $this->get_class_info( $type_name );
+		if ( $class_info !== null ) {
+			$short = ( new \ReflectionClass( $type_name ) )->getShortName();
+			if ( $class_info['kind'] === 'enum' ) {
+				$alias       = $short . 'Type';
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Enums\\{$short} as {$alias}";
+				$expr        = "{$alias}::get()";
+			} elseif ( $class_info['kind'] === 'type' ) {
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}";
+				$expr        = "{$short}::get()";
+			} elseif ( $class_info['kind'] === 'input_type' ) {
+				$gen_name    = str_ends_with( $short, 'Input' ) ? substr( $short, 0, -5 ) : $short;
+				$alias       = $gen_name . 'Input';
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Input\\{$gen_name} as {$alias}";
+				$expr        = "{$alias}::get()";
+			} else {
+				$expr = 'Type::string()'; // Fallback.
+			}
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Unknown type — fallback to string.
+		$this->warnings[] = "Unknown type '{$type_name}', falling back to String.";
+		return $nullable ? 'Type::string()' : 'Type::nonNull(Type::string())';
+	}
+
+	private function type_string_to_graphql_expr( string $type, array &$use_stmts ): string {
+		// Primitive string references.
+		return match ( $type ) {
+			'int'    => 'Type::int()',
+			'float'  => 'Type::float()',
+			'string' => 'Type::string()',
+			'bool'   => 'Type::boolean()',
+			default  => $this->class_type_to_graphql_expr( $type, $use_stmts ),
+		};
+	}
+
+	private function class_type_to_graphql_expr( string $fqcn, array &$use_stmts ): string {
+		$class_info = $this->get_class_info( $fqcn );
+		if ( $class_info === null ) {
+			$this->warnings[] = "Unknown class type '{$fqcn}' in ArrayOf.";
+			return 'Type::string()';
+		}
+
+		$short = ( new \ReflectionClass( $fqcn ) )->getShortName();
+
+		return match ( $class_info['kind'] ) {
+			'enum' => ( function () use ( $short, &$use_stmts ) {
+				$alias       = $short . 'Type';
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Enums\\{$short} as {$alias}";
+				return "{$alias}::get()";
+			} )(),
+			'type' => ( function () use ( $short, &$use_stmts ) {
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}";
+				return "{$short}::get()";
+			} )(),
+			default => 'Type::string()',
+		};
+	}
+
+	private function param_type_to_graphql_expr( Parameter $param, array &$use_stmts ): string {
+		$base = match ( $param->type ) {
+			'int'    => 'Type::int()',
+			'float'  => 'Type::float()',
+			'string' => 'Type::string()',
+			'bool'   => 'Type::boolean()',
+			default  => $this->class_type_to_graphql_expr( $param->type, $use_stmts ),
+		};
+
+		if ( $param->array ) {
+			$base = "Type::listOf(Type::nonNull({$base}))";
+		}
+
+		if ( ! $param->nullable && ! $param->has_default ) {
+			$base = "Type::nonNull({$base})";
+		}
+
+		return $base;
+	}
+
+	private function get_return_type_expr( \ReflectionMethod $method, array &$use_stmts ): string {
+		$return_type = $method->getReturnType();
+		if ( $return_type === null ) {
+			return 'Type::string()';
+		}
+
+		$type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+		$nullable  = $return_type->allowsNull();
+
+		// Check for ReturnType attribute (interface return).
+		$return_type_attr = $method->getAttributes( ReturnType::class );
+		if ( ! empty( $return_type_attr ) ) {
+			$iface_class = $return_type_attr[0]->newInstance()->type;
+			$iface_info  = $this->get_class_info( $iface_class );
+			if ( null !== $iface_info && 'interface' === $iface_info['kind'] ) {
+				$iface_short = ( new \ReflectionClass( $iface_class ) )->getShortName();
+				$alias       = $iface_short . 'Interface';
+				$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Interfaces\\{$iface_short} as {$alias}";
+				$expr        = "{$alias}::get()";
+				return $nullable ? $expr : "Type::nonNull({$expr})";
+			}
+		}
+
+		// Check for ConnectionOf on the method.
+		$conn_attr = $method->getAttributes( ConnectionOf::class );
+		if ( ! empty( $conn_attr ) ) {
+			$node_type   = $conn_attr[0]->newInstance()->type;
+			$node_short  = ( new \ReflectionClass( $node_type ) )->getShortName();
+			$conn_alias  = $node_short . 'ConnectionType';
+			$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Pagination\\{$node_short}Connection as {$conn_alias}";
+
+			$this->connections[ $node_type ] = array(
+				'node_type' => $node_type,
+				'source'    => $method->getDeclaringClass()->getShortName() . '::execute()',
+			);
+
+			$expr = "{$conn_alias}::get()";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Output type reference.
+		$class_info = $this->get_class_info( $type_name );
+		if ( $class_info !== null && $class_info['kind'] === 'type' ) {
+			$short       = ( new \ReflectionClass( $type_name ) )->getShortName();
+			$alias       = $short . 'Type';
+			$use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short} as {$alias}";
+			$expr        = "{$alias}::get()";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
+		// Primitive return.
+		$primitive = match ( $type_name ) {
+			'int'    => 'Type::int()',
+			'float'  => 'Type::float()',
+			'string' => 'Type::string()',
+			'bool'   => 'Type::boolean()',
+			default  => 'Type::string()',
+		};
+
+		return $nullable ? $primitive : "Type::nonNull({$primitive})";
+	}
+
+	private function to_screaming_snake_case( string $pascal_case ): string {
+		$result = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $pascal_case );
+		$result = preg_replace( '/([A-Z]+)([A-Z][a-z])/', '$1_$2', $result );
+		return strtoupper( $result );
+	}
+
+	private function pascal_to_snake_case( string $pascal_case ): string {
+		$result = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $pascal_case );
+		$result = preg_replace( '/([A-Z]+)([A-Z][a-z])/', '$1_$2', $result );
+		return strtolower( $result );
+	}
+
+	/**
+	 * Compute the GraphQL field name to use for a query or mutation on the
+	 * root Query/Mutation type.
+	 *
+	 * GraphQL convention is PascalCase for type names and camelCase for
+	 * field names, so a class like `CreateProduct` becomes the field
+	 * `createProduct`. When the user explicitly supplied a `#[Name(...)]`
+	 * attribute we respect their string verbatim.
+	 */
+	private function root_field_name( string $fqcn, \ReflectionClass $ref ): string {
+		$graphql_name = $this->graphql_names[ $fqcn ];
+		if ( ! empty( $ref->getAttributes( Name::class ) ) ) {
+			return $graphql_name;
+		}
+		return lcfirst( $graphql_name );
+	}
+
+	private function format_with_phpcbf( string $file_path ): void {
+		$wc_dir = realpath( __DIR__ . '/../../../../..' );
+		$phpcbf = $wc_dir . '/vendor/bin/phpcbf';
+		exec( $phpcbf . ' -q ' . escapeshellarg( $file_path ) . ' 2>&1' );
+	}
+
+	private function rmdir_recursive( string $dir ): void {
+		$iterator = new \RecursiveIteratorIterator(
+			new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+			\RecursiveIteratorIterator::CHILD_FIRST
+		);
+
+		foreach ( $iterator as $file ) {
+			if ( $file->isDir() ) {
+				rmdir( $file->getPathname() );
+			} else {
+				unlink( $file->getPathname() );
+			}
+		}
+
+		rmdir( $dir );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
new file mode 100644
index 00000000000..4a535f0ef05
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api\DesignTime\Scripts;
+
+/**
+ * Checks whether the autogenerated API code is stale and needs rebuilding.
+ */
+class StalenessChecker {
+	/**
+	 * Returns true if the autogenerated code is stale (needs rebuilding).
+	 */
+	public static function is_stale(): bool {
+		$timestamp_file = __DIR__ . '/../../Autogenerated/api_generation_date.txt';
+
+		if ( ! file_exists( $timestamp_file ) ) {
+			return true;
+		}
+
+		$generation_time = strtotime( file_get_contents( $timestamp_file ) );
+		$api_dir         = __DIR__ . '/../../../../Api';
+
+		$iterator = new \RecursiveIteratorIterator(
+			new \RecursiveDirectoryIterator( $api_dir, \FilesystemIterator::SKIP_DOTS )
+		);
+
+		foreach ( $iterator as $file ) {
+			if ( 'php' === $file->getExtension() && $file->getMTime() > $generation_time ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php
new file mode 100644
index 00000000000..a3ed5eddd88
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+// Refuse to run outside the CLI: this script wipes and regenerates the
+// Autogenerated/ directory, so a misconfigured web server that accidentally
+// serves this file could destroy the checked-in output on every hit.
+if ( PHP_SAPI !== 'cli' ) {
+	http_response_code( 403 );
+	exit;
+}
+
+if ( PHP_VERSION_ID < 80100 ) {
+	fwrite(
+		STDERR,
+		sprintf(
+			"Error: PHP 8.1 or later is required to run the API build script. Current version: %s.\n",
+			PHP_VERSION
+		)
+	);
+	exit( 2 );
+}
+
+require_once __DIR__ . '/../../../../../vendor/autoload.php';
+
+use Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\ApiBuilder;
+
+$skip_linter = in_array( '--no-linter', $argv, true );
+
+$builder = new ApiBuilder();
+$builder->build( $skip_linter );
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php
new file mode 100644
index 00000000000..356375b9b84
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+if ( PHP_VERSION_ID < 80100 ) {
+	fwrite(
+		STDERR,
+		sprintf(
+			"Error: PHP 8.1 or later is required. Current version: %s.\n",
+			PHP_VERSION
+		)
+	);
+	exit( 2 );
+}
+
+require_once __DIR__ . '/../../../../../vendor/autoload.php';
+
+use Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\StalenessChecker;
+
+if ( StalenessChecker::is_stale() ) {
+	fwrite( STDERR, "ERROR: Generated GraphQL API code is out of date.\n" );
+	fwrite( STDERR, "Run 'pnpm run build:api' to regenerate.\n" );
+	exit( 1 );
+}
+
+echo "GraphQL API code is up to date.\n";
+exit( 0 );
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
new file mode 100644
index 00000000000..388f3c0df96
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Template for generating a GraphQL EnumType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $enum_fqcn
+ * @var string $enum_alias
+ * @var array  $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string]
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $enum_fqcn; ?> as <?php echo $enum_alias; ?>;
+use GraphQL\Type\Definition\EnumType;
+
+class <?php echo $class_name; ?> {
+	private static ?EnumType $instance = null;
+
+	public static function get(): EnumType {
+		if ( null === self::$instance ) {
+			self::$instance = new EnumType(
+				array(
+					'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+					'values' => array(
+<?php foreach ( $values as $val ) : ?>
+						'<?php echo $val['graphql_name']; ?>' => array(
+							'value' => <?php echo $enum_alias; ?>::<?php echo $val['case_name']; ?>,
+	<?php if ( ! empty( $val['description'] ) ) : ?>
+							'description' => __( '<?php echo addslashes( $val['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $val['deprecation_reason'] ) ) : ?>
+							'deprecationReason' => '<?php echo addslashes( $val['deprecation_reason'] ); ?>',
+<?php endif; ?>
+						),
+<?php endforeach; ?>
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
new file mode 100644
index 00000000000..8c446288e60
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Template for generating a GraphQL InputObjectType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var array  $use_statements
+ * @var array  $fields - each: ['name', 'type_expr', 'description']
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+	private static ?InputObjectType $instance = null;
+
+	public static function get(): InputObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new InputObjectType(
+				array(
+					'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+					'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+						'<?php echo $field['name']; ?>' => array(
+							'type' => <?php echo $field['type_expr']; ?>,
+	<?php if ( ! empty( $field['description'] ) ) : ?>
+							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+						),
+<?php endforeach; ?>
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
new file mode 100644
index 00000000000..5624825876c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Template for generating a GraphQL InterfaceType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var array  $use_statements
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string]
+ * @var array  $type_map - each: ['fqcn' => string, 'alias' => string] mapping PHP FQCN to generated ObjectType alias
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+	private static ?InterfaceType $instance = null;
+
+	public static function get(): InterfaceType {
+		if ( null === self::$instance ) {
+			self::$instance = new InterfaceType(
+				array(
+					'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+					'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+						'<?php echo $field['name']; ?>' => array(
+							'type' => <?php echo $field['type_expr']; ?>,
+	<?php if ( ! empty( $field['description'] ) ) : ?>
+							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $field['args'] ) ) : ?>
+							'args' => array(
+		<?php foreach ( $field['args'] as $arg ) : ?>
+								'<?php echo $arg['name']; ?>' => array(
+									'type' => <?php echo $arg['type_expr']; ?>,
+			<?php if ( array_key_exists( 'default', $arg ) ) : ?>
+									'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+			<?php if ( ! empty( $arg['description'] ) ) : ?>
+									'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+								),
+<?php endforeach; ?>
+							),
+<?php endif; ?>
+	<?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
+							'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
+<?php endif; ?>
+						),
+<?php endforeach; ?>
+					),
+					'resolveType' => function ( $value ) {
+						$class = get_class( $value );
+						$map = array(
+<?php foreach ( $type_map as $entry ) : ?>
+							'<?php echo $entry['fqcn']; ?>' => <?php echo $entry['alias']; ?>::get(),
+<?php endforeach; ?>
+						);
+						return $map[ $class ] ?? null;
+					},
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php
new file mode 100644
index 00000000000..2a6393cebda
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Template for generating a mutation resolver class.
+ * Identical structure to QueryResolverTemplate — mutations and queries follow the same resolver pattern.
+ *
+ * Variables: same as QueryResolverTemplate.php
+ */
+
+// Re-use the query resolver template since the structure is identical.
+require __DIR__ . '/QueryResolverTemplate.php';
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
new file mode 100644
index 00000000000..96cdc825a42
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Template for generating a GraphQL ObjectType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var array  $use_statements
+ * @var array  $interfaces - each: ['alias' => string]
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool]
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php
+$has_paginated_connection = false;
+foreach ( $fields as $f ) {
+	if ( ! empty( $f['paginated_connection'] ) ) {
+		$has_paginated_connection = true;
+		break;
+	}
+}
+?>
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+<?php if ( $has_paginated_connection ) : ?>
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Internal\Api\Utils;
+<?php endif; ?>
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $interfaces ) ) : ?>
+					'interfaces' => fn() => array(
+	<?php foreach ( $interfaces as $iface ) : ?>
+						<?php echo $iface['alias']; ?>::get(),
+<?php endforeach; ?>
+					),
+<?php endif; ?>
+					'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+						'<?php echo $field['name']; ?>' => array(
+							'type' => <?php echo $field['type_expr']; ?>,
+	<?php if ( ! empty( $field['description'] ) ) : ?>
+							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $field['args'] ) ) : ?>
+							'args' => array(
+		<?php foreach ( $field['args'] as $arg ) : ?>
+								'<?php echo $arg['name']; ?>' => array(
+									'type' => <?php echo $arg['type_expr']; ?>,
+			<?php if ( array_key_exists( 'default', $arg ) ) : ?>
+									'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+			<?php if ( ! empty( $arg['description'] ) ) : ?>
+									'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+								),
+<?php endforeach; ?>
+							),
+<?php endif; ?>
+	<?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
+							'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
+<?php endif; ?>
+	<?php if ( ! empty( $field['paginated_connection'] ) ) : ?>
+							'complexity' => Utils::complexity_from_pagination(...),
+							'resolve'    => fn( $parent, array $args ): Connection => Utils::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) ),
+<?php endif; ?>
+						),
+<?php endforeach; ?>
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php
new file mode 100644
index 00000000000..1534a69d7ac
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Template for generating the shared PageInfo GraphQL type class.
+ *
+ * @var string $namespace
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class PageInfo {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'PageInfo',
+					'fields' => array(
+						'has_next_page'     => array(
+							'type' => Type::nonNull( Type::boolean() ),
+						),
+						'has_previous_page' => array(
+							'type' => Type::nonNull( Type::boolean() ),
+						),
+						'start_cursor'      => array(
+							'type' => Type::string(),
+						),
+						'end_cursor'        => array(
+							'type' => Type::string(),
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
new file mode 100644
index 00000000000..94351b88a8f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Template for generating a query/mutation resolver class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $command_fqcn
+ * @var string $command_alias
+ * @var string $return_type_expr
+ * @var array  $use_statements
+ * @var array  $args - each: ['name', 'type_expr', 'description', 'has_default', 'default']
+ * @var array  $capabilities
+ * @var bool   $public_access
+ * @var bool   $has_connection_of
+ * @var string $connection_type_alias
+ * @var array  $execute_params - each: ['name', 'conversion' => ?string, 'is_infrastructure' => bool, 'unroll' => ?array]
+ * @var array  $input_converters - each: ['method_name', 'input_fqcn', 'input_class', 'properties' => [['name', 'conversion']]]
+ * @var ?array $authorize_param_names - if non-null, the authorize() method param names (subset of execute params)
+ * @var bool   $has_preauthorized - true when authorize() declares a bool $_preauthorized infrastructure param
+ * @var string $preauthorized_expr - PHP expression that evaluates to the $_preauthorized bool at runtime
+ * @var bool   $scalar_return - true when execute() returns a scalar (bool, int, float, string)
+ */
+
+$escaped_description = addslashes( $description );
+$has_authorize       = $authorize_param_names !== null;
+$has_cap_check       = ! $has_authorize && ! $public_access && ! empty( $capabilities );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $command_fqcn; ?> as <?php echo $command_alias; ?>;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+	public static function get_field_definition(): array {
+		return array(
+<?php if ( $scalar_return ) : ?>
+			'type' => Type::nonNull(new \GraphQL\Type\Definition\ObjectType(array(
+				'name' => '<?php echo $class_name; ?>Result',
+				'fields' => array(
+					'result' => array( 'type' => <?php echo $return_type_expr; ?> ),
+				),
+			))),
+<?php else : ?>
+			'type' => <?php echo $return_type_expr; ?>,
+<?php endif; ?>
+<?php if ( $description !== '' ) : ?>
+			'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+			'args' => array(
+<?php foreach ( $args as $arg ) : ?>
+				'<?php echo $arg['name']; ?>' => array(
+					'type' => <?php echo $arg['type_expr']; ?>,
+	<?php if ( ! empty( $arg['description'] ) ) : ?>
+					'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( $arg['has_default'] ) : ?>
+					'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+				),
+<?php endforeach; ?>
+			),
+<?php if ( $has_connection_of ) : ?>
+			'complexity' => Utils::complexity_from_pagination(...),
+<?php endif; ?>
+			'resolve' => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+<?php if ( $has_cap_check ) : ?>
+<?php foreach ( $capabilities as $cap ) : ?>
+		Utils::check_current_user_can( '<?php echo addslashes( $cap ); ?>' );
+<?php endforeach; ?>
+
+<?php endif; ?>
+		$command = wc_get_container()->get( <?php echo $command_alias; ?>::class );
+
+		$execute_args = array();
+<?php
+$pagination_fqcn = 'Automattic\\WooCommerce\\Api\\Pagination\\PaginationParams';
+foreach ( $execute_params as $param ) :
+	if ( ! empty( $param['unroll'] ) && $param['unroll']['fqcn'] === $pagination_fqcn ) :
+?>
+		$execute_args['<?php echo $param['name']; ?>'] = Utils::create_pagination_params( $args );
+<?php elseif ( ! empty( $param['unroll'] ) ) : ?>
+		$execute_args['<?php echo $param['name']; ?>'] = Utils::create_input(
+			fn() => new \<?php echo $param['unroll']['fqcn']; ?>(
+<?php foreach ( $param['unroll']['properties'] as $uprop ) : ?>
+				<?php echo $uprop['name']; ?>: <?php echo $uprop['value_expr']; ?>,
+<?php endforeach; ?>
+			)
+		);
+<?php elseif ( $param['is_infrastructure'] && $param['name'] === '_query_info' ) : ?>
+		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+<?php elseif ( ! empty( $param['conversion'] ) ) : ?>
+		if ( array_key_exists( '<?php echo $param['name']; ?>', $args ) ) {
+			$execute_args['<?php echo $param['name']; ?>'] = <?php echo $param['conversion']; ?>;
+		}
+<?php else : ?>
+		if ( array_key_exists( '<?php echo $param['name']; ?>', $args ) ) {
+			$execute_args['<?php echo $param['name']; ?>'] = $args['<?php echo $param['name']; ?>'];
+		}
+<?php endif; ?>
+<?php endforeach; ?>
+
+<?php if ( $has_authorize ) : ?>
+		if ( ! Utils::authorize_command( $command, array(
+<?php foreach ( $authorize_param_names as $name ) : ?>
+			'<?php echo $name; ?>' => $execute_args['<?php echo $name; ?>'],
+<?php endforeach; ?>
+<?php if ( $has_preauthorized ) : ?>
+			'_preauthorized' => <?php echo $preauthorized_expr; ?>,
+<?php endif; ?>
+		) ) ) {
+			throw new \GraphQL\Error\Error(
+				'You do not have permission to perform this action.',
+				extensions: array( 'code' => 'UNAUTHORIZED' )
+			);
+		}
+
+<?php endif; ?>
+		$result = Utils::execute_command( $command, $execute_args );
+
+<?php if ( $scalar_return ) : ?>
+		return array( 'result' => $result );
+<?php else : ?>
+		return $result;
+<?php endif; ?>
+	}
+<?php foreach ( $input_converters as $converter ) : ?>
+
+	private static function <?php echo $converter['method_name']; ?>( array $data ): \<?php echo $converter['input_fqcn']; ?> {
+		$input = new \<?php echo $converter['input_fqcn']; ?>();
+
+	<?php foreach ( $converter['properties'] as $prop ) : ?>
+		if ( array_key_exists( '<?php echo $prop['name']; ?>', $data ) ) {
+			$input->mark_provided( '<?php echo $prop['name']; ?>' );
+		<?php if ( ! empty( $prop['conversion'] ) ) : ?>
+			$input-><?php echo $prop['name']; ?> = <?php echo $prop['conversion']; ?>;
+<?php else : ?>
+			$input-><?php echo $prop['name']; ?> = $data['<?php echo $prop['name']; ?>'];
+<?php endif; ?>
+		}
+<?php endforeach; ?>
+
+		return $input;
+	}
+<?php endforeach; ?>
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php
new file mode 100644
index 00000000000..241617d85c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the RootMutationType class.
+ *
+ * @var string $namespace
+ * @var array  $mutations - each: ['class_name', 'fqcn', 'graphql_name']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $mutations as $mutation ) : ?>
+use <?php echo $mutation['fqcn']; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ObjectType;
+
+class RootMutationType {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'Mutation',
+					'fields' => fn() => array(
+<?php foreach ( $mutations as $mutation ) : ?>
+						'<?php echo $mutation['graphql_name']; ?>' => <?php echo $mutation['class_name']; ?>::get_field_definition(),
+<?php endforeach; ?>
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
new file mode 100644
index 00000000000..54ed76c8868
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the RootQueryType class.
+ *
+ * @var string $namespace
+ * @var array  $queries - each: ['class_name', 'fqcn', 'graphql_name']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $queries as $query ) : ?>
+use <?php echo $query['fqcn']; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ObjectType;
+
+class RootQueryType {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'Query',
+					'fields' => fn() => array(
+<?php foreach ( $queries as $query ) : ?>
+						'<?php echo $query['graphql_name']; ?>' => <?php echo $query['class_name']; ?>::get_field_definition(),
+<?php endforeach; ?>
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
new file mode 100644
index 00000000000..6316734c7e3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Template for generating a GraphQL CustomScalarType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $scalar_fqcn
+ * @var string $scalar_alias
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $scalar_fqcn; ?> as <?php echo $scalar_alias; ?>;
+use GraphQL\Type\Definition\CustomScalarType;
+
+class <?php echo $class_name; ?> {
+	private static ?CustomScalarType $instance = null;
+
+	public static function get(): CustomScalarType {
+		if ( null === self::$instance ) {
+			self::$instance = new CustomScalarType(
+				array(
+					'name'         => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+					'description'  => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+					'serialize'    => fn( $value ) => <?php echo $scalar_alias; ?>::serialize( $value ),
+					'parseValue'   => function ( $value ) {
+						try {
+							return <?php echo $scalar_alias; ?>::parse( $value );
+						} catch ( \InvalidArgumentException $e ) {
+							throw new \GraphQL\Error\Error( $e->getMessage() );
+						}
+					},
+					'parseLiteral' => function ( $value_node, ?array $variables = null ) {
+						if ( $value_node instanceof \GraphQL\Language\AST\StringValueNode ) {
+							try {
+								return <?php echo $scalar_alias; ?>::parse( $value_node->value );
+							} catch ( \InvalidArgumentException $e ) {
+								throw new \GraphQL\Error\Error( $e->getMessage() );
+							}
+						}
+						throw new \GraphQL\Error\Error(
+							'<?php echo $graphql_name; ?> must be a string, got: ' . $value_node->kind
+						);
+					},
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php
new file mode 100644
index 00000000000..0538ffaeef2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the TypeRegistry class.
+ *
+ * Lists all concrete types that implement interfaces, so the schema
+ * can register them for inline fragment resolution.
+ *
+ * @var string $namespace
+ * @var array  $types - each: ['short_name', 'fqcn']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $types as $type ) : ?>
+use <?php echo $type['fqcn']; ?>;
+<?php endforeach; ?>
+
+class TypeRegistry {
+	/**
+	 * Return all concrete types that implement interfaces.
+	 *
+	 * Pass this to the Schema 'types' config so that inline fragments
+	 * (e.g. `... on VariableProduct`) are resolvable.
+	 *
+	 * @return array
+	 */
+	public static function get_interface_implementors(): array {
+		return array(
+<?php foreach ( $types as $type ) : ?>
+			<?php echo $type['short_name']; ?>::get(),
+<?php endforeach; ?>
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
new file mode 100644
index 00000000000..456bca157ed
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -0,0 +1,599 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\RootQueryType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\RootMutationType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\TypeRegistry;
+use GraphQL\GraphQL;
+use GraphQL\Language\AST\DocumentNode;
+use GraphQL\Language\AST\FieldNode;
+use GraphQL\Language\AST\InlineFragmentNode;
+use GraphQL\Language\AST\OperationDefinitionNode;
+use GraphQL\Language\AST\SelectionSetNode;
+use GraphQL\Type\Schema;
+use GraphQL\Error\DebugFlag;
+use GraphQL\Validator\DocumentValidator;
+use GraphQL\Validator\Rules\DisableIntrospection;
+use GraphQL\Validator\Rules\QueryComplexity;
+use GraphQL\Validator\Rules\QueryDepth;
+
+/**
+ * Handles incoming GraphQL requests over the WooCommerce REST API.
+ */
+class GraphQLController {
+	/**
+	 * Maximum nesting depth allowed in a GraphQL query.
+	 *
+	 * Queries exceeding this depth are rejected during validation, before any
+	 * resolver runs. See {@see self::get_max_query_depth()} for the accessor.
+	 */
+	private const MAX_QUERY_DEPTH = 15;
+
+	/**
+	 * Maximum computed complexity score allowed for a GraphQL query.
+	 *
+	 * Complexity is the sum of per-field scores; connection fields multiply
+	 * their child score by the requested page size. Queries exceeding this
+	 * score are rejected during validation. See {@see self::get_max_query_complexity()}.
+	 */
+	private const MAX_QUERY_COMPLEXITY = 1000;
+
+	/**
+	 * Cached GraphQL schema instance.
+	 *
+	 * @var ?Schema
+	 */
+	private ?Schema $schema = null;
+
+	/**
+	 * Query cache / APQ resolver.
+	 *
+	 * @var QueryCache
+	 */
+	private QueryCache $query_cache;
+
+	/**
+	 * DI: injected by WooCommerce container.
+	 *
+	 * @internal
+	 * @param QueryCache $query_cache The query cache instance.
+	 */
+	final public function init( QueryCache $query_cache ): void {
+		$this->query_cache = $query_cache;
+	}
+
+	/**
+	 * The maximum nesting depth allowed in a GraphQL query.
+	 *
+	 * Exposed as a method so the limit can become configurable — e.g. via a
+	 * filter or store option — without requiring call-site changes.
+	 */
+	public static function get_max_query_depth(): int {
+		return self::MAX_QUERY_DEPTH;
+	}
+
+	/**
+	 * The maximum computed complexity score allowed for a GraphQL query.
+	 *
+	 * Exposed as a method so the limit can become configurable — e.g. via a
+	 * filter or store option — without requiring call-site changes.
+	 */
+	public static function get_max_query_complexity(): int {
+		return self::MAX_QUERY_COMPLEXITY;
+	}
+
+	/**
+	 * Register the GraphQL REST route.
+	 */
+	public function register(): void {
+		register_rest_route(
+			'wc',
+			'/graphql',
+			array(
+				'methods'             => array( 'GET', 'POST' ),
+				'callback'            => array( $this, 'handle_request' ),
+				// Auth is handled per-query/mutation.
+				'permission_callback' => '__return_true',
+			)
+		);
+	}
+
+	/**
+	 * Handle an incoming GraphQL request.
+	 *
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
+		try {
+			return $this->process_request( $request );
+		} catch ( \Throwable $e ) {
+			$output = array(
+				'errors' => array(
+					$this->format_exception( $e, $request ),
+				),
+			);
+
+			$status = $this->get_error_status( $output['errors'] );
+			return new \WP_REST_Response( $output, $status );
+		}
+	}
+
+	/**
+	 * Process the GraphQL request. Extracted so that handle_request() can
+	 * wrap everything in a single try/catch that respects debug mode.
+	 *
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	private function process_request( \WP_REST_Request $request ): \WP_REST_Response {
+		// 2. Parse request. GET query-string `variables` and `extensions`
+		// arrive as JSON strings; decode_json_param() unifies them with the
+		// already-decoded-array path from POST bodies and rejects malformed
+		// or non-object payloads up front so they surface as HTTP 400
+		// INVALID_ARGUMENT instead of as confusing resolver errors (null
+		// decode) or HTTP 500 TypeErrors (scalar decode).
+		$query          = $request->get_param( 'query' );
+		$operation_name = $request->get_param( 'operationName' );
+		$variables      = $this->decode_json_param( $request->get_param( 'variables' ), 'variables' );
+		$extensions     = $this->decode_json_param( $request->get_param( 'extensions' ), 'extensions' );
+
+		// 3. Resolve query (cache lookup / APQ / parse).
+		$source = $this->query_cache->resolve( $query, $extensions );
+		if ( is_array( $source ) ) {
+			return new \WP_REST_Response( $source, $this->get_resolve_error_status( $source ) );
+		}
+
+		// 4. Reject mutations over GET (GraphQL over HTTP spec).
+		if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) {
+			return new \WP_REST_Response(
+				array(
+					'errors' => array(
+						array(
+							'message'    => 'Mutations are not allowed over GET requests. Use POST instead.',
+							'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
+						),
+					),
+				),
+				405
+			);
+		}
+
+		// 5. Load schema.
+		$schema = $this->get_schema();
+
+		// 6. Build validation rules.
+		// A single QueryComplexity instance is kept so its computed score can
+		// be surfaced in the debug extensions after execution.
+		$complexity_rule    = new QueryComplexity( self::get_max_query_complexity() );
+		$validation_rules   = array_values( DocumentValidator::allRules() );
+		$validation_rules[] = new QueryDepth( self::get_max_query_depth() );
+		$validation_rules[] = $complexity_rule;
+		if ( ! $this->is_introspection_allowed( $request ) ) {
+			$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
+		}
+
+		// 7. Execute.
+		$result = GraphQL::executeQuery(
+			schema: $schema,
+			source: $source,
+			variableValues: $variables,
+			operationName: $operation_name,
+			validationRules: $validation_rules,
+		);
+
+		// Install an error formatter that guarantees every error carries an
+		// `extensions.code`. Our resolvers route everything through
+		// Utils::execute_command / Utils::authorize_command, which already
+		// translate domain exceptions (ApiException, InvalidArgumentException,
+		// generic Throwable) into coded GraphQL errors at the throw site.
+		// What reaches us uncoded here is webonyx-native validation and
+		// execution output, so we infer from webonyx's ClientAware signal:
+		// client-safe errors become BAD_USER_INPUT (400), the rest become
+		// INTERNAL_ERROR (500).
+		//
+		// In debug mode the same formatter also walks the previous-exception
+		// chain so wrapped errors (e.g. a \ValueError caught by a resolver and
+		// re-thrown as INTERNAL_ERROR) stay visible to the developer instead
+		// of being masked behind the generic "Internal server error" message.
+		$debug_mode = $this->is_debug_mode( $request );
+		$result->setErrorFormatter(
+			function ( \Throwable $error ) use ( $debug_mode ): array {
+				$formatted = \GraphQL\Error\FormattedError::createFromException( $error );
+
+				if ( ! isset( $formatted['extensions']['code'] ) ) {
+					$client_safe                     = $error instanceof \GraphQL\Error\ClientAware && $error->isClientSafe();
+					$formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR';
+				}
+
+				if ( $debug_mode ) {
+					$chain = $this->extract_previous_chain( $error );
+					if ( ! empty( $chain ) ) {
+						$formatted['extensions']['previous'] = $chain;
+					}
+				}
+
+				return $formatted;
+			}
+		);
+
+		$debug_flags = $this->get_debug_flags( $request );
+		$output      = $result->toArray( $debug_flags );
+
+		// 8. Debug-mode metrics: expose the computed complexity and depth so
+		// clients tuning queries can see what the server scored the request at.
+		if ( $this->is_debug_mode( $request ) ) {
+			if ( ! isset( $output['extensions'] ) ) {
+				$output['extensions'] = array();
+			}
+			if ( ! isset( $output['extensions']['debug'] ) ) {
+				$output['extensions']['debug'] = array();
+			}
+			$output['extensions']['debug']['complexity'] = $complexity_rule->getQueryComplexity();
+			$output['extensions']['debug']['depth']      = $this->compute_query_depth( $source, $operation_name );
+		}
+
+		// 9. Determine HTTP status code. GraphQL emits `data: { field: null }`
+		// for nullable root fields even when the resolver errored, so gating
+		// the status override on `data` being absent would leave nearly every
+		// error response on HTTP 200. Always derive the status from the
+		// errors array when one is present — clients that need "200 with
+		// partial data" semantics can still read the `errors` array.
+		$status = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;
+
+		return new \WP_REST_Response( $output, $status );
+	}
+
+	/**
+	 * Build and cache the GraphQL schema.
+	 */
+	private function get_schema(): Schema {
+		if ( null === $this->schema ) {
+			$this->schema = new Schema(
+				array(
+					'query'    => RootQueryType::get(),
+					'mutation' => RootMutationType::get(),
+					'types'    => TypeRegistry::get_interface_implementors(),
+				)
+			);
+		}
+		return $this->schema;
+	}
+
+	/**
+	 * Decode an optional JSON-object param (`variables` / `extensions`) into an array.
+	 *
+	 * WP_REST_Request delivers POST-body params as already-decoded arrays,
+	 * but GET query-string equivalents arrive as raw JSON strings. This
+	 * helper unifies the two and rejects malformed JSON or non-object
+	 * payloads with an InvalidArgumentException — which handle_request()
+	 * surfaces as HTTP 400 INVALID_ARGUMENT, rather than letting a null
+	 * decode slip through as "no variables" or a scalar decode trigger a
+	 * downstream TypeError / HTTP 500.
+	 *
+	 * @param mixed  $value The param value from WP_REST_Request::get_param().
+	 * @param string $name  The param name, used in error messages.
+	 * @return array The decoded object, or an empty array when the param is omitted / empty / JSON null.
+	 * @throws \InvalidArgumentException When the payload is not a JSON object or not valid JSON.
+	 */
+	private function decode_json_param( $value, string $name ): array {
+		if ( null === $value ) {
+			return array();
+		}
+		if ( is_array( $value ) ) {
+			return $value;
+		}
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+		if ( ! is_string( $value ) ) {
+			throw new \InvalidArgumentException(
+				sprintf( 'Argument `%s` must be a JSON object or omitted.', $name )
+			);
+		}
+		if ( '' === $value ) {
+			return array();
+		}
+		$decoded = json_decode( $value, true );
+		if ( JSON_ERROR_NONE !== json_last_error() ) {
+			throw new \InvalidArgumentException(
+				sprintf( 'Argument `%s` is not valid JSON: %s', $name, json_last_error_msg() )
+			);
+		}
+		if ( null === $decoded ) {
+			// Literal "null" JSON payload — treat as omitted.
+			return array();
+		}
+		if ( ! is_array( $decoded ) ) {
+			throw new \InvalidArgumentException(
+				sprintf( 'Argument `%s` must be a JSON object (got %s).', $name, gettype( $decoded ) )
+			);
+		}
+		return $decoded;
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+	}
+
+	/**
+	 * Determine debug flags based on WP_DEBUG, user role, and query string.
+	 *
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	private function get_debug_flags( \WP_REST_Request $request ): int {
+		if ( ! $this->is_debug_mode( $request ) ) {
+			return DebugFlag::NONE;
+		}
+		return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
+	}
+
+	/**
+	 * Check whether GraphQL introspection is allowed for this request.
+	 *
+	 * Introspection is permitted if either condition holds:
+	 * - The request is in debug mode ({@see self::is_debug_mode()}).
+	 * - The caller has the `manage_woocommerce` capability.
+	 *
+	 * Gating on capability rather than mere authentication keeps the full
+	 * schema (including admin-only mutations) hidden from low-privilege
+	 * roles such as `customer`, which every storefront account is assigned
+	 * at checkout — while still allowing admin tooling (e.g. GraphiQL-like
+	 * explorers) to query it.
+	 *
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	private function is_introspection_allowed( \WP_REST_Request $request ): bool {
+		return $this->is_debug_mode( $request ) || current_user_can( 'manage_woocommerce' );
+	}
+
+	/**
+	 * Check if debug mode is active.
+	 *
+	 * Debug mode is active when either:
+	 * - WP_DEBUG is enabled AND the current user is an administrator (or in a local environment).
+	 * - The current user is an administrator (or in a local environment) AND `_debug=1` is in the query string.
+	 *
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	private function is_debug_mode( \WP_REST_Request $request ): bool {
+		if ( ! $this->is_local_environment() && ! current_user_can( 'manage_options' ) ) {
+			return false;
+		}
+
+		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+			return true;
+		}
+
+		return '1' === $request->get_param( '_debug' );
+	}
+
+	/**
+	 * Format a caught exception into a GraphQL error array.
+	 *
+	 * @param \Throwable       $e       The caught exception.
+	 * @param \WP_REST_Request $request The REST request.
+	 */
+	private function format_exception( \Throwable $e, \WP_REST_Request $request ): array {
+		if ( $e instanceof ApiException ) {
+			// Caller-supplied extensions come first so the canonical
+			// getErrorCode() can't be silently overridden by an extensions
+			// entry keyed 'code'. Mirrors the same invariant enforced by
+			// Utils::translate_exceptions() for the execute/authorize paths.
+			$error = array(
+				'message'    => $e->getMessage(),
+				'extensions' => array_merge(
+					$e->getExtensions(),
+					array( 'code' => $e->getErrorCode() )
+				),
+			);
+		} elseif ( $e instanceof \InvalidArgumentException ) {
+			$error = array(
+				'message'    => $e->getMessage(),
+				'extensions' => array( 'code' => 'INVALID_ARGUMENT' ),
+			);
+		} else {
+			$error = array(
+				'message'    => 'An unexpected error occurred.',
+				'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
+			);
+		}
+
+		if ( $this->is_debug_mode( $request ) ) {
+			$error['extensions']['debug'] = array(
+				'message' => $e->getMessage(),
+				'file'    => $e->getFile(),
+				'line'    => $e->getLine(),
+				'trace'   => $e->getTraceAsString(),
+			);
+
+			$chain = $this->extract_previous_chain( $e );
+			if ( ! empty( $chain ) ) {
+				$error['extensions']['debug']['previous'] = $chain;
+			}
+		}
+
+		return $error;
+	}
+
+	/**
+	 * Walk the `getPrevious()` chain of a Throwable and return one entry per
+	 * wrapped exception. Used in debug mode so that resolver-level wrappers
+	 * (which bury the real cause behind a generic "INTERNAL_ERROR") still
+	 * surface the underlying class/message/file/line/trace.
+	 *
+	 * @param \Throwable $e The outermost exception.
+	 * @return array<int, array{class: string, message: string, file: string, line: int, trace: string[]}>
+	 */
+	private function extract_previous_chain( \Throwable $e ): array {
+		$chain = array();
+		for ( $prev = $e->getPrevious(); null !== $prev; $prev = $prev->getPrevious() ) {
+			$chain[] = array(
+				'class'   => get_class( $prev ),
+				'message' => $prev->getMessage(),
+				'file'    => $prev->getFile(),
+				'line'    => $prev->getLine(),
+				'trace'   => explode( "\n", $prev->getTraceAsString() ),
+			);
+		}
+		return $chain;
+	}
+
+	/**
+	 * Mapping from machine-readable error codes to HTTP status codes.
+	 *
+	 * Any code not listed here defaults to 500, so unknown/unrecognised codes
+	 * from third-party resolvers stay on the safe side. The error formatter
+	 * installed in process_request() guarantees every error carries a code
+	 * from this table before get_error_status() inspects it.
+	 */
+	private const ERROR_STATUS_MAP = array(
+		'UNAUTHORIZED'              => 401,
+		'FORBIDDEN'                 => 403,
+		'NOT_FOUND'                 => 404,
+		'METHOD_NOT_ALLOWED'        => 405,
+		'INVALID_ARGUMENT'          => 400,
+		'BAD_USER_INPUT'            => 400,
+		'GRAPHQL_PARSE_ERROR'       => 400,
+		'GRAPHQL_PARSE_FAILED'      => 400,
+		'GRAPHQL_VALIDATION_FAILED' => 400,
+		'VALIDATION_ERROR'          => 422,
+		'INTERNAL_ERROR'            => 500,
+	);
+
+	/**
+	 * Determine the HTTP status code from an array of GraphQL errors.
+	 *
+	 * Applies the code-to-status lookup to each error and returns the worst
+	 * (highest) status seen. A single genuine 5xx among mixed errors surfaces
+	 * as 500, which is the more useful signal for monitoring and logs.
+	 *
+	 * @param array $errors The GraphQL errors array.
+	 */
+	private function get_error_status( array $errors ): int {
+		$status = 200;
+		foreach ( $errors as $error ) {
+			$code   = $error['extensions']['code'] ?? null;
+			$mapped = self::ERROR_STATUS_MAP[ $code ] ?? 500;
+			if ( $mapped > $status ) {
+				$status = $mapped;
+			}
+		}
+		return $status;
+	}
+
+	/**
+	 * Determine the HTTP status code for an error returned by QueryCache::resolve().
+	 *
+	 * PERSISTED_QUERY_NOT_FOUND uses 200 per the Apollo APQ convention (protocol signal, not error).
+	 *
+	 * @param array $response The error response array from resolve().
+	 */
+	private function get_resolve_error_status( array $response ): int {
+		$code = $response['errors'][0]['extensions']['code'] ?? '';
+
+		if ( 'PERSISTED_QUERY_NOT_FOUND' === $code ) {
+			return 200;
+		}
+
+		return 400;
+	}
+
+	/**
+	 * Compute the maximum nesting depth of the executing operation.
+	 *
+	 * Field selections add one level; inline fragments do not. Named-fragment
+	 * spreads are not expanded here — the depth returned is therefore a lower
+	 * bound when spreads are present. The webonyx QueryDepth validation rule
+	 * (which does expand spreads) remains the authoritative gate; this helper
+	 * only produces the metric surfaced in the debug extensions.
+	 *
+	 * @param DocumentNode $document       The parsed GraphQL document.
+	 * @param ?string      $operation_name The requested operation name, if any.
+	 */
+	private function compute_query_depth( DocumentNode $document, ?string $operation_name ): int {
+		$max = 0;
+		foreach ( $document->definitions as $definition ) {
+			if ( ! $definition instanceof OperationDefinitionNode ) {
+				continue;
+			}
+
+			if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
+				continue;
+			}
+
+			$max = max( $max, $this->walk_depth( $definition->selectionSet, 0 ) );
+		}
+
+		return $max;
+	}
+
+	/**
+	 * Recursively walk a selection set and return the maximum depth reached.
+	 *
+	 * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf.
+	 * @param int               $depth         The depth of the selection set's parent.
+	 */
+	private function walk_depth( ?SelectionSetNode $selection_set, int $depth ): int {
+		if ( null === $selection_set ) {
+			return $depth;
+		}
+
+		$max = $depth;
+		foreach ( $selection_set->selections as $selection ) {
+			if ( $selection instanceof FieldNode ) {
+				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth + 1 ) );
+			} elseif ( $selection instanceof InlineFragmentNode ) {
+				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth ) );
+			}
+		}
+
+		return $max;
+	}
+
+	/**
+	 * Check whether the parsed document contains a mutation operation.
+	 *
+	 * When an operation name is given, only that operation is checked;
+	 * otherwise any mutation definition in the document triggers a match.
+	 *
+	 * @param DocumentNode $document       The parsed GraphQL document.
+	 * @param ?string      $operation_name The requested operation name, if any.
+	 */
+	private function document_has_mutation( DocumentNode $document, ?string $operation_name ): bool {
+		foreach ( $document->definitions as $definition ) {
+			if ( ! $definition instanceof OperationDefinitionNode ) {
+				continue;
+			}
+
+			if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
+				continue;
+			}
+
+			if ( 'mutation' === $definition->operation ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Check if running in a local/development environment.
+	 *
+	 * Prefers {@see wp_get_environment_type()} when available. Otherwise
+	 * parses the site URL and performs a case-insensitive *exact* match
+	 * against the hostname — not a substring check, to avoid matching
+	 * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`.
+	 */
+	private function is_local_environment(): bool {
+		if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
+			return true;
+		}
+
+		$host = wp_parse_url( get_site_url(), PHP_URL_HOST );
+		if ( ! is_string( $host ) ) {
+			return false;
+		}
+
+		$host = strtolower( $host );
+		return 'localhost' === $host || '127.0.0.1' === $host;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
new file mode 100644
index 00000000000..16f0a43a3c9
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Entry point for the WooCommerce GraphQL API.
+ *
+ * This class is intentionally free of PHP 8.0+ syntax so that it can be
+ * loaded and called on PHP 7.4 without parse errors. The PHP-8.1-only
+ * classes (GraphQLController, QueryCache, etc.) are resolved lazily from
+ * the DI container only after is_enabled() confirms PHP 8.1+ is available.
+ */
+class Main {
+	/**
+	 * Feature flag slug registered in FeaturesController.
+	 */
+	private const FEATURE_SLUG = 'dual_code_graphql_api';
+
+	/**
+	 * Cached result of the feature-enabled check, null until first evaluated.
+	 *
+	 * @var ?bool
+	 */
+	private static ?bool $enabled = null;
+
+	/**
+	 * Check whether the Dual Code & GraphQL API feature is active.
+	 *
+	 * Requires PHP 8.1+ and the dual_code_graphql_api feature flag to be
+	 * enabled. The result is cached for the lifetime of the request.
+	 *
+	 * @return bool
+	 */
+	public static function is_enabled(): bool {
+		if ( null === self::$enabled ) {
+			self::$enabled = PHP_VERSION_ID >= 80100 && FeaturesUtil::feature_is_enabled( self::FEATURE_SLUG );
+		}
+		return self::$enabled;
+	}
+
+	/**
+	 * Register the GraphQL endpoint when the feature is active.
+	 *
+	 * When the feature is off this is a no-op. Classes in the public
+	 * Automattic\WooCommerce\Api\ namespace remain autoloadable — extensions
+	 * that want to know whether the feature is active should check
+	 * FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' ) rather
+	 * than class_exists() on the Api namespace.
+	 */
+	public static function register(): void {
+		if ( ! self::is_enabled() ) {
+			return;
+		}
+
+		add_action(
+			'rest_api_init',
+			static function () {
+				wc_get_container()->get( GraphQLController::class )->register();
+			}
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryCache.php b/plugins/woocommerce/src/Internal/Api/QueryCache.php
new file mode 100644
index 00000000000..9f1c5f2d0e1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryCache.php
@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use GraphQL\Language\AST\DocumentNode;
+use GraphQL\Language\Parser;
+use GraphQL\Utils\AST;
+
+/**
+ * Caches parsed GraphQL ASTs in the WP object cache and implements the
+ * Apollo Automatic Persisted Queries (APQ) protocol.
+ */
+class QueryCache {
+	/**
+	 * WP object-cache group.
+	 */
+	private const CACHE_GROUP = 'wc-graphql';
+
+	/**
+	 * Cache key prefix. Includes the library major version so that upgrading
+	 * webonyx/graphql-php naturally invalidates stale entries.
+	 *
+	 * Update this constant when bumping the major version in composer.json.
+	 */
+	private const CACHE_KEY_PREFIX = 'graphql_ast_v15_';
+
+	/**
+	 * Time-to-live (in seconds) for a cached parsed query.
+	 *
+	 * See {@see self::get_cache_ttl()} for the accessor.
+	 */
+	private const CACHE_TTL = DAY_IN_SECONDS;
+
+	/**
+	 * The time-to-live (in seconds) for a cached parsed query.
+	 */
+	public static function get_cache_ttl(): int {
+		return self::CACHE_TTL;
+	}
+
+	/**
+	 * Resolve a query string (and optional APQ extensions) into a DocumentNode.
+	 *
+	 * Returns a DocumentNode on success, or a GraphQL-shaped error array on failure.
+	 *
+	 * @param ?string $query      The GraphQL query string (may be null for APQ hash-only requests).
+	 * @param array   $extensions The request extensions (may contain persistedQuery).
+	 * @return DocumentNode|array
+	 */
+	public function resolve( ?string $query, array $extensions ) {
+		$apq = $extensions['persistedQuery'] ?? null;
+
+		if ( is_array( $apq ) && 1 === ( $apq['version'] ?? null ) && ! empty( $apq['sha256Hash'] ) ) {
+			return $this->resolve_apq( $query, $apq['sha256Hash'] );
+		}
+
+		// Standard query — no APQ.
+		if ( empty( $query ) ) {
+			return $this->error_response( 'No query provided.', 'BAD_REQUEST' );
+		}
+
+		$hash = hash( 'sha256', $query );
+		$doc  = $this->get_cached_document( $hash );
+		if ( false !== $doc ) {
+			return $doc;
+		}
+
+		return $this->parse_and_cache( $query, $hash );
+	}
+
+	/**
+	 * Handle an APQ request (hash present in extensions).
+	 *
+	 * @param ?string $query    The query string, if provided.
+	 * @param string  $apq_hash The sha256 hash from the persistedQuery extension.
+	 * @return DocumentNode|array
+	 */
+	private function resolve_apq( ?string $query, string $apq_hash ) {
+		if ( ! empty( $query ) ) {
+			// Registration: query + hash provided.
+			if ( hash( 'sha256', $query ) !== $apq_hash ) {
+				return $this->error_response(
+					'provided sha does not match query',
+					'PERSISTED_QUERY_HASH_MISMATCH'
+				);
+			}
+
+			$doc = $this->get_cached_document( $apq_hash );
+			if ( false !== $doc ) {
+				return $doc;
+			}
+
+			return $this->parse_and_cache( $query, $apq_hash );
+		}
+
+		// Hash-only lookup.
+		$doc = $this->get_cached_document( $apq_hash );
+		if ( false !== $doc ) {
+			return $doc;
+		}
+
+		return $this->error_response( 'PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND' );
+	}
+
+	/**
+	 * Retrieve a cached DocumentNode by hash.
+	 *
+	 * @param string $hash The SHA-256 hash.
+	 * @return DocumentNode|false
+	 */
+	private function get_cached_document( string $hash ) {
+		$cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
+		if ( false === $cached || ! is_array( $cached ) ) {
+			return false;
+		}
+
+		return AST::fromArray( $cached );
+	}
+
+	/**
+	 * Parse a query, cache the resulting AST, and return the DocumentNode.
+	 *
+	 * Returns an error array if the query has a syntax error.
+	 *
+	 * @param string $query The GraphQL query string.
+	 * @param string $hash  The SHA-256 hash to cache under.
+	 * @return DocumentNode|array
+	 */
+	private function parse_and_cache( string $query, string $hash ) {
+		try {
+			$document = Parser::parse( $query, array( 'noLocation' => true ) );
+		} catch ( \GraphQL\Error\SyntaxError $e ) {
+			return $this->error_response( 'GraphQL syntax error: ' . $e->getMessage(), 'GRAPHQL_PARSE_ERROR' );
+		}
+
+		wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+
+		return $document;
+	}
+
+	/**
+	 * Build a versioned cache key from a hash.
+	 *
+	 * @param string $hash The SHA-256 hash.
+	 * @return string
+	 */
+	private function build_cache_key( string $hash ): string {
+		return self::CACHE_KEY_PREFIX . $hash;
+	}
+
+	/**
+	 * Build a GraphQL-shaped error response array.
+	 *
+	 * @param string $message The error message.
+	 * @param string $code    The error code for extensions.
+	 * @return array
+	 */
+	private function error_response( string $message, string $code ): array {
+		return array(
+			'errors' => array(
+				array(
+					'message'    => $message,
+					'extensions' => array( 'code' => $code ),
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php b/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php
new file mode 100644
index 00000000000..353787c6b5a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php
@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use GraphQL\Language\AST\ArgumentNode;
+use GraphQL\Language\AST\FieldNode;
+use GraphQL\Language\AST\FragmentDefinitionNode;
+use GraphQL\Language\AST\FragmentSpreadNode;
+use GraphQL\Language\AST\InlineFragmentNode;
+use GraphQL\Language\AST\SelectionSetNode;
+use GraphQL\Type\Definition\ResolveInfo;
+
+/**
+ * Extracts a unified query info tree from a GraphQL ResolveInfo.
+ *
+ * The resulting array captures the full query structure: fields, arguments,
+ * sub-selections, inline fragments, and named fragment spreads.
+ *
+ * Structure rules:
+ * - Leaf field (no args, no sub-selection) => true
+ * - Field with sub-selections => nested associative array
+ * - Field arguments => '__args' reserved key
+ * - Inline fragments => '...TypeName' prefix key
+ * - Named fragment spreads => expanded inline (merged into the parent as
+ *   siblings of the other selections), matching how GraphQL evaluates them
+ * - Top-level query args included via '__args'
+ */
+class QueryInfoExtractor {
+	/**
+	 * Extract query info from a resolver's ResolveInfo and top-level args.
+	 *
+	 * @param ResolveInfo $info The GraphQL resolve info.
+	 * @param array       $args The top-level query arguments.
+	 * @return array The unified query info tree.
+	 */
+	public static function extract_from_info( ResolveInfo $info, array $args ): array {
+		$result = self::extract( $info->fieldNodes[0]->selectionSet ?? null, $info->variableValues, $info->fragments );
+		if ( ! empty( $args ) ) {
+			$result['__args'] = $args;
+		}
+		return $result;
+	}
+
+	/**
+	 * Recursively extract query info from a selection set.
+	 *
+	 * @param ?SelectionSetNode                     $selection_set   The selection set to process.
+	 * @param array                                 $variable_values Variable values for resolving arguments.
+	 * @param array<string, FragmentDefinitionNode> $fragments       Named fragment definitions from the document.
+	 * @return array The query info tree for the selection set.
+	 */
+	public static function extract( ?SelectionSetNode $selection_set, array $variable_values, array $fragments = array() ): array {
+		if ( null === $selection_set ) {
+			return array();
+		}
+
+		$result = array();
+
+		foreach ( $selection_set->selections as $selection ) {
+			if ( $selection instanceof FieldNode ) {
+				$field_name            = $selection->name->value;
+				$result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments );
+			} elseif ( $selection instanceof InlineFragmentNode ) {
+				$type_name      = $selection->typeCondition->name->value;
+				$key            = '...' . $type_name;
+				$result[ $key ] = self::extract( $selection->selectionSet, $variable_values, $fragments );
+			} elseif ( $selection instanceof FragmentSpreadNode ) {
+				// Expand named fragment spreads inline: their fields become
+				// siblings of the other selections, matching how GraphQL
+				// evaluates them. Consumers of _query_info (mappers that
+				// check array_key_exists for specific fields) see them the
+				// same as if the fragment had been written inline. Use a
+				// recursive merge so overlapping selections are unioned
+				// rather than replaced — `array_merge` would drop the
+				// existing sub-selection under the same field name.
+				$fragment = $fragments[ $selection->name->value ] ?? null;
+				if ( null === $fragment ) {
+					continue;
+				}
+				$spread = self::extract( $fragment->selectionSet, $variable_values, $fragments );
+				$result = self::merge_selections( $result, $spread );
+			}
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Build the entry for a single field node.
+	 *
+	 * @param FieldNode                             $field           The field node.
+	 * @param array                                 $variable_values Variable values for resolving arguments.
+	 * @param array<string, FragmentDefinitionNode> $fragments       Named fragment definitions from the document.
+	 * @return array|bool True for leaf fields, associative array otherwise.
+	 */
+	private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments ): array|bool {
+		$has_args          = ! empty( $field->arguments ) && count( $field->arguments ) > 0;
+		$has_sub_selection = null !== $field->selectionSet;
+
+		if ( ! $has_args && ! $has_sub_selection ) {
+			return true;
+		}
+
+		$entry = array();
+
+		if ( $has_args ) {
+			$args = array();
+			foreach ( $field->arguments as $arg ) {
+				$args[ $arg->name->value ] = self::resolve_argument_value( $arg, $variable_values );
+			}
+			$entry['__args'] = $args;
+		}
+
+		if ( $has_sub_selection ) {
+			$sub   = self::extract( $field->selectionSet, $variable_values, $fragments );
+			$entry = self::merge_selections( $entry, $sub );
+		}
+
+		return $entry;
+	}
+
+	/**
+	 * Recursively merge two selection trees produced by extract()/build_field_entry().
+	 *
+	 * Used wherever selections from different sources are combined under
+	 * the same key (notably: named fragment spreads expanded inline). Matches
+	 * GraphQL's selection-set merge semantics — overlapping fields have their
+	 * sub-selections unioned rather than one replacing the other, which a
+	 * shallow `array_merge` would do.
+	 *
+	 * Rules:
+	 * - Key only in one side: kept verbatim.
+	 * - Both sides arrays: recurse, unioning children.
+	 * - One array, one `true` (leaf): keep the array — it carries the
+	 *   sub-selection detail, and its presence already implies the field
+	 *   was requested.
+	 * - Both `true`: keep `true`.
+	 * - `__args` collisions (same field with different argument values):
+	 *   the second operand wins. Conflicting field args are a GraphQL
+	 *   validation error upstream of us, so this path is defensive.
+	 *
+	 * @param array $a First selection tree.
+	 * @param array $b Second selection tree, merged into $a.
+	 * @return array The merged tree.
+	 */
+	private static function merge_selections( array $a, array $b ): array {
+		foreach ( $b as $key => $value ) {
+			if ( ! array_key_exists( $key, $a ) ) {
+				$a[ $key ] = $value;
+				continue;
+			}
+			$existing = $a[ $key ];
+			if ( is_array( $existing ) && is_array( $value ) ) {
+				$a[ $key ] = self::merge_selections( $existing, $value );
+			} elseif ( is_array( $value ) ) {
+				// One side is `true`, the other is a sub-selection array — keep the array.
+				$a[ $key ] = $value;
+			}
+			// Both true, or existing-array + new-true: keep existing.
+		}
+		return $a;
+	}
+
+	/**
+	 * Resolve the value of a single argument node, handling variables.
+	 *
+	 * @param ArgumentNode $arg             The argument node.
+	 * @param array        $variable_values Variable values.
+	 * @return mixed The resolved argument value.
+	 */
+	private static function resolve_argument_value( ArgumentNode $arg, array $variable_values ): mixed {
+		$value_node = $arg->value;
+
+		if ( $value_node instanceof \GraphQL\Language\AST\VariableNode ) {
+			return $variable_values[ $value_node->name->value ] ?? null;
+		}
+
+		return \GraphQL\Utils\AST::valueFromASTUntyped( $value_node, $variable_values );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/README.md b/plugins/woocommerce/src/Internal/Api/README.md
new file mode 100644
index 00000000000..a84fa0e588a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/README.md
@@ -0,0 +1,7 @@
+# Important: Internal and experimental code
+
+**ALL** the code that's inside the `Automattic\WooCommerce\Internal` namespace and nested namespaces, or that's annotated with `@internal`, is for exclusive usage of WooCommerce core and must **NEVER** be used in released extensions or otherwise in production environments.
+
+Additionally, the code in this directory (`Automattic\WooCommerce\Internal\Api` namespace and nested namespaces) is part of [an experimental feature](https://github.com/woocommerce/woocommerce/pull/63772) that could get backwards-incompatible changes or even be completely removed in future versions of WooCommerce; moreover, it's infrastructure code that's really not intended for external usage.
+
+If you want to experiment with the feature (**NEVER** in production environments) from the code side, read [the provisional documentation](https://github.com/woocommerce/woocommerce/pull/63772) and look at the classes in the `src/Api` namespace.
diff --git a/plugins/woocommerce/src/Internal/Api/Utils.php b/plugins/woocommerce/src/Internal/Api/Utils.php
new file mode 100644
index 00000000000..682dd0ff95c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Utils.php
@@ -0,0 +1,189 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Shared utilities for the auto-generated GraphQL resolvers.
+ */
+class Utils {
+	/**
+	 * Assert that the current user has the given WordPress capability.
+	 *
+	 * Throws a GraphQL UNAUTHORIZED error if the check fails. Intended to
+	 * be called from generated resolver methods so the capability-check
+	 * boilerplate doesn't have to be repeated in every resolver.
+	 *
+	 * @param string $capability A WordPress capability slug.
+	 *
+	 * @throws \GraphQL\Error\Error When the current user lacks the capability.
+	 */
+	public static function check_current_user_can( string $capability ): void {
+		if ( ! current_user_can( $capability ) ) {
+			throw new \GraphQL\Error\Error(
+				'You do not have permission to perform this action.',
+				extensions: array( 'code' => 'UNAUTHORIZED' )
+			);
+		}
+	}
+
+	/**
+	 * Compute the complexity cost of a paginated connection field.
+	 *
+	 * Used as the `complexity` callable on every generated resolver field
+	 * that returns a `Connection`. Runs during query validation (before
+	 * resolver execution, so before `PaginationParams::validate_args()` has
+	 * a chance to reject bad input) — so out-of-range / wrong-type values
+	 * are clamped to MAX_PAGE_SIZE here. Using MAX_PAGE_SIZE as the
+	 * fallback means a malicious attempt to shrink cost via e.g. a
+	 * negative `first` value only inflates the computed complexity,
+	 * closing the cost-bypass angle.
+	 *
+	 * @param int   $child_complexity The complexity of a single child node.
+	 * @param array $args             The field arguments (expects `first` / `last`).
+	 *
+	 * @return int The total complexity for this connection field.
+	 */
+	public static function complexity_from_pagination( int $child_complexity, array $args ): int {
+		$requested = $args['first'] ?? $args['last'] ?? \Automattic\WooCommerce\Api\Pagination\PaginationParams::get_default_page_size();
+		$page_size = ( is_int( $requested ) && $requested >= 0 && $requested <= \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE )
+			? $requested
+			: \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE;
+		return $page_size * ( $child_complexity + 1 );
+	}
+
+	/**
+	 * Build a PaginationParams instance from the standard GraphQL pagination
+	 * arguments (first, last, after, before).
+	 *
+	 * @param array $args The GraphQL field arguments.
+	 *
+	 * @return \Automattic\WooCommerce\Api\Pagination\PaginationParams
+	 * @throws \GraphQL\Error\Error When a pagination value is out of range.
+	 */
+	public static function create_pagination_params( array $args ): \Automattic\WooCommerce\Api\Pagination\PaginationParams {
+		return self::create_input(
+			fn() => new \Automattic\WooCommerce\Api\Pagination\PaginationParams(
+				first: $args['first'] ?? null,
+				last: $args['last'] ?? null,
+				after: $args['after'] ?? null,
+				before: $args['before'] ?? null,
+			)
+		);
+	}
+
+	/**
+	 * Invoke a factory callable, catching InvalidArgumentException and
+	 * converting it to a client-visible GraphQL error.
+	 *
+	 * Used to wrap construction of unrolled input types (PaginationParams,
+	 * ProductFilterInput, etc.) whose constructors may validate their
+	 * arguments and throw.
+	 *
+	 * @param callable $factory A callable that returns the constructed object.
+	 *
+	 * @return mixed The return value of the factory.
+	 * @throws \GraphQL\Error\Error When the factory throws InvalidArgumentException.
+	 */
+	public static function create_input( callable $factory ): mixed {
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+		try {
+			return $factory();
+		} catch ( \InvalidArgumentException $e ) {
+			throw new \GraphQL\Error\Error(
+				$e->getMessage(),
+				extensions: array( 'code' => 'INVALID_ARGUMENT' )
+			);
+		}
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+	}
+
+	/**
+	 * Execute a command's execute() method, translating any thrown exceptions
+	 * into spec-compliant GraphQL errors.
+	 *
+	 * @param object $command      The command instance (must have an execute() method).
+	 * @param array  $execute_args Named arguments to pass to execute().
+	 *
+	 * @return mixed The return value of execute().
+	 * @throws \GraphQL\Error\Error On any exception from the command.
+	 */
+	public static function execute_command( object $command, array $execute_args ): mixed {
+		return self::translate_exceptions(
+			static fn() => $command->execute( ...$execute_args )
+		);
+	}
+
+	/**
+	 * Invoke a command's authorize() method, translating any thrown exceptions
+	 * into spec-compliant GraphQL errors.
+	 *
+	 * Mirror of execute_command() for the authorize step. Needed because an
+	 * authorize() call can throw an ApiException (e.g. AuthorizationException
+	 * when a target record does not exist); without this wrapper the
+	 * exception would propagate up to webonyx and lose its error code and
+	 * user-visible message on its way through the generic error formatter.
+	 *
+	 * @param object $command        The command instance (must have an authorize() method).
+	 * @param array  $authorize_args Named arguments to pass to authorize().
+	 *
+	 * @return bool The return value of authorize().
+	 * @throws \GraphQL\Error\Error On any exception from the authorize method.
+	 */
+	public static function authorize_command( object $command, array $authorize_args ): bool {
+		return self::translate_exceptions(
+			static fn() => $command->authorize( ...$authorize_args )
+		);
+	}
+
+	/**
+	 * Invoke a callable, translating any thrown exception into a
+	 * spec-compliant GraphQL error with a machine-readable code.
+	 *
+	 * - ApiException       → its own code + extensions, with the original message.
+	 * - InvalidArgumentException → INVALID_ARGUMENT, with the original message.
+	 * - Any other Throwable     → INTERNAL_ERROR, with a generic message; the
+	 *   original throwable is attached as `previous` for debug-mode surfacing.
+	 *
+	 * Public so that generated resolvers can wrap Code-API calls that happen
+	 * outside the execute()/authorize() pair (e.g. the Connection::slice()
+	 * call emitted for nested paginated connection fields, which can throw
+	 * InvalidArgumentException when pagination bounds are exceeded).
+	 *
+	 * @param callable $operation Callable to invoke.
+	 *
+	 * @return mixed The return value of the callable.
+	 * @throws \GraphQL\Error\Error On any exception from the callable.
+	 */
+	public static function translate_exceptions( callable $operation ): mixed {
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+		try {
+			return $operation();
+		} catch ( \Automattic\WooCommerce\Api\ApiException $e ) {
+			// Caller-supplied extensions come first so the canonical
+			// getErrorCode() can't be silently overridden by an extensions
+			// entry keyed 'code'. The invariant "the code on the wire
+			// equals ApiException::getErrorCode()" is worth enforcing.
+			throw new \GraphQL\Error\Error(
+				$e->getMessage(),
+				extensions: array_merge(
+					$e->getExtensions(),
+					array( 'code' => $e->getErrorCode() )
+				)
+			);
+		} catch ( \InvalidArgumentException $e ) {
+			throw new \GraphQL\Error\Error(
+				$e->getMessage(),
+				extensions: array( 'code' => 'INVALID_ARGUMENT' )
+			);
+		} catch ( \Throwable $e ) {
+			throw new \GraphQL\Error\Error(
+				'An unexpected error occurred.',
+				previous: $e,
+				extensions: array( 'code' => 'INTERNAL_ERROR' )
+			);
+		}//end try
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 3668b00e0b8..69bcb03f025 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -564,6 +564,18 @@ class FeaturesController {
 				'skip_compatibility_checks'    => true,
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 			),
+			'dual_code_graphql_api'              => array(
+				'name'                         => __( 'Dual Code & GraphQL API', 'woocommerce' ),
+				'description'                  => __(
+					'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
+					'woocommerce'
+				),
+				'enabled_by_default'           => false,
+				'is_experimental'              => true,
+				'disable_ui'                   => true,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			PushNotifications::FEATURE_NAME      => array(
 				'name'                         => __( 'Push Notifications', 'woocommerce' ),
 				'description'                  => __(