Skip to content

Custom Validation


Overview

This plugin is designed to centrally define structural constraints — such as field types, formats, and character limits — on the PHP side, and share them with the client via JSON Schema.

That said, real-world forms sometimes require "principled validation" that is locale- or environment-specific (e.g., verifying that a phone number belongs to a valid numbering plan). Because such constraints are difficult to express in JSON Schema, the plugin provides SV::custom(callable) as the primary dependency-free escape hatch for injecting arbitrary validation logic. For projects that already use the Respect/Validation library, RespectRules::rule() is also available as an optional alternative.


What Is "Principled Validation"?

JSON Schema (draft 2020-12) is a specification for describing the structure and format of fields; it cannot express every validation rule.

For example, the following constraints cannot be represented with keywords like type, length, or pattern:

  • Whether a phone number belongs to a real numbering plan (per-country number plan validation)
  • Whether a credit card number satisfies the Luhn algorithm
  • Whether an IBAN is consistent with its country code
  • Whether a password has sufficient strength

These are not "string format checks" but rather validations based on domain-specific rules or external databases. While a regular expression or a JSON Schema keyword can approximate them, a complete representation is fundamentally impossible.

In this plugin, such constraints are wrapped with SV::custom() or RespectRules::rule() and recorded in the x-unmapped-fields extension of the JSON Schema output.

text
SV::custom($predicate)                       [PRIMARY - dependency-free]

  ├─ Server side: validated with the callable predicate

  └─ JSON Schema: recorded in x-unmapped-fields (not included in properties)

       └─ Client side: add custom validation via @uuki/schemable-validator-client / Zod

RespectRules::rule($rule)                    [requires Respect/Validation]

  ├─ Server side: validated with Respect/Validation

  └─ JSON Schema: recorded in x-unmapped-fields (not included in properties)

       └─ Client side: add custom validation via @uuki/schemable-validator-client / Zod

Integration Patterns with External Libraries

When implementing constraints that cannot be expressed in JSON Schema, choose appropriate libraries for both the backend and frontend, and connect them through the plugin's escape hatches.

PHP Side (Server)

Primary: SV::custom(callable, message) (dependency-free)

SV::custom() accepts a callable predicate that returns bool. No external dependencies are required.

php
SV::custom(
  fn(mixed $value): bool => someExternalLibrary::validate($value),
  'Validation failed'
)

Alternative: RespectRules::rule(rule) (requires respect/validation)

RespectRules::rule() accepts a Respect/Validation Validator instance. Using v::callback(), you can inject any logic or external library.

php
use Respect\Validation\Validator as v;

RespectRules::rule(
  v::callback(function (mixed $value): bool {
    // Write any validation logic here
    return someExternalLibrary::validate($value);
  })
)

The server is always the authoritative validator. Client-side validation is treated purely as a UX aid.

JS Side (Client)

Fields listed in x-unmapped-fields are automatically skipped by validateObject. If you need client-side validation for them, add it via Constraint from @uuki/schemable-validator-client or Zod's .superRefine().

Using @uuki/schemable-validator-client:

typescript
import { type Constraint } from '@uuki/schemable-validator-client'

const checkCustomField: Constraint = (state) => {
  if (state.value === '') return state // pass through empty input for optional fields
  const ok = someJsLibrary.validate(state.value)
  return ok ? state : { ...state, errors: [...state.errors, 'Error message'] }
}

Using Zod:

typescript
const schema = buildZodSchema(jsonSchema).extend({
  fieldName: z.string().optional().superRefine((val, ctx) => {
    if (!val) return
    if (!someJsLibrary.validate(val)) {
      ctx.addIssue({ code: 'custom', message: 'Error message' })
    }
  }),
})

Applicable Use Cases

Use casePHP libraryJS libraryJSON Schema
Phone number (E.164 / per-country)giggsey/libphonenumber-for-phplibphonenumber-jsUNMAPPABLE
IBAN / bank account numberglobalcitizen/php-ibanibantoolsUNMAPPABLE
Credit card (Luhn)RespectRules::creditCard()Custom Luhn implementationUNMAPPABLE
Postal code (per-country)RespectRules::postalCode()postal-codes-jsApproximable with pattern
Password strengthCustom callbackzxcvbnUNMAPPABLE

Use Case: Phone Number Validation

Phone numbers are a canonical example where regex approximations fall short. libphonenumber (by Google) maintains a database of per-country numbering plans based on ITU-T E.164 and can accurately validate real number ranges.

PHP Side - giggsey/libphonenumber-for-php

Installation

bash
composer require giggsey/libphonenumber-for-php

Primary: Using SV::custom()

php
use SchemableValidator\SV;
use libphonenumber\PhoneNumberUtil;
use libphonenumber\NumberParseException;

/**
 * A libphonenumber-based phone number validator with a region code.
 * When $region = null, E.164 format (+81...) is required.
 */
function makePhonePredicate(string $region = null): callable {
  $util = PhoneNumberUtil::getInstance();

  return function (mixed $value) use ($util, $region): bool {
    if (!is_string($value) || $value === '') {
      return false;
    }
    try {
      $number = $util->parse($value, $region);
      return $region !== null
        ? $util->isValidNumberForRegion($number, $region)
        : $util->isValidNumber($number);
    } catch (NumberParseException) {
      return false;
    }
  };
}

Integrating into SchemaBuilder (SV::custom)

php
use SchemableValidator\SV;

$schema = SV::object([
  'name'  => SV::string()->min(1)->max(100),
  'email' => SV::string()->email(),
  'tel'   => SV::custom(makePhonePredicate('JP'), 'Please enter a valid phone number')->optional(),
]);

Alternative: Using RespectRules::rule()

php
use Respect\Validation\Validator as v;
use libphonenumber\PhoneNumberUtil;
use libphonenumber\NumberParseException;

function makePhoneRule(string $region = null): \Respect\Validation\Validator {
  $util = PhoneNumberUtil::getInstance();

  return v::callback(function (mixed $value) use ($util, $region): bool {
    if (!is_string($value) || $value === '') {
      return false;
    }
    try {
      $number = $util->parse($value, $region);
      return $region !== null
        ? $util->isValidNumberForRegion($number, $region)
        : $util->isValidNumber($number);
    } catch (NumberParseException) {
      return false;
    }
  });
}

$schema = SV::object([
  'name'  => SV::string()->min(1)->max(100),
  'email' => SV::string()->email(),
  'tel'   => RespectRules::rule(makePhoneRule('JP'))->optional(),
]);

JSON Schema Output

When toJson() is called, tel appears in x-unmapped-fields (not in properties).

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name":  { "type": "string", "minLength": 1, "maxLength": 100 },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "email"],
  "x-unmapped-fields": ["tel"]
}

JS Side (@uuki/schemable-validator-client) - libphonenumber-js

The Constraint in @uuki/schemable-validator-client is a pure function of FieldState → FieldState. You can wrap an external library directly.

Installation

bash
npm install libphonenumber-js

Implementing a Custom Constraint

typescript
import { isValidPhoneNumber } from 'libphonenumber-js'
import { type Constraint } from '@uuki/schemable-validator-client'

export const checkJapanesePhone: Constraint = (state) => {
  if (state.value === '') return state // pass through empty input for optional fields

  return isValidPhoneNumber(state.value, 'JP')
    ? state
    : { ...state, errors: [...state.errors, 'Please enter a valid Japanese phone number'] }
}

Composing with validateObject

typescript
import { validateObject } from '@uuki/schemable-validator-client'
import { checkJapanesePhone } from './constraints/phone'

async function validate(data: Record<string, string>, jsonSchema: ObjectSchema) {
  const result = { ...validateObject(data, jsonSchema) }

  // Additional validation for fields in x-unmapped-fields
  if ((jsonSchema['x-unmapped-fields'] ?? []).includes('tel')) {
    const state = checkJapanesePhone({ value: data['tel'] ?? '', errors: [] })
    result['tel'] = {
      value:    state.value,
      is_valid: state.errors.length === 0,
      errors:   state.errors.length > 0 ? state.errors : null,
    }
  }

  return result
}

JS Side (Zod) - libphonenumber-js

typescript
import { z } from 'zod'
import { isValidPhoneNumber } from 'libphonenumber-js'

const contactSchema = buildZodSchema(jsonSchema).extend({
  tel: z.string().optional().superRefine((val, ctx) => {
    if (!val) return
    if (!isValidPhoneNumber(val, 'JP')) {
      ctx.addIssue({
        code: 'custom',
        message: 'Please enter a valid Japanese phone number',
      })
    }
  }),
})