Domain · Adapter
Values
Model immutable domain facts with value equality, then keep persistence mapping at the adapter boundary.
Values make a business fact explicit, immutable, and comparable without making it an entity. Use the named factories at the point where untrusted or primitive input enters your Domain; pass the resulting value through your use case without coupling it to a framework.
Ownership. Fight\Common\Domain\Value owns the portable value contracts and concrete values.
The optional Doctrine DBAL mappings live in Fight\Common\Adapter\Persistence\Doctrine\Type; no
framework type belongs in a Domain model.
Dependencies. Core value objects require PHP 8.5+ and this package. The Doctrine mappings are
optional and require doctrine/dbal (and, when used with entities, the consumer's ORM setup).
Install.
Start with a portable domain value.
use Fight\Common\Domain\Value\Internet\EmailAddress;
$email = EmailAddress::fromString('customer@example.com');
if ($email->equals(EmailAddress::fromString('customer@example.com'))) {
$recipient = $email->toString();
}
Invalid email, URI, JSON, timezone, and UUID input is rejected by the relevant named factory with
DomainException. String indexing can throw IndexException, and attempts to write through a
string object's ArrayAccess surface throw ImmutableException. Do not use a value object as a
mutable record: construct a replacement instead.
Reference¶
Value objects are immutable, self-validating domain primitives. They measure, quantify, or describe something in the domain — they are not entities with identity, but rather values that are compared by their content rather than by reference.
All value objects in this library extend ValueObject, which implements the Value interface (Equatable + JsonSerializable + Stringable). Default equality requires the same concrete type and toString() value; default hashing uses only toString(), so unequal types may share a hash. InstantRange overrides both to use exact endpoint instants even when its serialized timezone context differs.
Money overrides both to use code, captured scale and minor units rather than saved definition-version metadata.
Recommended: Helper Functions¶
The recommended way to construct value objects is via the helper functions in Fight\Common\Domain. Import with use function Fight\Common\Domain\{fn};:
| Helper | Creates | Alias for |
|---|---|---|
string($value) |
StringObject |
StringObject::create($value) |
mb_string($value) |
MbStringObject |
MbStringObject::create($value) |
json_string($value) |
JsonObject |
JsonObject::fromString($value) |
json_data($data) |
JsonObject |
JsonObject::fromData($data) |
email($address) |
EmailAddress |
EmailAddress::fromString($address) |
uri($uri) |
Uri |
Uri::fromString($uri) |
url($url) |
Url |
Url::fromString($url) |
uuid() |
Uuid |
Uuid::comb() |
Sections show helpers where available and named factories; the IP, phone, calendar/local-time and Duration values have no helpers. WeekDay is a native enum, not a ValueObject.
Table of Contents¶
- StringObject
- MbStringObject
- JsonObject
- StrictJson
- EmailAddress
- IP addresses
- E164PhoneNumber
- Decimal
- Currency
- Money
- Uri
- Url
- Uuid
- Identity (UniqueId)
- StreamId
- Calendar and local time
- Inclusive calendar DateRange
- Strict zoned DateTime
- Half-open InstantRange
- Duration
- Doctrine Data Types
StringObject¶
Fight\Common\Domain\Value\Basic\StringObject
A byte-oriented string wrapper with rich manipulation methods. Implements ArrayAccess, Countable, and Comparable.
Construction¶
$str = string('hello'); // helper
$str = StringObject::create('hello');
$str = StringObject::fromString('hello');
Basic Access¶
$str->value(); // "hello"
$str->length(); // 5
$str->isEmpty(); // false
$str->count(); // 5
$str->get(1); // "e"
$str->has(10); // false
$str->chars(); // ArrayList("h", "e", "l", "l", "o")
Content Checks¶
$str->contains('ell'); // true
$str->contains('ELL', caseSensitive: false); // true
$str->startsWith('hel'); // true
$str->endsWith('lo'); // true
$str->indexOf('l'); // 2
$str->lastIndexOf('l'); // 3
Mutation (always returns new instance)¶
$str->append(' world'); // "hello world"
$str->prepend('>> '); // ">> hello"
$str->insert(5, '!'); // "hello!"
$str->surround('*'); // "*hello*"
$str->trim(); // removes surrounding whitespace
$str->trimLeft('h'); // "ello"
$str->trimRight('o'); // "hell"
$str->pad(7, '-'); // "-hello--"
$str->padLeft(7, '-'); // "--hello"
$str->padRight(7, '-'); // "hello--"
$str->truncate(4, '...'); // "h..."
$str->truncateWords(8, '...'); // word-aware truncation
$str->repeat(3); // "hellohellohello"
$str->replace('l', 'z'); // "hezzo"
$str->expandTabs(4); // replaces tabs with spaces
Substrings¶
$str->slice(1, 4); // "ell" (between indexes)
$str->substr(0, 3); // "hel" (start + length)
$str->split(' '); // ArrayList of StringObject parts
$str->chunk(2); // ArrayList("he", "ll", "o")
Case Transforms¶
$str->toLowerCase(); // "hello"
$str->toUpperCase(); // "HELLO"
$str->toFirstLowerCase(); // "hELLO"
$str->toFirstUpperCase(); // "Hello"
$str->toCamelCase(); // "hello"
$str->toPascalCase(); // "Hello"
$str->toSnakeCase(); // "hello"
$str->toLowerHyphenated(); // "hello"
$str->toUpperHyphenated(); // "HELLO"
$str->toLowerUnderscored(); // "hello"
$str->toUpperUnderscored(); // "HELLO"
$str->toSlug(); // URL-safe slug
ArrayAccess & Iteration¶
$str[0]; // "h"
$str[1] = 'a'; // throws ImmutableException
isset($str[0]); // true
foreach ($str as $char) { /* ... */ } // iterates characters
Comparison¶
MbStringObject¶
Fight\Common\Domain\Value\Basic\MbStringObject
Identical API to StringObject, but uses multibyte-safe mb_* functions with hard-coded UTF-8 encoding. Use this for Unicode strings where character indexes and lengths must account for multi-byte characters.
$mb = mb_string('café'); // helper
$mb = MbStringObject::create('café');
$mb->length(); // 4 (not 5)
$mb->get(3); // "é"
$mb->toUpperCase(); // "CAFÉ"
The following methods differ internally:
| Feature | StringObject | MbStringObject |
|---|---|---|
length() |
strlen |
mb_strlen |
get() |
string offset | mb_substr |
chars() |
str_split |
mb_substr loop |
split() |
explode |
preg_split |
chunk() |
str_split |
mb_substr loop |
indexOf() |
strpos/stripos |
mb_strpos/mb_stripos |
lastIndexOf() |
strrpos/strripos |
mb_strrpos/mb_strripos |
toCamelCase() |
delegates to lcfirst |
delegates to toFirstLowerCase |
| Case transforms | strtolower/ucfirst etc. |
mb_strtolower/mb_substr etc. |
Same ArrayAccess, Countable, and Comparable implementations as StringObject.
JsonObject¶
Fight\Common\Domain\Value\Basic\JsonObject
Wraps any JSON-encodable data. Validates on construction — throws DomainException if the data cannot be encoded.
Construction¶
// Helpers
$json = json_data(['user' => 'alice', 'role' => 'admin']);
$json = json_string('{"user":"alice","role":"admin"}');
// Direct
$json = JsonObject::fromData(['user' => 'alice', 'role' => 'admin']);
$json = JsonObject::fromString('{"user":"alice","role":"admin"}');
Output¶
$json->toString(); // '{"user":"alice","role":"admin"}'
$json->toData(); // ['user' => 'alice', 'role' => 'admin']
$json->prettyPrint(); // pretty-printed JSON with JSON_PRETTY_PRINT
$json->encode(JSON_UNESCAPED_UNICODE); // custom encoding options
Default encoding uses JSON_UNESCAPED_SLASHES. Pass custom options to fromData() or encode().
Immutable JSON snapshots¶
Contract ID: fight-common.behavior.json-object-snapshots.
Opt in with JsonObject::fromSnapshot(mixed $data, int $encodingOptions = JSON_UNESCAPED_SLASHES) or
JsonObject::fromSnapshotString(string $value, int $encodingOptions = JSON_UNESCAPED_SLASHES). Both return the
existing JsonObject type. These additive public factories are callable/constructible API under ADR 0009; they
introduce no extensibility, new dependency, database type or mandatory migration.
$input = (object) ['child' => (object) ['value' => 'original'], 'ratio' => 1.0];
$snapshot = JsonObject::fromSnapshot($input);
$input->child->value = 'changed';
$snapshot->toData()->child->value = 'also changed';
$snapshot->toString(); // {"child":{"value":"original"},"ratio":1.0}
$restored = JsonObject::fromSnapshotString($snapshot->toString());
$restored->equals($snapshot); // true
Capture encodes scalars, arrays, ordinary objects and supported JsonSerializable output at construction, then
verifies that object-mode decoding succeeds. Only captured JSON is retained, not the supplied object graph.
Every toData() and jsonSerialize() returns an independent object-mode reconstruction: objects (including empty
and numerically named objects) stay objects, lists stay arrays, and scalars/null keep their kinds. Nested input
objects, PHP references and earlier mutable outputs cannot alter the retained string, hash or equality against an
unchanged comparison value. Custom serializers run at capture, never again on reads. This does not promise one
callback per object identity when references repeat, retain class identity/methods or reference topology, deep-clone
arbitrary PHP state, perform redaction, or establish permission to publish the data. Private/non-JSON state is not
captured. Common does not sandbox or roll back consumer serializer effects.
Numbers and text. Snapshot-owned encoding temporarily selects PHP serialize_precision=-1 (shortest native
float round-trip spelling), restores the previous setting on success/failure, and always includes
JSON_PRESERVE_ZERO_FRACTION. Native integers and finite floats remain distinct (1 versus 1.0, including
negative float zero); changing ambient formatting later cannot change retained string/equality/hash or snapshot-owned
encode()/prettyPrint() output. Consumers own any global-setting changes inside their serializers. No arbitrary-
precision decimal or recovery of precision already lost in a caller's float is promised.
Text reconstruction uses PHP's native integer range (PHP_INT_MIN through PHP_INT_MAX) and native float conversion.
Every integer literal outside that range rejects, even in a subsequently overwritten duplicate property; numeric
strings and property names remain strings. Decimal/exponent literals become native floats: excess decimal digits
round, underflow can become 0.0, and overflow to infinity rejects, including literals in overwritten duplicate
properties or discarded subtrees. Float overflow uses the same fixed encoding failure and JSON_ERROR_INF_OR_NAN
cause whether retained or discarded. Whitespace, escapes, exponent spelling and decimal lexemes normalize;
1e0 becomes 1.0 and integer -0 becomes 0. Ordinary PHP JSON duplicate-name decoding keeps
the last value. This is not preservation of arbitrary input text or general JSON canonicalization.
Presentation options. The allowed bitmask is zero or any combination of JSON_HEX_TAG, JSON_HEX_AMP,
JSON_HEX_APOS, JSON_HEX_QUOT, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE,
JSON_UNESCAPED_LINE_TERMINATORS, JSON_PRETTY_PRINT and JSON_PRESERVE_ZERO_FRACTION.
Line terminators are unescaped only together with unescaped Unicode, as in PHP. Zero-fraction preservation is always
on. Other bits reject, including JSON_FORCE_OBJECT, JSON_NUMERIC_CHECK, partial-output/invalid-UTF-8 substitution
or omission, and JSON_THROW_ON_ERROR (the snapshot owns error handling). Integer constants with identical numeric
bits cannot be distinguished by their names; use encoding constants, not decoder flags.
The same option boundary applies to snapshot encode(). prettyPrint() adds pretty printing to the captured options;
encode() uses its explicit/default options without replacing the retained representation. Factory options can
change equality/hash because inherited ValueObject equality compares strings, including property order and formatting.
No key sorting or semantic/key-order-independent equality is introduced. A snapshot and a legacy value with identical
strings compare equal and have equal hashes; a separately mutable legacy comparator may subsequently change.
Outer json_encode($snapshot, ...) receives fresh data, not raw captured JSON. The outer encoder controls precision,
flags and total depth, including wrappers: use suitable precision and JSON_PRESERVE_ZERO_FRACTION when float kinds
matter. Its lossy flags/defaults can change external output but cannot mutate the snapshot. Snapshot factory presentation
options do not override outer encoding options.
Limits and failures. Both codecs use depth 512. At most 511 nested containers reconstruct, including empty
containers: in root-zero node terminology, containers stop at depth 510 and scalars may reach 511. Encoder-only
acceptance at 512 containers is insufficient and rejects at the independent decoder boundary. Malformed JSON/Unicode,
resources, cycles, non-finite floats and object property names beginning with U+0000 reject; empty names and non-leading
U+0000 are supported. No partial/error-substituted snapshot is returned. Codec failures throw catchable
DomainException with fixed messages Unable to encode JSON snapshot. or Unable to decode JSON snapshot. and a
JsonException cause carrying the codec error code. Integer-range rejection uses
Unsupported JSON snapshot representation.; unsupported option bits use Unsupported JSON snapshot encoding options..
Those validation failures have no underlying codec cause. Consumer serializer throwables, including JsonException
and PHP Error, propagate unchanged with original identity, code and previous cause. Fixed wrapper messages do not
make arbitrary consumer exceptions or diagnostic causes public-safe.
Legacy JSON and Doctrine boundaries¶
Contract ID: fight-common.behavior.json-object-snapshot-doctrine (controlled adapter translation evidence).
fromData(), fromString(), json_data() and json_string() retain their existing legacy behavior in this minor.
Legacy construction can retain nested mutable objects/references, expose them from accessors and reinvoke custom
serializers on reads. Legacy associative text decoding/encoding collapses {} to [], {"0":"zero"} to ["zero"]
and 1.0 to 1. These limitations explain why snapshot reconstruction has its own factory; they are not repaired or
silently replaced. Existing StrictJson/MCP semantics remain independent and unchanged.
Both canonical and deprecated Doctrine JsonObjectDataType identities write a snapshot via its captured string.
Hydration from database text still calls legacy fromString(): it does not restore snapshot mode or faithfully
round-trip all snapshot shapes/numeric representations. Applications needing snapshot restoration must explicitly
use the snapshot-aware text boundary in their own composition; no automatic Doctrine opt-in, persisted-data rewrite
or schema migration is supplied. Existing supported stored data remains readable. Adapter fixtures prove translation
against controlled platforms, not real database JSON storage/normalization or downstream qualification.
This is an additive minor capability under ADRs 0009–0011: existing public construction, helper semantics, exception paths, Doctrine identities and hydration remain unchanged. Snapshot-specific exceptions, fresh mutable output, numeric representation and string equality are explicit new promises, not a claim that signatures alone establish compatibility or that legacy JSON becomes immutable.
StrictJson¶
Fight\Common\Domain\Value\Basic\StrictJson
An immutable, framework-free JSON value that preserves objects, lists and scalar types. Unlike JsonObject,
it validates a bounded plain-data tree and never exposes mutable generic objects through data accessors.
Legacy JsonObject factories and helpers are unchanged; its opt-in snapshots above are a separate capability.
StrictJson supplies no Doctrine mapping or new global helper.
use Fight\Common\Domain\Value\Basic\StrictJson;
$value = StrictJson::fromString('{"options":{},"items":[],"limit":1.0,"cursor":null}');
$emptyObject = StrictJson::fromObject(); // {}
$emptyList = StrictJson::fromData([]); // []
$value->get('options')->isObject(); // true
$value->get('items'); // []
$value->has('cursor'); // true (explicit null)
$value->has('missing'); // false
$value->get('missing'); // null
$next = $value->with('limit', 2); // new object; $value is unchanged
$value->toString(); // retains {}, [], 1.0 and null
fromData() accepts null, Booleans, integers, finite floats, valid Unicode strings, PHP lists, associative arrays,
existing StrictJson values and exact plain PHP decoded objects. Arbitrary objects, subclasses of generic objects,
resources and consumer serializers reject without invoking serialization code. Associative arrays become objects;
use fromObject($properties) to require an object for empty or numerically named properties. Numeric PHP array keys
remain their original JSON property names on serialization, including when replaced through with().
properties() returns an object's property map; get(), has() and with() require an object and otherwise throw
DomainException. Nested objects are immutable StrictJson values, lists are ordinary arrays, and scalars retain
their types. toData() returns that same representation: the value itself for an object, or its list/scalar data.
It is not an associative-array decoder that collapses {} into []. jsonSerialize() is the encoding boundary:
any generic object it emits is a fresh representation, never retained mutable state.
Construction rejects malformed JSON/Unicode, non-finite numbers, cycles and nesting beyond maxDepth (default 64,
root depth zero), including depth introduced by composing existing values. All factories and with() accept this
optional limit from 0 through 511. The retained PHP decoder depth of 512 permits at most 511 nested containers:
scalars may occupy node depth 511, but objects and lists (including empty ones) must stop at depth 510. Construction
and replacement enforce that codec ceiling as well as maxDepth, so accepted values reconstruct from their emitted
JSON under the same limit. MCP uses 511 for the protocol envelope and 64 separately for Tool arguments, schemas and
output, so wrappers do not consume the Tool budget. Property names beginning with U+0000 reject because PHP's
object-mode decoder cannot represent them; empty names and embedded non-leading U+0000 are supported. Exceptions use
fixed messages without reflecting data. PHP-decoded number precision is not recovered. Encoding preserves zero-fraction
floats. Equality/hash follow the existing ValueObject string-representation contract: property order and 1 versus
1.0 remain significant; this is not JSON Schema's numeric-aware structural equality. Validation is not redaction.
EmailAddress¶
Fight\Common\Domain\Value\Internet\EmailAddress
Validates email address format on construction. Throws DomainException for invalid addresses.
Construction¶
$email = email('alice@example.com'); // helper
$email = EmailAddress::fromString('alice@example.com');
Accessors¶
$email->toString(); // "alice@example.com"
$email->localPart(); // "alice"
$email->domainPart(); // "example.com"
$email->canonical(); // "alice@example.com" (lowercased)
Email parts and identity¶
Contract ID: fight-common.behavior.email-parts-and-identity
Part extraction uses the separator after the complete accepted local part. Local quotes and escape bytes are
retained, not decoded; domain-literal brackets are removed, but literal contents (including IPv6:) are preserved.
$quoted = EmailAddress::fromString('"a@b"@Example.COM');
$quoted->localPart(); // '"a@b"'
$quoted->domainPart(); // 'Example.COM'
$quoted->toString(); // '"a@b"@Example.COM'
$quoted->canonical(); // '"a@b"@example.com'
$literal = EmailAddress::fromString('"a@b"@[IPv6:2001:db8::1]');
$literal->domainPart(); // 'IPv6:2001:db8::1'
This corrects extraction for already accepted quoted addresses; it does not broaden Validate::isEmail syntax.
String/JSON representation and hashing retain the original address, and equality remains concrete-type and
case-sensitive. canonical() lowercases the whole address as a separate operation; it does not change the
stored value or define login identity. The canonical and legacy Doctrine email types retain original stored text,
the common_email_address name, null/empty conversion and instance passthrough; no data rewrite is required.
Validation is lexical only: no DNS lookup, reachability or ownership verification occurs. Consumers own login identity, authorization and permission to contact the address; neither accepted syntax nor diagnostic text grants those guarantees or promises public-safe presentation.
IP addresses¶
Fight\Common\Domain\Value\Internet\IpAddress is an abstract readonly family boundary. Its generic
fromString() returns final readonly IpV4Address or IpV6Address; concrete factories accept only their own
family. These are additive, opt-in values; existing Validate::isIp* predicates, Application IsIp* rules and
URI/adapter callers retain their existing behavior and are not migrated automatically. No Doctrine mapping,
Identifier, ordering or consumer-extension contract is introduced by the abstract declaration.
IP literal normalization and family identity¶
Contract ID: fight-common.behavior.ip-address-values
Factories require a bare literal string, without trimming, resolving hostnames or inferring context. Empty or
malformed values, ambiguous leading-zero IPv4, whitespace, CIDR, brackets, IPv4 ports and IPv6 zone identifiers
raise DomainException. A valid final IPv6 hextet is address content, not a port; use a separate connection/URI
boundary for ports. Native PHP parameter typing remains distinct from invalid-string value validation.
Canonical IPv4 is dotted decimal. Canonical IPv6 uses lowercase hexadecimal hextets without leading zeros,
compressing the first longest run of at least two zero hextets to ::; an isolated zero is never compressed.
Every embedded IPv4 suffix, including mapped IPv6, is rendered as hexadecimal hextets rather than dotted decimal.
Common owns this formatting policy, using native binary parsing but not platform-specific native rendering.
use Fight\Common\Domain\Value\Internet\IpAddress;
use Fight\Common\Domain\Value\Internet\IpV4Address;
use Fight\Common\Domain\Value\Internet\IpV6Address;
$v4 = IpAddress::fromString('192.0.2.1'); // IpV4Address; '192.0.2.1'
$v6 = IpAddress::fromString('2001:0DB8:0:0:0:0:0:1'); // IpV6Address; '2001:db8::1'
$mapped = IpV6Address::fromString('::ffff:192.0.2.1');
$mapped->toString(); // '::ffff:c000:201'
$mapped->equals(IpV6Address::fromString('::FFFF:C000:0201')); // true
$mapped->equals($v4); // false: mapped IPv6 remains IPv6
// IpV4Address::fromString('::ffff:192.0.2.1') throws DomainException
Equality is concrete-family plus canonical address; equal values have equal hashes. String casting, toString(),
hashValue() and JSON serialization use the canonical string. Generic and family-specific factories reconstruct
that representation; independently constructed equivalent values deduplicate and support membership/removal in
HashSet. Values expose no mutable state. No exact PHP serialized-byte or new persistence promise is made.
Lexical validity does not imply reachability, trusted provenance, public routing, permission or public-safe presentation. Valid private, loopback, link-local, multicast, unspecified and broadcast addresses are accepted as applicable to the family. Construction makes no DNS or network call. Consumers own destination authorization, address-category restrictions, trusted client-IP sources and SSRF defenses; this is not a CIDR/subnet value or a sanitization/redaction boundary. Exception prose is not a stable diagnostic contract.
E164PhoneNumber¶
Fight\Common\Domain\Value\Internet\E164PhoneNumber
A final readonly lexical phone value, constructed with fromString(); no helper, Identifier, ordering,
extension point or Doctrine mapping is added.
E.164 lexical form and value identity¶
Contract ID: fight-common.behavior.e164-phone-number
Accept exactly \A\+[1-9][0-9]{0,14}\z: a leading plus, a nonzero first ASCII digit and one through fifteen
ASCII digits total, excluding the plus. There is no arbitrary minimum or country-code/assignment database.
+1 and +9 are valid lexical values, not claims of assigned or dialable numbers. Keep phone data as strings,
never numeric casts. Construction does not trim whitespace, remove separators or convert national prefixes.
use Fight\Common\Domain\Value\Internet\E164PhoneNumber;
$number = E164PhoneNumber::fromString('+15550001234');
$number->toString(); // '+15550001234'
(string) $number; // '+15550001234'
json_encode($number); // '"+15550001234"'
$number->equals(E164PhoneNumber::fromString($number->toString())); // true
// E164PhoneNumber::fromString('+0987654321') throws DomainException
// E164PhoneNumber::fromString('+1234567890123456') throws DomainException
Empty/plus-only input, leading zero, missing/double/embedded plus, letters, Unicode digits/lookalikes, all
whitespace/control bytes, separators, extensions and overlength strings raise DomainException without repair.
Unprefixed short codes and alphanumeric sender IDs are not values of this type. Native PHP parameter typing
remains separate from invalid-string validation; exception prose is not a stable diagnostic contract.
The accepted text is retained exactly by toString(), string casting, hashValue() and JSON serialization.
Equality is same-concrete-type plus exact string, and equal values have equal hashes. Reconstruct via fromString()
from the emitted string or decoded JSON string. Values expose no mutable state. No exact PHP serialized-byte or
new persistence contract is introduced.
Adoption is optional: explicitly pass toString() into the existing SMS string APIs.
Existing short codes, sender IDs and non-E.164 strings remain supported there; neither value construction nor
message creation sends anything. Lexical validity does not establish assignment, reachability, SMS capability,
provider eligibility, ownership or permission to contact. Consumers own sender selection, country/provider rules,
consent, authorization, redaction and delivery policy. Construction performs no network/provider lookup.
Decimal¶
Contract ID: fight-common.behavior.exact-decimal-arithmetic.
Fight\Common\Domain\Value\Basic\Decimal is an opt-in final readonly exact base-ten ValueObject and
Comparable. fromString(string) accepts an optional sign, mandatory ASCII integer digits and an optional dot
followed by one or more ASCII fractional digits. It does not trim, accept exponents/localized notation or convert
floats. Input is limited to 4,096 bytes; the normalized coefficient has at most 1,024 digits and its normalized
scale is 0–1,024. Excess leading/trailing zeros may fit in the input even when the unnormalized coefficient is
longer. Invalid inputs, unsupported exact results, negative/requested scales above 1,024, zero divisors and
nonterminating exact quotients raise DomainException. Native PHP argument and enum type errors remain native.
use Fight\Common\Domain\Value\Basic\Decimal;
$sum = Decimal::fromString('0.1')->add(Decimal::fromString('0.2')); // 0.3 exactly
$sum->toString(); // '0.3'
Decimal::fromString('+01.000')->equals(Decimal::fromString('1')); // true
Decimal::fromString('1')->divide(Decimal::fromString('8'))->toString(); // '0.125'
Decimal::fromString('1')->divideRounded(Decimal::fromString('3'), 2, RoundingMode::HalfEven)->toString(); // '0.33'
Decimal::fromString('-2.5')->round(0, RoundingMode::HalfEven)->toString(); // '-2'
add, subtract, multiply and divide return exact supported values or reject; no implicit rounding.
divideRounded(Decimal $divisor, int $scale, RoundingMode $mode) and
round(int $scale, RoundingMode $mode) require an explicit precision and one of all eight native PHP rounding
modes. The requested scale controls rounding, not stored zero padding. Identity, hash, ordering (-1/0/1), string
cast and JSON string use the normalized numeric value: -0, 0.00 and 0 are one value. fromString() reconstructs
string or decoded JSON; wrong-type equality is false and wrong-type comparison raises DomainException.
The implementation uses plain PHP exact digits without requiring BCMath, GMP or a third-party arithmetic backend;
it does not change global bcscale, locale or float precision. Passing a string made from an already-rounded native
float cannot recover precision lost by the caller. The new factories, operations, representations and documented
throwable families are additive public contracts under ADRs 0009–0011; no pre-existing consumer value changes,
automatic persistence mapping, subclassing contract, PHP serialized-byte format, unlimited precision or currency
policy is supplied. Consumers own financial rounding/admission rules and optional adoption.
Currency¶
Contract ID: fight-common.behavior.bounded-currency-definitions.
Fight\Common\Domain\Value\Money\Currency is a final readonly value. fromCode() and fromString()
accept only the uppercase ASCII codes below without trimming, normalization or lookup; malformed or unsupported
codes (including BGN) raise DomainException, not a guessed exponent. This is a deliberately bounded package-owned
set, not a complete global/historical catalog, ranking, legal-tender declaration or authorization to transact.
| Base-ten accounting exponent | Supported codes |
|---|---|
| 0 | CLP, JPY, KRW, VND |
| 3 | BHD, KWD |
| 2 | AED, ARS, AUD, BDT, BRL, CAD, CHF, CNY, COP, CZK, DKK, EGP, EUR, GBP, HKD, HUF, IDR, ILS, INR, KES, LKR, MAD, MXN, MYR, NGN, NOK, NZD, PEN, PHP, PKR, PLN, QAR, RON, RUB, SAR, SEK, SGD, THB, TRY, TWD, UAH, USD, ZAR |
use Fight\Common\Domain\Value\Money\Currency;
$currency = Currency::fromCode('JPY');
$currency->code(); // 'JPY'
$currency->accountingExponent(); // 0
$currency->definitionVersion(); // 'v1'
$currency->toString(); // 'JPY'
$old = Currency::fromDefinition('JPY', 'v1'); // explicit retained reader
All 49 initial codes have definition version v1. fromDefinition(code, version) retrieves only a retained
package definition and rejects unknown codes/versions. A saved Money reader must retain its code, definition version,
scale and exact minor units; it must never reconstruct via the current fromCode() definition alone. Currency's
string cast, native JSON string, hash and same-concrete-type equality use code only; fromString()/decoded JSON
reconstruct the current definition for that code. A future different version with the same code remains Currency-equal.
Money compares captured code and scale separately: metadata-only revisions at the same exponent remain compatible;
a changed exponent does not. Currency does not itself store or reconstruct Money. It carries immutable scalar
metadata and exposes no mutable shared data.
Maintenance: the package's internal CurrencyDefinitions snapshot owns code → version → exponent and code →
current version. New codes require deliberate documented additions and tests. A changed scale needs a new version,
retention of all old version/exponent pairs for backward reads and an explicit update to current selection;
revised() rejects dropping or changing a prior definition. Existing supported definitions are pinned by the
49-code test data. Do not alter an old entry or erase an old code, silently redenominate saved values, register
consumer-invented scales, or infer precision from external tables, the clock or online data. No numeric codes,
display names, exchange rates, physical denominations, cash rounding, persistence mapping or automatic conversion
are provided. Consumers decide transaction eligibility, accounting/regulatory and cash policy. Adoption is optional;
existing values and consumers are unchanged. Public factory/accessor/error/representation behavior is additive under
ADRs 0009–0011; no consumer subclassing, new platform/dependency floor, exact PHP serialized bytes or global currency
coverage is promised. Exception prose is diagnostic rather than public-safe.
Money¶
Contract ID: fight-common.behavior.exact-money-and-allocation.
Fight\Common\Domain\Value\Money\Money is a final readonly ValueObject and Comparable. It captures a
supported Currency definition at fromMinorUnits(int $minorUnits, Currency $currency) or
fromDecimal(Decimal $majorAmount, Currency $currency, ?RoundingMode $mode = null). Its amount is signed native
integer minor units, including zero, negatives and PHP_INT_MIN; minorUnits() returns that exact integer,
currency() returns the captured immutable context, and toDecimal() returns the exact major-unit Decimal.
The accounting exponent is 0, 2 or 3, not a cash-rounding or physical subdivision rule. Construction and
reconstruction do not consult an online table. Unsupported codes, definitions and caller-invented scales are
not supported. There is no mandatory 64-bit, BCMath, GMP or float backend.
use Fight\Common\Domain\Value\Basic\Decimal;
use Fight\Common\Domain\Value\Money\Currency;
use Fight\Common\Domain\Value\Money\Money;
$usd = Currency::fromCode('USD');
$price = Money::fromDecimal(Decimal::fromString('1.23'), $usd); // 123 minor units
$price->add(Money::fromMinorUnits(2, $usd))->toDecimal()->toString(); // '1.25'
Money::fromDecimal(Decimal::fromString('-1.005'), $usd, RoundingMode::HalfAwayFromZero)
->minorUnits(); // -101
$shares = Money::fromMinorUnits(-5, $usd)->allocate([1, 1, 1]);
array_map(static fn (Money $share): int => $share->minorUnits(), $shares); // [-2, -2, -1]
add, subtract, negate, multiply(Decimal $scalar, ?RoundingMode $mode = null), and
divide(Decimal $scalar, ?RoundingMode $mode = null) return new Money. Exact paths reject a fractional minor
unit, even when a decimal result is otherwise finite; divide without rounding also rejects a nonterminating
Decimal quotient. With a mode, scalar division rounds the exact ratio once at minor-unit scale zero, not at
an intermediate major scale. Decimal conversion scales exactly first, then makes one explicit rounding decision
at the minor-unit boundary. All eight native PHP RoundingMode cases are supported. Native integer limits are
checked after exact wide arithmetic, without abs(PHP_INT_MIN), native float multiplication or implicit
precision loss. A zero divisor and unrepresentable result reject. Amount comparison and addition/subtraction
require matching currency code and captured scale, not matching revision labels; different currencies or
scales reject rather than sorting, exchanging or redenominating. compareTo returns -1/0/1 for compatible Money.
allocate(array $weights) accepts only a nonempty list of nonnegative native integer weights with at least
one positive weight (even when allocating zero). It divides the exact absolute amount by the exact total weight,
takes floor shares, gives remaining minor units to the greatest fractional remainders (ties to the first original
index), restores the original sign and returns Money shares in original list order. Zero weights get zero; the
signed shares sum exactly to the original amount, even at native limits. Large sums/products are calculated exactly.
This includes equal-weight splitting, not a policy about fairness or authority to distribute funds.
Saved text and JSON are the same string, money:v1:CODE:definitionVersion:scale:signedMinorUnits (for example
money:v1:USD:v1:2:-123). fromString() reads only canonical text with an exact ASCII signed integer (no +,
leading zeros or negative zero), the saved scale and a retained package definition. It rejects unknown schema/
definition/code/scale, mismatches, malformed values and amounts outside the native integer range, never inferring
scale from today's current code definition. Exact minor units remain text in JSON, so a JSON decoder must retain
the string rather than parse an imprecise numeric JSON amount. Readers for previously supported definitions
remain available when new codes or definition versions are added; changed exponents require a new retained
version, not mutation of an old entry. Equality/hash and hash-collection membership use code, captured scale and
minor units, not definition version. Saved representations also carry provenance, so two numerically equal Money
values with different supported definition versions may have different strings/JSON. Reconstruct with
fromString() or from the decoded JSON string, not from Currency::fromCode() alone. PHP argument/type failures
remain native; invalid supported values, incompatibility, overflow and malformed saved data raise
DomainException (diagnostic messages are not public-safe contracts). Caller-side float or JSON numeric precision
loss cannot be recovered. Money's public factories accept only retained package definitions; hypothetical future
snapshots in tests are not consumer construction or reader APIs.
This new opt-in public factory/reader, operations, failure family and versioned representation are additive under ADRs 0009–0011; no pre-existing Money schema or persisted value is migrated. No extension contract, PHP serialized bytes, database mapping or automatic adoption is supplied. Consumers own currency eligibility, accounting/legal and cash rules, refunds/debts, permissions, ledger/payment writes and any persistence integration. Recognition is not permission to transact.
Uri¶
Fight\Common\Domain\Value\Internet\Uri
Full RFC 3986 URI implementation. Parses, validates, normalizes, and resolves URIs. Implements Comparable.
Construction¶
// Helper
$uri = uri('https://user:pass@api.example.com:8080/path/to?q=1#frag');
// From a URI string
$uri = Uri::parse('https://user:pass@api.example.com:8080/path/to?q=1#frag');
// From components
$uri = Uri::fromArray([
'scheme' => 'https',
'authority' => 'user:pass@api.example.com:8080',
'path' => '/path/to',
'query' => 'q=1',
'fragment' => 'frag',
]);
Accessors¶
$uri->scheme(); // "https"
$uri->authority(); // "user:pass@api.example.com:8080"
$uri->userInfo(); // "user:pass"
$uri->host(); // "api.example.com"
$uri->port(); // 8080
$uri->path(); // "/path/to"
$uri->query(); // "q=1"
$uri->fragment(); // "frag"
$uri->toArray(); // all components as array
Immutable Modification¶
$uri->withScheme('http'); // new instance, scheme changed
$uri->withAuthority(null); // remove authority
$uri->withPath('/new/path'); // replace path
$uri->withQuery(null); // remove query
$uri->withFragment(null); // remove fragment
Output¶
$uri->toString(); // "https://user:pass@api.example.com:8080/path/to?q=1#frag"
$uri->display(); // "https://api.example.com:8080/path/to?q=1#frag" (no userinfo)
Relative Reference Resolution¶
$base = Uri::parse('https://example.com/a/b/c');
$uri = Uri::resolve($base, 'd/e?q=2');
// result: "https://example.com/a/b/d/e?q=2"
Comparison¶
Url¶
Fight\Common\Domain\Value\Internet\Url
Extends Uri with HTTP/HTTPS-specific behavior.
Restricted Scheme¶
Only http and https schemes are accepted:
$url = url('https://example.com/path'); // helper
$url = Url::parse('https://example.com/path');
Url::parse('ftp://example.com'); // throws DomainException
Default Port Removal¶
Standard ports are omitted: port 80 for http and port 443 for https are stripped.
$url = Url::parse('https://example.com:443/path');
$url->toString(); // "https://example.com/path"
$url->port(); // null
Sorted Query Parameters¶
Query parameters are sorted by key. Parameters without keys (e.g., =value) are dropped.
Uuid¶
Fight\Common\Domain\Value\Identifier\Uuid
RFC 4122 UUID implementation with support for versions 1, 3, 4, and 5. Implements Comparable.
Named Constructors¶
// Helper — COMB UUID (default, recommended for DB primary keys)
$uuid = uuid(); // timestamp in MSB
$uuid = uuid(msb: false); // timestamp in LSB
// Version 4 — random
$uuid = Uuid::random();
// Version 4 — sequential (COMB)
$uuid = Uuid::comb(); // timestamp in MSB
$uuid = Uuid::comb(msb: false); // timestamp in LSB
// Version 1 — time-based
$uuid = Uuid::time(); // auto-generates node, clock sequence, timestamp
// Version 5 — SHA-1 named
$uuid = Uuid::named(Uuid::NAMESPACE_DNS, 'example.com');
// Version 3 — MD5 named
$uuid = Uuid::md5(Uuid::NAMESPACE_URL, 'https://example.com');
Parsing¶
$uuid = Uuid::parse('f47ac10b-58cc-4372-a567-0e02b2c3d479');
$uuid = Uuid::fromHex('f47ac10b58cc4372a5670e02b2c3d479');
$uuid = Uuid::fromBytes("\xf4\x7a\xc1\x0b\x58\xcc\x43\x72\xa5\x67\x0e\x02\xb2\xc3\xd4\x79");
$uuid = Uuid::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479');
$uuid = Uuid::fromString('urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479');
Validation¶
Accessors¶
$uuid->timeLow(); // "f47ac10b"
$uuid->timeMid(); // "58cc"
$uuid->timeHiAndVersion(); // "4372"
$uuid->clockSeqHiAndReserved(); // "a5"
$uuid->clockSeqLow(); // "67"
$uuid->node(); // "0e02b2c3d479"
$uuid->mostSignificantBits(); // "f47ac10b58cc4372"
$uuid->leastSignificantBits(); // "a5670e02b2c3d479"
Metadata¶
$uuid->version(); // 1, 2, 3, 4, or 5 (0 for unknown)
$uuid->variant(); // VARIANT_RFC_4122 (2) for standard UUIDs
Format Conversion¶
$uuid->toString(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
$uuid->toUrn(); // "urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479"
$uuid->toHex(); // "f47ac10b58cc4372a5670e02b2c3d479"
$uuid->toBytes(); // 16-byte binary string
$uuid->toArray(); // associative array of fields
Constants¶
Uuid::NIL; // "00000000-0000-0000-0000-000000000000"
Uuid::NAMESPACE_DNS; // "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
Uuid::NAMESPACE_URL; // "6ba7b811-9dad-11d1-80b4-00c04fd430c8"
Uuid::NAMESPACE_OID; // "6ba7b812-9dad-11d1-80b4-00c04fd430c8"
Uuid::NAMESPACE_X500; // "6ba7b814-9dad-11d1-80b4-00c04fd430c8"
Identity (UniqueId)¶
Fight\Common\Domain\Identity\UniqueId
An abstract base class for entity identity types. Wraps a Uuid under the hood and provides generate(), fromString(), equality, and comparison.
Creating a Typed Identity¶
To create an identity for a domain entity, extend UniqueId with no additional code:
That is all that is needed. The UserId class automatically inherits:
// Generate a new ID
$id = UserId::generate();
// Parse from string
$id = UserId::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479');
// String output
$id->toString(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
(string) $id; // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
Identity Safety¶
Two UserId instances with the same UUID are equal; a UserId and an OrderId with the same UUID are not — the type check prevents cross-entity identity confusion:
$uid = UserId::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479');
$oid = OrderId::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479');
$uid->equals($oid); // false (different types)
$uid->equals(UserId::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479')); // true
Interface¶
UniqueId implements Identifier (which extends Value + Comparable) and IdentifierFactory:
| Method | Description |
|---|---|
UserId::generate(): static |
Creates a new random COMB UUID identity |
UserId::fromString(string): static |
Parses a UUID string into an identity |
$id->toString(): string |
Returns the UUID string |
$id->compareTo($other): int |
Natural order comparison |
$id->equals($other): bool |
Type-safe equality check |
$id->hashValue(): string |
Hash including the type prefix |
StreamId¶
Fight\Common\Domain\EventSourcing\StreamId extends ValueObject and implements Identifier without UUID
restrictions. Its public constructor retains two nonempty byte strings: stable aggregate name and identifier.
Equality/hash include both components; comparison is bytewise name-first, then identifier, never numeric.
toString(), string casts and native JSON use the canonical ASCII stream:v1:<base64(name)>:<base64(id)> frame.
fromString() strictly reconstructs it with DomainException for invalid/noncanonical frames or empty components;
wrong-type comparison also raises DomainException, while wrong-type equality is false.
See Stream identity values for the exact grammar and the minor-upgrade notice for the explicitly approved changes to hash collection identity and native JSON. Component fields in storage and diagnostics remain unchanged; encoding is not redaction, authorization or automatic persistence migration.
Calendar and local time¶
Date, Time and WeekDay live under Fight\Common\Domain\Value\DateTime. They are optional new concepts, not
replacements for native timestamps or existing Timezone. Neither a date nor a local time identifies an instant,
contains a zone or grants scheduling/record-access permission. Consumers own calendar, locale and scheduling policy.
No ambient clock, midnight/default-date conversion, persistence mapping, arithmetic or automatic adoption is supplied.
The code is original; these APIs do not adopt Novuso's constructors or sequence semantics.
Gregorian Date values¶
Contract ID: fight-common.behavior.calendar-date-values.
Final readonly Date extends ValueObject and implements Comparable. Its factories are
fromParts(int $year, int $month, int $day), fromString(string $value) and fromNative(DateTimeInterface $value).
Supported dates are proleptic Gregorian years 0001–9999, including before historical Gregorian adoption.
Leap years are divisible by four, except centuries not divisible by 400. Year zero, invalid month/day, non-leap
February 29 and rollover reject with DomainException; native parameter typing remains distinct from validation.
The only accepted string spelling is exact ASCII YYYY-MM-DD: no trimming, short fields, signs, trailing newlines
or native free-form parser. toString(), string casts, JSON and hashValue() use that canonical date.
year(), month(), day() return integer components; weekDay() returns the Gregorian WeekDay case using
PHP's Sunday-zero w convention. It formats an already-validated date with explicit UTC internally, so ambient
zones (including skipped local dates) cannot change the weekday. This does not assign a consumer timezone/instant
to the value or introduce a public date-to-timestamp conversion.
Equality is same-concrete-type canonical equality; wrong-type equality is false. compareTo() returns -1/0/1
in chronological order, coherent with equality, and raises DomainException for a non-Date.
use Fight\Common\Domain\Value\DateTime\Date;
use Fight\Common\Domain\Value\DateTime\WeekDay;
$date = Date::fromParts(2024, 2, 29);
$date->toString(); // "2024-02-29"
$date->weekDay() === WeekDay::THURSDAY; // true
$date->compareTo(Date::fromString('2024-03-01')); // -1
Date::fromString('2023-02-29'); // throws DomainException, no March rollover
fromNative() copies the supplied timestamp's own local year/month/day without ambient-zone conversion or
retaining the native object; unsupported extracted years reject. Mutating the caller's DateTime afterward cannot
change the Date. It extracts existing components, not the text originally parsed by the native timestamp: native
normalization that already occurred is not undone. It does not recombine a date and time into a zoned timestamp.
Exact local Time values¶
Contract ID: fight-common.behavior.local-time-values.
Final readonly Time extends ValueObject and implements Comparable. Factories are
fromParts(int $hour, int $minute, int $second, int $microsecond = 0), fromString(string $value) and
fromNative(DateTimeInterface $value). Bounds are hour 0–23, minute/second 0–59 and microsecond 0–999999.
Only the microsecond component defaults to zero. Invalid components, 24:00, leap seconds and fractional overflow
raise DomainException, never rollover. Native parameter typing is not replaced with coercive domain parsing.
Only exact ASCII HH:MM:SS.ffffff strings are accepted: exactly six fractional digits, no timezone suffix,
short fields, missing fraction, whitespace or parser normalization. String/JSON/hash outputs retain exactly six
digits. Microseconds are integers, never floating-point time. Accessors are hour(), minute(), second() and
microsecond(). Equality/hash use the same-concrete-type canonical value; wrong-type equality is false.
compareTo() returns -1/0/1 in local component order, coherent with equality; non-Time input raises DomainException.
use Fight\Common\Domain\Value\DateTime\Time;
$time = Time::fromParts(1, 2, 3, 4);
$time->toString(); // "01:02:03.000004"
$time->microsecond(); // 4
Time::fromParts(1, 2, 3)->toString(); // "01:02:03.000000"
Time::fromString('23:59:59.999999'); // maximum local time
fromNative() copies local hour/minute/second/microsecond from the supplied DateTimeInterface, with no ambient-zone
conversion, date interpretation, instant arithmetic or retained mutable input. Native pre-epoch timestamps still
contribute their exact local microseconds. Mutating a native input cannot change the resulting Time. Stable string
and JSON reconstruction uses the factories above; PHP serialized bytes and automatic persistence are not promised.
Native WeekDay¶
Contract ID: fight-common.behavior.weekday-values.
WeekDay is a native integer-backed enum matching PHP's DateTimeInterface::format('w'): SUNDAY=0, MONDAY=1,
TUESDAY=2, WEDNESDAY=3, THURSDAY=4, FRIDAY=5, SATURDAY=6. Use case identity, name, value and native
cases(), from() or tryFrom(); it is not a ValueObject or Identifier and adds no value-object string/hash/comparison
factories. Native JSON encodes the integer
backing value. Invalid integer from() raises PHP ValueError, tryFrom() returns null; PHP's native parameter
rules still apply (for example a string backing value raises TypeError in strict calling code). There is no
DomainException wrapper, ISO Sunday-seven alias, locale-dependent renumbering or consumer extension promise.
WeekDay::from(0) === WeekDay::SUNDAY; // true
WeekDay::from(1) === WeekDay::MONDAY; // true
WeekDay::tryFrom(7); // null
json_encode(WeekDay::SUNDAY); // "0" (JSON number)
Inclusive calendar DateRange¶
Contract ID: fight-common.behavior.inclusive-calendar-date-range.
Fight\Common\Domain\Value\DateTime\DateRange is a final readonly ValueObject representing an inclusive
calendar interval [start, end]. Construct with fromDates(Date $start, Date $end); start() and end() return
immutable Date values. contains(Date $candidate) includes both endpoints and every intervening date, irrespective
of weekday, month, leap day or year. Equal endpoints represent one date, not an empty range. Reversed dates raise
DomainException; Dates themselves enforce proleptic Gregorian years 0001–9999. No timestamp, timezone,
midnight or step alignment is involved.
use Fight\Common\Domain\Value\DateTime\Date;
use Fight\Common\Domain\Value\DateTime\DateRange;
$range = DateRange::fromDates(Date::fromString('2024-02-28'), Date::fromString('2024-03-01'));
$range->contains(Date::fromString('2024-02-29')); // true
$range->end()->toString(); // "2024-03-01"
$copy = DateRange::fromString($range->toString());
The exact ASCII string/JSON/hash representation is date-range:v1:YYYY-MM-DD:YYYY-MM-DD, start first. fromString()
accepts only that frame with two valid canonical Dates in order; unsupported versions, malformed dates and reversed
intervals raise DomainException. equals() requires the same concrete type and equal ordered endpoints; wrong-type
equality is false, and equal ranges have equal hashes. There is no ordering contract for ranges, PHP serialized-byte
promise, persisted schema or new dependency. Exception prose is diagnostic, not public-safe.
This is original additive code, not Novuso's stepped DateRange. Its public callable/constructible operations and behavioral/representation contract are classified under ADRs 0009–0011; it is final, not an extension or implementer contract. Adoption is optional; existing native timestamps, inclusive audit lookups, scheduler and consumer data are unchanged. Calendar containment is not proof of occurrence or permission to act. Consumers own calendar, retention and access policy. Half-open instant intervals are a separate concept; there is no range length/count, iteration, recurrence, date arithmetic or automatic conversion to instants. Direct nonvisual containment and reconstruction tests are the appropriate acceptance evidence.
Strict zoned DateTime¶
Contract ID: fight-common.behavior.strict-zoned-datetime.
Fight\Common\Domain\Value\DateTime\DateTime is a final readonly value with an explicit timezone and an exact instant. It is separate from native DateTimeImmutable, Date (calendar-only), Time (local-only), Timezone (whose original string identity is unchanged) and elapsed Duration. Its code is original, not adapted from Novuso. No existing native timestamp signatures, audit range semantics, persistence formats or scheduler policy change; opt in explicitly.
use Fight\Common\Domain\Value\DateTime\{Date, DateTime, Time, Timezone};
$zone = Timezone::fromString('America/New_York');
$fold = DateTime::fromLocal(Date::fromString('2026-11-01'), Time::fromString('01:30:00.123456'), $zone, -18000);
$fold->toNative()->format('U.u'); // 1793514600.123456
$copy = DateTime::fromString($fold->toString()); // exact second fold, not a fresh wall-time choice
$utc = $fold->inTimezone(Timezone::fromString('UTC')); // same instant, different local components
$other = $fold->reinterpretInTimezone(Timezone::fromString('UTC')); // same wall components, different instant
fromLocal(Date, Time, Timezone, ?int $offsetSeconds = null) rejects nonexistent local times even with an offset. Repeated times require an exact signed integer offset in seconds selecting a real occurrence; a supplied offset for a unique time must also match. No one-hour/whole-minute assumption is made: historical second offsets, non-hour transitions, fixed-offset and abbreviation zones are supported. Native timezone rules are evaluated at construction, not pinned to a tzdb version. reinterpretInTimezone() repeats these checks on the target wall components; inTimezone() preserves the instant but rejects a target local year outside 0001–9999. Neither operation performs date or Duration arithmetic.
fromNative(DateTimeInterface) copies the supplied native instant/timezone (including a mutable DateTime) without retaining mutable caller state. fromInstant(string $seconds, int $microsecond, Timezone $timezone) takes canonical signed Unix seconds (0 or -?[1-9][0-9]*) and a 0–999999 microsecond component, without floating point or a combined integer-microsecond total. It validates the resulting local date. toNative() returns an immutable timestamp; date(), time(), timezone() and instantSeconds() expose values/coordinates. Invalid text, offsets, out-of-range local years and unrepresentable coordinates raise DomainException; native PHP argument type failures remain native. Unexpected native engine errors are not converted to domain validation failures. Exception messages/causes are diagnostic, not public-safe. The supported range is the 0001–9999 local year in the chosen zone; crossing it during conversion rejects rather than clamps.
String/JSON/hash use zoned:v1:<canonical signed Unix seconds>:<six microsecond digits>:<canonical Base64 native timezone identifier>; fromString() strictly reconstructs that instant and native identifier, including either overlap occurrence. Names are DateTimeZone::getName() values: IANA aliases remain distinct, while native offset normalization makes +5:30 and +05:30 the same identifier (+05:30). Pre-epoch -1.500000 means whole second -1 and microseconds 500000, not negative one-and-a-half seconds. Saved instants stay instants; timezone rule updates may change displayed local components but never silently reinterpret the stored wall time. No exact PHP serialized-byte, frozen tzdb or new Doctrine mapping is promised.
equals()/hash require both exact instant and native timezone identifier; compareTo() orders instants first and identifier second (-1/0/1), rejecting another type with DomainException. compareInstantTo(DateTime) and isSameInstantAs(DateTime) deliberately ignore the identifier so instant intervals need not inherit value tie-breaking. Wrong-type equality is false. All factories are additive callable/constructible-only operations under ADRs 0009–0011, without a new consumer-implementable/extension contract, platform/dependency floor or automatic migration. Consumers own occurrence provenance, scheduling/access eligibility and any presentation/redaction policy. Nonvisual native/DST and test transcripts are the appropriate acceptance evidence.
Half-open InstantRange¶
Contract ID: fight-common.behavior.half-open-instant-range.
Fight\Common\Domain\Value\DateTime\InstantRange is a final readonly value for exact instants in
[start, end). Construct with fromInstants(DateTime $start, DateTime $end); start() and end() return the
original immutable zoned endpoint values. contains(DateTime $candidate) includes the starting instant and excludes
the ending instant, at microsecond precision, regardless of any of their timezone identifiers. Reversed instants
raise DomainException; equal instants (even if their zones differ) form an empty interval containing nothing.
The endpoints must be supported strict zoned DateTimes (local year 0001–9999 in each endpoint zone). No separate
span limit or Duration calculation is imposed.
use Fight\Common\Domain\Value\DateTime\{DateTime, InstantRange, Timezone};
$utc = Timezone::fromString('UTC');
$start = DateTime::fromInstant('-1', 500000, $utc);
$end = DateTime::fromInstant('0', 1, $utc);
$range = InstantRange::fromInstants($start, $end);
$range->contains($start->inTimezone(Timezone::fromString('America/New_York'))); // true
$range->contains($end); // false
$copy = InstantRange::fromString($range->toString()); // exact instants and endpoint zone context
String casts and JSON strings use the versioned ASCII frame
instant-range:v1:<canonical Base64 zoned DateTime string>:<canonical Base64 zoned DateTime string>.
Each nested zoned:v1 representation preserves signed Unix seconds, six-digit microseconds and the original native
timezone identifier. Base64 frames avoid ambiguous separators. fromString() requires canonical Base64 and valid
zoned endpoints in instant order; malformed, unsupported, out-of-range or reversed frames raise DomainException.
Both factory and reconstructed getters retain their supplied endpoint zones, even at instants whose UTC local year
falls outside 0001–9999. A range with the same exact endpoint instants in different zones has a different string
and JSON representation, because they retain context, but compares equal and has the same hash. InstantRange
overrides the normal ValueObject string-based equality/hash: its hash is the canonical coordinate frame
instant-range:v1:<seconds>:<six digits>:<seconds>:<six digits> without timezone labels. For the example above it
is instant-range:v1:-1:500000:0:000001. Distinct endpoint pairs, including empty ranges at different instants,
remain distinct. Do not use string/JSON byte identity as a test of range equality. Exception prose is diagnostic,
not public-safe; native PHP argument typing remains separate.
This original additive callable/constructible API and versioned behavior are classified under ADRs 0009–0011. It
adds no consumer extension or implementer contract, dependency, persistence mapping or existing-data migration.
Adoption is optional: neither native timestamp APIs nor AuditRepository::getBetween(DateTimeImmutable,
DateTimeImmutable, Pagination) change. That audit query remains inclusive on both endpoints, using >= and
<= in the Doctrine adapter; do not pass an InstantRange as if it changed audit lookup semantics. This value is
not proof of occurrence, authorization or record access. Consumers own access, schedules and optional conversions;
there is no range length, iteration, recurrence, calendar interval or automatic audit/scheduler migration.
Direct nonvisual containment/reconstruction and controlled audit translation tests establish the package boundary,
not database execution.
Duration¶
Fight\Common\Domain\Value\DateTime\Duration represents signed fixed elapsed time, not a date, local time,
instant, timezone, calendar month/year or DST-aware wall-clock operation. This is original, additive code, not a
Novuso API adaptation. Adoption is optional; existing native timestamps, Timezone, scheduler, timeout and TTL
contracts remain unchanged. There is no helper, persistence mapping, DateTime arithmetic or interval-length API.
Exact elapsed Duration values¶
Contract ID: fight-common.behavior.exact-elapsed-durations.
Final readonly Duration extends ValueObject and implements Comparable. It stores an exact integer total of
microseconds in the native PHP_INT_MIN through PHP_INT_MAX range, including zero and negative values. No 64-bit
floor, float approximation, required arithmetic extension or arbitrary-precision storage is introduced. Numeric
range therefore depends on the runtime: a value representable on one integer width may reject on a narrower one.
Factories are fromMicroseconds(int $microseconds), fromMilliseconds(int $milliseconds),
fromSeconds(int $seconds) and fromString(string $value). Integer-unit factories scale exactly, checking bounds
before multiplication. toMicroseconds() returns the stored total; toMilliseconds() and toSeconds() return
an exact integer quotient or raise DomainException for any fractional unit, including negative fractions.
There is no implicit rounding/truncation or floating result. Native PHP parameter typing remains distinct from
value validation; strict calling code rejects floats with TypeError. Use exact integers, not weak-call coercion.
use Fight\Common\Domain\Value\DateTime\Duration;
$elapsed = Duration::fromSeconds(2);
$elapsed->toMilliseconds(); // 2000
$next = $elapsed->add(Duration::fromMicroseconds(1));
$next->toString(); // "2000001us"
$elapsed->toString(); // "2000000us" (unchanged)
$next->toSeconds(); // throws DomainException: fractional second
Duration::fromMilliseconds(-2)->toMicroseconds(); // -2000
Duration::fromMicroseconds(-1)->negate()->toString(); // "1us"
Canonical string/JSON/hash representation is ASCII decimal microseconds followed by us, with grammar
\A(0|-?[1-9][0-9]*)us\z and native numeric bounds. Zero is 0us; no plus, leading zeros, negative zero, whitespace,
Unicode digits, separators, exponent, fractional spelling or other units/ISO calendar-duration syntax is accepted.
fromString() checks decimal magnitude before casting; out-of-range text never clamps or becomes a float.
toString(), string casting and JSON emit the same exact text, independently of ambient float precision settings.
Reconstruct from that string or the decoded JSON string; no PHP serialized-byte or automatic persistence promise
is added.
add(Duration $other), subtract(Duration $other) and negate() return new values without changing either
operand. All check representability before evaluating arithmetic. Subtraction does not first negate its operand:
subtracting PHP_INT_MIN from itself succeeds as zero. PHP_INT_MIN itself is valid, but its negation rejects because
the positive magnitude is unrepresentable. Malformed/out-of-range text, scaled overflow, fractional exact
conversion and arithmetic overflow raise DomainException; exception prose is not a stable contract.
Equality requires the same concrete type and exact canonical total; equal durations have equal hashes and
deduplicate in value collections. Wrong-type equality is false. compareTo(mixed $other) returns -1/0/1 numeric
ordering, coherent with equality (not lexicographic string ordering), and raises DomainException for non-Duration
values. Values expose no mutable state and have no consumer-extension or Identifier promise.
Signed duration validity does not establish occurrence, permission, a timeout deadline or suitability for a TTL. Consumers own positivity, limits, scheduling/calendar policy and optional explicit conversion when adopting the value in their own APIs. Construction and arithmetic perform no clock, network, persistence or scheduler effects.
Doctrine Data Types¶
Thirteen custom DBAL types in Fight\Common\Adapter\Persistence\Doctrine\Type map domain value objects to SQL
columns, enabling Doctrine ORM to hydrate and dehydrate them directly. Each type extends
Doctrine\DBAL\Types\Type and registers under a common_ prefix.
Most types serialize via $value->toString() / ClassName::fromString(). The
MessageDataType uses JsonSerializer instead to support polymorphic message
deserialization through the Message interface.
| Type Name | SQL Column | PHP Class | Namespace |
|---|---|---|---|
audit_entry_id |
GUID/UUID | AuditEntryId |
Domain\Observability |
common_uuid |
GUID/UUID | Uuid |
Domain\Value\Identifier |
common_email_address |
VARCHAR | EmailAddress |
Domain\Value\Internet |
common_uri |
VARCHAR | Uri |
Domain\Value\Internet |
common_url |
VARCHAR | Url |
Domain\Value\Internet |
common_string |
VARCHAR | StringObject |
Domain\Value\Basic |
common_string_text |
TEXT/CLOB | StringObject |
Domain\Value\Basic |
common_mb_string |
VARCHAR | MbStringObject |
Domain\Value\Basic |
common_mb_string_text |
TEXT/CLOB | MbStringObject |
Domain\Value\Basic |
common_json |
JSON | JsonObject |
Domain\Value\Basic |
common_meta |
JSON | Meta |
Domain\Messaging |
common_type |
VARCHAR | Type |
Domain\Type |
common_message |
JSON | Message (interface) |
Domain\Messaging |
VARCHAR vs TEXT Variants¶
StringObject and MbStringObject each provide two mappings depending on expected field
length. Use common_string / common_mb_string (VARCHAR) for short strings and
common_string_text / common_mb_string_text (TEXT/CLOB) for large content.
Usage in an Entity¶
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'common_uuid')]
private Uuid $id;
#[ORM\Column(type: 'common_email_address')]
private EmailAddress $email;
#[ORM\Column(type: 'common_string', length: 255)]
private StringObject $name;
#[ORM\Column(type: 'common_string_text')]
private StringObject $biography;
}
Symfony Configuration¶
Register the types in config/packages/doctrine.yaml:
doctrine:
dbal:
types:
audit_entry_id: Fight\Common\Adapter\Persistence\Doctrine\Type\AuditEntryIdDataType
common_uuid: Fight\Common\Adapter\Persistence\Doctrine\Type\UuidDataType
common_email_address: Fight\Common\Adapter\Persistence\Doctrine\Type\EmailAddressDataType
common_uri: Fight\Common\Adapter\Persistence\Doctrine\Type\UriDataType
common_url: Fight\Common\Adapter\Persistence\Doctrine\Type\UrlDataType
common_string: Fight\Common\Adapter\Persistence\Doctrine\Type\StringObjectDataType
common_string_text: Fight\Common\Adapter\Persistence\Doctrine\Type\StringTextDataType
common_mb_string: Fight\Common\Adapter\Persistence\Doctrine\Type\MbStringObjectDataType
common_mb_string_text: Fight\Common\Adapter\Persistence\Doctrine\Type\MbStringTextDataType
common_json: Fight\Common\Adapter\Persistence\Doctrine\Type\JsonObjectDataType
common_meta: Fight\Common\Adapter\Persistence\Doctrine\Type\MetaDataType
common_type: Fight\Common\Adapter\Persistence\Doctrine\Type\TypeDataType
common_message: Fight\Common\Adapter\Persistence\Doctrine\Type\MessageDataType
The former Fight\Common\Adapter\Doctrine\*DataType paths remain silent deprecated 1.x
identities for existing consumers; use the canonical paths above for new configuration.