docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
A plugin can define its own code API and get a matching GraphQL endpoint using WooCommerce's infrastructure. The plugin writes its own classes under src/Api/, runs the same builder against them, commits the generated tree to its own repo, and registers a dedicated endpoint.
The full, runnable reference for everything here is the
woocommerce-simple-eventsplugin. The snippets below are condensed; see that repo for complete files.
dual_code_graphql_api feature flag enabled, on PHP 8.1+.vendor/autoload.php.The endpoint is dedicated: each plugin registers its own REST route. You cannot federate into core's /wc/graphql.
Use the same directory conventions as core, under your plugin's namespace:
my-plugin/
├── bin/build-api.php
├── src/Api/
│ ├── Queries/ Mutations/ Types/ InputTypes/
│ ├── Enums/ Interfaces/ Scalars/
│ ├── Attributes/ ← custom attributes (optional)
│ └── Infrastructure/ ← custom convention classes (optional)
└── src/Internal/Api/Autogenerated/ ← generated; committed
Writing the code-API classes is identical to core, see Extending the code API.
A plugin's bin/build-api.php is a thin wrapper around ApiBuilder::run_for_plugin():
<?php
require_once $wc_path . '/vendor/autoload.php'; // dev-mode WC autoloader
use Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder;
ApiBuilder::run_for_plugin( dirname( __DIR__ ), 'Automattic\\MyPlugin' );
run_for_plugin( $plugin_root, $namespace_prefix ) derives the conventional dirs/namespaces: source at $plugin_root/src/Api (namespace <prefix>\Api), output at $plugin_root/src/Internal/Api/Autogenerated (namespace <prefix>\Internal\Api\Autogenerated). Run it with WC_PATH=<path-to-woocommerce> php bin/build-api.php (or a package.json script), and commit the generated tree. See Building and staleness checks, and add an equivalent staleness check to your CI.
ApiBuilderlives under WooCommerce'sbin/api-builder/and is registered viaautoload-dev, so it is only resolvable from a dev-mode WooCommerce install. It is not shipped in release builds.
In your plugin bootstrap, register the route through core's Main:
use Automattic\WooCommerce\Api\Infrastructure\Main as WooCommerceApiMain;
add_action( 'plugins_loaded', static function () {
if ( ! method_exists( WooCommerceApiMain::class, 'register_graphql_endpoint' ) ) {
return; // WooCommerce too old, or feature/PHP unavailable
}
WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
} );
The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a no-op when the feature flag is off or PHP is < 8.1. Your endpoint goes through the same request pipeline as core's and inherits the core GraphQL settings.
ApiBuilder detects a small set of convention classes at <your-namespace>\Api\Infrastructure\*. Ship one only when you need to diverge from the default; otherwise core's default applies. The same overriding mechanism is what core itself uses.
| Class | Default | Ship your own to… |
|---|---|---|
ClassResolver | wc_get_container()->get() | Instantiate commands through your own DI container. |
PrincipalResolver | wraps wp_get_current_user() | Authenticate against something other than WP users. Its return type declares your principal type. |
Principal | wraps WP_User | Carry your own identity/permission data. Add is_authenticated(), and can_introspect()/can_query_metadata()/can_use_debug_mode() to opt into those surfaces. |
HttpStatusResolver | none (per-error-code map) | Override response HTTP status, e.g. always return 200. |
See Infrastructure classes for exact signatures.
Custom authentication example (HTTP basic against a fixed credential, role in a header):
namespace Automattic\MyPlugin\Api\Infrastructure;
use Automattic\WooCommerce\Api\InvalidTokenException;
final class PrincipalResolver {
public function resolve_principal( \WP_REST_Request $request ): EventsPrincipal {
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
if ( null === $user || null === $pass ) {
return EventsPrincipal::anonymous();
}
if ( 'password' !== $pass || ! isset( EventsPrincipal::SCOPES_BY_ROLE[ $user ] ) ) {
throw new InvalidTokenException();
}
return new EventsPrincipal( $user, $user, EventsPrincipal::SCOPES_BY_ROLE[ $user ] );
}
}
Api/Attributes/ becomes an authorization attribute by declaring authorize( <PrincipalType> $principal ): bool, a metadata attribute by extending Metadata, and so on. See Attributes reference. Authorization attributes can gate operations, types, fields, and arguments.ApiException (or a subclass) to pin your own (error code, HTTP status). See Exceptions reference.Your committed generated tree references only WooCommerce's public Api\Infrastructure\* surface, never the underlying GraphQL engine (Vendor\GraphQL\*). If WooCommerce ever swaps engines, that surface absorbs the change and your already-committed generated code keeps working. The flip side: never write code (generated or hand-written) that imports from Vendor\GraphQL\* or from Internal\Api\*. See Architecture and Extending the infrastructure.