PHP port of Raoh — a decoder library for turning untyped boundary input into typed domain values.
It is built around a parse-don't-validate approach:
- decode at the boundary
- keep invalid states out of the domain model
- return failures as values instead of throwing
- attach structured errors to precise paths
raoh-php is closer to a parser/decoder library than to a traditional validation library.
If you are coming from a validator-oriented library, the main difference in feel is this:
- you do not validate an already-constructed domain object
- you decode raw input into a domain object
- object construction happens only after decoding succeeds
- PHP 8.2+
- Composer
Install and run tests:
composer install
./vendor/bin/phpunitsrc/
├── Result.php # abstract readonly class (Ok / Err parent)
├── Ok.php # final readonly class Ok
├── Err.php # final readonly class Err
├── Path.php # JSON Pointer path (immutable cons-list)
├── Issue.php # single error (path, code, message, meta)
├── Issues.php # error collection (accumulation)
├── Decoder.php # interface Decoder
├── DecoderTrait.php # map / flatMap / pipe / asList defaults
├── CallableDecoder.php # closure → Decoder adapter
├── Encoder.php # interface Encoder
├── CallableEncoder.php # closure → Encoder adapter
├── ErrorCodes.php # enum ErrorCodes: string
├── Decoders.php # utility: lazy / withDefault / recover / oneOf
├── StaticConstructor.php # trait for first-class callable constructors
├── Presence.php # tri-state presence base
├── Absent.php # field not present
├── PresentNull.php # field explicitly null
├── Present.php # field present with a value
│
├── Builtin/
│ ├── StringDecoder.php
│ ├── IntDecoder.php
│ ├── FloatDecoder.php
│ └── BoolDecoder.php
│
├── Combinator/
│ └── Combiner.php # variadic applicative combinator
│
└── Boundary/
├── Array_/
│ ├── functions.php # decoder use function imports
│ ├── ArrayDecoders.php
│ └── Encode/
│ └── functions.php # encoder use function imports
└── Json/
├── functions.php # decoder use function imports
├── JsonDecoders.php
└── Encode/
└── functions.php # encoder use function imports
Decoding returns a value instead of throwing:
Okfor successErrfor failure
Result supports:
map(...)flatMap(...)fold(...)getOrThrow()orElseThrow(...)Result::map2(...)— applicative combination of two resultsResult::traverse(...)— list traversal with full error accumulation
Each error includes:
pathcodemessagemeta
Paths use JSON Pointer notation (RFC 6901), for example:
/email/address/city/items/0/name
Issues can be merged, rebased, flattened, formatted, or converted to JSON-like data.
The core abstraction is:
interface Decoder {
public function decode(mixed $in, ?Path $path = null): Result;
}A decoder reads an input value and produces either:
- a typed value wrapped in
Ok - structured issues wrapped in
Err
Two boundary implementations are included:
Raoh\Boundary\Array_— PHP arrays and form dataRaoh\Boundary\Json— raw JSON strings
The symmetric counterpart to Decoder is:
interface Encoder {
public function encode(mixed $value): mixed;
public function contramap(callable $f): Encoder;
public function andThen(Encoder $next): Encoder;
}An encoder converts a trusted domain object into an external representation (array, JSON string, etc.) and never fails.
contramap($f)— pre-process the input before encoding (useful for unwrapping value objects)andThen($next)— post-process the output after encoding (useful for chaining transformations)
The normal raoh-php workflow looks like this:
- Start from raw input such as a JSON string or PHP array.
- Define small decoders for domain primitives such as
Email,Age, orUserId. - Combine them into object decoders.
- If decoding succeeds, you get a fully-typed value.
- If decoding fails, you get structured issues with paths.
That means the "happy path" looks like object construction, while the failure path looks like machine-readable diagnostics.
<?php
use function Raoh\Boundary\Array_\{field, string_, int_, combine};
class Email
{
public function __construct(public readonly string $value) {}
}
class Age
{
public function __construct(public readonly int $value) {}
}
class User
{
use \Raoh\StaticConstructor;
public function __construct(
public readonly Email $email,
public readonly Age $age,
) {}
}
function emailDecoder(): \Raoh\Decoder {
return string_()->trim()->toLowerCase()->email()
->map(fn($v) => new Email($v));
}
function ageDecoder(): \Raoh\Decoder {
return int_()->range(0, 150)
->map(fn($v) => new Age($v));
}
function userDecoder(): \Raoh\Decoder {
return combine(
field('email', emailDecoder()),
field('age', ageDecoder()),
)->map(User::of(...));
}Use it like this:
$result = userDecoder()->decode($_POST);Success case:
$result->fold(
fn(User $user) => saveUser($user),
fn(\Raoh\Issues $errs) => respond(422, $errs->toJsonList()),
);Example failure shape:
[
{ "path": "/email", "code": "invalid_format", "message": "not a valid email address", "meta": {} }
]<?php
use function Raoh\Boundary\Json\{field, string_, int_, combine, from_json};
$dec = from_json(combine(
field('host', string_()->nonBlank()),
field('port', int_()->range(1, 65535)),
)->map(fn($host, $port) => new Config($host, $port)));
$result = $dec->decode('{"host":"localhost","port":5432}');This is useful for:
- HTTP request bodies
- webhook payloads
- configuration files
StringDecoder supports:
nonBlank()allowBlank()minLength(...)maxLength(...)fixedLength(...)pattern(...)startsWith(...)endsWith(...)includes(...)oneOf(...)email()url()ip()ipv4()ipv6()uuid()ulid()trim()toLowerCase()toUpperCase()toInt()toFloat()toBool()toDate(...)
IntDecoder supports:
min(...)max(...)range(...)positive()negative()nonNegative()nonPositive()multipleOf(...)oneOf(...)
FloatDecoder supports:
min(...)max(...)range(...)positive()scale(...)
BoolDecoder supports:
isTrue()isFalse()
raoh-php distinguishes these cases:
field($name, $dec)— required fieldoptional_field($name, $dec)— missing field is allowed, returnsnullnullable($dec)—nullvalue is allowedoptional_nullable_field($name, $dec)— tri-state presence
Tri-state presence returns one of:
Absent— field not present in the inputPresentNull— field explicitly set to nullPresent— field present with a value
This distinction matters when "missing" and "explicitly null" have different meanings, which often comes up in PATCH-style APIs:
$dec = optional_nullable_field('nickname', string_());
// Absent: don't update
// PresentNull: clear the existing value
// Present: set to the new value<?php
use Raoh\Issues;
use Raoh\Path;
use Raoh\Result;
use function Raoh\Boundary\Array_\{field, string_, float_, combine, enum_of, nested};
enum Currency: string
{
case JPY = 'JPY';
case USD = 'USD';
}
class Money
{
private function __construct(
public readonly float $amount,
public readonly Currency $currency,
) {}
public static function parse(float $amount, Currency $currency): Result
{
if ($amount <= 0) {
return Result::fail(Path::root(), 'out_of_range', 'amount must be positive');
}
return Result::ok(new self($amount, $currency));
}
}
class User
{
use \Raoh\StaticConstructor;
public function __construct(
public readonly string $email,
public readonly Money $balance,
) {}
}
function moneyDecoder(): \Raoh\Decoder {
return combine(
field('amount', float_()->positive()),
field('currency', enum_of(Currency::class)),
)->flatMap(Money::parse(...));
}
function userDecoder(): \Raoh\Decoder {
return combine(
field('email', string_()->trim()->toLowerCase()->email()),
field('balance', nested(moneyDecoder())),
)->map(User::of(...));
}
$result = userDecoder()->decode($input);
$result->fold(
fn(User $user) => saveUser($user),
fn(Issues $errs) => respond(422, $errs->toJsonList()),
);This reads naturally as:
- "read
emailas a trimmed lowercased email" - "read
balancestructurally, then apply domain rules" - "construct
Useronly if everything succeeded"
raoh-php offers four distinct composition patterns.
All fields decoded independently; errors accumulate:
combine(
field('email', string_()->email()),
field('age', int_()->range(0, 150)),
)->map(fn($email, $age) => new User($email, $age));All fields decoded first, then a second step runs that can also fail — useful for cross-field validation:
combine(
field('password', string_()->minLength(8)),
field('passwordConfirm', string_()),
)->flatMap(function ($pw, $confirm) {
if ($pw !== $confirm) {
return Result::fail(Path::of('passwordConfirm'), 'invalid_value', 'passwords do not match');
}
return Result::ok(['password' => $pw]);
});Applicative combination of exactly two results:
$result = Result::map2(
$emailResult,
$ageResult,
fn($email, $age) => new User($email, $age),
);Decode every element in a list and accumulate all errors:
$result = Result::traverse($items, fn($item) => itemDecoder()->decode($item));or use list_of(...) which wraps this:
field('tags', list_of(string_()->nonBlank()))Given this decoder:
$dec = combine(
field('email', string_()->email()),
field('age', int_()->range(0, 150)),
)->map(fn($email, $age) => ['email' => $email, 'age' => $age]);And this input:
['email' => 'not-an-email', 'age' => 300]raoh-php returns both issues:
$err->issues->flatten();
// [
// '/email' => ['not a valid email address'],
// '/age' => ['must be between 0 and 150'],
// ]The Decoders class and boundary functions provide reusable combinators.
Decoders::lazy(callable $fn)— for recursive decodersDecoders::withDefault(Decoder $dec, mixed $default)— fallback for missing/null-like failuresDecoders::recover(Decoder $dec, mixed $fallback)— fallback for any decoding failureDecoders::oneOf(Decoder ...$candidates)— tries multiple candidates; returnsone_of_failedif all failenum_of(string $enumClass)— matches enum values; backed enums by value, pure enums by case nameliteral(mixed $value)— matches one exact value
Reject unknown fields:
combine(
field('name', string_()),
field('age', int_()),
)->strict(fn($name, $age) => new Person($name, $age));For recursive structures:
use Raoh\Decoders;
$commentDecoder = null;
$commentDecoder = combine(
field('body', string_()->nonBlank()),
Decoders::withDefault(field('replies', list_of(Decoders::lazy(fn() => $commentDecoder))), []),
)->map(fn($body, $replies) => new Comment($body, $replies));For discriminated union decoding:
use Raoh\Decoders;
$contactDecoder = Decoders::oneOf(
combine(
field('kind', literal('email')),
field('value', string_()->email()),
)->map(fn($kind, $value) => new EmailContact($value)),
combine(
field('kind', literal('phone')),
field('value', string_()->pattern('/^\d+$/')),
)->map(fn($kind, $value) => new PhoneContact($value)),
);If all candidates fail, one_of_failed is returned with candidate-specific errors in meta.candidates.
Use Decoders::withDefault(...) when a value is conceptually optional and you want a fallback for missing/null-like cases:
field('role', Decoders::withDefault(enum_of(Role::class), Role::Member))Use Decoders::recover(...) when you want to tolerate any decoding failure:
Decoders::recover(field('pageSize', int_()->range(1, 100)), 20)Decoders::recover(...) is more permissive. Decoders::withDefault(...) is stricter.
PHP does not support new ClassName(...) as a first-class callable. The StaticConstructor trait bridges this gap:
class User
{
use \Raoh\StaticConstructor;
public function __construct(
public readonly string $email,
public readonly int $age,
) {}
}
// enables this syntax:
combine(
field('email', string_()->email()),
field('age', int_()->range(0, 150)),
)->map(User::of(...));Without the trait, use a closure:
)->map(fn($email, $age) => new User($email, $age));raoh-php ships two boundary modules for different input types.
For PHP arrays (form data, deserialized YAML, framework request objects, etc.):
use function Raoh\Boundary\Array_\{field, string_, int_, float_, bool_, combine,
optional_field, optional_nullable_field, nullable, nested, list_of,
enum_of, literal, bytes};For raw JSON strings (HTTP bodies, webhook payloads, config files):
use function Raoh\Boundary\Json\{from_json, field, string_, int_, float_, bool_, combine,
optional_field, nullable, nested, list_of};from_json($dec) wraps any array decoder to accept a raw JSON string as input.
The Json boundary exposes a subset of the Array_ helpers. optional_nullable_field, enum_of, literal, and bytes are available only in Raoh\Boundary\Array_; use them inside a from_json() decoder when you need them with JSON input.
Converts domain objects to PHP arrays suitable for DB inserts, framework responses, or further serialization:
use function Raoh\Boundary\Array_\Encode\{string_, int_, float_, bool_,
date_, date_time_, enum_of, nullable, with_default,
property, object_, nested, list_};| Function | Purpose |
|---|---|
string_(), int_(), float_(), bool_() |
Primitive pass-through encoders |
date_() |
DateTimeInterface → 'Y-m-d' string |
date_time_() |
DateTimeInterface → ISO-8601 string |
enum_of() |
BackedEnum → backing value; pure enum → case name |
nullable($enc) |
Passes null through; delegates non-null to $enc |
with_default($enc, $default) |
Encodes $default when input is null |
property($key, $getter, $enc) |
Binds a map key, a getter, and a value encoder |
object_(...$props) |
Domain object → array<string, mixed> |
nested($enc) |
Marks an object encoder as a nested value (intent signal) |
list_($enc) |
Encodes every element of a list |
Example — domain object to array:
use function Raoh\Boundary\Array_\Encode\{object_, property, string_, int_};
$userEncoder = object_(
property('id', fn(User $u): string => $u->id, string_()),
property('email', fn(User $u): string => $u->email, string_()),
property('age', fn(User $u): int => $u->age, int_()),
);
$row = $userEncoder->encode($user);
// ['id' => '...', 'email' => '...', 'age' => 30]contramap — unwrapping value objects:
$idEncoder = string_()->contramap(fn(UserId $id): string => $id->value);Converts domain objects directly to a JSON string:
use function Raoh\Boundary\Json\Encode\to_json;
$encode = to_json($userEncoder);
$json = $encode->encode($user);
// '{"id":"...","email":"...","age":30}'to_json($enc) returns an Encoder<mixed, string> and uses JSON_THROW_ON_ERROR so encoding failures throw a \JsonException rather than returning silently.
Use fold():
$result->fold(
fn(User $user) => saveUser($user),
fn(Issues $issues) => respond(422, $issues->toJsonList()),
);Or use instanceof:
if ($result instanceof \Raoh\Ok) {
$user = $result->value;
} else {
$issues = $result->issues;
}Useful helpers on Issues:
flatten()— path-keyed list of messages, convenient for form-like UIsformat()— nested structure with_errorskeystoJsonList()— flat list of{path, code, message, meta}objects, convenient for APIstoArray()— access the raw list ofIssueobjects
The current implementation covers:
- decoding nested objects
- decoding lists
- optional, nullable, and tri-state fields
- custom constraints via
flatMap - cross-field validation
- defaults and recovery
- strict mode
- recursive decoders
- discriminated variants
- single-value decoding
- constructor shorthand via
StaticConstructor
Examples:
Nested object decoding:
combine(
field('name', string_()),
field('address', nested(addressDecoder())),
)->map(fn($name, $address) => new User($name, $address));Cross-field validation:
combine(
field('start', int_()),
field('end', int_()),
)->flatMap(function ($start, $end) {
if ($start >= $end) {
return Result::fail(Path::of('end'), 'invalid_value', 'end must be after start');
}
return Result::ok(new Period($start, $end));
});Defaults:
field('role', Decoders::withDefault(enum_of(Role::class), Role::Member))Strict mode:
combine(
field('id', string_()->uuid()),
field('email', string_()->email()),
field('age', int_()->range(0, 150)),
)->strict(fn($id, $email, $age) => new User($id, $email, $age));Single value decoding:
$result = string_()->email()->decode($input);The intended workflow is:
- Read dirty external input at the boundary.
- Decode it into domain values.
- Either get a fully-typed object or a structured error value.
This avoids passing partially-valid data deeper into the application and keeps the domain model focused on valid states.