Repository: symfony/ux
Pull request #2926 (2025-07-15 12:59:22)

[LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Docs?         | yes
| Issues        | 
| License       | MIT

**[LiveComponent]**
This PR introduces support for input validation modifiers used in data-model, including:

- min_length(x)
- max_length(x)
- min_value(x)
- max_value(x)

These modifiers prevent model updates and AJAX calls if the user input doesn't meet the validation constraints.
It helps avoid unnecessary re-renders and network traffic.

They can be combined with other modifiers like debounce.

```twig
  <input data-model="name|min_length(3)" />
  <input data-model="price|min_value(1)|max_value(100)" type="number" />
```

github-actions[bot]: <h1>📊 Packages dist files size difference</h1>

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

<table>
    <thead><tr><th>File</th><th>Before (Size / Gzip)</th><th>After (Size / Gzip)</th></tr></thead>
    <tbody><tr><td colspan="3"><a href="https://github.com/xDeSwa/ux/tree/live-component-new-modifiers/src/LiveComponent/assets/dist"><b>LiveComponent</b></a></td></tr><tr>
                <td><a href="https://github.com/xDeSwa/ux/blob/live-component-new-modifiers/src/LiveComponent/assets/dist/live_controller.js"><code>live_controller.js</code></a></td>
            <td>
                    <code>120.02 kB</code>
                    / <code>23.44 kB</code> 
                </td><td>
                    <code>121.67 kB</code><sup>+1% 📈</sup>
                    / <code>23.71 kB</code><sup>+1% 📈</sup>
                </td></tr></tbody>
</table>

<!-- Sticky Pull Request Comment -->
xDeSwa: >     * `min_length(3)`
> 
>     * `min_length(1)|max_length(10)`
> 
>     * `min_value(1990)`
> 
>     * `max_value(2025)`
> 
>     * `min_value(1990)|max_value(2025)`
> 
Your suggestion makes more sense, I'll update. thanks


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5d6249cd..ad2328a0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -100,7 +100,7 @@ When contributing to the documentation, please make sure to follow the Symfony
 [documentation guidelines](https://symfony.com/doc/current/contributing/documentation/index.html).
 
 To verify your changes locally, you can use the `oskarstark/doctor-rst` Docker image. Run the following
-command from the root directory of the project:
+command from the root directory of the projet:
 
 ```shell
 docker run --rm -it -e DOCS_DIR='/docs' -v ${PWD}:/docs  oskarstark/doctor-rst -vvv
diff --git a/src/Autocomplete/assets/package.json b/src/Autocomplete/assets/package.json
index d10cadff..4d3a280d 100644
--- a/src/Autocomplete/assets/package.json
+++ b/src/Autocomplete/assets/package.json
@@ -37,10 +37,7 @@
         },
         "importmap": {
             "@hotwired/stimulus": "^3.0.0",
-            "tom-select": "^2.2.2",
-            "tom-select/dist/css/tom-select.default.css": "^2.2.2",
-            "tom-select/dist/css/tom-select.bootstrap4.css": "^2.2.2",
-            "tom-select/dist/css/tom-select.bootstrap5.css": "^2.2.2"
+            "tom-select": "^2.2.2"
         }
     },
     "peerDependencies": {
diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json
index f8253d95..040fdf15 100644
--- a/src/Autocomplete/composer.json
+++ b/src/Autocomplete/composer.json
@@ -48,7 +48,7 @@
         "symfony/uid": "^6.3|^7.0",
         "twig/twig": "^2.14.7|^3.0.4",
         "zenstruck/browser": "^1.1",
-        "zenstruck/foundry": "^2.0"
+        "zenstruck/foundry": "1.37.*"
     },
     "conflict": {
         "doctrine/orm": "2.9.0 || 2.9.1"
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 84ed16c8..89b02d5a 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -1,5 +1,19 @@
 # CHANGELOG
 
+## 2.28.0
+
+-   Add new modifiers for input validations, useful to prevent uneccessary HTTP requests:
+    - `min_length` and `max_length`: validate length from textual input elements
+    - `min_value` and `max_value`: validate value from numeral input elements
+
+```twig
+<!-- Do not trigger model update until 3 characters are typed -->
+<input data-model="min_length(3)|username" type="text" value="" />
+
+<!-- Only trigger updates when value number is between 10 and 100 -->
+<input data-model="min_value(10)|max_value(100)|quantity" type="number" value="20" />
+```
+
 ## 2.27.0
 
 -  Add events assertions in `InteractsWithLiveComponents`:
diff --git a/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts b/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts
index 5d36c0d4..daaeb36f 100644
--- a/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts
+++ b/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts
@@ -5,5 +5,9 @@ export interface ModelBinding {
     shouldRender: boolean;
     debounce: number | boolean;
     targetEventName: string | null;
+    minLength: number | null;
+    maxLength: number | null;
+    minValue: number | null;
+    maxValue: number | null;
 }
 export default function (modelDirective: Directive): ModelBinding;
diff --git a/src/LiveComponent/assets/dist/dom_utils.d.ts b/src/LiveComponent/assets/dist/dom_utils.d.ts
index 72dac3db..af330333 100644
--- a/src/LiveComponent/assets/dist/dom_utils.d.ts
+++ b/src/LiveComponent/assets/dist/dom_utils.d.ts
@@ -8,3 +8,6 @@ export declare function getModelDirectiveFromElement(element: HTMLElement, throw
 export declare function elementBelongsToThisComponent(element: Element, component: Component): boolean;
 export declare function cloneHTMLElement(element: HTMLElement): HTMLElement;
 export declare function htmlToElement(html: string): HTMLElement;
+export declare function isTextualInputElement(el: HTMLElement): el is HTMLInputElement;
+export declare function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement;
+export declare function isNumericalInputElement(element: Element): element is HTMLInputElement;
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index 4902665e..7c906620 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -475,6 +475,15 @@ const getMultipleCheckboxValue = (element, currentValues) => {
     return finalValues;
 };
 const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value;
+function isTextualInputElement(el) {
+    return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type);
+}
+function isTextareaElement(el) {
+    return el instanceof HTMLTextAreaElement;
+}
+function isNumericalInputElement(element) {
+    return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type);
+}
 
 class HookManager {
     constructor() {
@@ -2343,6 +2352,10 @@ function getModelBinding (modelDirective) {
     let shouldRender = true;
     let targetEventName = null;
     let debounce = false;
+    let minLength = null;
+    let maxLength = null;
+    let minValue = null;
+    let maxValue = null;
     modelDirective.modifiers.forEach((modifier) => {
         switch (modifier.name) {
             case 'on':
@@ -2360,6 +2373,18 @@ function getModelBinding (modelDirective) {
             case 'debounce':
                 debounce = modifier.value ? Number.parseInt(modifier.value) : true;
                 break;
+            case 'min_length':
+                minLength = modifier.value ? Number.parseInt(modifier.value) : null;
+                break;
+            case 'max_length':
+                maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
+                break;
+            case 'min_value':
+                minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
+                break;
+            case 'max_value':
+                maxValue = modifier.value ? Number.parseFloat(modifier.value) : null;
+                break;
             default:
                 throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
         }
@@ -2371,6 +2396,10 @@ function getModelBinding (modelDirective) {
         shouldRender,
         debounce,
         targetEventName,
+        minLength,
+        maxLength,
+        minValue,
+        maxValue,
     };
 }
 
@@ -3153,6 +3182,27 @@ class LiveControllerDefault extends Controller {
             }
         }
         const finalValue = getValueFromElement(element, this.component.valueStore);
+        if (isTextualInputElement(element) || isTextareaElement(element)) {
+            if (modelBinding.minLength !== null &&
+                typeof finalValue === 'string' &&
+                finalValue.length < modelBinding.minLength) {
+                return;
+            }
+            if (modelBinding.maxLength !== null &&
+                typeof finalValue === 'string' &&
+                finalValue.length > modelBinding.maxLength) {
+                return;
+            }
+        }
+        if (isNumericalInputElement(element)) {
+            const numericValue = Number(finalValue);
+            if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
+                return;
+            }
+            if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
+                return;
+            }
+        }
         this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
     }
     dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) {
diff --git a/src/LiveComponent/assets/src/Directive/get_model_binding.ts b/src/LiveComponent/assets/src/Directive/get_model_binding.ts
index 7b61a6a7..e94d7baa 100644
--- a/src/LiveComponent/assets/src/Directive/get_model_binding.ts
+++ b/src/LiveComponent/assets/src/Directive/get_model_binding.ts
@@ -6,12 +6,20 @@ export interface ModelBinding {
     shouldRender: boolean;
     debounce: number | boolean;
     targetEventName: string | null;
+    minLength: number | null;
+    maxLength: number | null;
+    minValue: number | null;
+    maxValue: number | null;
 }
 
 export default function (modelDirective: Directive): ModelBinding {
     let shouldRender = true;
     let targetEventName = null;
     let debounce: number | boolean = false;
+    let minLength: number | null = null;
+    let maxLength: number | null = null;
+    let minValue: number | null = null;
+    let maxValue: number | null = null;
 
     modelDirective.modifiers.forEach((modifier) => {
         switch (modifier.name) {
@@ -38,6 +46,26 @@ export default function (modelDirective: Directive): ModelBinding {
             case 'debounce':
                 debounce = modifier.value ? Number.parseInt(modifier.value) : true;
 
+                break;
+
+            case 'min_length':
+                minLength = modifier.value ? Number.parseInt(modifier.value) : null;
+
+                break;
+
+            case 'max_length':
+                maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
+
+                break;
+
+            case 'min_value':
+                minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
+
+                break;
+
+            case 'max_value':
+                maxValue = modifier.value ? Number.parseFloat(modifier.value) : null;
+
                 break;
             default:
                 throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
@@ -52,5 +80,9 @@ export default function (modelDirective: Directive): ModelBinding {
         shouldRender,
         debounce,
         targetEventName,
+        minLength,
+        maxLength,
+        minValue,
+        maxValue,
     };
 }
diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts
index 9fb1390c..0a8afe29 100644
--- a/src/LiveComponent/assets/src/dom_utils.ts
+++ b/src/LiveComponent/assets/src/dom_utils.ts
@@ -262,3 +262,24 @@ const getMultipleCheckboxValue = (element: HTMLInputElement, currentValues: Arra
 
 const inputValue = (element: HTMLInputElement): string =>
     element.dataset.value ? element.dataset.value : element.value;
+
+/**
+ * Checks whether the given element is a textual input (input[type=text/email/...]).
+ */
+export function isTextualInputElement(el: HTMLElement): el is HTMLInputElement {
+    return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type);
+}
+
+/**
+ * Checks whether the given element is a textarea.
+ */
+export function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement {
+    return el instanceof HTMLTextAreaElement;
+}
+
+/**
+ * Checks whether the given element is a numerical input (input[type=number] or input[type=range]).
+ */
+export function isNumericalInputElement(element: Element): element is HTMLInputElement {
+    return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type);
+}
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index 34380a8f..baa9a65b 100644
--- a/src/LiveComponent/assets/src/live_controller.ts
+++ b/src/LiveComponent/assets/src/live_controller.ts
@@ -13,7 +13,14 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel
 import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin';
 import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser';
 import getModelBinding from './Directive/get_model_binding';
-import { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement } from './dom_utils';
+import {
+    elementBelongsToThisComponent,
+    getModelDirectiveFromElement,
+    getValueFromElement,
+    isNumericalInputElement,
+    isTextareaElement,
+    isTextualInputElement,
+} from './dom_utils';
 import getElementAsTagText from './Util/getElementAsTagText';
 
 export { Component };
@@ -30,6 +37,7 @@ export interface LiveController {
     element: HTMLElement;
     component: Component;
 }
+
 export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
     static values = {
         name: String,
@@ -429,6 +437,36 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
 
         const finalValue = getValueFromElement(element, this.component.valueStore);
 
+        if (isTextualInputElement(element) || isTextareaElement(element)) {
+            if (
+                modelBinding.minLength !== null &&
+                typeof finalValue === 'string' &&
+                finalValue.length < modelBinding.minLength
+            ) {
+                return;
+            }
+
+            if (
+                modelBinding.maxLength !== null &&
+                typeof finalValue === 'string' &&
+                finalValue.length > modelBinding.maxLength
+            ) {
+                return;
+            }
+        }
+
+        if (isNumericalInputElement(element)) {
+            const numericValue = Number(finalValue);
+
+            if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
+                return;
+            }
+
+            if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
+                return;
+            }
+        }
+
         this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
     }
 
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index b59142e1..a16f65f5 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -384,6 +384,66 @@ This can be useful along with a button that triggers a render on click:
     <input data-model="norender|coupon">
     <button data-action="live#$render">Apply</button>
 
+Input Model Validation Modifiers
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 2.28
+
+    Modifiers to validate ``<input>`` value were added in UX LiveComponent 2.28.
+
+Input model validation modifiers help to reduce unnecessary server requests and provide
+a lightweight form of frontend validation, for example:
+
+.. code-block:: html
+
+    <!-- Do not trigger model update until 3 characters are typed -->
+    <input data-model="min_length(3)|username" type="text" value="" />
+
+    <!-- Only trigger updates when value number is between 10 and 100 -->
+    <input data-model="min_value(10)|max_value(100)|quantity" type="number" value="20" />
+
+``min_length``
+..............
+
+Validate that an ``<input>`` element of type ``text``, ``email``, ``password``, ``search``, ``url``
+or a ``<textarea>`` element, has a value length not less than the specified length:
+
+.. code-block:: html
+
+    <!-- Validate the search term is at least 3 characters long-->
+    <input type="search" data-model="min_length(3)|search">
+
+``max_length``
+..............
+
+Validate that an ``<input>`` element of type ``text``, ``email``, ``password``, ``search``, ``url``
+or a ``<textarea>`` element, has a value length not higher than the specified length:
+
+.. code-block:: html
+
+    <!-- Validates that the username is not longer than 10 characters -->
+    <input type="text" data-model="max_length(10)|username">
+
+``min_value``
+.............
+
+Validate that an ``<input>`` element of type ``number`` or ``range`` has a numeric value which is not less than the specified value:
+
+.. code-block:: html
+
+    <!-- Validate that the age is not less than 18 -->
+    <input type="number" data-model="min_value(18)|age">
+
+``max_value``
+.............
+
+Validate that an ``<input>`` element of type ``number`` or ``range`` has a numeric value which is not higher than the specified value:
+
+.. code-block:: html
+
+    <!-- Validate that the year is not higher than 2025 -->
+    <input type="number" data-model="max_value(2025)|year">
+
 Forcing a Re-Render Explicitly
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index c0a832f9..5fedbf52 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -191,7 +191,6 @@ final class LiveComponentExtension extends Extension implements PrependExtension
             ->setArguments([
                 new Reference('ux.twig_component.component_factory'),
                 new Reference('property_info'),
-                new Reference('type_info.resolver', ContainerInterface::NULL_ON_INVALID_REFERENCE),
             ])
             ->addTag('kernel.reset', ['method' => 'reset'])
         ;
diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php
index 63109093..fddff53a 100644
--- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php
+++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php
@@ -13,9 +13,9 @@ namespace Symfony\UX\LiveComponent\Metadata;
 
 use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
 use Symfony\Component\PropertyInfo\Type as LegacyType;
-use Symfony\Component\TypeInfo\Type;
-use Symfony\Component\TypeInfo\Type\CollectionType;
-use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
+use Symfony\Component\TypeInfo\Type\IntersectionType;
+use Symfony\Component\TypeInfo\Type\NullableType;
+use Symfony\Component\TypeInfo\Type\UnionType;
 use Symfony\Contracts\Service\ResetInterface;
 use Symfony\UX\LiveComponent\Attribute\LiveProp;
 use Symfony\UX\TwigComponent\ComponentFactory;
@@ -33,11 +33,7 @@ class LiveComponentMetadataFactory implements ResetInterface
     public function __construct(
         private ComponentFactory $componentFactory,
         private PropertyTypeExtractorInterface $propertyTypeExtractor,
-        private ?TypeResolver $typeResolver = null,
     ) {
-        if (method_exists($this->propertyTypeExtractor, 'getType') && !$this->typeResolver) {
-            throw new \LogicException('Symfony TypeInfo is required to use LiveProps. Try running "composer require symfony/type-info".');
-        }
     }
 
     public function getMetadata(string $name): LiveComponentMetadata
@@ -81,13 +77,13 @@ class LiveComponentMetadataFactory implements ResetInterface
 
     public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata|LegacyLivePropMetadata
     {
-        $reflectionType = $property->getType();
-        if ($reflectionType instanceof \ReflectionUnionType || $reflectionType instanceof \ReflectionIntersectionType) {
-            throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName()));
-        }
-
         // BC layer when "symfony/type-info" is not available
         if (!method_exists($this->propertyTypeExtractor, 'getType')) {
+            $type = $property->getType();
+            if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
+                throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName()));
+            }
+
             $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];
 
             $collectionValueType = null;
@@ -100,16 +96,14 @@ class LiveComponentMetadataFactory implements ResetInterface
                 }
             }
 
-            if (null === $reflectionType && null === $collectionValueType && isset($infoTypes[0])) {
-                // If it's an "advanced" type (like a Collection), let's use the PropertyTypeExtractor to get the Type
+            if (null === $type && null === $collectionValueType && isset($infoTypes[0])) {
                 $infoType = LegacyType::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType();
                 $isTypeBuiltIn = null === $infoTypes[0]->getClassName();
                 $isTypeNullable = $infoTypes[0]->isNullable();
             } else {
-                // Otherwise, we can use the ReflectionType to get the Type
-                $infoType = $reflectionType?->getName();
-                $isTypeBuiltIn = $reflectionType?->isBuiltin() ?? false;
-                $isTypeNullable = $reflectionType?->allowsNull() ?? true;
+                $infoType = $type?->getName();
+                $isTypeBuiltIn = $type?->isBuiltin() ?? false;
+                $isTypeNullable = $type?->allowsNull() ?? true;
             }
 
             return new LegacyLivePropMetadata(
@@ -121,17 +115,10 @@ class LiveComponentMetadataFactory implements ResetInterface
                 $collectionValueType
             );
         } else {
-            $infoType = $this->propertyTypeExtractor->getType($className, $property->getName());
-
-            if ($infoType instanceof CollectionType) {
-                // If it's an "advanced" type (like CollectionType), let's use the PropertyTypeExtractor to get the Type
-                $type = $infoType;
-            } elseif (null !== $reflectionType) {
-                // Otherwise, we can use the TypeResolver to convert the ReflectionType to a Type
-                $type = $this->typeResolver->resolve($reflectionType);
-            } else {
-                // If no type is available, we default to mixed
-                $type = Type::mixed();
+            $type = $this->propertyTypeExtractor->getType($className, $property->getName());
+
+            if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) {
+                throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property "%s" in "%s".', $propertyName, $className));
             }
 
             return new LivePropMetadata($property->getName(), $liveProp, $type);


Test patch:
diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php
index 71ef991a..8e255cb2 100644
--- a/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php
+++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php
@@ -11,15 +11,33 @@
 
 namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
 
+use Doctrine\ORM\EntityRepository;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category;
-use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\ModelFactory;
+use Zenstruck\Foundry\Proxy;
+use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @extends PersistentObjectFactory<Category>
+ * @extends ModelFactory<Category>
+ *
+ * @method static Category|Proxy                   createOne(array $attributes = [])
+ * @method static Category[]|Proxy[]               createMany(int $number, array|callable $attributes = [])
+ * @method static Category|Proxy                   find(object|array|mixed $criteria)
+ * @method static Category|Proxy                   findOrCreate(array $attributes)
+ * @method static Category|Proxy                   first(string $sortedField = 'id')
+ * @method static Category|Proxy                   last(string $sortedField = 'id')
+ * @method static Category|Proxy                   random(array $attributes = [])
+ * @method static Category|Proxy                   randomOrCreate(array $attributes = []))
+ * @method static Category[]|Proxy[]               all()
+ * @method static Category[]|Proxy[]               findBy(array $attributes)
+ * @method static Category[]|Proxy[]               randomSet(int $number, array $attributes = []))
+ * @method static Category[]|Proxy[]               randomRange(int $min, int $max, array $attributes = []))
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method        Category|Proxy                   create(array|callable $attributes = [])
  */
-final class CategoryFactory extends PersistentObjectFactory
+final class CategoryFactory extends ModelFactory
 {
-    protected function defaults(): array
+    protected function getDefaults(): array
     {
         $name = self::faker()->name();
         return [
@@ -28,7 +46,12 @@ final class CategoryFactory extends PersistentObjectFactory
         ];
     }
 
-    public static function class(): string
+    protected function initialize(): self
+    {
+        return $this;
+    }
+
+    protected static function getClass(): string
     {
         return Category::class;
     }
diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php
index 9234df82..32eec81c 100644
--- a/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php
+++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php
@@ -11,23 +11,46 @@
 
 namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
 
+use Doctrine\ORM\EntityRepository;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\CategoryTag;
-use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\ModelFactory;
+use Zenstruck\Foundry\Proxy;
+use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @extends PersistentObjectFactory<CategoryTag>
+ * @extends ModelFactory<CategoryTag>
+ *
+ * @method static CategoryTag|Proxy                createOne(array $attributes = [])
+ * @method static CategoryTag[]|Proxy[]            createMany(int $number, array|callable $attributes = [])
+ * @method static CategoryTag|Proxy                find(object|array|mixed $criteria)
+ * @method static CategoryTag|Proxy                findOrCreate(array $attributes)
+ * @method static CategoryTag|Proxy                first(string $sortedField = 'id')
+ * @method static CategoryTag|Proxy                last(string $sortedField = 'id')
+ * @method static CategoryTag|Proxy                random(array $attributes = [])
+ * @method static CategoryTag|Proxy                randomOrCreate(array $attributes = [])
+ * @method static CategoryTag[]|Proxy[]            all()
+ * @method static CategoryTag[]|Proxy[]            findBy(array $attributes)
+ * @method static CategoryTag[]|Proxy[]            randomSet(int $number, array $attributes = [])
+ * @method static CategoryTag[]|Proxy[]            randomRange(int $min, int $max, array $attributes = [])
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method        CategoryTag|Proxy                create(array|callable $attributes = [])
  */
-final class CategoryTagFactory extends PersistentObjectFactory
+final class CategoryTagFactory extends ModelFactory
 {
-    public static function class(): string
-    {
-        return CategoryTag::class;
-    }
-
-    protected function defaults(): array
+    protected function getDefaults(): array
     {
         return [
             'name' => self::faker()->word(),
         ];
     }
+
+    protected function initialize(): self
+    {
+        return $this;
+    }
+
+    protected static function getClass(): string
+    {
+        return CategoryTag::class;
+    }
 }
diff --git a/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php
index 6f12a56a..4669fa98 100644
--- a/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php
+++ b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php
@@ -11,25 +11,48 @@
 
 namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
 
+use Doctrine\ORM\EntityRepository;
 use Symfony\Component\Uid\UuidV4;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Ingredient;
-use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\ModelFactory;
+use Zenstruck\Foundry\Proxy;
+use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @extends PersistentObjectFactory<Ingredient>
+ * @extends ModelFactory<Ingredient>
+ *
+ * @method static Ingredient|Proxy                 createOne(array $attributes = [])
+ * @method static Ingredient[]|Proxy[]             createMany(int $number, array|callable $attributes = [])
+ * @method static Ingredient|Proxy                 find(object|array|mixed $criteria)
+ * @method static Ingredient|Proxy                 findOrCreate(array $attributes)
+ * @method static Ingredient|Proxy                 first(string $sortedField = 'id')
+ * @method static Ingredient|Proxy                 last(string $sortedField = 'id')
+ * @method static Ingredient|Proxy                 random(array $attributes = [])
+ * @method static Ingredient|Proxy                 randomOrCreate(array $attributes = []))
+ * @method static Ingredient[]|Proxy[]             all()
+ * @method static Ingredient[]|Proxy[]             findBy(array $attributes)
+ * @method static Ingredient[]|Proxy[]             randomSet(int $number, array $attributes = []))
+ * @method static Ingredient[]|Proxy[]             randomRange(int $min, int $max, array $attributes = []))
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method        Ingredient|Proxy                 create(array|callable $attributes = [])
  */
-final class IngredientFactory extends PersistentObjectFactory
+final class IngredientFactory extends ModelFactory
 {
-    public static function class(): string
-    {
-        return Ingredient::class;
-    }
-
-    protected function defaults(): array
+    protected function getDefaults(): array
     {
         return [
             'id' => new UuidV4(),
             'name' => self::faker()->text(),
         ];
     }
+
+    protected function initialize(): self
+    {
+        return $this;
+    }
+
+    protected static function getClass(): string
+    {
+        return Ingredient::class;
+    }
 }
diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php
index 264486d1..975c10be 100644
--- a/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php
+++ b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php
@@ -13,19 +13,31 @@ namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
 
 use Doctrine\ORM\EntityRepository;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
-use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\ModelFactory;
+use Zenstruck\Foundry\Proxy;
+use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @extends PersistentObjectFactory<Product>
+ * @extends ModelFactory<Product>
+ *
+ * @method static Product|Proxy                    createOne(array $attributes = [])
+ * @method static Product[]|Proxy[]                createMany(int $number, array|callable $attributes = [])
+ * @method static Product|Proxy                    find(object|array|mixed $criteria)
+ * @method static Product|Proxy                    findOrCreate(array $attributes)
+ * @method static Product|Proxy                    first(string $sortedField = 'id')
+ * @method static Product|Proxy                    last(string $sortedField = 'id')
+ * @method static Product|Proxy                    random(array $attributes = [])
+ * @method static Product|Proxy                    randomOrCreate(array $attributes = []))
+ * @method static Product[]|Proxy[]                all()
+ * @method static Product[]|Proxy[]                findBy(array $attributes)
+ * @method static Product[]|Proxy[]                randomSet(int $number, array $attributes = []))
+ * @method static Product[]|Proxy[]                randomRange(int $min, int $max, array $attributes = []))
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method        Product|Proxy                    create(array|callable $attributes = [])
  */
-final class ProductFactory extends PersistentObjectFactory
+final class ProductFactory extends ModelFactory
 {
-    public static function class(): string
-    {
-        return Product::class;
-    }
-
-    protected function defaults(): array
+    protected function getDefaults(): array
     {
         return [
             'name' => self::faker()->text(),
@@ -35,8 +47,18 @@ final class ProductFactory extends PersistentObjectFactory
         ];
     }
 
+    protected function initialize(): self
+    {
+        return $this;
+    }
+
     public function disable(): self
     {
-        return $this->with(['isEnabled' => false]);
+        return $this->addState(['isEnabled' => false]);
+    }
+
+    protected static function getClass(): string
+    {
+        return Product::class;
     }
 }
diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php
index 424f76f5..868d61fc 100644
--- a/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php
+++ b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php
@@ -14,24 +14,43 @@ namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory;
 use Doctrine\ORM\EntityRepository;
 use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\ProductTag;
 use Zenstruck\Foundry\ModelFactory;
-use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
 use Zenstruck\Foundry\Proxy;
 use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @extends PersistentObjectFactory<ProductTag>
+ * @extends ModelFactory<ProductTag>
+ *
+ * @method static ProductTag|Proxy                createOne(array $attributes = [])
+ * @method static ProductTag[]|Proxy[]            createMany(int $number, array|callable $attributes = [])
+ * @method static ProductTag|Proxy                find(object|array|mixed $criteria)
+ * @method static ProductTag|Proxy                findOrCreate(array $attributes)
+ * @method static ProductTag|Proxy                first(string $sortedField = 'id')
+ * @method static ProductTag|Proxy                last(string $sortedField = 'id')
+ * @method static ProductTag|Proxy                random(array $attributes = [])
+ * @method static ProductTag|Proxy                randomOrCreate(array $attributes = [])
+ * @method static ProductTag[]|Proxy[]            all()
+ * @method static ProductTag[]|Proxy[]            findBy(array $attributes)
+ * @method static ProductTag[]|Proxy[]            randomSet(int $number, array $attributes = [])
+ * @method static ProductTag[]|Proxy[]            randomRange(int $min, int $max, array $attributes = [])
+ * @method static EntityRepository|RepositoryProxy repository()
+ * @method        ProductTag|Proxy                create(array|callable $attributes = [])
  */
-final class ProductTagFactory extends PersistentObjectFactory
+final class ProductTagFactory extends ModelFactory
 {
-    public static function class(): string
-    {
-        return ProductTag::class;
-    }
-
-    protected function defaults(): array
+    protected function getDefaults(): array
     {
         return [
             'name' => self::faker()->word(),
         ];
     }
+
+    protected function initialize(): self
+    {
+        return $this;
+    }
+
+    protected static function getClass(): string
+    {
+        return ProductTag::class;
+    }
 }
diff --git a/src/Autocomplete/tests/Fixtures/Kernel.php b/src/Autocomplete/tests/Fixtures/Kernel.php
index b8439ae4..7fc8adcf 100644
--- a/src/Autocomplete/tests/Fixtures/Kernel.php
+++ b/src/Autocomplete/tests/Fixtures/Kernel.php
@@ -101,7 +101,9 @@ final class Kernel extends BaseKernel
             'default_path' => '%kernel.project_dir%/tests/Fixtures/templates',
         ]);
 
-        $c->extension('zenstruck_foundry', []);
+        $c->extension('zenstruck_foundry', [
+            'auto_refresh_proxies' => false,
+        ]);
 
         $doctrineConfig = [
             'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'],
@@ -153,6 +155,10 @@ final class Kernel extends BaseKernel
             ],
         ]);
 
+        $c->extension('zenstruck_foundry', [
+            'auto_refresh_proxies' => false,
+        ]);
+
         $services = $c->services();
         $services
             ->defaults()
diff --git a/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php
index 0d6a73df..40bed6f7 100644
--- a/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php
+++ b/src/Autocomplete/tests/Integration/Doctrine/EntityMetadataTest.php
@@ -43,7 +43,7 @@ class EntityMetadataTest extends KernelTestCase
     public function testGetIdValue(): void
     {
         $product = ProductFactory::createOne();
-        $this->assertEquals($product->getId(), $this->getMetadata()->getIdValue($product));
+        $this->assertEquals($product->getId(), $this->getMetadata()->getIdValue($product->object()));
     }
 
     public function testGetPropertyDataType(): void
diff --git a/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php b/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php
index 90dd33b3..e10560fc 100644
--- a/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php
+++ b/src/Autocomplete/tests/Integration/Doctrine/EntitySearchUtilTest.php
@@ -34,7 +34,7 @@ class EntitySearchUtilTest extends KernelTestCase
         $prod4 = ProductFactory::createOne(['description' => 'all about prod 4']);
 
         $results = $this->callAddSearchClass('prod');
-        $this->assertSame([$prod1, $prod2, $prod4], $results);
+        $this->assertSame([$prod1->object(), $prod2->object(), $prod4->object()], $results);
     }
 
     public function testItSearchesOnCorrectFields(): void
@@ -43,7 +43,7 @@ class EntitySearchUtilTest extends KernelTestCase
         ProductFactory::createOne(['description' => 'foo prod2']);
 
         $results = $this->callAddSearchClass('prod', ['name']);
-        $this->assertSame([$prod1], $results);
+        $this->assertSame([$prod1->object()], $results);
     }
 
     public function testItCanSearchOnRelationFields(): void
@@ -55,7 +55,7 @@ class EntitySearchUtilTest extends KernelTestCase
         ProductFactory::createOne(['name' => 'puzzle', 'category' => $category2]);
 
         $results = $this->callAddSearchClass('food', ['name', 'category.name']);
-        $this->assertSame([$prod1, $prod2], $results);
+        $this->assertSame([$prod1->object(), $prod2->object()], $results);
     }
 
     /**
diff --git a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
index cbaaaf5a..d2c0ee07 100644
--- a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
+++ b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
@@ -4,34 +4,105 @@ import getModelBinding from '../../src/Directive/get_model_binding';
 describe('get_model_binding', () => {
     it('returns correctly with simple directive', () => {
         const directive = parseDirectives('firstName')[0];
+
         expect(getModelBinding(directive)).toEqual({
             modelName: 'firstName',
             innerModelName: null,
             shouldRender: true,
             debounce: false,
             targetEventName: null,
+            minLength: null,
+            maxLength: null,
+            minValue: null,
+            maxValue: null,
         });
     });
 
     it('returns all modifiers correctly', () => {
         const directive = parseDirectives('on(change)|norender|debounce(100)|firstName')[0];
+
         expect(getModelBinding(directive)).toEqual({
             modelName: 'firstName',
             innerModelName: null,
             shouldRender: false,
             debounce: 100,
             targetEventName: 'change',
+            minLength: null,
+            maxLength: null,
+            minValue: null,
+            maxValue: null,
         });
     });
 
     it('parses the parent:inner model name correctly', () => {
         const directive = parseDirectives('firstName:first')[0];
+
         expect(getModelBinding(directive)).toEqual({
             modelName: 'firstName',
             innerModelName: 'first',
             shouldRender: true,
             debounce: false,
             targetEventName: null,
+            minLength: null,
+            maxLength: null,
+            minValue: null,
+            maxValue: null,
+        });
+    });
+
+    it('parses min_length and max_length modifiers', () => {
+        const directive = parseDirectives('min_length(3)|max_length(20)|username')[0];
+
+        expect(getModelBinding(directive)).toEqual({
+            modelName: 'username',
+            innerModelName: null,
+            shouldRender: true,
+            debounce: false,
+            targetEventName: null,
+            minLength: 3,
+            maxLength: 20,
+            minValue: null,
+            maxValue: null,
+        });
+    });
+
+    it('parses min_value and max_value modifiers', () => {
+        const directive = parseDirectives('min_value(18)|max_value(65)|age')[0];
+
+        expect(getModelBinding(directive)).toEqual({
+            modelName: 'age',
+            innerModelName: null,
+            shouldRender: true,
+            debounce: false,
+            targetEventName: null,
+            minLength: null,
+            maxLength: null,
+            minValue: 18,
+            maxValue: 65,
+        });
+    });
+
+    it('handles mixed modifiers correctly', () => {
+        const directive = parseDirectives('on(change)|norender|debounce(100)|min_value(18)|max_value(65)|age:years')[0];
+
+        expect(getModelBinding(directive)).toEqual({
+            modelName: 'age',
+            innerModelName: 'years',
+            shouldRender: false,
+            debounce: 100,
+            targetEventName: 'change',
+            minLength: null,
+            maxLength: null,
+            minValue: 18,
+            maxValue: 65,
         });
     });
+
+    it('handles empty modifier values gracefully', () => {
+        const directive = parseDirectives('min_length|max_length|username')[0];
+        const binding = getModelBinding(directive);
+
+        expect(binding.minLength).toBeNull();
+        expect(binding.maxLength).toBeNull();
+    });
 });
diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts
index 2aef9dcd..63756d9c 100644
--- a/src/LiveComponent/assets/test/controller/model.test.ts
+++ b/src/LiveComponent/assets/test/controller/model.test.ts
@@ -25,7 +25,7 @@ describe('LiveController data-model Tests', () => {
                     data-model="name"
                     value="${data.name}"
                 >
-                
+
                 Name is: ${data.name}
             </div>
         `
@@ -56,7 +56,7 @@ describe('LiveController data-model Tests', () => {
                     data-model="norender|name"
                     value="${data.name}"
                 >
-                
+
                 Name is: ${data.name}
             </div>
         `
@@ -83,7 +83,7 @@ describe('LiveController data-model Tests', () => {
                     data-model="on(change)|name"
                     value="${data.name}"
                 >
-                
+
                 Name is: ${data.name}
                 <button>Do nothing</button>
             </div>
@@ -127,7 +127,7 @@ describe('LiveController data-model Tests', () => {
                     data-model="name"
                     data-value="Dan"
                 ><span>Change name to Dan</span></a>
-                
+
                 Name is: ${data.name}
             </div>
         `
@@ -159,7 +159,7 @@ describe('LiveController data-model Tests', () => {
                         value="${data.color}"
                     >
                 </form>
-                
+
                 Favorite color: ${data.color}
             </div>
         `
@@ -184,7 +184,7 @@ describe('LiveController data-model Tests', () => {
                         data-model="firstName"
                     >
                 </form>
-                
+
                 First name: ${data.firstName}
             </div>
         `
@@ -209,7 +209,7 @@ describe('LiveController data-model Tests', () => {
                     data-model="sport"
                     data-value="cross country"
                 >
-                
+
                 Sport: ${data.sport}
             </div>
         `
@@ -232,7 +232,7 @@ describe('LiveController data-model Tests', () => {
                 <input
                     data-model="user[name]"
                 >
-                
+
                 Name: ${data.user.name}
             </div>
         `
@@ -287,7 +287,7 @@ describe('LiveController data-model Tests', () => {
                         Checkbox 2: <input type="checkbox" name="form[check2]" value="1" ${data.form.check2 ? 'checked' : ''} />
                     </label>
                 </form>
-                
+
                 Checkbox 2 is ${data.form.check2 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -326,7 +326,7 @@ describe('LiveController data-model Tests', () => {
                         Checkbox 2: <input type="checkbox" name="form[check2]" value="1" ${data.form.check2 ? 'checked' : ''} />
                     </label>
                 </form>
-                
+
                 Checkbox 1 is ${data.form.check1 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -359,7 +359,7 @@ describe('LiveController data-model Tests', () => {
                         Checkbox 2: <input type="checkbox" name="form[check][]" value="bar" ${data.form.check.indexOf('bar') > -1 ? 'checked' : ''} />
                     </label>
                 </form>
-                
+
                 Checkbox 2 is ${data.form.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -393,7 +393,7 @@ describe('LiveController data-model Tests', () => {
                         Checkbox 2: <input type="checkbox" name="check[]" value="bar" ${data.check.indexOf('bar') > -1 ? 'checked' : ''} />
                     </label>
                 </form>
-                
+
                 Checkbox 2 is ${data.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -427,7 +427,7 @@ describe('LiveController data-model Tests', () => {
                         Checkbox 2: <input type="checkbox" name="form[check][]" value="bar" ${data.form.check.indexOf('bar') > -1 ? 'checked' : ''} />
                     </label>
                 </form>
-                
+
                 Checkbox 1 is ${data.form.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -459,7 +459,7 @@ describe('LiveController data-model Tests', () => {
                 <label>
                     Checkbox 2: <input type="checkbox" data-model="check[]" value="bar" ${data.check.indexOf('bar') > -1 ? 'checked' : ''} />
                 </label>
-                
+
                 Checkbox 1 is ${data.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'}
             </div>
         `
@@ -493,7 +493,7 @@ describe('LiveController data-model Tests', () => {
                         </select>
                     </label>
                 </form>
-                
+
                 Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'}
             </div>
         `
@@ -525,7 +525,7 @@ describe('LiveController data-model Tests', () => {
                         </select>
                     </label>
                 </form>
-                
+
                 Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'}
             </div>
         `
@@ -976,4 +976,164 @@ describe('LiveController data-model Tests', () => {
         expect(test.element).toHaveTextContent('Food is: Popcorn');
         expect(test.element).toHaveTextContent('Rating is: 5');
     });
+
+    it('does not update model if input value length is less than min_length', async () => {
+        const test = await createTest(
+            { username: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_length(3)|username" value="${data.username}" />
+                Username: ${data.username}
+            </div>
+        `
+        );
+
+        await userEvent.type(test.queryByDataModel('username'), 'ab');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+    });
+
+    it('updates model if input value length satisfies min_length', async () => {
+        const test = await createTest(
+            { username: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_length(3)|username" value="${data.username}" />
+                Username: ${data.username}
+            </div>
+        `
+        );
+
+        test.expectsAjaxCall().expectUpdatedData({ username: 'abc' });
+
+        await userEvent.type(test.queryByDataModel('username'), 'abc');
+        await waitFor(() => expect(test.element).toHaveTextContent('Username: abc'));
+    });
+
+    it('does not update model if input value length exceeds max_length', async () => {
+        const test = await createTest(
+            { username: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="max_length(5)|username" value="${data.username}" />
+                Username: ${data.username}
+            </div>
+        `
+        );
+
+        await userEvent.type(test.queryByDataModel('username'), 'abcdef');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+    });
+
+    it('updates model if number input value is within min_value/max_value range', async () => {
+        const test = await createTest(
+            { age: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_value(18)|max_value(65)|age" type="number" />
+                Age: ${data.age}
+            </div>
+        `
+        );
+
+        test.expectsAjaxCall().expectUpdatedData({ age: '30' });
+
+        const input = test.queryByDataModel('age');
+        await userEvent.clear(input);
+        await userEvent.type(input, '30');
+
+        await waitFor(() => expect(test.element).toHaveTextContent('Age: 30'));
+    });
+
+    it('does not update model if number input value is less than min_value', async () => {
+        const test = await createTest(
+            { age: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_value(18)|age" type="number" />
+                Age: ${data.age}
+            </div>
+        `
+        );
+
+        const input = test.queryByDataModel('age');
+        await userEvent.clear(input);
+        await userEvent.type(input, '15');
+
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+    });
+
+    it('does not update model if number input value exceeds max_value', async () => {
+        const test = await createTest(
+            { age: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="max_value(65)|age" type="number" />
+                Age: ${data.age}
+            </div>
+        `
+        );
+
+        const input = test.queryByDataModel('age');
+        await userEvent.clear(input);
+        await userEvent.type(input, '70');
+
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+    });
+
+    it('does not update model if value is shorter than min_length or longer than max_length', async () => {
+        const test = await createTest(
+            { username: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_length(3)|max_length(5)|username" value="${data.username}" />
+                Username: ${data.username}
+            </div>
+        `
+        );
+
+        // too short
+        await userEvent.type(test.queryByDataModel('username'), 'ab');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+
+        // too long
+        await userEvent.clear(test.queryByDataModel('username'));
+        await userEvent.type(test.queryByDataModel('username'), 'abcdef');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+
+        // valid
+        test.expectsAjaxCall().expectUpdatedData({ username: 'abc' });
+        await userEvent.clear(test.queryByDataModel('username'));
+        await userEvent.type(test.queryByDataModel('username'), 'abc');
+        await waitFor(() => expect(test.element).toHaveTextContent('Username: abc'));
+    });
+
+    it('does not update model if number is less than min_value or greater than max_value', async () => {
+        const test = await createTest(
+            { age: '' },
+            (data: any) => `
+            <div ${initComponent(data)}>
+                <input data-model="min_value(18)|max_value(65)|age" type="number" />
+                Age: ${data.age}
+            </div>
+        `
+        );
+
+        const input = test.queryByDataModel('age');
+
+        // too low
+        await userEvent.clear(input);
+        await userEvent.type(input, '17');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+
+        // too high
+        await userEvent.clear(input);
+        await userEvent.type(input, '70');
+        expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+
+        // valid
+        test.expectsAjaxCall().expectUpdatedData({ age: '30' });
+        await userEvent.clear(input);
+        await userEvent.type(input, '30');
+        await waitFor(() => expect(test.element).toHaveTextContent('Age: 30'));
+    });
 });
diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts
index a1b0a17d..0e656d5f 100644
--- a/src/LiveComponent/assets/test/dom_utils.test.ts
+++ b/src/LiveComponent/assets/test/dom_utils.test.ts
@@ -7,6 +7,9 @@ import {
     getModelDirectiveFromElement,
     getValueFromElement,
     htmlToElement,
+    isNumericalInputElement,
+    isTextareaElement,
+    isTextualInputElement,
     setValueOnElement,
 } from '../src/dom_utils';
 import { noopElementDriver } from './tools';
@@ -324,3 +327,92 @@ describe('cloneHTMLElement', () => {
         expect(clone.outerHTML).toEqual('<div class="foo"></div>');
     });
 });
+
+describe('isTextualInputElement', () => {
+    describe.each([
+        ['text', true],
+        ['email', true],
+        ['password', true],
+        ['search', true],
+        ['tel', true],
+        ['url', true],
+        ['number', false],
+        ['range', false],
+        ['file', false],
+        ['date', false],
+        ['checkbox', false],
+        ['radio', false],
+        ['submit', false],
+        ['reset', false],
+        ['color', false],
+        ['datetime-local', false],
+        ['hidden', false],
+        ['image', false],
+        ['month', false],
+        ['time', false],
+        ['week', false],
+    ])('input[type="%s"] should return %s', (type, expected) => {
+        it(`returns ${expected}`, () => {
+            const input = document.createElement('input');
+            if (typeof type === 'string') {
+                input.type = type;
+            }
+            expect(isTextualInputElement(input)).toBe(expected);
+        });
+    });
+
+    it('returns false for <textarea>', () => {
+        const textarea = document.createElement('textarea');
+        expect(isTextualInputElement(textarea)).toBe(false);
+    });
+
+    it('returns false for non-input elements', () => {
+        const div = document.createElement('div');
+        expect(isTextualInputElement(div)).toBe(false);
+    });
+});
+
+describe('isTextareaElement', () => {
+    it('returns true for <textarea>', () => {
+        const textarea = document.createElement('textarea');
+        expect(isTextareaElement(textarea)).toBe(true);
+    });
+
+    it('returns false for <input>', () => {
+        const input = document.createElement('input');
+        input.type = 'text';
+        expect(isTextareaElement(input)).toBe(false);
+    });
+
+    it('returns false for other elements', () => {
+        const span = document.createElement('span');
+        expect(isTextareaElement(span)).toBe(false);
+    });
+});
+
+describe('isNumericalInputElement', () => {
+    describe.each([
+        ['number', true],
+        ['range', true],
+        ['text', false],
+        ['email', false],
+        ['checkbox', false],
+        ['submit', false],
+    ])('input[type="%s"] should return %s', (type, expected) => {
+        it(`returns ${expected}`, () => {
+            const input = document.createElement('input');
+            if (typeof type === 'string') {
+                input.type = type;
+            }
+            expect(isNumericalInputElement(input)).toBe(expected);
+        });
+    });
+
+    it('returns false for non-input elements', () => {
+        const div = document.createElement('div');
+        expect(isNumericalInputElement(div)).toBe(false);
+
+        const textarea = document.createElement('textarea');
+        expect(isNumericalInputElement(textarea)).toBe(false);
+    });
+});
diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php
deleted file mode 100644
index 0e7d53b2..00000000
--- a/src/LiveComponent/tests/Fixtures/Component/FormWithUserInterfaceComponent.php
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
-
-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Symfony\Component\Form\FormInterface;
-use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
-use Symfony\UX\LiveComponent\Attribute\LiveProp;
-use Symfony\UX\LiveComponent\ComponentWithFormTrait;
-use Symfony\UX\LiveComponent\DefaultActionTrait;
-use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
-use Symfony\UX\LiveComponent\Tests\Fixtures\Form\UserFormType;
-
-#[AsLiveComponent('form_with_user_interface', template: 'components/form_with_user_interface.html.twig')]
-class FormWithUserInterfaceComponent extends AbstractController
-{
-    use ComponentWithFormTrait;
-    use DefaultActionTrait;
-
-    #[LiveProp]
-    public User $user;
-
-    protected function instantiateForm(): FormInterface
-    {
-        return $this->createForm(UserFormType::class, $this->user);
-    }
-}
diff --git a/src/LiveComponent/tests/Fixtures/Entity/User.php b/src/LiveComponent/tests/Fixtures/Entity/User.php
deleted file mode 100644
index 499559a3..00000000
--- a/src/LiveComponent/tests/Fixtures/Entity/User.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\LiveComponent\Tests\Fixtures\Entity;
-
-use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\Security\Core\User\UserInterface;
-
-#[ORM\Entity]
-class User implements UserInterface
-{
-    #[ORM\Id]
-    #[ORM\GeneratedValue]
-    #[ORM\Column(type: 'integer')]
-    public $id;
-
-    #[ORM\Column(type: 'string', length: 180, unique: true)]
-    public $username;
-
-    public function getRoles(): array
-    {
-        return ['ROLE_USER'];
-    }
-
-    public function eraseCredentials(): void
-    {
-        // no-op
-    }
-
-    public function getUsername(): string
-    {
-        return $this->getUserIdentifier();
-    }
-
-    public function getUserIdentifier(): string
-    {
-        return $this->username;
-    }
-
-    public function getPassword(): ?string
-    {
-        return null;
-    }
-
-    public function getSalt(): ?string
-    {
-        return null;
-    }
-}
diff --git a/src/LiveComponent/tests/Fixtures/Form/UserFormType.php b/src/LiveComponent/tests/Fixtures/Form/UserFormType.php
deleted file mode 100644
index 99bd456d..00000000
--- a/src/LiveComponent/tests/Fixtures/Form/UserFormType.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form;
-
-use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\Extension\Core\Type\TextType;
-use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
-
-class UserFormType extends AbstractType
-{
-    public function buildForm(FormBuilderInterface $builder, array $options)
-    {
-        $builder
-            ->add('username', TextType::class)
-        ;
-    }
-
-    public function configureOptions(OptionsResolver $resolver)
-    {
-        $resolver->setDefaults([
-            'data_class' => User::class,
-        ]);
-    }
-}
diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php
index 9b39acb9..4b0cd90d 100644
--- a/src/LiveComponent/tests/Fixtures/Kernel.php
+++ b/src/LiveComponent/tests/Fixtures/Kernel.php
@@ -143,7 +143,9 @@ final class Kernel extends BaseKernel
             'anonymous_template_directory' => 'components/',
         ]);
 
-        $c->extension('zenstruck_foundry', []);
+        $c->extension('zenstruck_foundry', [
+            'auto_refresh_proxies' => false,
+        ]);
 
         $doctrineConfig = [
             'dbal' => [
diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig
deleted file mode 100644
index 32dce1b7..00000000
--- a/src/LiveComponent/tests/Fixtures/templates/components/form_with_user_interface.html.twig
+++ /dev/null
@@ -1,3 +0,0 @@
-<div{{ attributes }}>
-    {{ form(form) }}
-</div>
diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
index 9b527ab3..e0d72870 100644
--- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
+++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
@@ -15,7 +15,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
 use Symfony\Component\DomCrawler\Crawler;
 use Symfony\Component\Form\FormFactoryInterface;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent;
-use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType;
 use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
@@ -23,9 +22,6 @@ use Zenstruck\Browser\Test\HasBrowser;
 use Zenstruck\Foundry\Test\Factories;
 use Zenstruck\Foundry\Test\ResetDatabase;
 
-use function Zenstruck\Foundry\Persistence\persist;
-use function Zenstruck\Foundry\Persistence\refresh;
-
 /**
  * @author Jakub Caban <kuba.iluvatar@gmail.com>
  */
@@ -454,38 +450,4 @@ class ComponentWithFormTest extends KernelTestCase
             ->assertElementAttributeContains('form', 'data-model', 'on(change)|*')
         ;
     }
-
-    public function testFormWithLivePropContainingAnEntityImplementingAnInterface(): void
-    {
-        $user = persist(User::class, ['username' => 'Fabien']);
-        self::assertInstanceOf(User::class, $user);
-        self::assertEquals(1, $user->id);
-        self::assertEquals('Fabien', $user->username);
-
-        $mounted = $this->mountComponent('form_with_user_interface', [
-            'user' => $user,
-        ]);
-
-        $dehydrated = $this->dehydrateComponent($mounted)->getProps();
-
-        $this->browser()
-            ->post('/_components/form_with_user_interface', [
-                'body' => [
-                    'data' => json_encode([
-                        'props' => $dehydrated,
-                        'updated' => [
-                            'user_form.username' => 'Nicolas',
-                            'validatedFields' => ['user_form.username'],
-                        ],
-                    ]),
-                ],
-            ])
-            ->assertStatus(200)
-            ->assertElementAttributeContains('form', 'data-model', 'on(change)|*')
-        ;
-
-        refresh($user);
-        self::assertEquals(1, $user->id);
-        self::assertEquals('Nicolas', $user->username);
-    }
 }
diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
index 3dacba5d..9df33c46 100644
--- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
+++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php
@@ -37,7 +37,6 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Embeddable1;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity2;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\ProductFixtureEntity;
-use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\EmptyStringEnum;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\IntEnum;
 use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\StringEnum;
@@ -337,27 +336,6 @@ final class LiveComponentHydratorTest extends KernelTestCase
             ;
         }];
 
-        yield 'Persisted entity: (de)hydration works correctly to/from id, when the entity implements an interface' => [function () {
-            $user = persist(User::class, [
-                'username' => 'Fabien',
-            ]);
-            \assert($user instanceof User);
-
-            return HydrationTest::create(new class {
-                #[LiveProp]
-                public User $user;
-            })
-                ->mountWith(['user' => $user])
-                ->assertDehydratesTo(['user' => $user->id])
-                ->assertObjectAfterHydration(function (object $object) use ($user) {
-                    self::assertSame(
-                        $user->id,
-                        $object->user->id
-                    );
-                })
-            ;
-        }];
-
         yield 'Persisted entity: writable CAN be changed via id' => [function () {
             $entityOriginal = persist(Entity1::class);
             $entityNext = persist(Entity1::class);