From add013de24a08743834382bc4e3f0e8ed945de7c Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 10 Oct 2025 15:06:28 +0200 Subject: [PATCH 1/9] feat: add class SafeCast --- README.md | 25 ++++ src/FluidXPlate/FluidXScanner.php | 2 +- src/SafeCast.php | 184 ++++++++++++++++++++++++++++++ tests/SafeCastTest.php | 173 ++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/SafeCast.php create mode 100644 tests/SafeCastTest.php diff --git a/README.md b/README.md index 3cc88c10..ee9fc773 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,31 @@ composer require mll-lab/php-utils See [tests](tests). +### SafeCast + +PHP's native type casts like `(int)` and `(float)` can produce unexpected results, especially when casting from strings. +The `SafeCast` utility provides safe alternatives that validate input before casting: + +```php +use MLL\Utils\SafeCast; + +// Safe integer casting +SafeCast::toInt(42); // 42 +SafeCast::toInt('42'); // 42 +SafeCast::toInt('hello'); // throws InvalidArgumentException + +// Safe float casting +SafeCast::toFloat(3.14); // 3.14 +SafeCast::toFloat('3.14'); // 3.14 +SafeCast::toFloat('abc'); // throws InvalidArgumentException + +// Safe string casting +SafeCast::toString(42); // '42' +SafeCast::toString(null); // '' +``` + +See [tests](tests/SafeCastTest.php) for more examples. + ### Holidays You can add custom holidays by registering a method that returns a map of holidays for a given year. diff --git a/src/FluidXPlate/FluidXScanner.php b/src/FluidXPlate/FluidXScanner.php index 166ddf5b..664a394b 100644 --- a/src/FluidXPlate/FluidXScanner.php +++ b/src/FluidXPlate/FluidXScanner.php @@ -54,7 +54,7 @@ public static function parseRawContent(string $rawContent): FluidXPlate $barcodes = []; $id = null; foreach ($lines as $line) { - if ($line === '' || $line === self::READING || $line === self::XTR_96_CONNECTED) { + if (in_array($line, ['', self::READING, self::XTR_96_CONNECTED], true)) { continue; } $content = explode(', ', $line); diff --git a/src/SafeCast.php b/src/SafeCast.php new file mode 100644 index 00000000..a1b507d5 --- /dev/null +++ b/src/SafeCast.php @@ -0,0 +1,184 @@ + 5) + if (is_float($value)) { + if ($value === floor($value) && is_finite($value)) { + return (int) $value; + } + + throw new \InvalidArgumentException('Float value "' . $value . '" cannot be safely cast to int (not a whole number or not finite)'); + } + + if (is_string($value)) { + $trimmed = trim($value); + + // Empty string is not a valid integer + if ($trimmed === '') { + throw new \InvalidArgumentException('Empty string cannot be cast to int'); + } + + // Check if the string represents a valid integer + if (! self::isIntegerString($trimmed)) { + throw new \InvalidArgumentException('String value "' . $value . '" is not a valid integer format'); + } + + return (int) $trimmed; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to int'); + } + + /** + * Safely cast a value to a float. + * + * Only accepts: + * - Floats (returned as-is) + * - Integers (cast to float) + * - Numeric strings that represent valid floats + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a float + */ + public static function toFloat($value): float + { + if (is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + // Empty string is not a valid float + if ($trimmed === '') { + throw new \InvalidArgumentException('Empty string cannot be cast to float'); + } + + // Check if the string represents a valid numeric value + if (! self::isNumericString($trimmed)) { + throw new \InvalidArgumentException('String value "' . $value . '" is not a valid numeric format'); + } + + return (float) $trimmed; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to float'); + } + + /** + * Safely cast a value to a string. + * + * Only accepts: + * - Strings (returned as-is) + * - Integers and floats (converted to string) + * - Objects with __toString() method + * - null (converted to empty string) + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a string + */ + public static function toString($value): string + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if ($value === null) { + return ''; + } + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string'); + } + + /** + * Check if a string represents a valid integer. + * + * Accepts optional leading/trailing whitespace, optional sign, and digits only. + */ + private static function isIntegerString(string $value): bool + { + try { + return preg_match('/^[+-]?\d+$/', $value) === 1; + } catch (PcreException $ex) { + return false; + } + } + + /** + * Check if a string represents a valid numeric value (integer or float). + * + * Accepts scientific notation, decimals with optional sign. + */ + private static function isNumericString(string $value): bool + { + // Use is_numeric() but verify it's not in a weird format + if (! is_numeric($value)) { + return false; + } + + // is_numeric accepts some formats we might want to reject + // like hexadecimal (0x1F) or binary (0b1010) + // Check for these and reject them for stricter validation + try { + $hasHexOrBinary = preg_match('/^0[xXbB]/', $value) === 1; + + return ! $hasHexOrBinary; + } catch (PcreException $ex) { + return false; + } + } +} diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php new file mode 100644 index 00000000..ff8716ff --- /dev/null +++ b/tests/SafeCastTest.php @@ -0,0 +1,173 @@ + */ + public static function validIntProvider(): iterable + { + yield [42, 42]; + yield [-123, -123]; + yield [0, 0]; + yield [42, '42']; + yield [-123, '-123']; + yield [0, '0']; + yield [999, ' 999 ']; + yield [5, 5.0]; + yield [-10, -10.0]; + yield [0, 0.0]; + yield [42, '042']; + yield [0, '000']; + yield [42, '+42']; + } + + /** + * @dataProvider invalidIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidIntProvider')] + public function testToIntThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toInt($input); + } + + /** @return iterable */ + public static function invalidIntProvider(): iterable + { + yield ['String value "hello" is not a valid integer format', 'hello']; + yield ['String value "123abc" is not a valid integer format', '123abc']; + yield ['String value "12.34" is not a valid integer format', '12.34']; + yield ['Empty string cannot be cast to int', '']; + yield ['Float value "5.5" cannot be safely cast to int', 5.5]; + yield ['cannot be safely cast to int (not a whole number or not finite)', INF]; + yield ['Cannot cast value of type "array" to int', []]; + } + + /** + * @dataProvider validFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validFloatProvider')] + public function testToFloatWithValidInput(float $expected, $input): void + { + self::assertSame($expected, SafeCast::toFloat($input)); + } + + /** @return iterable */ + public static function validFloatProvider(): iterable + { + yield [3.14, 3.14]; + yield [-2.5, -2.5]; + yield [0.0, 0.0]; + yield [42.0, 42]; + yield [-123.0, -123]; + yield [0.0, 0]; + yield [3.14, '3.14']; + yield [-2.5, '-2.5']; + yield [42.0, '42']; + yield [1.23, ' 1.23 ']; + yield [1.5e3, '1.5e3']; + yield [2.5e-2, '2.5e-2']; + } + + /** + * @dataProvider invalidFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidFloatProvider')] + public function testToFloatThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toFloat($input); + } + + /** @return iterable */ + public static function invalidFloatProvider(): iterable + { + yield ['String value "hello" is not a valid numeric format', 'hello']; + yield ['String value "3.14abc" is not a valid numeric format', '3.14abc']; + yield ['String value "1.2.3" is not a valid numeric format', '1.2.3']; + yield ['Empty string cannot be cast to float', '']; + yield ['Cannot cast value of type "array" to float', []]; + yield ['is not a valid numeric format', '0x1A']; + yield ['is not a valid numeric format', '0b1010']; + } + + /** + * @dataProvider validStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validStringProvider')] + public function testToStringWithValidInput(string $expected, $input): void + { + self::assertSame($expected, SafeCast::toString($input)); + } + + /** @return iterable */ + public static function validStringProvider(): iterable + { + yield ['hello', 'hello']; + yield ['', '']; + yield ['42', 42]; + yield ['-123', -123]; + yield ['0', 0]; + yield ['3.14', 3.14]; + yield ['-2.5', -2.5]; + yield ['', null]; + } + + public function testToStringWithObjectHavingToStringMethod(): void + { + $object = new class() { + public function __toString(): string + { + return 'object-string'; + } + }; + + self::assertSame('object-string', SafeCast::toString($object)); + } + + /** + * @dataProvider invalidStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidStringProvider')] + public function testToStringThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toString($input); + } + + /** @return iterable */ + public static function invalidStringProvider(): iterable + { + yield ['Cannot cast value of type "object" to string', new \stdClass()]; + yield ['Cannot cast value of type "array" to string', []]; + } +} From e2bf17eb72e923a1b756bde5d04d69971f6ec2c1 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 10 Nov 2025 12:11:03 +0100 Subject: [PATCH 2/9] actually use SafeCast --- .../V2/BclConvert/OverrideCycles.php | 3 ++- .../LightcyclerDataParsingTrait.php | 7 ++----- .../LightcyclerXmlParser.php | 3 ++- .../AbsoluteQuantificationSample.php | 7 ++++++- src/Microplate/CoordinateSystem.php | 9 +++++---- src/Microplate/Coordinates.php | 3 ++- src/Microplate/FullColumnSection.php | 3 ++- .../MicroplateSet/MicroplateSet.php | 3 ++- src/SafeCast.php | 20 +++---------------- src/StringUtil.php | 7 ++++--- .../BasicPipettingActionCommand.php | 3 ++- .../V2/DataSectionTest.php | 9 +++++++++ .../QpcrXmlParserTest.php | 2 +- tests/SafeCastTest.php | 6 ++++++ tests/StringUtilTest.php | 8 ++++++++ 15 files changed, 56 insertions(+), 37 deletions(-) diff --git a/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php b/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php index 03715cfd..33565ac8 100644 --- a/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php +++ b/src/IlluminaSampleSheet/V2/BclConvert/OverrideCycles.php @@ -4,6 +4,7 @@ use MLL\Utils\IlluminaSampleSheet\IlluminaSampleSheetException; use MLL\Utils\IlluminaSampleSheet\V2\HeaderSection; +use MLL\Utils\SafeCast; class OverrideCycles { @@ -55,7 +56,7 @@ public function makeOverrideCycle(string $cycleString): OverrideCycle return new OverrideCycle( array_map( - fn (array $match): CycleTypeWithCount => new CycleTypeWithCount(new CycleType($match[1]), (int) $match[2]), + fn (array $match): CycleTypeWithCount => new CycleTypeWithCount(new CycleType($match[1]), SafeCast::toInt($match[2])), $matches ) ); diff --git a/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php index d40dea02..014a8c06 100644 --- a/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php +++ b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php @@ -3,6 +3,7 @@ namespace MLL\Utils\LightcyclerExportSheet; use Illuminate\Support\Collection; +use MLL\Utils\SafeCast; trait LightcyclerDataParsingTrait { @@ -14,11 +15,7 @@ protected function parseFloatValue(?string $value): ?float return null; } - if (! is_numeric($cleanString)) { - throw new \InvalidArgumentException("Invalid float value: '{$cleanString}'"); - } - - return (float) $cleanString; + return SafeCast::toFloat($cleanString); } /** @return array{float, float} */ diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php index 3f3b0f00..08a75acc 100644 --- a/src/LightcyclerExportSheet/LightcyclerXmlParser.php +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -5,6 +5,7 @@ use Illuminate\Support\Collection; use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem12x8; +use MLL\Utils\SafeCast; use function Safe\simplexml_load_string; @@ -77,7 +78,7 @@ private function extractPropertiesFromXml(\SimpleXMLElement $xmlElement): array $properties = []; foreach ($xmlElement->prop as $propertyNode) { - $propertyName = (string) $propertyNode->attributes()->name; + $propertyName = SafeCast::toString($propertyNode->attributes()->name); $propertyValue = $propertyNode->__toString(); if (! isset($properties[$propertyName])) { diff --git a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php index 9a236b8c..eefb394c 100644 --- a/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php +++ b/src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php @@ -4,6 +4,7 @@ use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem12x8; +use MLL\Utils\SafeCast; class AbsoluteQuantificationSample { @@ -42,7 +43,11 @@ public static function formatConcentration(?int $concentration): ?string return null; } - $exponent = (int) floor(log10(abs($concentration))); + if ($concentration === 0) { + return '0.00E0'; + } + + $exponent = SafeCast::toInt(floor(log10(abs($concentration)))); $mantissa = $concentration / (10 ** $exponent); return number_format($mantissa, 2) . 'E' . $exponent; diff --git a/src/Microplate/CoordinateSystem.php b/src/Microplate/CoordinateSystem.php index e63014b3..729fe29f 100644 --- a/src/Microplate/CoordinateSystem.php +++ b/src/Microplate/CoordinateSystem.php @@ -3,6 +3,7 @@ namespace MLL\Utils\Microplate; use Illuminate\Support\Arr; +use MLL\Utils\SafeCast; /** * Children should be called `CoordinateSystemXxY`, where X is the number of columns and Y is the number of rows. @@ -34,14 +35,14 @@ public function paddedColumns(): array /** 0-pad column to be as long as the longest column in the coordinate system. */ public function padColumn(int $column): string { - $maxColumnLength = strlen((string) $this->columnsCount()); + $maxColumnLength = strlen(SafeCast::toString($this->columnsCount())); - return str_pad((string) $column, $maxColumnLength, '0', STR_PAD_LEFT); + return str_pad(SafeCast::toString($column), $maxColumnLength, '0', STR_PAD_LEFT); } public function rowForRowFlowPosition(int $position): string { - $index = (int) floor(($position - 1) / $this->columnsCount()); + $index = SafeCast::toInt(floor(($position - 1) / $this->columnsCount())); return $this->rows()[$index]; } @@ -58,7 +59,7 @@ public function columnForRowFlowPosition(int $position): int public function columnForColumnFlowPosition(int $position): int { - $index = (int) floor(($position - 1) / $this->rowsCount()); + $index = SafeCast::toInt(floor(($position - 1) / $this->rowsCount())); return $this->columns()[$index]; } diff --git a/src/Microplate/Coordinates.php b/src/Microplate/Coordinates.php index a13c7b40..dd7cb367 100644 --- a/src/Microplate/Coordinates.php +++ b/src/Microplate/Coordinates.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use MLL\Utils\Microplate\Enums\FlowDirection; use MLL\Utils\Microplate\Exceptions\UnexpectedFlowDirection; +use MLL\Utils\SafeCast; use function Safe\preg_match; @@ -89,7 +90,7 @@ public static function fromString(string $coordinatesString, CoordinateSystem $c } /** @var array{1: string, 2: string} $matches */ - return new static($matches[1], (int) $matches[2], $coordinateSystem); + return new static($matches[1], SafeCast::toInt($matches[2]), $coordinateSystem); } /** diff --git a/src/Microplate/FullColumnSection.php b/src/Microplate/FullColumnSection.php index 6a87c26b..e985aaeb 100644 --- a/src/Microplate/FullColumnSection.php +++ b/src/Microplate/FullColumnSection.php @@ -4,6 +4,7 @@ use MLL\Utils\Microplate\Exceptions\MicroplateIsFullException; use MLL\Utils\Microplate\Exceptions\SectionIsFullException; +use MLL\Utils\SafeCast; /** * A section that occupies all wells of a column if one sample exists in this column. @@ -90,6 +91,6 @@ private function sectionCanGrow(): bool private function reservedColumns(): int { - return (int) ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount()); + return SafeCast::toInt(ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount())); } } diff --git a/src/Microplate/MicroplateSet/MicroplateSet.php b/src/Microplate/MicroplateSet/MicroplateSet.php index f00b2497..ce181ca3 100644 --- a/src/Microplate/MicroplateSet/MicroplateSet.php +++ b/src/Microplate/MicroplateSet/MicroplateSet.php @@ -5,6 +5,7 @@ use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem; use MLL\Utils\Microplate\Enums\FlowDirection; +use MLL\Utils\SafeCast; /** @template TCoordinateSystem of CoordinateSystem */ abstract class MicroplateSet @@ -39,7 +40,7 @@ public function locationFromPosition(int $setPosition, FlowDirection $direction) throw new \OutOfRangeException("Expected a position between 1-{$positionsCount}, got: {$setPosition}."); } - $plateIndex = (int) floor(($setPosition - 1) / $this->coordinateSystem->positionsCount()); + $plateIndex = SafeCast::toInt(floor(($setPosition - 1) / $this->coordinateSystem->positionsCount())); $positionOnSinglePlate = $setPosition - ($plateIndex * $this->coordinateSystem->positionsCount()); return new Location( diff --git a/src/SafeCast.php b/src/SafeCast.php index a1b507d5..07b7328a 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -2,8 +2,6 @@ namespace MLL\Utils; -use Safe\Exceptions\PcreException; - use function Safe\preg_match; /** @@ -151,11 +149,7 @@ public static function toString($value): string */ private static function isIntegerString(string $value): bool { - try { - return preg_match('/^[+-]?\d+$/', $value) === 1; - } catch (PcreException $ex) { - return false; - } + return preg_match('/^[+-]?\d+$/', $value) === 1; } /** @@ -165,20 +159,12 @@ private static function isIntegerString(string $value): bool */ private static function isNumericString(string $value): bool { - // Use is_numeric() but verify it's not in a weird format if (! is_numeric($value)) { return false; } - // is_numeric accepts some formats we might want to reject - // like hexadecimal (0x1F) or binary (0b1010) + // is_numeric accepts some formats we want to reject, like hexadecimal (0x1F) or binary (0b1010). // Check for these and reject them for stricter validation - try { - $hasHexOrBinary = preg_match('/^0[xXbB]/', $value) === 1; - - return ! $hasHexOrBinary; - } catch (PcreException $ex) { - return false; - } + return preg_match('/^0[xXbB]/', $value) !== 1; } } diff --git a/src/StringUtil.php b/src/StringUtil.php index 21a6092f..bddc93e6 100644 --- a/src/StringUtil.php +++ b/src/StringUtil.php @@ -171,11 +171,12 @@ private static function guessEncoding(string $text): string */ public static function leftPadNumber($number, int $length): string { - if (is_string($number) && ! is_numeric($number)) { - throw new \InvalidArgumentException("Expected numeric string, got: {$number}"); + // For strings, validate they're numeric by casting to float first + if (is_string($number)) { + $number = SafeCast::toFloat($number); } - return str_pad((string) $number, $length, '0', STR_PAD_LEFT); + return str_pad(SafeCast::toString($number), $length, '0', STR_PAD_LEFT); } /** Remove forbidden chars (<,>,:,",/,\,|,?,*) from file name. */ diff --git a/src/Tecan/BasicCommands/BasicPipettingActionCommand.php b/src/Tecan/BasicCommands/BasicPipettingActionCommand.php index 37fe25d5..80defdca 100644 --- a/src/Tecan/BasicCommands/BasicPipettingActionCommand.php +++ b/src/Tecan/BasicCommands/BasicPipettingActionCommand.php @@ -2,6 +2,7 @@ namespace MLL\Utils\Tecan\BasicCommands; +use MLL\Utils\SafeCast; use MLL\Utils\Tecan\LiquidClass\LiquidClass; use MLL\Utils\Tecan\Location\Location; @@ -36,6 +37,6 @@ protected function getTipMask(): string public function setTipMask(int $tipMask): void { - $this->tipMask = (string) $tipMask; + $this->tipMask = SafeCast::toString($tipMask); } } diff --git a/tests/IlluminaSampleSheet/V2/DataSectionTest.php b/tests/IlluminaSampleSheet/V2/DataSectionTest.php index af44ba2e..cffb3e46 100644 --- a/tests/IlluminaSampleSheet/V2/DataSectionTest.php +++ b/tests/IlluminaSampleSheet/V2/DataSectionTest.php @@ -55,4 +55,13 @@ public function testToStringWithProject(): void self::assertSame($expected, $dataSection->convertSectionToString()); } + + public function testThrowsExceptionForInvalidCycleFormat(): void + { + $dataSection = new DataSection(); + + $this->expectException(IlluminaSampleSheetException::class); + $this->expectExceptionMessage('Invalid Override Cycle Part'); + new OverrideCycles($dataSection, 'invalid', 'I8', null, null); + } } diff --git a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php index 24ee8ccb..42e45c79 100644 --- a/tests/LightcyclerExportSheet/QpcrXmlParserTest.php +++ b/tests/LightcyclerExportSheet/QpcrXmlParserTest.php @@ -219,7 +219,7 @@ public function testParseXmlHandlesInvalidFloatValues(): void XML; - $this->expectExceptionObject(new \InvalidArgumentException("Invalid float value: 'invalid'")); + $this->expectExceptionObject(new \InvalidArgumentException('String value "invalid" is not a valid numeric format')); $parser = new LightcyclerXmlParser(); $parser->parse($xmlWithInvalidFloat); diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php index ff8716ff..47879615 100644 --- a/tests/SafeCastTest.php +++ b/tests/SafeCastTest.php @@ -60,6 +60,8 @@ public static function invalidIntProvider(): iterable yield ['Float value "5.5" cannot be safely cast to int', 5.5]; yield ['cannot be safely cast to int (not a whole number or not finite)', INF]; yield ['Cannot cast value of type "array" to int', []]; + yield ['Cannot cast value of type "boolean" to int', true]; + yield ['Cannot cast value of type "boolean" to int', false]; } /** @@ -113,6 +115,8 @@ public static function invalidFloatProvider(): iterable yield ['Cannot cast value of type "array" to float', []]; yield ['is not a valid numeric format', '0x1A']; yield ['is not a valid numeric format', '0b1010']; + yield ['Cannot cast value of type "boolean" to float', true]; + yield ['Cannot cast value of type "boolean" to float', false]; } /** @@ -169,5 +173,7 @@ public static function invalidStringProvider(): iterable { yield ['Cannot cast value of type "object" to string', new \stdClass()]; yield ['Cannot cast value of type "array" to string', []]; + yield ['Cannot cast value of type "boolean" to string', true]; + yield ['Cannot cast value of type "boolean" to string', false]; } } diff --git a/tests/StringUtilTest.php b/tests/StringUtilTest.php index c0ac9bcd..1234b30b 100644 --- a/tests/StringUtilTest.php +++ b/tests/StringUtilTest.php @@ -154,9 +154,17 @@ public function testLeftPadNumber(): void ); self::expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid numeric format'); StringUtil::leftPadNumber('foo', 3); } + public function testLeftPadNumberRejectsHexAndBinary(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid numeric format'); + StringUtil::leftPadNumber('0x1A', 5); + } + public function testHasContent(): void { self::assertFalse(StringUtil::hasContent(null)); From e2bfcb8767f8c9da4bd9824758de4667b4c28152 Mon Sep 17 00:00:00 2001 From: spawnia <12158000+spawnia@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:54:18 +0000 Subject: [PATCH 3/9] Apply php-cs-fixer changes --- tests/SafeCastTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php index 47879615..1dd3700a 100644 --- a/tests/SafeCastTest.php +++ b/tests/SafeCastTest.php @@ -145,7 +145,7 @@ public static function validStringProvider(): iterable public function testToStringWithObjectHavingToStringMethod(): void { - $object = new class() { + $object = new class() implements \Stringable { public function __toString(): string { return 'object-string'; From 38972309d200b4e8559527429d4b7534c079a59e Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 16 Jan 2026 12:32:11 +0100 Subject: [PATCH 4/9] feat(SafeCast): add toBool method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add safe boolean casting that only accepts: - bool (pass-through) - int 0/1 → false/true - string "0"/"1" → false/true Rejects ambiguous values like "true", "false", "yes", "no" to prevent bugs where (bool) "false" evaluates to true. Co-Authored-By: Claude --- README.md | 6 +++++ src/SafeCast.php | 32 ++++++++++++++++++++++++++ tests/SafeCastTest.php | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/README.md b/README.md index ee9fc773..f9d6e992 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ SafeCast::toFloat('abc'); // throws InvalidArgumentException // Safe string casting SafeCast::toString(42); // '42' SafeCast::toString(null); // '' + +// Safe boolean casting +SafeCast::toBool(true); // true +SafeCast::toBool(1); // true +SafeCast::toBool('0'); // false +SafeCast::toBool('true'); // throws InvalidArgumentException ``` See [tests](tests/SafeCastTest.php) for more examples. diff --git a/src/SafeCast.php b/src/SafeCast.php index 07b7328a..6a79f7ae 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -142,6 +142,38 @@ public static function toString($value): string throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string'); } + /** + * Safely cast a value to a boolean. + * + * Only accepts: + * - Booleans (returned as-is) + * - Integer 0 or 1 + * - String "0" or "1" + * + * @param mixed $value The value to cast + * + * @throws \InvalidArgumentException If the value cannot be safely cast to a boolean + */ + public static function toBool($value): bool + { + if (is_bool($value)) { + return $value; + } + + if ($value === 0 || $value === '0') { + return false; + } + + if ($value === 1 || $value === '1') { + return true; + } + + throw new \InvalidArgumentException( + 'Cannot safely cast value of type "' . gettype($value) . '" to bool. ' + . 'Only bool, int 0/1, or string "0"/"1" are accepted.' + ); + } + /** * Check if a string represents a valid integer. * diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php index 1dd3700a..aa5aedfa 100644 --- a/tests/SafeCastTest.php +++ b/tests/SafeCastTest.php @@ -176,4 +176,56 @@ public static function invalidStringProvider(): iterable yield ['Cannot cast value of type "boolean" to string', true]; yield ['Cannot cast value of type "boolean" to string', false]; } + + /** + * @dataProvider validBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validBoolProvider')] + public function testToBoolWithValidInput(bool $expected, $input): void + { + self::assertSame($expected, SafeCast::toBool($input)); + } + + /** @return iterable */ + public static function validBoolProvider(): iterable + { + yield [true, true]; + yield [false, false]; + yield [true, 1]; + yield [false, 0]; + yield [true, '1']; + yield [false, '0']; + } + + /** + * @dataProvider invalidBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidBoolProvider')] + public function testToBoolThrowsExceptionForInvalidInput(string $expectedMessage, $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + SafeCast::toBool($input); + } + + /** @return iterable */ + public static function invalidBoolProvider(): iterable + { + yield ['Cannot safely cast value of type "string" to bool', 'true']; + yield ['Cannot safely cast value of type "string" to bool', 'false']; + yield ['Cannot safely cast value of type "string" to bool', 'yes']; + yield ['Cannot safely cast value of type "string" to bool', 'no']; + yield ['Cannot safely cast value of type "string" to bool', '']; + yield ['Cannot safely cast value of type "NULL" to bool', null]; + yield ['Cannot safely cast value of type "integer" to bool', 2]; + yield ['Cannot safely cast value of type "integer" to bool', -1]; + yield ['Cannot safely cast value of type "array" to bool', []]; + yield ['Cannot safely cast value of type "object" to bool', new \stdClass()]; + yield ['Cannot safely cast value of type "double" to bool', 1.0]; + yield ['Cannot safely cast value of type "double" to bool', 0.0]; + } } From 3e29c99f138c17bc1bf87af75d541897abe3e3c0 Mon Sep 17 00:00:00 2001 From: spawnia <12158000+spawnia@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:32:47 +0000 Subject: [PATCH 5/9] Apply php-cs-fixer changes --- src/SafeCast.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/SafeCast.php b/src/SafeCast.php index 6a79f7ae..db563235 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -168,10 +168,7 @@ public static function toBool($value): bool return true; } - throw new \InvalidArgumentException( - 'Cannot safely cast value of type "' . gettype($value) . '" to bool. ' - . 'Only bool, int 0/1, or string "0"/"1" are accepted.' - ); + throw new \InvalidArgumentException('Cannot safely cast value of type "' . gettype($value) . '" to bool. Only bool, int 0/1, or string "0"/"1" are accepted.'); } /** From 0b664a39d548e1e5b9eb2674b6559aab3445cfb8 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 27 Feb 2026 12:10:26 +0100 Subject: [PATCH 6/9] feat: add try* variants to SafeCast Add tryInt, tryFloat, tryString, tryBool that return null instead of throwing, following the Enum::from/Enum::tryFrom pattern. This enables callers to provide specific error context: SafeCast::tryInt($value) ?? throw new SpecificException("context: {$value}") Co-Authored-By: Claude Opus 4.6 --- src/SafeCast.php | 171 ++++++++++++++++++++++++++--------------- tests/SafeCastTest.php | 100 ++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 61 deletions(-) diff --git a/src/SafeCast.php b/src/SafeCast.php index db563235..6997e061 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -16,13 +16,25 @@ * - (int)"123abc" returns 123 (partial conversion, data loss) * - (float)"1.23.45" returns 1.23 (invalid format accepted) * - * The methods in this class throw exceptions for invalid inputs instead of - * silently producing incorrect values. + * Each type has two variants: + * - toX($value): returns the cast value or throws \InvalidArgumentException + * - tryX($value): returns the cast value or null (like Enum::tryFrom) */ class SafeCast { /** - * Safely cast a value to an integer. + * Safely cast a value to an integer, or throw. + * + * @param mixed $value The value to cast + */ + public static function toInt($value): int + { + return self::tryInt($value) + ?? throw self::failedToCastToInt($value); + } + + /** + * Safely cast a value to an integer, or return null. * * Only accepts: * - Integers (returned as-is) @@ -30,45 +42,41 @@ class SafeCast * - Floats that are exact integer values (e.g., 5.0) * * @param mixed $value The value to cast - * - * @throws \InvalidArgumentException If the value cannot be safely cast to an integer */ - public static function toInt($value): int + public static function tryInt($value): ?int { if (is_int($value)) { return $value; } - // Allow floats that represent exact integers (e.g., 5.0 -> 5) - if (is_float($value)) { - if ($value === floor($value) && is_finite($value)) { - return (int) $value; - } - - throw new \InvalidArgumentException('Float value "' . $value . '" cannot be safely cast to int (not a whole number or not finite)'); + if (is_float($value) && $value === floor($value) && is_finite($value)) { + return (int) $value; } if (is_string($value)) { $trimmed = trim($value); - // Empty string is not a valid integer - if ($trimmed === '') { - throw new \InvalidArgumentException('Empty string cannot be cast to int'); - } - - // Check if the string represents a valid integer - if (! self::isIntegerString($trimmed)) { - throw new \InvalidArgumentException('String value "' . $value . '" is not a valid integer format'); + if ($trimmed !== '' && self::isIntegerString($trimmed)) { + return (int) $trimmed; } - - return (int) $trimmed; } - throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to int'); + return null; + } + + /** + * Safely cast a value to a float, or throw. + * + * @param mixed $value The value to cast + */ + public static function toFloat($value): float + { + return self::tryFloat($value) + ?? throw self::failedToCastToFloat($value); } /** - * Safely cast a value to a float. + * Safely cast a value to a float, or return null. * * Only accepts: * - Floats (returned as-is) @@ -76,10 +84,8 @@ public static function toInt($value): int * - Numeric strings that represent valid floats * * @param mixed $value The value to cast - * - * @throws \InvalidArgumentException If the value cannot be safely cast to a float */ - public static function toFloat($value): float + public static function tryFloat($value): ?float { if (is_float($value)) { return $value; @@ -92,24 +98,27 @@ public static function toFloat($value): float if (is_string($value)) { $trimmed = trim($value); - // Empty string is not a valid float - if ($trimmed === '') { - throw new \InvalidArgumentException('Empty string cannot be cast to float'); - } - - // Check if the string represents a valid numeric value - if (! self::isNumericString($trimmed)) { - throw new \InvalidArgumentException('String value "' . $value . '" is not a valid numeric format'); + if ($trimmed !== '' && self::isNumericString($trimmed)) { + return (float) $trimmed; } - - return (float) $trimmed; } - throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to float'); + return null; + } + + /** + * Safely cast a value to a string, or throw. + * + * @param mixed $value The value to cast + */ + public static function toString($value): string + { + return self::tryString($value) + ?? throw self::failedToCastToString($value); } /** - * Safely cast a value to a string. + * Safely cast a value to a string, or return null. * * Only accepts: * - Strings (returned as-is) @@ -118,10 +127,8 @@ public static function toFloat($value): float * - null (converted to empty string) * * @param mixed $value The value to cast - * - * @throws \InvalidArgumentException If the value cannot be safely cast to a string */ - public static function toString($value): string + public static function tryString($value): ?string { if (is_string($value)) { return $value; @@ -139,11 +146,22 @@ public static function toString($value): string return (string) $value; } - throw new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string'); + return null; + } + + /** + * Safely cast a value to a boolean, or throw. + * + * @param mixed $value The value to cast + */ + public static function toBool($value): bool + { + return self::tryBool($value) + ?? throw self::failedToCastToBool($value); } /** - * Safely cast a value to a boolean. + * Safely cast a value to a boolean, or return null. * * Only accepts: * - Booleans (returned as-is) @@ -151,10 +169,8 @@ public static function toString($value): string * - String "0" or "1" * * @param mixed $value The value to cast - * - * @throws \InvalidArgumentException If the value cannot be safely cast to a boolean */ - public static function toBool($value): bool + public static function tryBool($value): ?bool { if (is_bool($value)) { return $value; @@ -168,32 +184,65 @@ public static function toBool($value): bool return true; } - throw new \InvalidArgumentException('Cannot safely cast value of type "' . gettype($value) . '" to bool. Only bool, int 0/1, or string "0"/"1" are accepted.'); + return null; } - /** - * Check if a string represents a valid integer. - * - * Accepts optional leading/trailing whitespace, optional sign, and digits only. - */ private static function isIntegerString(string $value): bool { return preg_match('/^[+-]?\d+$/', $value) === 1; } - /** - * Check if a string represents a valid numeric value (integer or float). - * - * Accepts scientific notation, decimals with optional sign. - */ private static function isNumericString(string $value): bool { if (! is_numeric($value)) { return false; } - // is_numeric accepts some formats we want to reject, like hexadecimal (0x1F) or binary (0b1010). - // Check for these and reject them for stricter validation + // is_numeric accepts some formats we want to reject, like hexadecimal (0x1F) or binary (0b1010) return preg_match('/^0[xXbB]/', $value) !== 1; } + + /** @param mixed $value The value that failed to cast */ + private static function failedToCastToInt($value): \InvalidArgumentException + { + if (is_float($value)) { + return new \InvalidArgumentException("Float value \"{$value}\" cannot be safely cast to int (not a whole number or not finite)"); + } + + if (is_string($value)) { + if (trim($value) === '') { + return new \InvalidArgumentException('Empty string cannot be cast to int'); + } + + return new \InvalidArgumentException("String value \"{$value}\" is not a valid integer format"); + } + + return new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to int'); + } + + /** @param mixed $value The value that failed to cast */ + private static function failedToCastToFloat($value): \InvalidArgumentException + { + if (is_string($value)) { + if (trim($value) === '') { + return new \InvalidArgumentException('Empty string cannot be cast to float'); + } + + return new \InvalidArgumentException("String value \"{$value}\" is not a valid numeric format"); + } + + return new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to float'); + } + + /** @param mixed $value The value that failed to cast */ + private static function failedToCastToString($value): \InvalidArgumentException + { + return new \InvalidArgumentException('Cannot cast value of type "' . gettype($value) . '" to string'); + } + + /** @param mixed $value The value that failed to cast */ + private static function failedToCastToBool($value): \InvalidArgumentException + { + return new \InvalidArgumentException('Cannot safely cast value of type "' . gettype($value) . '" to bool. Only bool, int 0/1, or string "0"/"1" are accepted.'); + } } diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php index aa5aedfa..29e4f1de 100644 --- a/tests/SafeCastTest.php +++ b/tests/SafeCastTest.php @@ -155,6 +155,18 @@ public function __toString(): string self::assertSame('object-string', SafeCast::toString($object)); } + public function testTryStringWithObjectHavingToStringMethod(): void + { + $object = new class() implements \Stringable { + public function __toString(): string + { + return 'object-string'; + } + }; + + self::assertSame('object-string', SafeCast::tryString($object)); + } + /** * @dataProvider invalidStringProvider * @@ -177,6 +189,94 @@ public static function invalidStringProvider(): iterable yield ['Cannot cast value of type "boolean" to string', false]; } + /** + * @dataProvider validIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validIntProvider')] + public function testTryIntWithValidInput(int $expected, $input): void + { + self::assertSame($expected, SafeCast::tryInt($input)); + } + + /** + * @dataProvider invalidIntProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidIntProvider')] + public function testTryIntReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryInt($input)); + } + + /** + * @dataProvider validFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validFloatProvider')] + public function testTryFloatWithValidInput(float $expected, $input): void + { + self::assertSame($expected, SafeCast::tryFloat($input)); + } + + /** + * @dataProvider invalidFloatProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidFloatProvider')] + public function testTryFloatReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryFloat($input)); + } + + /** + * @dataProvider validStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validStringProvider')] + public function testTryStringWithValidInput(string $expected, $input): void + { + self::assertSame($expected, SafeCast::tryString($input)); + } + + /** + * @dataProvider invalidStringProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidStringProvider')] + public function testTryStringReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryString($input)); + } + + /** + * @dataProvider validBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('validBoolProvider')] + public function testTryBoolWithValidInput(bool $expected, $input): void + { + self::assertSame($expected, SafeCast::tryBool($input)); + } + + /** + * @dataProvider invalidBoolProvider + * + * @param mixed $input can be anything + */ + #[DataProvider('invalidBoolProvider')] + public function testTryBoolReturnsNullForInvalidInput(string $expectedMessage, $input): void + { + self::assertNull(SafeCast::tryBool($input)); + } + /** * @dataProvider validBoolProvider * From 6bcdcd6a2564107913591f173afe514ec1b5f8fc Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Fri, 27 Feb 2026 13:54:14 +0100 Subject: [PATCH 7/9] fix: make tryString return null for null input tryString(null) now returns null for consistency with tryInt, tryFloat, and tryBool. toString(null) still returns '' for backwards compatibility. Co-Authored-By: Claude Opus 4.6 --- src/SafeCast.php | 9 ++++----- tests/SafeCastTest.php | 11 ++++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/SafeCast.php b/src/SafeCast.php index 6997e061..8744362e 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -113,6 +113,10 @@ public static function tryFloat($value): ?float */ public static function toString($value): string { + if ($value === null) { + return ''; + } + return self::tryString($value) ?? throw self::failedToCastToString($value); } @@ -124,7 +128,6 @@ public static function toString($value): string * - Strings (returned as-is) * - Integers and floats (converted to string) * - Objects with __toString() method - * - null (converted to empty string) * * @param mixed $value The value to cast */ @@ -138,10 +141,6 @@ public static function tryString($value): ?string return (string) $value; } - if ($value === null) { - return ''; - } - if (is_object($value) && method_exists($value, '__toString')) { return (string) $value; } diff --git a/tests/SafeCastTest.php b/tests/SafeCastTest.php index 29e4f1de..7bae3081 100644 --- a/tests/SafeCastTest.php +++ b/tests/SafeCastTest.php @@ -140,7 +140,16 @@ public static function validStringProvider(): iterable yield ['0', 0]; yield ['3.14', 3.14]; yield ['-2.5', -2.5]; - yield ['', null]; + } + + public function testToStringWithNull(): void + { + self::assertSame('', SafeCast::toString(null)); + } + + public function testTryStringWithNullReturnsNull(): void + { + self::assertNull(SafeCast::tryString(null)); } public function testToStringWithObjectHavingToStringMethod(): void From 70bea0477768e690209d74cbbd625c30fec2c43a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 28 Feb 2026 18:25:15 +0100 Subject: [PATCH 8/9] fix: replace throw expressions with statements for PHP 7.4 compatibility The `?? throw` syntax is a PHP 8.0 feature (throw as expression). Replace with equivalent if-null-throw statements to support PHP 7.4. Co-Authored-By: Claude Opus 4.6 --- src/SafeCast.php | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/SafeCast.php b/src/SafeCast.php index 8744362e..e1163eeb 100644 --- a/src/SafeCast.php +++ b/src/SafeCast.php @@ -29,8 +29,12 @@ class SafeCast */ public static function toInt($value): int { - return self::tryInt($value) - ?? throw self::failedToCastToInt($value); + $result = self::tryInt($value); + if ($result === null) { + throw self::failedToCastToInt($value); + } + + return $result; } /** @@ -71,8 +75,12 @@ public static function tryInt($value): ?int */ public static function toFloat($value): float { - return self::tryFloat($value) - ?? throw self::failedToCastToFloat($value); + $result = self::tryFloat($value); + if ($result === null) { + throw self::failedToCastToFloat($value); + } + + return $result; } /** @@ -117,8 +125,12 @@ public static function toString($value): string return ''; } - return self::tryString($value) - ?? throw self::failedToCastToString($value); + $result = self::tryString($value); + if ($result === null) { + throw self::failedToCastToString($value); + } + + return $result; } /** @@ -155,8 +167,12 @@ public static function tryString($value): ?string */ public static function toBool($value): bool { - return self::tryBool($value) - ?? throw self::failedToCastToBool($value); + $result = self::tryBool($value); + if ($result === null) { + throw self::failedToCastToBool($value); + } + + return $result; } /** From 4aad969418092feb7cf232be18d2fbd0d03fc343 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sat, 28 Feb 2026 18:50:26 +0100 Subject: [PATCH 9/9] docs: update README with try variants --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index f9d6e992..a96e793c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ See [tests](tests). PHP's native type casts like `(int)` and `(float)` can produce unexpected results, especially when casting from strings. The `SafeCast` utility provides safe alternatives that validate input before casting: +Each type has two variants: +- `toX()`: returns the cast value or throws `\InvalidArgumentException` +- `tryX()`: returns the cast value or `null` (like `Enum::tryFrom()`) + ```php use MLL\Utils\SafeCast; @@ -34,21 +38,25 @@ use MLL\Utils\SafeCast; SafeCast::toInt(42); // 42 SafeCast::toInt('42'); // 42 SafeCast::toInt('hello'); // throws InvalidArgumentException +SafeCast::tryInt('hello'); // null // Safe float casting SafeCast::toFloat(3.14); // 3.14 SafeCast::toFloat('3.14'); // 3.14 SafeCast::toFloat('abc'); // throws InvalidArgumentException +SafeCast::tryFloat('abc'); // null // Safe string casting SafeCast::toString(42); // '42' SafeCast::toString(null); // '' +SafeCast::tryString([1, 2]); // null // Safe boolean casting SafeCast::toBool(true); // true SafeCast::toBool(1); // true SafeCast::toBool('0'); // false SafeCast::toBool('true'); // throws InvalidArgumentException +SafeCast::tryBool('true'); // null ``` See [tests](tests/SafeCastTest.php) for more examples.