Skip to main content

BetterRouteBridge

BetterData\Route\BetterRouteBridge wires DTOs into a better-route Router. The DTO drives hydration, validation, REST args, OpenAPI metadata, and response shaping.

It's part of the better-data package; better-route is not a hard Composer dependency. The bridge operates on duck-typed $router objects (calls get/post/put/patch/delete by name), so it only fires when both libraries are installed.

HTTP method entry points

BetterRouteBridge::get($router, $uri, DtoClass::class, $handler, $options);
BetterRouteBridge::post($router, $uri, DtoClass::class, $handler, $options);
BetterRouteBridge::put($router, $uri, DtoClass::class, $handler, $options);
BetterRouteBridge::patch($router, $uri, DtoClass::class, $handler, $options);
BetterRouteBridge::delete($router, $uri, DtoClass::class, $handler, $options);

Returns the RouteBuilder-compatible object the underlying router returns, so you can keep chaining (->args(), ->meta(), ->permission(), etc.) when you want to override the bridge's defaults.

Handler signature

function (DataObject $dto, \WP_REST_Request $request): mixed

The bridge inspects the callable's arity and passes ($dto, $request) or just ($dto) accordingly. Variadic handlers receive both. Reflection failures fall back to ($dto)-only.

The handler can return:

  • A DataObject — the bridge presents it via Presenter::for($result)->context(PresentationContext::rest())->toArray()
  • An array of values — recursively presented (DataObject items get the same treatment)
  • A scalar — passed through unchanged
  • A WP_REST_Response / WP_Error — passed through unchanged

Options array

Every key is optional.

KeyTypeDefaultNotes
sourcestring'auto''auto' | 'merged' | 'json' | 'body' | 'query' | 'url' — which request bucket to hydrate from. auto picks JSON/body/query in order.
routeFieldslist<string>[]DTO field names that are URL-owned (path params). Enforced via no-collision check; URL params merged authoritatively.
validatebooltrueRun validate()->throwIfInvalid() after hydration
envelopeboolfalseWrap handler result in ['data' => …]
argsarray | false(auto)Override args map (passed to $builder->args()); false skips the call entirely
metaarray[]Extra better-route meta merged over generated meta
permissionCallbackcallablenoneForwarded to $builder->permission()
middlewareslist<callable|class-string>[]Forwarded to $builder->middleware()
operationIdstringnoneOpenAPI operation ID
tagslist<string>[SchemaName]OpenAPI tags
scopeslist<string>[]OAuth scopes for meta.scopes
requestSchemastringautoOpenAPI request schema $ref (e.g. '#/components/schemas/Product')
responseSchemastringautoOpenAPI response schema $ref
requestSchemaNamestringderivedOverride schema name when auto-generating refs
responseSchemaNamestringderivedSame for response
securityarraynoneOpenAPI security requirements
openapiarraynoneArbitrary extra OpenAPI metadata merged into meta

What the bridge does on each request

WP_REST_Request

1. Resolve param source (source option: auto/json/body/query/url/merged)

2. If routeFields set:
- Assert listed fields are NOT in body/JSON/query
- Merge URL params as authoritative overlay

3. DataObject::fromArray($payload)

4. If validate: true (default) → $dto->validate()->throwIfInvalid()

5. Invoke user handler with ($dto, $request) — arity-aware

6. Recursively present result:
- DataObject → Presenter::for($r)->context(PresentationContext::rest())->toArray()
- array → recurse on each element
- scalar → pass through
- WP_REST_Response / WP_Error → pass through

7. If envelope: true → wrap in ['data' => $presented]

8. Return value to better-route's response pipeline

Exception mapping

The bridge catches the better-data exceptions inside the handler closure and translates them to better-route HTTP errors:

ExceptionStatusCodeDetails
ValidationException400validation_faileddetails.fieldErrors from ValidationResult::errors
RequestParamCollisionException400request_param_collision(route-owned field appeared in body/query)
RequestGuardException (and subclasses)403request_guard_failed(nonce / capability failure)
DataObjectException (TypeCoercionException, MissingRequiredFieldException, UnknownFieldException)400validation_failedfield name when available

Other exceptions propagate to better-route's normal error handler (and end up as 500 unless caught upstream).

Static utilities

For more advanced flows where you want to assemble pieces yourself:

BetterRouteBridge::handler($dtoClass, $handler, $options): \Closure

Returns the wrapped handler closure without binding it to a route. Useful when you have a custom router or want to reuse the same wrapper.

BetterRouteBridge::hydrate($request, $dtoClass, $options): DataObject

Runs the hydration step alone — source resolution, routeFields enforcement, fromArray, optional validation. Useful in non-bridge-managed routes where you want the bridge's hydration semantics.

BetterRouteBridge::args($dtoClass, $options): array

Generates the RouteBuilder::args() map. Calls MetaKeyRegistry::toRestArgs() and marks routeFields entries as required: true.

BetterRouteBridge::meta($dtoClass, $options): array

Generates the better-route meta dict (operationId, tags, scopes, parameters, requestSchema, responseSchema). Use to feed $builder->meta() directly.

BetterRouteBridge::openApiComponents(array $dtoClasses): array

Returns ['schemas' => [...]] for the OpenAPI exporter. The $dtoClasses array can be:

  • A list of class strings (uses BetterRouteBridge::schemaName($class) to derive the schema key — strips a Dto suffix if present)
  • A map [customName => className] for explicit naming
$components = BetterRouteBridge::openApiComponents([
'Product' => ProductDto::class,
'CreateProduct' => CreateProductDto::class,
OrderDto::class, // → 'Order' (Dto suffix stripped)
]);

$openapi = BetterRoute::openApiExporter()->export(
$router->contracts(true),
['components' => $components],
);

BetterRouteBridge::schemaRef($dtoClass, ?$schemaName = null): string

Returns '#/components/schemas/<Name>'.

BetterRouteBridge::schemaName($dtoClass): string

Returns the schema name derived from the class (short class name with Dto suffix stripped).

BetterRouteBridge::parameters($dtoClass, $options): array

Builds the OpenAPI parameter list for path and query params (used internally by meta()).

Worked examples

POST with validation, envelope, and capability check

BetterRouteBridge::post(
$router,
'/products',
CreateProductDto::class,
function (CreateProductDto $dto): ProductDto {
$id = $dto->saveAsPost();
return ProductDto::fromPost($id);
},
[
'operationId' => 'productsCreate',
'tags' => ['Products'],
'envelope' => true,
'permissionCallback' => fn () => current_user_can('edit_posts'),
],
);

PATCH with URL-authoritative ID and partial update

BetterRouteBridge::patch(
$router,
'/products/{id}',
ProductDto::class,
function (ProductDto $dto): ProductDto {
$dto->saveAsPost(only: ['price', 'stock'], skipNullDeletes: true);
return $dto;
},
[
'source' => 'json',
'routeFields' => ['id'],
'operationId' => 'productsUpdate',
'tags' => ['Products'],
],
);

If a client PATCHes /products/42 with {"id": 999, "price": 24.99}, the bridge throws RequestParamCollisionException → 400 request_param_collision. The handler never runs.

Skip auto-args, override schema name

BetterRouteBridge::post(
$router,
'/orders',
OrderDto::class,
fn (OrderDto $o) => $o->saveAsPost(),
[
'args' => false, // skip RouteBuilder::args()
'requestSchemaName' => 'OrderInput', // → '#/components/schemas/OrderInput'
'responseSchema' => '#/components/schemas/Order',
],
);

Custom middleware in the chain

BetterRouteBridge::post(
$router,
'/orders',
OrderDto::class,
fn (OrderDto $o) => $o->saveAsPost(),
[
'middlewares' => [
new RateLimitMiddleware(...),
new AuditMiddleware(...),
],
'permissionCallback' => fn () => current_user_can('edit_shop_orders'),
],
);

Middleware runs before the bridge's hydration. RateLimit failures, auth rejections, etc. short-circuit before any DTO work.

Common mistakes

  • Using routeFields: ['id'] but writing the route as /products/{ id } (with whitespace) — better-route's URL pattern parser is strict; match the field name exactly.
  • Setting validate: false and forgetting to call $dto->validate() inside the handler when the user expects validation errors — the bridge respects the option literally.
  • Returning a WP_REST_Response from the handler expecting the bridge to apply Presenter — WP_REST_Response passes through unchanged, by design. Return a DataObject (or array) for auto-presentation.
  • Expecting envelope: true to apply to WP_Error returns — it only wraps successful results. Errors flow through better-route's error envelope.
  • Combining routeFields with source: 'merged' and a body that doesn't include the URL field — fine. The bridge merges URL params authoritatively after merged resolution.