gladehq / php-coerce
Safe, predictable type coercion for PHP. If it can't convert meaningfully, return null — never guess.
Requires
- php: ^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^1.10|^2.0
- phpunit/phpunit: ^10.5|^11.0
This package is auto-updated.
Last update: 2026-03-15 11:37:32 UTC
README
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
Table of Contents
- Requirements
- Installation
- Quick Start
- API Reference
- Configuration
- Real-World Examples
- Architecture
- Design Principles
- Edge Cases & Gotchas
- Testing
- Contributing
- Security
- Changelog
- License
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.1 |
| Runtime dependencies | None |
Framework-agnostic. Works with Laravel, Symfony, Slim, or plain PHP.
Installation
composer require gladehq/php-coerce
No service providers, no configuration files, no bootstrapping required. Import the class and use it.
Quick Start
Static API
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); // ?Status
Fluent API
Chain 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.2
API Reference
Boolean Coercion
Coerce::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) === false
Fluent:
Coerce::from($value)->toBoolean(); // ?bool Coerce::from($value)->toBooleanOr(false); // bool Coerce::from($value)->isTruthy(); // bool Coerce::from($value)->isFalsy(); // bool
Integer Coercion
Coerce::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); // int
Positive & Unsigned Integer
Coerce::toPositiveInt(mixed $value): ?int // > 0 Coerce::toUnsignedInt(mixed $value): ?int // >= 0
Thin 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); // int
Float Coercion
Coerce::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); // float
Rounded Float
Coerce::toRoundedFloat(mixed $value, int $precision): ?float
Coerces 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); // null
Fluent:
Coerce::from($value)->toRoundedFloat(2); // ?float Coerce::from($value)->toRoundedFloatOr(2, 0.0); // float
Decimal String (BC Math safe)
Coerce::toBcDecimal(mixed $value, int $scale = 10): ?string
Returns 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'); // null
Throws \InvalidArgumentException if $scale < 0.
Fluent:
Coerce::from($value)->toBcDecimal(); // ?string (scale 10) Coerce::from($value)->toBcDecimal(4); // ?string (scale 4)
Percent
Coerce::toPercent(mixed $value): ?float
Normalises 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%'); // null
Note: 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); // float
String Coercion
Coerce::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(); // string
Array Coercion
Coerce::toArray(mixed $value, ?string $separator = null): ?array
Conversion 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); // null
JSON takes priority over separator splitting:
Coerce::toArray('["a","b"]', ','); // ['a', 'b'] (JSON wins)
Fluent:
Coerce::from($value)->toArray(','); // ?array Coerce::from($value)->toArrayOr([], ','); // array
Array Transform
/** * @param callable(mixed): mixed $fn * @return array<int|string, mixed>|null */ Coerce::coerceEach(mixed $value, callable $fn): ?array
Coerces 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); // null
Fluent:
Coerce::from($value)->coerceEach(fn($v) => Coerce::toInteger($v)); // ?array
DateTime Coercion
Coerce::toDateTime(mixed $value): ?DateTimeImmutable
Always 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()); // DateTimeImmutable
Enum Coercion
Coerce::toEnum(mixed $value, string $enumClass): ?BackedEnum
Works 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); // Status
Comparison
Coerce::equals(mixed $a, mixed $b): bool Coerce::isOneOf(mixed $value, array $values): bool
Semantic 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]); // false
The 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.
Blank Detection
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 otherwise
toNullIfBlank() is useful for normalising optional form fields before persistence:
$bio = Coerce::from($request->input('bio'))->toNullIfBlank(); // null or string
Format Validation
Coerce::isEmail(mixed $value): bool Coerce::isUrl(mixed $value): bool
Thin 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); // false
Fluent:
Coerce::from($value)->isEmail(); // bool Coerce::from($value)->isUrl(); // bool
Configuration
All configuration is optional. The package works out of the box with sensible defaults.
Number Format
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.56
Auto-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 |
Date 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)
Boolean Values
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'); // false
Float Epsilon
Controls 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); // false
The 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.
Bulk Configuration
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 |
Reset
Coerce::resetConfiguration(); // Restores all defaults
Real-World Examples
Form Input Processing
$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'));
Environment Variables
$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(''); // string
CSV / Spreadsheet Data
Coerce::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 }
API Response Normalization
$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']); }
Safe Defaults with Fluent API
$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([]), ];
Blank vs Empty Checks
// 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 }
Cross-Type Enum Matching
// 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
Financial / Decimal Arithmetic
// 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' }
Validated User Input
$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.'); }
Batch Array Coercion
// 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);
Positive / Unsigned Guards
$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);
Architecture
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.
Design Principles
| 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 |
Edge Cases & Gotchas
| 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 |
Testing
# 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 fix
The 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
Contributing
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.
Security
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.
Changelog
All notable changes are documented in CHANGELOG.md. This project follows Semantic Versioning.
License
The MIT License (MIT). See LICENSE for full details.
Made with care by Dulitha Rajapaksha