Skip to content

gladehq/php-coerce

Repository files navigation

php-coerce

Safe, predictable type coercion for PHP. If it cannot convert meaningfully, it returns null. Never guess.

PHP Version Latest Version on Packagist Total Downloads Tests PHPStan Level License: MIT PSR-12


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

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 to true.
  • Only integers 0 and 1 map to booleans. 2, -1, and other integers return null.
  • 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 1 is treated as within [-1, 1] and returned as 1.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:

  1. Already an array: returned as-is
  2. Traversable (Iterator, Generator): converted via iterator_to_array()
  3. JSON string (starts with [ or {): decoded with json_decode()
  4. String with separator: split by separator, each part trimmed
  5. Scalar string (no JSON, no separator): wrapped in a single-element array
  6. int, float, bool: wrapped in a single-element array
  7. 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:

  1. Same type: strict === for non-floats; epsilon comparison for floats
  2. One side is native bool: coerce both to boolean and compare
  3. Both coercible to float: epsilon comparison
  4. Both coercible to string: compare as strings
  5. 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 is PHP_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, toUnsignedInt
  • isEmail, 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:

  1. Fork the repository and create a feature branch from main
  2. Write tests for all new behaviour. The test suite must pass.
  3. Ensure PHPStan level 9 reports no errors: vendor/bin/phpstan analyse
  4. Follow PSR-12 coding style: vendor/bin/php-cs-fixer fix --dry-run --diff
  5. 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

About

Safe, predictable type coercion for PHP. Returns null instead of guessing when a conversion is ambiguous or meaningless.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages