PHPackages                             better-route/better-route - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. better-route/better-route

ActiveLibrary

better-route/better-route
=========================

Thin PHP 8.1+ REST routing and resource library for WordPress.

v1.1.0(1mo ago)16MITPHPPHP ^8.1

Since Feb 21Pushed 1mo agoCompare

[ Source](https://github.com/Lonsdale201/better-route)[ Packagist](https://packagist.org/packages/better-route/better-route)[ RSS](/packages/better-route-better-route/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (8)Dependencies (5)Versions (10)Used By (0)

better-route
============

[](#better-route)

[![OPEN BETTER-ROUTE DOCS](https://camo.githubusercontent.com/d28ea1a1126aad744824c4e28d08aad914b6e91040fac2ff0bfdb8372331b345/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f50454e2d4245545445522d2d524f555445253230444f43532d3237414536303f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d324333453530)](https://lonsdale201.github.io/better-docs/docs/better-route/getting-started/installation)

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.9 stubs. (The WordPress stub package is currently capped at 6.9 by the WooCommerce stubs' dependency constraint; the library targets current WordPress 7.0 / WooCommerce 10.9 and is verified against a live WP 7.0 / WC 10.9 HPOS install.)

What It Gives You
-----------------

[](#what-it-gives-you)

- Fluent REST router on top of `register_rest_route()`
- Middleware pipeline (`global -> group -> route`)
- Explicit `OPTIONS` route support and a WordPress CORS bridge for preflight endpoints
- Resource DSL for:
    - CPT-backed endpoints
    - custom table-backed endpoints
- Strict query contract (unknown params -&gt; `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
- Observability baseline:
    - audit event schema
    - metrics middleware
    - Prometheus-friendly sink

Install
-------

[](#install)

Published on [Packagist](https://packagist.org/packages/better-route/better-route) — install with Composer:

```
composer require better-route/better-route:^1.1
```

Only add a VCS repository (pointing at this GitHub repo) if you need to track an unreleased branch or a fork. For local development you can still use a path repository + symlink.

Quick Start
-----------

[](#quick-start)

```
use BetterRoute\Router\Router;

add_action('rest_api_init', function () {
    $router = Router::make('better-route', 'v1');
    $router
        ->get('/ping', fn () => ['pong' => true])
        ->publicRoute()
        ->meta(['operationId' => 'ping', 'tags' => ['System']]);

    $router->register();
});
```

Every raw Router route, including `GET` and `OPTIONS`, denies by default. 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.

Router + Middleware Example
---------------------------

[](#router--middleware-example)

```
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])
            ->protectedByMiddleware('bearerAuth')
            ->meta(['operationId' => 'secureMe', 'tags' => ['Auth']]);

        $r->post('/articles', fn () => ['created' => true])
            ->protectedByMiddleware('bearerAuth')
            ->meta(['operationId' => 'secureCreateArticle', 'tags' => ['Auth']]);
    });

    $router->register();
});
```

Resource DSL Examples
---------------------

[](#resource-dsl-examples)

### CPT Resource

[](#cpt-resource)

```
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();
});
```

An arbitrary `cptVisibilityPolicy()` callback is evaluated item by item. To keep `total` and pagination truthful, the Resource layer scans all matching repository pages before slicing the visible result. For large datasets, express visibility as a query-level CPT filter/repository condition instead of a per-item callback.

### Custom Table Resource

[](#custom-table-resource)

```
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();
});
```

Built-in Middlewares
--------------------

[](#built-in-middlewares)

### Auth bridge

[](#auth-bridge)

- `BetterRoute\Middleware\Jwt\JwtAuthMiddleware`
- `BetterRoute\Middleware\Jwt\Rs256JwksJwtVerifier`
- `BetterRoute\Middleware\Jwt\HttpJwksProvider`
- `BetterRoute\Middleware\Jwt\StaticJwksProvider`
- `BetterRoute\Middleware\Auth\BearerTokenAuthMiddleware`
- `BetterRoute\Middleware\Auth\HmacSignatureMiddleware`
- `BetterRoute\Middleware\Auth\ArrayHmacSecretProvider`
- `BetterRoute\Middleware\Auth\CookieNonceAuthMiddleware`
- `BetterRoute\Middleware\Auth\ApplicationPasswordAuthMiddleware`
- `BetterRoute\Middleware\Auth\WpClaimsUserMapper`
- `BetterRoute\Middleware\Auth\OwnershipGuardMiddleware`

### Write safety

[](#write-safety)

- `BetterRoute\Middleware\Write\IdempotencyMiddleware`
- `BetterRoute\Middleware\Write\WpdbIdempotencyStore`
- `BetterRoute\Middleware\Write\AtomicIdempotencyMiddleware`
- `BetterRoute\Middleware\Write\ArrayAtomicIdempotencyStore`
- `BetterRoute\Middleware\Write\WpdbAtomicIdempotencyStore`
- `BetterRoute\Middleware\Write\SingleUseTokenMiddleware`
- `BetterRoute\Middleware\Write\ArraySingleUseTokenStore`
- `BetterRoute\Middleware\Write\WpdbSingleUseTokenStore`
- `BetterRoute\Middleware\Write\WpCacheSingleUseTokenStore`
- `BetterRoute\Middleware\Write\OptimisticLockMiddleware`
- `BetterRoute\Middleware\Write\WpdbOptimisticLockCriticalSection`
- `BetterRoute\Http\ConflictException` (`409`)
- `BetterRoute\Http\PreconditionFailedException` (`412`)
- `BetterRoute\Http\PreconditionRequiredException` (`428`)

`AtomicIdempotencyMiddleware` keeps a failed reservation until its TTL expires by default, preventing an uncertain side effect from being executed again. The wpdb store uses a per-reservation lease token, creates/migrates its table through `installSchema()`, and never deserializes arbitrary classes. `ArrayAtomicIdempotencyStore`is for tests only.

The default optimistic-lock critical section serializes Better Route writes with a MySQL advisory lock. Writers outside this protocol can still race; use the same lock or enforce the version in a storage-level conditional `UPDATE` when external writers modify the same record.

### Public-client API hardening

[](#public-client-api-hardening)

- `BetterRoute\Middleware\Cors\CorsMiddleware`
- `BetterRoute\Middleware\Cors\CorsPolicy`

Attach CORS middleware before `Router::register()`. The WordPress bridge handles preflight before the route callback and replaces WordPress core CORS headers for matched routes, so the configured allowlist remains authoritative. Origins, methods, and header names are validated against response-header injection.

### Cache / conditional reads

[](#cache--conditional-reads)

- `BetterRoute\Middleware\Cache\CachingMiddleware`
- `BetterRoute\Middleware\Cache\ETagMiddleware`

### Rate limiting

[](#rate-limiting)

- `BetterRoute\Middleware\RateLimit\RateLimitMiddleware`
- `BetterRoute\Middleware\RateLimit\TransientRateLimiter`
- `BetterRoute\Middleware\RateLimit\WpObjectCacheRateLimiter`
- `BetterRoute\Http\ClientIpResolver`
- `BetterRoute\Middleware\Network\TrustedProxyClientIpResolver`
- `BetterRoute\Middleware\Network\IpAllowlistMiddleware`

`WpObjectCacheRateLimiter` requires a persistent external object cache with atomic `wp_cache_incr()`. `TransientRateLimiter` uses a MySQL advisory lock in its default WordPress configuration. Neither silently falls back to a racy read/modify/write.

### Support

[](#support)

- `BetterRoute\Support\Crypto`
- `BetterRoute\Support\CryptoEncoding`
- `BetterRoute\Http\OAuthErrorNormalizer`

### Observability

[](#observability)

- `BetterRoute\Middleware\Audit\AuditMiddleware`
- `BetterRoute\Middleware\Audit\AuditEnricherMiddleware`
- `BetterRoute\Middleware\Observability\MetricsMiddleware`
- `BetterRoute\Observability\AuditEventFactory`
- `BetterRoute\Observability\PrometheusMetricSink`

`PrometheusMetricSink` and `InMemoryMetricSink` are process/request-local collectors; export or replace them with a persistent backend for cross-request totals. Audit and metric sink failures are best-effort and never change the API response.

OpenAPI (MVP)
-------------

[](#openapi-mvp)

The library already stores route/resource metadata for OpenAPI.
You can export a document from contracts and optionally expose it as a REST endpoint.

### Manual export from contracts

[](#manual-export-from-contracts)

```
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' => 'v1.1.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])
    ->publicRoute()
    ->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']]],
    ]);
```

### Register `openapi.json` endpoint

[](#register-openapijson-endpoint)

```
use BetterRoute\OpenApi\OpenApiRouteRegistrar;

OpenApiRouteRegistrar::register(
    restNamespace: 'better-route/v1',
    contractsProvider: static fn (): array => OpenApiRouteRegistrar::contractsFromSources([
        $router,
        $articlesResource,
    ]),
    options: [
        'title' => 'better-route API',
        'version' => 'v1.1.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`

Executable route `args` are exported automatically as path/query parameters; explicit `meta.parameters` entries override derived entries with the same name and location. Resource create/update responses are `{ "data": ... }` envelopes, so strict component sets should provide `Response` in addition to ``, `Input`, and `ListResponse`.

WooCommerce HPOS Integration (Optional)
---------------------------------------

[](#woocommerce-hpos-integration-optional)

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
            'coupons' => [], // explicitly expose no coupon routes
        ],
    ]);

    // Optional OpenAPI export from registered Woo routes:
    // $contracts = $woo->contracts(true);
    // $components = BetterRoute::wooOpenApiComponents();
});
```

Registered endpoints under `/wp-json///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:

- `409` for `idempotency_conflict`
- `400` for `idempotency_key_required` (if `requireKey=true`)

Without an explicit store the Woo registrar installs and uses the lease-aware wpdb atomic store. A schema/install failure is reported instead of degrading to a request-local store. A versioned WordPress option prevents repeated DDL/schema checks after a successful install or migration. Product `price` is read-only; customer email and coupon code are required on create; nested order/customer fields reject unknown keys.

Error Contract
--------------

[](#error-contract)

```
{
  "error": {
    "code": "validation_failed",
    "message": "Invalid request.",
    "requestId": "req_...",
    "details": {
      "fieldErrors": {
        "title": ["required"]
      }
    }
  }
}
```

Local Quality Commands
----------------------

[](#local-quality-commands)

```
composer test
composer analyse
composer cs-check
```

Current Status
--------------

[](#current-status)

Stable — 1.1.0. Available on Packagist: `composer require better-route/better-route:^1.1`.

Changelog
---------

[](#changelog)

### 1.1.0 — 2026-07-13

[](#110--2026-07-13)

Security, atomicity, REST compatibility, and deterministic behavior:

- Raw Router routes now deny by default for every HTTP method; public and middleware-protected intent is explicit. Route groups unwind safely after exceptions, handler resolution supports static/union-typed callables, and WordPress registration fails clearly outside `rest_api_init` or when core rejects a route.
- CPT reads fail closed on missing/private/password-protected visibility data. Custom visibility callbacks now produce correct visible totals/pages, while invariant WP query arguments cannot be overridden through filters.
- Native WordPress user identity now scopes cache, idempotency, and rate-limit keys even without an auth middleware attribute. HMAC identities populate the shared auth context.
- CORS is authoritative over WordPress core headers for matched routes, validates all configured header tokens/origins, and handles allowed/denied preflight before dispatch.
- ETags preserve WordPress REST status/data/headers, support weak/list matching, and produce correct `304` responses. WordPress errors and non-2xx responses are never cached.
- Rate limiting now requires atomic persistent-cache increments or uses a MySQL-locked transient update; `429` errors include `Retry-After` and rate-limit headers.
- Atomic idempotency uses unforgeable reservation leases, bounded keys, deep canonical fingerprints, and data-only response serialization. Failed requests remain reserved by default. Woo write routes use the atomic store across orders, products, customers, and coupons.
- Optimistic locking encloses version resolution and the write in a per-resource MySQL critical section for cooperating Better Route writers.
- Woo order payloads are fully validated before persistence and order writes run in a Woo transaction. Product/customer/coupon validation is strict, coupon-code updates are uniqueness-locked, OpenAPI inputs match runtime behavior, empty action lists disable a resource, and list ordering has an ID tie-breaker.
- OpenAPI derives path/query parameters from route args, permits explicit overrides, documents `OPTIONS` as `204`, and lets custom responses replace defaults.
- JWT max-lifetime enforcement requires both `iat` and `exp`; unknown-key JWKS refreshes are throttled and preserve last-known-good keys on fetch failure.
- SQL `NULL` writes are real SQL nulls, custom-table default ordering uses the primary key, response/error headers are validated, WP error details are allowlisted, and telemetry failures cannot mask application results.
- Verified locally against PHP 8.3, WordPress 7.0.1, WooCommerce 10.9.4 with HPOS, PHPUnit, PHPStan, coding standards, and live REST/Woo smoke tests.

### 1.0.0 — 2026-07-12

[](#100--2026-07-12)

First stable release. Consolidates the 0.3–0.6 development line and adds a full pre-1.0 hardening pass across security, correctness, and the WooCommerce integration layer. All items below are verified by the test suite (135 tests), PHPStan, and a live WordPress 7.0 / WooCommerce 10.9 HPOS install.

Hardening pass (security, correctness, WooCommerce).

Security:

- `TrustedProxyClientIpResolver` now resolves the client IP by walking `X-Forwarded-For` right-to-left and skipping trusted-proxy hops, instead of trusting the (spoofable) leftmost entry. Prevents client-IP spoofing that could bypass `IpAllowlistMiddleware`, forge rate-limit buckets, and falsify audit IPs.
- `WpClaimsUserMapper` no longer maps `email`/`login` claims to WordPress users by default (email/login claim lists are now empty). When enabled, email mapping requires a truthy `email_verified` claim (`$requireEmailVerified`, default true). Prevents account takeover from unverified third-party-issuer email claims. Prefer a `user_id` claim or an issuer-scoped custom resolver.
- `CorsPolicy` now throws if constructed with a wildcard origin (`*`) together with `allowCredentials: true`.
- `HmacSignatureMiddleware` can optionally sign the canonicalized query string (`$signQueryString`); query params remain unauthenticated by default (documented on the constructor).
- `WpCacheSingleUseTokenStore` now requires a persistent object cache (throws otherwise) so single-use consumption is actually atomic; use `WpdbSingleUseTokenStore` on installs without one.
- `WpdbIdempotencyStore` / `WpdbAtomicIdempotencyStore` restrict `unserialize()` to the library's own `Response` class (no arbitrary object injection).
- `JwtAuthMiddleware` / `BearerTokenAuthMiddleware` treat token-supplied (granted) scopes as literals; a trailing-`*` wildcard on a granted scope is honored only via the new `allowGrantedScopeWildcards` opt-in. Server-defined required-scope wildcards are unchanged.
- `HttpJwksProvider` uses `wp_safe_remote_get()` with bounded redirects and response size.
- The error envelope no longer leaks internal exception class names or raw messages for uncaught throwables (400/500 are generic; intentional `ApiException` detail is preserved).

Correctness:

- `WpdbAdapter` list no longer calls `$wpdb->prepare()` on the binding-less COUNT query (avoided a `_doing_it_wrong` notice on every unfiltered list).
- Optimistic-lock "precondition required" now returns `428` via `PreconditionRequiredException` (was `412`).
- `CachingMiddleware` no longer caches non-2xx responses; documented the auth-before-cache ordering requirement.
- CPT delete treats a `null` return from `wp_delete_post()`/`wp_trash_post()` as failure.
- Single-use token salt derives from the documented `wp_salt('auth')` scheme.

WooCommerce:

- Variation line items are now priced from the actual variation product, not the parent.
- Order and product `search` use the supported `s` query var (previously a silently-ignored `search` var).
- Customer delete loads `wp-admin/includes/user.php` so `wp_delete_user()` works in REST context.
- Order line-item replacement is rejected on stock-reduced orders (prevents silent stock corruption).
- Monetary fields (order/line-item/coupon/customer) are serialized as decimal strings; OpenAPI money types aligned to `string`.
- HPOS-unavailable now returns `503` (was `409`); product routes no longer require HPOS.
- Product `price` is read-only (derived field); set `regular_price`/`sale_price`.
- WooCommerce `WC_Data_Exception` from CRUD setters maps to `400` instead of `500`.
- Product `price` sort removed (unsupported/unsorted in WC); order `total` sort retained (verified on HPOS).
- Coupon code filter/create resolve through `wc_get_coupon_id_by_code()` with a duplicate-code `409` guard.
- Customer list no longer computes `orders_count`/`total_spent` by default (avoids an N+1; request them explicitly).
- Added `HposGuard::declareCompatibility(__FILE__)` helper for host plugins to declare HPOS compatibility.

### 0.6.0

[](#060)

Security and auth primitives for integration-heavy REST APIs. Full details and usage examples live in the docs: [Release notes — v0.6.0](https://lonsdale201.github.io/better-docs/docs/better-route/release-notes/v0.6.0).

- Added `Rs256JwksJwtVerifier` for `RS256` and `ES256` JWTs backed by JWKS, with strict JOSE `kid` matching, explicit algorithm allowlisting, issuer/audience/time claim validation, and rejection of `none`/`HS*` algorithms.
- Added `JwksProviderInterface`, `HttpJwksProvider`, and `StaticJwksProvider`. HTTP fetches require `https`, use `sslverify => true`, cache through transients, strip private JWK fields defensively, and support `better_route/jwks_refresh` cache invalidation.
- Added `BetterRoute\Support\Crypto` and `CryptoEncoding` for CSPRNG token generation, hex/base64/base64url encoding, strict base64url decoding, and constant-time string comparison.
- Added `TrustedProxyClientIpResolver`, `ClientIpResolverInterface`, `CidrMatcher`, and `IpAllowlistMiddleware` for IPv4/IPv6 CIDR matching and trusted-proxy aware client IP resolution.
- Added `HmacSignatureMiddleware`, `HmacSecretProviderInterface`, and `ArrayHmacSecretProvider` for multi-key request signature verification with timestamp replay-window enforcement.
- Added `SingleUseTokenMiddleware`, `SingleUseTokenStoreInterface`, `ArraySingleUseTokenStore`, `WpdbSingleUseTokenStore`, and `WpCacheSingleUseTokenStore` for atomic one-time token consumption. Token values are hashed before storage helpers are used.
- Added `OAuthErrorNormalizer` and route-level `->meta(['error_format' => 'oauth_rfc6749'])` support for OAuth-style error responses.
- `Hs256JwtVerifier` now uses the shared `Crypto` helper for constant-time signature comparison and base64url decoding.
- `Http\ClientIpResolver` delegates internally to the hardened trusted-proxy resolver while preserving its existing constructor and `resolve(?array $server = null)` API.
- `RateLimitMiddleware` accepts either the legacy `Http\ClientIpResolver` or the new `Middleware\Network\ClientIpResolverInterface`.
- `Router` now passes normalized route metadata into `RequestContext` attributes as `routeMeta`, enabling route-scoped normalizers without changing handler signatures.
- Updated `Support\Version::VERSION` to `0.6.0-dev`.

### 0.5.0

[](#050)

Public-client and account API hardening. Full details and usage examples live in the docs: [Release notes — v0.5.0](https://lonsdale201.github.io/better-docs/docs/better-route/release-notes/v0.5.0).

- Added `AtomicIdempotencyMiddleware` and atomic idempotency store contracts for side-effectful write routes.
- Added `WpdbAtomicIdempotencyStore` with `INSERT IGNORE` reservation semantics and a dedicated installable table schema.
- Added `ArrayAtomicIdempotencyStore` for tests and non-production local use.
- Added `CorsMiddleware` and `CorsPolicy` for explicit origin allowlists, credential support, exposed headers, and preflight `OPTIONS` responses.
- Added `Router::options()` and public-by-default `OPTIONS` route permissions for explicit preflight endpoints.
- Added `OwnershipGuardMiddleware` for route-level owner checks based on the authenticated `auth` context or current WP user.
- Added `OwnedResourcePolicy::currentUserOwns()` for Resource DSL ownership policies.
- Added `AuditEnricherMiddleware` and taught `AuditMiddleware` to merge safe `audit` context attributes into emitted events.
- Updated `RateLimitMiddleware` so array handler responses are wrapped into `Response` and still receive rate-limit headers.
- Updated `Support\Version::VERSION` to `0.5.0-dev`.

### 0.4.0

[](#040)

Security and route intent:

- Made raw Router write routes deny-by-default unless `permission()`, `protectedByMiddleware()`, or `publicRoute()` is explicit.
- Added explicit route intent helpers: `protectedByMiddleware()` for middleware-authenticated routes and `publicRoute()` for intentionally public routes.

### 0.3.0

[](#030)

Security and hardening:

- Fixed route `id` handling 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 `exp` by default, optional issuer/audience checks, max lifetime, and max token size.
- Removed default numeric `sub` to WP user ID mapping from `WpClaimsUserMapper`.
- 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 `WpdbAdapter` by rejecting cross-database table names and structured write payloads.
- OpenAPI document route now defaults to `manage_options` instead of public access.
- Sanitized accepted `X-Request-ID` values.

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 `ResourcePolicy` presets: `adminOnly()`, `publicReadPrivateWrite()`, `capabilities()`, and `callbacks()`.
- Added `deleteMode('trash'|'force')` for CPT resources.
- Added Woo `deleteMode` option for orders, products, and coupons.
- Added strict OpenAPI schema mode via `strictSchemas => true`.
- Added `ETagMiddleware` with `If-None-Match` / `304 Not Modified` support.
- Added `ClientIpResolver` with 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.

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance90

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~20 days

Total

8

Last Release

48d ago

Major Versions

v0.6.0 → v1.0.02026-07-12

### Community

Maintainers

![](https://www.gravatar.com/avatar/8fbe2bb8e02d27e255f91ee2aa30ff1cfb78f406e5a65023e8ccd2fc8447fcd7?d=identicon)[Lonsdale201](/maintainers/Lonsdale201)

---

Top Contributors

[![Lonsdale201](https://avatars.githubusercontent.com/u/23199033?v=4)](https://github.com/Lonsdale201 "Lonsdale201 (54 commits)")

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/better-route-better-route/health.svg)

```
[![Health](https://phpackages.com/badges/better-route-better-route/health.svg)](https://phpackages.com/packages/better-route-better-route)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
