Claude Skill

bd-better-route-bridge

Compose better-data DTOs with the better-route library — use BetterRouteBridge::{get, post, put, patch, delete} to register a REST route that hydrates the request into a DTO, validates, calls the handler with (DataObject, mixed $request), and presents returned DataObject values t

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-better-data_bd-better-route-bridge-52f6020.zip · 5 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/better-data/bd-better-route-bridge
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

better-data: Composing with better-route

For developers using better-data and better-route together — DTO-backed REST endpoints, OpenAPI generation from DTO schemas, request hydration into typed DataObject instances inside route handlers. The integration seam is the optional BetterRouteBridge (src/Route/BetterRouteBridge.php); using it correctly keeps the data layer free of router concerns and the router layer free of data-shape concerns.

Misconception this skill corrects

"I'll just use register_rest_route directly inside my better-data consumer code, parse WP_REST_Request myself, and call MyDto::fromArray($request->get_params())."

That works for one route. For an API of 10+ routes, it duplicates the request-parsing, validation, route-owned-field, and Presenter-projection wiring at every callsite. The bridge centralizes that pipeline:

  1. Register the route on better-route's Router via the appropriate HTTP-verb method.
  2. On request, hydrate a WP_REST_Request-shaped object into the DTO (URL params, JSON body, query string — buckets resolved per source option).
  3. Reject collisions: a route-owned field like id (in the URL /posts/{id}) MUST NOT also appear in the JSON body — RequestParamCollisionException (line 194, 680).
  4. Validate the DTO via the BuiltInValidator.
  5. Call the handler with (DataObject $dto, mixed $request).
  6. If the handler returns a DataObject, present through Presenter::for($dto)->context(PresentationContext::rest()).

Other AI-prone misconceptions:

  • "I'll add better-route/better-route as a hard runtime dep of better-data so the bridge always works." Wrong — the bridge is deliberately duck-typed by method name (class docblock at lines 21-26) so better-data installs without better-route. Don't break that.
  • "Permission and middleware concerns are data-layer, so I'll put permissionCallback inside the DTO." Wrong — those are route-owned and pass through the bridge's $options to better-route's RouteBuilder. The data layer doesn't care who's allowed; the route layer does.
  • "I'll reimplement better-route's Resource DSL inside better-data so consumers only need one library." Wrong — the bridge composes; it doesn't replace either side. Keep the boundary.

When to use this skill

Trigger when ANY of the following is true:

  • The diff or PR registers a REST route AND uses a DataObject for request parsing or response shaping.
  • Calls to BetterRouteBridge::{get, post, put, patch, delete}.
  • The diff modifies src/Route/BetterRouteBridge.php.
  • OpenAPI exporter setup that includes DTO schemas.
  • The consumer asks "how do I get a DTO from WP_REST_Request?" or "how do I avoid duplicating request parsing across 20 routes?".

Workflow

1. Read better-route's flow first when behavior is unclear

Don't edit better-route from a better-data PR. The relevant files in the sibling repo:

  • ../better-route/README.md — overview.
  • ../better-route/src/Router/Router.php — main entry.
  • ../better-route/src/Router/RouteBuilder.php — fluent builder for one route.
  • ../better-route/src/OpenApi/OpenApiExporter.php — schema export.

Use the bridge as the integration seam; if you find yourself wanting to change better-route to make the bridge work, that's a sign the bridge needs to absorb the concern instead.

2. Register a read route

use BetterData\Route\BetterRouteBridge;
use MyPlugin\Dto\PostDto;

$router = my_plugin_get_router();  // returns the better-route Router instance

BetterRouteBridge::get(
    $router,
    '/posts/{id}',
    PostDto::class,
    function (PostDto $dto, $request) {
        // $dto is hydrated from URL params (id in particular)
        // $request is the original better-route / WP_REST_Request-ish object
        return PostDto::fromPost($dto->id);  // re-hydrate from store, return for projection
    },
    [
        'routeFields'        => ['id'],  // 'id' lives in the URL, not in body / query
        'permissionCallback' => 'is_user_logged_in',
    ],
);

The handler returns a DataObject. The bridge wraps the response through Presenter::for($returned)->context(PresentationContext::rest())->toArray(), applying Sensitive / Secret redaction automatically.

3. Register a write route

BetterRouteBridge::post(
    $router,
    '/posts',
    CreatePostDto::class,
    function (CreatePostDto $dto, $request) {
        $id = $dto->saveAsPost();  // via HasWpSinks
        return PostDto::fromPost($id);
    },
    [
        // No routeFields — entire DTO comes from JSON body
        'permissionCallback' => fn () => current_user_can('edit_posts'),
    ],
);

For POST / PUT / PATCH, the body is the canonical source. Validation runs before the handler — if the DTO has #[Rule\Required] on a missing field, the bridge returns a 4xx with the validation report.

4. URL-owned fields and the collision guard

Verified at BetterRouteBridge.php:244-249, 661-680:

// In options:
['routeFields' => ['id', 'slug']]

// At dispatch time:
self::assertNoRouteFieldCollisions($request, ['id', 'slug']);
// Throws RequestParamCollisionException::forFields(['id']) if 'id' appears in BOTH the URL and the JSON body.

Why: a request PUT /posts/5 with {"id": 7, "title": "..."} is ambiguous — does the user want to update post 5 with the new id 7, or did they accidentally include a stale id? The bridge rejects the request to surface the bug. This is the route-side equivalent of RequestSource::noCollision for JSON/body/query buckets.

5. Bucket selection via source option

The source option (default 'auto') controls where the bridge looks for non-route fields:

Value Meaning
'auto' Pick by HTTP method — body for write, query for read
'merged' Merge JSON + body + query into one bag (loose)
'json' Only the JSON-decoded body
'body' Form-encoded body
'query' URL query string
'url' Only the URL params (degenerate — combine with routeFields)

For most routes, 'auto' is correct. Use a specific value when you need to lock down where data comes from (security-sensitive endpoint that ignores query strings, etc.).

6. Generate args and meta automatically

Better-route's RouteBuilder accepts args(...) (REST args schema) and meta(...) (request metadata). The bridge fills both from the DTO automatically:

// Inside register():
$args = MetaKeyRegistry::toRestArgs($dtoClass);   // produces the args spec
$meta = MetaKeyRegistry::toJsonSchema($dtoClass); // produces JSON schema

For OpenAPI integration:

$exporter = BetterRoute::openApiExporter();
$components = BetterRouteBridge::openApiComponents([
    PostDto::class,
    CreatePostDto::class,
    UpdatePostDto::class,
]);
$exporter->addComponents($components);

openApiComponents (line 349) walks each DTO class, extracts its REST schema via RestSchemaBuilder, and returns a components.schemas-shaped array.

7. Permission and middleware stay route-owned

BetterRouteBridge::post($router, '/posts', CreatePostDto::class, $handler, [
    'permissionCallback' => fn () => current_user_can('edit_posts'),
    'middlewares'        => [
        new RateLimitMiddleware(60, 'minute'),
        new AuditLogMiddleware('post_create'),
    ],
]);

The bridge passes both options straight to better-route's RouteBuilder::permissionCallback(...) and ->middleware(...). Don't introduce auth-related abstractions in the data layer.

8. Tests

Bridge unit tests (tests/Unit/BetterRouteBridgeTest.php) use fake Router / RouteBuilder / request objects — no WP, no real better-route required. The fakes implement the duck-typed methods (get, post, args, meta, etc.) and assert the bridge invokes them with the expected arguments.

Live-WP behavior (real register_rest_route, real WP request parsing) goes in the companion plugin's smoke / stress suite.

vendor/bin/phpunit --filter BetterRouteBridge
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
wp better-data stress --filter BridgeRoute

Critical rules

  • Use the bridge as the integration seam, not direct register_rest_route from data-layer code. Centralizes hydration, validation, route-fields, and Presenter projection.
  • Method-name duck typing. The bridge talks to Router / RouteBuilder by method name; don't add a hard Composer dependency on better-route/better-route. Better-data must install standalone.
  • routeFields for URL-owned fields. Always set when the DTO has a parameter that's bound to a URL placeholder. Without it, you get JSON-body-vs-URL collisions silently.
  • RequestParamCollisionException is loud and intentional. Don't catch and ignore — the request is genuinely ambiguous.
  • Permission and middleware stay route-owned. Pass through the $options bag; don't introduce auth abstractions in better-data.
  • Don't reimplement better-route's Resource DSL. The bridge composes existing primitives.
  • MetaKeyRegistry::toRestArgs / toJsonSchema generate the schema; don't duplicate that work in the consumer.
  • Returned DTO → Presenter::rest() projection automatically. Don't manually ->toArray() from the handler — that bypasses sensitive-field redaction.
  • Bridge tests use fakes. Don't require WP or better-route in unit tests.

Common mistakes

// WRONG — register_rest_route directly, bypassing the bridge
\register_rest_route('myplugin/v1', '/posts/(?P<id>\d+)', [
    'callback' => function (\WP_REST_Request $req) {
        $dto = PostDto::fromArray((array) $req->get_params());  // WRONG: no routeFields, no validation, no projection
        return PostDto::fromPost($dto->id)->toArray();          // WRONG: bypasses Presenter redaction
    },
]);

// RIGHT — through the bridge
BetterRouteBridge::get($router, '/posts/{id}', PostDto::class,
    fn (PostDto $dto) => PostDto::fromPost($dto->id),
    ['routeFields' => ['id']],
);

// WRONG — hard Composer dep on better-route
// composer.json: "require": { "better-route/better-route": "^1.0" }
// Now better-data can't be installed standalone for testing.

// RIGHT — soft dep, duck-typed
// composer.json: "suggest": { "better-route/better-route": "Optional REST router integration" }

// WRONG — missing routeFields
BetterRouteBridge::put($router, '/posts/{id}', UpdatePostDto::class, $handler, [
    // No 'routeFields' option
]);
// Request: PUT /posts/5 with {"id": 7, "title": "..."} — handler gets DTO with id=7, the URL '5' is silently lost.

// RIGHT
BetterRouteBridge::put($router, '/posts/{id}', UpdatePostDto::class, $handler, [
    'routeFields' => ['id'],  // forces id from URL, rejects collision in body
]);

// WRONG — auth in the DTO
final readonly class PostDto extends DataObject {
    public function __construct(
        public int $id = 0,
        public string $title = '',
    ) {
        if (!\current_user_can('edit_posts')) {  // WRONG: DTO is data shape, not auth gate
            throw new \RuntimeException('Unauthorized');
        }
    }
}

// RIGHT — auth in the bridge options
BetterRouteBridge::put($router, '/posts/{id}', PostDto::class, $handler, [
    'permissionCallback' => fn () => \current_user_can('edit_posts'),
    'routeFields'        => ['id'],
]);

// WRONG — manual ->toArray() in the handler
function (PostDto $dto) {
    return PostDto::fromPost($dto->id)->toArray();  // bypasses Presenter
}

// RIGHT — return the DTO; bridge presents
function (PostDto $dto) {
    return PostDto::fromPost($dto->id);
}
// Bridge applies Presenter::for($returned)->context(PresentationContext::rest())->toArray()
// → Sensitive / Secret fields automatically redacted.

// WRONG — duplicating schema generation
function ($dto) {
    return [
        'id' => ['type' => 'integer'],
        'title' => ['type' => 'string'],
        // ... handwritten REST schema
    ];
}

// RIGHT — let MetaKeyRegistry handle it
$args = MetaKeyRegistry::toRestArgs($dtoClass);

Cross-references

  • Run bd-data-object when designing the DTO that backs a route — DTO + route design co-evolve.
  • Run bd-presenter when the route response needs custom shaping beyond default redaction (computed fields, locale switching).
  • Run bd-security when the route DTO carries Secret / #[Sensitive] fields — verify Presenter redaction is in effect at the response boundary.

What this skill does NOT cover

  • Better-route's own routing semantics (uri patterns, middleware ordering, openapi customization). Read ../better-route/README.md.
  • Caching of REST responses. Better-route handles cache headers; the data layer doesn't.
  • WebSocket / SSE / streaming responses. Bridge is request-response only.
  • Rate limiting, throttling — better-route middlewares.
  • Authentication mechanisms (JWT, OAuth, …) — permissionCallback consumes the resolved current user; auth is upstream.
  • GraphQL / RPC bindings. Bridge is REST-only.

References

Files (wp-agent-skills)
  • SKILL.md 15.6 KB
    ---
    name: bd-better-route-bridge
    description: Compose better-data DTOs with the better-route library — use BetterRouteBridge::{get, post, put, patch, delete} to register a REST route that hydrates the request into a DTO, validates, calls the handler with (DataObject, mixed $request), and presents returned DataObject values through Presenter with PresentationContext::rest(). Contract — the bridge is method-name based (duck-types Router / RouteBuilder) so better-data takes no hard Composer dependency on better-route. URL-owned fields go into routeFields option — those are merged from URL params AND rejected from JSON / body / query buckets via RequestParamCollisionException; this is the route-side equivalent of RequestSource::noCollision. Use when wiring DTO-backed REST endpoints, feeding DTO schemas into better-route's OpenAPI exporter, or moving request data from a route handler into a better-data DataObject. Triggers on BetterRouteBridge::get/post/put/patch/delete, routeFields, RequestParamCollisionException, OpenAPI / OpenApi DTO schema in better-data.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "better-data"
      wp-skills-plugin-version-tested: "phase-9"
      wp-skills-php-min: "8.3"
      wp-skills-last-updated: "2026-04-29"
    ---
    
    # better-data: Composing with better-route
    
    For developers using better-data and better-route together — DTO-backed REST endpoints, OpenAPI generation from DTO schemas, request hydration into typed `DataObject` instances inside route handlers. The integration seam is the optional `BetterRouteBridge` ([src/Route/BetterRouteBridge.php](BetterRouteBridge.php)); using it correctly keeps the data layer free of router concerns and the router layer free of data-shape concerns.
    
    ## Misconception this skill corrects
    
    > "I'll just use `register_rest_route` directly inside my better-data consumer code, parse `WP_REST_Request` myself, and call `MyDto::fromArray($request->get_params())`."
    
    That works for one route. For an API of 10+ routes, it duplicates the request-parsing, validation, route-owned-field, and Presenter-projection wiring at every callsite. The bridge centralizes that pipeline:
    
    1. Register the route on `better-route`'s `Router` via the appropriate HTTP-verb method.
    2. On request, hydrate a `WP_REST_Request`-shaped object into the DTO (URL params, JSON body, query string — buckets resolved per `source` option).
    3. Reject collisions: a route-owned field like `id` (in the URL `/posts/{id}`) MUST NOT also appear in the JSON body — `RequestParamCollisionException` ([line 194, 680](BetterRouteBridge.php)).
    4. Validate the DTO via the `BuiltInValidator`.
    5. Call the handler with `(DataObject $dto, mixed $request)`.
    6. If the handler returns a `DataObject`, present through `Presenter::for($dto)->context(PresentationContext::rest())`.
    
    Other AI-prone misconceptions:
    
    - "I'll add `better-route/better-route` as a hard runtime dep of better-data so the bridge always works." Wrong — the bridge is deliberately duck-typed by method name ([class docblock at lines 21-26](BetterRouteBridge.php)) so better-data installs without better-route. Don't break that.
    - "Permission and middleware concerns are data-layer, so I'll put `permissionCallback` inside the DTO." Wrong — those are route-owned and pass through the bridge's `$options` to better-route's `RouteBuilder`. The data layer doesn't care who's allowed; the route layer does.
    - "I'll reimplement better-route's Resource DSL inside better-data so consumers only need one library." Wrong — the bridge composes; it doesn't replace either side. Keep the boundary.
    
    ## When to use this skill
    
    Trigger when ANY of the following is true:
    
    - The diff or PR registers a REST route AND uses a `DataObject` for request parsing or response shaping.
    - Calls to `BetterRouteBridge::{get, post, put, patch, delete}`.
    - The diff modifies `src/Route/BetterRouteBridge.php`.
    - OpenAPI exporter setup that includes DTO schemas.
    - The consumer asks "how do I get a DTO from `WP_REST_Request`?" or "how do I avoid duplicating request parsing across 20 routes?".
    
    ## Workflow
    
    ### 1. Read better-route's flow first when behavior is unclear
    
    Don't edit `better-route` from a better-data PR. The relevant files in the sibling repo:
    
    - `../better-route/README.md` — overview.
    - `../better-route/src/Router/Router.php` — main entry.
    - `../better-route/src/Router/RouteBuilder.php` — fluent builder for one route.
    - `../better-route/src/OpenApi/OpenApiExporter.php` — schema export.
    
    Use the bridge as the integration seam; if you find yourself wanting to change better-route to make the bridge work, that's a sign the bridge needs to absorb the concern instead.
    
    ### 2. Register a read route
    
    ```php
    use BetterData\Route\BetterRouteBridge;
    use MyPlugin\Dto\PostDto;
    
    $router = my_plugin_get_router();  // returns the better-route Router instance
    
    BetterRouteBridge::get(
        $router,
        '/posts/{id}',
        PostDto::class,
        function (PostDto $dto, $request) {
            // $dto is hydrated from URL params (id in particular)
            // $request is the original better-route / WP_REST_Request-ish object
            return PostDto::fromPost($dto->id);  // re-hydrate from store, return for projection
        },
        [
            'routeFields'        => ['id'],  // 'id' lives in the URL, not in body / query
            'permissionCallback' => 'is_user_logged_in',
        ],
    );
    ```
    
    The handler returns a `DataObject`. The bridge wraps the response through `Presenter::for($returned)->context(PresentationContext::rest())->toArray()`, applying `Sensitive` / `Secret` redaction automatically.
    
    ### 3. Register a write route
    
    ```php
    BetterRouteBridge::post(
        $router,
        '/posts',
        CreatePostDto::class,
        function (CreatePostDto $dto, $request) {
            $id = $dto->saveAsPost();  // via HasWpSinks
            return PostDto::fromPost($id);
        },
        [
            // No routeFields — entire DTO comes from JSON body
            'permissionCallback' => fn () => current_user_can('edit_posts'),
        ],
    );
    ```
    
    For `POST` / `PUT` / `PATCH`, the body is the canonical source. Validation runs before the handler — if the DTO has `#[Rule\Required]` on a missing field, the bridge returns a 4xx with the validation report.
    
    ### 4. URL-owned fields and the collision guard
    
    Verified at [BetterRouteBridge.php:244-249, 661-680](BetterRouteBridge.php):
    
    ```php
    // In options:
    ['routeFields' => ['id', 'slug']]
    
    // At dispatch time:
    self::assertNoRouteFieldCollisions($request, ['id', 'slug']);
    // Throws RequestParamCollisionException::forFields(['id']) if 'id' appears in BOTH the URL and the JSON body.
    ```
    
    Why: a request `PUT /posts/5` with `{"id": 7, "title": "..."}` is ambiguous — does the user want to update post 5 with the new id 7, or did they accidentally include a stale id? The bridge rejects the request to surface the bug. This is the route-side equivalent of `RequestSource::noCollision` for JSON/body/query buckets.
    
    ### 5. Bucket selection via `source` option
    
    The `source` option (default `'auto'`) controls where the bridge looks for non-route fields:
    
    | Value | Meaning |
    |---|---|
    | `'auto'` | Pick by HTTP method — body for write, query for read |
    | `'merged'` | Merge JSON + body + query into one bag (loose) |
    | `'json'` | Only the JSON-decoded body |
    | `'body'` | Form-encoded body |
    | `'query'` | URL query string |
    | `'url'` | Only the URL params (degenerate — combine with `routeFields`) |
    
    For most routes, `'auto'` is correct. Use a specific value when you need to lock down where data comes from (security-sensitive endpoint that ignores query strings, etc.).
    
    ### 6. Generate `args` and `meta` automatically
    
    Better-route's `RouteBuilder` accepts `args(...)` (REST args schema) and `meta(...)` (request metadata). The bridge fills both from the DTO automatically:
    
    ```php
    // Inside register():
    $args = MetaKeyRegistry::toRestArgs($dtoClass);   // produces the args spec
    $meta = MetaKeyRegistry::toJsonSchema($dtoClass); // produces JSON schema
    ```
    
    For OpenAPI integration:
    
    ```php
    $exporter = BetterRoute::openApiExporter();
    $components = BetterRouteBridge::openApiComponents([
        PostDto::class,
        CreatePostDto::class,
        UpdatePostDto::class,
    ]);
    $exporter->addComponents($components);
    ```
    
    `openApiComponents` ([line 349](BetterRouteBridge.php)) walks each DTO class, extracts its REST schema via `RestSchemaBuilder`, and returns a `components.schemas`-shaped array.
    
    ### 7. Permission and middleware stay route-owned
    
    ```php
    BetterRouteBridge::post($router, '/posts', CreatePostDto::class, $handler, [
        'permissionCallback' => fn () => current_user_can('edit_posts'),
        'middlewares'        => [
            new RateLimitMiddleware(60, 'minute'),
            new AuditLogMiddleware('post_create'),
        ],
    ]);
    ```
    
    The bridge passes both options straight to better-route's `RouteBuilder::permissionCallback(...)` and `->middleware(...)`. Don't introduce auth-related abstractions in the data layer.
    
    ### 8. Tests
    
    Bridge unit tests ([tests/Unit/BetterRouteBridgeTest.php](BetterRouteBridgeTest.php)) use fake `Router` / `RouteBuilder` / request objects — no WP, no real better-route required. The fakes implement the duck-typed methods (`get`, `post`, `args`, `meta`, etc.) and assert the bridge invokes them with the expected arguments.
    
    Live-WP behavior (real `register_rest_route`, real WP request parsing) goes in the companion plugin's smoke / stress suite.
    
    ```bash
    vendor/bin/phpunit --filter BetterRouteBridge
    vendor/bin/phpstan analyse --memory-limit=1G
    vendor/bin/php-cs-fixer fix
    wp better-data stress --filter BridgeRoute
    ```
    
    ## Critical rules
    
    - **Use the bridge as the integration seam, not direct `register_rest_route` from data-layer code.** Centralizes hydration, validation, route-fields, and Presenter projection.
    - **Method-name duck typing.** The bridge talks to `Router` / `RouteBuilder` by method name; don't add a hard Composer dependency on `better-route/better-route`. Better-data must install standalone.
    - **`routeFields` for URL-owned fields.** Always set when the DTO has a parameter that's bound to a URL placeholder. Without it, you get JSON-body-vs-URL collisions silently.
    - **`RequestParamCollisionException` is loud and intentional.** Don't catch and ignore — the request is genuinely ambiguous.
    - **Permission and middleware stay route-owned.** Pass through the `$options` bag; don't introduce auth abstractions in better-data.
    - **Don't reimplement better-route's Resource DSL.** The bridge composes existing primitives.
    - **`MetaKeyRegistry::toRestArgs` / `toJsonSchema`** generate the schema; don't duplicate that work in the consumer.
    - **Returned DTO → Presenter::rest() projection automatically.** Don't manually `->toArray()` from the handler — that bypasses sensitive-field redaction.
    - **Bridge tests use fakes.** Don't require WP or better-route in unit tests.
    
    ## Common mistakes
    
    ```php
    // WRONG — register_rest_route directly, bypassing the bridge
    \register_rest_route('myplugin/v1', '/posts/(?P<id>\d+)', [
        'callback' => function (\WP_REST_Request $req) {
            $dto = PostDto::fromArray((array) $req->get_params());  // WRONG: no routeFields, no validation, no projection
            return PostDto::fromPost($dto->id)->toArray();          // WRONG: bypasses Presenter redaction
        },
    ]);
    
    // RIGHT — through the bridge
    BetterRouteBridge::get($router, '/posts/{id}', PostDto::class,
        fn (PostDto $dto) => PostDto::fromPost($dto->id),
        ['routeFields' => ['id']],
    );
    
    // WRONG — hard Composer dep on better-route
    // composer.json: "require": { "better-route/better-route": "^1.0" }
    // Now better-data can't be installed standalone for testing.
    
    // RIGHT — soft dep, duck-typed
    // composer.json: "suggest": { "better-route/better-route": "Optional REST router integration" }
    
    // WRONG — missing routeFields
    BetterRouteBridge::put($router, '/posts/{id}', UpdatePostDto::class, $handler, [
        // No 'routeFields' option
    ]);
    // Request: PUT /posts/5 with {"id": 7, "title": "..."} — handler gets DTO with id=7, the URL '5' is silently lost.
    
    // RIGHT
    BetterRouteBridge::put($router, '/posts/{id}', UpdatePostDto::class, $handler, [
        'routeFields' => ['id'],  // forces id from URL, rejects collision in body
    ]);
    
    // WRONG — auth in the DTO
    final readonly class PostDto extends DataObject {
        public function __construct(
            public int $id = 0,
            public string $title = '',
        ) {
            if (!\current_user_can('edit_posts')) {  // WRONG: DTO is data shape, not auth gate
                throw new \RuntimeException('Unauthorized');
            }
        }
    }
    
    // RIGHT — auth in the bridge options
    BetterRouteBridge::put($router, '/posts/{id}', PostDto::class, $handler, [
        'permissionCallback' => fn () => \current_user_can('edit_posts'),
        'routeFields'        => ['id'],
    ]);
    
    // WRONG — manual ->toArray() in the handler
    function (PostDto $dto) {
        return PostDto::fromPost($dto->id)->toArray();  // bypasses Presenter
    }
    
    // RIGHT — return the DTO; bridge presents
    function (PostDto $dto) {
        return PostDto::fromPost($dto->id);
    }
    // Bridge applies Presenter::for($returned)->context(PresentationContext::rest())->toArray()
    // → Sensitive / Secret fields automatically redacted.
    
    // WRONG — duplicating schema generation
    function ($dto) {
        return [
            'id' => ['type' => 'integer'],
            'title' => ['type' => 'string'],
            // ... handwritten REST schema
        ];
    }
    
    // RIGHT — let MetaKeyRegistry handle it
    $args = MetaKeyRegistry::toRestArgs($dtoClass);
    ```
    
    ## Cross-references
    
    - Run **`bd-data-object`** when designing the DTO that backs a route — DTO + route design co-evolve.
    - Run **`bd-presenter`** when the route response needs custom shaping beyond default redaction (computed fields, locale switching).
    - Run **`bd-security`** when the route DTO carries `Secret` / `#[Sensitive]` fields — verify Presenter redaction is in effect at the response boundary.
    
    ## What this skill does NOT cover
    
    - Better-route's own routing semantics (uri patterns, middleware ordering, openapi customization). Read `../better-route/README.md`.
    - Caching of REST responses. Better-route handles cache headers; the data layer doesn't.
    - WebSocket / SSE / streaming responses. Bridge is request-response only.
    - Rate limiting, throttling — better-route middlewares.
    - Authentication mechanisms (JWT, OAuth, …) — `permissionCallback` consumes the resolved current user; auth is upstream.
    - GraphQL / RPC bindings. Bridge is REST-only.
    
    ## References
    
    - Bridge file: [libraries/better-data/src/Route/BetterRouteBridge.php](BetterRouteBridge.php) — `final class BetterRouteBridge`. Class docblock with the duck-typing rationale at lines 21-26.
    - HTTP verb entry points: [BetterRouteBridge.php:55-110](BetterRouteBridge.php) — `get`, `post`, `put`, `patch`, `delete` — all delegate to `register()`.
    - Route-field collision: [BetterRouteBridge.php:661-680](BetterRouteBridge.php) — `assertNoRouteFieldCollisions` throws `RequestParamCollisionException`.
    - Source bucket selection: [BetterRouteBridge.php:48](BetterRouteBridge.php) — `SOURCES = ['auto', 'merged', 'json', 'body', 'query', 'url']`.
    - OpenAPI integration: [BetterRouteBridge.php:349](BetterRouteBridge.php) — `openApiComponents(array $dtoClasses): array`.
    - `MetaKeyRegistry::toRestArgs` / `toJsonSchema`: [libraries/better-data/src/Registration/MetaKeyRegistry.php](MetaKeyRegistry.php) — schema generation.
    - `RequestSource::noCollision`: [libraries/better-data/src/Source/RequestSource.php](RequestSource.php) — the source-side collision guard the bridge mirrors.
    - Official documentation: <https://github.com/lonsdale201/better-data>
    - Official documentation: <https://github.com/lonsdale201/better-route>
    - Verified source paths:
      - `src/Internal/RestSchemaBuilder.php`
      - `src/Presenter/Presenter.php`
      - `src/Presenter/PresentationContext.php`
      - `src/Exception/RequestParamCollisionException.php`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related