diff --git a/UPGRADE-6.3.md b/UPGRADE-6.3.md index 59861f7b7785f..166040bd3d54e 100644 --- a/UPGRADE-6.3.md +++ b/UPGRADE-6.3.md @@ -63,6 +63,12 @@ HttpFoundation * `Response::sendHeaders()` now takes an optional `$statusCode` parameter +HttpFoundation +-------------- + + * Deprecate conversion of invalid values in `ParameterBag::getInt()` and `ParameterBag::getBoolean()` + * Deprecate ignoring invalid values when using `ParameterBag::filter()`, unless flag `FILTER_NULL_ON_FAILURE` is set + HttpKernel ---------- diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 9b2c0bcf4e4cf..7b6a048882642 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -4,10 +4,14 @@ CHANGELOG 6.3 --- + * Calling `ParameterBag::getDigit()`, `getAlnum()`, `getAlpha()` on an `array` throws a `UnexpectedValueException` instead of a `TypeError` + * Add `ParameterBag::getString()` to convert a parameter into string and throw an exception if the value is invalid * Add `ParameterBag::getEnum()` * Create migration for session table when pdo handler is used * Add support for Relay PHP extension for Redis * The `Response::sendHeaders()` method now takes an optional HTTP status code as parameter, allowing to send informational responses such as Early Hints responses (103 status code) + * Deprecate conversion of invalid values in `ParameterBag::getInt()` and `ParameterBag::getBoolean()`, + * Deprecate ignoring invalid values when using `ParameterBag::filter()`, unless flag `FILTER_NULL_ON_FAILURE` is set 6.2 --- diff --git a/src/Symfony/Component/HttpFoundation/InputBag.php b/src/Symfony/Component/HttpFoundation/InputBag.php index d0e069b505c11..77990f5711ece 100644 --- a/src/Symfony/Component/HttpFoundation/InputBag.php +++ b/src/Symfony/Component/HttpFoundation/InputBag.php @@ -92,6 +92,15 @@ public function getEnum(string $key, string $class, \BackedEnum $default = null) } } + /** + * Returns the parameter value converted to string. + */ + public function getString(string $key, string $default = ''): string + { + // Shortcuts the parent method because the validation on scalar is already done in get(). + return (string) $this->get($key, $default); + } + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed { $value = $this->has($key) ? $this->all()[$key] : $default; @@ -109,6 +118,22 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } - return filter_var($value, $filter, $options); + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + $method = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + $method = ($method['object'] ?? null) === $this ? $method['function'] : 'filter'; + $hint = 'filter' === $method ? 'pass' : 'use method "filter()" with'; + + trigger_deprecation('symfony/http-foundation', '6.3', 'Ignoring invalid values when using "%s::%s(\'%s\')" is deprecated and will throw a "%s" in 7.0; '.$hint.' flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', $this::class, $method, $key, BadRequestException::class); + + return false; } } diff --git a/src/Symfony/Component/HttpFoundation/ParameterBag.php b/src/Symfony/Component/HttpFoundation/ParameterBag.php index 44d8d96d28213..9d7012de35d30 100644 --- a/src/Symfony/Component/HttpFoundation/ParameterBag.php +++ b/src/Symfony/Component/HttpFoundation/ParameterBag.php @@ -114,7 +114,7 @@ public function remove(string $key) */ public function getAlpha(string $key, string $default = ''): string { - return preg_replace('/[^[:alpha:]]/', '', $this->get($key, $default)); + return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default)); } /** @@ -122,7 +122,7 @@ public function getAlpha(string $key, string $default = ''): string */ public function getAlnum(string $key, string $default = ''): string { - return preg_replace('/[^[:alnum:]]/', '', $this->get($key, $default)); + return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default)); } /** @@ -130,8 +130,20 @@ public function getAlnum(string $key, string $default = ''): string */ public function getDigits(string $key, string $default = ''): string { - // we need to remove - and + because they're allowed in the filter - return str_replace(['-', '+'], '', $this->filter($key, $default, \FILTER_SANITIZE_NUMBER_INT)); + return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the parameter as string. + */ + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new \UnexpectedValueException(sprintf('Parameter value "%s" cannot be converted to "string".', $key)); + } + + return (string) $value; } /** @@ -139,7 +151,8 @@ public function getDigits(string $key, string $default = ''): string */ public function getInt(string $key, int $default = 0): int { - return (int) $this->get($key, $default); + // In 7.0 remove the fallback to 0, in case of failure an exception will be thrown + return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]) ?: 0; } /** @@ -147,7 +160,7 @@ public function getInt(string $key, int $default = 0): int */ public function getBoolean(string $key, bool $default = false): bool { - return $this->filter($key, $default, \FILTER_VALIDATE_BOOL); + return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]); } /** @@ -178,7 +191,8 @@ public function getEnum(string $key, string $class, \BackedEnum $default = null) /** * Filter key. * - * @param int $filter FILTER_* constant + * @param int $filter FILTER_* constant + * @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants * * @see https://php.net/filter-var */ @@ -196,11 +210,31 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER $options['flags'] = \FILTER_REQUIRE_ARRAY; } + if (\is_object($value) && !$value instanceof \Stringable) { + throw new \UnexpectedValueException(sprintf('Parameter value "%s" cannot be filtered.', $key)); + } + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } - return filter_var($value, $filter, $options); + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + $method = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + $method = ($method['object'] ?? null) === $this ? $method['function'] : 'filter'; + $hint = 'filter' === $method ? 'pass' : 'use method "filter()" with'; + + trigger_deprecation('symfony/http-foundation', '6.3', 'Ignoring invalid values when using "%s::%s(\'%s\')" is deprecated and will throw an "%s" in 7.0; '.$hint.' flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', $this::class, $method, $key, \UnexpectedValueException::class); + + return false; } /** diff --git a/src/Symfony/Component/HttpFoundation/Tests/InputBagTest.php b/src/Symfony/Component/HttpFoundation/Tests/InputBagTest.php index cdcdb3d0bd979..6a447a39ccd23 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/InputBagTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/InputBagTest.php @@ -12,12 +12,15 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum; class InputBagTest extends TestCase { + use ExpectDeprecationTrait; + public function testGet() { $bag = new InputBag(['foo' => 'bar', 'null' => null, 'int' => 1, 'float' => 1.0, 'bool' => false, 'stringable' => new class() implements \Stringable { @@ -36,6 +39,58 @@ public function __toString(): string $this->assertFalse($bag->get('bool'), '->get() gets the value of a bool parameter'); } + /** + * @group legacy + */ + public function testGetIntError() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\InputBag::getInt(\'foo\')" is deprecated and will throw a "Symfony\Component\HttpFoundation\Exception\BadRequestException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new InputBag(['foo' => 'bar']); + $result = $bag->getInt('foo'); + $this->assertSame(0, $result); + } + + /** + * @group legacy + */ + public function testGetBooleanError() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\InputBag::getBoolean(\'foo\')" is deprecated and will throw a "Symfony\Component\HttpFoundation\Exception\BadRequestException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new InputBag(['foo' => 'bar']); + $result = $bag->getBoolean('foo'); + $this->assertFalse($result); + } + + public function testGetString() + { + $bag = new InputBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + public function __toString(): string + { + return 'strval'; + } + }]); + + $this->assertSame('123', $bag->getString('integer'), '->getString() gets a value of parameter as string'); + $this->assertSame('abc', $bag->getString('string'), '->getString() gets a value of parameter as string'); + $this->assertSame('', $bag->getString('unknown'), '->getString() returns zero if a parameter is not defined'); + $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); + $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); + $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + } + + public function testGetStringExceptionWithArray() + { + $bag = new InputBag(['key' => ['abc']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "key" contains a non-scalar value.'); + + $bag->getString('key'); + } + public function testGetDoesNotUseDeepByDefault() { $bag = new InputBag(['foo' => ['bar' => 'moo']]); @@ -126,4 +181,34 @@ public function testGetEnumThrowsExceptionWithInvalidValue() $this->assertNull($bag->getEnum('invalid-value', FooEnum::class)); } + + public function testGetAlnumExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getAlnum('word'); + } + + public function testGetAlphaExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getAlpha('word'); + } + + public function testGetDigitsExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getDigits('word'); + } } diff --git a/src/Symfony/Component/HttpFoundation/Tests/ParameterBagTest.php b/src/Symfony/Component/HttpFoundation/Tests/ParameterBagTest.php index b5f7e60068611..62b95f42f4573 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/ParameterBagTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/ParameterBagTest.php @@ -12,12 +12,15 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum; class ParameterBagTest extends TestCase { + use ExpectDeprecationTrait; + public function testConstructor() { $this->testAll(); @@ -112,34 +115,137 @@ public function testHas() public function testGetAlpha() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_012', 'bool' => true, 'integer' => 123]); + + $this->assertSame('fooBAR', $bag->getAlpha('word'), '->getAlpha() gets only alphabetic characters'); + $this->assertSame('', $bag->getAlpha('unknown'), '->getAlpha() returns empty string if a parameter is not defined'); + $this->assertSame('abcDEF', $bag->getAlpha('unknown', 'abc_DEF_012'), '->getAlpha() returns filtered default if a parameter is not defined'); + $this->assertSame('', $bag->getAlpha('integer', 'abc_DEF_012'), '->getAlpha() returns empty string if a parameter is an integer'); + $this->assertSame('', $bag->getAlpha('bool', 'abc_DEF_012'), '->getAlpha() returns empty string if a parameter is a boolean'); + } + + public function testGetAlphaExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); - $this->assertEquals('fooBAR', $bag->getAlpha('word'), '->getAlpha() gets only alphabetic characters'); - $this->assertEquals('', $bag->getAlpha('unknown'), '->getAlpha() returns empty string if a parameter is not defined'); + $bag->getAlpha('word'); } public function testGetAlnum() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_012', 'bool' => true, 'integer' => 123]); + + $this->assertSame('fooBAR012', $bag->getAlnum('word'), '->getAlnum() gets only alphanumeric characters'); + $this->assertSame('', $bag->getAlnum('unknown'), '->getAlnum() returns empty string if a parameter is not defined'); + $this->assertSame('abcDEF012', $bag->getAlnum('unknown', 'abc_DEF_012'), '->getAlnum() returns filtered default if a parameter is not defined'); + $this->assertSame('123', $bag->getAlnum('integer', 'abc_DEF_012'), '->getAlnum() returns the number as string if a parameter is an integer'); + $this->assertSame('1', $bag->getAlnum('bool', 'abc_DEF_012'), '->getAlnum() returns 1 if a parameter is true'); + } + + public function testGetAlnumExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); - $this->assertEquals('fooBAR012', $bag->getAlnum('word'), '->getAlnum() gets only alphanumeric characters'); - $this->assertEquals('', $bag->getAlnum('unknown'), '->getAlnum() returns empty string if a parameter is not defined'); + $bag->getAlnum('word'); } public function testGetDigits() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_0+1-2', 'bool' => true, 'integer' => 123]); + + $this->assertSame('012', $bag->getDigits('word'), '->getDigits() gets only digits as string'); + $this->assertSame('', $bag->getDigits('unknown'), '->getDigits() returns empty string if a parameter is not defined'); + $this->assertSame('012', $bag->getDigits('unknown', 'abc_DEF_012'), '->getDigits() returns filtered default if a parameter is not defined'); + $this->assertSame('123', $bag->getDigits('integer', 'abc_DEF_012'), '->getDigits() returns the number as string if a parameter is an integer'); + $this->assertSame('1', $bag->getDigits('bool', 'abc_DEF_012'), '->getDigits() returns 1 if a parameter is true'); + } + + public function testGetDigitsExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); - $this->assertEquals('012', $bag->getDigits('word'), '->getDigits() gets only digits as string'); - $this->assertEquals('', $bag->getDigits('unknown'), '->getDigits() returns empty string if a parameter is not defined'); + $bag->getDigits('word'); } public function testGetInt() { - $bag = new ParameterBag(['digits' => '0123']); + $bag = new ParameterBag(['digits' => '123', 'bool' => true]); + + $this->assertSame(123, $bag->getInt('digits', 0), '->getInt() gets a value of parameter as integer'); + $this->assertSame(0, $bag->getInt('unknown', 0), '->getInt() returns zero if a parameter is not defined'); + $this->assertSame(10, $bag->getInt('unknown', 10), '->getInt() returns the default if a parameter is not defined'); + $this->assertSame(1, $bag->getInt('bool', 0), '->getInt() returns 1 if a parameter is true'); + } + + /** + * @group legacy + */ + public function testGetIntExceptionWithArray() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\ParameterBag::getInt(\'digits\')" is deprecated and will throw an "UnexpectedValueException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new ParameterBag(['digits' => ['123']]); + $result = $bag->getInt('digits', 0); + $this->assertSame(0, $result); + } + + /** + * @group legacy + */ + public function testGetIntExceptionWithInvalid() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\ParameterBag::getInt(\'word\')" is deprecated and will throw an "UnexpectedValueException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $result = $bag->getInt('word', 0); + $this->assertSame(0, $result); + } - $this->assertEquals(123, $bag->getInt('digits'), '->getInt() gets a value of parameter as integer'); - $this->assertEquals(0, $bag->getInt('unknown'), '->getInt() returns zero if a parameter is not defined'); + public function testGetString() + { + $bag = new ParameterBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + public function __toString(): string + { + return 'strval'; + } + }]); + + $this->assertSame('123', $bag->getString('integer'), '->getString() gets a value of parameter as string'); + $this->assertSame('abc', $bag->getString('string'), '->getString() gets a value of parameter as string'); + $this->assertSame('', $bag->getString('unknown'), '->getString() returns zero if a parameter is not defined'); + $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); + $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); + $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + } + + public function testGetStringExceptionWithArray() + { + $bag = new ParameterBag(['key' => ['abc']]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "key" cannot be converted to "string".'); + + $bag->getString('key'); + } + + public function testGetStringExceptionWithObject() + { + $bag = new ParameterBag(['object' => $this]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "object" cannot be converted to "string".'); + + $bag->getString('object'); } public function testFilter() @@ -164,13 +270,13 @@ public function testFilter() // This test is repeated for code-coverage $this->assertEquals('http://example.com/foo', $bag->filter('url', '', \FILTER_VALIDATE_URL, \FILTER_FLAG_PATH_REQUIRED), '->filter() gets a value of parameter as URL with a path'); - $this->assertFalse($bag->filter('dec', '', \FILTER_VALIDATE_INT, [ - 'flags' => \FILTER_FLAG_ALLOW_HEX, + $this->assertNull($bag->filter('dec', '', \FILTER_VALIDATE_INT, [ + 'flags' => \FILTER_FLAG_ALLOW_HEX | \FILTER_NULL_ON_FAILURE, 'options' => ['min_range' => 1, 'max_range' => 0xFF], ]), '->filter() gets a value of parameter as integer between boundaries'); - $this->assertFalse($bag->filter('hex', '', \FILTER_VALIDATE_INT, [ - 'flags' => \FILTER_FLAG_ALLOW_HEX, + $this->assertNull($bag->filter('hex', '', \FILTER_VALIDATE_INT, [ + 'flags' => \FILTER_FLAG_ALLOW_HEX | \FILTER_NULL_ON_FAILURE, 'options' => ['min_range' => 1, 'max_range' => 0xFF], ]), '->filter() gets a value of parameter as integer between boundaries'); @@ -218,12 +324,25 @@ public function testCount() public function testGetBoolean() { - $parameters = ['string_true' => 'true', 'string_false' => 'false']; + $parameters = ['string_true' => 'true', 'string_false' => 'false', 'string' => 'abc']; $bag = new ParameterBag($parameters); $this->assertTrue($bag->getBoolean('string_true'), '->getBoolean() gets the string true as boolean true'); $this->assertFalse($bag->getBoolean('string_false'), '->getBoolean() gets the string false as boolean false'); $this->assertFalse($bag->getBoolean('unknown'), '->getBoolean() returns false if a parameter is not defined'); + $this->assertTrue($bag->getBoolean('unknown', true), '->getBoolean() returns default if a parameter is not defined'); + } + + /** + * @group legacy + */ + public function testGetBooleanExceptionWithInvalid() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\ParameterBag::getBoolean(\'invalid\')" is deprecated and will throw an "UnexpectedValueException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new ParameterBag(['invalid' => 'foo']); + $result = $bag->getBoolean('invalid', 0); + $this->assertFalse($result); } public function testGetEnum() @@ -260,3 +379,15 @@ public function testGetEnumThrowsExceptionWithInvalidValueType() $this->assertNull($bag->getEnum('invalid-value', FooEnum::class)); } } + +class InputStringable +{ + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +}