├── src ├── Factory │ ├── Exception │ │ ├── MissingDependencyException.php │ │ └── UnsupportedFormatException.php │ ├── ParserFactoryInterface.php │ ├── FormatterFactoryInterface.php │ ├── ParserFactory.php │ └── FormatterFactory.php ├── Format.php ├── Validator │ └── Constraints │ │ ├── MoneyEqualToValidator.php │ │ ├── MoneyLessThanValidator.php │ │ ├── MoneyNotEqualToValidator.php │ │ ├── MoneyGreaterThanValidator.php │ │ ├── MoneyEqualTo.php │ │ ├── MoneyLessThan.php │ │ ├── MoneyLessThanOrEqualValidator.php │ │ ├── MoneyGreaterThan.php │ │ ├── MoneyNotEqualTo.php │ │ ├── MoneyGreaterThanOrEqualValidator.php │ │ ├── MoneyLessThanOrEqual.php │ │ ├── MoneyGreaterThanOrEqual.php │ │ ├── AbstractMoneyComparison.php │ │ └── AbstractMoneyComparisonValidator.php ├── Twig │ └── MoneyExtension.php ├── Form │ ├── DataTransformer │ │ └── MoneyToLocalizedStringTransformer.php │ └── Type │ │ └── MoneyType.php ├── Serializer │ ├── Normalizer │ │ └── MoneyNormalizer.php │ └── Handler │ │ └── MoneyHandler.php └── BabDevMoneyBundle.php ├── config ├── serializer.php ├── jms_serializer.php ├── mapping │ ├── Currency.orm.xml │ ├── Currency.mongodb.xml │ ├── Money.orm.xml │ └── Money.mongodb.xml ├── twig.php ├── form.php ├── money.php └── validator.php ├── LICENSE └── composer.json /src/Factory/Exception/MissingDependencyException.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('money.serializer.normalizer', MoneyNormalizer::class) 10 | ->tag('serializer.normalizer') 11 | ; 12 | }; 13 | -------------------------------------------------------------------------------- /src/Format.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('money.serializer.handler', MoneyHandler::class) 10 | ->tag('jms_serializer.subscribing_handler') 11 | ; 12 | }; 13 | -------------------------------------------------------------------------------- /config/mapping/Currency.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /config/twig.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('money.twig_extension', MoneyExtension::class) 10 | ->args([ 11 | service('money.factory.formatter'), 12 | param('babdev_money.default_currency'), 13 | ]) 14 | ->tag('twig.extension') 15 | ; 16 | }; 17 | -------------------------------------------------------------------------------- /config/mapping/Currency.mongodb.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /config/mapping/Money.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /config/form.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('money.form.type.money', MoneyType::class) 10 | ->args([ 11 | service('money.factory.formatter'), 12 | service('money.factory.parser'), 13 | param('babdev_money.default_currency'), 14 | ]) 15 | ->tag('form.type') 16 | ; 17 | }; 18 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyEqualToValidator.php: -------------------------------------------------------------------------------- 1 | equals($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyEqualTo::NOT_EQUAL_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyLessThanValidator.php: -------------------------------------------------------------------------------- 1 | lessThan($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyLessThan::TOO_HIGH_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/mapping/Money.mongodb.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyNotEqualToValidator.php: -------------------------------------------------------------------------------- 1 | equals($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyNotEqualTo::IS_EQUAL_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyGreaterThanValidator.php: -------------------------------------------------------------------------------- 1 | greaterThan($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyGreaterThan::TOO_LOW_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyEqualTo.php: -------------------------------------------------------------------------------- 1 | 'NOT_EQUAL_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should be equal to {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyLessThan.php: -------------------------------------------------------------------------------- 1 | 'TOO_HIGH_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should be less than {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyLessThanOrEqualValidator.php: -------------------------------------------------------------------------------- 1 | lessThanOrEqual($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyLessThanOrEqual::TOO_HIGH_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyGreaterThan.php: -------------------------------------------------------------------------------- 1 | 'TOO_LOW_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should be greater than {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyNotEqualTo.php: -------------------------------------------------------------------------------- 1 | 'IS_EQUAL_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should not be equal to {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyGreaterThanOrEqualValidator.php: -------------------------------------------------------------------------------- 1 | greaterThanOrEqual($value2); 15 | } 16 | 17 | protected function getErrorCode(): string 18 | { 19 | return MoneyGreaterThanOrEqual::TOO_LOW_ERROR; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyLessThanOrEqual.php: -------------------------------------------------------------------------------- 1 | 'TOO_HIGH_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should be less than or equal to {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Validator/Constraints/MoneyGreaterThanOrEqual.php: -------------------------------------------------------------------------------- 1 | 'TOO_LOW_ERROR', 15 | ]; 16 | 17 | public ?string $message = 'This value should be greater than or equal to {{ compared_value }}.'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Factory/Exception/UnsupportedFormatException.php: -------------------------------------------------------------------------------- 1 | $formats 11 | * 12 | * @phpstan-param list $formats 13 | */ 14 | public function __construct( 15 | private readonly array $formats, 16 | string $message = '', 17 | int $code = 0, 18 | ?\Throwable $previous = null, 19 | ) { 20 | parent::__construct($message, $code, $previous); 21 | } 22 | 23 | /** 24 | * @return list 25 | * 26 | * @phpstan-return list 27 | */ 28 | public function getFormats(): array 29 | { 30 | return $this->formats; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Factory/ParserFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $options 18 | * 19 | * @throws UnsupportedFormatException if an unsupported format was requested 20 | * @throws MissingDependencyException if a dependency for a parser is not available 21 | */ 22 | public function createParser(string $format, ?string $locale, array $options): MoneyParser; 23 | } 24 | -------------------------------------------------------------------------------- /src/Factory/FormatterFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $options 18 | * 19 | * @throws UnsupportedFormatException if an unsupported format was requested 20 | * @throws MissingDependencyException if a dependency for a formatter is not available 21 | */ 22 | public function createFormatter(string $format, ?string $locale, array $options): MoneyFormatter; 23 | } 24 | -------------------------------------------------------------------------------- /config/money.php: -------------------------------------------------------------------------------- 1 | services() 12 | ->set('money.factory.formatter', FormatterFactory::class) 13 | ->args([ 14 | param('kernel.default_locale'), 15 | ]) 16 | ->alias(FormatterFactoryInterface::class, 'money.factory.formatter') 17 | 18 | ->set('money.factory.parser', ParserFactory::class) 19 | ->args([ 20 | param('kernel.default_locale'), 21 | ]) 22 | ->alias(ParserFactoryInterface::class, 'money.factory.parser') 23 | ; 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Michael Babker 4 | https://www.babdev.com/ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Twig/MoneyExtension.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getFilters(): array 30 | { 31 | return [ 32 | new TwigFilter('money', $this->formatMoney(...)), 33 | ]; 34 | } 35 | 36 | /** 37 | * @return list 38 | */ 39 | public function getFunctions(): array 40 | { 41 | return [ 42 | new TwigFunction('money', $this->createMoney(...)), 43 | ]; 44 | } 45 | 46 | /** 47 | * @phpstan-param numeric-string|int $amount 48 | * @phpstan-param non-empty-string|null $currency 49 | * 50 | * @throws \InvalidArgumentException if the amount cannot be converted to a {@see Money} instance 51 | */ 52 | public function createMoney(string|int $amount, ?string $currency = null): Money 53 | { 54 | return new Money($amount, new Currency($currency ?: $this->defaultCurrency)); 55 | } 56 | 57 | /** 58 | * @param array $options 59 | * 60 | * @phpstan-param Format::* $format 61 | * 62 | * @throws UnsupportedFormatException if an unsupported format was requested 63 | * @throws MissingDependencyException if a dependency for a formatter is not available 64 | * @throws FormatterException if the {@see Money} instance cannot be formatted 65 | */ 66 | public function formatMoney(Money $money, string $format = Format::INTL_MONEY, ?string $locale = null, array $options = []): string 67 | { 68 | return $this->formatterFactory->createFormatter($format, $locale, $options)->format($money); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /config/validator.php: -------------------------------------------------------------------------------- 1 | services() 14 | ->set('money.validator.abstract') 15 | ->abstract() 16 | ->args([ 17 | service('money.factory.formatter'), 18 | service('money.factory.parser'), 19 | param('babdev_money.default_currency'), 20 | service('property_accessor'), 21 | ]) 22 | 23 | ->set('money.validator.equal_to', MoneyEqualToValidator::class) 24 | ->parent('money.validator.abstract') 25 | ->tag('validator.constraint_validator', ['alias' => MoneyEqualToValidator::class]) 26 | 27 | ->set('money.validator.not_equal_to', MoneyNotEqualToValidator::class) 28 | ->parent('money.validator.abstract') 29 | ->tag('validator.constraint_validator', ['alias' => MoneyNotEqualToValidator::class]) 30 | 31 | ->set('money.validator.greater_than', MoneyGreaterThanValidator::class) 32 | ->parent('money.validator.abstract') 33 | ->tag('validator.constraint_validator', ['alias' => MoneyGreaterThanValidator::class]) 34 | 35 | ->set('money.validator.greater_than_or_equal', MoneyGreaterThanOrEqualValidator::class) 36 | ->parent('money.validator.abstract') 37 | ->tag('validator.constraint_validator', ['alias' => MoneyGreaterThanOrEqualValidator::class]) 38 | 39 | ->set('money.validator.less_than', MoneyLessThanValidator::class) 40 | ->parent('money.validator.abstract') 41 | ->tag('validator.constraint_validator', ['alias' => MoneyLessThanValidator::class]) 42 | 43 | ->set('money.validator.less_than_or_equal', MoneyLessThanOrEqualValidator::class) 44 | ->parent('money.validator.abstract') 45 | ->tag('validator.constraint_validator', ['alias' => MoneyLessThanOrEqualValidator::class]) 46 | ; 47 | }; 48 | -------------------------------------------------------------------------------- /src/Form/DataTransformer/MoneyToLocalizedStringTransformer.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | final readonly class MoneyToLocalizedStringTransformer implements DataTransformerInterface 26 | { 27 | public function __construct( 28 | private FormatterFactoryInterface $formatterFactory, 29 | private ParserFactoryInterface $parserFactory, 30 | private Currency $currency, 31 | private NumberToLocalizedStringTransformer $numberTransformer, 32 | private ?string $locale = null, 33 | ) {} 34 | 35 | /** 36 | * @param Money|null $value Money object 37 | * 38 | * @return string Localized money string 39 | * 40 | * @throws TransformationFailedException if the given value is not a Money instance or if the value can not be transformed 41 | */ 42 | public function transform($value): string 43 | { 44 | if (null === $value) { 45 | return ''; 46 | } 47 | 48 | if (!($value instanceof Money)) { 49 | throw new TransformationFailedException(\sprintf('Expected an instance of "%s", "%s" given.', Money::class, get_debug_type($value))); 50 | } 51 | 52 | $formatter = $this->formatterFactory->createFormatter(Format::DECIMAL, $this->locale, []); 53 | 54 | return $this->numberTransformer->transform((float) $formatter->format($value)); 55 | } 56 | 57 | /** 58 | * @param string $value Localized money string 59 | * 60 | * @phpstan-return Money|null 61 | * 62 | * @throws TransformationFailedException if the given value is not a string or if the value can not be transformed 63 | */ 64 | public function reverseTransform($value): ?Money 65 | { 66 | $value = $this->numberTransformer->reverseTransform($value); 67 | 68 | if (null === $value) { 69 | return null; 70 | } 71 | 72 | $parser = $this->parserFactory->createParser(Format::DECIMAL, $this->locale, []); 73 | 74 | try { 75 | return $parser->parse(\sprintf('%.53f', $value), $this->currency); 76 | } catch (ParserException $e) { 77 | throw new TransformationFailedException($e->getMessage(), 0, $e); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Serializer/Normalizer/MoneyNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getAmount(), 28 | 'currency' => $object->getCurrency()->getCode(), 29 | ]; 30 | } 31 | 32 | public function supportsNormalization($data, ?string $format = null, array $context = []): bool 33 | { 34 | return $data instanceof Money; 35 | } 36 | 37 | /** 38 | * @param mixed $data Data to restore 39 | * 40 | * @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported 41 | * @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data 42 | */ 43 | public function denormalize($data, string $type, ?string $format = null, array $context = []): Money 44 | { 45 | if (!\is_array($data)) { 46 | throw new InvalidArgumentException(\sprintf('Data expected to be an array, "%s" given.', get_debug_type($data))); 47 | } 48 | 49 | if (!isset($data['amount']) || !isset($data['currency'])) { 50 | throw new UnexpectedValueException('Missing required keys from data array, must provide "amount" and "currency".'); 51 | } 52 | 53 | \assert((\is_int($data['amount']) || is_numeric($data['amount'])) && (\is_string($data['currency']) && '' !== $data['currency'])); 54 | 55 | try { 56 | return new Money($data['amount'], new Currency($data['currency'])); // @phpstan-ignore-line argument.type 57 | } catch (\Exception $e) { 58 | throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e); 59 | } 60 | } 61 | 62 | public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool 63 | { 64 | return Money::class === $type; 65 | } 66 | 67 | /** 68 | * @return array 69 | */ 70 | public function getSupportedTypes(?string $format): array 71 | { 72 | return [ 73 | Money::class => true, 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "babdev/money-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Bundle integrating the Money for PHP library with Symfony", 5 | "keywords": ["money", "moneyphp", "currency", "symfony"], 6 | "license": "MIT", 7 | "require": { 8 | "php": "^8.2", 9 | "moneyphp/money": "^4.5", 10 | "symfony/config": "^6.4 || ^7.3 || ^8.0", 11 | "symfony/dependency-injection": "^6.4 || ^7.3 || ^8.0", 12 | "symfony/deprecation-contracts": "^2.1 || ^3.0", 13 | "symfony/http-kernel": "^6.4 || ^7.3 || ^8.0" 14 | }, 15 | "require-dev": { 16 | "doctrine/doctrine-bundle": "^2.11 || ^3.0", 17 | "doctrine/mongodb-odm": "^2.7", 18 | "doctrine/mongodb-odm-bundle": "^5.0", 19 | "doctrine/orm": "^2.19 || ^3.0", 20 | "jms/serializer": "^3.28", 21 | "jms/serializer-bundle": "^5.4", 22 | "matthiasnoback/symfony-dependency-injection-test": "^6.2", 23 | "phpstan/extension-installer": "^1.4", 24 | "phpstan/phpstan": "2.1.33", 25 | "phpstan/phpstan-phpunit": "2.0.10", 26 | "phpstan/phpstan-symfony": "2.0.9", 27 | "phpunit/phpunit": "11.5.46", 28 | "symfony/form": "^6.4 || ^7.3 || ^8.0", 29 | "symfony/intl": "^6.4 || ^7.3 || ^8.0", 30 | "symfony/property-access": "^6.4 || ^7.3 || ^8.0", 31 | "symfony/serializer": "^6.4 || ^7.3 || ^8.0", 32 | "symfony/twig-bundle": "^6.4 || ^7.3 || ^8.0", 33 | "symfony/validator": "^6.4 || ^7.3 || ^8.0", 34 | "twig/twig": "^2.13 || ^3.0" 35 | }, 36 | "conflict": { 37 | "doctrine/doctrine-bundle": "<2.1.1", 38 | "doctrine/mongodb-odm": "<2.2", 39 | "doctrine/mongodb-odm-bundle": "<4.3", 40 | "doctrine/orm": "<2.8", 41 | "jms/serializer": "<3.14", 42 | "symfony/form": "<6.4 || >=7.0 <7.3", 43 | "symfony/serializer": "<6.4 || >=7.0 <7.3", 44 | "symfony/validator": "<6.4 || >=7.0 <7.3", 45 | "twig/twig": "<2.13" 46 | }, 47 | "suggest": { 48 | "ext/intl": "To use the intl Money\\MoneyFormatter instances", 49 | "doctrine/mongodb-odm": "To use the Money\\Money class with the Doctrine MongoDB ODM", 50 | "doctrine/orm": "To use the Money\\Money class with the Doctrine ORM", 51 | "jms/serializer-bundle": "To use the Money\\Money class with the JMS Serializer", 52 | "symfony/form": "To use the Money\\Money class with the Symfony Form component", 53 | "symfony/serializer": "To use the Money\\Money class with the Symfony Serializer", 54 | "symfony/validator": "To use the Money\\Money class with the Symfony Validator", 55 | "twig/twig": "To use the Money\\Money class with Twig" 56 | }, 57 | "autoload": { 58 | "psr-4": { 59 | "BabDev\\MoneyBundle\\": "src/" 60 | } 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "BabDev\\MoneyBundle\\Tests\\": "tests/" 65 | } 66 | }, 67 | "config": { 68 | "allow-plugins": { 69 | "composer/package-versions-deprecated": true, 70 | "ocramius/package-versions": true, 71 | "phpstan/extension-installer": true 72 | } 73 | }, 74 | "minimum-stability": "dev" 75 | } 76 | -------------------------------------------------------------------------------- /src/BabDevMoneyBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(DoctrineMongoDBMappingsPass::createXmlMappingDriver([realpath(__DIR__.'/../config/mapping') => 'Money'], [])); 32 | } 33 | 34 | // Register ORM mappings if DoctrineBundle and the ORM are installed 35 | if (class_exists(DoctrineBundle::class) && class_exists(EntityManager::class)) { 36 | $container->addCompilerPass(DoctrineOrmMappingsPass::createXmlMappingDriver([realpath(__DIR__.'/../config/mapping') => 'Money'], [], false, [], true)); 37 | } 38 | } 39 | 40 | /** 41 | * @param DefinitionConfigurator<'array'> $definition 42 | */ 43 | public function configure(DefinitionConfigurator $definition): void 44 | { 45 | $definition->rootNode() 46 | ->children() 47 | ->scalarNode('default_currency')->defaultValue('USD')->end() 48 | ->end() 49 | ; 50 | } 51 | 52 | /** 53 | * @param array{default_currency: non-empty-string} $config 54 | */ 55 | public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void 56 | { 57 | $container->parameters() 58 | ->set('babdev_money.default_currency', $config['default_currency']); 59 | 60 | $container->import('../config/money.php'); 61 | 62 | if (ContainerBuilder::willBeAvailable('twig/twig', Environment::class, ['symfony/twig-bundle', 'babdev/money-bundle'])) { 63 | $container->import('../config/twig.php'); 64 | } 65 | 66 | if (ContainerBuilder::willBeAvailable('jms/serializer', SerializerInterface::class, ['jms/serializer-bundle', 'babdev/money-bundle'])) { 67 | $container->import('../config/jms_serializer.php'); 68 | } 69 | 70 | if (ContainerBuilder::willBeAvailable('symfony/form', FormInterface::class, ['babdev/money-bundle'])) { 71 | $container->import('../config/form.php'); 72 | } 73 | 74 | if (ContainerBuilder::willBeAvailable('symfony/serializer', NormalizerInterface::class, ['babdev/money-bundle'])) { 75 | $container->import('../config/serializer.php'); 76 | } 77 | 78 | if (ContainerBuilder::willBeAvailable('symfony/validator', ValidatorInterface::class, ['babdev/money-bundle'])) { 79 | $container->import('../config/validator.php'); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Validator/Constraints/AbstractMoneyComparison.php: -------------------------------------------------------------------------------- 1 | 45 | */ 46 | public int $fractionDigits = 2; 47 | 48 | public bool $groupingUsed = true; 49 | public ?string $locale = null; 50 | public string $style = 'currency'; 51 | 52 | public string|PropertyPathInterface|null $propertyPath = null; 53 | 54 | /** 55 | * @param mixed $value The value to compare or a set of options 56 | * @param string|PropertyPathInterface|null $propertyPath An optional property path to read 57 | * @param array|null $options A set of options 58 | * @param string[] $groups An array of validation groups 59 | * @param mixed $payload Domain-specific data attached to a constraint 60 | */ 61 | #[HasNamedArguments] 62 | public function __construct(mixed $value = null, $propertyPath = null, ?string $message = null, ?array $options = null, ?array $groups = null, mixed $payload = null) 63 | { 64 | if (\is_array($value)) { 65 | trigger_deprecation('babdev/money-bundle', '2.1', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class); 66 | 67 | $options = array_merge($value, $options ?? []); 68 | } elseif (null !== $value) { 69 | if (\is_array($options)) { 70 | trigger_deprecation('babdev/money-bundle', '2.1', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class); 71 | } else { 72 | $options = []; 73 | } 74 | 75 | $options['value'] = $value; 76 | } 77 | 78 | parent::__construct($options, $groups, $payload); 79 | 80 | $this->message = $message ?? $this->message; 81 | $this->value = $value ?? $this->value; 82 | $this->propertyPath = $propertyPath ?? $this->propertyPath; 83 | 84 | if (null === $this->value && null === $this->propertyPath) { 85 | throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires either the "value" or "propertyPath" option to be set.', static::class)); 86 | } 87 | 88 | if (null !== $this->value && null !== $this->propertyPath) { 89 | throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires only one of the "value" or "propertyPath" options to be set, not both.', static::class)); 90 | } 91 | 92 | if (null !== $this->propertyPath && !class_exists(PropertyAccess::class)) { 93 | throw new LogicException(\sprintf('The "%s" constraint requires the Symfony PropertyAccess component to use the "propertyPath" option.', static::class)); 94 | } 95 | } 96 | 97 | public function getDefaultOption(): ?string 98 | { 99 | return 'value'; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Serializer/Handler/MoneyHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigatorInterface::DIRECTION_SERIALIZATION, 24 | 'format' => 'json', 25 | 'type' => Money::class, 26 | 'method' => 'serializeMoneyToJson', 27 | ], 28 | [ 29 | 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, 30 | 'format' => 'json', 31 | 'type' => Money::class, 32 | 'method' => 'deserializeMoneyFromJson', 33 | ], 34 | [ 35 | 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, 36 | 'format' => 'xml', 37 | 'type' => Money::class, 38 | 'method' => 'serializeMoneyToXml', 39 | ], 40 | [ 41 | 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, 42 | 'format' => 'xml', 43 | 'type' => Money::class, 44 | 'method' => 'deserializeMoneyFromXml', 45 | ], 46 | ]; 47 | } 48 | 49 | /** 50 | * @param array{amount: int|numeric-string, currency: non-empty-string} $moneyAsArray 51 | * 52 | * @throws InvalidArgumentException if a {@see Money} instance could not be created from the serialized data 53 | */ 54 | public function deserializeMoneyFromJson(DeserializationVisitorInterface $visitor, array $moneyAsArray, array $type, DeserializationContext $context): Money 55 | { 56 | try { 57 | return new Money($moneyAsArray['amount'], new Currency($moneyAsArray['currency'])); 58 | } catch (\Exception $exception) { 59 | throw new InvalidArgumentException('Could not deserialize Money data.', $exception->getCode(), $exception); 60 | } 61 | } 62 | 63 | /** 64 | * @throws InvalidArgumentException if a {@see Money} instance could not be created from the serialized data 65 | */ 66 | public function deserializeMoneyFromXml(XmlDeserializationVisitor $visitor, \SimpleXMLElement $moneyAsXml, array $type, DeserializationContext $context): Money 67 | { 68 | /** @phpstan-var numeric-string $amount */ 69 | $amount = (string) $moneyAsXml->amount; 70 | 71 | /** @phpstan-var non-empty-string $currency */ 72 | $currency = (string) $moneyAsXml->currency; 73 | 74 | try { 75 | return new Money($amount, new Currency($currency)); 76 | } catch (\Exception $exception) { 77 | throw new InvalidArgumentException('Could not deserialize Money data.', $exception->getCode(), $exception); 78 | } 79 | } 80 | 81 | /** 82 | * @param array{name: string, params: array} $type 83 | * 84 | * @return array|\ArrayObject 85 | */ 86 | public function serializeMoneyToJson(JsonSerializationVisitor $visitor, Money $money, array $type, SerializationContext $context) 87 | { 88 | /** @phpstan-ignore-next-line return.type */ 89 | return $visitor->visitArray( 90 | [ 91 | 'amount' => $money->getAmount(), 92 | 'currency' => $money->getCurrency()->getCode(), 93 | ], 94 | $type 95 | ); 96 | } 97 | 98 | public function serializeMoneyToXml(XmlSerializationVisitor $visitor, Money $money, array $type, SerializationContext $context): \DOMElement 99 | { 100 | $amountNode = $visitor->getDocument()->createElement('amount'); 101 | $amountNode->nodeValue = $money->getAmount(); 102 | 103 | $currencyNode = $visitor->getDocument()->createElement('currency'); 104 | $currencyNode->nodeValue = $money->getCurrency()->getCode(); 105 | 106 | $moneyNode = $visitor->getDocument()->createElement('money'); 107 | $moneyNode->appendChild($amountNode); 108 | $moneyNode->appendChild($currencyNode); 109 | 110 | return $moneyNode; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Factory/ParserFactory.php: -------------------------------------------------------------------------------- 1 | > 20 | * 21 | * @phpstan-var array> 22 | */ 23 | private const PARSER_MAP = [ 24 | Format::BITCOIN => BitcoinMoneyParser::class, 25 | Format::DECIMAL => DecimalMoneyParser::class, 26 | Format::INTL_LOCALIZED_DECIMAL => IntlLocalizedDecimalParser::class, 27 | Format::INTL_MONEY => IntlMoneyParser::class, 28 | ]; 29 | 30 | public function __construct(private readonly string $defaultLocale) {} 31 | 32 | /** 33 | * @param array{fraction_digits?: int<0, max>, grouping_used?: bool, style?: string} $options 34 | * 35 | * @phpstan-param Format::* $format 36 | * @phpstan-param array $options 37 | * 38 | * @throws UnsupportedFormatException if an unsupported format was requested 39 | * @throws MissingDependencyException if a dependency for a parser is not available 40 | */ 41 | public function createParser(string $format, ?string $locale = null, array $options = []): MoneyParser 42 | { 43 | switch ($format) { 44 | case Format::AGGREGATE: 45 | throw new UnsupportedFormatException(array_keys(self::PARSER_MAP), \sprintf('The "%s" class is not supported by "%s".', AggregateMoneyParser::class, self::class)); 46 | case Format::BITCOIN: 47 | $fractionDigits = (int) ($options['fraction_digits'] ?? 8); 48 | 49 | return new BitcoinMoneyParser($fractionDigits); 50 | 51 | case Format::DECIMAL: 52 | return new DecimalMoneyParser(new ISOCurrencies()); 53 | 54 | case Format::INTL_LOCALIZED_DECIMAL: 55 | if (!class_exists(\NumberFormatter::class)) { 56 | throw new MissingDependencyException(\sprintf('The "intl_localized_decimal" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class)); 57 | } 58 | 59 | $formatterLocale = $locale ?: $this->defaultLocale; 60 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2); 61 | $groupingUsed = (bool) ($options['grouping_used'] ?? true); 62 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY; 63 | 64 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY); 65 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits); 66 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0); 67 | 68 | return new IntlLocalizedDecimalParser($numberFormatter, new ISOCurrencies()); 69 | 70 | case Format::INTL_MONEY: 71 | if (!class_exists(\NumberFormatter::class)) { 72 | throw new MissingDependencyException(\sprintf('The "intl_money" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class)); 73 | } 74 | 75 | $formatterLocale = $locale ?: $this->defaultLocale; 76 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2); 77 | $groupingUsed = (bool) ($options['grouping_used'] ?? true); 78 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY; 79 | 80 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY); 81 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits); 82 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0); 83 | 84 | return new IntlMoneyParser($numberFormatter, new ISOCurrencies()); 85 | 86 | default: 87 | throw new UnsupportedFormatException(array_keys(self::PARSER_MAP), \sprintf('Unsupported format "%s"', $format)); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Factory/FormatterFactory.php: -------------------------------------------------------------------------------- 1 | > 21 | * 22 | * @phpstan-var array> 23 | */ 24 | private const FORMAT_MAP = [ 25 | Format::BITCOIN => BitcoinMoneyFormatter::class, 26 | Format::DECIMAL => DecimalMoneyFormatter::class, 27 | Format::INTL_LOCALIZED_DECIMAL => IntlLocalizedDecimalFormatter::class, 28 | Format::INTL_MONEY => IntlMoneyFormatter::class, 29 | ]; 30 | 31 | public function __construct(private readonly string $defaultLocale) {} 32 | 33 | /** 34 | * @param array{fraction_digits?: int<0, max>, grouping_used?: bool, style?: string} $options 35 | * 36 | * @phpstan-param Format::* $format 37 | * @phpstan-param array $options 38 | * 39 | * @throws UnsupportedFormatException if an unsupported format was requested 40 | * @throws MissingDependencyException if a dependency for a formatter is not available 41 | */ 42 | public function createFormatter(string $format, ?string $locale = null, array $options = []): MoneyFormatter 43 | { 44 | switch ($format) { 45 | case Format::AGGREGATE: 46 | throw new UnsupportedFormatException(array_keys(self::FORMAT_MAP), \sprintf('The "%s" class is not supported by "%s".', AggregateMoneyFormatter::class, self::class)); 47 | case Format::BITCOIN: 48 | $fractionDigits = (int) ($options['fraction_digits'] ?? 8); 49 | 50 | return new BitcoinMoneyFormatter($fractionDigits, new BitcoinCurrencies()); 51 | 52 | case Format::DECIMAL: 53 | return new DecimalMoneyFormatter(new ISOCurrencies()); 54 | 55 | case Format::INTL_LOCALIZED_DECIMAL: 56 | if (!class_exists(\NumberFormatter::class)) { 57 | throw new MissingDependencyException(\sprintf('The "intl_localized_decimal" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class)); 58 | } 59 | 60 | $formatterLocale = $locale ?: $this->defaultLocale; 61 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2); 62 | $groupingUsed = (bool) ($options['grouping_used'] ?? true); 63 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY; 64 | 65 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY); 66 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits); 67 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0); 68 | 69 | return new IntlLocalizedDecimalFormatter($numberFormatter, new ISOCurrencies()); 70 | 71 | case Format::INTL_MONEY: 72 | if (!class_exists(\NumberFormatter::class)) { 73 | throw new MissingDependencyException(\sprintf('The "intl_money" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class)); 74 | } 75 | 76 | $formatterLocale = $locale ?: $this->defaultLocale; 77 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2); 78 | $groupingUsed = (bool) ($options['grouping_used'] ?? true); 79 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY; 80 | 81 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY); 82 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits); 83 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0); 84 | 85 | return new IntlMoneyFormatter($numberFormatter, new ISOCurrencies()); 86 | 87 | default: 88 | throw new UnsupportedFormatException(array_keys(self::FORMAT_MAP), \sprintf('Unsupported format "%s"', $format)); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Form/Type/MoneyType.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | final class MoneyType extends AbstractType 29 | { 30 | private readonly Currency $defaultCurrency; 31 | 32 | /** 33 | * @var array> 34 | */ 35 | private static array $patterns = []; 36 | 37 | /** 38 | * @phpstan-param non-empty-string $defaultCurrency 39 | */ 40 | public function __construct( 41 | private readonly FormatterFactoryInterface $formatterFactory, 42 | private readonly ParserFactoryInterface $parserFactory, 43 | string $defaultCurrency, 44 | ) { 45 | $this->defaultCurrency = new Currency($defaultCurrency); 46 | } 47 | 48 | public function buildForm(FormBuilderInterface $builder, array $options): void 49 | { 50 | // Values used in HTML5 number inputs should be formatted as in "1234.5", ie. 'en' format without grouping, 51 | // according to https://www.w3.org/TR/html51/sec-forms.html#date-time-and-number-formats 52 | $builder 53 | ->addViewTransformer(new MoneyToLocalizedStringTransformer( 54 | $this->formatterFactory, 55 | $this->parserFactory, 56 | $options['currency'], 57 | new NumberToLocalizedStringTransformer( 58 | $options['scale'], 59 | $options['grouping'], 60 | $options['rounding_mode'], 61 | $options['html5'] ? 'en' : null 62 | ) 63 | )) 64 | ; 65 | } 66 | 67 | public function buildView(FormView $view, FormInterface $form, array $options): void 68 | { 69 | $view->vars['money_pattern'] = self::getPattern($options['currency']); // @phpstan-ignore-line argument.type 70 | 71 | if ($options['html5']) { 72 | $view->vars['type'] = 'number'; 73 | } 74 | } 75 | 76 | public function configureOptions(OptionsResolver $resolver): void 77 | { 78 | $resolver->setDefaults([ 79 | 'scale' => 2, 80 | 'grouping' => false, 81 | 'rounding_mode' => \NumberFormatter::ROUND_HALFUP, 82 | 'currency' => $this->defaultCurrency, 83 | 'compound' => false, 84 | 'html5' => false, 85 | 'invalid_message' => 'Please enter a valid money amount.', 86 | ]); 87 | 88 | $resolver->setAllowedValues( 89 | 'rounding_mode', 90 | [ 91 | \NumberFormatter::ROUND_FLOOR, 92 | \NumberFormatter::ROUND_DOWN, 93 | \NumberFormatter::ROUND_HALFDOWN, 94 | \NumberFormatter::ROUND_HALFEVEN, 95 | \NumberFormatter::ROUND_HALFUP, 96 | \NumberFormatter::ROUND_UP, 97 | \NumberFormatter::ROUND_CEILING, 98 | ] 99 | ); 100 | 101 | $resolver->setAllowedTypes('scale', 'int'); 102 | $resolver->setAllowedTypes('html5', 'bool'); 103 | $resolver->setAllowedTypes('currency', Currency::class); 104 | 105 | $resolver->setNormalizer( 106 | 'grouping', 107 | static function (Options $options, $value) { 108 | if ($value && $options['html5']) { 109 | throw new LogicException('Cannot use the "grouping" option when the "html5" option is enabled.'); 110 | } 111 | 112 | return $value; 113 | } 114 | ); 115 | } 116 | 117 | public function getBlockPrefix(): string 118 | { 119 | return 'money'; 120 | } 121 | 122 | /** 123 | * Returns the pattern for this locale in UTF-8. 124 | * 125 | * The pattern contains the placeholder "{{ widget }}" where the HTML tag should be inserted. 126 | */ 127 | private static function getPattern(Currency $currency): string 128 | { 129 | $currencyCode = $currency->getCode(); 130 | 131 | $locale = \Locale::getDefault(); 132 | 133 | if (!isset(self::$patterns[$locale])) { 134 | self::$patterns[$locale] = []; 135 | } 136 | 137 | if (!isset(self::$patterns[$locale][$currencyCode])) { 138 | $format = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); 139 | $pattern = $format->formatCurrency(123.0, $currencyCode); 140 | 141 | // the spacings between currency symbol and number are ignored, because 142 | // a single space leads to better readability in combination with input 143 | // fields 144 | 145 | // the regex also considers non-break spaces (0xC2 or 0xA0 in UTF-8) 146 | 147 | preg_match('/^([^\s\xc2\xa0]*)[\s\xc2\xa0]*123(?:[,.]0+)?[\s\xc2\xa0]*([^\s\xc2\xa0]*)$/u', $pattern, $matches); 148 | 149 | if (!empty($matches[1])) { 150 | self::$patterns[$locale][$currencyCode] = $matches[1].' {{ widget }}'; 151 | } elseif (!empty($matches[2])) { 152 | self::$patterns[$locale][$currencyCode] = '{{ widget }} '.$matches[2]; 153 | } else { 154 | self::$patterns[$locale][$currencyCode] = '{{ widget }}'; 155 | } 156 | } 157 | 158 | return self::$patterns[$locale][$currencyCode]; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Validator/Constraints/AbstractMoneyComparisonValidator.php: -------------------------------------------------------------------------------- 1 | propertyPath) { 47 | if (null === $object = $this->context->getObject()) { 48 | return; 49 | } 50 | 51 | try { 52 | $comparedValue = $this->getPropertyAccessor()->getValue($object, $path); 53 | } catch (NoSuchPropertyException $e) { 54 | throw new InvalidArgumentException(\sprintf('Invalid property path "%s" provided to "%s" constraint: ', $path, get_debug_type($constraint)).$e->getMessage(), 0, $e); 55 | } 56 | } else { 57 | $comparedValue = $constraint->value; 58 | } 59 | 60 | /** @var Money $firstValue */ 61 | $firstValue = $this->ensureMoneyObject($constraint, $value); 62 | $secondValue = $this->ensureMoneyObject($constraint, $comparedValue); 63 | 64 | if (!$this->compareValues($firstValue, $secondValue)) { 65 | $violationBuilder = $this->context->buildViolation($constraint->message) 66 | ->setParameter('{{ value }}', $this->formatterFactory->createFormatter($constraint->formatterFormat, $constraint->locale, $this->createFactoryOptions($constraint))->format($firstValue)) 67 | ->setParameter('{{ compared_value }}', null !== $secondValue ? $this->formatterFactory->createFormatter($constraint->formatterFormat, $constraint->locale, $this->createFactoryOptions($constraint))->format($secondValue) : 'N/A') 68 | ->setParameter('{{ compared_value_type }}', $this->formatTypeOf($comparedValue)) 69 | ->setCode($this->getErrorCode()); 70 | 71 | if (null !== $path) { 72 | $violationBuilder->setParameter('{{ compared_value_path }}', (string) $path); 73 | } 74 | 75 | $violationBuilder->addViolation(); 76 | } 77 | } 78 | 79 | /** 80 | * @return array{fraction_digits: int<0, max>, grouping_used: bool, style: string} 81 | */ 82 | private function createFactoryOptions(AbstractMoneyComparison $constraint): array 83 | { 84 | return [ 85 | 'fraction_digits' => $constraint->fractionDigits, 86 | 'grouping_used' => $constraint->groupingUsed, 87 | 'style' => $constraint->style, 88 | ]; 89 | } 90 | 91 | /** 92 | * @phpstan-param Money|float|int|numeric-string|null $value 93 | */ 94 | private function ensureMoneyObject(AbstractMoneyComparison $constraint, Money|float|int|string|null $value): ?Money 95 | { 96 | if ($value instanceof Money || null === $value) { 97 | return $value; 98 | } 99 | 100 | // First try to parse (assuming formatted input) then fall back to treating as a number 101 | if (\is_string($value) && str_contains($value, '.')) { 102 | try { 103 | return $this->parserFactory->createParser($constraint->parserFormat, $constraint->locale, $this->createFactoryOptions($constraint))->parse($value, new Currency($constraint->currency ?: $this->defaultCurrency)); 104 | } catch (ParserException $exception) { 105 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Money::class), 0, $exception); 106 | } 107 | } 108 | 109 | try { 110 | $number = \is_float($value) ? Number::fromFloat($value) : Number::fromNumber($value); 111 | } catch (\InvalidArgumentException $exception) { 112 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Number::class), 0, $exception); 113 | } 114 | 115 | try { 116 | return new Money((string) $number, new Currency($constraint->currency ?: $this->defaultCurrency)); 117 | } catch (\InvalidArgumentException $exception) { 118 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Money::class), 0, $exception); 119 | } 120 | } 121 | 122 | private function getPropertyAccessor(): PropertyAccessorInterface 123 | { 124 | return $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor(); 125 | } 126 | 127 | abstract protected function compareValues(Money $value1, ?Money $value2): bool; 128 | 129 | protected function getErrorCode(): ?string 130 | { 131 | return null; 132 | } 133 | } 134 | --------------------------------------------------------------------------------