Install
$ agentstack add skill-lonsdale201-wp-agent-skills-br-resource-cpt ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-route: CPT-backed Resource CRUD
For developers generating REST CRUD over a custom post type using better-route's Resource API. The Resource walks the configured fields, filters, write schema, and policy and emits a full list / get / create / update / delete route set automatically — no per-action handler boilerplate.
Misconception this skill corrects
> "I'll use ->allowedStatuses(['publish', 'draft']) to restrict visibility per the docs."
The agents.md doc lists this method, but the source-verified name is ->cptVisibleStatuses(...) at [src/Resource/Resource.php:235](Resource.php). Same semantics — a list of allowed post_status values for read operations — but the call site needs the right name or you'll get a fatal "method not found".
// WRONG (per docs draft, but not in v0.4.0 source)
->allowedStatuses(['publish', 'draft'])
// RIGHT (per src/Resource/Resource.php:235)
->cptVisibleStatuses(['publish', 'draft'])
If you need conditional / dynamic visibility, use ->cptVisibilityPolicy(callable) ([Resource.php:253](Resource.php)) — receives ($status, $request, $action) and returns bool.
Other AI-prone misconceptions:
- "The Resource handles permissions automatically — I don't need a
->policy()." Wrong — without->policy(), the resource has empty permission rules, which behaves as deny-by-default for writes (and 200 for reads, but inconsistent). Always declare a policy (ResourcePolicy::publicReadPrivateWrite()is the most common). - "
writeSchemaandpayloadSchemado different things." Wrong —payloadSchema()([Resource.php:159](Resource.php)) is just an alias forwriteSchema()([Resource.php:150](Resource.php)). Pick one, stay consistent. - "I'll set
deleteMode('soft')for trash semantics." Wrong — valid values are'force'(default) or'trash'. Verified at [Resource.php:212](Resource.php).
When to use this skill
Trigger when ANY of the following is true:
- The diff calls
Resource::make(...)->sourceCpt(...). - The user asks "how do I expose a CPT over REST" / "CRUD endpoints for a post type".
- Reviewing PR with
Resource::makechains. - Migrating a hand-rolled
register_rest_routeset into the Resource API.
Workflow
1. Minimal Resource setup
add_action('rest_api_init', function () {
$router = \BetterRoute\BetterRoute::router('myapp', 'v1');
\BetterRoute\Resource\Resource::make('books')
->restNamespace('myapp/v1')
->sourceCpt('book')
->fields(['id', 'title', 'status', 'content'])
->policy(\BetterRoute\Resource\ResourcePolicy::publicReadPrivateWrite('edit_posts'))
->register($router);
$router->register();
});
This generates:
GET /myapp/v1/books— listGET /myapp/v1/books/{id}— get onePOST /myapp/v1/books— createPUT /myapp/v1/books/{id}— updatePATCH /myapp/v1/books/{id}— partial updateDELETE /myapp/v1/books/{id}— delete
2. Choose which actions to emit
Resource::make('books')
->sourceCpt('book')
->allow(['list', 'get']) // read-only resource — no create/update/delete routes registered
->register($router);
->allow([...]) ([Resource.php:105](Resource.php)) restricts which actions exist on the resource. Useful for read-only catalogs or admin-only mutations exposed via a separate resource.
3. Field-level configuration
Resource::make('books')
->sourceCpt('book')
->fields(['id', 'title', 'status', 'content', 'meta'])
->filters(['status', 'author']) // ?status=publish&author=42
->sort(['title', '-modified']) // accept ?sort=title or ?sort=-modified
->cptVisibleStatuses(['publish', 'draft']) // restrict read to these statuses
->deleteMode('trash') // moves to trash; default is 'force'
->register($router);
| Method | Purpose | |---|---| | fields(list) | Which post columns / virtual fields appear in responses (incl. 'meta' to surface meta entries). | | filters(list) | Which fields can appear as query-string filters (?status=publish). | | sort(list) | Allowed sort keys. Prefix - for DESC. Sorts NOT in this list return 400. | | cptVisibleStatuses(list) | Allowed post_status values for reads. Posts in other statuses 404. | | cptVisibilityPolicy(callable) | Dynamic visibility — receives ($status, $request, $action), returns bool. | | deleteMode('force'\|'trash') | DELETE behavior. Default 'force' permanently deletes; 'trash' moves to trash. | | defaultPerPage(int) | Default per_page when not supplied (default 20). | | maxPerPage(int) | Cap on per_page (default 100). | | maxOffset(int) | Cap on offset to prevent deep pagination DoS. | | uniformEnvelope(bool) | When true, responses are wrapped in {data: ..., meta: ...} envelope. |
4. Write validation (writeSchema)
Resource::make('books')
->sourceCpt('book')
->writeSchema([
'title' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 200, 'sanitize' => 'text'],
'isbn' => ['type' => 'string', 'regex' => '/^[0-9]{10,13}$/'],
'price' => ['type' => 'float', 'min' => 0],
'status' => ['type' => 'enum', 'enum' => ['values' => ['draft', 'publish']], 'required' => true],
'website' => ['type' => 'url', 'nullable' => true],
])
->register($router);
Validation runs before the handler. Failures return:
{
"error": {
"code": "validation_failed",
"message": "Invalid request.",
"details": { "fieldErrors": { "title": ["..."], "price": ["..."] } }
}
}
See br-write-schema for the complete rule reference.
5. Field-level write policy
Resource::make('books')
->sourceCpt('book')
->fieldPolicy([
'featured' => static fn ($request, string $action): bool
=> current_user_can('manage_options'),
'price' => static fn ($request, string $action): bool
=> current_user_can('edit_posts'),
])
->register($router);
The closure receives ($request, $action) — usually $action is one of 'create', 'update', 'patch'. Returning false strips the field from the incoming payload silently. Useful when a single endpoint serves multiple roles with different write permissions per field.
6. Permission policy
->policy(ResourcePolicy::publicReadPrivateWrite('edit_posts'))
->policy(ResourcePolicy::adminOnly('manage_options'))
->policy(ResourcePolicy::capabilities([
'*' => 'edit_posts',
'delete' => 'manage_options',
]))
->policy(ResourcePolicy::callbacks([
'update' => fn ($req, $action, $self) => current_user_can('edit_post', (int) $req->get_param('id')),
]))
See br-resource-policy for the complete preset reference.
7. Filter schema (custom query-param validation)
->filterSchema([
'status' => ['type' => 'enum', 'enum' => ['values' => ['publish', 'draft']]],
'author' => ['type' => 'int', 'min' => 1],
])
filterSchema ([Resource.php:226](Resource.php)) declares the type / validation contract for query-string filters. Combined with ->filters(['status', 'author']), it enforces both "this field is allowed as a filter" AND "the value must match this shape".
8. Custom repository for non-default behavior
->usingCptRepository(new MyCustomCptRepository())
[Resource.php:173](Resource.php) — replaces the default WordPressCptRepository. Most plugins don't need this; the default handles standard CPT semantics correctly. Useful when you have a multi-table CPT or a virtual CPT backed by an external store.
Critical rules
restNamespaceis required. ThrowsInvalidArgumentExceptionotherwise. Format:'vendor/version'(e.g.'myapp/v1').->register($router)is mandatory. Without it, no routes are wired.writeSchema()andpayloadSchema()are aliases. Pick one, stay consistent across the codebase.cptVisibleStatuses(...)notallowedStatuses(...). The agents.md doc has the older name; source usescptVisibleStatuses([Resource.php:235](Resource.php)).deleteModeis'force'(default) or'trash'. Any other value fails validation.->policy()is effectively required — without one, write actions deny by default. Pick aResourcePolicypreset or pass apermissionsarray.- Sort and filter fields must be allowlisted.
->sort([])and->filters([])are open by default; passing keys not in the allowlist returns 400. ->fieldPolicystrips silently — denied fields are removed from the payload before the handler. Use response-side validation if you need to surface "field not authorized" errors.->allow(['list', 'get'])restricts which actions register. Useful for read-only resources.uniformEnvelope(true)changes the response shape. Wraps lists and individual responses in{data, meta}. Don't toggle mid-API — clients that expect one shape will break on the other.
Common mistakes
// WRONG — using allowedStatuses (older docs name)
Resource::make('books')->sourceCpt('book')
->allowedStatuses(['publish', 'draft']) // 🔴 method does not exist
->register($router);
// RIGHT
->cptVisibleStatuses(['publish', 'draft'])
// WRONG — no policy declared
Resource::make('books')->sourceCpt('book')
->fields(['id', 'title'])
->register($router);
// Reads work; writes deny silently because permissions array is empty.
// RIGHT — declare a policy
->policy(ResourcePolicy::publicReadPrivateWrite('edit_posts'))
// WRONG — restNamespace omitted
Resource::make('books')->sourceCpt('book')->register($router);
// → InvalidArgumentException: 'restNamespace is required.'
// RIGHT
->restNamespace('myapp/v1')
// WRONG — invalid deleteMode
->deleteMode('soft') // 🔴 throws
->deleteMode('archive')
// RIGHT
->deleteMode('force') // default — permanent
->deleteMode('trash') // move to trash
// WRONG — both writeSchema and payloadSchema declared
->writeSchema([...])
->payloadSchema([...]) // overrides the previous; confusing on read-back
// RIGHT — pick one
->writeSchema([...])
// WRONG — fields not in sort allowlist
->sort([]) // empty → no sort allowed
// Client requests ?sort=title → 400 unknown_parameter
// RIGHT — declare allowed sorts
->sort(['title', '-modified', 'date'])
// WRONG — filterSchema without filters allowlist
->filterSchema(['status' => ['type' => 'enum', 'enum' => [...]]])
// status not in ->filters([...]), so the filter is rejected as unknown.
// RIGHT — both
->filters(['status'])
->filterSchema(['status' => ['type' => 'enum', 'enum' => ['values' => ['publish', 'draft']]]])
// WRONG — assuming `meta` field appears without listing it
->fields(['id', 'title', 'status'])
// Response has no meta_data even though the post has meta.
// RIGHT — include 'meta' explicitly
->fields(['id', 'title', 'status', 'meta'])
// See br-woo-routes for meta_data shape; CPT meta uses the same envelope.
Cross-references
- Run
br-write-schemafor the full validation rule catalog (types, sanitizers, error envelope). - Run
br-resource-policyfor the policy preset reference (publicReadPrivateWrite,adminOnly,capabilities,callbacks) andfieldPolicy. - Run
br-resource-tablewhen the data lives in a custom DB table instead of a CPT. - Run
br-routeswhen you need to combine Resource-driven routes with raw routes (e.g. a/books/{id}/featuredaction endpoint).
What this skill does NOT cover
- Custom-table resources (
->sourceTable(...)). Covered bybr-resource-table— different deny-by-default rule. - Block-editor / Gutenberg integration. Resources expose REST; consumer apps decide how to render.
- WP capability design (which capabilities to map to which actions). WP-level decision.
- Async / batched operations. Each Resource action is sync; bulk write is the consumer's concern.
- File / media uploads. The Resource doesn't handle multipart bodies — write a raw route for that.
References
- Resource class: [libraries/better-route/src/Resource/Resource.php](Resource.php) — fluent builder. Key methods:
restNamespace(string)line 83 — required.sourceCpt(string $postType)line 89.sourceTable(string $table, string $primaryKey = 'id')line 95.allow(list $actions)line 105 — restrict to subset of['list', 'get', 'create', 'update', 'delete'].fields(list)line 114.filters(list)line 123.sort(list)line 132.policy(array)line 141.writeSchema(array)line 150 /payloadSchema(array)line 159 alias.fieldPolicy(array)line 167.usingCptRepository(CptRepositoryInterface)line 173.usingTableRepository(TableRepositoryInterface)line 179.defaultPerPage(int)line 185.maxPerPage(int)line 192.maxOffset(int)line 199.uniformEnvelope(bool $enabled = true)line 206.deleteMode(string $mode)line 212 —'force'or'trash'.filterSchema(array)line 226.cptVisibleStatuses(list)line 235.cptVisibilityPolicy(callable)line 253.register(?DispatcherInterface)line 259.- CPT repository / dispatcher: [libraries/better-route/src/Resource/Cpt/](Cpt/) —
WordPressCptRepository,CptListQuery,CptListQueryParser. - ResourcePolicy: [libraries/better-route/src/Resource/ResourcePolicy.php](ResourcePolicy.php) — preset factory.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.