Install
$ agentstack add skill-lonsdale201-wp-agent-skills-br-resource-table ✓ 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 Used
- ✓ 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: Custom-table-backed Resource CRUD
For developers generating REST CRUD over a custom database table (audit logs, analytics events, plugin-specific entities) using better-route's Resource API. Same fluent builder as CPT resources, but with two stricter rules — deny-by-default for all actions, and explicit fields() required.
Misconception this skill corrects
> "I set up a Resource::make('audit')->sourceTable('wp_myapp_events')->register($router) and the list endpoint returns 403 even for admins. Must be a permission cache issue."
It's not a cache issue. Verified at [src/Resource/Resource.php:835-844](Resource.php) — the defaultPermissionForAction for table-source resources returns false for every action (list, get, create, update, delete) when no ->policy() is declared:
return match ($action) {
'list', 'get' => $this->sourceCpt !== null
? static fn (): bool => true
: static fn (): bool => false, // ← table reads deny by default
default => static fn (): bool => false,
};
The rationale: a CPT inherits WordPress's visibility model (post_status, capability mapping, public/private logic) so reads are reasonable to allow by default. A raw custom table has no such model — better-route deliberately refuses to guess and forces the developer to declare intent.
The fix:
Resource::make('audit')
->sourceTable('wp_myapp_events')
->fields(['id', 'event_type', 'user_id', 'created_at'])
->policy(ResourcePolicy::publicReadPrivateWrite('manage_options'))
->register($router);
Other AI-prone misconceptions:
- "I'll skip
fields([...])since the table has obvious columns the resource can introspect." Wrong — table-source requires explicitfields(). Verified at [Resource.php:514-517](Resource.php):if ($fields === []) throw new InvalidArgumentException('fields are required for sourceTable resources.'). Better-route doesn't introspect schemas; you list the exposed columns. - "Custom table resources support the same
cptVisibleStatusesfor soft-delete." Wrong —cptVisibleStatusesandcptVisibilityPolicyare CPT-only. Custom tables have nopost_statusconcept. For soft-delete on a custom table, model it explicitly: add adeleted_atcolumn, exclude rows via repository logic. - "
->sourceTable('wp_myapp_events')works with cross-database tables." Wrong — table names with.(cross-database references) are rejected by the underlyingWpdbIdempotencyStoreand table dispatcher to prevent cross-DB writes.
When to use this skill
Trigger when ANY of the following is true:
- The diff calls
Resource::make(...)->sourceTable(...). - The user asks "how do I expose a custom DB table over REST" / "CRUD endpoints for
wp_myapp_*". - Reviewing a PR with
Resource::make+sourceTable. - Triaging "Resource returns 403 on every call" after a v0.3.0 upgrade — table policy is the missing piece.
Workflow
1. Minimal table resource
add_action('rest_api_init', function () {
$router = \BetterRoute\BetterRoute::router('myapp', 'v1');
\BetterRoute\Resource\Resource::make('audit_events')
->restNamespace('myapp/v1')
->sourceTable('wp_myapp_audit_events')
->fields(['id', 'event_type', 'user_id', 'created_at'])
->filters(['event_type', 'user_id'])
->sort(['created_at', 'id'])
->policy(\BetterRoute\Resource\ResourcePolicy::adminOnly('manage_options'))
->register($router);
$router->register();
});
This generates:
GET /myapp/v1/audit_events— listGET /myapp/v1/audit_events/{id}— get onePOST /myapp/v1/audit_events— createPUT /myapp/v1/audit_events/{id}— updatePATCH /myapp/v1/audit_events/{id}— partial updateDELETE /myapp/v1/audit_events/{id}— delete
(Use ->allow(['list', 'get']) to restrict — see step 4.)
2. Pass the unprefixed table name? Or the prefixed?
->sourceTable('wp_myapp_events') — pass the FULL table name as it appears in $wpdb. Better-route does NOT auto-prefix:
global $wpdb;
$tableName = $wpdb->prefix . 'myapp_events'; // e.g. 'wp_myapp_events' or 'wp_2_myapp_events' on multisite
->sourceTable($tableName)
If you hardcode 'wp_myapp_events', your resource breaks on multisite installs where $wpdb->prefix is 'wp_2_'. Use $wpdb->prefix to compose the full name at registration time.
3. Custom primary key
->sourceTable('wp_myapp_events', 'event_id') // second arg = primary-key column name
Default is 'id'. The primary key drives URL routing (/audit_events/{id} looks up by event_id column when configured).
4. Restrict actions
Resource::make('audit_events')
->sourceTable('wp_myapp_events')
->fields(['id', 'event_type', 'user_id', 'created_at'])
->allow(['list', 'get']) // read-only — no create/update/delete routes registered
->policy(ResourcePolicy::adminOnly('manage_options'))
->register($router);
For event logs / read-only feeds, restrict to ['list', 'get']. Don't register write routes that would never be exercised.
5. Sort default
If you don't declare ->sort([...]), the default sort is by primary key ([Resource.php:524](Resource.php) — allowedSort: $this->sort !== [] ? $this->sort : [$primaryKey]). For chronological data, declare 'created_at' first:
->sort(['-created_at', 'id']) // newest-first by default
6. Pagination
->defaultPerPage(20) // default page size
->maxPerPage(100) // hard cap
->maxOffset(10000) // prevent deep-pagination DoS (LIMIT N OFFSET 50000 is expensive)
The list response shape matches the CPT resource's:
{
"data": [ /* rows */ ],
"meta": { "page": 1, "perPage": 20, "total": 1234 }
}
(uniformEnvelope(true) applies the same {data, meta} wrapper to single-record responses.)
7. Custom repository for derived tables
->usingTableRepository(new MyJoinedTableRepository())
The default WordPressTableRepository does single-table CRUD via $wpdb. For derived data (joins, aggregates), implement TableRepositoryInterface ([src/Resource/Table/TableRepositoryInterface.php](TableRepositoryInterface.php)). Most plugins don't need this.
8. Write validation
->writeSchema([
'event_type' => ['type' => 'string', 'required' => true, 'enum' => ['values' => ['login', 'logout', 'failed_attempt']]],
'user_id' => ['type' => 'int', 'min' => 0, 'nullable' => true],
'created_at' => ['type' => 'date', 'required' => true],
])
Same syntax as CPT resources — see br-write-schema for the full catalog.
Critical rules
- Custom-table resources are deny-by-default for ALL actions (including list and get). Without
->policy(), every call returns 403. Verified at [Resource.php:840-844](Resource.php). ->fields([...])is REQUIRED for table sources. ThrowsInvalidArgumentExceptionotherwise.->restNamespace()is REQUIRED. Same as CPT.- Pass the full prefixed table name — better-route does NOT add
$wpdb->prefix. Use$wpdb->prefix . 'myapp_events'to be multisite-safe. - Cross-database table names (containing
.) are rejected — security guard against accidental cross-DB writes. - Default sort is the primary key when
->sort([])is empty. Declare an explicit sort for chronological / business-meaningful ordering. cptVisibleStatuses/cptVisibilityPolicydon't apply. Tables have nopost_status. Implement soft-delete via a column + repository filter.deleteMode('trash')doesn't apply — there's no trash for custom tables.deleteMode('force')is the only valid value (the default).->allow([...])is the right way to make read-only resources — don't try to deny writes via empty policy callbacks; restrict at registration.
Common mistakes
// WRONG — no policy declared
Resource::make('audit')->sourceTable('wp_myapp_events')
->fields(['id', 'event_type'])
->register($router);
// → list / get / create / update / delete ALL return 403
// RIGHT — declare a policy
->policy(ResourcePolicy::adminOnly('manage_options'))
// WRONG — no fields declared
Resource::make('audit')->sourceTable('wp_myapp_events')
->policy(ResourcePolicy::adminOnly())
->register($router);
// → throws InvalidArgumentException: 'fields are required for sourceTable resources.'
// RIGHT
->fields(['id', 'event_type', 'user_id', 'created_at'])
// WRONG — hardcoded table name without prefix
->sourceTable('myapp_events') // WP looks for actual table 'myapp_events' (no wp_ prefix); usually doesn't exist
// RIGHT
global $wpdb;
->sourceTable($wpdb->prefix . 'myapp_events')
// WRONG — assuming CPT visibility methods work on tables
Resource::make('events')->sourceTable($wpdb->prefix . 'events')
->cptVisibleStatuses(['active']) // 🔴 method exists but does nothing for table-source
->register($router);
// RIGHT — implement soft-delete in the table itself
// Add a 'status' column, filter via filterSchema:
->fields(['id', 'name', 'status'])
->filters(['status'])
->filterSchema(['status' => ['type' => 'enum', 'enum' => ['values' => ['active']]]])
// Or use ->usingTableRepository(...) with a custom repo that always filters status='active' on reads.
// WRONG — cross-database table reference
->sourceTable('other_db.wp_myapp_events') // 🔴 rejected by guard
// RIGHT — same database
->sourceTable($wpdb->prefix . 'myapp_events')
// WRONG — empty sort + numeric primary key on a chronological log
->sourceTable($wpdb->prefix . 'audit_log')
->fields(['id', 'created_at', 'event_type'])
// Default sort is 'id' — fine for primary-key fetch, but a list scroll feels random.
// RIGHT
->sort(['-created_at', 'id']) // newest-first; id as tiebreaker
// WRONG — hoping deleteMode('trash') gives soft-delete on tables
->deleteMode('trash') // 🔴 throws or no-ops; trash is CPT-only
// RIGHT — soft-delete via column + repository filter
// Add 'deleted_at' nullable column, filter rows where deleted_at IS NULL, set deleted_at on DELETE.
// Implement via a custom TableRepository.
// WRONG — assuming the resource introspects the table schema for fields
->sourceTable('wp_myapp_events')
// (no fields() call)
// Hopes resource discovers columns via DESCRIBE — it does not. Throws.
// RIGHT — explicit fields list
->fields(['id', 'event_type', 'user_id', 'payload', 'created_at'])
Cross-references
- Run
br-resource-cptfor the CPT-source equivalent — same fluent builder, different default-permission semantics. - Run
br-resource-policyfor the policy preset reference —adminOnly,publicReadPrivateWrite,capabilities,callbacks. - Run
br-write-schemafor the validation rule catalog used in->writeSchema(...). - Run
br-install-and-migrateif you're upgrading from usingTableRepository(...)` with a custom repo. - Row-level access control (e.g. "user X can only see rows where
user_id = X"). Use->fieldPolicyor, for whole-row gating, apolicy(ResourcePolicy::callbacks(...))that reads the request and rejects. - Bulk operations / batch writes. Resources are single-row; bulk is a consumer concern.
- Real-time subscriptions / SSE / WebSocket. Out of scope.
References
- Resource builder: [libraries/better-route/src/Resource/Resource.php:95-100](Resource.php) —
sourceTable(string $table, string $primaryKey = 'id'). - Required-field guard: [Resource.php:512-517](Resource.php) —
if ($fields === []) throw new InvalidArgumentException('fields are required for sourceTable resources.'). - Default permission for table source: [Resource.php:835-844](Resource.php) —
defaultPermissionForActionreturnsfalsefor table-source on every action. - Default sort fallback: [Resource.php:524](Resource.php) —
allowedSort: $this->sort !== [] ? $this->sort : [$primaryKey]. - Table dispatcher: [libraries/better-route/src/Resource/Table/](Table/) —
WordPressTableRepository,TableListQuery,TableListQueryParser. - ResourcePolicy presets: [libraries/better-route/src/Resource/ResourcePolicy.php](ResourcePolicy.php).
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.