Search by

karewan / kndata

Karewan

Strict and fast PHP 8.3+ data mapping and validation (JSON, forms, arrays) with TypeScript, Java, Kotlin and Swift type generation

Package info

github.com/Karewan/KnData

pkg:composer/karewan/kndata

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-10-07 14:52 UTC

This package is auto-updated.

Last update: 2026-10-07 14:53:30 UTC


README

Strict and fast PHP 8.3+ data mapping and validation (JSON, forms, arrays) with TypeScript, Java, Kotlin and Swift type generation.

  • One definition: a PHP class describes the data, its validation, its JSON form and the types of its clients.
  • Strict by design: exact conversions, every error at once with its path and a stable code, never the value.
  • Fast: each class is compiled to plain PHP, cached like the routes of KnRoute; mapping an 18-field object costs about 2.5 µs (JSON decoding aside).
  • No dependency.

Table of contents

Installation

PHP 8.3+ with the mbstring and tokenizer extensions.

composer require karewan/kndata

Quick start

declare(strict_types=1);

use Karewan\KnData\Attribute\NullIfInvalid;
use Karewan\KnData\Validation\{Latitude, Length, Round, Trim};

final readonly class NewDelivery
{
	/**
	 * @param list<int> $parcel_ids
	 */
	public function __construct(
		public int $order_id,
		public int $courier_id,
		public array $parcel_ids = [],
		#[Trim, Length(max: 50)] public ?string $recipient = null,
		#[NullIfInvalid, Round(6), Latitude] public ?float $lat = null,
		public ?Address $address = null,
		public DeliveryStatus $status = DeliveryStatus::Pending,
		public ?DateTimeImmutable $at = null,
	) {
	}
}
use Karewan\KnData\Error\InvalidDataException;
use Karewan\KnData\KnData;

KnData::configure(cacheDir: __DIR__ . '/Storage/cache/kndata', scanForModifiedFiles: $debug);

try {
	$delivery = KnData::fromJson($body, NewDelivery::class);
} catch (InvalidDataException $e) {
	// $.order_id: wrong_type (expected int, got string); $.parcel_ids[2]: wrong_type (expected int, got string)
	$logger->warning($e->getMessage());
	respond(400, ['errors' => $e->toArray()]);
}

$form = KnData::fromPost(PasswordForm::class);   // $_POST, form profile
$json = KnData::toJson($delivery);               // back to JSON, same keys

KnData is a static facade on a shared Mapper. Inject a Mapper instead when you prefer: it has the same methods.

Sources and profiles

The source says how the raw values are read. The rules and the classes are the same for every source.

Source Created with Profile
Decoded JSON, PHP array or object KnData::map($data, ...), Source::json($data) Json
JSON text KnData::fromJson($text, ...), Source::fromJson($text) Json
Exact JSON types Source::strict($data), or #[Strict] on a class or a field Strict
HTML form, query string KnData::fromPost(...), KnData::fromQuery(...), Source::form($array) Form
  • Json (default): numbers written as strings ("12", "4.5") and integral floats (3.0 for an int) are accepted; booleans must be true or false.
  • Strict: exact JSON types only.
  • Form: every scalar is a string. Numbers are converted with surrounding spaces ignored, booleans accept 1, true, on and 0, false, off (any case), and an empty string counts as absent. #[Strict] has no effect on a form.

fromJson() decodes JSON objects as PHP objects, so {} and [] keep their meaning.

Conversion rules

A string holding a number must follow the JSON grammar exactly: "12abc", " 12" (except in a form), "+12", "012", "0x1A" and "" are refused. Nothing is truncated or guessed.

PHP type Accepted Refused
int 12; "12" and 3.0 (not Strict); " 12 " (Form) 2.9 (wrong_type), 1e20 or "9223372036854775808" (out_of_range), "1e3", true
float 4.5, 4; "4.5", "1e-3" (not Strict) "4,5", ".5", "NaN", false
string any string, kept as it is numbers, booleans, arrays
bool true, false; "1", "true", "on", "0", "false", "off" (Form) "true" and 1 in JSON, "yes"
backed enum its value, converted like its backing type an unknown value (invalid_enum)
pure enum the name of the case, "Green" anything else
class an object or an associative array; [] as an empty object (not Strict); an instance of the class a list, a scalar
list<T> a JSON array, each item converted; any array in a form (name[]) a JSON object
array<string, T> a JSON object, or [] a non-empty JSON array
DateTimeImmutable RFC 3339 "2026-09-29T10:00:00+02:00" (or #[DateFormat]) "2026-02-30T10:00:00Z" and other formats (invalid_format)
mixed any JSON value, objects as associative arrays

Classes

A class is mapped through its constructor when it has parameters: every parameter is a field, promoted or not, and the constructor keeps its invariants. readonly classes are the natural fit. A class without constructor parameters is created with new and its public properties are set.

final class Filters                     // property mode
{
	public int $page = 1;

	/** @var list<string> */
	public array $tags = [];
}

An InvalidArgumentException thrown by the constructor becomes an invalid violation on the object, with the exception message as message.

Types

PHP Notes
int, float, string, bool
?T, T|null The only union supported
backed or pure enum
a class Any depth, maxDepth (64) guards recursion
array + @var list<T>, T[], array<T> A list
array + @var non-empty-list<T> A list with at least one item (too_few)
array + @var array<string, T> or array<int, T> A map; with int keys, a JSON array is accepted too
array + @var array<string, mixed>, mixed Free JSON, objects as associative arrays
DateTimeImmutable, DateTimeInterface, DateTime RFC 3339, or #[DateFormat('Y-m-d')], #[DateFormat('U')] for a timestamp

The item type of an array comes from the PHPDoc (@var on the property, @param on the constructor, the phpstan- and psalm- forms win), which PHPStan reads too. Short class names are resolved with the use imports of the file, aliases and group imports included. #[ListOf(Device::class)] and #[MapOf('int')] give it without PHPDoc.

A type KnData cannot handle is refused when the class is first used, with a DefinitionException that names the field: missing type, union type, array without item type, array{...} shape, interface, readonly property without constructor, rule on a type it does not support, invalid pattern... It is a programming error: answer it as a server error.

Required, optional and nullable fields

  • A field without default value is required: absent, it is a missing violation.
  • A field with a default value may be absent: the default applies.
  • Nullable is not optional: ?int $x must be present, even as null. Write ?int $x = null for an optional field.
  • null for a non-nullable field is null_not_allowed.
  • An unknown key is ignored, and reported as a warning by tryMap(). #[RejectUnknownKeys] makes it a violation.
  • Keys match exactly (no case folding).

Mapping attributes

In Karewan\KnData\Attribute.

Attribute Target Effect
#[Name('crossOrigin')] field Key in the data, read and written
#[Naming(KeyCase::Snake)] class Key of every field without #[Name]: Snake (fromUserId → from_user_id), Camel, Pascal, Kebab
#[Strict] class, field Exact JSON types
#[RejectUnknownKeys] class An unknown key is a violation
#[NullIfInvalid] nullable field An invalid value becomes null, with a warning: a malformed detail does not reject the object
#[DateFormat('Y-m-d')] date field Format read and written, 'U' for a Unix timestamp
#[Ignore] field Neither read nor written (a constructor parameter needs a default)
#[OmitNull] class, field A null value is left out of the serialized data
#[ListOf(T)], #[MapOf(T)] array field Item type without PHPDoc
#[Message('key')] field Message key or text for every violation of the field
#[Export(Direction::Input)] class, enum Generated types, see Type generation

Validation

Rules are attributes in Karewan\KnData\Validation, checked while mapping. They are typed objects: a rule on a type it does not apply to (#[Length] on an int), an invalid pattern or inverted bounds are refused when the class is read.

final readonly class PasswordChangeForm
{
	public function __construct(
		#[Length(max: 50)]
		public string $current,
		#[Length(min: 12, max: 50), Message('bad_new_password')]
		#[Regex('/[a-z]/', message: 'pwd_lower')]
		#[Regex('/[A-Z]/', message: 'pwd_upper')]
		#[Regex('/[0-9]/', message: 'pwd_number')]
		#[Regex('/[\p{P}\p{S}]/u', message: 'pwd_ssymb')]
		public string $new,
		#[SameAs('new')]
		public string $confirm,
	) {
	}
}

Order of processing

  1. The raw value is read from the source.
  2. Raw transforms apply (Trim, StripControlChars...).
  3. Presence: absent (or empty in a form) takes the default, or is missing.
  4. The value is converted to the type of the field.
  5. Typed transforms (Round) and constraints run in declaration order. The first constraint that fails stops the field: one violation per field.
  6. Comparisons between fields (SameAs), once every field is valid.
  7. The object is built, then the class-level #[Callback] rules run.

Transforms

Attribute Types Effect
#[Trim] string Removes surrounding spaces, Unicode spaces included
#[StripControlChars(keepNewlines: false)] string Removes tab, line breaks and the other control characters
#[CollapseWhitespace] string Each run of spaces and line breaks becomes one space
#[Lowercase], #[Uppercase] string Multibyte case
#[EmptyToNull] nullable "" becomes null
#[Round(6)] int, float Rounds to a number of decimals (negative: tens, hundreds), mode: PHP_ROUND_HALF_EVEN...

Constraints

Every constraint takes an optional message:.

Attribute Types Code
#[NotBlank] string blank
#[Length(min: 2, max: 50)] string (characters) too_short, too_long
#[Range(min: 1, max: 10)] int, float too_small, too_large
#[Positive], #[PositiveOrZero] int, float not_positive, negative
#[MultipleOf(0.5)] int, float not_multiple
#[MaxDecimals(6)] int, float too_many_decimals
#[Count(min: 1, max: 50)] list, map too_few, too_many
#[Unique] list of scalars or enums not_unique
#[Regex('/.../')], #[Regex('/.../', match: false)] (repeatable) string pattern_mismatch, forbidden_pattern
#[Letters], #[AlphaNum] (unicode: true), #[Hex], #[Digits] string invalid_chars
#[NoControlChars(allowNewlines: false)] string control_chars
#[Email] string invalid_email
#[Url(schemes: ['https'])] string invalid_url
#[Phone] (E.164, allowSpaces: false) string invalid_phone
#[Latitude], #[Longitude] int, float invalid_coordinate
#[Uuid] string invalid_uuid
#[Ip(version: 4)] string invalid_ip
#[Timezone] string invalid_timezone
#[DateRange(min: '2020-01-01', max: 'now')] date too_early, too_late
#[OneOf(['fr', 'en'])] scalars not_allowed
#[SameAs('password')] any not_same
#[Each(...)] list, map codes of its rules, at the path of the item
#[Callback(...)] (repeatable) any, class invalid or the code returned

Put a #[Length(max: ...)] before a #[Regex] on user input: some patterns are slow on long strings (kndata warmup warns about it). A pattern error never passes as a success.

Limit the decimals of a number

Round the value, for example a GPS coordinate to 6 decimals (about 11 cm), or refuse the extra decimals:

#[Round(6), Latitude] public ?float $lat = null,       // 45.76404312 -> 45.764043
#[MaxDecimals(2)] public float $price,                   // 12.345 -> too_many_decimals

Decimals are counted on the shortest writing of the number, so 0.1 + 0.2 has 17 and 1.5E-7 has 8.

Rules on the items of a list

/** @param list<string> $tags */
public function __construct(#[Count(max: 10), Each(new Trim(), new Length(max: 20))] public array $tags = [])

A refused item is reported at its path: $.tags[3]: too_long.

Comparison between fields

#[SameAs('password')] compares with another field (PHP name), strictly, once both are valid.

Custom rules

With a function, on a field or on a class:

#[Callback([Siret::class, 'check'])]         // static method, 'Siret::check' also works
public string $siret,

#[Callback([Period::class, 'ordered'])]      // on the class: receives the built object
final readonly class Period { ... }

The function returns true or null when valid; false, an error code, a Failure or a list of Failure when not.

Or as a class, reusable and typed:

use Karewan\KnData\Metadata\{Type, TypeKind};
use Karewan\KnData\Validation\{Constraint, Failure};

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final readonly class Siret implements Constraint
{
	public function __construct(public ?string $message = null)
	{
	}

	public function supports(Type $type): bool
	{
		return $type->kind === TypeKind::String;
	}

	public function check(mixed $value): ?Failure
	{
		return self::luhn($value) ? null : new Failure('invalid_siret', [], $this->message);
	}
}

Implement Transform for a custom transform. Give your rules promoted constructor properties: the cache files rebuild them with their constructor; others are reloaded through reflection.

Validate without a class

For a query string or a small form, declare the fields at the call. Same reading, same rules:

use Karewan\KnData\Source;
use Karewan\KnData\Validation\{Field, Length, Range, Trim};

$result = KnData::validate(Source::query(), [
	'page' => Field::int(new Range(min: 1))->default(1),
	'search' => Field::string(new Trim(), new Length(max: 100))->optional(),
	'sort' => Field::enum(Sort::class)->default(Sort::Name),
	'ids' => Field::listOf(Field::int(new Positive()))->optional(),
]);

$result->throwIfInvalid();
$page = $result->int('page');         // int|null; string(), float(), bool(), array(), get()

Modifiers: optional(), default($value), nullable(), nullIfInvalid(), strict(), message('key'), intKeys(). Types: int, float, string, bool, mixed, enum(Class), date(?format), object(Class), listOf(Field), mapOf(Field).

Errors

Every violation is collected (up to maxErrors, 50) before InvalidDataException is thrown.

final readonly class Violation
{
	public string $path;      // "$.position.lat", "$.items[2]", "$['odd key']"
	public string $code;      // ErrorCode value or custom code
	public array $params;     // ['max' => 50]
	public ?string $expected; // type errors: "int", "list<int>"
	public ?string $given;    // type errors: "string", "number", "boolean", "object", "array", "null"
	public ?string $message;  // from message: or #[Message]
}

A violation never holds the value: it can be logged or sent back to a client without leaking a password, a token or a position. Class names are shortened.

Code Meaning
missing Required field absent
null_not_allowed null for a non-nullable field
wrong_type Value of another type
out_of_range Number beyond the int range
invalid_enum Value outside the enum (params.allowed)
invalid_format Date in another format
unknown_key With #[RejectUnknownKeys]
too_deep Nesting beyond maxDepth
too_many_errors maxErrors reached
invalid_json fromJson() text is not JSON
invalid Constructor InvalidArgumentException, Callback returning false

InvalidDataException: getViolations(), getMessages(), toArray() (for a JSON answer), and an English getMessage() for the logs.

Warnings and tryMap

tryMap() never throws for refused data. Its Result holds the object or the violations, and the warnings: unknown keys, values replaced by #[NullIfInvalid]. Watch them before making a contract stricter.

$result = KnData::tryMap($data, NewDelivery::class);
foreach ($result->getWarnings() as $warning) {
	$logger->info((string)$warning);  // $.lat: nulled_invalid (code 'wrong_type')
}
$delivery = $result->getValue();       // throws InvalidDataException when refused

Messages

getMessages() gives one message per violation through the message resolver: English by default (The field age must be at least 18.). A message set with message: or #[Message] replaces it; its {placeholders} are filled: {field}, {path}, {code}, {expected}, {given} and the parameters ({min}, {max}, {allowed}...).

Plug your translations with a resolver:

use Karewan\KnData\Error\Violation;
use Karewan\KnData\Validation\DefaultMessageResolver;

KnData::configure(messageResolver: static fn(Violation $v): string =>
	DefaultMessageResolver::format(__($v->message ?? 'validation_' . $v->code), $v)
);

Serialization

serialize() and toJson() write objects back with the same keys, enums (value, or name for a pure enum) and date formats. Only the readable fields are written (a constructor parameter without public property is input only). An empty map or object is written {}, not the [] of json_encode(). #[OmitNull] leaves null values out. toJson() uses JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION.

KnData::toJson($settings);               // {"delay":5,"lang":"fr","langs":[],"labels":{}}
KnData::serialize([$a, $b]);             // list of objects

Cache

Each class is compiled to a PHP file in cacheDir (one per class, loaded by OPcache), as KnRoute caches its routes:

KnData::configure(
	cacheDir: __DIR__ . '/Storage/cache/kndata',
	scanForModifiedFiles: $debug,
);
  • scanForModifiedFiles: false (production): a cached class is used without looking at its source files. Empty the cache directory at each deployment, or run kndata warmup after emptying it.
  • scanForModifiedFiles: true (development): a class is recompiled when its file, a parent, a trait, or a class or enum of its fields changed (size or modification time).
  • Files are written atomically (temporary file then rename) and invalidated in OPcache. A file of another KnData version is recompiled.
  • Without cacheDir, classes are compiled in memory at their first use in each request: fine for tests and CLI scripts, slow for a website.

Type generation

Mark the classes and enums with #[Export]: the classes and enums they reference are exported with them.

vendor/bin/kndata generate typescript --src App/Dto --out web/src/api.ts
vendor/bin/kndata generate kotlin --src App/Dto --out android/Api.kt --package com.example.api
vendor/bin/kndata generate swift --src App/Dto --out ios/Api.swift
vendor/bin/kndata generate java --src App/Dto --out android/src/main/java/com/example/api --package com.example.api --style fastjson2

Add --check in the CI: it writes nothing and fails when the files are not up to date.

Directions

#[Export(...)] Meaning
Direction::Input Sent by the clients: a field with a default value is optional
Direction::Output Written by PHP: every readable field is present, readonly in TypeScript
Direction::Both (default) Both ways. TypeScript gets Name (output) and NameInput when they differ

A referenced class takes the direction of the classes that reference it, merged with its own #[Export]: a class exported as input and read in an output class is generated both ways.

#[Export(name: 'OrderDto')] renames the type. Two exported types with the same name are a DefinitionException.

Generated types

PHP TypeScript Java Kotlin Swift
int number long / Long Long Int
float number double / Double Double Double
string string String String String
bool boolean boolean / Boolean Boolean Bool
?T T | null boxed type T? T?
list<T> T[] List<T> List<T> [T]
array<string, T> Record<string, T> Map<String, T> Map<String, T> [String: T]
mixed unknown JsonNode / JsonElement / Object JsonElement / Any? JSONValue (generated)
date string (number for U) String String String
backed enum as const object + type enum with getValue() / fromValue(), + UNRECOGNIZED when the clients read it enum class with value, + Unrecognized when the clients read it enum: Int / String, + unrecognized(raw) when the clients read it
class interface record, class with public fields for FastJson2 data class struct: Codable
  • The PHPDoc summaries and the constraints become documentation (@minLength 12, @format email...). #[OneOf] becomes a literal union in TypeScript.
  • Default values are carried over when the language can write them. Kotlin: an unchanged default is not sent (encodeDefaults = false). Swift: a nullable field without a null default is encoded as null.
  • Identifiers are camelCase with the key kept by @JsonProperty, @SerializedName, @JSONField, @SerialName or CodingKeys; reserved words are escaped. --int-type int gives 32-bit integers in Java and Kotlin.

Enum values added later

An enum the clients read (reached from an output class, or exported as output) gets a fallback case in Java (UNRECOGNIZED), Kotlin (Unrecognized) and Swift (unrecognized(raw)): a value added to the server after the generation reads as this case instead of failing the whole object, in lists and maps too. fromValue() returns it instead of throwing. Java and Kotlin cannot send it: its value is unknown, getValue() throws. Swift keeps the raw value and sends it back as received. A case named Unrecognized is a DefinitionException on an enum the clients read. TypeScript is unchanged: a web front ships with its server.

when (order.status) {
    OrderStatus.Pending, OrderStatus.Paid -> showProgress()
    OrderStatus.Shipped -> showTracking()
    OrderStatus.Unrecognized -> showUpdateHint()   // a status this version of the app does not know
}

A pure enum gets the same fallback, its case names being its values. An enum only sent by the clients (reached from input classes only) keeps its plain form: the clients never decode it.

Java and Kotlin libraries

--style picks the JSON library. The generated code carries what the library needs to send exactly what PHP expects, with its default configuration: no null where PHP would refuse it or apply its default, a null where PHP needs one, int enums as numbers, unknown keys and enum values of a newer server accepted.

Style Generated Notes
Java jackson (default) records @JsonIgnoreProperties(ignoreUnknown = true), @JsonInclude(NON_NULL) on the fields whose null means "not set"
Java gson records int enums and the enums the clients read get a TypeAdapter; the nulls PHP must receive go through the generated KnDataWriteNull
Java fastjson2 classes with public fields For Android: FastJson2 for Android reads no records. Defaults are field initializers, int map keys are strings
Java none records No annotation
Kotlin kotlinx (default) data classes Use Json { ignoreUnknownKeys = true }, so that a key added by the server does not break the installed apps
Kotlin fastjson2 data classes Works without kotlin-reflect: every parameter carries @param:JSONField
Kotlin none data classes No annotation

The Kotlin styles generate the same Kotlin API: moving from FastJson2 to kotlinx.serialization takes a new generation, not a code change. One class cannot serve both libraries: FastJson2 would pick the constructor generated by the kotlinx plugin. The Java classes of the fastjson2 style can also be used from Kotlin.

With KnHttp, getAsObject() and getAsObjectList() read the fastjson2 types as they are. To send one, prefer JSON.toJSONString(dto):

KnHttp.post(url)
	.addStringBody(JSON.toJSONString(dto))
	.setContentType("application/json; charset=utf-8")

addJSONObjectBody((JSONObject) JSON.toJSON(dto)) works too, but drops every null: a field that PHP requires and that is null is then missing.

On Android, keep the generated classes from R8 when the library reads them by reflection (Gson, Jackson, FastJson2): -keep class com.example.api.** { *; }.

Command line

kndata warmup --cache-dir <dir> <path|class>...
kndata generate <typescript|java|kotlin|swift> --src <path> [--src <path>...] --out <path>
               [--package <name>] [--style <style>] [--int-type long|int] [--check]
kndata --help | --version

The project autoloader is found by Composer's bin proxy, or give it with --autoload vendor/autoload.php.

Performance

composer bench (php -d opcache.enable_cli=1 benchmarks/run.php), PHP 8.5, OPcache, JIT off, 50,000 runs:

Operation µs
json_decode() of the event, alone 1.8
Strict hydration written by hand, json_decode() included 3.5
KnData::fromJson(), 18 fields, json_decode() included 4.3
Form with 5 fields, trims and rules 2.0
toJson() of the event 2.9
First use in a request: new mapper, cache file loaded, mapping 6.7

The event is a parcel tracking scan with 18 fields: integers, floats, strings, nulls and a list. For comparison, netresearch/jsonmapper took 15 µs for an object of the same shape with a reused instance, 34 µs with a new one.

PHP 8.5

KnData runs on PHP 8.3 and uses PHP 8.5 when it is there:

  • Closures in attributes: #[Callback(static fn(string $v): bool => ...)] works; the cache reloads such rules through reflection.
  • #[\NoDiscard] on tryMap() and validate(): PHP 8.5 warns when their result is ignored.

Tests

composer test

A plain PHP runner, no dependency. Every PHP warning fails a test. The tests cover the conversion table of each profile, every rule through both the compiled classes and the standalone validator, the cache, the generated code, the command line, and random data against every fixture. Refresh the snapshots of the generated code with KNDATA_UPDATE_SNAPSHOTS=1 composer test.

The generated code is compiled and run when the tools are there, skipped otherwise:

Variable Test
KNDATA_TSC or tsc in the PATH The TypeScript compiles in strict mode
KNDATA_JAVAC or javac in the PATH The Java compiles
KNDATA_JARS: directory of jars Java with Jackson, Gson and FastJson2 reads what KnData writes, enum values added later included, and what it writes maps back in the strict profile
KNDATA_KOTLIN_HOME or kotlinc in the PATH The same with Kotlin, kotlinx.serialization and FastJson2, without kotlin-reflect
KNDATA_SWIFTC or swiftc in the PATH The Swift compiles, keeps the enum values a newer server adds and sends them back

KNDATA_JARS holds fastjson2, gson, jackson-core, jackson-databind, jackson-annotations, kotlinx-serialization-core-jvm and kotlinx-serialization-json-jvm.

Changelog

See CHANGELOG.md.

License

MIT