Skip to content
Back to skills

Br Write Schema

ASecurity

Validate POST / PUT / PATCH bodies on a better-route

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

Works with

  • cli
  • api

Security analysis

A100/100

Scanned June 5, 2026

npx -y skills add Lonsdale201/wp-agent-skills --skill br-write-schema --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Br Write Schema?

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

Security grade badge for Br Write Schema
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lonsdale201-br-write-schema/badge)](https://www.skillsdirectory.com/skills/lonsdale201-br-write-schema)

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-write-schema
description: Validate POST / PUT / PATCH bodies on a better-route
  Resource via ->writeSchema([...]) (alias ->payloadSchema([...])).
  Maps each field to a rule array with type / required / nullable /
  min / max / minLength / maxLength / regex / enum-values / sanitize.
  Important — the source-verified shape for enum is {type: 'enum',
  'values': [...]} (FLAT), NOT {type: 'enum', 'enum': {'values': [...]}}
  (the agents.md doc nests it). Verified at src/Resource/Resource.php:
  1366 — assertValueConstraints reads $rule['values'] directly. Other
  shape rules — sanitize values are 'text'|'email'|'key'|'url'|callable;
  type values are int|integer / float|number / bool|boolean / string /
  date / email / url / enum / array / object / mixed (the integer /
  number / boolean variants are aliases). Validation failures throw
  ApiException with code validation_failed and details.fieldErrors.
  Use when adding payload validation to a Resource. Triggers on
  ->writeSchema(, ->payloadSchema(, fieldErrors, validation_failed.
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/Http/ApiException.php
---

# better-route: Resource write-schema validation

For developers wiring payload validation onto a `Resource::make(...)` POST / PUT / PATCH endpoint via `->writeSchema([...])`. Each field gets a rule shape that drives type coercion, sanitization, and constraint checks before the handler runs. Failures return a structured `validation_failed` error envelope with per-field error lists.

## Misconception this skill corrects

> "For an enum field I'll write `['type' => 'enum', 'enum' => ['values' => ['draft', 'publish']]]` per the docs."

The agents.md doc shows that nested shape, but the source-verified shape is FLAT:

```php
// WRONG (per agents.md, but not what source reads)
'status' => ['type' => 'enum', 'enum' => ['values' => ['draft', 'publish']]]

// RIGHT (source-verified at src/Resource/Resource.php:1366)
'status' => ['type' => 'enum', 'values' => ['draft', 'publish']]
```

Verified at [Resource.php:1364-1369](Resource.php):

```php
if (($rule['type'] ?? null) === 'enum') {
    $values = $rule['values'] ?? [];
    if (!is_array($values) || !in_array($value, $values, true)) {
        throw $this->validationError([$field => ['must be one of the allowed values']]);
    }
}
```

The validator looks for `$rule['values']` directly on the rule array. A nested `'enum' => ['values' => [...]]` block is ignored, so every value falls through to the rejection branch — every request fails as "must be one of the allowed values" with an empty allowlist.

Other AI-prone misconceptions:

- "`writeSchema` and `payloadSchema` do different things." Wrong — `payloadSchema()` ([Resource.php:159](Resource.php)) is just an alias for `writeSchema()` ([Resource.php:150](Resource.php)). Same in-memory storage, same validator.
- "I'll `'sanitize' => 'sanitize_text_field'` and pass the WP function name." Wrong — `sanitize` accepts the strings `'text'`, `'email'`, `'key'`, `'url'`, OR a callable. A function name string that isn't one of the four passes through unchanged because the match defaults to `$value`. Pass an actual callable: `'sanitize' => 'sanitize_text_field'` works because PHP treats the function-name string as callable in `is_callable()`.
- "`'type' => 'integer'` and `'type' => 'int'` are different." Wrong — both alias to the same `coerceInt`. Same for `'float'`/`'number'`, `'bool'`/`'boolean'`. Pick one and stay consistent.

## When to use this skill

Trigger when ANY of the following is true:

- The diff calls `->writeSchema([...])` or `->payloadSchema([...])` on a Resource.
- A consumer reports `validation_failed` errors with cryptic `fieldErrors`.
- Reviewing a PR that hand-rolls payload validation inside a handler instead of using `writeSchema`.
- Setting up CRUD endpoints over a CPT or table where the payload needs constraints.

## Workflow

### 1. Minimal write schema

```php
Resource::make('books')
    ->sourceCpt('book')
    ->writeSchema([
        'title'   => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 200],
        'isbn'    => ['type' => 'string', 'regex' => '/^[0-9]{10,13}$/'],
        'price'   => ['type' => 'float', 'min' => 0],
        'status'  => ['type' => 'enum', 'values' => ['draft', 'publish'], 'required' => true],
        'website' => ['type' => 'url', 'nullable' => true],
    ])
    ->register($router);
```

The validator runs before the handler. On success, the coerced + sanitized values reach the handler. On failure, the route returns:

```json
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "validation_failed",
    "message": "Invalid request.",
    "requestId": "req_abc123",
    "details": {
      "fieldErrors": {
        "title":  ["is too short"],
        "price":  ["is too small"],
        "status": ["must be one of the allowed values"]
      }
    }
  }
}
```

### 2. Type catalog (verified)

| `type` | What it accepts | Coercion |
|---|---|---|
| `'int'` / `'integer'` | int OR string matching `/^-?\d+$/` | `(int) $value` |
| `'float'` / `'number'` | float, int, or numeric string | `(float) $value` |
| `'bool'` / `'boolean'` | bool, `0`/`1`, `'1'`/`'true'`/`'yes'`/`'0'`/`'false'`/`'no'` | normalized to bool |
| `'string'` | string, int, float, bool | `(string) $value` |
| `'date'` | string (no parsing — same as string here) | string-coerce |
| `'email'` | string passing `FILTER_VALIDATE_EMAIL` | string-coerce + email format check |
| `'url'` | string passing `FILTER_VALIDATE_URL` | string-coerce + URL format check |
| `'enum'` | value present in `$rule['values']` | string-coerce + allowlist check |
| `'array'` | array | passthrough; rejects non-array |
| `'object'` | non-list array (associative) | passthrough; rejects list arrays |
| `'mixed'` | anything | passthrough |

Aliases (`'integer'` ↔ `'int'`, `'number'` ↔ `'float'`, `'boolean'` ↔ `'bool'`) are equivalent; pick one across the codebase.

### 3. Constraint catalog

| Key | Applies to | Behavior |
|---|---|---|
| `required: true` | All | Field must be present in the payload (create only — partial updates skip). |
| `nullable: true` | All | Explicit null is allowed; missing field still falls through to default. |
| `min: <number>` | int / float | Reject if value `<` min. |
| `max: <number>` | int / float | Reject if value `>` max. |
| `minLength: <int>` | string-shaped types | Reject if `strlen($value) <` minLength. |
| `maxLength: <int>` | string-shaped types | Reject if `strlen($value) >` maxLength. |
| `regex: '/pattern/'` | string-shaped types | Reject if `preg_match` returns 0. |
| `values: [...]` | enum | The allowed-value list. **FLAT shape, not nested under `'enum'`**. |

Numeric constraints (`min`, `max`) only apply to numeric types. Length/regex constraints only apply to strings. Cross-type constraints silently pass — `'minLength' => 3` on an int field does nothing.

### 4. Sanitize catalog

```php
'sanitize' => 'text'      // sanitize_text_field() if available, else trim(strip_tags(...))
'sanitize' => 'email'     // strtolower(trim(...))
'sanitize' => 'key'       // preg_replace('/[^A-Za-z0-9_-]/', '', $value)
'sanitize' => 'url'       // trim(...)
'sanitize' => callable    // your own: fn ($value, $field) => mixed
```

Sanitization runs AFTER coercion but BEFORE constraint checks. So `'sanitize' => 'text'` strips HTML tags from the input before `regex` runs — your regex sees the cleaned value.

### 5. Custom sanitizer callable

```php
->writeSchema([
    'tags' => [
        'type' => 'string',
        'sanitize' => static function (string $value, string $field): string {
            $tags = array_map('trim', explode(',', $value));
            return implode(',', array_filter($tags));
        },
    ],
])
```

The callable receives `($value, $field)` — the field name lets you reuse one sanitizer across multiple fields with field-specific behavior.

### 6. Combining required + nullable

```php
'status' => ['type' => 'enum', 'values' => ['draft', 'publish'], 'required' => true]
// → must be present, must be 'draft' or 'publish'

'website' => ['type' => 'url', 'nullable' => true]
// → may be omitted (default applies); if present, must be null OR a valid URL

'website' => ['type' => 'url', 'required' => true, 'nullable' => true]
// → must be present; may be the literal null OR a valid URL
```

`required` checks presence in the payload. `nullable` allows the explicit null value when the field IS present. They're orthogonal — combine when you want "must be set, may be null".

### 7. Per-action validation

By default, `required` is enforced on `create`. Updates and patches don't enforce required (allowing partial updates). If you need stricter behavior, declare a separate Resource with a different schema:

```php
Resource::make('books_strict')
    ->allow(['update'])
    ->writeSchema([
        'title' => ['type' => 'string', 'required' => true],
    ])
    ->register($router);
```

Or do per-field validation inside the handler for complex rules.

## Critical rules

- **Enum values shape is FLAT.** `['type' => 'enum', 'values' => [...]]`. NOT `['type' => 'enum', 'enum' => ['values' => [...]]]`. Verified at [Resource.php:1366](Resource.php).
- **`writeSchema` and `payloadSchema` are aliases.** Pick one, stay consistent.
- **`sanitize` is `'text'` | `'email'` | `'key'` | `'url'` | callable.** Other strings pass through unchanged (silent footgun).
- **Type aliases are equivalent.** `int`/`integer`, `float`/`number`, `bool`/`boolean`. Stay consistent across the codebase.
- **Email / URL / regex run AFTER sanitize.** Use sanitize to clean the input before format checks bite.
- **`required` checks presence; `nullable` allows null.** Orthogonal — combine for "must be set, may be null".
- **`required` defaults to `false`.** Don't omit it for create-mandatory fields.
- **Validation errors are `400 validation_failed`** with `details.fieldErrors`. Don't try to throw a custom 422 — better-route's contract is 400.
- **Cross-type constraints silently pass.** `minLength` on an int does nothing; the validator only applies length checks on strings.

## Common mistakes

```php
// WRONG — nested enum shape (from older docs draft)
'status' => ['type' => 'enum', 'enum' => ['values' => ['draft', 'publish']]]
// → every request fails: "must be one of the allowed values" because $rule['values'] is empty.

// RIGHT — flat enum shape
'status' => ['type' => 'enum', 'values' => ['draft', 'publish']]

// WRONG — sanitize with random WP function name string
'title' => ['type' => 'string', 'sanitize' => 'wp_kses_post']
// 🔴 'wp_kses_post' isn't 'text'/'email'/'key'/'url' AND it IS callable —
// PHP's is_callable() treats function-name strings as callable, so it actually works,
// but ONLY because the function exists at runtime.
// Cleaner: use the callable form explicitly:
'title' => ['type' => 'string', 'sanitize' => 'wp_kses_post']
// (Works, but the documentation surface for sanitize lists 'text'/'email'/'key'/'url'.)

// WRONG — mixing required + missing default behavior
'price' => ['type' => 'float', 'required' => true, 'min' => 0]
// Create with body {} → 400 validation_failed for missing 'price'. Correct.
// PATCH with body {} → no validation error (required isn't enforced on partial updates).

// RIGHT — handle the asymmetry intentionally
// Use Resource::make()->allow(['update', 'patch']) with a non-required schema for partial updates,
// and a separate ->allow(['create']) resource with required fields.

// WRONG — assuming 'date' validates date format
'created_at' => ['type' => 'date', 'required' => true]
// Source treats 'date' identically to 'string' — no format validation. The body {created_at: 'banana'} passes.

// RIGHT — combine type with regex
'created_at' => ['type' => 'string', 'regex' => '/^\d{4}-\d{2}-\d{2}/', 'required' => true]
// Or do format parsing in the handler.

// WRONG — using minLength on a numeric field
'price' => ['type' => 'float', 'minLength' => 1]
// 🔴 silently no-ops; minLength is for strings.

// RIGHT
'price' => ['type' => 'float', 'min' => 0.01]

// WRONG — null without nullable
'website' => ['type' => 'url']
// Body { "website": null } → 400 'must not be null'.

// RIGHT
'website' => ['type' => 'url', 'nullable' => true]

// WRONG — sanitize 'text' on an int field
'count' => ['type' => 'int', 'sanitize' => 'text']
// sanitize runs only when value is_string; int values bypass sanitization. Effectively no-op.

// RIGHT — match sanitize to type
'comment' => ['type' => 'string', 'sanitize' => 'text']
'count'   => ['type' => 'int']

// WRONG — assuming validator runs on read endpoints
->writeSchema([
    'name' => ['type' => 'string', 'required' => true],
])
// → enforced on POST/PUT/PATCH only. GET routes never invoke the schema.

// RIGHT — for read-side filter validation, use ->filterSchema(...) instead.
```

## Cross-references

- Run **`br-resource-cpt`** / **`br-resource-table`** for the Resource builder context — `writeSchema` is one of many fluent methods.
- Run **`br-error-contract`** for the full `validation_failed` envelope shape and how it composes with other 4xx errors.
- Run **`br-resource-policy`** for `fieldPolicy` (per-field write authorization) — pairs naturally with `writeSchema` (per-field validation).

## What this skill does NOT cover

- Cross-field validation ("password matches passwordConfirmation"). `writeSchema` is per-field; do cross-field in the handler or via a custom rule.
- Async validation ("is this email already taken in the DB"). `writeSchema` is sync and has no DB access. Do that check in the handler, throw `ApiException::conflict(...)`.
- Client-side validation. `writeSchema` is server-side only; mirror the rules in your client code if needed.
- Localization of error messages. Messages are framework-agnostic English; translate at the consumer layer.
- Complex shape validation (nested objects, arrays of objects). The validator handles primitives + flat arrays/objects; for deep nesting, validate manually in the handler or use a separate JSON Schema library.

## References

- writeSchema fluent method: [libraries/better-route/src/Resource/Resource.php:150-156](Resource.php) — `writeSchema(array $schema): self`.
- payloadSchema alias: [Resource.php:159-165](Resource.php) — `payloadSchema(array $schema): self` (same internal storage).
- Coercion entry: [Resource.php:1225-1260](Resource.php) — `coercePayloadValue(string $field, mixed $value)`.
- Type coercers: [Resource.php:1276-1320](Resource.php) — `coerceString`, `coerceInt`, `coerceFloat`, `coerceBool`.
- Sanitizer: [Resource.php:1332-1349](Resource.php) — `sanitizeValue` with the four string options + callable fallback.
- Constraint validator: [Resource.php:1362-1406](Resource.php) — `assertValueConstraints` — enum (line 1366), minLength/maxLength/regex (line 1373-1383), email/url filter (1385-1391), min/max numeric (1393-).
- Error helper: [Resource.php:1453-1460](Resource.php) — `validationError(array $fieldErrors): ApiException` — throws `ApiException` with code `validation_failed`, status 400, details `{fieldErrors: ...}`.

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…