diff --git a/config/services.php b/config/services.php
index b620fde529..f986ec3b24 100644
--- a/config/services.php
+++ b/config/services.php
@@ -51,6 +51,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\LocaleConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\MoneyConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\NumberConfigurator;
+use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\PasswordConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\PercentConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\SlugConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\TelephoneConfigurator;
@@ -418,6 +419,7 @@
->arg(1, service('property_accessor'))
->set(NumberConfigurator::class)
+ ->set(PasswordConfigurator::class)
->arg(0, service(IntlFormatter::class))
->set(PercentConfigurator::class)
diff --git a/doc/fields.rst b/doc/fields.rst
index 7cb502cba4..58b22ba99d 100644
--- a/doc/fields.rst
+++ b/doc/fields.rst
@@ -711,6 +711,7 @@ These are all the built-in fields provided by EasyAdmin:
* :doc:`LocaleField `
* :doc:`MoneyField `
* :doc:`NumberField `
+* :doc:`PasswordField `
* :doc:`PercentField `
* :doc:`SlugField `
* :doc:`TelephoneField `
diff --git a/doc/fields/PasswordField.rst b/doc/fields/PasswordField.rst
new file mode 100644
index 0000000000..2116a385ae
--- /dev/null
+++ b/doc/fields/PasswordField.rst
@@ -0,0 +1,62 @@
+PasswordField
+=============
+
+This field is used to represent a user password in forms. On index and detail
+pages, the password value is hidden and displayed as a sequence of bullets for
+security purposes.
+
+Basic Information
+-----------------
+
+* **PHP Class**: ``EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField``
+* **Doctrine DBAL Type**: ``string``
+* **Symfony Form Type**: ``Symfony\Component\Form\Extension\Core\Type\PasswordType``
+* **Rendered as**: ````
+
+Usage
+-----
+
+Basic usage::
+
+ use EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField;
+
+ public function configureFields(string $pageName): iterable
+ {
+ return [
+ // ...
+ PasswordField::new('password', 'User password'),
+ ];
+ }
+
+Password Hashing
+----------------
+
+In most modern applications, plain text passwords should never be stored in the database.
+You can use the ``hashPassword()`` method to define a callable that processes the plain
+password submitted in the form before it is saved into the entity::
+
+ use EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField;
+ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+
+ class UserCrudController extends AbstractCrudController
+ {
+ public function __construct(
+ private UserPasswordHasherInterface $userPasswordHasher
+ ) {}
+
+ public function configureFields(string $pageName): iterable
+ {
+ $hashPassword = function ($plainPassword) {
+ // you can get the user entity from the current context or create a dummy one
+ // based on your Symfony configuration
+ return $this->userPasswordHasher->hashPassword($this->getUser(), $plainPassword);
+ };
+
+ return [
+ PasswordField::new('password')
+ ->hashPassword($hashPassword),
+ ];
+ }
+ }
+
+Alternatively, you could use Doctrine Entity Listeners to hash the password right before the entity is strictly persisted. Either way works natively with this field.
diff --git a/src/Field/Configurator/PasswordConfigurator.php b/src/Field/Configurator/PasswordConfigurator.php
new file mode 100644
index 0000000000..fe11d76281
--- /dev/null
+++ b/src/Field/Configurator/PasswordConfigurator.php
@@ -0,0 +1,50 @@
+getFieldFqcn();
+ }
+
+ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
+ {
+ $hashPasswordCallable = $field->getCustomOption(PasswordField::OPTION_HASH_PASSWORD);
+
+ if (null !== $hashPasswordCallable) {
+ $field->setFormTypeOption('mapped', false);
+
+ $builderDecorator = function (FormBuilderInterface $formBuilder) use ($hashPasswordCallable, $field) {
+ $formBuilder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($hashPasswordCallable, $field) {
+ $plainPassword = $event->getData();
+
+ if (null !== $plainPassword && '' !== $plainPassword) {
+ $hashedPassword = $hashPasswordCallable($plainPassword);
+
+ $entity = $event->getForm()->getParent()->getData();
+ $propertyName = $field->getProperty();
+
+ $propertyAccessor = \Symfony\Component\PropertyAccess\PropertyAccess::createPropertyAccessorBuilder()
+ ->enableExceptionOnInvalidIndex()
+ ->getPropertyAccessor();
+
+ $propertyAccessor->setValue($entity, $propertyName, $hashedPassword);
+ }
+ });
+ };
+
+ $field->setFormTypeOption('builder_callable', $builderDecorator);
+ }
+ }
+}
diff --git a/src/Field/PasswordField.php b/src/Field/PasswordField.php
new file mode 100644
index 0000000000..e2712880b7
--- /dev/null
+++ b/src/Field/PasswordField.php
@@ -0,0 +1,31 @@
+setProperty($propertyName)
+ ->setLabel($label)
+ ->setTemplateName('crud/field/password')
+ ->setFormType(PasswordType::class)
+ ->addCssClass('field-password');
+ }
+
+ public function hashPassword(callable $passwordHasher): self
+ {
+ $this->setCustomOption(self::OPTION_HASH_PASSWORD, $passwordHasher);
+
+ return $this;
+ }
+}
diff --git a/templates/crud/field/password.html.twig b/templates/crud/field/password.html.twig
new file mode 100644
index 0000000000..756ad6c651
--- /dev/null
+++ b/templates/crud/field/password.html.twig
@@ -0,0 +1,8 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
+{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
+{% if field.value is null %}
+ {{ 'label.empty'|trans(domain: 'EasyAdminBundle') }}
+{% else %}
+ ••••••
+{% endif %}
diff --git a/tests/Unit/Field/PasswordFieldTest.php b/tests/Unit/Field/PasswordFieldTest.php
new file mode 100644
index 0000000000..ef2cc44bb0
--- /dev/null
+++ b/tests/Unit/Field/PasswordFieldTest.php
@@ -0,0 +1,56 @@
+configurator = new PasswordConfigurator();
+ }
+
+ public function testDefaultOptions(): void
+ {
+ $field = PasswordField::new('foo');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(PasswordType::class, $fieldDto->getFormType());
+ self::assertStringContainsString('field-password', $fieldDto->getCssClass());
+ self::assertSame('crud/field/password', $fieldDto->getTemplateName());
+ self::assertNull($fieldDto->getCustomOption(PasswordField::OPTION_HASH_PASSWORD));
+ }
+
+ public function testFieldWithNullValue(): void
+ {
+ $field = PasswordField::new('foo');
+ $field->setValue(null);
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getValue());
+ }
+
+ public function testFieldWithStringValue(): void
+ {
+ $field = PasswordField::new('foo');
+ $field->setValue('my_secret_password');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('my_secret_password', $fieldDto->getValue());
+ }
+
+ public function testHashPasswordOption(): void
+ {
+ $hashFunction = static fn (string $password) => md5($password);
+ $field = PasswordField::new('foo')->hashPassword($hashFunction);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($hashFunction, $fieldDto->getCustomOption(PasswordField::OPTION_HASH_PASSWORD));
+ self::assertFalse($fieldDto->getFormTypeOption('mapped'));
+ self::assertNotNull($fieldDto->getFormTypeOption('builder_callable'));
+ }
+}