Safe, predictable type coercion for PHP. If it cannot convert meaningfully, it returns
null. Never guess.
PHP's native type juggling is unpredictable. (int)"abc" returns 0. empty(0) returns true. BackedEnum::from() throws on invalid values. Data arrives in many formats: HTML forms, JSON APIs, CSV files, .env files, and database rows. Each source has its own quirks.
php-coerce provides a single, consistent interface to handle all of it. The rule is simple: if a conversion is ambiguous, lossy, or meaningless, it returns null. Never guess.
use GladeHq\PhpCoerce\Coerce; Coerce::toInteger('42'); // 42 Coerce::toInteger('abc'); // null (not 0) Coerce::toInteger(42.9); // null (no silent truncation) Coerce::toBoolean('yes'); // true Coerce::toFloat('1,234.56'); // 1234.56 (auto-detected US format) Coerce::toDateTime('2024-01-15'); // DateTimeImmutable Coerce::toEnum('active', Status::class); // Status::Active Coerce::equals(0.1 + 0.2, 0.3); // true (IEEE-754 safe comparison) Coerce::toBcDecimal(3.14, 4); // '3.1400' (financial-safe decimal string) Coerce::toPercent('50%'); // 0.5 Coerce::isEmail('user@example.com'); // true- Requirements
- Installation
- Quick Start
- API Reference
- Configuration
- Real-World Examples
- Architecture
- Design Principles
- Edge Cases & Gotchas
- Testing
- Contributing
- Security
- Changelog
- License
| Requirement | Version |
|---|---|
| PHP | ^8.1 |
| Runtime dependencies | None |
Framework-agnostic. Works with Laravel, Symfony, Slim, or plain PHP.
composer require gladehq/php-coerceNo service providers, no configuration files, no bootstrapping required. Import the class and use it.
The simplest way to use the package. All methods are static, zero setup required.
use GladeHq\PhpCoerce\Coerce; $age = Coerce::toInteger($request->input('age')); // ?int $price = Coerce::toFloat($row['price']); // ?float $active = Coerce::toBoolean($env['FEATURE_FLAG']); // ?bool $status = Coerce::toEnum($data['status'], Status::class); // ?StatusChain from a single value with Coerce::from(). Use *Or($default) methods for guaranteed non-null returns.
use GladeHq\PhpCoerce\Coerce; $age = Coerce::from($request->input('age'))->toPositiveIntOr(1); $name = Coerce::from($input)->toStringOrEmpty(); $status = Coerce::from($value)->toEnum(Status::class); $tags = Coerce::from($input)->toArrayOr([], ','); $email = Coerce::from($input)->toNullIfBlank(); $discount = Coerce::from($input)->toPercent(); // '20%' → 0.2Coerce::toBoolean(mixed $value): ?bool| Input | Output |
|---|---|
true / false | true / false |
1 / 0 | true / false |
'true', '1', 'yes', 'on' | true |
'false', '0', 'no', 'off' | false |
2, 'abc', null, [] | null |
- String matching is case-insensitive:
"TRUE","True", and"true"all resolve totrue. - Only integers
0and1map to booleans.2,-1, and other integers returnnull. - Truthy and falsy string sets are configurable.
Coerce::isTruthy(mixed $value): bool // coerce($value) === true Coerce::isFalsy(mixed $value): bool // coerce($value) === falseFluent:
Coerce::from($value)->toBoolean(); // ?bool Coerce::from($value)->toBooleanOr(false); // bool Coerce::from($value)->isTruthy(); // bool Coerce::from($value)->isFalsy(); // boolCoerce::toInteger(mixed $value): ?int| Input | Output |
|---|---|
42 | 42 |
'42' | 42 |
' 42 ' | 42 (trimmed) |
'-5' | -5 |
'+42' | 42 |
'1,234,567' | 1234567 (auto-detected US format) |
'1.234.567' | 1234567 (auto-detected EU format) |
42.9 | null. No silent truncation. |
'42.9' | null |
true / false | null. Booleans are not numbers. |
'abc' | null |
'1e5' | null. Scientific notation is not integer domain. |
'007' | null. Leading zeros rejected. |
'9999999999999999999' | null. Overflow protection. |
Fluent:
Coerce::from($value)->toInteger(); // ?int Coerce::from($value)->toIntegerOr(0); // intCoerce::toPositiveInt(mixed $value): ?int // > 0 Coerce::toUnsignedInt(mixed $value): ?int // >= 0Thin guards built on top of toInteger().
| Input | toPositiveInt | toUnsignedInt |
|---|---|---|
1 | 1 | 1 |
0 | null | 0 |
-1 | null | null |
'42' | 42 | 42 |
'abc' | null | null |
Fluent:
Coerce::from($value)->toPositiveInt(); // ?int Coerce::from($value)->toPositiveIntOr(1); // int Coerce::from($value)->toUnsignedInt(); // ?int Coerce::from($value)->toUnsignedIntOr(0); // intCoerce::toFloat(mixed $value): ?float| Input | Output |
|---|---|
3.14 | 3.14 |
42 | 42.0 (int promoted to float) |
'3.14' | 3.14 |
'1,234.56' | 1234.56 (auto-detected US) |
'1.234,56' | 1234.56 (auto-detected EU) |
'1e5' | 100000.0 (scientific notation) |
'1.5E-3' | 0.0015 |
INF | INF (native float passthrough) |
NAN | null. Not a meaningful number. |
'INF' / 'NaN' | null. String representations rejected. |
true / false | null |
'abc' | null |
Fluent:
Coerce::from($value)->toFloat(); // ?float Coerce::from($value)->toFloatOr(0.0); // floatCoerce::toRoundedFloat(mixed $value, int $precision): ?floatCoerces to float then rounds to the requested number of decimal places. Returns null if the value cannot be coerced.
Coerce::toRoundedFloat(3.14159, 2); // 3.14 Coerce::toRoundedFloat('3.14159', 3); // 3.142 Coerce::toRoundedFloat('abc', 2); // nullFluent:
Coerce::from($value)->toRoundedFloat(2); // ?float Coerce::from($value)->toRoundedFloatOr(2, 0.0); // floatCoerce::toBcDecimal(mixed $value, int $scale = 10): ?stringReturns a fixed-point decimal string suitable for bcmath functions or financial display. The result has exactly $scale digits after the decimal point. Returns null for values that cannot be coerced, INF, or NAN.
Coerce::toBcDecimal(3.14); // '3.1400000000' Coerce::toBcDecimal(3.14, 2); // '3.14' Coerce::toBcDecimal('1,234.56', 4); // '1234.5600' Coerce::toBcDecimal(1e5, 2); // '100000.00' Coerce::toBcDecimal(INF); // null Coerce::toBcDecimal('abc'); // nullThrows \InvalidArgumentException if $scale < 0.
Fluent:
Coerce::from($value)->toBcDecimal(); // ?string (scale 10) Coerce::from($value)->toBcDecimal(4); // ?string (scale 4)Coerce::toPercent(mixed $value): ?floatNormalises a percentage value to a decimal ratio (0–1 range). Two input conventions are supported:
- Percent string (
'50%'): strips the%suffix and divides by 100 - Numeric value: if the value is within
[-1, 1]it is returned as-is (already a ratio); otherwise it is divided by 100
Coerce::toPercent('50%'); // 0.5 Coerce::toPercent('100%'); // 1.0 Coerce::toPercent('-50%'); // -0.5 Coerce::toPercent(50); // 0.5 (50 / 100) Coerce::toPercent(0.5); // 0.5 (already a ratio) Coerce::toPercent(1.5); // 0.015 (1.5 / 100) Coerce::toPercent('abc%'); // nullNote: An integer
1is treated as within[-1, 1]and returned as1.0(100%). If you mean 1%, pass'1%'.
Fluent:
Coerce::from($value)->toPercent(); // ?float Coerce::from($value)->toPercentOr(0.0); // floatCoerce::toString(mixed $value): ?string Coerce::toStringOrEmpty(mixed $value): string| Input | Output |
|---|---|
'hello' | 'hello' |
42 | '42' |
3.14 | '3.14' |
true / false | 'true' / 'false' |
| Stringable object | Result of __toString() |
null, [], non-Stringable object | null |
toStringOrEmpty() returns '' instead of null for unconvertible values.
Fluent:
Coerce::from($value)->toString(); // ?string Coerce::from($value)->toStringOr('N/A'); // string Coerce::from($value)->toStringOrEmpty(); // stringCoerce::toArray(mixed $value, ?string $separator = null): ?arrayConversion is attempted in this priority order:
- Already an array: returned as-is
- Traversable (Iterator, Generator): converted via
iterator_to_array() - JSON string (starts with
[or{): decoded withjson_decode() - String with separator: split by separator, each part trimmed
- Scalar string (no JSON, no separator): wrapped in a single-element array
- int, float, bool: wrapped in a single-element array
- null, non-traversable objects:
null
Coerce::toArray('["a","b"]'); // ['a', 'b'] Coerce::toArray('{"key":"val"}'); // ['key' => 'val'] Coerce::toArray('a, b, c', ','); // ['a', 'b', 'c'] Coerce::toArray('one|two', '|'); // ['one', 'two'] Coerce::toArray('[]'); // [] Coerce::toArray('{}'); // [] Coerce::toArray(42); // [42] Coerce::toArray(null); // nullJSON takes priority over separator splitting:
Coerce::toArray('["a","b"]', ','); // ['a', 'b'] (JSON wins)Fluent:
Coerce::from($value)->toArray(','); // ?array Coerce::from($value)->toArrayOr([], ','); // array/** * @param callable(mixed): mixed $fn * @return array<int|string, mixed>|null */ Coerce::coerceEach(mixed $value, callable $fn): ?arrayCoerces the value to an array (using the same logic as toArray()) then applies $fn to every element. Returns null if the value cannot be converted to an array. Keys are preserved.
Coerce::coerceEach('[1,2,3]', fn($v) => Coerce::toInteger($v)); // [1, 2, 3] Coerce::coerceEach('a,b,c', fn($v) => strtoupper($v)); // null — no separator provided, 'a,b,c' wraps to ['a,b,c'] // Pass separator via toArray first, or use the fluent API: Coerce::from('a,b,c')->toArray(','); // then map manually, or: Coerce::coerceEach(['a', 'b', 'c'], fn($v) => strtoupper($v)); // ['A', 'B', 'C'] Coerce::coerceEach(null, fn($v) => $v); // nullFluent:
Coerce::from($value)->coerceEach(fn($v) => Coerce::toInteger($v)); // ?arrayCoerce::toDateTime(mixed $value): ?DateTimeImmutableAlways returns DateTimeImmutable or null. Never DateTime.
| Input | Output |
|---|---|
DateTimeImmutable instance | Returned as-is |
DateTime instance | Converted to DateTimeImmutable |
1705276800 (int) | Unix timestamp, returns DateTimeImmutable |
0 (int) | Unix epoch (1970-01-01) |
-1 (int) | 1969-12-31 23:59:59 |
'2024-01-15' | Parsed by PHP |
'2024-01-15T10:30:00+00:00' | ISO 8601 parsed |
'0' (string) | null. Numeric strings rejected. |
'1705276800' (string) | null. Pass as int for timestamps. |
'not a date' | null |
'', ' ' | null |
3.14, true, [] | null |
Important: Numeric strings are intentionally rejected in auto mode. To handle a timestamp stored as a string, cast it first:
Coerce::toDateTime(1705276800); // DateTimeImmutable (2024-01-15) Coerce::toDateTime('1705276800'); // null Coerce::toDateTime((int) '1705276800'); // DateTimeImmutable (2024-01-15)Custom date format enforces strict matching. Trailing garbage is rejected:
Coerce::setDateFormat('d/m/Y'); Coerce::toDateTime('15/01/2024'); // DateTimeImmutable Coerce::toDateTime('15/01/2024 garbage'); // null (strict matching) Coerce::toDateTime('2024-01-15'); // null (format mismatch)Auto mode accepts PHP's relative date strings.
In auto mode, toDateTime() delegates to PHP's native \DateTimeImmutable constructor, which accepts the full range of formats that strtotime() understands. This includes relative expressions:
Coerce::toDateTime('next monday'); // DateTimeImmutable (next Monday at 00:00:00) Coerce::toDateTime('last friday'); // DateTimeImmutable (last Friday at 00:00:00) Coerce::toDateTime('tomorrow'); // DateTimeImmutable (tomorrow at 00:00:00) Coerce::toDateTime('yesterday'); // DateTimeImmutable (yesterday at 00:00:00) Coerce::toDateTime('+2 weeks'); // DateTimeImmutable (14 days from now) Coerce::toDateTime('now'); // DateTimeImmutable (current date and time)This is not a bug. It is PHP's documented behavior, and it is intentionally preserved so that toDateTime() in auto mode is as permissive as PHP itself.
However, if your input comes from untrusted sources (user form fields, API payloads, CSV rows) and you expect a concrete date, relative strings passing through silently may be surprising. For example, an API field that should contain a birth date would silently accept 'next monday' and produce a future date with no error.
The fix is to set an explicit format. When a format is configured, the native fallback is bypassed entirely and only strings that match the format exactly are accepted:
Coerce::setDateFormat('Y-m-d'); Coerce::toDateTime('2024-01-15'); // DateTimeImmutable Coerce::toDateTime('next monday'); // null (does not match Y-m-d) Coerce::toDateTime('tomorrow'); // null (does not match Y-m-d) Coerce::toDateTime('now'); // null (does not match Y-m-d)Use setDateFormat() whenever you are processing external input that must represent a real, absolute date.
Fluent:
Coerce::from($value)->toDateTime(); // ?DateTimeImmutable Coerce::from($value)->toDateTimeOr(new DateTimeImmutable()); // DateTimeImmutableCoerce::toEnum(mixed $value, string $enumClass): ?BackedEnumWorks with PHP 8.1+ backed enums (string-backed and int-backed). Returns null instead of throwing.
enum Status: string { case Active = 'active'; case Inactive = 'inactive'; } enum Priority: int { case Low = 1; case Medium = 2; case High = 3; }| Input | Enum Class | Output |
|---|---|---|
'active' | Status::class | Status::Active |
Status::Active | Status::class | Status::Active (returned as-is) |
'invalid' | Status::class | null |
1 | Priority::class | Priority::Low |
'2' | Priority::class | Priority::Medium (cross-type coercion) |
99 | Priority::class | null |
null | any | null |
| any value | Unit enum class | null. Unit enums not supported. |
Cross-type coercion: a string "2" will match an int-backed enum with value 2. An int 1 will try string "1" against string-backed enums. Unit enums (enums without a backing type) safely return null. No crash.
Fluent:
Coerce::from($value)->toEnum(Status::class); // ?Status Coerce::from($value)->toEnumOr(Status::class, Status::Active); // StatusCoerce::equals(mixed $a, mixed $b): bool Coerce::isOneOf(mixed $value, array $values): boolSemantic equality that respects types. Comparison layers are applied in this priority order:
- Same type: strict
===for non-floats; epsilon comparison for floats - One side is native
bool: coerce both to boolean and compare - Both coercible to float: epsilon comparison
- Both coercible to string: compare as strings
- None match:
false
Coerce::equals(42, '42'); // true (numeric layer) Coerce::equals(3.14, '3.14'); // true (numeric layer) Coerce::equals(true, 'yes'); // true (boolean layer, one side is bool) Coerce::equals(true, 1); // true (boolean layer, one side is bool) Coerce::equals('1', 'true'); // false (neither side is bool) Coerce::equals('0', 'false'); // false (neither side is bool) Coerce::equals(null, null); // true (same type) // IEEE-754 safe — these all return true Coerce::equals(0.1 + 0.2, 0.3); // true Coerce::equals(1/3 * 3, 1.0); // true Coerce::isOneOf(42, [1, 42, 100]); // true Coerce::isOneOf('42', [1, 42]); // true (cross-type) Coerce::isOneOf(99, [1, 42]); // falseThe boolean layer only activates when at least one operand is a native
bool. Two strings like"1"and"true"are never compared as booleans. They fall through to string comparison.
Float comparisons use a relative epsilon (
|a - b| ≤ ε × max(|a|, |b|, 1)). The default epsilon isPHP_FLOAT_EPSILON. See Float Epsilon to customise it.
Coerce::isBlank(mixed $value): bool Coerce::isPresent(mixed $value): bool| Input | isBlank | isPresent |
|---|---|---|
null | true | false |
'' | true | false |
' ' (whitespace only) | true | false |
[] | true | false |
0 | false | true |
false | false | true |
'hello' | false | true |
[1, 2] | false | true |
Unlike PHP's empty(), the values 0 and false are not blank. They are valid, meaningful values.
Fluent:
Coerce::from($value)->isBlank(); // bool Coerce::from($value)->isPresent(); // bool Coerce::from($value)->toNullIfBlank(); // mixed — returns null if blank, original value otherwisetoNullIfBlank() is useful for normalising optional form fields before persistence:
$bio = Coerce::from($request->input('bio'))->toNullIfBlank(); // null or stringCoerce::isEmail(mixed $value): bool Coerce::isUrl(mixed $value): boolThin wrappers over PHP's filter_var. Non-string values always return false.
Coerce::isEmail('user@example.com'); // true Coerce::isEmail('invalid'); // false Coerce::isEmail(null); // false Coerce::isEmail(42); // false Coerce::isUrl('https://example.com'); // true Coerce::isUrl('not-a-url'); // false Coerce::isUrl(null); // falseFluent:
Coerce::from($value)->isEmail(); // bool Coerce::from($value)->isUrl(); // boolAll configuration is optional. The package works out of the box with sensible defaults.
Controls how numeric strings with separators are interpreted.
// Auto (default): intelligently detects format Coerce::toFloat('1,234.56'); // 1234.56 (detected US) Coerce::toFloat('1.234,56'); // 1234.56 (detected EU) // Force US format: comma = thousands separator, dot = decimal Coerce::setNumberFormat('us'); Coerce::toFloat('1,234.56'); // 1234.56 // Force EU format: dot = thousands separator, comma = decimal Coerce::setNumberFormat('eu'); Coerce::toFloat('1.234,56'); // 1234.56Auto-detection rules:
| Input pattern | Interpretation |
|---|---|
Single dot or comma only (1.5, 1,5) | Decimal separator |
Multiple dots (1.234.567) | Thousands separator |
Multiple commas (1,234,567) | Thousands separator |
Both dot and comma, dot last (1,234.56) | US format |
Both dot and comma, comma last (1.234,56) | EU format |
// Auto (default): PHP native parsing Coerce::toDateTime('2024-01-15'); // works Coerce::toDateTime('Jan 15, 2024'); // works // Custom format: strict matching, no trailing garbage Coerce::setDateFormat('d/m/Y'); Coerce::toDateTime('15/01/2024'); // DateTimeImmutable Coerce::toDateTime('2024-01-15'); // null (wrong format) Coerce::toDateTime('15/01/2024 extra'); // null (trailing data rejected)Customize which strings are considered truthy or falsy:
Coerce::configure([ 'truthy_values' => ['true', '1', 'yes', 'on', 'enabled', 'active'], 'falsy_values' => ['false', '0', 'no', 'off', 'disabled', 'inactive'], ]); Coerce::toBoolean('enabled'); // true Coerce::toBoolean('inactive'); // falseControls the tolerance used by equals() when comparing float values.
// Default: PHP_FLOAT_EPSILON (~2.2e-16) Coerce::equals(0.1 + 0.2, 0.3); // true // Widen epsilon for less-precise comparisons Configuration::setFloatEpsilon(0.01); Coerce::equals(1.0, 1.005); // true Coerce::equals(1.0, 1.02); // falseThe comparison formula is relative, not absolute: |a - b| ≤ ε × max(|a|, |b|, 1). This prevents epsilon from becoming meaninglessly small for large numbers or inappropriately large for numbers near zero.
Throws \InvalidArgumentException if epsilon is <= 0.
Coerce::configure([ 'number_format' => 'eu', 'date_format' => 'd/m/Y', 'truthy_values' => ['true', '1', 'yes', 'on'], 'falsy_values' => ['false', '0', 'no', 'off'], 'float_epsilon' => 0.0001, ]);| Option | Accepted values | Default |
|---|---|---|
number_format | 'auto', 'us', 'eu' | 'auto' |
date_format | Any PHP date format string, or 'auto' | 'auto' |
truthy_values | list<string> | ['true', '1', 'yes', 'on'] |
falsy_values | list<string> | ['false', '0', 'no', 'off'] |
float_epsilon | float > 0 | PHP_FLOAT_EPSILON |
Coerce::resetConfiguration(); // Restores all defaults$age = Coerce::from($request->input('age'))->toIntegerOr(0); $subscribe = Coerce::from($request->input('newsletter'))->toBooleanOr(false); $tags = Coerce::from($request->input('tags'))->toArrayOr([], ','); $joinedAt = Coerce::toDateTime($request->input('joined_at'));$debug = Coerce::toBoolean(env('APP_DEBUG')); // ?bool $port = Coerce::from(env('PORT'))->toIntegerOr(8080); // int $timeout = Coerce::from(env('TIMEOUT'))->toFloatOr(30.0); // float $dsn = Coerce::from(env('DATABASE_URL'))->toStringOr(''); // stringCoerce::setNumberFormat('eu'); foreach ($rows as $row) { $price = Coerce::toFloat($row['price']); // "1.234,56" -> 1234.56 $qty = Coerce::toInteger($row['qty']); // "42" -> 42, "abc" -> null $date = Coerce::toDateTime($row['date']); // "15/01/2024" -> DateTimeImmutable }$status = Coerce::toEnum($response['status'], OrderStatus::class); $createdAt = Coerce::toDateTime($response['created_at']); $amount = Coerce::toFloat($response['amount']); if ($status === null) { throw new InvalidResponseException('Unknown order status: ' . $response['status']); }$config = [ 'retries' => Coerce::from($options['retries'] ?? null)->toIntegerOr(3), 'timeout' => Coerce::from($options['timeout'] ?? null)->toFloatOr(30.0), 'verbose' => Coerce::from($options['verbose'] ?? null)->toBooleanOr(false), 'tags' => Coerce::from($options['tags'] ?? null)->toArrayOr([]), ];// Unlike empty(), zero and false are NOT blank Coerce::isBlank(0); // false Coerce::isBlank(false); // false Coerce::isBlank(''); // true Coerce::isBlank(' '); // true Coerce::isBlank(null); // true Coerce::isBlank([]); // true if (Coerce::isPresent($input)) { // We have a real, meaningful value }// String value matching int-backed enum $priority = Coerce::toEnum('2', Priority::class); // Priority::Medium // Enum instance passed through unchanged $same = Coerce::toEnum(Status::Active, Status::class); // Status::Active // Unknown value, returns null instead of throwing $unknown = Coerce::toEnum('deleted', Status::class); // null// Safe decimal strings for bcmath — no floating-point drift $price = Coerce::toBcDecimal($row['price'], 2); // '1234.56' $tax_rate = Coerce::toPercent($row['tax_rate']); // 0.2 from '20%' or 20 if ($price !== null && $tax_rate !== null) { $tax = bcmul($price, (string) $tax_rate, 2); // '246.91' }$email = Coerce::from($request->input('email'))->toNullIfBlank(); if ($email !== null && ! Coerce::isEmail($email)) { throw new ValidationException('Invalid email address.'); } $website = Coerce::from($request->input('website'))->toNullIfBlank(); if ($website !== null && ! Coerce::isUrl($website)) { throw new ValidationException('Invalid URL.'); }// JSON payload with mixed types — coerce all IDs to integers $ids = Coerce::coerceEach($request->input('ids'), fn($v) => Coerce::toInteger($v)); // null if 'ids' is not array-like, or [1, 2, 3] after coercion // Filter out nulls after coercion $validIds = array_filter($ids ?? [], fn($v) => $v !== null);$page = Coerce::from($request->input('page'))->toPositiveIntOr(1); // min page 1 $offset = Coerce::from($request->input('offset'))->toUnsignedIntOr(0); // no negatives $perPage = Coerce::from($request->input('per_page'))->toPositiveIntOr(25);Coerce (static facade + configuration API) │ ├── CoerceValue (fluent wrapper for Coerce::from()) │ ├── Configuration (number_format, date_format, truthy/falsy values, float_epsilon) │ ├── Coercers/ │ ├── BooleanCoercer toBoolean, isTruthy, isFalsy │ ├── NumericCoercer toInteger, toFloat, toPositiveInt, toUnsignedInt, │ │ toPercent, toRoundedFloat, toBcDecimal (US / EU / auto) │ ├── StringCoercer toString, toStringOrEmpty │ ├── ArrayCoercer JSON, separator, Traversable │ ├── DateTimeCoercer timestamps, string parsing, strict format │ ├── EnumCoercer BackedEnum with cross-type coercion │ └── ComparisonCoercer equals (IEEE-754 safe), isOneOf │ └── Detectors/ └── BlankDetector isBlank, isPresent, toNullIfBlank, isEmail, isUrl All coercers and detectors are @internal. The public API surface consists of Coerce and CoerceValue only. Internal classes may change between minor versions without a semver break.
| Principle | Description |
|---|---|
| Null over guessing | If a conversion is ambiguous or lossy, return null. Never invent a value. |
| No exceptions | Coercion methods never throw; invalid input returns null |
| No silent truncation | toInteger(42.9) returns null, not 42 |
| Booleans are not numbers | toInteger(true) returns null, not 1 |
| Type safety | Generics on enum coercion, strict return types throughout |
| Zero dependencies | Only PHP 8.1+ standard library. No framework coupling. |
| PHPStan level 9 | Maximum static analysis strictness enforced in CI |
| Scenario | Behavior | Rationale |
|---|---|---|
toInteger(42.0) | null | Float is never silently truncated to int |
toInteger(true) | null | Booleans must not silently become numbers |
toBoolean(2) | null | Only 0 and 1 are boolean integers |
toFloat(NAN) | null | NaN is not a meaningful number |
toFloat(INF) | INF | Valid IEEE 754 float, passed through |
toFloat("INF") | null | String "INF" is not a valid numeric string |
toFloat("1e5") | 100000.0 | Scientific notation is valid float domain |
toInteger("1e5") | null | Scientific notation is not integer domain |
toInteger("007") | null | Leading zeros rejected (ambiguous octal) |
toDateTime("0") | null | Numeric strings rejected. Use toDateTime(0). |
toDateTime(0) | 1970-01-01 | Integer zero is valid Unix epoch |
toEnum('x', UnitEnum::class) | null | Unit enums gracefully return null, not crash |
equals("1", "true") | false | Boolean layer requires a native bool operand |
equals(true, "yes") | true | Native bool present. Boolean layer activates. |
equals(0.1 + 0.2, 0.3) | true | IEEE-754 epsilon comparison, not === |
toArray("a,b,", ",") | ["a","b",""] | Trailing separator produces empty string element |
isBlank(0) | false | Unlike empty(), zero is a meaningful value |
isBlank(false) | false | Unlike empty(), false is a meaningful value |
toPositiveInt(0) | null | Zero is not positive |
toUnsignedInt(-1) | null | Negative integers are not unsigned |
toPercent(1) | 1.0 | Integer 1 is within [-1, 1], returned as-is (100%) — use '1%' for 1% |
toBcDecimal(INF) | null | Infinity has no decimal representation |
isEmail(42) | false | Non-string values always return false |
isUrl(null) | false | Non-string values always return false |
toNullIfBlank('') | null | Blank values become null |
toNullIfBlank(0) | 0 | Non-blank values returned unchanged |
# Run the full test suite vendor/bin/phpunit # Static analysis, PHPStan level 9 vendor/bin/phpstan analyse # Code style check, PSR-12 (dry run) vendor/bin/php-cs-fixer fix --dry-run --diff # Code style fix, apply changes vendor/bin/php-cs-fixer fixThe test suite covers 282 tests and 410 assertions, including:
- All coercion methods with valid, invalid, and boundary inputs
- Auto-detection logic for US/EU number formats
- DateTime parsing: timestamps, strings, custom formats, edge cases
- Enum coercion: backed enums, cross-type, unit enum safety
- IEEE-754 float comparison regression (
equals(0.1 + 0.2, 0.3) === true) - Comparison semantics: boolean layer activation rules, custom epsilon
toBcDecimal,toPercent,toRoundedFloat,toPositiveInt,toUnsignedIntisEmail,isUrl,toNullIfBlank,coerceEach- Configuration mutations and reset (including
float_epsilon) - Full fluent API coverage with default fallbacks
Contributions are welcome. Please follow these guidelines:
- Fork the repository and create a feature branch from
main - Write tests for all new behaviour. The test suite must pass.
- Ensure PHPStan level 9 reports no errors:
vendor/bin/phpstan analyse - Follow PSR-12 coding style:
vendor/bin/php-cs-fixer fix --dry-run --diff - Open a pull request with a clear description of the change and its rationale
For significant changes or new coercion domains, open an issue first to discuss the approach.
If you discover a security vulnerability, please do not open a public GitHub issue. Report it privately via the repository's Security Advisories tab on GitHub.
All security reports will be acknowledged within 48 hours.
All notable changes are documented in CHANGELOG.md. This project follows Semantic Versioning.
The MIT License (MIT). See LICENSE for full details.
Made with care by Dulitha Rajapaksha