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)
{
$this->entityManager->persist($data);
$this->entityManager->flush();
}
public function remove($data)
{
$this->entityManager->remove($data);
$this->entityManager->flush();
}
... 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()) {
$data->setPassword(
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
);
$data->eraseCredentials();
}
... 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!

  • 2020-06-16 weaverryan

    Yaaaay! Yes, that validation auto-mapping is a double-edge sword! It's nice, but when it doesn't work right, it can be really hard to debug. I'm glad we got it :).

    Cheers!

  • 2020-06-14 Mario

    I've struggled with this for days and also the people in the symfony-dev slack couldn't help me. I dd'd in the data persister but it seemed that is was never called which made debugging quite hard. After days I came back here and the answer was the solution to my problem :). Thank you!

  • 2020-06-09 weaverryan

    Hey MrReasonable!

    Is it possible that you have validation auto-mapping enabled? That is where validation rules are "guessed" from Doctrine mapping information (and other sources) and this is a common problem it causes: it adds a NotNull constraints to your password field. If you do have it enabled, you'll need to add @Assert\DisableAutomapping() above the password field like we do here: https://symfonycasts.com/sc...

    Cheers!

  • 2020-06-09 MrReasonable

    I'm using api-platform/core 2.5.6 and symfony/framework-bundle 5.0. It appears that the Symfony validators are firing based on the ORM definition of user before the data persister is used. The error I get when I follow this tutorial is

    {
    "@context": "/api/contexts/ConstraintViolationList",
    "@type": "ConstraintViolationList",
    "hydra:title": "An error occurred",
    "hydra:description": "password: This value should not be null.",
    "violations": [
    {
    "propertyPath": "password",
    "message": "This value should not be null."
    }
    ]
    }

    The code in my User entity is
    /**
    * @Groups({"user:write"})
    * @ORM\Column(type="string")
    */
    private ?string $password;

    /**
    * @Groups({"user:write"})
    * @SerializedName("password")
    */
    private ?string $plainPassword;

    I have no further annotations on setPlainPassword or setPassword.

    Any ideas? Suggestions?

  • 2020-04-23 weaverryan

    Hey Annemieke Buijs!

    Haha, yes, time is a bit tighter these days with a 3 year old at home 7 days a week... so work gets done whenever I can manage ;).

    It sounds like you've "ruled out" my "security is the problem" idea... which didn't make complete sense anyways. But... I can't explain what's going on! What I DO know is this:

    A) On a PUT (edit), API Platform queries for your object. This puts it into the "identity map" of Doctrine's UnitOfWork. Unless you have some customization, the class that should be handling that is this one: https://github.com/api-plat... - I would put some dump & die statements in that class to make sure it IS being hit (and that something else isn't handling this).

    B) No, *here* is the mystery. Because of step (A), your object is in Doctrine's UnitOfWork's identity map, which means that it is aware that this object exists. Between step (A) and your data persister, something is *removing* that from the identity map. The only way I'm aware that this is possible is by something calling $entityManager->clear() or UnitOfWork::clear(). To test that theory, I would open up Doctrine's UnitOfWork.php file, find the clear() method, and throw a big giant exception at the beginning. Then try your PUT endpoint and see if it gets hit.

    But what's *still* troubling me is that I can't figure out why allowing the "normal" data persister would make any difference. It - https://github.com/api-plat... - is doing the SAME thing that you're doing. This makes me think that I'm *still* missing a detail... I just can't see what it is.

    By the way, if you're able to repeat this in a project that you can share, I'd be happy to take a look at it. It's very strange and you've got my interest :).

    Cheers!

  • 2020-04-20 Maboa Garaboa

    I am having the same problem, how did you solve it? Thanks

  • 2020-04-20 Annemieke Buijs

    Hi Ryan, working on sunday i see. You're crazy ! (So am i sometimes...)

    My answers to your questions (Phew)

    A). This behaviour also happens in another project, an update of data that has nothing todo with security. It's about saving data of an salesorder.

    B). The workaround works. Everything is saved with the PUT operation.

    C). That was one of the first things i tried. But then NOTHING is saved during the PUT operation.

    Thank you for your great help. You are the best !

    Cheers,
    Annemieke

  • 2020-04-19 weaverryan

    Hey Annemieke Buijs!

    Interesting problem :). I have a few question:

    A) Does this behavior *only* happen if you are changing the username? Like, if you send a PUT request that does *not* update the username (it updates some other field), does it work ok? Avoid the "roles" field - try updating some boring, non-security related field.

    B) Just to verify, you workaround... *works*, right? With this workaround, you are able to send a PUT request that changes the username... and it correctly updates?

    C) This should NOT make a difference, but just to be sure, if you removed the $this->entityManager->persist($data); line and tried the PUT request (which updates the username), what is the behavior? Does it work? Does it do nothing (no error, but it also doesn't update)?

    As you can probably tell from my questions, I don't know the cause, but I do have some clues. Overall, when you have a situation where an entity HAS an id, but Doctrine tries to INSERT it instead of updating, the reason is that this User object has (somehow) become "detached" from Doctrine's EntityManager. *Why* that his happening in your case is the mystery.

    I asked question (A) above because I think this could be related somehow to the security layer. By changing the username, your User object may suddenly look "changed" and the security system may be trying to log you out. That... does NOT explain why you are NOT getting logged out but your User object is detached, but if the username IS the only field that causes problems, then this must be related in some way. Are you using a custom user provider? Or just the normal Doctrine/entity one in security.yaml?

    Phew - let me know the answers and... maybe (?) I can help :D.

    Cheers!

  • 2020-04-18 Annemieke Buijs

    Hi everyone! Great tutorial.

    I've run into a weird problem.

    I have created the Custom Datapersister for user.
    But when i want to update a user's username, it tries to do a insert.
    When i do a dump of the $user->getId() it is there.
    When i do a dump of the operations name, it is a PUT.
    But still it does an insert of a user.

    This is my datapersister.
    I've 'solved' the problem by adding a check to the support method. It checks if it is a post. If it's not, go do the original datapersistence. And it works fine.
    But ofcourse I want to be able to do an update also in the UserDataPersister.

    This is my code.




    namespace App\DataPersister;

    use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
    use App\Entity\User;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

    class UserDataPersister implements ContextAwareDataPersisterInterface
    {
    private $entityManager;
    private $userPasswordEncoder;

    public function __construct(
    EntityManagerInterface $entityManager,
    UserPasswordEncoderInterface $userPasswordEncoder
    ) {
    $this->entityManager = $entityManager;
    $this->userPasswordEncoder = $userPasswordEncoder;
    }

    /**
    * Is the data supported by the persister?
    * @param $data
    * @param array $context
    * @return bool
    */
    public function supports($data, array $context = []): bool
    {
    $isPost = ($context['collection_operation_name'] ?? null) === 'post';
    if (! $isPost) {
    return false;
    }


    return $data instanceof User;
    }

    /**
    * Persists the data.
    *
    * @param User $data
    * @param array $context
    */
    public function persist($data, array $context = [])
    {
    if ($data->getPlainPassword()) {
    $data->setPassword(
    $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
    );
    $data->eraseCredentials();
    }

    $this->entityManager->persist($data);
    $this->entityManager->flush();
    }

    /**
    * Removes the data.
    * @param $data
    * @param array $context
    */
    public function remove($data, array $context = []): void
    {
    $this->entityManager->remove($data);
    $this->entityManager->flush();
    }
    }
  • 2020-04-13 Romain Soko

    Hey ! I finaly found that the problem come from a misktake that i hadn't seen, sorry for the inconvenience and thank you again.

  • 2020-04-10 Vladimir Sadicov

    Weird... of course you did die() or dumped with dd() hm and what about database? have you checked values inside database user table?

  • 2020-04-09 Romain Soko

    Hi Vladimir Sadicov !

    I've dumped inside the method and nothing comes up :/. During my test i just get a 401 cause i'm trying to loggin with a non encoded password in the database.

  • 2020-04-08 Vladimir Sadicov

    Hey Romain Soko

    Have you checked if support() method was called? and Does it returns right value for you persister?

    Cheers!

  • 2020-04-07 Romain Soko

    Hi symfony team !

    I'm having some problems with the custom data persisting. My password is never encoded since the data persister doesn't seem to be used (when I dump inside I don't get anything back).

    I've also checked the enability of autowiring and autoconfiguring but both are good.

    I've didn't found any issue about this so, have you any idea of what happens ?

    Thanks !

  • 2020-03-25 weaverryan

    Hey Sung Lee!

    I was also going to say "events", until I saw your message :). I'm much less familiar with the GraphQL support. However, I *do* think I can see one hook point, though it's a bit of an odd hook point. If you look at the workflow of the Mutation Resolver - https://api-platform.com/do... - you'll see one called "Security PostDenormalize". THAT resolver has access to the "previous object". So, in theory, if you decorate that resolver, you'll get it. It should be passed to you inside the $context variable as an extra_variables key, with another previous_object inside. To help, here is where the "core" calls that resolver: https://github.com/api-plat...

    I wouldn't normally want this type of logic to go into, what's "meant" to be a security layer, but it's the only hook point I can see, and I don't see any practical harm :). Not that this security resolver is ALSO called when you delete an object - so make sure to check for which situation you're in.

    Let me know if that helps!

    Cheers!

  • 2020-03-24 Sung Lee

    Diego Aguiar The problem of using the Event System is GraphQL is not supported. I wonder how I can achieve the goal with both REST and GraphQL supports.

  • 2020-03-24 Diego Aguiar

    Ohh I thought the same as you but I think I know what it doesn't work. At that point, ApiPlatform has already fetched for such object and modified it, so, when you try to fetch it again, Doctrine will detect that it's already on his data set and will return to you a reference to the same object. I'm not sure if I was clear enough, but in short, you are getting the same, modified instance from Doctrine. What we need to do is to get access to the object one layer before ApiPlatform modifies it. Give it a try using events
    I'll see if I can get Ryan's attention to this problem

  • 2020-03-24 Sung Lee

    I thought getting data before persist will return previous data. However, when I dump $data and $previous, it is same. Here is what I have in UserDataPersister class.


    public function persist($data, array $context = []) {
    ...
    // send email notification when a new User is updated
    if (($context['item_operation_name'] ?? null) === 'put') {
    $previous = $this->entityManager->getRepository(User::class)->findOneBy(['id' => $data->getId()]);
    $this->sendUserUpdateEmail($data, $previous);
    }

    $this->entityManager->persist($data);
    $this->entityManager->flush();
    }

    Is this how you get data before update? or is it even possible to get old data in DataPersister class?

  • 2020-03-23 Sung Lee

    Hi Diego Aguiar
    Thanks for your reply. I think the last option sounds right for this case, using EntityManager, and I have it in my data persister class right now.
    However, I am not sure how to get the value of previous object from EntityManger. How can I do that?

    Thank you!

  • 2020-03-23 Diego Aguiar

    Hey Sung Lee

    There is a way to access to the previous object when working with Voters, you can check it here https://symfonycasts.com/sc...
    But for what you actually want I'm not 100% sure. I think you can do it by using events https://api-platform.com/do...
    or maybe you can fetch for it manually if you inject the EntityManager to your custom data persister

    Cheers!

  • 2020-03-23 Sung Lee

    Hi Symfony team,

    I am trying to add more functions to this data persister class using ContextAwareDataPersisterInterface.
    What I am going to do are:
    1. when a new user is created, send email notification.
    2. when an existing user information is updated, send email notification what had been changed.

    I could do the first one in persist function like this:

    public function persist($data, array $context = []) {
    // ...
    // email notification
    if (
    ($context['collection_operation_name'] ?? null) === 'post' ||
    ($context['graphql_operation_name'] ?? null) === 'create'
    ) {
    $this->sendWelcomeEmail($data);
    }

    $this->entityManager->persist($data);
    $this->entityManager->flush();
    }

    However, when I tried to do the second one, to know what had been updated, I cannot find where to see the old data. For example, a user updates email address from "old_email@test.com" to "new_email@test.com", the system sends email content like this: "You updated email from old_email@test.com to new_email@test.com."

    Is it possible to access old data from data persister class?

    Thank you!

  • 2020-02-26 weaverryan

    Hey Lydie!

    Ah, nice work! I bet that was it - this auto-mapping feature changed a little bit in later version - it's now more "opt in" and less automatic.

    Keep up the good work!

  • 2020-02-25 Lydie

    Hi weaverryan !

    Sorry for my late reply. After reading your message, I have seen that for this project, I still had the symfony v4.3. I so decided to upgrade to v4.4 (following the tutorial "Upgrading & What's New in Symfony 5!". This has solved my issue :) I would guess the issue i had was related to the auto-mapping feature (I remember that I had issue with it before). Next step will be the upgrade to 5 :)

    Thx!

  • 2020-02-20 weaverryan

    Hi Lydie!

    Hmmm! What is the *exact* error that you get - like the *exact* wording? I'm also pretty sure this is coming from validation - but the *exact* error is important to match where it's coming from. And, you said that you *can* create this object with a form without any problems? And you only get the errors through ApiPlatform? If that's the case... then validation should be affecting *both* of them equally... unless you have some custom setup. Do you have any validation constraints added to these fields? Or the form? Or any special config on your form that could be causing the validation to be different in the form versus the API?

    Something is going on here... but it's still "fuzzy" to me - I need some more details. Without any special setup, your object should be validated exactly the same whether it's submitted through a form or through API Platform. The createdAt and updatedAt fields should not need any validation constraints on them, because they're not set directly. And I'm assuming that you have correctly *not* added any constraints. In that case, these errors are not coming from the validation layer (somehow) OR you have validation auto-mapping enabled - https://symfonycasts.com/sc... - which is causing these fields to have these constraints automatically added. But, again, if that were true, we should see this same problem when submitting a form 🤔

    Cheers!

  • 2020-02-20 Lydie

    Hello!

    I use TimestampableEntity (Gedmo\Timestampable\Traits\) for the createAt and updatedAt field. When creating an element with a form, those fields are automatically populated. But when sending a post request via the API platform, I got the error: "createdAt: this field can not be empty". Same for the updatedAt field. I so tried to set them via a data persister but it looks like it is called after the validation?

  • 2020-01-28 Diego Aguiar

    Hey Roberto Rielo

    Could you tell me what error were you having? I'm not sure why the canonical fields may cause a problem here

    Cheers!

  • 2020-01-26 Roberto Rielo

    Hey, I'm just wondering if it would be good to use the default UserManager from FOSUserBundle. Besides hashing the password, it also works with the canonical fields(username and email). I had to implement that solution since I was getting a weird error when following this tutorial about the resource IRI.

  • 2020-01-20 weaverryan

    Hey Tobias Ingold!

    Sorry for my slow reply. And, really awesome question :).

    > so I would assume that just validating that the checkbox is ticked in the frontend and setting a date in the backend, say in a data persister, is a bad idea

    Yes, legally, I think this might not be as "solid". But I'm not sure about this.

    Your solution looks good - but, as you mentioned, it feels weird because you don't *really* need that agreedTerms property... except to power this feature. However, typically it's a best practice to store the agreedToTermsDate - the date when the terms were agreed. So I think you could do something like:


    /**
    * @Assert\NotNull(groups={"Register"}, message="Please agree to the terms")
    **/
    private $agreedTermsDate;

    /**
    * @Groups("user:write")
    */
    public function setAgreedTerms(book $agreed)
    {
    if (true === $agreed) {
    $this->agreedTermsDate = new \DateTimeImmutable();
    }
    }

    The idea is that the setAgreedTerms will cause a write-only boolean field to be added called agreedTerms. But when this is set with a true value, it sets the date field. If the date field is *not* set, we would know that the agreedTerms field was not sent with a "true" value.

    Let me know if this helps!

    Cheers!

  • 2020-01-11 Tobias Ingold

    Hey there
    In the Symfony 4 Forms course we looked at the "Agree to terms" checkbox (that is required by law, I think). How would we go about implementing this in our API?
    I'm not too familiar with javascript and other frontend technologies, so I would assume that just validating that the checkbox is ticked in the frontend and setting a date in the backend, say in a data persister, is a bad idea.
    Do we have any better options than using a field like

    /**
    * @Assert\IsTrue(groups={"Register"})
    **/
    private $agreedTerms = false;

    and a data persister with

    public function persist($data, array $context = [])
    {
    // ...
    if($data->agreedTerms) {
    // set some agreedTermsAt field or something
    }


    ?

  • 2019-10-29 Diego Aguiar

    Hey Jérôme Zecca

    Hahaha don't worry man! We all have been there ;) And there are many options to achieve what you want
    The thing here is to pick the best option for your case but don't over engineer a solution

    Cheers!

  • 2019-10-29 Ramazan

    Hi Jérôme Zecca ,

    Just some ideas:

    Did you tried to call UserDataPersister from your fixtures?

    Or maybe you can encode the password from your fixtures?


    //...
    $data->setPassword(
    $this->userPasswordEncoder->encodePassword($user, $password)
    );
    $this->entityManager->persist($user);
    $this->entityManager->flush();

    Or might not be what you are looking for but you can also create your user via the API from your fixtures:


    //...
    $userParams = [
    'email' => $email,
    'username' => substr($email, 0, strpos($email, '@')),
    'password' => $password
    ];

    $this->getClient()->request('POST', '/users', [
    'json' => $userParams
    ]);

  • 2019-10-29 Jérôme Zecca

    I really feel stupid for finding the answer to my own question just half an hour later... anyway :p
    I should have just RTFM ;)

  • 2019-10-29 Jérôme Zecca

    Disregard the question about the creation of a user on an empty DB, I guess I can just write a Symfony Command to do the job ;)
    The question still stands for fixtures loading though.

  • 2019-10-29 Jérôme Zecca

    Hi Ryan,
    Thanks again for yet another awesome tutorial :)

    Just a question though: How do you deal with loading users fixtures, or even creating an initial user on an empty DB with this method?

    If I got it right, DataPersister are only used when reading/writing a resource through an API request, so loading fixtures with AliceBundle or creating a user programmatically won't trigger the password encoding.

    Using a Doctrine EntityListener to encode passwords on PrePersist works in all those cases... but only when creating the user.
    PreUpdate is not triggered by DQL queries, which Api Platform uses internally :/

    Maybe I'm doing/understood something wrong?

  • 2019-09-26 weaverryan

    Hey Sung Lee!

    Hmm, yea, this is tricky and @Ramazan asked some good questions already to try to explain this odd behavior. First, when I try this with the code from the tutorial (right at this spot), I'm not having any problems deleting the user... so it's gotta be some small detail in your implementation. And I don't think that the User vs Users would be that cause. Everything points to *something* (somehow, for some reason) "deleting" your User object on all requests. If something deleted your User object... then your data persister correctly later in the request called $em->remove(), that would cause this error. When updating a User, if something deleted the User object early in the request, then when our data persister correctly called $em->persist later in the request, this would cause a new record to be inserted (since the old record is no longer there). The big mystery is: what is mysteriously deleting the User object? Because you don't have any relations, there *must* be something deleting the User object (sometimes relations could cause this, as deleting a related object could cause a User object to be deleted, but that's not your case).

    Do you have any other event listeners... or anything else (even if it's related or not related to API Platform)?

    Cheers!

  • 2019-09-25 Sung Lee

    Users:

    namespace App\Entity;

    use ApiPlatform\Core\Annotation\ApiFilter;
    use ApiPlatform\Core\Annotation\ApiResource;
    use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Serializer\Annotation\Groups;
    use Symfony\Component\Serializer\Annotation\SerializedName;
    use Symfony\Component\Validator\Constraints as Assert;

    /**
    * @ORM\Entity(repositoryClass = "App\Repository\UsersRepository")
    * @UniqueEntity(fields = {"email"})
    *
    * @ApiResource(
    * shortName = "User",
    * accessControl = "is_granted('ROLE_USER')",
    * collectionOperations = {
    * "get",
    * "post" = {
    * "access_control" = "is_granted('ROLE_ADMIN')",
    * "validation_groups" = { "Default", "create" }
    * }
    * },
    * itemOperations = {
    * "get",
    * "put",
    * "delete" = {
    * "access_control" = "is_granted('ROLE_ADMIN')",
    * "access_control_message" = "Only admin can delete."
    * }
    * },
    * normalizationContext = {
    * "groups" = {"user:read"},
    * "swagger_definition_name" = "Read"
    * },
    * denormalizationContext = {
    * "groups" = {"user:write"},
    * "swagger_definition_name" = "Write"
    * }
    * )
    * @ApiFilter(PropertyFilter::class)
    */
    class Users implements UserInterface {
    /**
    * @ORM\Id()
    * @ORM\GeneratedValue()
    * @ORM\Column(type="integer")
    */
    private $id;

    /**
    * @ORM\Column(type="string", length=180, unique=true)
    * @Groups({"user:read", "user:write"})
    * @Assert\NotBlank()
    * @Assert\Email()
    */
    private $email;

    /**
    * @ORM\Column(type="json")
    * @Groups({"admin:read", "admin:write"})
    */
    private $roles = [];

    /**
    * @var string The hashed password
    * @ORM\Column(type="string")
    */
    private $password;

    /**
    * @var string The plain password stored temporarily
    * @Groups({"user:write"})
    * @SerializedName("password")
    * @Assert\NotBlank(groups={"create"})
    */
    private $plainPassword;

    public function getId(): ?int {
    return $this->id;
    }

    public function getEmail(): ?string {
    return $this->email;
    }

    public function setEmail(string $email): self {
    $this->email = $email;

    return $this;
    }

    /**
    * A visual identifier that represents this user.
    *
    * @see UserInterface
    */
    public function getUsername(): string {
    return (string)$this->email;
    }

    /**
    * @see UserInterface
    */
    public function getRoles(): array {
    $roles = $this->roles;
    // guarantee every user at least has ROLE_USER
    $roles[] = 'ROLE_USER';

    return array_unique($roles);
    }

    public function setRoles(array $roles): self {
    $this->roles = $roles;

    return $this;
    }

    /**
    * @see UserInterface
    */
    public function getPassword(): string {
    return (string)$this->password;
    }

    public function setPassword(string $password): self {
    $this->password = $password;

    return $this;
    }

    /**
    * @see UserInterface
    */
    public function getSalt() {
    // not needed when using the "bcrypt" algorithm in security.yaml
    }

    /**
    * @see UserInterface
    */
    public function eraseCredentials() {
    // If you store any temporary, sensitive data on the user, clear it here
    $this->plainPassword = null;
    }

    /**
    * @return string
    */
    public function getPlainPassword(): ?string {
    return $this->plainPassword;
    }

    /**
    * @param string $plainPassword
    */
    public function setPlainPassword(string $plainPassword): self {
    $this->plainPassword = $plainPassword;

    return $this;
    }
    }

    UsersDataPersister:

    namespace App\DataPersister;
    use ApiPlatform\Core\DataPersister\DataPersisterInterface;
    use App\Entity\Users;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

    class UsersDataPersister implements DataPersisterInterface {

    private $entityManager;
    private $userPasswordEncoder;

    public function __construct(EntityManagerInterface $entityManager, UserPasswordEncoderInterface $userPasswordEncoder) {
    $this->entityManager = $entityManager;
    $this->userPasswordEncoder = $userPasswordEncoder;
    }

    public function supports($data): bool {
    return $data instanceof Users;
    }

    /**
    * @param Users $data
    */
    public function persist($data) {
    if ($data->getPlainPassword()) {
    $data->setPassword(
    $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
    );
    $data->eraseCredentials();
    }

    $this->entityManager->persist($data);
    $this->entityManager->flush();
    }

    public function remove($data) {
    $this->entityManager->remove($data);
    $this->entityManager->flush();
    }

    }

  • 2019-09-25 Ramazan

    Could you show the Users entity class and UsersDataPersister class

  • 2019-09-25 Sung Lee

    No. This User entity doesn't have any relations with other entities yet. I posted the response from the server in my previous reply.
    Thanks for your time looking into that.

  • 2019-09-25 Ramazan

    Do you have OneToMany or ManyToMany fields in your Users entity?
    In that case, you probably should need a cascade persist, remove like that:
    @ORM\OneToMany(targetEntity="App\Entity\YourEntity", mappedBy="users", cascade={"persist", "remove"}})

  • 2019-09-25 Sung Lee

    No, I don't have it anywhere.

    This is what response looks like:

    {
    "type": "https://tools.ietf.org/html/rfc2616#section-10",
    "title": "An error occurred",
    "detail": "Detached entity App\\Entity\\Users@0000000010e7173b0000000018612146 cannot be removed",
    "trace": [
    {
    "namespace": "",
    "short_class": "",
    "class": "",
    "type": "",
    "function": "",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/doctrine/orm/lib/Doctrine/ORM/ORMInvalidArgumentException.php",
    "line": 177,
    "args": []
    },
    {
    "namespace": "Doctrine\\ORM",
    "short_class": "ORMInvalidArgumentException",
    "class": "Doctrine\\ORM\\ORMInvalidArgumentException",
    "type": "::",
    "function": "detachedEntityCannot",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php",
    "line": 1852,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ],
    [
    "string",
    "removed"
    ]
    ]
    },
    {
    "namespace": "Doctrine\\ORM",
    "short_class": "UnitOfWork",
    "class": "Doctrine\\ORM\\UnitOfWork",
    "type": "->",
    "function": "doRemove",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php",
    "line": 1801,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ],
    [
    "array",
    {
    "0000000010e7173b0000000018612146": [
    "object",
    "App\\Entity\\Users"
    ]
    }
    ]
    ]
    },
    {
    "namespace": "Doctrine\\ORM",
    "short_class": "UnitOfWork",
    "class": "Doctrine\\ORM\\UnitOfWork",
    "type": "->",
    "function": "remove",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/doctrine/orm/lib/Doctrine/ORM/EntityManager.php",
    "line": 615,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ]
    ]
    },
    {
    "namespace": "Doctrine\\ORM",
    "short_class": "EntityManager",
    "class": "Doctrine\\ORM\\EntityManager",
    "type": "->",
    "function": "remove",
    "file": "/Users/me/Documents/Projects/api-platform/src/DataPersister/UsersDataPersister.php",
    "line": 42,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ]
    ]
    },
    {
    "namespace": "App\\DataPersister",
    "short_class": "UsersDataPersister",
    "class": "App\\DataPersister\\UsersDataPersister",
    "type": "->",
    "function": "remove",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/api-platform/core/src/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersister.php",
    "line": 66,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ],
    [
    "array",
    {
    "resource_class": [
    "string",
    "App\\Entity\\Users"
    ],
    "item_operation_name": [
    "string",
    "delete"
    ],
    "receive": [
    "boolean",
    true
    ],
    "respond": [
    "boolean",
    true
    ],
    "persist": [
    "boolean",
    true
    ]
    }
    ]
    ]
    },
    {
    "namespace": "ApiPlatform\\Core\\Bridge\\Symfony\\Bundle\\DataPersister",
    "short_class": "TraceableChainDataPersister",
    "class": "ApiPlatform\\Core\\Bridge\\Symfony\\Bundle\\DataPersister\\TraceableChainDataPersister",
    "type": "->",
    "function": "remove",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/api-platform/core/src/EventListener/WriteListener.php",
    "line": 109,
    "args": [
    [
    "object",
    "App\\Entity\\Users"
    ],
    [
    "array",
    {
    "resource_class": [
    "string",
    "App\\Entity\\Users"
    ],
    "item_operation_name": [
    "string",
    "delete"
    ],
    "receive": [
    "boolean",
    true
    ],
    "respond": [
    "boolean",
    true
    ],
    "persist": [
    "boolean",
    true
    ]
    }
    ]
    ]
    },
    {
    "namespace": "ApiPlatform\\Core\\EventListener",
    "short_class": "WriteListener",
    "class": "ApiPlatform\\Core\\EventListener\\WriteListener",
    "type": "->",
    "function": "onKernelView",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/event-dispatcher/Debug/WrappedListener.php",
    "line": 126,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ],
    [
    "string",
    "kernel.view"
    ],
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Debug\\TraceableEventDispatcher"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\EventDispatcher\\Debug",
    "short_class": "WrappedListener",
    "class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener",
    "type": "->",
    "function": "__invoke",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
    "line": 260,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ],
    [
    "string",
    "kernel.view"
    ],
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Debug\\TraceableEventDispatcher"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\EventDispatcher",
    "short_class": "EventDispatcher",
    "class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
    "type": "->",
    "function": "doDispatch",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
    "line": 235,
    "args": [
    [
    "array",
    [
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ]
    ]
    ],
    [
    "string",
    "kernel.view"
    ],
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\EventDispatcher",
    "short_class": "EventDispatcher",
    "class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
    "type": "->",
    "function": "callListeners",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
    "line": 73,
    "args": [
    [
    "array",
    [
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ],
    [
    "object",
    "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
    ]
    ]
    ],
    [
    "string",
    "kernel.view"
    ],
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\EventDispatcher",
    "short_class": "EventDispatcher",
    "class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
    "type": "->",
    "function": "dispatch",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php",
    "line": 168,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ],
    [
    "string",
    "kernel.view"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\EventDispatcher\\Debug",
    "short_class": "TraceableEventDispatcher",
    "class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher",
    "type": "->",
    "function": "dispatch",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/http-kernel/HttpKernel.php",
    "line": 156,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpKernel\\Event\\ViewEvent"
    ],
    [
    "string",
    "kernel.view"
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\HttpKernel",
    "short_class": "HttpKernel",
    "class": "Symfony\\Component\\HttpKernel\\HttpKernel",
    "type": "->",
    "function": "handleRaw",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/http-kernel/HttpKernel.php",
    "line": 68,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpFoundation\\Request"
    ],
    [
    "integer",
    1
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\HttpKernel",
    "short_class": "HttpKernel",
    "class": "Symfony\\Component\\HttpKernel\\HttpKernel",
    "type": "->",
    "function": "handle",
    "file": "/Users/me/Documents/Projects/api-platform/vendor/symfony/http-kernel/Kernel.php",
    "line": 198,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpFoundation\\Request"
    ],
    [
    "integer",
    1
    ],
    [
    "boolean",
    true
    ]
    ]
    },
    {
    "namespace": "Symfony\\Component\\HttpKernel",
    "short_class": "Kernel",
    "class": "Symfony\\Component\\HttpKernel\\Kernel",
    "type": "->",
    "function": "handle",
    "file": "/Users/me/Documents/Projects/api-platform/public/index.php",
    "line": 25,
    "args": [
    [
    "object",
    "Symfony\\Component\\HttpFoundation\\Request"
    ]
    ]
    }
    ]
    }

  • 2019-09-25 Ramazan

    Do you have a doctrine listener or a datapersister where you do somthing like:
    $entityManager->detach($entity);
    ?

  • 2019-09-25 Sung Lee

    When I tried to remove a user, I got the following error: "Detached entity App\Entity\Users@0000000006cbbc2c00000000380a1292 cannot be removed". Also when I tried to update a user, it creates a new record of a user instead of updating existing one.

    Any thoughts on this? (I named the entity "Users" instead of "User")

  • 2019-09-16 weaverryan

    Hey alsbury!

    Hmm, excellent question :). Yes, this is the correct place. Well, of course, anything is subjective - but if you asked Kévin Dunglas, he would agree with using a data persister for this - https://github.com/api-plat...

    Cheers!

  • 2019-09-13 alsbury

    I saw this after I asked my question

  • 2019-09-13 alsbury

    I'd like to interact with 3rd party services like Stripe. I'd be creating accounts, payment profiles, charges etc. Is Data Persister the best place to put that sort of logic in API Platform? The only other option seems to be events. Thanks for the great tutorials!

  • 2019-09-05 weaverryan

    Hey Younes OUASSI!

    I thought a bit more about this and I want to add *one* more detail about this. If you decide to start using the GraphQL functionality from API Platform, then the custom controller and event listener solutions will *not* work. To say that differently, the custom controller and event listeners are specific to the REST API - they will have no effect if you use GraphQL. However, the custom data persister will work in both situations :).

    Cheers!

  • 2019-09-05 weaverryan

    Hi again lwillems!

    Actually, I'm going to modify my statement and say that there IS at least one difference between using a data persister versus the PRE_WRITE event. A data persister will work both for the REST API *and* also if you start using GraphQL with API Platform. The PRE_WRITE event, however, is specific to the REST API. So, the data persister is a bit more powerful because it's used in more places :).

    Cheers!

  • 2019-09-04 weaverryan

    Hey Younes OUASSI!

    Good question :). Yea, I think this is valid. However, I'd say two things:

    1) This will work for creating users, but not if a user wants to update their password.

    2) The docs also say this:

    > Note: the event system should be preferred over custom controllers when applicable

    I believe that's mostly because... events are a more standard way of "extending" functionality. And, indeed, in this situation, we could have use the PRE_WRITE listener priority on the kernel.view event to accomplish this same thing in a (I believe) 100% equivalent way as the custom data persister: https://api-platform.com/do...

    So, I'd use a data persister or an event. Using a custom controller (other than my point above in 1) is technically also equivalent, but isn't a "recommended" solution, fwiw :).

    Cheers!

  • 2019-09-04 Younes OUASSI

    Hello,
    Thanks a lot for this explanation, I've tested the DataPersister approach and it worked very well, but I tried using operation as mentioned in docs,


    encoder = $encoder;
    }

    public function register(User $data)
    {
    $user = $data;
    $plainPassword = $user->getPassword();
    $user->setPassword($this->encoder->encodePassword($user, $plainPassword));

    return $user;
    }
    }

    /**
    * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
    * @ApiResource(collectionOperations={
    * "get",
    * "post"={
    * "controller"="App\Api\Operation\UserRegister::register",
    * "method"="POST"
    *}
    * })
    */
    class User implements UserInterface
    {
    //etc..
    }

    Is this a good approach to follow ?

    Regards,

  • 2019-09-03 weaverryan

    Yep, this is sound advice. Towards the end of this tutorial, we'll set add something to "auto-set" the author via an entity listener (which is basically the same as a Doctrine listener). That doesn't accomplish the goal of having all that logic in the same spot, but it is true that createdAt and updatedAt can be solved easily with lifecycle callbacks.

    Cheers!

  • 2019-09-03 weaverryan

    Hey Tobias Ingold !

    Thanks for the nice words! And... I love this question. I mean... I don't love that there are so many options... but it's a really good question. Let's take it part-by-part:

    > I could use the constructor of Comment to do something like $this->createdAt = new \DateTimeImmutable(); but this works only for $createdAt and not for $updatedAt or $author, so no good, I'd like to keep all the "setting things not writable by the user"-logic in the same place if you know what I mean.

    Yep, setting createdAt via the constructor is super easy...but it only works for createdAt... so has limited usefulness.

    > One way would be to use stof/doctrine-extensions-bundle, but I don't like this bundle very much anymore because it gives me like 20+ deprecation warnings and it doesn't seem like its maintained anymore these days.

    Sigh. This bundle indeed needs love... it's needed love for a long time. It makes something that should be easy... kinda hard... at least to initially set up. Once it's set up, then it does get easier. We use it on SymfonyCasts.

    > Also it doesn't allow me to use Blameable, so I would have to set $author myself somewhere.

    It doesn't? We don't use Blameable, but the docs say that it does support this?

    > So another way would be to use the doctrine events like prePersist, preUpdate, preFlush etc, but then why use these events and not the api platform events for example?

    Ah, so there IS a subtle difference here. API Platform events would only cause these properties to be set when being modified/created via API Platform operations/endpoints. If you had some other part of your system that also works on entities (e.g. some console command... admin area, etc), they would not be set. This doesn't make Doctrine events superior to API Platform events in all cases - it depends. For low-level stuff like createdAt, updatedAt and "blameable", it probably makes sense to set these always. But if you were, for example, going to send a "welcome email" after a user is created/registered (and use creation is done via your API), I would do that via an API Platform event - we don't want some internal, legacy user-importing script to trigger registration emails to be sent automatically.

    > To me, the data persister seems like the best place to do what I want to do

    To complicate things further... this has the same downside as API Platform events... the data is only set when API Platform is the one saving the data :p

    > Maybe a better example would be if I had a commentCount field on my User entity that needs updating when a comment is persisted.

    Just to keep using more examples, in this case I would (once again) prefer a Doctrine listener over an API Platform solution, as I want to make sure the commentCount is ALWAYS updated... even if some non-API part of my system were saving data.

    So.... let's summarize things :). At a high-level, there are two different solutions:

    1) Doctrine events (whether through StofDoctrineExtensions or not - that part is an implementation detail). This is great for data/activity that you ALWAYS want to happen no matter WHO is saving the data. This is probably what you want for createdAt/updatedAt/Blameable

    2) API Platform events / data persister (or, if you're not using API Platform, the equivalent would be "put logic in the controller/service where you are saving the data) - appropriate for activities that are triggered more on "user activity" - e.g. sending an email on registration. In this case, you are *purposely* saying: I *only* want this "action" to happen when someone *actually* uses my API, but not when (for example) some legacy import script saves data.

    For your situation, use Doctrine events. And even though it can be confusing to set up (and deprecations are annoying), I'd still recommend StofDoctrineExtensions first. However, remember that StofDoctrineExtensions is just a thin layer around: https://github.com/Atlantic... - so you could NOT use the bundle, and just manually add the two listener services you need for Timestampable and Blameable - https://github.com/Atlantic...

    Phew! Let me know if this helps! I know that feeling of "I know 5 ways to do this... but which way *should* I use" :).

    Cheers!

  • 2019-09-03 weaverryan

    Hey lwillems!

    Good question :). And I didn't have any specific reason behind this. The truth is that data persisters were the way that occurred to me first - then later I realized that an event listener can also accomplish this. I can't think of any practical difference between these two options. As a side note, I *had* planned to show events in a future tutorial - mentioning PRE_WRITE as a parallel option to a data persister might be a good thing to mention there.

    Cheers!

  • 2019-09-02 lwillems

    Hi SymfonyCast team,

    I was wondering why you did not use an event instead of data persister to hook and crypt password (eg : PRE_WRITE event) ?

    Regards

  • 2019-09-02 Ramazan

    Hi Tobias Ingold ,

    Personally, for fields like createdAt and updatedAt I choose to use Doctrine Lifecycle Callbacks:
    https://www.doctrine-projec...

    For author, api-platform already handle it if you give the author IRI to the POST/PUT endpoint.

  • 2019-08-29 Tobias Ingold

    Hey symfonycast peeps
    I love love love your screencasts and tutorials! It is so nice to see how things get done in symfony and how things really work. Your projects are always fun to code along :) I have one question though, I noticed that in symfony we often have multiple ways of acheiving the same thing, and I always end up asking myself what the best approach is to a given problem.
    Let me make an example: I have a User and a Comment entity


    // Comment
    class Comment {
    // ...
    /**
    * @ORM\Column(type="datetime")
    */
    private $createdAt;
    /**
    * @ORM\Column(type="datetime")
    */
    private $updatedAt;
    /**
    * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="comments")
    * @ORM\JoinColumn(nullable=false)
    */
    private $author;
    // ...
    }

    In this case I would be thinking about where, how and when $createdAt, $updatedAt and $author would be set.
    I could use the constructor of Comment to do something like $this->createdAt = new \DateTimeImmutable(); but this works only for $createdAt and not for $updatedAt or $author, so no good, I'd like to keep all the "setting things not writable by the user"-logic in the same place if you know what I mean.
    One way would be to use stof/doctrine-extensions-bundle, but I don't like this bundle very much anymore because it gives me like 20+ deprecation warnings and it doesn't seem like its maintained anymore these days. Also it doesn't allow me to use Blameable, so I would have to set $author myself somewhere. And in general I don't really like having properties set "magically" on my entities.
    So another way would be to use the doctrine events like prePersist, preUpdate, preFlush etc, but then why use these events and not the api platform events for example? Speaking of api platform, I could also set these properties in a data persister.
    I hope you get my point. I don't see how one would be better than the other and I often struggle with these kinds of questions.
    To me, the data persister seems like the best place to do what I want to do, since it is really easy to inject other services into it, and with ContextAwareDataPersisterInterface I have full control, but then again it is a data persister and I think it should only be worried about how the data gets persisted or removed from the database and not neccessarily how it needs to be modified beforehand.
    Maybe a better example would be if I had a commentCound field on my User entity that needs updating when a comment is persisted. Then I would even have to update the User inside of the comment data persister and while this might look simple at first, I think this could get messy really quickly.

    I would love to get some feedback from you guys on how you would handle these things.