Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Data Persister: Encoding the Plain Password

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When an API client makes a POST request to /api/users, we need to be able to run some code after API Platform deserializes the JSON into a User object, but before it gets saved to Doctrine. That code will encode the plainPassword and set it on the password property.

Introducing Data Persisters

How can we do that? One great answer is a custom "data persister". OooooOOOo. API Platform comes with only one data persister out-of-the-box, at least, only one that we care about for now: the Doctrine data persister. After deserializing the data into a User object, running security checks and executing validation, API Platform finally says:

It's time to save this resource!

To figure out how to save the object, it loops over all of its data persisters... so... really... just one at this point... and asks:

Hi data persister! Do you know how to "save" this object?

Because our two API resources - User and CheeseListing are both Doctrine entities, the Doctrine data persister says:

Oh yea, I totally do know how to save that!

And then it happily calls persist() and flush() on the entity manager.

This... is awesome. Why? Because if you want to hook into the "saving" process... or if you ever create an API Resource class that is not stored in Doctrine, you can do that beautifully with a custom data persister.

Check it out: in the src/ directory - it doesn't matter where - but let's create a DataPersister/ directory with a new class inside: UserDataPersister.

This class will be responsible for "persisting" User objects. Make it implement DataPersisterInterface. You could also use ContextAwareDataPersisterInterface... which is the same, except that all 3 methods are passed the "context", in case you need the $context to help your logic.

... lines 1 - 4
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
class UserDataPersister implements DataPersisterInterface
... lines 9 - 22

Anyways I'll go to the Code -> Generate menu - or Command+N on a Mac - and select "Implement Methods" to generate the three methods this interface requires.

... lines 1 - 8
public function supports($data): bool
// TODO: Implement supports() method.
public function persist($data)
// TODO: Implement persist() method.
public function remove($data)
// TODO: Implement remove() method.
... lines 23 - 24

And... we're... ready! As soon as you create a class that implements DataPersisterInterface, API Platform will immediately start using that. This means that, whenever an object is saved - or removed - it will now call supports() on our data persister to see if we know how to handle it.

In our case, if data is a User object, we do support saving this object. Say that with: return $data instanceof User.

... lines 1 - 17
public function supports($data): bool
return $data instanceof User;
... lines 22 - 35

As soon as API Platform finds one data persister whose supports() returns true, it calls persist() on that data persister and does not call any other data persisters. The core "Doctrine" data persister we talked about earlier has a really low "priority" in this system and so its supports() method is always called last. That means that our custom data persister is now solely responsible for saving User objects, but the core Doctrine data persister will still handle all other Doctrine entities.

Saving in the Data Persister

Ok, forget about encoding the password for a minute. Now that our class is completely responsible for saving users... we need to... yea know... make sure we save the user! We need to call persist and flush on the entity manager.

Add public function __construct() with the EntityManagerInterface $entityManager argument to autowire that into our class. I'll hit my favorite Alt + Enter and select "Initialize fields" to create that property and set it.

... lines 1 - 6
use Doctrine\ORM\EntityManagerInterface;
... line 8
class UserDataPersister implements DataPersisterInterface
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
$this->entityManager = $entityManager;
... lines 17 - 33

Down in persist(), it's pretty simple: $this->entityManager->persist($data) and $this->entityManager->flush(). Data persisters are also called when an object is being deleted. In remove(), we need $this->entityManager->remove($data) and $this->entityManager->flush().

... lines 1 - 22
public function persist($data)
public function remove($data)
... lines 34 - 35

Congrats! We now have a data persister that... does exactly the same thing as the core Doctrine data persister! But... oh yea... now, we're dangerous. Now we can encode the plain password.

Encoding the Plain Password

To do that, we need to autowire the service responsible for encoding passwords. If you can't remember the right type-hint, find your terminal and run:

php bin/console debug:autowiring pass

And... there it is: UserPasswordEncoderInterface. Add the argument - UserPasswordEncoderInterface $userPasswordEncoder - hit "Alt + Enter" again and select "Initialize fields" to create that property and set it.

... lines 1 - 7
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
... line 9
class UserDataPersister implements DataPersisterInterface
... line 12
private $userPasswordEncoder;
... line 14
public function __construct(EntityManagerInterface $entityManager, UserPasswordEncoderInterface $userPasswordEncoder)
... line 17
$this->userPasswordEncoder = $userPasswordEncoder;
... lines 20 - 46

Now, down in persist(), we know that $data will always be an instance of User. ... because that's the only time our supports() method returns true. I'm going to add a little PHPdoc above this to help my editor.

Hey PhpStorm! $data is a User! Ok?,

... lines 1 - 5
use App\Entity\User;
... lines 7 - 9
class UserDataPersister implements DataPersisterInterface
... lines 12 - 25
* @param User $data
public function persist($data)
... lines 30 - 46

Let's think. This endpoint will be called both when creating a user, but also when it's being updated. And... when someone updates a User record, they may or may not send the plainPassword field in the PUT data. They would probably only send this if they wanted to update the password.

This means that the plainPassword field might be blank here. And if it is, we should do nothing. So, if $data->getPlainPassword(), then $data->setPassword() to $this->userPasswordEncoder->encodePassword() passing the User object - that's $data - and the plain password: $data->getPlainPassword().

That's it friends! Well, to be extra cool, let's call $data->eraseCredentials()... just to make sure the plain password doesn't stick around any longer than it needs to. Again, this is probably not needed because this field isn't saved to the database anyways... but it might avoid the plainPassword from being serialized to the session via the security system.

... lines 1 - 28
public function persist($data)
if ($data->getPlainPassword()) {
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
... lines 37 - 39
... lines 41 - 48

And... done! Aren't data persisters positively lovely?

Oh, well, we're not quite finished yet. The field in our API is still called plainPassword... but we wrote our test expecting that it would be called just password... which I kinda like better.

No problem. Inside User, find the plainPassword property and give it a new identity: @SerializedName("password").

... lines 1 - 13
use Symfony\Component\Serializer\Annotation\SerializedName;
... lines 15 - 36
class User implements UserInterface
... lines 39 - 78
... line 80
* @SerializedName("password")
private $plainPassword;
... lines 84 - 216

Let's check that on the docs... under the POST operation... perfect!

So... how can we see if this all works? Oh... I don't know... maybe we can run our awesome test!

php bin/phpunit --filter=testCreateUser

Above all the noise.. we got it!

Next, our validation rules around the plainPassword field... aren't quite right yet. And it's trickier than it looks at first: plainPassword should be required when creating a User, but not when updating it. Duh, duh, duh!

Leave a comment!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2