# Bd Validation Rule

> Add a new validation rule to better-data — implement

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-bd-validation-rule`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [Lonsdale201](https://agentstack.voostack.com/s/lonsdale201)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** https://github.com/Lonsdale201/wp-agent-skills/tree/main/better-data/bd-validation-rule

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-bd-validation-rule
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# better-data: Adding a validation rule

For library maintainers introducing a new built-in validation rule (`Rule\CreditCard`, `Rule\PhoneE164`, `Rule\StrongPassword`, etc.). Rules are tiny, pure, attribute-decorated classes that the validator iterates per field. The contract is small but precise — getting it wrong (throwing instead of returning, applying to nulls, embedding side effects) breaks compositional rules and surfaces messy errors to consumers.

## Misconception this skill corrects

> "I'll throw an exception with the validation error message inside `check()` — easier than a return-string protocol."

The contract at [src/Validation/Rule.php:25-28](Rule.php) is:

```php
interface Rule
{
    public function check(mixed $value, string $fieldName, DataObject $subject): ?string;
}
```

`null` = pass, short error string = fail. Throwing is the engine's prerogative, not an individual rule's. The `BuiltInValidator` ([src/Validation/BuiltInValidator.php](BuiltInValidator.php)) iterates rules across many fields and accumulates errors; an exception would short-circuit the entire validation pass and return a single error instead of the complete failure list — which is what `ValidationResult` is for.

Other AI-prone misconceptions:

- "Rule\Foo extends Rule\Email." Wrong — rules don't compose via inheritance; PHP's attribute reflection looks up exact class names. Add a new rule.
- "The interface is RuleInterface." Wrong — it's literally `Rule`. The AGENTS.md docs in older drafts referred to it as `RuleInterface`, but the actual file is `src/Validation/Rule.php` and the interface is `Rule`. Use that name.
- "Rules apply to null values too — I want to validate that `?Email $email = null` is non-null." Wrong — convention is `null` means "skip"; if you want non-null, add `#[Rule\Required]` *also*. Single responsibility.

## When to use this skill

Trigger when ANY of the following is true:

- Creating a new file under `src/Validation/Rule/`.
- The diff adds `implements Rule` (or implements the deprecated `RuleInterface`).
- Adding a new `#[Rule\Foo]` attribute to a DTO and you can't find `Foo` in `src/Validation/Rule/`.
- Reviewing a PR that throws inside a `check()` method — flag and convert to return-string.

## Workflow

### 1. File layout

```
src/Validation/Rule/CreditCard.php
tests/Unit/Validation/Rule/CreditCardTest.php  ← optional, or co-located
tests/Unit/ValidationTest.php                   ← shared rule tests
```

### 2. Class shape (use Required.php as the template)

```php
allowTestNumbers && self::isTestCard($digits)) {
            return 'test card numbers are not accepted';
        }

        return null;
    }

    private static function luhnPasses(string $digits): bool { /* ... */ }
    private static function isTestCard(string $digits): bool { /* ... */ }
}
```

Five structural rules:

1. **`final readonly class`** — same immutability the rest of the lib enforces.
2. **`implements Rule`** — the interface from `BetterData\Validation`. Not `RuleInterface`, not your own.
3. **`#[Attribute(...)] | IS_REPEATABLE`** — repeatable so a single field can carry both `#[Rule\Required]` and `#[Rule\Email]`.
4. **Constructor-promoted public properties for parameters** (`allowTestNumbers` here). Same data-carrier shape as other attributes.
5. **`check()` returns `?string`** — null on pass, short message on fail. Messages are short, lowercase-by-convention, framework-agnostic; consumers wrap them in localized strings if needed.

### 3. Null handling (the convention)

Look at the difference between [Required.php:14-20](Required.php) and [Email.php:14-20](Email.php):

```php
// Required: null is the failure case
if ($value === null) {
    return 'is required';
}

// Email (and every other rule): null is "skip"
if ($value === null) {
    return null;
}
```

Reason: a `?Email $email = null` field with `#[Rule\Email]` is legitimately "no email yet". If `Email` rejected null, you'd be forced to make the field non-nullable. Only `Required` treats null as failure — every other rule treats null as "not my concern; pair me with `Required` if you want presence enforcement".

Your new rule MUST follow this. Skip null first.

### 4. Type-narrow before checking

Most rules want a string / int / array. Narrow early and return a type-mismatch message:

```php
if (!\is_string($value)) {
    return 'must be a string';  // not "is not a string" — keep the verb tone consistent
}
```

This avoids `TypeError` deep inside the check logic if a DTO author somehow lands a non-string in a `Rule\Email`-decorated field.

### 5. Cross-field rules use `$subject`

The third argument is the full DataObject snapshot at validation time:

```php
final readonly class MatchesField implements Rule
{
    public function __construct(public string $other) {}

    public function check(mixed $value, string $fieldName, DataObject $subject): ?string
    {
        $snapshot = $subject->toArray();
        if (!isset($snapshot[$this->other])) {
            return "matches field '{$this->other}' which is missing";
        }
        if ($snapshot[$this->other] !== $value) {
            return "must match '{$this->other}'";
        }
        return null;
    }
}
```

Use `$subject->toArray()` not direct property access — `Secret` and other rich types appear as their canonical array form there.

### 6. Surface in JSON Schema (when applicable)

If the rule maps to a JSON Schema constraint, add a case in `RestSchemaBuilder::applyRuleAttribute` ([src/Internal/RestSchemaBuilder.php:220-260](RestSchemaBuilder.php)):

```php
// Inside the switch on $name:
case CreditCard::class:
    $schema['format'] = 'credit-card';  // or pattern, depending on convention
    break;
```

The pattern for built-ins:

| Rule | JSON Schema key |
|---|---|
| `Email` | `format: 'email'` |
| `Url` | `format: 'uri'` |
| `Uuid` | `format: 'uuid'` |
| `MinLength(n)` | `minLength: n` |
| `MaxLength(n)` | `maxLength: n` |
| `Min(n)` | `minimum: n` |
| `Max(n)` | `maximum: n` |
| `Regex(pattern)` | `pattern: ` |
| `OneOf(values)` | `enum: [values]` |

If your rule has no schema equivalent (e.g. `Callback` runs arbitrary PHP), don't add a case — `applyRuleAttribute` will pass through.

### 7. Unit tests cover four paths

`tests/Unit/ValidationTest.php` (or co-located `tests/Unit/Validation/Rule/CreditCardTest.php`) MUST cover:

```php
public function test_it_passes_a_valid_value(): void { /* check() returns null */ }
public function test_it_fails_an_explicit_invalid_value(): void { /* check() returns string */ }
public function test_it_skips_null(): void { /* unless this IS Rule\Required */ }
public function test_it_handles_the_edge_case_implied_by_its_name(): void
{
    // For Email: a string that's almost an email
    // For Min(0): exact-zero (boundary)
    // For Required: empty string + empty array (both fail per the impl)
}
```

Run:

```bash
vendor/bin/phpunit --filter CreditCardTest
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
```

## Critical rules

- **`implements Rule`** — interface name is `Rule`, file `src/Validation/Rule.php`. Not `RuleInterface`.
- **`check()` returns `?string`.** Throwing breaks `BuiltInValidator`'s accumulation pass.
- **Skip null first** (except in `Required`). Convention: rules treat null as "not applicable" so they compose with nullable fields.
- **`final readonly class` with `IS_REPEATABLE`.** A single field commonly carries multiple rules.
- **No runtime configuration in rules.** No environment reads, no global lookups, no WP function calls. Rules are pure and testable without WP.
- **Short error messages, framework-agnostic tone.** "must not be blank", "must be a valid email address", "must match 'passwordConfirmation'". Consumer code localizes.
- **Add a JSON Schema mapping if applicable.** Rules without schema equivalents are fine; partial mapping creates surprise.
- **Cover four test paths**: pass, fail, null handling, edge case named in the rule.

## Common mistakes

```php
// WRONG — throwing instead of returning
public function check(mixed $value, string $fieldName, DataObject $subject): ?string
{
    if (!is_email($value)) {
        throw new \InvalidArgumentException('not an email');  // breaks BuiltInValidator
    }
    return null;
}

// RIGHT
return is_email($value) ? null : 'must be a valid email address';

// WRONG — applies to nulls
public function check(mixed $value, string $fieldName, DataObject $subject): ?string
{
    if ($value === null) {
        return 'must be set';  // if you want presence, the user adds #[Required], not in your rule
    }
    // ...
}

// RIGHT — null is skip
if ($value === null) {
    return null;
}

// WRONG — environment reads in rules
public function check(mixed $value, string $fieldName, DataObject $subject): ?string
{
    $strict = (bool) ($_ENV['STRICT_VALIDATION'] ?? false);  // 🔴 rule is no longer pure
    return $strict ? $this->strictCheck($value) : $this->lenientCheck($value);
}

// RIGHT — make it a constructor parameter
public function __construct(public bool $strict = false) {}

// WRONG — long error message that mixes localization concerns
return 'A megadott érték nem érvényes hitelkártyaszám, kérjük adjon meg egy 13-19 számjegyből álló kártyaszámot.';
// Rules emit short framework-agnostic strings. Localization happens in the consumer (admin UI,
// REST response middleware) which can swap to the user's language.

// RIGHT
return 'must be a valid card number';

// WRONG — implementing the wrong interface name
final readonly class MyRule implements RuleInterface { /* ... */ }
// Class doesn't exist. The interface is BetterData\Validation\Rule.

// RIGHT
final readonly class MyRule implements Rule { /* ... */ }

// WRONG — partial schema mapping
case CreditCard::class:
    // (forgot to set anything on $schema)
    break;
// Result: rule runs at validation time but disappears from REST schema — consumer apps don't
// know the constraint is there.

// RIGHT
case CreditCard::class:
    $schema['format'] = 'credit-card';
    break;
```

## Cross-references

- Run **`bd-attribute`** when adding a NEW non-rule attribute. Rules ARE attributes too, but live under `src/Validation/Rule/` and follow this skill's contract.
- Run **`bd-data-object`** when the new rule is being applied to a new DTO field — the DTO design and the rule design often co-evolve.
- Run **`bd-better-route-bridge`** when the rule should appear in REST response error formatting — the bridge wraps `ValidationResult` for HTTP error envelopes.

## What this skill does NOT cover

- Validation result formatting / localization. `BuiltInValidator` accumulates `ValidationResult`; UI / API layers translate.
- Async / I/O-bound validation (e.g. "is this email already in the DB?"). Rules are sync and pure; do that work in a controller layer.
- Conditional validation ("only validate when other field is X"). Use `Rule\Callback` or build a domain-specific rule that reads `$subject->toArray()`.
- Validation engine internals — replacing `BuiltInValidator` with a custom engine is out of scope.
- Internationalization of rule messages. Library messages stay English; consumers translate via the field-name + message pair.

## References

- Rule interface: [libraries/better-data/src/Validation/Rule.php:25-28](Rule.php) — `check(mixed, string, DataObject): ?string`. Note: interface name is `Rule`, NOT `RuleInterface`.
- Reference rule (null-as-fail): [libraries/better-data/src/Validation/Rule/Required.php:14-30](Required.php).
- Reference rule (null-as-skip): [libraries/better-data/src/Validation/Rule/Email.php:14-25](Email.php).
- Built-in rule directory: [libraries/better-data/src/Validation/Rule/](Rule/) — `Required`, `Email`, `Url`, `Uuid`, `Min`, `Max`, `MinLength`, `MaxLength`, `Regex`, `OneOf`, `Callback` as templates.
- Schema mapping: [libraries/better-data/src/Internal/RestSchemaBuilder.php:220-260](RestSchemaBuilder.php) — `applyRuleAttribute` switch.
- Validator: [libraries/better-data/src/Validation/BuiltInValidator.php](BuiltInValidator.php) — accumulates `ValidationResult` across rules.

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lonsdale201-wp-agent-skills-bd-validation-rule
- Seller: https://agentstack.voostack.com/s/lonsdale201
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
