A plugin defines its own code API and gets a matching GraphQL endpoint through the WooCommerce Dual API plugin. The plugin writes its own classes under src/Api/, runs the 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.
Requires Plugins: woocommerce, woocommerce-dual-api.vendor/autoload.php.composer install run in it.The endpoint is dedicated: each plugin registers its own REST route.
Use the directory conventions 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
Map both trees in your composer.json:
{
"autoload": {
"psr-4": {
"MyPlugin\\Api\\": "src/Api/",
"MyPlugin\\Internal\\Api\\Autogenerated\\": "src/Internal/Api/Autogenerated/"
}
}
}
Writing the code-API classes is covered in Writing the code API.
A plugin’s bin/build-api.php is a thin wrapper around ApiBuilder::run_for_plugin(). It locates the WooCommerce Dual API plugin, requires its autoloader (which is what makes ApiBuilder available), and hands over the plugin root and namespace prefix:
<?php
declare(strict_types=1);
if ( PHP_SAPI !== 'cli' ) {
http_response_code( 403 );
exit;
}
$plugin_root = dirname( __DIR__ );
$dual_api_path = getenv( 'WC_DUAL_API_PATH' ) ?: dirname( $plugin_root ) . '/woocommerce-dual-api';
if ( ! is_file( $dual_api_path . '/vendor/autoload.php' ) ) {
fwrite( STDERR, "WooCommerce Dual API plugin not found at {$dual_api_path}. Set WC_DUAL_API_PATH to its directory.\n" );
exit( 1 );
}
require_once $dual_api_path . '/vendor/autoload.php';
use Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder;
ApiBuilder::run_for_plugin( $plugin_root, 'MyPlugin' );
run_for_plugin( $plugin_root, $namespace_prefix, $text_domain = null ) 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). The generated code translates its schema descriptions with your plugin’s text domain, which defaults to the name of the plugin directory; pass it explicitly if yours differs.
Run it with php bin/build-api.php (set WC_DUAL_API_PATH if the dual API plugin isn’t a sibling directory of your plugin), and commit the generated tree. See Building and staleness checks, and add the staleness check to your CI.
In your plugin bootstrap, register the route through the engine’s Main:
use Automattic\WooCommerce\Api\Infrastructure\Main as DualApiMain;
add_action( 'plugins_loaded', static function () {
if ( ! method_exists( DualApiMain::class, 'register_graphql_endpoint' ) ) {
return; // The WooCommerce Dual API plugin is not active.
}
DualApiMain::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. The method_exists() guard keeps your plugin loading cleanly when the dual API plugin is missing or inactive (or when WooCommerce is too old): the class simply doesn’t exist. Your endpoint goes through the engine’s request pipeline and inherits the site-wide 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 the engine’s default applies.
| 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 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 the public Api\Infrastructure\* surface, never the underlying GraphQL engine (Vendor\GraphQL\*). If the dual API plugin 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.