Skip to content
Back to skills

Br Resource Table

ASecurity

Create CRUD endpoints over a custom database table via

  • 22 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added June 5, 2026
databasesgophpgitapidatabasesecurity

Works with

  • api

Security analysis

A100/100

Scanned June 5, 2026

npx -y skills add Lonsdale201/wp-agent-skills --skill br-resource-table --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Br Resource Table?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Br Resource Table
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lonsdale201-br-resource-table/badge)](https://www.skillsdirectory.com/skills/lonsdale201-br-resource-table)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: br-resource-table
description: Create CRUD endpoints over a custom database table via
  better-route's Resource API — Resource::make('audit')->sourceTable(
  'wp_myapp_events')->fields([...])->policy(...)->register($router).
  Critical v0.3.0 rule — custom-table resources are deny-by-default
  for ALL actions including list and get (CPT sources still allow
  reads, but raw tables don't have WP's visibility model). Without an
  explicit ->policy() declaration the resource returns 403 on every
  call. fields() is also REQUIRED — throws InvalidArgumentException
  if empty (CPT sources have implicit fields from post columns; tables
  do not). Other Resource builder methods carry over from CPT —
  filters, sort, writeSchema, fieldPolicy, deleteMode, defaultPerPage.
  Use when generating CRUD over a custom DB table. Triggers on
  Resource::make + ->sourceTable, "REST CRUD over custom table" with
  better-route, "$wpdb->prefix . 'myapp_*'".
author: Soczó Kristóf
contact: mailto:lonsdale201@hotmail.com
plugin: better-route
plugin-version-tested: "0.4.0"
php-min: "8.1"
last-updated: "2026-04-29"
docs:
  - https://lonsdale201.github.io/better-docs/docs/better-route/agents
source-refs:
  - src/Resource/Resource.php
  - src/Resource/ResourcePolicy.php
  - src/Resource/Table/WordPressTableRepository.php
  - src/Resource/Table/TableRepositoryInterface.php
  - src/Resource/Table/TableListQuery.php
  - src/Resource/Table/TableListQueryParser.php
---

# 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:

```php
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:

```php
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 explicit `fields()`. 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 `cptVisibleStatuses` for soft-delete." Wrong — `cptVisibleStatuses` and `cptVisibilityPolicy` are CPT-only. Custom tables have no `post_status` concept. For soft-delete on a custom table, model it explicitly: add a `deleted_at` column, 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 underlying `WpdbIdempotencyStore` and 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

```php
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`         — list
- `GET    /myapp/v1/audit_events/{id}`    — get one
- `POST   /myapp/v1/audit_events`         — create
- `PUT    /myapp/v1/audit_events/{id}`    — update
- `PATCH  /myapp/v1/audit_events/{id}`    — partial update
- `DELETE /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:

```php
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

```php
->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

```php
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:

```php
->sort(['-created_at', 'id'])   // newest-first by default
```

### 6. Pagination

```php
->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:

```json
{
  "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

```php
->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

```php
->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.** Throws `InvalidArgumentException` otherwise.
- **`->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` / `cptVisibilityPolicy` don't apply.** Tables have no `post_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

```php
// 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-cpt`** for the CPT-source equivalent — same fluent builder, different default-permission semantics.
- Run **`br-resource-policy`** for the policy preset reference — `adminOnly`, `publicReadPrivateWrite`, `capabilities`, `callbacks`.
- Run **`br-write-schema`** for the validation rule catalog used in `->writeSchema(...)`.
- Run **`br-install-and-migrate`** if you're upgrading from <v0.3.0 — the deny-by-default rule for tables landed in v0.3.0.

## What this skill does NOT cover

- DB schema migration. Better-route assumes the table exists; create it via plugin activation hooks (see `wp-plugin-lifecycle` skill).
- `dbDelta` calls / table creation syntax. WP-level concern.
- Multi-table joins (one Resource exposing data from N tables). Use `->usingTableRepository(...)` with a custom repo.
- Row-level access control (e.g. "user X can only see rows where `user_id = X`"). Use `->fieldPolicy` or, for whole-row gating, a `policy(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) — `defaultPermissionForAction` returns `false` for 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).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.