A thin PHP 8.1+ REST routing and resource library for WordPress.
Built for headless and integration-heavy projects where you want a stable, versioned API contract on top of WP.
Supports PHP 8.1+ and is tested against WordPress 6.9 stubs. WooCommerce support is optional and tested against WooCommerce 10.6 stubs.
- Fluent REST router on top of
register_rest_route() - Middleware pipeline (
global -> group -> route) - Explicit
OPTIONSroute support for preflight endpoints - Resource DSL for:
- CPT-backed endpoints
- custom table-backed endpoints
- Strict query contract (unknown params ->
400) - Payload schema + field-level write policy for Resource writes
- Unified error payload with
requestId - Built-in auth bridge middlewares:
- JWT/Bearer
- RS256/ES256 JWKS verifier
- HMAC request signatures
- cookie + nonce
- application password
- Write safety middlewares:
- idempotency key
- single-use token consume
- optimistic lock (
If-Match/ version)
- Network hardening:
- trusted-proxy IP resolution
- CIDR allowlist middleware
- Read safety/caching helpers:
- ETag /
If-None-Match - identity-aware cache and rate-limit keys
- ETag /
- Observability baseline:
- audit event schema
- metrics middleware
- Prometheus-friendly sink
From public GitHub via Composer (type: vcs):
{
"require": {
"better-route/better-route": "^0.6.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Lonsdale201/better-route"
}
]
}For local development only, you can still use path repository + symlink.
use BetterRoute\Router\Router;
add_action('rest_api_init', function () {
Router::make('better-route', 'v1')
->get('/ping', fn () => ['pong' => true])
->meta(['operationId' => 'ping', 'tags' => ['System']]);
});GET routes are public by default. Write routes (POST, PUT, PATCH, DELETE)
deny by default unless you explicitly call ->permission(...),
->protectedByMiddleware(), or ->publicRoute().
WordPress validates and sanitizes registered REST args before
permission_callback runs. Keep ->args() callbacks cheap and side-effect free.
Use handler-level validation or Resource writeSchema() for expensive payload
checks that must happen after authorization.
use BetterRoute\Router\Router;
use BetterRoute\Middleware\Jwt\JwtAuthMiddleware;
use BetterRoute\Middleware\Jwt\Hs256JwtVerifier;
use BetterRoute\Middleware\Auth\WpClaimsUserMapper;
add_action('rest_api_init', function () {
$jwt = new Hs256JwtVerifier(
secret: $_ENV['JWT_SECRET'],
expectedIssuer: 'https://auth.example.com',
expectedAudience: 'better-route'
);
$router = Router::make('better-route', 'v1')
->middlewareFactory(function (string $class) use ($jwt) {
if ($class === JwtAuthMiddleware::class) {
return new JwtAuthMiddleware($jwt, ['content:*'], new WpClaimsUserMapper());
}
return null;
});
$router->group('/secure', function (Router $r): void {
$r->middleware([JwtAuthMiddleware::class]);
$r->get('/me', fn () => ['ok' => true])
->meta(['operationId' => 'secureMe', 'tags' => ['Auth']]);
$r->post('/articles', fn () => ['created' => true])
->protectedByMiddleware('bearerAuth')
->meta(['operationId' => 'secureCreateArticle', 'tags' => ['Auth']]);
});
$router->register();
});use BetterRoute\Resource\Resource;
use BetterRoute\Resource\ResourcePolicy;
add_action('rest_api_init', function () {
Resource::make('articles')
->restNamespace('better-route/v1')
->sourceCpt('post')
->allow(['list', 'get', 'create', 'update', 'delete'])
->fields(['id', 'title', 'slug', 'excerpt', 'content', 'date', 'status', 'author'])
->filters(['status', 'author', 'after', 'before'])
->filterSchema([
'status' => ['type' => 'enum', 'values' => ['publish', 'draft', 'private']],
'author' => 'int',
'after' => 'date',
'before' => 'date',
])
->sort(['date', 'id'])
->policy([
'permissions' => [
'list' => true,
'get' => true,
'create' => 'edit_posts',
'update' => 'edit_posts',
'delete' => 'delete_posts',
],
])
->fieldPolicy([
'status' => ['write' => 'publish_posts'],
'author' => ['write' => 'edit_others_posts'],
])
->writeSchema([
'title' => ['type' => 'string', 'required' => true, 'minLength' => 3, 'sanitize' => 'text'],
'status' => ['type' => 'enum', 'values' => ['draft', 'publish']],
'author' => ['type' => 'int', 'min' => 1],
])
->deleteMode('trash')
->maxPerPage(100)
->maxOffset(5000)
->register();
});use BetterRoute\Resource\Resource;
add_action('rest_api_init', function () {
Resource::make('raw-articles')
->restNamespace('better-route/v1')
->sourceTable('ai_raw_articles', 'id')
->allow(['list', 'get', 'create', 'update', 'delete'])
->fields(['id', 'source', 'title', 'lang', 'published', 'version', 'created_at', 'updated_at'])
->filters(['source', 'lang', 'published'])
->filterSchema([
'source' => 'string',
'lang' => 'string',
'published' => 'bool',
])
->policy(ResourcePolicy::adminOnly())
->writeSchema([
'title' => ['type' => 'string', 'required' => true, 'sanitize' => 'text'],
'published' => ['type' => 'bool'],
'lang' => ['type' => 'enum', 'values' => ['hu', 'en']],
])
->sort(['created_at', 'id'])
->maxPerPage(100)
->maxOffset(5000)
->register();
});BetterRoute\Middleware\Jwt\JwtAuthMiddlewareBetterRoute\Middleware\Jwt\Rs256JwksJwtVerifierBetterRoute\Middleware\Jwt\HttpJwksProviderBetterRoute\Middleware\Jwt\StaticJwksProviderBetterRoute\Middleware\Auth\BearerTokenAuthMiddlewareBetterRoute\Middleware\Auth\HmacSignatureMiddlewareBetterRoute\Middleware\Auth\ArrayHmacSecretProviderBetterRoute\Middleware\Auth\CookieNonceAuthMiddlewareBetterRoute\Middleware\Auth\ApplicationPasswordAuthMiddlewareBetterRoute\Middleware\Auth\WpClaimsUserMapperBetterRoute\Middleware\Auth\OwnershipGuardMiddleware
BetterRoute\Middleware\Write\IdempotencyMiddlewareBetterRoute\Middleware\Write\WpdbIdempotencyStoreBetterRoute\Middleware\Write\AtomicIdempotencyMiddlewareBetterRoute\Middleware\Write\ArrayAtomicIdempotencyStoreBetterRoute\Middleware\Write\WpdbAtomicIdempotencyStoreBetterRoute\Middleware\Write\SingleUseTokenMiddlewareBetterRoute\Middleware\Write\ArraySingleUseTokenStoreBetterRoute\Middleware\Write\WpdbSingleUseTokenStoreBetterRoute\Middleware\Write\WpCacheSingleUseTokenStoreBetterRoute\Middleware\Write\OptimisticLockMiddlewareBetterRoute\Http\ConflictException(409)BetterRoute\Http\PreconditionFailedException(412)
BetterRoute\Middleware\Cors\CorsMiddlewareBetterRoute\Middleware\Cors\CorsPolicy
BetterRoute\Middleware\Cache\CachingMiddlewareBetterRoute\Middleware\Cache\ETagMiddleware
BetterRoute\Middleware\RateLimit\RateLimitMiddlewareBetterRoute\Middleware\RateLimit\TransientRateLimiterBetterRoute\Middleware\RateLimit\WpObjectCacheRateLimiterBetterRoute\Http\ClientIpResolverBetterRoute\Middleware\Network\TrustedProxyClientIpResolverBetterRoute\Middleware\Network\IpAllowlistMiddleware
BetterRoute\Support\CryptoBetterRoute\Support\CryptoEncodingBetterRoute\Http\OAuthErrorNormalizer
BetterRoute\Middleware\Audit\AuditMiddlewareBetterRoute\Middleware\Audit\AuditEnricherMiddlewareBetterRoute\Middleware\Observability\MetricsMiddlewareBetterRoute\Observability\AuditEventFactoryBetterRoute\Observability\PrometheusMetricSink
The library already stores route/resource metadata for OpenAPI.
You can export a document from contracts and optionally expose it as a REST endpoint.
use BetterRoute\OpenApi\OpenApiExporter;
use BetterRoute\BetterRoute;
$contracts = array_merge(
$router->contracts(true),
$articlesResource->contracts(true)
);
$openApi = (new OpenApiExporter())->export($contracts, [
'title' => 'better-route API',
'version' => 'v0.5.0',
'serverUrl' => '/wp-json',
'strictSchemas' => true,
'components' => array_replace_recursive(
BetterRoute::wooOpenApiComponents(),
[
'schemas' => [
// Your non-Woo schemas can be merged here
],
]
),
// Reusable security definitions, merged into components.securitySchemes
'securitySchemes' => [
'bearerAuth' => [
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'JWT',
],
'cookieNonce' => [
'type' => 'apiKey',
'in' => 'header',
'name' => 'X-WP-Nonce',
],
],
// Document-level default security; per-route meta['security'] overrides.
'globalSecurity' => [
['bearerAuth' => []],
],
]);Per-route overrides live in route meta:
$router->get('/public/ping', fn () => ['pong' => true])
->meta([
'operationId' => 'publicPing',
'security' => [], // explicit no-auth (overrides globalSecurity)
]);
$router->post('/admin/reset', fn () => ['ok' => true])
->protectedByMiddleware('bearerAuth')
->meta([
'operationId' => 'adminReset',
'security' => [['bearerAuth' => ['admin:write']]],
]);use BetterRoute\OpenApi\OpenApiRouteRegistrar;
OpenApiRouteRegistrar::register(
restNamespace: 'better-route/v1',
contractsProvider: static fn (): array => OpenApiRouteRegistrar::contractsFromSources([
$router,
$articlesResource,
]),
options: [
'title' => 'better-route API',
'version' => 'v0.5.0',
'serverUrl' => '/wp-json',
// Defaults to manage_options when omitted.
'permissionCallback' => static fn (): bool => current_user_can('manage_options'),
]
);Result endpoint: GET /wp-json/better-route/v1/openapi.json
The library can register WooCommerce routes as an optional integration layer.
This is extra support and does not affect non-Woo projects.
use BetterRoute\BetterRoute;
add_action('rest_api_init', function () {
$woo = BetterRoute::wooRouteRegistrar()->register('better-route/v1', [
'requireHpos' => true, // HPOS-only guard
'basePath' => 'woo',
'deleteMode' => 'trash', // force|trash for orders/products/coupons
'idempotency' => [
'enabled' => true,
'requireKey' => true,
'ttlSeconds' => 600,
// optional:
// 'resources' => ['orders' => true, 'products' => true],
// 'store' => new CustomIdempotencyStore(),
],
'permissions' => [
// defaults are manage_woocommerce for all actions
'orders.list' => 'manage_woocommerce',
'orders.get' => 'manage_woocommerce',
'orders.create' => 'manage_woocommerce',
'orders.update' => 'manage_woocommerce',
'orders.delete' => 'manage_woocommerce',
'products.list' => 'manage_woocommerce',
'products.get' => 'manage_woocommerce',
'products.create' => 'manage_woocommerce',
'products.update' => 'manage_woocommerce',
'products.delete' => 'manage_woocommerce',
'customers.list' => 'manage_woocommerce',
'customers.get' => 'manage_woocommerce',
'customers.create' => 'manage_woocommerce',
'customers.update' => 'manage_woocommerce',
'customers.delete' => 'manage_woocommerce',
'coupons.list' => 'manage_woocommerce',
'coupons.get' => 'manage_woocommerce',
'coupons.create' => 'manage_woocommerce',
'coupons.update' => 'manage_woocommerce',
'coupons.delete' => 'manage_woocommerce',
],
// Optional — restrict which CRUD actions each resource exposes.
// Omit a key to get the full `['list', 'get', 'create', 'update', 'delete']` set.
'actions' => [
'customers' => ['list', 'get'], // read-only customers, full CRUD elsewhere
],
]);
// Optional OpenAPI export from registered Woo routes:
// $contracts = $woo->contracts(true);
// $components = BetterRoute::wooOpenApiComponents();
});Registered endpoints under /wp-json/<vendor>/<version>/woo:
- orders:
list,get,create,update(PUT/PATCH),delete - products:
list,get,create,update(PUT/PATCH),delete - customers:
list,get,create,update(PUT/PATCH),delete - coupons:
list,get,create,update(PUT/PATCH),delete
When idempotency is enabled, write routes document and accept Idempotency-Key header, and may return:
409foridempotency_conflict400foridempotency_key_required(ifrequireKey=true)
{
"error": {
"code": "validation_failed",
"message": "Invalid request.",
"requestId": "req_...",
"details": {
"fieldErrors": {
"title": ["required"]
}
}
}
}composer test
composer analyse
composer cs-checkActive development.
Security and auth primitives for integration-heavy REST APIs. Full details and usage examples live in the docs: Release notes — v0.6.0.
- Added
Rs256JwksJwtVerifierforRS256andES256JWTs backed by JWKS, with strict JOSEkidmatching, explicit algorithm allowlisting, issuer/audience/time claim validation, and rejection ofnone/HS*algorithms. - Added
JwksProviderInterface,HttpJwksProvider, andStaticJwksProvider. HTTP fetches requirehttps, usesslverify => true, cache through transients, strip private JWK fields defensively, and supportbetter_route/jwks_refreshcache invalidation. - Added
BetterRoute\Support\CryptoandCryptoEncodingfor CSPRNG token generation, hex/base64/base64url encoding, strict base64url decoding, and constant-time string comparison. - Added
TrustedProxyClientIpResolver,ClientIpResolverInterface,CidrMatcher, andIpAllowlistMiddlewarefor IPv4/IPv6 CIDR matching and trusted-proxy aware client IP resolution. - Added
HmacSignatureMiddleware,HmacSecretProviderInterface, andArrayHmacSecretProviderfor multi-key request signature verification with timestamp replay-window enforcement. - Added
SingleUseTokenMiddleware,SingleUseTokenStoreInterface,ArraySingleUseTokenStore,WpdbSingleUseTokenStore, andWpCacheSingleUseTokenStorefor atomic one-time token consumption. Token values are hashed before storage helpers are used. - Added
OAuthErrorNormalizerand route-level->meta(['error_format' => 'oauth_rfc6749'])support for OAuth-style error responses. Hs256JwtVerifiernow uses the sharedCryptohelper for constant-time signature comparison and base64url decoding.Http\ClientIpResolverdelegates internally to the hardened trusted-proxy resolver while preserving its existing constructor andresolve(?array $server = null)API.RateLimitMiddlewareaccepts either the legacyHttp\ClientIpResolveror the newMiddleware\Network\ClientIpResolverInterface.Routernow passes normalized route metadata intoRequestContextattributes asrouteMeta, enabling route-scoped normalizers without changing handler signatures.- Updated
Support\Version::VERSIONto0.6.0-dev.
Public-client and account API hardening. Full details and usage examples live in the docs: Release notes — v0.5.0.
- Added
AtomicIdempotencyMiddlewareand atomic idempotency store contracts for side-effectful write routes. - Added
WpdbAtomicIdempotencyStorewithINSERT IGNOREreservation semantics and a dedicated installable table schema. - Added
ArrayAtomicIdempotencyStorefor tests and non-production local use. - Added
CorsMiddlewareandCorsPolicyfor explicit origin allowlists, credential support, exposed headers, and preflightOPTIONSresponses. - Added
Router::options()and public-by-defaultOPTIONSroute permissions for explicit preflight endpoints. - Added
OwnershipGuardMiddlewarefor route-level owner checks based on the authenticatedauthcontext or current WP user. - Added
OwnedResourcePolicy::currentUserOwns()for Resource DSL ownership policies. - Added
AuditEnricherMiddlewareand taughtAuditMiddlewareto merge safeauditcontext attributes into emitted events. - Updated
RateLimitMiddlewareso array handler responses are wrapped intoResponseand still receive rate-limit headers. - Updated
Support\Version::VERSIONto0.5.0-dev.
Security and route intent:
- Made raw Router write routes deny-by-default unless
permission(),protectedByMiddleware(), orpublicRoute()is explicit. - Added explicit route intent helpers:
protectedByMiddleware()for middleware-authenticated routes andpublicRoute()for intentionally public routes.
Security and hardening:
- Fixed route
idhandling so resource and Woo endpoints prefer URL route parameters over merged request parameters. - Hardened error responses so unexpected server errors no longer expose internal exception messages or classes.
- Hardened JWT handling with required
expby default, optional issuer/audience checks, max lifetime, and max token size. - Removed default numeric
subto WP user ID mapping fromWpClaimsUserMapper. - Made custom table resource reads deny-by-default unless an explicit policy is configured.
- Made cache, idempotency, and rate-limit default keys identity-aware.
- Restricted Woo customer endpoints to customer users and added user capability checks for create/update/delete operations.
- Protected Woo meta keys (
_...) are no longer writable or returned by default. - Hardened CPT writes with WordPress capability checks around publish/status/author/delete operations.
- Hardened
WpdbAdapterby rejecting cross-database table names and structured write payloads. - OpenAPI document route now defaults to
manage_optionsinstead of public access. - Sanitized accepted
X-Request-IDvalues.
New features:
- Added Resource
writeSchema()/payloadSchema()for write validation, coercion, sanitization, required fields, ranges, lengths, regex, enum, email, and URL checks. - Added Resource
fieldPolicy()for field-level write authorization. - Added
ResourcePolicypresets:adminOnly(),publicReadPrivateWrite(),capabilities(), andcallbacks(). - Added
deleteMode('trash'|'force')for CPT resources. - Added Woo
deleteModeoption for orders, products, and coupons. - Added strict OpenAPI schema mode via
strictSchemas => true. - Added
ETagMiddlewarewithIf-None-Match/304 Not Modifiedsupport. - Added
ClientIpResolverwith trusted proxy support. - Added
WpObjectCacheRateLimiter. - Added
WpdbIdempotencyStore.
Developer experience:
- Composer scripts now run tools through
php vendor/bin/..., avoiding executable-bit issues on some deployments. - Expanded regression coverage for security defaults, resource validation, OpenAPI strict mode, ETag handling, and WP-backed stores.