├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Constraints ├── OptionalLength6.php ├── OptionalLength6Validator.php ├── OptionalLength7.php └── OptionalLength7Validator.php ├── Datalist.php ├── Factory.php ├── Form.php ├── Groups ├── Group.php ├── GroupCollection.php ├── InputGroup.php ├── MultipleGroupCollection.php ├── RadioGroup.php └── SubmitGroup.php ├── InputInterface.php ├── Inputs ├── Checkbox.php ├── Color.php ├── Date.php ├── DatetimeLocal.php ├── Email.php ├── File.php ├── Hidden.php ├── Input.php ├── Month.php ├── Number.php ├── Password.php ├── Radio.php ├── Range.php ├── Search.php ├── Select.php ├── Submit.php ├── Tel.php ├── Text.php ├── Textarea.php ├── Time.php ├── Url.php └── Week.php ├── Node.php ├── NodeInterface.php ├── Traits └── HasOptionsTrait.php ├── ValidationError.php ├── ValidatorFactory.php └── Validators ├── AcceptFile.php ├── Step.php └── UploadedFile.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [7.0.0] - 2025-04-26 9 | ### Added 10 | - Support for PHP 8.4. 11 | - Support for `symfony/validator` v6 (LTS) and v7. 12 | - Support for custom validator factory `Factory::setValidator()`. 13 | - This allows to use `symfony/translations` for example to have translations. 14 | - See (examples/translations.php)[examples/translations.php]. 15 | - Support for custom global error messages: 16 | ```php 17 | F::setErrorMessages([ 18 | 'required' => 'The field is required', 19 | 'email' => 'The email is invalid', 20 | ]); 21 | ``` 22 | - Support for required radio group: 23 | ```php 24 | $colors = F::radioGroup([ 25 | 'red' => F::radio('Red', ['required' => true]), 26 | 'blue' => 'Blue', 27 | 'green' => 'Green', 28 | ]); 29 | ``` 30 | 31 | ## Changed 32 | - Minimum requirement is `php >= 7.2` 33 | - Set minimum requirement of `symfony/validator` to v5.4 34 | - no active support anymore 35 | - [security fixes until 2028 though!](https://endoflife.date/symfony) 36 | - the pinned version to 5.4 is to avoid outdated and buggy versions that we cannot rely on. 37 | 38 | 39 | ## [6.1.2] - 2021-07-19 40 | ### Added 41 | - Added support for PHP 8 42 | 43 | ## [6.1.1] - 2021-01-13 44 | ### Fixed 45 | - Inputs with `multiple` attribute needs to force an array by appending a `[]` to the name [#89] 46 | 47 | ## [6.1.0] - 2020-11-14 48 | ### Added 49 | - Some phpdoc annotations to help IDEs [#81] 50 | - Elements that can contain options (`Select`, `Datalist`) have the `setOptgroups` method to define the optgroups. 51 | - The method `setOptions` can assign other attributes to the options [#83] 52 | ```php 53 | $select->setOptions([ 54 | 'value1' => [ 55 | 'label' => 'Value label', 56 | 'disabled' => true 57 | ] 58 | ]) 59 | ``` 60 | 61 | ### Removed 62 | - BREAKING: Ability to add optgroups with the `setOptions` method of select and datalist elements. Use `setOptgroups` instead 63 | 64 | ### Fixed 65 | - Validate step attribute with decimal values [#87] 66 | 67 | ## [6.0.1] - 2019-03-24 68 | ### Fixed 69 | - Error on throw `InvalidArgumentException` inside other namespace [#79] 70 | - Added php7.3 to travis 71 | 72 | ## 6.0.0 - 2018-12-24 73 | This library was rewritten and a lot of breaking changes were included. 74 | 75 | ### Added 76 | - This changelog 77 | 78 | ### Changed 79 | - Minimum requirement is `php >= 7.1` 80 | - Use of `symfony/validator` to validate the values 81 | - Removed a lot of logic and html features. Focus only in inputs and data structure. 82 | - Better error messages and easy to customize and translate 83 | - Replaced jQuery inspired API for a DOM inspired API. For example, use `$input->getAttribute()` and `$input->setAttribute()` instead `$input->attr()`. 84 | - Removed magic methods to add attributes in benefit of magic properties. For example, instead `$input->required()`, use `$input->required = true` or `$input->setAttribute('required', true)`. 85 | - Added the ability of define label and properties in the input constructors. For example: `F::text('Write your name', ['required'])` instead `F::text()->label('Write your name')->required()` 86 | 87 | [#79]: https://github.com/oscarotero/form-manager/issues/79 88 | [#81]: https://github.com/oscarotero/form-manager/issues/81 89 | [#83]: https://github.com/oscarotero/form-manager/issues/83 90 | [#87]: https://github.com/oscarotero/form-manager/issues/87 91 | [#89]: https://github.com/oscarotero/form-manager/issues/89 92 | 93 | [7.0.0]: https://github.com/oscarotero/form-manager/compare/v6.1.2...v7.0.0 94 | [6.1.2]: https://github.com/oscarotero/form-manager/compare/v6.1.1...v6.1.2 95 | [6.1.1]: https://github.com/oscarotero/form-manager/compare/v6.1.0...v6.1.1 96 | [6.1.0]: https://github.com/oscarotero/form-manager/compare/v6.0.1...v6.1.0 97 | [6.0.1]: https://github.com/oscarotero/form-manager/compare/v6.0.0...v6.0.1 98 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | This project adheres to [The Code Manifesto](http://codemanifesto.com) as its guidelines for contributor interactions. 4 | 5 | ## The Code Manifesto 6 | 7 | We want to work in an ecosystem that empowers developers to reach their potential--one that encourages growth and effective collaboration. A space that is safe for all. 8 | 9 | A space such as this benefits everyone that participates in it. It encourages new developers to enter our field. It is through discussion and collaboration that we grow, and through growth that we improve. 10 | 11 | In the effort to create such a place, we hold to these values: 12 | 13 | 1. **Discrimination limits us.** This includes discrimination on the basis of race, gender, sexual orientation, gender identity, age, nationality, technology and any other arbitrary exclusion of a group of people. 14 | 2. **Boundaries honor us.** Your comfort levels are not everyone’s comfort levels. Remember that, and if brought to your attention, heed it. 15 | 3. **We are our biggest assets.** None of us were born masters of our trade. Each of us has been helped along the way. Return that favor, when and where you can. 16 | 4. **We are resources for the future.** As an extension of #3, share what you know. Make yourself a resource to help those that come after you. 17 | 5. **Respect defines us.** Treat others as you wish to be treated. Make your discussions, criticisms and debates from a position of respectfulness. Ask yourself, is it true? Is it necessary? Is it constructive? Anything less is unacceptable. 18 | 6. **Reactions require grace.** Angry responses are valid, but abusive language and vindictive actions are toxic. When something happens that offends you, handle it assertively, but be respectful. Escalate reasonably, and try to allow the offender an opportunity to explain themselves, and possibly correct the issue. 19 | 7. **Opinions are just that: opinions.** Each and every one of us, due to our background and upbringing, have varying opinions. That is perfectly acceptable. Remember this: if you respect your own opinions, you should respect the opinions of others. 20 | 8. **To err is human.** You might not intend it, but mistakes do happen and contribute to build experience. Tolerate honest mistakes, and don't hesitate to apologize if you make one yourself. 21 | 22 | ## How to contribute 23 | 24 | This is a collaborative effort. We welcome all contributions submitted as pull requests. 25 | 26 | (Contributions on wording & style are also welcome.) 27 | 28 | ### Bugs 29 | 30 | A bug is a demonstrable problem that is caused by the code in the repository. Good bug reports are extremely helpful – thank you! 31 | 32 | Please try to be as detailed as possible in your report. Include specific information about the environment – version of PHP, etc, and steps required to reproduce the issue. 33 | 34 | ### Pull Requests 35 | 36 | Good pull requests – patches, improvements, new features – are a fantastic help. Before create a pull request, please follow these instructions: 37 | 38 | * The code must follow the [PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). Run `composer cs-fix` to fix your code before commit. 39 | * Write tests 40 | * Document any change in `README.md` and `CHANGELOG.md` 41 | * One pull request per feature. If you want to do more than one thing, send multiple pull request 42 | 43 | ### Runing tests 44 | 45 | ```sh 46 | composer test 47 | ``` 48 | 49 | To get code coverage information execute the following comand: 50 | 51 | ```sh 52 | composer coverage 53 | ``` 54 | 55 | Then, open the `./coverage/index.html` file in your browser. 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2025 Oscar Otero Marzoa, Filis Futsarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form Manager 2 | 3 | [ico-version]: https://img.shields.io/packagist/v/form-manager/form-manager.svg?style=flat-square 4 | [ico-ga]: https://github.com/oscarotero/form-manager/workflows/testing/badge.svg 5 | [ico-downloads]: https://img.shields.io/packagist/dt/form-manager/form-manager.svg?style=flat-square 6 | 7 | [link-packagist]: https://packagist.org/packages/form-manager/form-manager 8 | [link-downloads]: https://packagist.org/packages/form-manager/form-manager 9 | 10 | [![Latest Version on Packagist][ico-version]][link-packagist] 11 | [![Software License][ico-license]](LICENSE) 12 | ![Testing][ico-ga] 13 | [![Total Downloads][ico-downloads]][link-downloads] 14 | 15 | > ### Note: this is the documentation of FormManager 7.x 16 | > For v6.x version [Click here](https://github.com/oscarotero/form-manager/tree/v6) 17 | 18 | ## Installation: 19 | 20 | This package requires `PHP>=7.2` and is available on [Packagist](https://packagist.org/packages/form-manager/form-manager): 21 | 22 | Supports `symfony/validator` v5, v6 and v7. 23 | 24 | ``` 25 | composer require form-manager/form-manager 26 | ``` 27 | 28 | ## Create a field 29 | 30 | FormManager is namespaced, but you only need to import a single class into your context: 31 | 32 | ```php 33 | use FormManager\Factory as F; 34 | ``` 35 | 36 | Use the imported factory to create all form elements: 37 | 38 | ```php 39 | // Create an input type="text" element 40 | $name = F::text(); 41 | 42 | // Create the input with a label 43 | $name = F::text('Please, introduce your name'); 44 | 45 | // Or with extra attributes 46 | $name = F::text('Please, introduce your name', ['class' => 'name-field']); 47 | 48 | // Add or remove attributes 49 | $name->setAttribute('title', 'This is the name input'); 50 | $name->removeAttribute('class'); 51 | $name->setAttributes([ 52 | 'required', 53 | 'readonly', 54 | 'tabindex' => 2, 55 | 'maxlength' => 50 56 | ]); 57 | 58 | // Set the value 59 | $name->setValue('MyName'); 60 | 61 | // Use magic properties to get/set/remove attributes 62 | $name->class = 'name-field'; 63 | $name->required = false; 64 | unset($name->readonly); 65 | ``` 66 | 67 | ### List of all available inputs: 68 | 69 | All HTML5 field types are supported: 70 | 71 | * `F::checkbox($label, $attributes)` 72 | * `F::color($label, $attributes)` 73 | * `F::date($label, $attributes)` 74 | * `F::datetimeLocal($label, $attributes)` 75 | * `F::email($label, $attributes)` 76 | * `F::file($label, $attributes)` 77 | * `F::hidden($value, $attributes)` 78 | * `F::month($label, $attributes)` 79 | * `F::number($label, $attributes)` 80 | * `F::password($label, $attributes)` 81 | * `F::radio($label, $attributes)` 82 | * `F::range($label, $attributes)` 83 | * `F::search($label, $attributes)` 84 | * `F::select($label, $options, $attributes)` 85 | * `F::submit($label, $attributes)` 86 | * `F::tel($label, $attributes)` 87 | * `F::text($label, $attributes)` 88 | * `F::textarea($label, $attributes)` 89 | * `F::time($label, $attributes)` 90 | * `F::url($label, $attributes)` 91 | * `F::week($label, $attributes)` 92 | 93 | > Note that all inputs accepts the same arguments except `hidden` and `select`. 94 | 95 | ## Validation 96 | 97 | This library uses internally [symfony/validation](https://symfony.com/doc/current/validation.html) to perform basic html5 validations and error reporting. HTML5 validation attributes like `required`, `maxlength`, `minlength`, `pattern`, etc are supported, in addition to intrinsic validations assigned to each input like email, url, date, etc. 98 | 99 | ```php 100 | // Set global default error messages 101 | F::setErrorMessages([ 102 | 'required' => 'The field is required' 103 | 'maxlength' => 'The field is too long, it must have {{ limit }} characters or less', 104 | ]); 105 | 106 | $email = F::email(); 107 | 108 | // Set per-fied error messages 109 | $email->setErrorMessages([ 110 | 'email' => 'The email is not valid', 111 | 'required' => 'The email is required', 112 | 'maxlength' => 'The email is too long, it must have {{ limit }} characters or less', 113 | ]); 114 | 115 | $email->setValue('invalid-email'); 116 | 117 | // Validate the value 118 | if ($email->isValid()) { 119 | return true; 120 | } 121 | 122 | // Get the errors 123 | $error = $email->getError(); 124 | 125 | // Print the first error message 126 | echo $error; 127 | 128 | // Iterate through all error messages 129 | foreach ($error as $err) { 130 | echo $err->getMessage(); 131 | } 132 | 133 | // And add more symfony constraints 134 | $ip = F::text(); 135 | $ip->addConstraint(new Constraints\Ip()); 136 | ``` 137 | 138 | See [all supported constraints by symfony/validation](https://symfony.com/doc/current/validation.html#supported-constraints). 139 | 140 | ## Translations 141 | 142 | This package allows you to set your custom Validation instance with `Factory::setValidator()`. 143 | 144 | This allows you to use [symfony/translations](https://symfony.com/doc/current/translation.html) in order to have translations in place. 145 | 146 | ```shell 147 | composer require symfony/translations 148 | ``` 149 | 150 | ```php 151 | $validator = Validation::createValidatorBuilder() 152 | ->setTranslator($translator) 153 | ->setTranslationDomain('validators') 154 | ->getValidator(); 155 | 156 | // Set validator 157 | F::setValidator($validator); 158 | ``` 159 | 160 | See [examples/translations.php](examples/translations.php) to see the full example. 161 | 162 | ## Render html 163 | 164 | ```php 165 | $name = F::text('What is your name?', ['name' => 'name']); 166 | 167 | echo $name; 168 | ``` 169 | ```html 170 | 171 | ``` 172 | 173 | Set a custom template using `{{ label }}` and `{{ input }}` placeholders: 174 | 175 | ```php 176 | $name->setTemplate('{{ label }}
{{ input }}
'); 177 | echo $name; 178 | ``` 179 | ```html 180 |
181 | ``` 182 | 183 | If you want to wrap the previous template in a custom html, use the `{{ template }}` placeholder: 184 | 185 | ```php 186 | $name->setTemplate('
{{ template }}
'); 187 | echo $name; 188 | ``` 189 | ```html 190 |
191 | ``` 192 | 193 | ## Grouping fields 194 | 195 | Group the fields to follow a specific data structure: 196 | 197 | ### Group 198 | 199 | Groups allow to place a set of inputs under an specific name: 200 | 201 | ```php 202 | $group = F::group([ 203 | 'name' => F::text('Username'), 204 | 'email' => F::email('Email'), 205 | 'password' => F::password('Password'), 206 | ]); 207 | 208 | $group->setValue([ 209 | 'name' => 'oscar', 210 | 'email' => 'oom@oscarotero.com', 211 | 'password' => 'supersecret', 212 | ]); 213 | ``` 214 | 215 | ### Radio group 216 | 217 | Special case for radios where all inputs share the same name with different values: 218 | 219 | ```php 220 | $radios = F::radioGroup([ 221 | 'red' => 'Red', 222 | 'blue' => 'Blue', 223 | 'green' => 'Green', 224 | ]); 225 | 226 | $radios->setValue('blue'); 227 | ``` 228 | 229 | If you need the radio group to be required, you should add the `required` attribute to at least one radio button: 230 | 231 | ```php 232 | $radios = F::radioGroup([ 233 | 'red' => F::radio('Red', ['required' => true]), 234 | 'blue' => 'Blue', 235 | 'green' => 'Green', 236 | ]); 237 | ``` 238 | 239 | ### Submit group 240 | 241 | Special case to group several submit buttons under the same name but different values: 242 | 243 | ```php 244 | $buttons = F::submitGroup([ 245 | 'save' => 'Save the row', 246 | 'duplicate' => 'Save as new row', 247 | ]); 248 | 249 | $buttons->setName('action'); 250 | ``` 251 | 252 | ### Group collection 253 | 254 | Is a collection of values using the same group: 255 | 256 | ```php 257 | $groupCollection = F::groupCollection( 258 | f::group([ 259 | 'name' => F::text('Name'), 260 | 'genre' => F::radioGroup([ 261 | 'm' => 'Male', 262 | 'f' => 'Female', 263 | 'o' => 'Other', 264 | ]), 265 | ]) 266 | ]); 267 | 268 | $groupCollection->setValue([ 269 | [ 270 | 'name' => 'Oscar', 271 | 'genre' => 'm' 272 | ],[ 273 | 'name' => 'Laura', 274 | 'genre' => 'f' 275 | ], 276 | ]) 277 | ``` 278 | 279 | ### Multiple group collection 280 | 281 | Is a collection of values using various groups, using the field `type` to identify which group is used by each row: 282 | 283 | ```php 284 | $multipleGroupCollection = F::multipleGroupCollection( 285 | 'text' => f::group([ 286 | 'type' => F::hidden(), 287 | 'title' => F::text('Title'), 288 | 'text' => F::textarea('Body'), 289 | ]), 290 | 'image' => f::group([ 291 | 'type' => F::hidden(), 292 | 'file' => F::file('Image file'), 293 | 'alt' => F::text('Alt text'), 294 | 'text' => F::textarea('Caption'), 295 | ]), 296 | 'link' => f::group([ 297 | 'type' => F::hidden(), 298 | 'text' => F::text('Link text'), 299 | 'href' => F::url('Url'), 300 | 'target' => F::select([ 301 | '_blank' => 'New window', 302 | '_self' => 'The same window', 303 | ]), 304 | ]), 305 | ]); 306 | 307 | $multipleGroupCollection->setValue([ 308 | [ 309 | 'type' => 'text', 310 | 'title' => 'Welcome to my page', 311 | 'text' => 'I hope you like it', 312 | ],[ 313 | 'type' => 'image', 314 | 'file' => 'avatar.jpg', 315 | 'alt' => 'Image of mine', 316 | 'text' => 'This is my photo', 317 | ],[ 318 | 'type' => 'link', 319 | 'text' => 'Go to my webpage', 320 | 'href' => 'https://oscarotero.com', 321 | 'target' => '_self', 322 | ], 323 | ]); 324 | ``` 325 | 326 | ## Datalist 327 | 328 | [Datalists](http://www.w3.org/TR/html5/forms.html#the-datalist-element) are also allowed, just use the `createDatalist()` method: 329 | 330 | ```php 331 | $input = F::search(); 332 | 333 | $datalist = $input->createDatalist([ 334 | 'female' => 'Female', 335 | 'male' => 'Male' 336 | ]); 337 | 338 | echo $input; 339 | echo $datalist; 340 | ``` 341 | 342 | ## Forms 343 | 344 | We need a form to put all this things together. 345 | 346 | ```php 347 | $loginForm = F::form([ 348 | 'username' => F::text('User name'), 349 | 'password' => F::password('Password'), 350 | '' => F::submit('Login'), 351 | ]); 352 | 353 | $loginForm->setAttributes([ 354 | 'action' => 'login.php', 355 | 'method' => 'post', 356 | ]); 357 | 358 | // mLoad data from globals $_GET, $_POST, $_FILES 359 | $loginForm->loadFromGlobals(); 360 | 361 | // Load data passing the arrays 362 | $loginForm->loadFromArrays($_GET, $_POST, $_FILES); 363 | 364 | // Or load from PSR-7 server request 365 | $loginForm->loadFromServerRequest($serverRequest); 366 | 367 | // Get loaded data 368 | $data = $loginForm->getValue(); 369 | 370 | // Print the form 371 | echo $loginForm; 372 | 373 | // Access to specific inputs: 374 | echo $loginForm->getOpeningTag(); 375 | echo '

Login:

'; 376 | 377 | echo $loginForm['username']; 378 | echo '
'; 379 | echo $loginForm['password']; 380 | echo '
'; 381 | echo $loginForm['']; 382 | echo $loginForm->getClosingTag(); 383 | 384 | echo $loginForm->getOpeningTag(); 385 | echo '

Login:

'; 386 | 387 | //nIterate through all inputs 388 | foreach ($loginForm as $input) { 389 | echo "
{$input}
"; 390 | } 391 | echo $loginForm->getClosingTag(); 392 | ``` 393 | 394 | --- 395 | 396 | Please see [CHANGELOG](CHANGELOG.md) for more information about recent changes and [CONTRIBUTING](CONTRIBUTING.md) for contributing details. 397 | 398 | The MIT License (MIT). Please see [LICENSE](LICENSE) for more information. 399 | 400 | [ico-version]: https://img.shields.io/packagist/v/oscarotero/form-manager.svg?style=flat-square 401 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 402 | [ico-ga]: https://github.com/oscarotero/form-manager/workflows/testing/badge.svg 403 | [ico-downloads]: https://img.shields.io/packagist/dt/oscarotero/form-manager.svg?style=flat-square 404 | 405 | [link-packagist]: https://packagist.org/packages/oscarotero/form-manager 406 | [link-downloads]: https://packagist.org/packages/oscarotero/form-manager 407 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "form-manager/form-manager", 3 | "type": "library", 4 | "description": "PHP-HTML form manager", 5 | "keywords": ["form", "html", "data", "validator"], 6 | "homepage": "https://github.com/oscarotero/form-manager", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Oscar Otero", 11 | "email": "oom@oscarotero.com", 12 | "homepage": "http://oscarotero.com", 13 | "role": "Developer" 14 | }, 15 | { 16 | "name": "Filis Futsarov", 17 | "email": "filisfutsarov@gmail.com", 18 | "homepage": "https://filis.me", 19 | "role": "Developer" 20 | } 21 | ], 22 | "support": { 23 | "email": "oom@oscarotero.com", 24 | "issues": "https://github.com/oscarotero/form-manager/issues" 25 | }, 26 | "require": { 27 | "php": ">=7.2", 28 | "symfony/validator": "^5.4 || ^6.4 || ^7.2" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", 32 | "nyholm/psr7": "^1.0", 33 | "squizlabs/php_codesniffer": "^3", 34 | "friendsofphp/php-cs-fixer": "^3", 35 | "oscarotero/php-cs-fixer-config": "^1 || ^2", 36 | "phpstan/phpstan": "^1 || ^2" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "FormManager\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "FormManager\\Tests\\": "tests" 46 | } 47 | }, 48 | "scripts": { 49 | "cs": "php vendor/bin/phpcs", 50 | "cs-fix": "php vendor/bin/php-cs-fixer fix", 51 | "phpstan": "phpstan analyse", 52 | "test": "phpunit", 53 | "coverage": "phpunit --coverage-text", 54 | "coverage-html": "phpunit --coverage-html=coverage" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Constraints/OptionalLength6.php: -------------------------------------------------------------------------------- 1 | setOptions($options); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | $messages 102 | */ 103 | public static function setErrorMessages(array $messages = []): void 104 | { 105 | ValidatorFactory::setMessages($messages); 106 | } 107 | 108 | public static function setValidator(ValidatorInterface $validator): void 109 | { 110 | self::$validator = $validator; 111 | } 112 | 113 | public static function getValidator(): ValidatorInterface 114 | { 115 | if (null === self::$validator) { 116 | return self::$validator = Validation::createValidator(); 117 | } 118 | 119 | return self::$validator; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Form.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class Form extends Node implements ArrayAccess, IteratorAggregate 19 | { 20 | private $inputs = []; 21 | 22 | public function __construct(array $inputs = [], array $attributes = []) 23 | { 24 | parent::__construct('form', $attributes); 25 | 26 | foreach ($inputs as $name => $input) { 27 | $this->offsetSet($name, $input); 28 | } 29 | } 30 | 31 | public function __clone() 32 | { 33 | foreach ($this->inputs as $k => $input) { 34 | $this->inputs[$k] = (clone $input)->setParentNode($this); 35 | } 36 | } 37 | 38 | public function getIterator(): Traversable 39 | { 40 | return new ArrayIterator($this->inputs); 41 | } 42 | 43 | /** 44 | * @param $name string 45 | * @param $input InputInterface 46 | */ 47 | public function offsetSet($name, $input): void 48 | { 49 | if (!($input instanceof InputInterface)) { 50 | throw new InvalidArgumentException( 51 | sprintf('The input "%s" must be an instance of %s (%s)', $name, InputInterface::class, gettype($input)) 52 | ); 53 | } 54 | 55 | $input->setName($name); 56 | $this->inputs[$name] = $input; 57 | $this->appendChild($input); 58 | } 59 | 60 | /** 61 | * @param $name string 62 | * @return InputInterface|null 63 | */ 64 | #[\ReturnTypeWillChange] 65 | public function offsetGet($name) 66 | { 67 | return $this->inputs[$name] ?? null; 68 | } 69 | 70 | /** 71 | * @param $name string 72 | */ 73 | public function offsetUnset($name): void 74 | { 75 | unset($this->inputs[$name]); 76 | } 77 | 78 | /** 79 | * @param $name string 80 | */ 81 | public function offsetExists($name): bool 82 | { 83 | return isset($this->inputs[$name]); 84 | } 85 | 86 | /** 87 | * @param $value array 88 | */ 89 | public function setValue($value): self 90 | { 91 | $value = (array) $value; 92 | 93 | foreach ($this->inputs as $name => $input) { 94 | $input->setValue($value[$name] ?? null); 95 | } 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @return array 102 | */ 103 | public function getValue(): array 104 | { 105 | $value = []; 106 | 107 | foreach ($this->inputs as $name => $input) { 108 | $value[$name] = $input->getValue(); 109 | } 110 | 111 | return $value; 112 | } 113 | 114 | public function isValid(): bool 115 | { 116 | foreach ($this->inputs as $input) { 117 | if (!$input->isValid()) { 118 | return false; 119 | } 120 | } 121 | 122 | return true; 123 | } 124 | 125 | public function loadFromServerRequest(ServerRequestInterface $serverRequest): self 126 | { 127 | $method = $this->getAttribute('method') ?: 'get'; 128 | 129 | if (strtolower($method) === 'post') { 130 | return $this->setValue(array_replace_recursive( 131 | (array) $serverRequest->getParsedBody(), 132 | $serverRequest->getUploadedFiles() 133 | )); 134 | } 135 | 136 | return $this->setValue($serverRequest->getQueryParams()); 137 | } 138 | 139 | public function loadFromArrays(array $get, array $post = [], array $files = []): self 140 | { 141 | $method = $this->getAttribute('method') ?: 'get'; 142 | 143 | if (strtolower($method) === 'post') { 144 | return $this->setValue(array_replace_recursive($post, $files)); 145 | } 146 | 147 | return $this->setValue($get); 148 | } 149 | 150 | public function loadFromGlobals(): self 151 | { 152 | return $this->loadFromArrays($_GET, $_POST, $_FILES); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Groups/Group.php: -------------------------------------------------------------------------------- 1 | $inputs 25 | */ 26 | public function __construct(iterable $inputs = []) 27 | { 28 | foreach ($inputs as $name => $input) { 29 | $this->offsetSet((string) $name, $input); 30 | } 31 | } 32 | 33 | public function __clone() 34 | { 35 | foreach ($this->inputs as $k => $input) { 36 | $this->inputs[$k] = (clone $input)->setParentNode($this); 37 | } 38 | } 39 | 40 | public function getIterator(): Traversable 41 | { 42 | return new ArrayIterator($this->inputs); 43 | } 44 | 45 | /** 46 | * @param $name string 47 | * @param $input InputInterface 48 | */ 49 | public function offsetSet($name, $input): void 50 | { 51 | if (!($input instanceof InputInterface)) { 52 | throw new InvalidArgumentException( 53 | sprintf('The element "%s" must implement %s', $name, InputInterface::class) 54 | ); 55 | } 56 | 57 | $input->setName($this->name === '' ? $name : "{$this->name}[{$name}]"); 58 | $this->inputs[$name] = $input; 59 | } 60 | 61 | /** 62 | * @param $name string 63 | */ 64 | #[\ReturnTypeWillChange] 65 | public function offsetGet($name) 66 | { 67 | return $this->inputs[$name] ?? null; 68 | } 69 | 70 | /** 71 | * @param $name string 72 | */ 73 | public function offsetUnset($name): void 74 | { 75 | unset($this->inputs[$name]); 76 | } 77 | 78 | /** 79 | * @param $name string 80 | */ 81 | public function offsetExists($name): bool 82 | { 83 | return isset($this->inputs[$name]); 84 | } 85 | 86 | public function setValue($value): InputInterface 87 | { 88 | $value = (array) $value; 89 | 90 | foreach ($this->inputs as $name => $input) { 91 | $input->setValue($value[$name] ?? null); 92 | } 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * @return array 99 | */ 100 | public function getValue(): array 101 | { 102 | $value = []; 103 | 104 | foreach ($this->inputs as $name => $input) { 105 | $value[$name] = $input->getValue(); 106 | } 107 | 108 | return $value; 109 | } 110 | 111 | public function isValid(): bool 112 | { 113 | foreach ($this->inputs as $input) { 114 | if (!$input->isValid()) { 115 | return false; 116 | } 117 | } 118 | 119 | return true; 120 | } 121 | 122 | public function setName(string $name): InputInterface 123 | { 124 | $this->name = $name; 125 | 126 | foreach ($this->inputs as $name => $input) { 127 | $input->setName($this->name === '' ? $name : "{$this->name}[{$name}]"); 128 | } 129 | 130 | return $this; 131 | } 132 | 133 | public function getParentNode(): ?NodeInterface 134 | { 135 | return $this->parentNode; 136 | } 137 | 138 | public function setParentNode(NodeInterface $node): NodeInterface 139 | { 140 | $this->parentNode = $node; 141 | 142 | return $this; 143 | } 144 | 145 | public function __toString(): string 146 | { 147 | return implode("\n", $this->inputs); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Groups/GroupCollection.php: -------------------------------------------------------------------------------- 1 | group = $group; 28 | } 29 | 30 | public function __clone() 31 | { 32 | $this->group = clone $this->group; 33 | 34 | foreach ($this->values as $index => $input) { 35 | $this->values[$index] = (clone $input)->setParentNode($this); 36 | } 37 | } 38 | 39 | public function count(): int 40 | { 41 | return count($this->values); 42 | } 43 | 44 | public function getIterator(): Traversable 45 | { 46 | return new ArrayIterator($this->values); 47 | } 48 | 49 | public function offsetSet($name, $input): void 50 | { 51 | throw new RuntimeException(sprintf('Cannot add elements dynamically to a %s instance', self::class)); 52 | } 53 | 54 | #[\ReturnTypeWillChange] 55 | public function offsetGet($index) 56 | { 57 | return $this->values[$index] ?? null; 58 | } 59 | 60 | public function offsetUnset($index): void 61 | { 62 | unset($this->values[$index]); 63 | } 64 | 65 | public function offsetExists($index): bool 66 | { 67 | return isset($this->values[$index]); 68 | } 69 | 70 | public function setValue($value): InputInterface 71 | { 72 | $this->values = []; 73 | 74 | foreach ((array) $value as $index => $val) { 75 | $group = clone $this->group; 76 | $group->setValue($val); 77 | $group->setName("{$this->name}[{$index}]"); 78 | $this->values[] = $group; 79 | } 80 | 81 | return $this; 82 | } 83 | 84 | public function getValue() 85 | { 86 | $value = []; 87 | 88 | foreach ($this->values as $name => $input) { 89 | $value[$name] = $input->getValue(); 90 | } 91 | 92 | return $value; 93 | } 94 | 95 | public function isValid(): bool 96 | { 97 | foreach ($this->values as $group) { 98 | if (!$group->isValid()) { 99 | return false; 100 | } 101 | } 102 | 103 | return true; 104 | } 105 | 106 | public function setName(string $name): InputInterface 107 | { 108 | $this->name = $name; 109 | $this->group->setName("{$name}[]"); 110 | 111 | foreach ($this->values as $index => $input) { 112 | $input->setName("{$name}[{$index}]"); 113 | } 114 | 115 | return $this; 116 | } 117 | 118 | public function getGroup(): Group 119 | { 120 | return $this->group; 121 | } 122 | 123 | public function getParentNode(): ?NodeInterface 124 | { 125 | return $this->parentNode; 126 | } 127 | 128 | public function setParentNode(NodeInterface $node): NodeInterface 129 | { 130 | $this->parentNode = $node; 131 | 132 | return $this; 133 | } 134 | 135 | public function __toString() 136 | { 137 | return implode("\n", $this->values); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Groups/InputGroup.php: -------------------------------------------------------------------------------- 1 | $input) { 26 | $this->offsetSet($value, $input); 27 | } 28 | } 29 | 30 | public function __clone() 31 | { 32 | foreach ($this->inputs as $k => $input) { 33 | $this->inputs[$k] = (clone $input)->setParentNode($this); 34 | } 35 | } 36 | 37 | public function getIterator(): Traversable 38 | { 39 | return new ArrayIterator($this->inputs); 40 | } 41 | 42 | public function offsetSet($value, $input): void 43 | { 44 | $input->setAttribute('value', $value); 45 | $input->setName($this->name); 46 | $input->setParentNode($this); 47 | 48 | $this->inputs[$value] = $input; 49 | } 50 | 51 | #[\ReturnTypeWillChange] 52 | public function offsetGet($value) 53 | { 54 | return $this->inputs[$value] ?? null; 55 | } 56 | 57 | public function offsetUnset($value): void 58 | { 59 | unset($this->inputs[$value]); 60 | } 61 | 62 | public function offsetExists($value): bool 63 | { 64 | return isset($this->inputs[$value]); 65 | } 66 | 67 | public function setValue($value): InputInterface 68 | { 69 | foreach ($this->inputs as $input) { 70 | $input->setValue($value); 71 | } 72 | 73 | return $this; 74 | } 75 | 76 | public function getValue() 77 | { 78 | foreach ($this->inputs as $input) { 79 | $value = $input->getValue(); 80 | 81 | if ($value !== null) { 82 | return $value; 83 | } 84 | } 85 | } 86 | 87 | public function isValid(): bool 88 | { 89 | foreach ($this->inputs as $input) { 90 | if (!$input->isValid()) { 91 | return false; 92 | } 93 | } 94 | 95 | return true; 96 | } 97 | 98 | public function getError(): ?ValidationError 99 | { 100 | foreach ($this->inputs as $input) { 101 | if ($error = $input->getError()) { 102 | return $error; 103 | } 104 | } 105 | 106 | return null; 107 | } 108 | 109 | public function setName(string $name): InputInterface 110 | { 111 | $this->name = $name; 112 | 113 | foreach ($this->inputs as $input) { 114 | $input->setName($name); 115 | } 116 | 117 | return $this; 118 | } 119 | 120 | public function getParentNode(): ?NodeInterface 121 | { 122 | return $this->parentNode; 123 | } 124 | 125 | public function setParentNode(NodeInterface $node): NodeInterface 126 | { 127 | $this->parentNode = $node; 128 | 129 | return $this; 130 | } 131 | 132 | public function __toString() 133 | { 134 | return implode("\n", $this->inputs); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Groups/MultipleGroupCollection.php: -------------------------------------------------------------------------------- 1 | $group) { 29 | if (!($group instanceof Group)) { 30 | throw new InvalidArgumentException( 31 | sprintf('The group tagged as %s is not a %s instance', $key, Group::class) 32 | ); 33 | } 34 | } 35 | 36 | $this->groups = $groups; 37 | } 38 | 39 | public function __clone() 40 | { 41 | foreach ($this->groups as $k => $group) { 42 | $this->groups[$k] = (clone $group)->setParentNode($this); 43 | } 44 | 45 | foreach ($this->values as $k => $group) { 46 | $this->values[$k] = (clone $group)->setParentNode($this); 47 | } 48 | } 49 | 50 | public function count(): int 51 | { 52 | return count($this->values); 53 | } 54 | 55 | public function getIterator(): Traversable 56 | { 57 | return new ArrayIterator($this->values); 58 | } 59 | 60 | public function offsetSet($name, $input): void 61 | { 62 | throw new RuntimeException(sprintf('Cannot add elements dynamically to a %s instance', self::class)); 63 | } 64 | 65 | #[\ReturnTypeWillChange] 66 | public function offsetGet($index) 67 | { 68 | return $this->values[$index] ?? null; 69 | } 70 | 71 | public function offsetUnset($index): void 72 | { 73 | unset($this->values[$index]); 74 | } 75 | 76 | public function offsetExists($index): bool 77 | { 78 | return isset($this->values[$index]); 79 | } 80 | 81 | public function setValue($value): InputInterface 82 | { 83 | $this->values = []; 84 | 85 | foreach ((array) $value as $index => $val) { 86 | $key = $val['type'] ?? null; 87 | 88 | if (!isset($key) || !isset($this->groups[$key])) { 89 | continue; 90 | } 91 | 92 | $group = clone $this->groups[$key]; 93 | $group->setValue($val); 94 | $group->setName("{$this->name}[{$index}]"); 95 | $this->values[] = $group; 96 | } 97 | 98 | return $this; 99 | } 100 | 101 | public function getValue() 102 | { 103 | $value = []; 104 | 105 | foreach ($this->values as $name => $input) { 106 | $value[$name] = $input->getValue(); 107 | } 108 | 109 | return $value; 110 | } 111 | 112 | public function isValid(): bool 113 | { 114 | foreach ($this->values as $input) { 115 | if (!$input->isValid()) { 116 | return false; 117 | } 118 | } 119 | 120 | return true; 121 | } 122 | 123 | public function setName(string $name): InputInterface 124 | { 125 | $this->name = $name; 126 | 127 | foreach ($this->groups as $index => $group) { 128 | $group->setName("{$name}[]"); 129 | } 130 | 131 | foreach ($this->values as $index => $group) { 132 | $group->setName("{$name}[{$index}]"); 133 | } 134 | 135 | return $this; 136 | } 137 | 138 | public function getGroups(): array 139 | { 140 | return $this->groups; 141 | } 142 | 143 | public function getParentNode(): ?NodeInterface 144 | { 145 | return $this->parentNode; 146 | } 147 | 148 | public function setParentNode(NodeInterface $node): NodeInterface 149 | { 150 | $this->parentNode = $node; 151 | 152 | return $this; 153 | } 154 | 155 | public function __toString() 156 | { 157 | return implode("\n", $this->values); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Groups/RadioGroup.php: -------------------------------------------------------------------------------- 1 | value = null; 42 | 43 | foreach ($this->inputs as $input) { 44 | if ((string) $input->getValue() === (string) $value) { 45 | $this->value = $value; 46 | break; 47 | } 48 | } 49 | 50 | return $this; 51 | } 52 | 53 | public function getValue() 54 | { 55 | return $this->value; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/InputInterface.php: -------------------------------------------------------------------------------- 1 | 'required', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'checkbox'); 22 | $this->setAttribute('value', 'on'); 23 | 24 | if (isset($label)) { 25 | $this->setLabel($label); 26 | } 27 | } 28 | 29 | public function setValue($value): InputInterface 30 | { 31 | $this->error = null; 32 | 33 | if (((string) $this->getAttribute('value') === (string) $value) || 34 | filter_var($value, FILTER_VALIDATE_BOOLEAN) 35 | ) { 36 | return $this->setAttribute('checked', true); 37 | } 38 | 39 | return $this->removeAttribute('checked'); 40 | } 41 | 42 | public function getValue() 43 | { 44 | return $this->getAttribute('checked') ? true : null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Inputs/Color.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | ]; 15 | 16 | public function __construct(?string $label = null, iterable $attributes = []) 17 | { 18 | parent::__construct('input', $attributes); 19 | $this->setAttribute('type', 'color'); 20 | 21 | if (isset($label)) { 22 | $this->setLabel($label); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Inputs/Date.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'max' => 'max', 15 | 'min' => 'min', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'date'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/DatetimeLocal.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'max' => 'max', 15 | 'min' => 'min', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'datetime-local'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/Email.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'length' => ['minlength', 'maxlength'], 15 | 'pattern' => 'pattern', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'email'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/File.php: -------------------------------------------------------------------------------- 1 | 'required', 18 | 'accept' => 'accept', 19 | ]; 20 | 21 | public function __construct(?string $label = null, iterable $attributes = []) 22 | { 23 | parent::__construct('input', $attributes); 24 | $this->setAttribute('type', 'file'); 25 | 26 | if (isset($label)) { 27 | $this->setLabel($label); 28 | } 29 | } 30 | 31 | public function setValue($value): InputInterface 32 | { 33 | $this->value = $value; 34 | 35 | return $this; 36 | } 37 | 38 | public function getValue() 39 | { 40 | return $this->value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Inputs/Hidden.php: -------------------------------------------------------------------------------- 1 | setAttribute('value', $value); 17 | $this->setAttribute('type', 'hidden'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Inputs/Input.php: -------------------------------------------------------------------------------- 1 | getValue(); 36 | } 37 | 38 | return parent::__get($name); 39 | } 40 | 41 | public function __set(string $name, $value) 42 | { 43 | if ($name === 'value') { 44 | $this->setValue($value); 45 | 46 | return; 47 | } 48 | 49 | if ($name === 'id') { 50 | $this->setId($value); 51 | 52 | return; 53 | } 54 | 55 | return parent::__set($name, $value); 56 | } 57 | 58 | public function setAttribute(string $name, $value): Node 59 | { 60 | return parent::setAttribute($name, $value); 61 | } 62 | 63 | public function removeAttribute($name): Node 64 | { 65 | return parent::removeAttribute($name); 66 | } 67 | 68 | public function __toString() 69 | { 70 | if ($this->label) { 71 | return strtr($this->template, [ 72 | '{{ label }}' => (string) $this->label, 73 | '{{ input }}' => parent::__toString(), 74 | ]); 75 | } 76 | 77 | return parent::__toString(); 78 | } 79 | 80 | public function createLabel(string $text, array $attributes = []): Node 81 | { 82 | $label = new Node('label', $attributes); 83 | $label->innerHTML = $text; 84 | 85 | if (!$this->getAttribute('id')) { 86 | $this->setAttribute('id', self::generateId('id-input')); 87 | } 88 | 89 | $label->setAttribute('for', $this->getAttribute('id')); 90 | 91 | return $this->labels[] = $label; 92 | } 93 | 94 | public function createDatalist(array $options, array $attributes = []): Node 95 | { 96 | $datalist = new Datalist($options, $attributes); 97 | 98 | if (!$datalist->getAttribute('id')) { 99 | $datalist->setAttribute('id', self::generateId('id-datalist')); 100 | } 101 | 102 | $this->setAttribute('list', $datalist->getAttribute('id')); 103 | 104 | return $datalist; 105 | } 106 | 107 | public function getConstraints(): array 108 | { 109 | $validators = []; 110 | 111 | foreach ($this->validators as $name => $attributes) { 112 | if (is_int($name)) { 113 | $validators[] = $attributes; 114 | continue; 115 | } 116 | 117 | foreach ((array) $attributes as $attribute) { 118 | if ($this->getAttribute($attribute)) { 119 | $validators[] = $name; 120 | continue; 121 | } 122 | } 123 | } 124 | 125 | return $validators; 126 | } 127 | 128 | public function addConstraint(Constraint $constraint): self 129 | { 130 | $this->validators[] = $constraint; 131 | 132 | return $this; 133 | } 134 | 135 | public function isValid(): bool 136 | { 137 | if ($this->error === null) { 138 | $this->error = ValidationError::assert($this) ?: false; 139 | } 140 | 141 | return $this->error === false; 142 | } 143 | 144 | public function setErrorMessages(array $messages): self 145 | { 146 | $this->errorMessages = $messages; 147 | 148 | return $this; 149 | } 150 | 151 | public function getErrorMessages(): array 152 | { 153 | return $this->errorMessages; 154 | } 155 | 156 | public function getError(): ?ValidationError 157 | { 158 | return $this->isValid() ? null : $this->error; 159 | } 160 | 161 | public function setValue($value): InputInterface 162 | { 163 | $this->error = null; 164 | $this->setAttribute('value', $value); 165 | 166 | return $this; 167 | } 168 | 169 | public function getValue() 170 | { 171 | return $this->getAttribute('value'); 172 | } 173 | 174 | public function setName(string $name): InputInterface 175 | { 176 | if ($this->getAttribute('multiple')) { 177 | $name .= '[]'; 178 | } 179 | $this->setAttribute('name', $name); 180 | 181 | return $this; 182 | } 183 | 184 | public function setLabel(string $text, array $attributes = []): self 185 | { 186 | $this->label = $this->createLabel($text, $attributes); 187 | 188 | return $this; 189 | } 190 | 191 | public function setId(string $id): InputInterface 192 | { 193 | $this->setAttribute('id', $id); 194 | 195 | foreach ($this->labels as $label) { 196 | $label->setAttribute('for', $id); 197 | } 198 | 199 | return $this; 200 | } 201 | 202 | public function setTemplate(string $template): self 203 | { 204 | $this->template = strtr($template, ['{{ template }}' => $this->template]); 205 | 206 | return $this; 207 | } 208 | 209 | private static function generateId(string $prefix): string 210 | { 211 | return sprintf('%s-%s', $prefix, ++self::$idIndex); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Inputs/Month.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'max' => 'max', 15 | 'min' => 'min', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'month'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/Number.php: -------------------------------------------------------------------------------- 1 | 'required', 16 | 'max' => 'max', 17 | 'min' => 'min', 18 | 'step' => 'step', 19 | ]; 20 | 21 | public function __construct(?string $label = null, iterable $attributes = []) 22 | { 23 | parent::__construct('input', $attributes); 24 | $this->setAttribute('type', 'number'); 25 | 26 | if (isset($label)) { 27 | $this->setLabel($label); 28 | } 29 | } 30 | 31 | public function setValue($value): InputInterface 32 | { 33 | if ($value === '') { 34 | $value = null; 35 | } 36 | 37 | return parent::setValue($value); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Inputs/Password.php: -------------------------------------------------------------------------------- 1 | 'required', 13 | 'length' => ['minlength', 'maxlength'], 14 | 'pattern' => 'pattern', 15 | ]; 16 | 17 | public function __construct(?string $label = null, iterable $attributes = []) 18 | { 19 | parent::__construct('input', $attributes); 20 | $this->setAttribute('type', 'password'); 21 | 22 | if (isset($label)) { 23 | $this->setLabel($label); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Inputs/Radio.php: -------------------------------------------------------------------------------- 1 | 'required', 16 | ]; 17 | 18 | protected $template = '{{ input }} {{ label }}'; 19 | 20 | public function __construct(?string $label = null, iterable $attributes = []) 21 | { 22 | parent::__construct('input', $attributes); 23 | $this->setAttribute('type', 'radio'); 24 | 25 | if (isset($label)) { 26 | $this->setLabel($label); 27 | } 28 | } 29 | 30 | public function setValue($value): InputInterface 31 | { 32 | $this->error = null; 33 | 34 | if (!empty($value) && (string) $this->getAttribute('value') === (string) $value) { 35 | return $this->setAttribute('checked', true); 36 | } 37 | 38 | $parent = $this->getParentNode(); 39 | 40 | if ($parent instanceof RadioGroup) { 41 | if (!empty($value) && isset($parent[(string) $value])) { 42 | unset($this->validators['required']); 43 | } else { 44 | $this->validators['required'] = 'required'; 45 | } 46 | } 47 | 48 | return $this->removeAttribute('checked'); 49 | } 50 | 51 | public function getValue() 52 | { 53 | return $this->getAttribute('checked') ? $this->getAttribute('value') : null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Inputs/Range.php: -------------------------------------------------------------------------------- 1 | setAttribute('type', 'range'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Inputs/Search.php: -------------------------------------------------------------------------------- 1 | 'required', 13 | 'length' => ['minlength', 'maxlength'], 14 | 'pattern' => 'pattern', 15 | ]; 16 | 17 | public function __construct(?string $label = null, iterable $attributes = []) 18 | { 19 | parent::__construct('input', $attributes); 20 | $this->setAttribute('type', 'search'); 21 | 22 | if (isset($label)) { 23 | $this->setLabel($label); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Inputs/Select.php: -------------------------------------------------------------------------------- 1 | 'required', 18 | ]; 19 | 20 | private $allowNewValues = false; 21 | 22 | public function __construct(?string $label = null, iterable $options = [], iterable $attributes = []) 23 | { 24 | parent::__construct('select', $attributes); 25 | 26 | if ($options) { 27 | $this->setOptions($options); 28 | } 29 | 30 | if (isset($label)) { 31 | $this->setLabel($label); 32 | } 33 | } 34 | 35 | public function allowNewValues(bool $allowNewValues = true): self 36 | { 37 | $this->allowNewValues = $allowNewValues; 38 | 39 | return $this; 40 | } 41 | 42 | public function setValue($value): InputInterface 43 | { 44 | $this->error = null; 45 | 46 | if ($this->allowNewValues) { 47 | $this->addNewValues((array) $value); 48 | } 49 | 50 | if ($this->getAttribute('multiple')) { 51 | $this->setMultipleValues((array) $value); 52 | 53 | return $this; 54 | } 55 | 56 | foreach ($this->options as $option) { 57 | $option->selected = (string) $option->value === (string) $value; 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | public function getValue() 64 | { 65 | $values = []; 66 | 67 | foreach ($this->options as $option) { 68 | if ($option->getAttribute('selected')) { 69 | $values[] = $option->getAttribute('value'); 70 | } 71 | } 72 | 73 | if ($this->getAttribute('multiple')) { 74 | return $values; 75 | } 76 | 77 | return $values[0] ?? null; 78 | } 79 | 80 | private function setMultipleValues(iterable $values) 81 | { 82 | $values = array_map( 83 | function ($value) { 84 | return (string) $value; 85 | }, 86 | $values 87 | ); 88 | 89 | foreach ($this->options as $option) { 90 | $option->selected = in_array((string) $option->value, $values, true); 91 | } 92 | } 93 | 94 | private function addNewValues(iterable $values) 95 | { 96 | foreach ($values as $value) { 97 | foreach ($this->options as $option) { 98 | if ((string) $option->value === (string) $value) { 99 | continue 2; 100 | } 101 | } 102 | 103 | $this->appendChild($this->createOption($value)); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Inputs/Submit.php: -------------------------------------------------------------------------------- 1 | setAttribute('type', 'submit'); 17 | 18 | if (isset($label)) { 19 | $this->innerHTML = $label; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Inputs/Tel.php: -------------------------------------------------------------------------------- 1 | 'required', 13 | 'length' => ['minlength', 'maxlength'], 14 | 'pattern' => 'pattern', 15 | ]; 16 | 17 | public function __construct(?string $label = null, iterable $attributes = []) 18 | { 19 | parent::__construct('input', $attributes); 20 | $this->setAttribute('type', 'tel'); 21 | 22 | if (isset($label)) { 23 | $this->setLabel($label); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Inputs/Text.php: -------------------------------------------------------------------------------- 1 | 'required', 13 | 'length' => ['minlength', 'maxlength'], 14 | 'pattern' => 'pattern', 15 | ]; 16 | 17 | public function __construct(?string $label = null, iterable $attributes = []) 18 | { 19 | parent::__construct('input', $attributes); 20 | $this->setAttribute('type', 'text'); 21 | 22 | if (isset($label)) { 23 | $this->setLabel($label); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Inputs/Textarea.php: -------------------------------------------------------------------------------- 1 | 'required', 17 | 'length' => ['minlength', 'maxlength'], 18 | ]; 19 | 20 | public function __construct(?string $label = null, iterable $attributes = []) 21 | { 22 | parent::__construct('textarea', $attributes); 23 | 24 | if (isset($label)) { 25 | $this->setLabel($label); 26 | } 27 | } 28 | 29 | public function setValue($value): InputInterface 30 | { 31 | $this->value = $value; 32 | $this->innerHTML = self::escape((string) $value); 33 | 34 | return $this; 35 | } 36 | 37 | public function getValue() 38 | { 39 | return $this->value; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Inputs/Time.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'max' => 'max', 15 | 'min' => 'min', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'time'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/Url.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'length' => ['minlength', 'maxlength'], 15 | 'pattern' => 'pattern', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'url'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Inputs/Week.php: -------------------------------------------------------------------------------- 1 | 'required', 14 | 'max' => 'max', 15 | 'min' => 'min', 16 | ]; 17 | 18 | public function __construct(?string $label = null, iterable $attributes = []) 19 | { 20 | parent::__construct('input', $attributes); 21 | $this->setAttribute('type', 'week'); 22 | 23 | if (isset($label)) { 24 | $this->setLabel($label); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Node.php: -------------------------------------------------------------------------------- 1 | nodeName = $nodeName; 39 | $this->setAttributes($attributes); 40 | } 41 | 42 | public function __toString() 43 | { 44 | try { 45 | if (!empty($this->childNodes)) { 46 | return $this->getOpeningTag().implode('', $this->childNodes).$this->getClosingTag(); 47 | } 48 | 49 | return $this->getOpeningTag().$this->innerHTML.$this->getClosingTag(); 50 | } catch (\Exception $exception) { 51 | return '
'.(string) $exception.'
'; 52 | } 53 | } 54 | 55 | public function __clone() 56 | { 57 | $this->removeAttribute('id'); 58 | 59 | foreach ($this->childNodes as $k => $child) { 60 | $this->childNodes[$k] = (clone $child)->setParentNode($this); 61 | } 62 | } 63 | 64 | public function __get(string $name) 65 | { 66 | return $this->getAttribute($name); 67 | } 68 | 69 | public function __set(string $name, $value) 70 | { 71 | $this->setAttribute($name, $value); 72 | } 73 | 74 | public function __unset(string $name) 75 | { 76 | $this->removeAttribute($name); 77 | } 78 | 79 | public function __isset(string $name) 80 | { 81 | return self::isset($this->getAttribute($name)); 82 | } 83 | 84 | public function getNodeName(): string 85 | { 86 | return $this->nodeName; 87 | } 88 | 89 | public function getParentNode(): ?NodeInterface 90 | { 91 | return $this->parentNode; 92 | } 93 | 94 | public function setParentNode(NodeInterface $node): NodeInterface 95 | { 96 | $this->parentNode = $node; 97 | 98 | return $this; 99 | } 100 | 101 | public function getChildNodes(): array 102 | { 103 | return $this->childNodes; 104 | } 105 | 106 | public function appendChild(NodeInterface $node): self 107 | { 108 | $this->childNodes[] = $node->setParentNode($this); 109 | 110 | return $this; 111 | } 112 | 113 | public function setAttribute(string $name, $value): self 114 | { 115 | $this->attributes[$name] = $value; 116 | 117 | return $this; 118 | } 119 | 120 | public function setAttributes(iterable $attributes): self 121 | { 122 | foreach ($attributes as $name => $value) { 123 | if (is_int($name)) { 124 | $this->setAttribute($value, true); 125 | continue; 126 | } 127 | 128 | $this->setAttribute($name, $value); 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | public function getAttribute(string $name) 135 | { 136 | return $this->attributes[$name] ?? null; 137 | } 138 | 139 | public function removeAttribute($name): self 140 | { 141 | unset($this->attributes[$name]); 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Set an arbitrary variable. 148 | * @param null|mixed $value 149 | */ 150 | public function setVariable(string $name, $value = null): self 151 | { 152 | $this->variables[$name] = $value; 153 | 154 | return $this; 155 | } 156 | 157 | /** 158 | * Get an arbitrary variable. 159 | */ 160 | public function getVariable(string $name) 161 | { 162 | return $this->variables[$name] ?? null; 163 | } 164 | 165 | /** 166 | * Returns the html code of the opening tag. 167 | */ 168 | public function getOpeningTag(): string 169 | { 170 | $attributes = []; 171 | 172 | foreach ($this->attributes as $name => $value) { 173 | $attributes[] = self::getHtmlAttribute($name, $value); 174 | } 175 | 176 | $attributes = implode(' ', array_filter($attributes)); 177 | 178 | return sprintf('<%s%s>', $this->nodeName, $attributes === '' ? '' : " {$attributes}"); 179 | } 180 | 181 | /** 182 | * Returns the html code of the closing tag. 183 | */ 184 | public function getClosingTag(): ?string 185 | { 186 | if (!in_array($this->nodeName, self::SELF_CLOSING_TAGS)) { 187 | return sprintf('', $this->nodeName); 188 | } 189 | 190 | return null; 191 | } 192 | 193 | /** 194 | * Escapes an attribute value. 195 | */ 196 | protected static function escape(string $value): string 197 | { 198 | return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); 199 | } 200 | 201 | /** 202 | * Creates a html attribute. 203 | * @param mixed $value 204 | */ 205 | private static function getHtmlAttribute(string $name, $value): string 206 | { 207 | if (!self::isset($value)) { 208 | return ''; 209 | } 210 | 211 | if ($value === true) { 212 | return $name; 213 | } 214 | 215 | if (is_array($value)) { 216 | $value = self::convertAttributeArrayValue($name, $value); 217 | } 218 | 219 | return sprintf('%s="%s"', $name, static::escape((string) $value)); 220 | } 221 | 222 | private static function convertAttributeArrayValue(string $name, array $value): string 223 | { 224 | //data-* attributes 225 | if (stripos($name, 'data-') === 0) { 226 | return json_encode($value); 227 | } 228 | 229 | //accept or accept-charset attributes 230 | if (stripos($name, 'accept') === 0) { 231 | return implode(', ', $value); 232 | } 233 | 234 | return implode(' ', $value); 235 | } 236 | 237 | private static function isset($value): bool 238 | { 239 | return ($value !== null) && ($value !== false); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/NodeInterface.php: -------------------------------------------------------------------------------- 1 | and (like selects and datalist) 11 | */ 12 | trait HasOptionsTrait 13 | { 14 | private $options = []; 15 | 16 | abstract public function appendChild(NodeInterface $node): Node; 17 | 18 | public function setOptgroups(iterable $optgroups): self 19 | { 20 | $this->options = []; 21 | 22 | foreach ($optgroups as $label => $options) { 23 | $this->appendChild($this->createOptgroup($label, $options)); 24 | } 25 | 26 | return $this; 27 | } 28 | 29 | public function setOptions(iterable $options): self 30 | { 31 | $this->options = []; 32 | 33 | foreach ($options as $value => $label) { 34 | $attributes = []; 35 | 36 | if (is_array($label)) { 37 | $attributes = $label; 38 | $label = $attributes['label'] ?? $value; 39 | unset($attributes['label']); 40 | } 41 | 42 | $this->appendChild($this->createOption($value, (string) $label)->setAttributes($attributes)); 43 | } 44 | 45 | return $this; 46 | } 47 | 48 | private function createOptgroup($label, iterable $options): Node 49 | { 50 | $optgroup = new Node('optgroup', compact('label')); 51 | 52 | foreach ($options as $value => $label) { 53 | $optgroup->appendChild($this->createOption($value, $label)); 54 | } 55 | 56 | return $optgroup; 57 | } 58 | 59 | private function createOption($value, ?string $label = null): Node 60 | { 61 | $option = new Node('option', compact('value')); 62 | $option->innerHTML = $label ?: (string) $value; 63 | 64 | return $this->options[] = $option; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ValidationError.php: -------------------------------------------------------------------------------- 1 | validate($input->getValue(), $constraints); 25 | 26 | if (count($violations)) { 27 | /* @phpstan-ignore-next-line */ 28 | return new static($violations); 29 | } 30 | 31 | return null; 32 | } 33 | 34 | public function __construct(ConstraintViolationListInterface $violations) 35 | { 36 | $this->violations = $violations; 37 | } 38 | 39 | public function getIterator(): Traversable 40 | { 41 | return $this->violations->getIterator(); 42 | } 43 | 44 | public function __toString() 45 | { 46 | return $this->violations[0]->getMessage(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ValidatorFactory.php: -------------------------------------------------------------------------------- 1 | */ 19 | private static $messages = []; 20 | 21 | public static function setMessages(array $messages): void 22 | { 23 | self::$messages = $messages; 24 | } 25 | 26 | public static function createConstraints(Input $input): array 27 | { 28 | $constraints = []; 29 | 30 | foreach ($input->getConstraints() as $method) { 31 | if ($method instanceof Constraint) { 32 | $constraints[] = $method; 33 | continue; 34 | } 35 | 36 | if (!method_exists(self::class, $method)) { 37 | throw new RuntimeException(sprintf('Invalid validator name "%s"', $method)); 38 | } 39 | 40 | $constraint = self::$method($input); 41 | 42 | if ($constraint) { 43 | $constraints[] = $constraint; 44 | } 45 | } 46 | 47 | return $constraints; 48 | } 49 | 50 | private static function options( 51 | Input $input, 52 | string $messageType, 53 | array $defaultOptions = [], 54 | $messageKey = 'message' 55 | ): array { 56 | $messages = $input->getErrorMessages(); 57 | $message = $messages[$messageType] ?? self::$messages[$messageType] ?? null; 58 | 59 | if (null === $message) { 60 | return $defaultOptions; 61 | } 62 | 63 | return [$messageKey => $message] + $defaultOptions; 64 | } 65 | 66 | public static function number(Input $input): Constraint 67 | { 68 | return new Constraints\Type( 69 | self::options($input, 'number', [ 70 | 'type' => 'numeric', 71 | 'message' => 'This value is not a valid number.', 72 | ]) 73 | ); 74 | } 75 | 76 | public static function url(Input $input): Constraint 77 | { 78 | return new Constraints\Url( 79 | self::options($input, 'url') 80 | ); 81 | } 82 | 83 | public static function email(Input $input): Constraint 84 | { 85 | return new Constraints\Email( 86 | self::options($input, 'email', ['mode' => 'html5']) 87 | ); 88 | } 89 | 90 | public static function color(Input $input): Constraint 91 | { 92 | return new Constraints\Regex( 93 | self::options($input, 'color', [ 94 | 'pattern' => '/^#[a-f0-9]{6}$/', 95 | 'message' => 'This value is not a valid color.', 96 | ]) 97 | ); 98 | } 99 | 100 | public static function date(Input $input): Constraint 101 | { 102 | return new Constraints\Date( 103 | self::options($input, 'date') 104 | ); 105 | } 106 | 107 | public static function datetimeLocal(Input $input): Constraint 108 | { 109 | return new Constraints\DateTime( 110 | self::options($input, 'datetime-local', ['format' => 'Y-m-d?H:i:s']) 111 | ); 112 | } 113 | 114 | public static function month(Input $input): Constraint 115 | { 116 | return new Constraints\DateTime( 117 | self::options($input, 'month', [ 118 | 'format' => 'Y-m', 119 | 'message' => 'This value is not a valid month.', 120 | ]) 121 | ); 122 | } 123 | 124 | public static function week(Input $input): Constraint 125 | { 126 | return new Constraints\Regex( 127 | self::options($input, 'week', [ 128 | 'pattern' => '/^[\d]{4}-W(0[1-9]|[1-4][0-9]|5[1-3])$/', 129 | 'message' => 'This value is not a valid week.', 130 | ]) 131 | ); 132 | } 133 | 134 | public static function time(Input $input): Constraint 135 | { 136 | if ($input->getAttribute('step')) { 137 | return new Constraints\Time( 138 | self::options($input, 'time') 139 | ); 140 | } 141 | 142 | return new Constraints\DateTime( 143 | self::options($input, 'time', [ 144 | 'format' => 'H:i', 145 | 'message' => 'This value is not a valid time.', 146 | ]) 147 | ); 148 | } 149 | 150 | public static function file(Input $input): Constraint 151 | { 152 | return new Constraints\Callback( 153 | [new Validators\UploadedFile(self::options($input, 'file')), '__invoke'] 154 | ); 155 | } 156 | 157 | public static function required(Input $input): Constraint 158 | { 159 | return new Constraints\NotBlank( 160 | self::options($input, 'required') 161 | ); 162 | } 163 | 164 | public static function length(Input $input): Constraint 165 | { 166 | $options = []; 167 | 168 | $minlength = $input->getAttribute('minlength'); 169 | $maxlength = $input->getAttribute('maxlength'); 170 | 171 | if (!empty($minlength)) { 172 | $options += self::options($input, 'minlength', ['min' => $minlength], 'minMessage'); 173 | } 174 | 175 | if (!empty($maxlength)) { 176 | $options += self::options($input, 'maxlength', ['max' => $maxlength], 'maxMessage'); 177 | } 178 | 179 | $version = self::getValidatorVersion(); 180 | 181 | if ($version === 7) { 182 | return new \FormManager\Constraints\OptionalLength7($options); 183 | } elseif ($version === 6) { 184 | return new \FormManager\Constraints\OptionalLength6($options); 185 | } 186 | $options['allowEmptyString'] = true; 187 | 188 | return new Constraints\Length($options); 189 | } 190 | 191 | private static function getValidatorVersion(): int 192 | { 193 | if (property_exists(Constraints\Length::class, 'allowEmptyString')) { 194 | return 5; 195 | } 196 | 197 | $reflection = new ReflectionClass(LengthValidator::class); 198 | $hasReturnType = $reflection->getMethod('validate')->hasReturnType(); 199 | if (!$hasReturnType) { 200 | return 6; 201 | } 202 | 203 | if ($hasReturnType) { 204 | return 7; 205 | } 206 | 207 | throw new RuntimeException('Cannot determine symfony/validator version. Please report this on GitHub.'); 208 | } 209 | 210 | public static function max(Input $input): Constraint 211 | { 212 | return new Constraints\LessThanOrEqual( 213 | self::options($input, 'max', ['value' => $input->getAttribute('max')]) 214 | ); 215 | } 216 | 217 | public static function min(Input $input): Constraint 218 | { 219 | return new Constraints\GreaterThanOrEqual( 220 | self::options($input, 'min', ['value' => $input->getAttribute('min')]) 221 | ); 222 | } 223 | 224 | public static function step(Input $input): Constraint 225 | { 226 | return new Constraints\Callback( 227 | [new Validators\Step(self::options($input, 'step', ['step' => $input->getAttribute('step')])), '__invoke'] 228 | ); 229 | } 230 | 231 | public static function pattern(Input $input): Constraint 232 | { 233 | $pattern = sprintf('/^%s$/u', str_replace('/', '\\/', $input->getAttribute('pattern'))); 234 | 235 | return new Constraints\Regex( 236 | self::options($input, 'pattern', ['pattern' => $pattern]) 237 | ); 238 | } 239 | 240 | public static function accept(Input $input): Constraint 241 | { 242 | return new Constraints\Callback([ 243 | new Validators\AcceptFile( 244 | self::options($input, 'accept', ['accept' => $input->getAttribute('accept')]) 245 | ), 246 | '__invoke', 247 | ]); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Validators/AcceptFile.php: -------------------------------------------------------------------------------- 1 | options = $options + [ 17 | 'message' => 'This file type is not valid.', 18 | ]; 19 | 20 | $accept = array_map('trim', explode(',', strtolower($this->options['accept']))); 21 | 22 | $this->extensions = array_filter($accept, function ($value) { 23 | return !strstr($value, '/'); 24 | }); 25 | 26 | $this->mimes = array_filter($accept, function ($value) { 27 | return strstr($value, '/'); 28 | }); 29 | 30 | array_walk($this->mimes, function (&$value) { 31 | $value = strtolower(str_replace('*', '.*', "|^{$value}\$|")); 32 | }); 33 | } 34 | 35 | public function __invoke($input, $context) 36 | { 37 | if (empty($input) 38 | || (is_array($input) && $this->validateArray($input)) 39 | || ($input instanceof UploadedFileInterface && $this->validatePsr7($input)) 40 | ) { 41 | return; 42 | } 43 | 44 | $context->buildViolation($this->options['message'])->addViolation(); 45 | } 46 | 47 | private function validateArray(array $file): bool 48 | { 49 | if (empty($file['tmp_name'])) { 50 | return true; 51 | } 52 | 53 | return $this->checkExtension($file['name']) 54 | && $this->checkMime($file['tmp_name']); 55 | } 56 | 57 | private function validatePsr7(UploadedFileInterface $file): bool 58 | { 59 | if ($file->getError() === UPLOAD_ERR_NO_FILE) { 60 | return true; 61 | } 62 | 63 | return $this->checkExtension($file->getClientFilename()) 64 | && $this->checkMime($file->getStream()->getMetadata('uri')); 65 | } 66 | 67 | private function checkExtension(string $name): bool 68 | { 69 | if (empty($this->extensions)) { 70 | return true; 71 | } 72 | 73 | $original = explode('.', $name); 74 | $original = '.'.end($original); 75 | 76 | foreach ($this->extensions as $extension) { 77 | if ($original === $extension) { 78 | return true; 79 | } 80 | } 81 | 82 | return false; 83 | } 84 | 85 | private function checkMime(string $file): bool 86 | { 87 | if (empty($this->mimes)) { 88 | return true; 89 | } 90 | 91 | $finfo = finfo_open(FILEINFO_MIME_TYPE); 92 | $mime = finfo_file($finfo, $file); 93 | finfo_close($finfo); 94 | 95 | foreach ($this->mimes as $pattern) { 96 | if (preg_match($pattern, $mime)) { 97 | return true; 98 | } 99 | } 100 | 101 | return false; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Validators/Step.php: -------------------------------------------------------------------------------- 1 | options = $options + [ 13 | 'message' => 'This number is not valid.', 14 | ]; 15 | } 16 | 17 | public function __invoke($input, $context) 18 | { 19 | $step = (float) $this->options['step']; 20 | $input = (float) $input; 21 | 22 | if (!$step || !$input) { 23 | return; 24 | } 25 | 26 | if (fmod($input, $step)) { 27 | $context->buildViolation($this->options['message'])->addViolation(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Validators/UploadedFile.php: -------------------------------------------------------------------------------- 1 | 'The uploaded file exceeds the upload_max_filesize directive in php.ini', 15 | 2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form', 16 | 3 => 'The uploaded file was only partially uploaded', 17 | 6 => 'Missing a temporary folder', 18 | 7 => 'Failed to write file to disk', 19 | 8 => 'A PHP extension stopped the file upload', 20 | ]; 21 | 22 | public function __construct(array $options = []) 23 | { 24 | $this->options = $options + [ 25 | 'message' => 'This value is not a valid file.', 26 | ]; 27 | } 28 | 29 | /** 30 | * @param UploadedFileInterface|array|string|null $input 31 | * @param ExecutionContext $context 32 | */ 33 | public function __invoke($input, $context) 34 | { 35 | if (empty($input) 36 | || (is_array($input) && $this->validateArray($input)) 37 | || ($input instanceof UploadedFileInterface && $this->validatePsr7($input)) 38 | ) { 39 | return; 40 | } 41 | 42 | $context->buildViolation($this->options['message'])->addViolation(); 43 | } 44 | 45 | /** 46 | * @param array $file 47 | */ 48 | private function validateArray(array $file): bool 49 | { 50 | if (!array_key_exists('name', $file)) { 51 | return false; 52 | } 53 | 54 | if (!array_key_exists('type', $file)) { 55 | return false; 56 | } 57 | 58 | if (!array_key_exists('tmp_name', $file)) { 59 | return false; 60 | } 61 | 62 | return $this->checkErrorType($file['error'] ?? null); 63 | } 64 | 65 | private function validatePsr7(UploadedFileInterface $file): bool 66 | { 67 | return $this->checkErrorType($file->getError()); 68 | } 69 | 70 | /** 71 | * @param int|null $error 72 | */ 73 | private function checkErrorType($error): bool 74 | { 75 | return !isset($error) || !isset(self::ERROR_TYPES[$error]); 76 | } 77 | } 78 | --------------------------------------------------------------------------------