├── .github └── workflows │ └── ci.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── ToRussianPeople.md ├── composer.json ├── docs ├── exception_handling.md ├── factory_value_object.md └── mapping.md ├── phpstan.dist.neon └── src └── Qossmic ├── DataMapper ├── DataMapper.php └── PropertyMapperInterface.php ├── DataTransformer └── ValueObjectTransformer.php ├── DependencyInjection ├── Compiler │ └── RegisterExceptionHandlersPass.php └── RichModelFormsExtension.php ├── ExceptionHandling ├── ArgumentTypeMismatchExceptionHandler.php ├── ChainExceptionHandler.php ├── Error.php ├── ExceptionHandlerInterface.php ├── ExceptionHandlerRegistry.php ├── ExceptionToErrorMapperTrait.php ├── FallbackExceptionHandler.php ├── FormExceptionHandler.php └── GenericExceptionHandler.php ├── Extension └── RichModelFormsTypeExtension.php ├── Instantiator ├── FormDataInstantiator.php ├── ObjectInstantiator.php └── ViewDataInstantiator.php ├── Resources └── config │ └── services.xml └── RichModelFormsBundle.php /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | 3 | on: 4 | - 'push' 5 | - 'pull_request' 6 | 7 | jobs: 8 | cs-fixer: 9 | name: 'PHP CS Fixer' 10 | 11 | runs-on: 'ubuntu-latest' 12 | 13 | steps: 14 | - name: 'Check out' 15 | uses: 'actions/checkout@v4' 16 | 17 | - name: 'Set up PHP' 18 | uses: 'shivammathur/setup-php@v2' 19 | with: 20 | php-version: '8.1' 21 | coverage: 'none' 22 | 23 | - name: 'Check the code style' 24 | uses: docker://oskarstark/php-cs-fixer-ga 25 | with: 26 | args: '--diff --dry-run' 27 | 28 | phpstan: 29 | name: 'PhpStan' 30 | 31 | runs-on: 'ubuntu-latest' 32 | 33 | steps: 34 | - name: 'Check out' 35 | uses: 'actions/checkout@v4' 36 | 37 | - name: 'Set up PHP' 38 | uses: 'shivammathur/setup-php@v2' 39 | with: 40 | php-version: '8.1' 41 | coverage: 'none' 42 | 43 | - name: 'Install dependencies' 44 | uses: php-actions/composer@v6 45 | with: 46 | php_version: '8.1' 47 | 48 | - name: 'Run PhpStan' 49 | run: | 50 | vendor/bin/phpstan analyze 51 | 52 | tests: 53 | name: 'PHPUnit' 54 | 55 | runs-on: 'ubuntu-latest' 56 | 57 | strategy: 58 | matrix: 59 | include: 60 | - php-version: '8.1' 61 | composer-options: '--prefer-stable' 62 | symfony-version: '6.4.*' 63 | - php-version: '8.2' 64 | composer-options: '--prefer-stable' 65 | symfony-version: '6.4.*' 66 | - php-version: '8.2' 67 | composer-options: '--prefer-stable' 68 | symfony-version: '7.1.*' 69 | - php-version: '8.2' 70 | composer-options: '--prefer-stable' 71 | symfony-version: '7.2.*' 72 | - php-version: '8.3' 73 | composer-options: '--prefer-stable' 74 | symfony-version: '6.4.*' 75 | - php-version: '8.3' 76 | composer-options: '--prefer-stable' 77 | symfony-version: '7.1.*' 78 | - php-version: '8.3' 79 | composer-options: '--prefer-stable' 80 | symfony-version: '7.2.*' 81 | - php-version: '8.4' 82 | composer-options: '--prefer-stable' 83 | symfony-version: '6.4.*' 84 | - php-version: '8.4' 85 | composer-options: '--prefer-stable' 86 | symfony-version: '7.1.*' 87 | - php-version: '8.4' 88 | composer-options: '--prefer-stable' 89 | symfony-version: '7.2.*' 90 | 91 | steps: 92 | - name: 'Check out' 93 | uses: 'actions/checkout@v4' 94 | 95 | - name: 'Set up PHP' 96 | uses: 'shivammathur/setup-php@v2' 97 | with: 98 | php-version: '${{ matrix.php-version }}' 99 | coverage: 'none' 100 | tools: flex 101 | 102 | - name: 'Get Composer cache directory' 103 | id: 'composer-cache' 104 | run: 'echo "::set-output name=cache-dir::$(composer config cache-files-dir)"' 105 | 106 | - name: 'Cache dependencies' 107 | uses: 'actions/cache@v4' 108 | with: 109 | path: '${{ steps.composer-cache.outputs.cache-dir }}' 110 | key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" 111 | restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' 112 | 113 | - name: 'Install dependencies' 114 | env: 115 | SYMFONY_REQUIRE: '${{ matrix.symfony-version }}' 116 | run: | 117 | composer update --no-progress ${{ matrix.composer-options }} 118 | 119 | - name: 'Install PHPUnit' 120 | run: 'vendor/bin/simple-phpunit install' 121 | 122 | - name: 'Run tests' 123 | run: | 124 | vendor/bin/simple-phpunit --testsuite="unit tests" 125 | vendor/bin/simple-phpunit --testsuite="integration tests" 126 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 0.4.0 5 | ----- 6 | 7 | * add Symfony 7 support 8 | * the following classes are internal and can break BC at any time: 9 | * `RegisterExceptionHandlersPass` 10 | * `RichModelFormsExtension` 11 | * `RichModelFormsTypeExtension` 12 | * [BC BREAK] add `mixed` return-type to the following methods: 13 | * `ObjectInstantiator::getArgumentData()` 14 | * `ObjectInstantiator::getData()` 15 | * `PropertyMapperInterface::readPropertyValue()` 16 | * `PropertyMapperInterface::writePropertyValue()` 17 | * [BC BREAK] mark the following classes as final: 18 | * `ArgumentTypeMismatchExceptionHandler` 19 | * `ChainExceptionHandler` 20 | * `DataMapper` 21 | * `Error` 22 | * `ExceptionHandlerRegistry` 23 | * `FallbackExceptionHandler` 24 | * `FormDataInstantiator` 25 | * `FormExceptionHandler` 26 | * `GenericExceptionHandler` 27 | * `ValueObjectTransformer` 28 | * `ViewDataInstantiator` 29 | * drop support for Symfony 5.4, 6.0, 6.1, 6.2 and 6.3 30 | * drop support for PHP 7.4 and 8.0 31 | 32 | 0.3.0 33 | ----- 34 | 35 | * allow `psr/container` 2.0 36 | * drop support for Symfony 5.3 37 | * drop support for PHP 7.2 and 7.3 38 | 39 | 0.2.0 40 | ----- 41 | 42 | * add Symfony 6 support 43 | * drop support for Symfony 5.0, 5.1, and 5.2 44 | * drop support for PHP 7.1 45 | 46 | 0.1.0 47 | ----- 48 | 49 | Initial release of the bundle under its new `qossmic/rich-model-forms-bundle` package name. This release is 50 | feature-equivalent to the `0.8.0` release of the `sensiolabs-de/rich-model-forms-bundle` package apart from 51 | all deprecated services and PHP classes being removed. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2023 Christian Flothmann & Christopher Hertel & QOSSMIC GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rich Model Forms Bundle 2 | ======================= 3 | 4 | [![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) 5 | 6 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) 7 | 8 | ## A message to Russian 🇷🇺 people 9 | 10 | If you currently live in Russia, please read [this message](./ToRussianPeople.md). 11 | 12 | The Rich Model Forms Bundle enhances the [Symfony Form component](https://symfony.com/doc/current/forms.html) with 13 | useful options that ease the work with rich domain models. 14 | 15 | Installation 16 | ------------ 17 | 18 | Use Composer to install the bundle: 19 | 20 | ```bash 21 | $ composer require qossmic/rich-model-forms-bundle 22 | ``` 23 | 24 | When using Symfony Flex, the bundle will be enabled automatically. Otherwise, you need to make sure that the bundle is 25 | registered in your application kernel. 26 | 27 | Usage 28 | ----- 29 | 30 | The bundle currently supports the following use cases: 31 | 32 | * [Differing Property Paths For Reading And Writing](docs/mapping.md) 33 | 34 | * [Support for constructors with arguments and for value objects](docs/factory_value_object.md) 35 | 36 | * [Enhanced exception handling](docs/exception_handling.md) 37 | 38 | Resources 39 | --------- 40 | 41 | * Video - [SymfonyCon Lisbon 2018: Symfony Form Rich Domain Models - Video](https://symfonycasts.com/screencast/symfonycon2018/symfony-forms-rich-domain-models) 42 | * Slide deck - [SymfonyCon Lisbon 2018: Symfony Form Rich Domain Models - Slides](https://speakerdeck.com/el_stoffel/using-symfony-forms-with-rich-domain-models) 43 | -------------------------------------------------------------------------------- /ToRussianPeople.md: -------------------------------------------------------------------------------- 1 | ## 🇷🇺 Русским гражданам 2 | 3 | В Украине сейчас идет война. Силами РФ наносятся удары по гражданской инфраструктуре в [Харькове][1], [Киеве][2], [Чернигове][3], [Сумах][4], [Ирпене][5] и десятках других городов. Гибнут люди - и гражданское население, и военные, в том числе российские призывники, которых бросили воевать. Чтобы лишить собственный народ доступа к информации, правительство РФ запретило называть войну войной, закрыло независимые СМИ и принимает сейчас ряд диктаторских законов. Эти законы призваны заткнуть рот всем, кто против войны. За обычный призыв к миру сейчас можно получить несколько лет тюрьмы. 4 | 5 | Не молчите! Молчание - знак вашего согласия с политикой российского правительства. 6 | **Вы можете сделать выбор НЕ МОЛЧАТЬ.** 7 | 8 | --- 9 | 10 | ## 🇺🇸 To people of Russia 11 | 12 | There is a war in Ukraine right now. The forces of the Russian Federation are attacking civilian infrastructure in [Kharkiv][1], [Kyiv][2], [Chernihiv][3], [Sumy][4], [Irpin][5] and dozens of other cities. People are dying – both civilians and military servicemen, including Russian conscripts who were thrown into the fighting. In order to deprive its own people of access to information, the government of the Russian Federation has forbidden calling a war a war, shut down independent media and is passing a number of dictatorial laws. These laws are meant to silence all those who are against war. You can be jailed for multiple years for simply calling for peace. 13 | 14 | Do not be silent! Silence is a sign that you accept the Russian government's policy. 15 | **You can choose NOT TO BE SILENT.** 16 | 17 | [1]: "Kharkiv under attack" 18 | [2]: "Kyiv under attack" 19 | [3]: "Chernihiv under attack" 20 | [4]: "Sumy under attack" 21 | [5]: "Irpin under attack" 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qossmic/rich-model-forms-bundle", 3 | "description": "Provides additional data mapper options that ease the use of the Symfony Form component with rich models.", 4 | "keywords": ["Symfony", "form", "forms", "bundle", "rich model", "DDD", "domain-driven design"], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christian Flothmann", 10 | "email": "christian.flothmann@qossmic.com" 11 | }, 12 | { 13 | "name": "Christopher Hertel", 14 | "email": "mail@christopher-hertel.de" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.1", 19 | "psr/container": "^1.0||^2.0", 20 | "symfony/config": "^6.4||^7.0", 21 | "symfony/dependency-injection": "^6.4||^7.0", 22 | "symfony/form": "^6.4||^7.0", 23 | "symfony/framework-bundle": "^6.4||^7.0", 24 | "symfony/http-kernel": "^6.4||^7.0", 25 | "symfony/options-resolver": "^6.4||^7.0", 26 | "symfony/property-access": "^6.4||^7.0" 27 | }, 28 | "require-dev": { 29 | "phpstan/phpstan": "^2.1", 30 | "symfony/phpunit-bridge": "^6.4||^7.0", 31 | "symfony/translation": "^6.4||^7.0" 32 | }, 33 | "conflict": { 34 | "sensiolabs-de/rich-model-forms-bundle": "0.8.*" 35 | }, 36 | "minimum-stability": "dev", 37 | "config": { 38 | "sort-packages": true 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Qossmic\\RichModelForms\\": "src/Qossmic" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Qossmic\\RichModelForms\\Tests\\": "tests" 48 | } 49 | }, 50 | "extra": { 51 | "branch-alias": { 52 | "dev-main": "0.4-dev" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docs/exception_handling.md: -------------------------------------------------------------------------------- 1 | Catching Model Exceptions 2 | ========================= 3 | 4 | To ensure that your model is in a valid state at all times you need to forbid interactions with your model that would 5 | transform it into an invalid state. The exceptions being thrown to achieve that will by default not be caught by the 6 | Form component meaning that any invalid user input leads to "internal server error" responses. 7 | 8 | You can use the `expected_exception` option to indicate which exceptions can be triggered when a particular property 9 | is modified. Exceptions that can be thrown inside the constructor must be specified for the whole form: 10 | 11 | ```php 12 | class ProductType extends AbstractType 13 | { 14 | public function buildForm(FormBuilderInterface $builder, array $options) 15 | { 16 | $builder 17 | ->add('name', null, [ 18 | 'read_property_path' => 'getName', 19 | 'write_property_path' => 'rename', 20 | 'expected_exception' => ProductException::class, 21 | ]) 22 | ->add('category', EntityType::class, [ 23 | 'class' => Category::class, 24 | 'choice_label' => function (Category $category) { 25 | return $category->getName(); 26 | }, 27 | 'read_property_path' => 'getCategory', 28 | 'write_property_path' => 'moveToCategory', 29 | ]) 30 | ->add('price', PriceType::class, [ 31 | 'read_property_path' => 'getPrice', 32 | 'write_property_path' => 'costs', 33 | 'expected_exception' => PriceException::class, 34 | ]); 35 | } 36 | 37 | public function configureOptions(OptionsResolver $resolver) 38 | { 39 | $resolver->setDefault('factory', Product::class); 40 | 41 | // catches exceptions thrown in the constructor 42 | $resolver->setDefault('expected_exception', [ProductException::class, PriceException::class]); 43 | } 44 | } 45 | ``` 46 | 47 | Additionally, the bundle will catch all [TypeError instances](http://www.php.net/manual/en/class.typeerror.php) that are 48 | caused by passing invalid types when the submitted data is mapped to your model. 49 | -------------------------------------------------------------------------------- /docs/factory_value_object.md: -------------------------------------------------------------------------------- 1 | Initializing Objects 2 | ==================== 3 | 4 | When a form is not initialized with existing data (for example, because the object is to be created based on the user 5 | submitted data), the Form component, by default, will create a new empty instance by calling the constructor of the 6 | configured [data class](https://symfony.com/doc/current/reference/forms/types/form.html#data-class). 7 | 8 | This approach does not work for models that require some initial data to be passed to the constructor (for example, to 9 | ensure that the internal state of the object is always valid). 10 | 11 | The `factory` option can be used to tell the Form component to create the initial data object in a more sophisticated 12 | manner. The value passed here can be any of the following: 13 | 14 | * When given a string, it must refer to an existing class whose constructor will be called (and thus must be `public`). 15 | 16 | * When given an array, the value must be a valid [callable](http://www.php.net/manual/en/function.is-callable.php). 17 | 18 | * Finally, the value can be a closure. In this case, the form data is passed as arguments to this anonymous function 19 | allowing for full flexibility on how to create the initial object. 20 | 21 | The `immutable` and `data_class` options are mutually exclusive, meaning you cannot set them both at the same time. If 22 | you do so, an `InvalidConfigurationException` will be thrown. This can be confusing when you don't specify a 23 | `data_class` in your form type. The underlying `Symfony\Component\Form\Extension\Core\Type\FormType` class will attempt 24 | to automatically set `data_class` when you pass an existing object when creating the form. You can prevent any 25 | confusion or mistakes by always explicitly setting `data_class` to `null` when you use the `immutable` option as shown 26 | in the example below. 27 | 28 | Mapping Value Objects 29 | ===================== 30 | 31 | When working with value objects, you have to pass all data the value object consists of as a whole to be sure that it 32 | is always in a valid state and cannot be changed. This however means that every time a submitted form is mapped to your 33 | model, a new value object must be created instead of manipulating the existing data. 34 | 35 | Therefore, besides configuring how to create new instances (see the `factory` option above) you also need to tell the 36 | form that the underlying model is immutable using the option with the same name: 37 | 38 | ```php 39 | // ... 40 | 41 | class PriceType extends AbstractType 42 | { 43 | // ... 44 | 45 | public function configureOptions(OptionsResolver $resolver): void 46 | { 47 | $resolver->setDefault('data_class', null); 48 | $resolver->setDefault('factory', Price::class); 49 | $resolver->setDefault('immutable', true); 50 | } 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/mapping.md: -------------------------------------------------------------------------------- 1 | Differing Property Paths for Reading and Writing 2 | ================================================ 3 | 4 | When your model provides methods for reading and writing an attribute whose names can not be handled by the built-in 5 | `property_path` option, you can use `read_property_path` and `write_property_path` to separate read and write access. 6 | 7 | Given a model like this: 8 | 9 | ```php 10 | class Category 11 | { 12 | private $name; 13 | 14 | // ... 15 | 16 | public function getName(): string 17 | { 18 | return $this->name; 19 | } 20 | 21 | public function rename(string $name): void 22 | { 23 | $this->validateName($name); 24 | 25 | $this->name = $name; 26 | } 27 | ``` 28 | 29 | The corresponding form type can be configured this way: 30 | 31 | ```php 32 | // ... 33 | 34 | class CategoryType extends AbstractType 35 | { 36 | public function buildForm(FormBuilderInterface $builder, array $options) 37 | { 38 | $builder 39 | ->add('name', TextType::class, [ 40 | 'read_property_path' => 'getName', 41 | 'write_property_path' => 'rename', 42 | ]) 43 | ; 44 | } 45 | ``` 46 | 47 | Mapping Several Form Fields to a Single Method 48 | ---------------------------------------------- 49 | 50 | When a method that changes the state of your model requires more than one argument you need to pass the method name as 51 | is to the `write_property_path` option of all the form fields that should act as an argument: 52 | 53 | ```php 54 | class Order 55 | { 56 | private $shippingAddress; 57 | private $trackingNumber; 58 | 59 | public function ship(Address $address, string $trackingNumber): void 60 | { 61 | $this->shippingAddress = $address; 62 | $this->trackingNumber = $trackingNumber; 63 | } 64 | 65 | // ... 66 | } 67 | ``` 68 | 69 | In this example, the `ship()` method requires two arguments, the address to ship to and the tracking number of the 70 | parcel service. 71 | 72 | The corresponding form type can now look like this: 73 | 74 | ```php 75 | // ... 76 | 77 | class ShipOrderType extends AbstractType 78 | { 79 | public function buildForm(FormBuilderInterface $builder, array $options): void 80 | { 81 | $builder 82 | ->add('address', AddressType::class, [ 83 | 'read_property_path' => 'shippingAddress', 84 | 'write_property_path' => 'ship', 85 | ]) 86 | ->add('trackingNumber', TextType::class, [ 87 | 'read_property_path' => 'trackingNumber', 88 | 'write_property_path' => 'ship', 89 | ]) 90 | ; 91 | } 92 | 93 | // ... 94 | } 95 | ``` 96 | 97 | Reading Data Based on the Model's State 98 | --------------------------------------- 99 | 100 | If the method used to expose data from the model depends on its state, `read_property_path` can be a closure that will 101 | be executed when the model is mapped to the form: 102 | 103 | ```php 104 | // ... 105 | 106 | class CategoryType extends AbstractType 107 | { 108 | public function buildForm(FormBuilderInterface $builder, array $options) 109 | { 110 | $builder 111 | ->add('parent', ChoiceType::class, [ 112 | 'choices' => $options['categories'], 113 | 'read_property_path' => function (Category $category): ?Category { 114 | if ($category->hasParent()) { 115 | return $category->getParent(); 116 | } 117 | 118 | return null; 119 | }, 120 | ]) 121 | ; 122 | } 123 | ``` 124 | 125 | In the example above, this proves to be useful when `getParent()` throws an exception in case the category is a root 126 | category (i.e. it has no parent category). 127 | 128 | Mapping to the Model Depending on the Submitted Form Data 129 | --------------------------------------------------------- 130 | 131 | When your model expects different method calls depending on the state change induced by the submitted form data, the 132 | `write_property_path` option can be a closure. It will receive the underlying model as well as the data submitted by the 133 | user: 134 | 135 | ```php 136 | // ... 137 | 138 | public function buildForm(FormBuilderInterface $builder, array $options): void 139 | { 140 | $builder 141 | ->add('state', ChoiceType::class, [ 142 | 'choices' => [ 143 | 'active' => true, 144 | 'paused' => false, 145 | ], 146 | 'read_property_path' => 'isSuspended', 147 | 'write_property_path' => function (Subscription $subscription, $submittedData): void { 148 | if (true === $submittedData) { 149 | $subscription->reactivate(); 150 | } elseif (false === $submittedData) { 151 | $subscription->suspend(); 152 | } 153 | }, 154 | ]) 155 | ; 156 | } 157 | ``` 158 | 159 | Custom Property Mappers 160 | ----------------------- 161 | 162 | Alternatively, you can also implement the `PropertyMapperInterface` and fully customize the mapping to your needs: 163 | 164 | ```php 165 | // ... 166 | use Qossmic\RichModelForms\DataMapper\PropertyMapperInterface; 167 | 168 | public function buildForm(FormBuilderInterface $builder, array $options): void 169 | { 170 | $builder 171 | ->add('state', ChoiceType::class, [ 172 | 'choices' => [ 173 | 'active' => true, 174 | 'paused' => false, 175 | ], 176 | 'property_mapper' => new class() implements PropertyMapperInterface { 177 | public function readPropertyValue($data) 178 | { 179 | return $data->isSuspended(); 180 | } 181 | 182 | public function writePropertyValue($data, $value): void 183 | { 184 | if (true === $value) { 185 | $subscription->reactivate(); 186 | } elseif (false === $value) { 187 | $subscription->suspend(); 188 | } 189 | } 190 | }, 191 | ]) 192 | ; 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | inferPrivatePropertyTypeFromConstructor: true 6 | level: max 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /src/Qossmic/DataMapper/DataMapper.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\DataMapper; 17 | 18 | use Qossmic\RichModelForms\ExceptionHandling\FormExceptionHandler; 19 | use Symfony\Component\Form\DataMapperInterface; 20 | use Symfony\Component\Form\Exception\LogicException; 21 | use Symfony\Component\Form\Exception\UnexpectedTypeException; 22 | use Symfony\Component\Form\FormInterface; 23 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 24 | 25 | /** 26 | * @author Christian Flothmann 27 | */ 28 | final class DataMapper implements DataMapperInterface 29 | { 30 | private DataMapperInterface $dataMapper; 31 | private PropertyAccessorInterface $propertyAccessor; 32 | private FormExceptionHandler $formExceptionHandler; 33 | 34 | public function __construct(DataMapperInterface $dataMapper, PropertyAccessorInterface $propertyAccessor, FormExceptionHandler $formExceptionHandler) 35 | { 36 | $this->dataMapper = $dataMapper; 37 | $this->propertyAccessor = $propertyAccessor; 38 | $this->formExceptionHandler = $formExceptionHandler; 39 | } 40 | 41 | public function mapDataToForms(mixed $data, \Traversable $forms): void 42 | { 43 | $isDataEmpty = null === $data || [] === $data; 44 | 45 | if (!$isDataEmpty && !\is_array($data) && !\is_object($data)) { 46 | throw new UnexpectedTypeException($data, 'object, array or null'); 47 | } 48 | 49 | $formsToBeMapped = []; 50 | 51 | foreach ($forms as $form) { 52 | $readPropertyPath = null; 53 | $propertyMapper = null; 54 | 55 | if (!$this->isImmutable($form)) { 56 | $readPropertyPath = $form->getConfig()->getOption('read_property_path'); 57 | $propertyMapper = $form->getConfig()->getOption('property_mapper'); 58 | } 59 | 60 | if (!$isDataEmpty && $readPropertyPath instanceof \Closure && $form->getConfig()->getMapped()) { 61 | $form->setData($readPropertyPath($data)); 62 | } elseif (!$isDataEmpty && null !== $readPropertyPath && $form->getConfig()->getMapped()) { 63 | /* @phpstan-ignore-next-line */ 64 | $form->setData($this->propertyAccessor->getValue($data, $readPropertyPath)); 65 | } elseif (!$isDataEmpty && null !== $propertyMapper) { 66 | /* @phpstan-ignore-next-line */ 67 | $form->setData($propertyMapper->readPropertyValue($data)); 68 | } elseif (null !== $readPropertyPath) { 69 | $form->setData($form->getConfig()->getData()); 70 | } else { 71 | $formsToBeMapped[] = $form; 72 | } 73 | } 74 | 75 | $this->dataMapper->mapDataToForms($data, new \ArrayIterator($formsToBeMapped)); 76 | } 77 | 78 | public function mapFormsToData(\Traversable $forms, mixed &$data): void 79 | { 80 | if (null === $data) { 81 | return; 82 | } 83 | 84 | if (!\is_array($data) && !\is_object($data)) { 85 | throw new UnexpectedTypeException($data, 'object, array or null'); 86 | } 87 | 88 | $writePropertyPaths = []; 89 | 90 | foreach ($forms as $form) { 91 | $forwardToWrappedDataMapper = false; 92 | $config = $form->getConfig(); 93 | $readPropertyPath = null; 94 | $propertyMapper = null; 95 | 96 | if (!$this->isImmutable($form)) { 97 | $readPropertyPath = $form->getConfig()->getOption('read_property_path'); 98 | $propertyMapper = $form->getConfig()->getOption('property_mapper'); 99 | } 100 | 101 | $writePropertyPath = $config->getOption('write_property_path'); 102 | 103 | if ($readPropertyPath instanceof \Closure) { 104 | $previousValue = $readPropertyPath($data); 105 | } elseif (null !== $readPropertyPath) { 106 | /* @phpstan-ignore-next-line */ 107 | $previousValue = $this->propertyAccessor->getValue($data, $readPropertyPath); 108 | } elseif (null !== $propertyMapper) { 109 | /* @phpstan-ignore-next-line */ 110 | $previousValue = $propertyMapper->readPropertyValue($data); 111 | } else { 112 | $previousValue = null; 113 | } 114 | 115 | if (null === $writePropertyPath && null === $propertyMapper) { 116 | $forwardToWrappedDataMapper = true; 117 | } elseif (!$config->getMapped() || !$form->isSubmitted() || !$form->isSynchronized() || $form->isDisabled()) { 118 | // write-back is disabled if the form is not synchronized (transformation failed), 119 | // if the form was not submitted and if the form is disabled (modification not allowed) 120 | $forwardToWrappedDataMapper = true; 121 | } elseif (\is_object($data) && $config->getByReference() && $form->getData() === $previousValue && !$writePropertyPath instanceof \Closure) { 122 | $forwardToWrappedDataMapper = true; 123 | } 124 | 125 | try { 126 | if ($forwardToWrappedDataMapper) { 127 | $this->dataMapper->mapFormsToData(new \ArrayIterator([$form]), $data); 128 | } elseif ($writePropertyPath instanceof \Closure) { 129 | $writePropertyPath($data, $form->getData()); 130 | } elseif ($propertyMapper instanceof PropertyMapperInterface) { 131 | $propertyMapper->writePropertyValue($data, $form->getData()); 132 | } else { 133 | $writePropertyPaths[$writePropertyPath][] = $form; 134 | } 135 | } catch (\Throwable $e) { 136 | $this->formExceptionHandler->handleException($form, $data, $e); 137 | } 138 | } 139 | 140 | /** @var string $writePropertyPath */ 141 | foreach ($writePropertyPaths as $writePropertyPath => $forms) { 142 | try { 143 | if (1 === \count($forms)) { 144 | $this->propertyAccessor->setValue($data, $writePropertyPath, reset($forms)->getData()); 145 | } elseif (!\is_object($data)) { 146 | throw new LogicException(\sprintf('Mapping multiple forms to a single method requires the form data to be an object but is "%s".', \gettype($data))); 147 | } else { 148 | $formData = []; 149 | 150 | foreach ($forms as $form) { 151 | $formData[$form->getName()] = $form->getData(); 152 | } 153 | 154 | $method = new \ReflectionMethod($data::class, $writePropertyPath); 155 | $arguments = []; 156 | 157 | foreach ($method->getParameters() as $parameter) { 158 | $arguments[] = $formData[$parameter->getName()]; 159 | } 160 | 161 | $method->invokeArgs($data, $arguments); 162 | } 163 | } catch (\Throwable $e) { 164 | foreach ($forms as $form) { 165 | $this->formExceptionHandler->handleException($form, $data, $e); 166 | } 167 | } 168 | } 169 | } 170 | 171 | private function isImmutable(FormInterface $form): bool 172 | { 173 | do { 174 | if ($form->getConfig()->getOption('immutable')) { 175 | return true; 176 | } 177 | } while ($form = $form->getParent()); 178 | 179 | return false; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Qossmic/DataMapper/PropertyMapperInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\DataMapper; 17 | 18 | /** 19 | * @author Christian Flothmann 20 | */ 21 | interface PropertyMapperInterface 22 | { 23 | public function readPropertyValue(mixed $data): mixed; 24 | 25 | public function writePropertyValue(mixed $data, mixed $value): void; 26 | } 27 | -------------------------------------------------------------------------------- /src/Qossmic/DataTransformer/ValueObjectTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\DataTransformer; 17 | 18 | use Qossmic\RichModelForms\ExceptionHandling\ExceptionHandlerRegistry; 19 | use Qossmic\RichModelForms\ExceptionHandling\ExceptionToErrorMapperTrait; 20 | use Qossmic\RichModelForms\Instantiator\ViewDataInstantiator; 21 | use Symfony\Component\Form\ButtonBuilder; 22 | use Symfony\Component\Form\DataTransformerInterface; 23 | use Symfony\Component\Form\Exception\TransformationFailedException; 24 | use Symfony\Component\Form\FormBuilderInterface; 25 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 26 | 27 | /** 28 | * @author Christian Flothmann 29 | */ 30 | final class ValueObjectTransformer implements DataTransformerInterface 31 | { 32 | use ExceptionToErrorMapperTrait; 33 | 34 | private PropertyAccessorInterface $propertyAccessor; 35 | private FormBuilderInterface $form; 36 | 37 | public function __construct(ExceptionHandlerRegistry $exceptionHandlerRegistry, PropertyAccessorInterface $propertyAccessor, FormBuilderInterface $form) 38 | { 39 | $this->exceptionHandlerRegistry = $exceptionHandlerRegistry; 40 | $this->propertyAccessor = $propertyAccessor; 41 | $this->form = $form; 42 | } 43 | 44 | /** 45 | * @param object|null $value 46 | * 47 | * @return array|bool|int|string|null 48 | */ 49 | public function transform(mixed $value): array|bool|int|string|null 50 | { 51 | if (null === $value) { 52 | return null; 53 | } 54 | 55 | if ($this->form->getCompound()) { 56 | $viewData = []; 57 | 58 | /** @var string $name */ 59 | foreach ($this->form as $name => $child) { 60 | if ($child instanceof ButtonBuilder) { 61 | continue; 62 | } 63 | 64 | if (!$child->getOption('mapped')) { 65 | continue; 66 | } 67 | 68 | $viewData[$name] = $this->getPropertyValue($child, $value); 69 | } 70 | 71 | return $viewData; 72 | } 73 | 74 | return $this->getPropertyValue($this->form, $value); 75 | } 76 | 77 | public function reverseTransform(mixed $value): ?object 78 | { 79 | try { 80 | /* @phpstan-ignore-next-line */ 81 | return (new ViewDataInstantiator($this->form, $value))->instantiateObject(); 82 | } catch (\Throwable $e) { 83 | $error = $this->mapExceptionToError($this->form, $value, $e); 84 | 85 | if (null !== $error) { 86 | throw new TransformationFailedException(strtr($error->getMessageTemplate(), $error->getParameters()), 0, $e); 87 | } 88 | 89 | throw $e; 90 | } 91 | } 92 | 93 | private function getPropertyValue(FormBuilderInterface $form, object $object): bool|int|string|null 94 | { 95 | if (null !== $form->getPropertyPath()) { 96 | /* @phpstan-ignore-next-line */ 97 | return $this->propertyAccessor->getValue($object, $form->getPropertyPath()); 98 | } 99 | 100 | $readPropertyPath = $form->getFormConfig()->getOption('read_property_path') ?? $form->getName(); 101 | 102 | if ($readPropertyPath instanceof \Closure) { 103 | return $readPropertyPath($object); 104 | } 105 | 106 | /* @phpstan-ignore-next-line */ 107 | return $this->propertyAccessor->getValue($object, $readPropertyPath); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Qossmic/DependencyInjection/Compiler/RegisterExceptionHandlersPass.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\DependencyInjection\Compiler; 17 | 18 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 19 | use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; 20 | use Symfony\Component\DependencyInjection\ContainerBuilder; 21 | use Symfony\Component\DependencyInjection\TypedReference; 22 | 23 | /** 24 | * @author Christian Flothmann 25 | * 26 | * @internal 27 | */ 28 | final class RegisterExceptionHandlersPass implements CompilerPassInterface 29 | { 30 | public function process(ContainerBuilder $container): void 31 | { 32 | if (!$container->hasDefinition('qossmic.rich_model_forms.exception_handler.registry')) { 33 | return; 34 | } 35 | 36 | $exceptionHandlerRegistry = $container->getDefinition('qossmic.rich_model_forms.exception_handler.registry'); 37 | 38 | $exceptionHandlers = []; 39 | $strategies = []; 40 | 41 | foreach ($container->findTaggedServiceIds('qossmic.rich_model_forms.exception_handler') as $id => $tag) { 42 | /** @var class-string $class */ 43 | $class = $container->getParameterBag()->resolveValue($container->getDefinition($id)->getClass()); 44 | $exceptionHandlers[$id] = new TypedReference($id, $class); 45 | 46 | foreach ($tag as $attributes) { 47 | $strategies[$attributes['strategy']] = $id; 48 | } 49 | } 50 | 51 | $exceptionHandlersLocator = ServiceLocatorTagPass::register($container, $exceptionHandlers); 52 | $exceptionHandlerRegistry->setArgument('$container', $exceptionHandlersLocator); 53 | $exceptionHandlerRegistry->setArgument('$strategies', $strategies); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Qossmic/DependencyInjection/RichModelFormsExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\DependencyInjection; 17 | 18 | use Symfony\Component\Config\FileLocator; 19 | use Symfony\Component\DependencyInjection\ContainerBuilder; 20 | use Symfony\Component\DependencyInjection\Extension\Extension; 21 | use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; 22 | 23 | /** 24 | * @author Christian Flothmann 25 | * 26 | * @internal 27 | */ 28 | final class RichModelFormsExtension extends Extension 29 | { 30 | public function load(array $configs, ContainerBuilder $container): void 31 | { 32 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 33 | $loader->load('services.xml'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/ArgumentTypeMismatchExceptionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; 20 | 21 | /** 22 | * @author Christian Flothmann 23 | */ 24 | final class ArgumentTypeMismatchExceptionHandler implements ExceptionHandlerInterface 25 | { 26 | public function getError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error 27 | { 28 | if ($e instanceof \TypeError) { 29 | if (str_starts_with($e->getMessage(), 'Argument ') || str_contains($e->getMessage(), 'Argument #')) { 30 | // code for extracting the expected type borrowed from the error handling in the Symfony PropertyAccess component 31 | if (false !== $pos = strpos($e->getMessage(), 'must be of type ')) { 32 | $pos += 16; 33 | } elseif (false !== $pos = strpos($e->getMessage(), 'must be an instance of ')) { 34 | $pos += 23; 35 | } else { 36 | $pos = strpos($e->getMessage(), 'must implement interface ') + 25; 37 | } 38 | 39 | return new Error($e, 'This value should be of type {{ type }}.', [ 40 | '{{ type }}' => substr($e->getMessage(), $pos, strpos($e->getMessage(), ',', $pos) - $pos), 41 | ]); 42 | } 43 | 44 | if (str_starts_with($e->getMessage(), 'Cannot assign ') && false !== $pos = strpos($e->getMessage(), ' of type ')) { 45 | return new Error($e, 'This value should be of type {{ type }}.', [ 46 | '{{ type }}' => substr($e->getMessage(), $pos + 9), 47 | ]); 48 | } 49 | 50 | // we are not interested in type errors that are not related to argument type nor property type (PHP 7.4+) mismatches 51 | return null; 52 | } 53 | 54 | // type errors that are triggered when the property accessor performs the write-call are wrapped in an 55 | // InvalidArgumentException by the PropertyAccess component 56 | if ($e instanceof InvalidArgumentException) { 57 | return new Error($e, 'This value should be of type {{ type }}.', [ 58 | '{{ type }}' => substr($e->getMessage(), 27, strpos($e->getMessage(), '",') - 27), 59 | ]); 60 | } 61 | 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/ChainExceptionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | 20 | /** 21 | * Delegates execution to a list of handlers, stopping after the first handler that transformed the exception. 22 | * 23 | * The execution of the handler chain will be stopped as soon as one of the handlers returns a form error object. Thus, 24 | * you need to make sure to give specialized exception handlers a higher priority (i.e. place them before more generic 25 | * handlers in the list that you pass as the argument to the constructor) than any generic handler. This ensures that 26 | * specialized handlers do their job first before the chain falls back to eventually process the more generic exception 27 | * handlers. 28 | * 29 | * @author Christian Flothmann 30 | */ 31 | final class ChainExceptionHandler implements ExceptionHandlerInterface 32 | { 33 | /** @var ExceptionHandlerInterface[] */ 34 | private iterable $exceptionHandlers; 35 | 36 | /** 37 | * @param ExceptionHandlerInterface[] $exceptionHandlers 38 | */ 39 | public function __construct(iterable $exceptionHandlers) 40 | { 41 | $this->exceptionHandlers = $exceptionHandlers; 42 | } 43 | 44 | public function getError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error 45 | { 46 | foreach ($this->exceptionHandlers as $exceptionHandler) { 47 | if (null !== $error = $exceptionHandler->getError($formConfig, $data, $e)) { 48 | return $error; 49 | } 50 | } 51 | 52 | return null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/Error.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | /** 19 | * @author Christian Flothmann 20 | */ 21 | final class Error 22 | { 23 | private \Throwable $cause; 24 | private string $messageTemplate; 25 | /** @var array */ 26 | private array $parameters; 27 | 28 | /** 29 | * @param array $parameters 30 | */ 31 | public function __construct(\Throwable $cause, string $messageTemplate, array $parameters = []) 32 | { 33 | $this->cause = $cause; 34 | $this->messageTemplate = $messageTemplate; 35 | $this->parameters = $parameters; 36 | } 37 | 38 | public function getCause(): \Throwable 39 | { 40 | return $this->cause; 41 | } 42 | 43 | public function getMessageTemplate(): string 44 | { 45 | return $this->messageTemplate; 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function getParameters(): array 52 | { 53 | return $this->parameters; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/ExceptionHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | 20 | /** 21 | * Converts exceptions into form errors. 22 | * 23 | * An exception handler gets passed the exception (or catchable error) that occurred during mapping the form to some 24 | * data, the form and the underlying data. Its responsibility is to extract some useful information for the user out 25 | * of the passed arguments and based on it create a FormError instance that will be attached to the form by the data 26 | * mapping layer. 27 | * 28 | * An exception layer can abstain from converting an exception (e.g. because it is a specialized implementation for 29 | * particular exceptions) and let other handlers deal with it by returning null. 30 | * 31 | * @author Christian Flothmann 32 | */ 33 | interface ExceptionHandlerInterface 34 | { 35 | public function getError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error; 36 | } 37 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/ExceptionHandlerRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Psr\Container\ContainerInterface; 19 | 20 | /** 21 | * @author Christian Flothmann 22 | */ 23 | final class ExceptionHandlerRegistry 24 | { 25 | private ContainerInterface $container; 26 | /** @var array */ 27 | private array $strategies; 28 | 29 | /** 30 | * @param array $strategies 31 | */ 32 | public function __construct(ContainerInterface $container, array $strategies) 33 | { 34 | $this->container = $container; 35 | $this->strategies = $strategies; 36 | } 37 | 38 | public function has(string $strategy): bool 39 | { 40 | return isset($this->strategies[$strategy]); 41 | } 42 | 43 | public function get(string $strategy): ExceptionHandlerInterface 44 | { 45 | if (!isset($this->strategies[$strategy])) { 46 | throw new \InvalidArgumentException(\sprintf('The exception handling strategy "%s" is not registered (use one of ["%s"]).', $strategy, implode(', ', array_keys($this->strategies)))); 47 | } 48 | 49 | /* @phpstan-ignore-next-line */ 50 | return $this->container->get($this->strategies[$strategy]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/ExceptionToErrorMapperTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | 20 | /** 21 | * @author Christian Flothmann 22 | * 23 | * @internal 24 | */ 25 | trait ExceptionToErrorMapperTrait 26 | { 27 | private ExceptionHandlerRegistry $exceptionHandlerRegistry; 28 | 29 | private function mapExceptionToError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error 30 | { 31 | $exceptionHandlers = []; 32 | 33 | if (null !== $formConfig->getOption('handle_exception')) { 34 | /* @phpstan-ignore-next-line */ 35 | foreach ($formConfig->getOption('handle_exception') as $exceptionClass) { 36 | /* @phpstan-ignore-next-line */ 37 | $exceptionHandlers[] = new GenericExceptionHandler($exceptionClass); 38 | } 39 | 40 | $exceptionHandlers[] = $this->exceptionHandlerRegistry->get('type_error'); 41 | } else { 42 | /* @phpstan-ignore-next-line */ 43 | foreach ($formConfig->getOption('exception_handling_strategy') as $strategy) { 44 | /* @phpstan-ignore-next-line */ 45 | $exceptionHandlers[] = $this->exceptionHandlerRegistry->get($strategy); 46 | } 47 | } 48 | 49 | if (1 === \count($exceptionHandlers)) { 50 | $exceptionHandler = reset($exceptionHandlers); 51 | } else { 52 | $exceptionHandler = new ChainExceptionHandler($exceptionHandlers); 53 | } 54 | 55 | return $exceptionHandler->getError($formConfig, $data, $e); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/FallbackExceptionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | 20 | /** 21 | * Converts all exceptions into form errors. 22 | * 23 | * The main purpose of this handler is to catch all remaining exceptions to prevent "internal server error" responses. 24 | * To not leak any sensitive information the error message presented to the user is generic and likely not very helpful 25 | * to the users. Thus, this handler should only be used as a fallback in a chain of error handlers. 26 | * 27 | * @author Christian Flothmann 28 | */ 29 | final class FallbackExceptionHandler implements ExceptionHandlerInterface 30 | { 31 | public function getError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error 32 | { 33 | if (!$e instanceof \Exception) { 34 | return null; 35 | } 36 | 37 | $messageTemplate = $formConfig->getOption('invalid_message') ?? 'This value is not valid.'; 38 | $parameters = $formConfig->getOption('invalid_message_parameters') ?? []; 39 | 40 | /* @phpstan-ignore-next-line */ 41 | return new Error($e, $messageTemplate, $parameters); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/FormExceptionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormError; 19 | use Symfony\Component\Form\FormInterface; 20 | use Symfony\Contracts\Translation\TranslatorInterface; 21 | 22 | /** 23 | * @author Christian Flothmann 24 | */ 25 | final class FormExceptionHandler 26 | { 27 | use ExceptionToErrorMapperTrait; 28 | 29 | private ?TranslatorInterface $translator; 30 | private ?string $translationDomain; 31 | 32 | public function __construct(ExceptionHandlerRegistry $exceptionHandlerRegistry, ?TranslatorInterface $translator = null, ?string $translationDomain = null) 33 | { 34 | $this->exceptionHandlerRegistry = $exceptionHandlerRegistry; 35 | $this->translator = $translator; 36 | $this->translationDomain = $translationDomain; 37 | } 38 | 39 | public function handleException(FormInterface $form, mixed $data, \Throwable $e): void 40 | { 41 | if (null !== $error = $this->mapExceptionToError($form->getConfig(), $data, $e)) { 42 | if (null !== $this->translator) { 43 | $message = $this->translator->trans($error->getMessageTemplate(), $error->getParameters(), $this->translationDomain); 44 | } else { 45 | $message = strtr($error->getMessageTemplate(), $error->getParameters()); 46 | } 47 | 48 | $form->addError(new FormError($message, $error->getMessageTemplate(), $error->getParameters(), null, $error->getCause())); 49 | } else { 50 | throw $e; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Qossmic/ExceptionHandling/GenericExceptionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\ExceptionHandling; 17 | 18 | use Symfony\Component\Form\FormConfigInterface; 19 | 20 | /** 21 | * A generic exception handler that can transform arbitrary exceptions into form errors. 22 | * 23 | * Instances of this exception handler will transform exactly one type of exception (and all of its subtypes) into form 24 | * errors by extracting the exception's message and passing it to the new FormError instances. 25 | * 26 | * CAUTION: Since this listener reuses the exception messages when building form errors these messages will eventually be 27 | * presented to the end user. Therefore, it should only be used for custom domain exceptions for which developers are 28 | * absolutely sure not to leak any sensitive information. 29 | * 30 | * @author Christian Flothmann 31 | */ 32 | final class GenericExceptionHandler implements ExceptionHandlerInterface 33 | { 34 | /** @var class-string */ 35 | private string $handledExceptionClass; 36 | 37 | /** 38 | * @param class-string $handledExceptionClass 39 | */ 40 | public function __construct(string $handledExceptionClass) 41 | { 42 | $this->handledExceptionClass = $handledExceptionClass; 43 | } 44 | 45 | public function getError(FormConfigInterface $formConfig, mixed $data, \Throwable $e): ?Error 46 | { 47 | if (!$e instanceof $this->handledExceptionClass) { 48 | return null; 49 | } 50 | 51 | return new Error($e, $e->getMessage()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Qossmic/Extension/RichModelFormsTypeExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\Extension; 17 | 18 | use Qossmic\RichModelForms\DataMapper\DataMapper; 19 | use Qossmic\RichModelForms\DataMapper\PropertyMapperInterface; 20 | use Qossmic\RichModelForms\DataTransformer\ValueObjectTransformer; 21 | use Qossmic\RichModelForms\ExceptionHandling\ExceptionHandlerRegistry; 22 | use Qossmic\RichModelForms\ExceptionHandling\FormExceptionHandler; 23 | use Qossmic\RichModelForms\Instantiator\FormDataInstantiator; 24 | use Symfony\Component\Form\AbstractTypeExtension; 25 | use Symfony\Component\Form\Exception\InvalidConfigurationException; 26 | use Symfony\Component\Form\Extension\Core\Type\FormType; 27 | use Symfony\Component\Form\FormBuilderInterface; 28 | use Symfony\Component\Form\FormInterface; 29 | use Symfony\Component\OptionsResolver\Options; 30 | use Symfony\Component\OptionsResolver\OptionsResolver; 31 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 32 | 33 | /** 34 | * @author Christian Flothmann 35 | * 36 | * @internal 37 | */ 38 | final class RichModelFormsTypeExtension extends AbstractTypeExtension 39 | { 40 | private PropertyAccessorInterface $propertyAccessor; 41 | private ExceptionHandlerRegistry $exceptionHandlerRegistry; 42 | private FormExceptionHandler $formExceptionHandler; 43 | 44 | public function __construct(PropertyAccessorInterface $propertyAccessor, ExceptionHandlerRegistry $exceptionHandlerRegistry, FormExceptionHandler $formExceptionHandler) 45 | { 46 | $this->propertyAccessor = $propertyAccessor; 47 | $this->exceptionHandlerRegistry = $exceptionHandlerRegistry; 48 | $this->formExceptionHandler = $formExceptionHandler; 49 | } 50 | 51 | public function buildForm(FormBuilderInterface $builder, array $options): void 52 | { 53 | if (null !== $options['factory'] && ($options['immutable'] || !$builder->getCompound())) { 54 | $builder->addViewTransformer(new ValueObjectTransformer($this->exceptionHandlerRegistry, $this->propertyAccessor, $builder)); 55 | } 56 | 57 | if (null === $dataMapper = $builder->getDataMapper()) { 58 | return; 59 | } 60 | 61 | $builder->setDataMapper(new DataMapper($dataMapper, $this->propertyAccessor, $this->formExceptionHandler)); 62 | } 63 | 64 | public function configureOptions(OptionsResolver $resolver): void 65 | { 66 | $resolver->setDefault('read_property_path', null); 67 | $resolver->setAllowedTypes('read_property_path', ['string', 'null', \Closure::class]); 68 | 69 | $resolver->setDefault('write_property_path', null); 70 | $resolver->setAllowedTypes('write_property_path', ['string', 'null', \Closure::class]); 71 | 72 | $resolver->setDefault('property_mapper', null); 73 | $resolver->setAllowedTypes('property_mapper', [PropertyMapperInterface::class, 'null']); 74 | 75 | $resolver->setDefault('expected_exception', null); 76 | $resolver->setAllowedTypes('expected_exception', ['string', 'string[]', 'null']); 77 | $resolver->setNormalizer('expected_exception', function (Options $options, $value) { 78 | if (null !== $value) { 79 | @trigger_error('The "expected_exception" option is deprecated since RichModelFormsBundle 0.2 and will be removed in 03. Use the "handle_exception" option instead.', \E_USER_DEPRECATED); 80 | 81 | $value = (array) $value; 82 | } 83 | 84 | return $value; 85 | }); 86 | $resolver->setDefault('handle_exception', null); 87 | $resolver->setAllowedTypes('handle_exception', ['string', 'string[]', 'null']); 88 | $resolver->setNormalizer('handle_exception', function (Options $options, $value) { 89 | if (null !== $value && null !== $options['expected_exception']) { 90 | throw new InvalidConfigurationException('The "expected_exception" and "handle_exception" options cannot be used at the same time.'); 91 | } 92 | 93 | if (null === $value && null !== $options['expected_exception']) { 94 | return $options['expected_exception']; 95 | } 96 | 97 | if (null !== $value) { 98 | $value = (array) $value; 99 | } 100 | 101 | return $value; 102 | }); 103 | 104 | $resolver->setDefault('exception_handling_strategy', null); 105 | $resolver->setNormalizer('exception_handling_strategy', function (Options $options, $value) { 106 | if (null !== $value && null !== $options['expected_exception']) { 107 | throw new InvalidConfigurationException('The "expected_exception" and "exception_handling_strategy" options cannot be used at the same time.'); 108 | } 109 | 110 | if (null !== $value && null !== $options['handle_exception']) { 111 | throw new InvalidConfigurationException('The "handle_exception" and "exception_handling_strategy" options cannot be used at the same time.'); 112 | } 113 | 114 | if (null !== $options['handle_exception']) { 115 | return null; 116 | } 117 | 118 | if (null === $value) { 119 | $value = ['type_error', 'fallback']; 120 | } 121 | 122 | $value = (array) $value; 123 | 124 | foreach ($value as $strategy) { 125 | if (!$this->exceptionHandlerRegistry->has($strategy)) { 126 | throw new InvalidConfigurationException(\sprintf('The "%s" error handling strategy is not registered.', $strategy)); 127 | } 128 | } 129 | 130 | return $value; 131 | }); 132 | 133 | $resolver->setDefault('factory', null); 134 | $resolver->setAllowedTypes('factory', ['string', 'array', 'null', \Closure::class]); 135 | $resolver->setNormalizer('factory', function (Options $options, $value) { 136 | if (\is_string($value) && !class_exists($value)) { 137 | throw new InvalidConfigurationException(\sprintf('The configured value for the "factory" option is not a valid class name ("%s" given).', $value)); 138 | } 139 | 140 | if (\is_array($value) && !\is_callable($value)) { 141 | throw new InvalidConfigurationException('An array used for the "factory" option must be a valid callable.'); 142 | } 143 | 144 | return $value; 145 | }); 146 | $resolver->setDefault('factory_argument', null); 147 | $resolver->setAllowedTypes('factory_argument', ['null', 'string']); 148 | 149 | $resolver->setDefault('immutable', false); 150 | $resolver->setAllowedTypes('immutable', 'bool'); 151 | $resolver->setNormalizer('immutable', function (Options $options, $value) { 152 | if ($value && null === $options['factory']) { 153 | throw new InvalidConfigurationException('Immutable objects require a configured factory.'); 154 | } 155 | 156 | return $value; 157 | }); 158 | 159 | $resolver->setNormalizer('data_class', function (Options $options, $value) { 160 | if (null !== $value && $options['immutable']) { 161 | throw new InvalidConfigurationException('The "data_class" option cannot be used on immutable forms.'); 162 | } 163 | 164 | if (!$options['immutable'] && null !== $options['factory'] && \is_string($options['factory'])) { 165 | return $options['factory']; 166 | } 167 | 168 | return $value; 169 | }); 170 | 171 | $resolver->setNormalizer('empty_data', function (Options $options, $value) { 172 | if (null !== $options['factory']) { 173 | return function (FormInterface $form) use ($options) { 174 | if ($options['immutable']) { 175 | // the view data of value objects is represented by an array, a dedicated view transformer 176 | // will create the object representation during reverse transformation 177 | return []; 178 | } 179 | 180 | try { 181 | /* @phpstan-ignore-next-line */ 182 | return (new FormDataInstantiator($options['factory'], $form))->instantiateObject(); 183 | } catch (\Throwable $e) { 184 | $this->formExceptionHandler->handleException($form, $form->getData(), $e); 185 | } 186 | }; 187 | } 188 | 189 | return $value; 190 | }); 191 | } 192 | 193 | public static function getExtendedTypes(): iterable 194 | { 195 | return [FormType::class]; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Qossmic/Instantiator/FormDataInstantiator.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\Instantiator; 17 | 18 | use Symfony\Component\Form\FormInterface; 19 | 20 | /** 21 | * @author Christian Flothmann 22 | */ 23 | final class FormDataInstantiator extends ObjectInstantiator 24 | { 25 | private FormInterface $form; 26 | /** @var array */ 27 | private array $formNameForArgument; 28 | 29 | /** 30 | * @param class-string|\Closure|((callable(): object)&array{0: class-string|object, 1: string}) $factory 31 | */ 32 | public function __construct(string|callable $factory, FormInterface $form) 33 | { 34 | parent::__construct($factory); 35 | 36 | $this->form = $form; 37 | $this->formNameForArgument = []; 38 | 39 | foreach ($form as $child) { 40 | /* @phpstan-ignore-next-line */ 41 | $this->formNameForArgument[$child->getConfig()->getOption('factory_argument') ?? $child->getName()] = $child->getName(); 42 | } 43 | } 44 | 45 | protected function isCompoundForm(): bool 46 | { 47 | return $this->form->getConfig()->getCompound(); 48 | } 49 | 50 | protected function getData(): mixed 51 | { 52 | if ($this->isCompoundForm()) { 53 | $data = []; 54 | 55 | foreach ($this->form as $childForm) { 56 | $data[$childForm->getConfig()->getName()] = $childForm->getData(); 57 | } 58 | 59 | return $data; 60 | } 61 | 62 | return $this->form->getData(); 63 | } 64 | 65 | protected function getArgumentData(string $argument): mixed 66 | { 67 | return $this->form->get($this->formNameForArgument[$argument])->getData(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Qossmic/Instantiator/ObjectInstantiator.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\Instantiator; 17 | 18 | use Symfony\Component\Form\Exception\TransformationFailedException; 19 | 20 | /** 21 | * @author Christian Flothmann 22 | */ 23 | abstract class ObjectInstantiator 24 | { 25 | /** @var class-string|(\Closure(): object)|((callable(): object)&array{0: class-string|object, 1: string}) */ 26 | private mixed $factory; 27 | 28 | /** 29 | * @param class-string|(\Closure(): object)|((callable(): object)&array{0: class-string|object, 1: string}) $factory 30 | */ 31 | public function __construct(mixed $factory) 32 | { 33 | $this->factory = $factory; 34 | } 35 | 36 | public function instantiateObject(): ?object 37 | { 38 | if (\is_string($this->factory) && class_exists($this->factory)) { 39 | $factoryMethod = (new \ReflectionClass($this->factory))->getConstructor(); 40 | 41 | if (null === $factoryMethod) { 42 | throw new TransformationFailedException(\sprintf('The class "%s" used as a factory does not have a constructor.', $this->factory)); 43 | } 44 | 45 | $factoryMethodAsString = $this->factory.'::__construct'; 46 | if (!$factoryMethod->isPublic()) { 47 | throw new TransformationFailedException(\sprintf('The factory method %s() is not public.', $factoryMethodAsString)); 48 | } 49 | } elseif (\is_array($this->factory)) { 50 | /** @var class-string $class */ 51 | $class = \is_object($this->factory[0]) ? \get_class($this->factory[0]) : $this->factory[0]; 52 | $factoryMethod = (new \ReflectionMethod($class, $this->factory[1])); 53 | $factoryMethodAsString = $class.'::'.$this->factory[1]; 54 | if (!$factoryMethod->isPublic()) { 55 | throw new TransformationFailedException(\sprintf('The factory method %s() is not public.', $factoryMethodAsString)); 56 | } 57 | } elseif ($this->factory instanceof \Closure) { 58 | $factoryMethod = new \ReflectionFunction($this->factory); 59 | } else { 60 | /* @phpstan-ignore-next-line */ 61 | return $this->getData(); 62 | } 63 | 64 | $arguments = []; 65 | 66 | if ($this->isCompoundForm()) { 67 | foreach ($factoryMethod->getParameters() as $parameter) { 68 | $arguments[] = $this->getArgumentData($parameter->getName()); 69 | } 70 | } else { 71 | $arguments[] = $this->getData(); 72 | } 73 | 74 | if (\is_string($this->factory)) { 75 | return new $this->factory(...$arguments); 76 | } 77 | 78 | /* @phpstan-ignore-next-line */ 79 | return ($this->factory)(...$arguments); 80 | } 81 | 82 | abstract protected function isCompoundForm(): bool; 83 | 84 | abstract protected function getData(): mixed; 85 | 86 | abstract protected function getArgumentData(string $argument): mixed; 87 | } 88 | -------------------------------------------------------------------------------- /src/Qossmic/Instantiator/ViewDataInstantiator.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms\Instantiator; 17 | 18 | use Symfony\Component\Form\FormBuilderInterface; 19 | 20 | /** 21 | * @author Christian Flothmann 22 | */ 23 | final class ViewDataInstantiator extends ObjectInstantiator 24 | { 25 | private FormBuilderInterface $form; 26 | /** @var array|bool|int|string */ 27 | private array|bool|int|string $viewData; 28 | /** @var array */ 29 | private array $formNameForArgument; 30 | 31 | /** 32 | * @param array|bool|int|string $viewData 33 | */ 34 | public function __construct(FormBuilderInterface $form, array|bool|int|string $viewData) 35 | { 36 | /* @phpstan-ignore-next-line */ 37 | parent::__construct($form->getFormConfig()->getOption('factory')); 38 | 39 | $this->form = $form; 40 | $this->viewData = $viewData; 41 | $this->formNameForArgument = []; 42 | 43 | foreach ($form as $child) { 44 | /* @phpstan-ignore-next-line */ 45 | $this->formNameForArgument[$child->getOption('factory_argument') ?? $child->getName()] = $child->getName(); 46 | } 47 | } 48 | 49 | protected function isCompoundForm(): bool 50 | { 51 | return $this->form->getFormConfig()->getCompound(); 52 | } 53 | 54 | /** 55 | * @return array|bool|int|string 56 | */ 57 | protected function getData(): array|bool|int|string 58 | { 59 | return $this->viewData; 60 | } 61 | 62 | protected function getArgumentData(string $argument): mixed 63 | { 64 | if (!\is_array($this->viewData)) { 65 | return null; 66 | } 67 | 68 | return $this->viewData[$this->formNameForArgument[$argument]] ?? null; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Qossmic/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | %validator.translation_domain% 20 | 21 | 22 | 23 | null 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Qossmic/RichModelFormsBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Christopher Hertel 8 | * (c) QOSSMIC GmbH 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | declare(strict_types = 1); 15 | 16 | namespace Qossmic\RichModelForms; 17 | 18 | use Qossmic\RichModelForms\DependencyInjection\Compiler\RegisterExceptionHandlersPass; 19 | use Symfony\Component\DependencyInjection\ContainerBuilder; 20 | use Symfony\Component\HttpKernel\Bundle\Bundle; 21 | 22 | /** 23 | * @author Christian Flothmann 24 | */ 25 | class RichModelFormsBundle extends Bundle 26 | { 27 | public function build(ContainerBuilder $container): void 28 | { 29 | $container->addCompilerPass(new RegisterExceptionHandlersPass()); 30 | } 31 | } 32 | --------------------------------------------------------------------------------