If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe decided to make the owner
property a field that an API client must send when creating a CheeseListing
. That gives us some flexibility: an admin user can send this field set to any User, which might be handy for a future admin section. To make sure the owner
is valid, we've added a custom validator that even has an edge-case that allows admin users to do this.
But the most common use-case - when a normal user wants to create a CheeseListing
under their own account - is a bit annoying: they're forced to pass the owner
field... but it must be set to their own user's IRI. That's perfectly explicit and straightforward. But... couldn't we make life easier by automatically setting owner
to the currently-authenticated user if that field isn't sent?
Let's try it... but start by doing this in our test... which will be a tiny change. When we send a POST
request to /api/cheeses
with title
, description
and price
, we expect it to return a 400
error because we forgot to send the owner
field. Let's change this to expect a 201
status code. Once we finish this feature, only sending title
, description
and price
will work.
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 13 | |
public function testCreateCheeseListing() | |
{ | |
... lines 16 - 33 | |
$this->assertResponseStatusCodeSame(201); | |
... lines 35 - 44 | |
} | |
... lines 46 - 74 | |
} |
To start, take off the NotBlank
constraint from $owner
- we definitely don't want it to be required anymore.
... lines 1 - 48 | |
class CheeseListing | |
{ | |
... lines 51 - 95 | |
/** | |
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings") | |
* @ORM\JoinColumn(nullable=false) | |
* @Groups({"cheese:read", "cheese:collection:post"}) | |
* @IsValidOwner() | |
*/ | |
private $owner; | |
... lines 103 - 206 | |
} |
If we run the tests now...
php bin/phpunit --filter=testCreateCheeseListing
Yep! It fails... we're getting a 500 error because it's trying to insert into cheese_listing
with an owner_id
that is null
.
So, how can we automatically set the owner
on a CheeseListing
if it's not already set? We have a few options! Which in programming... is almost never a good thing. Hmm. Don't worry, I'll tell you which one I would use and why.
Our options include an API Platform event listener - a topic we haven't talked about yet - an API Platform data persister or a Doctrine event listener. The first two - an API Platform event listener or data persister - have the same possible downside: the owner would only be automatically set when a CheeseListing
is created through the API. Depending on what you're trying to accomplish, that might be exactly what you want - you may want this magic to only affect your API operations.
But... in general... if I save a CheeseListing
- no matter if it's being saved as part of an API call or in some other part of my system - and the owner
is null, I think automatically setting the owner
makes sense. So, instead of making this feature only work for our API endpoints, let's use a Doctrine event listener and make it work everywhere.
To set this via Doctrine, we can create an event listener or an "entity" listener... which are basically two, effectively identical ways to run some code before or after an entity is saved, updated or deleted. We'll use an "entity" listener.
In the src/
directory, create a Doctrine/
directory... though, like usual, the name of the directory and class doesn't matter. Put a new class inside called, how about, CheeseListingSetOwnerListener
. This will be an "entity listener": a class with one or more functions that Doctrine will call before or after certain things happen to a specific entity. In our case, we want to run some code before a CheeseListing
is created. That's called "pre persist" in Doctrine. Add public function prePersist()
with a CheeseListing
argument.
... lines 1 - 4 | |
use App\Entity\CheeseListing; | |
class CheeseListingSetOwnerListener | |
{ | |
public function prePersist(CheeseListing $cheeseListing) | |
{ | |
} | |
} |
Two things about this. First, the name of this method is important: Doctrine will look at all the public functions in this class and use the names to determine which methods should be called when. Calling this prePersist()
will mean that Doctrine will call us before persisting - i.e. inserting - a CheeseListing
. You can also add other methods like postPersist()
, preUpdate()
or preRemove()
.
Second, this method will only be called when a CheeseListing
is being saved. How does Doctrine know to only call this entity listener for cheese listings? Well, it doesn't happen magically thanks to the type-hint. Nope, to hook all of this up, we need to add some config to the CheeseListing
entity. At the top, add a new annotation. Actually... let's reorganize the annotations first... and move @ORM\Entity
to the bottom... so it's not mixed up in the middle of all the API Platform stuff. Now add @ORM\EntityListeners()
and pass this an array with one item inside: the full class name of the entity listener class: App\Doctrine\
... and then I'll get lazy and copy the class name: CheeseListingSetOwnerListener
.
... lines 1 - 17 | |
/** | |
... lines 19 - 47 | |
* @ORM\EntityListeners({"App\Doctrine\CheeseListingSetOwnerListener"}) | |
*/ | |
class CheeseListing | |
... lines 51 - 209 |
That's it for the basic setup! Thanks to this annotation and the method being called prePersist()
, Doctrine will automatically call this before it persists - meaning inserts - a new CheeseListing
.
The logic for setting the owner is pretty simple! To find the currently-authenticated user, add an __construct()
method, type-hint the Security
service and then press Alt + Enter and select "Initialize fields" to create that property and set it.
... lines 1 - 5 | |
use Symfony\Component\Security\Core\Security; | |
... line 7 | |
class CheeseListingSetOwnerListener | |
{ | |
private $security; | |
... line 11 | |
public function __construct(Security $security) | |
{ | |
$this->security = $security; | |
} | |
... lines 16 - 26 | |
} |
Next, inside the method, start by seeing if the owner was already set: if $cheeseListing->getOwner()
, just return: we don't want to override that.
... lines 1 - 16 | |
public function prePersist(CheeseListing $cheeseListing) | |
{ | |
if ($cheeseListing->getOwner()) { | |
return; | |
} | |
... lines 22 - 25 | |
} | |
... lines 27 - 28 |
Then if $this->security->getUser()
- so if there is a currently-authenticated User
, call $cheeseListing->setOwner($this->security->getUser())
.
... lines 1 - 16 | |
public function prePersist(CheeseListing $cheeseListing) | |
{ | |
if ($cheeseListing->getOwner()) { | |
... lines 20 - 22 | |
if ($this->security->getUser()) { | |
$cheeseListing->setOwner($this->security->getUser()); | |
} | |
} | |
... lines 27 - 28 |
Cool! Go tests go!
php bin/phpunit --filter=testCreateCheeseListing
And... it passes! I'm kidding... that exploded. Hmm, it says:
Too few arguments to
CheeseListingSetOwnerListener::__construct()
0 passed.
Huh. Who's instantiating that class? Usually in Symfony, we expect any "service class" - any class that's not a simple data-holding object like our entities - to be instantiated by Symfony's container. That's important because Symfony's container is responsible for all the autowiring magic.
But... if you look at the stack trace... it looks like Doctrine itself is trying to instantiate the class. Why is Doctrine trying to create this object instead of asking the container for it?
The answer is... that's... sort of... just how it works? Um, ok, better explanation. When used as an independent library, Doctrine typically handles instantiating these "entity listener" classes itself. However, when integrated with Symfony, you can tell Doctrine to instead fetch that service from the container. But... you need a little bit of extra config.
Open config/services.yaml
and override the automatically-registered service definition: App\Doctrine\
and go grab the CheeseListingSetOwnerListener
class name again. We're doing this so that we can add a little bit of extra service configuration. Specifically, we need to add a tag called doctrine.orm.entity_listener
.
... lines 1 - 8 | |
services: | |
... lines 10 - 41 | |
App\Doctrine\CheeseListingSetOwnerListener: | |
tags: [doctrine.orm.entity_listener] |
This says:
Hey Doctrine! This service is an entity listener. So when you need the CheeseListingSetOwnerListener object to do the entity listener stuff, use this service instead of trying to instantiate it yourself.
And that will let Symfony do its normal, autowiring logic. Try the test one last time:
php bin/phpunit --filter=testCreateCheeseListing
And... we're good! We've got the best of all worlds! The flexibility for an API client to send the owner
property, validation when they do, and an automatic fallback if they don't.
Next, let's talk about the last big piece of access control: filtering a collection result to only the items that an API client should see. For example, when we make a GET
request to /api/cheeses
, we should probably not return unpublished cheese listings... unless you're an admin.
Hi,
Do you have a NotNull
constraint on User
field? I think it's a little complex problem because Validation is called before any Doctrine Listener that changes data before persisting, so the easiest way to get it work, you should add a ValidatorInterface
service to your Listener and call validate manually after setting the user. You should take validator service from ApiPlatform
package. In this case you will have a proper validation exception 'cause ValidatorInterface::validate
in this case will do all work automaticaly!
Cheers
Hi thank you for your reply
I am a completly noob can uou show how and where i can add this interface ? I tried inside the listener but could'nt manage to make it work :(
Heh, but you are on chapter 36 of ApiPlatform course ;) you should know how to autowire services ;)
namespace App\Listeners;
use ApiPlatform\Validator\ValidatorInterface;
// ...
class YourDoctrineListener //...
{
private ValidatorInterface $validator;
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
public function prePersist(/*...*/)
{
// ...
$this->validator->validate($entity)
}
// ....
}
This is a small example, it's hard to say how exactly you should use it, because I don't see your code, but it should be easy to inject anywhere!
Cheers!
Hi,
Thank you for your reply.
It was as simple as that. I tried the same thing but i probably messed it up somewhere.
it's working like a charm. Thank you.
Hey there :) amazing cast. Thank yu!
Small question, why when we update an entity ! The prePersist is not fired, instead it's the preUpdated callback who is called ?
Hey ahmedbhs
I'm glad to hear you are liking our content. About your question, the prePersist
event is only fired when a new entity is saved in the database for the first time. And, the preUpdate
event is fired every time an entity is updated in the database, that's just how Doctrine events works.
Cheers!
Hi,
I'm a bit confused on this part. Doesn't this cuase validation errors? For example I want to check if the userID
is unique. So I use the @UniqueEntity("userID")
annotation. This doesn't seem to trigger at all. The same goes for the @IsValidOwner()
annotation. It just throws a 500 error because of the unique SQL key. Is there a solution for this. For example add the userID
in a earlier state (before the validation)?
Hope you guys can help!
Thanks!
Hey Mees E.!
Yes, actually. The listener is running AFTER validation, so we're setting the owner and it's saving, without any validation. For our example, that works fine: we don't need to run IsValidOwner() because that checks to see if the owner === the current user, for when we're doing an edit. But for a create, it's always valid to set the owner to the currently-authenticated user. So no validation needed. And for UniqueEntity, it's just not something we need here.
> For example I want to check if the userID is unique
Let's see if we can figure this out :). One option would be to use the listener, and then execute validation yourself. Do that by injecting the ValidatorInterface service and calling validate. That will return a ConstraintViolationList. If that has any validations errors in it, throw the special ValidationException. You're basically repeating what Api Platform does for its own validation layer: https://github.com/api-plat...
Let me know if that helps!
Cheers!
Thank you for responding! That should work!
Just for extra information: Are there any best practices on validating OneToOne
relations without throwing a 500 error? This solution should work but does sound like there's a better option.
Thanks!
Hey Mees E.!
Hmm. In general, I think UniqueEntity should work for this... I can't think of a reason why it wouldn't. BUT, that's assuming that you aren't using a listener to set the owner like this: that assumes that you are just allowing the user to send data, and you want to guarantee that there isn't already a record in the database with the same user. I could be wrong, as I rarely use OneToOne, but I think that in the "normal data setting situation", UniqueEntity should catch it.
Cheers!
Hey Stephane,
If you want to use that new PHP property types feature - yes, you have to :) It's tricky because we need to allow putting the entity into invalid state to validate it. Thanks for sharing your solution with others
Cheers!
I was thinking, in cases like this (when persisting data), is there a difference between using an Entity Listener or a Data Persister?
To my understanding, a Data Persister is only executed when using API Platform and an Entity Listener is always executed be it with API Platform or not. Am I thinking right?
So, if that was not an issue I could use a Data Persister to auto-set the owner the same way I could use an Entity Listener to encode a password?
Thanks!
Hey André P.
Yes, you're right. The DataPersister is a layer inside ApiPlatform, so, if you save an entity out of ApiPlatform, as far as I know, its DataPersister won't be called. My recomendation here is to use your API to create your entities, instead of having 2 different ways for creating those, it avoids code duplication and may simplify your logic
Cheers!
Hi,
if i want to set the owner of multiple entities, should i duplicate my listener for each entity or there is a smart way to do this?
Thanks for this great course.
Hey @adam!
Excellent question :). No need to duplicate the listener. What you can do is this:
A) Create an interface with a setOwner(User $user) method. Make all your entities that need this "set owner functionality" have this. Maybe the interface is called "SettableOwnerInterface".
B) Create an entity listener just like in this video, but use the SettableOwnerInterface type-hint on the prePersist() argument. Because, once you're done, this method will receive one of multiple different objects.
C) Register your entity listener on all the entity classes that you need.
That should do it! Btw, if you used a Doctrine event subscriber that's called on prePersist() of *all* entities, then you could skip step (C) and instead just check of the entity being passed to your method is an instance of SettableOwnerInterface.
Let me know if you have any questions!
Cheers!
This more advanced symfony content is great. The non-screencast visuals / presentation stuff for covering theory (even text) is a really helpful comprehension aide too.
Hi There!
I have A question about the entitylistener, but first: please tell me this is not the right place for this question since its maybe already a bit too much off-topic :)
Anyways lets give it a try!:
I use the doctrine entitylistener as I learned a whilo ago from this video. Only difference, I use postPersist.
I am now experimenting a bit with phpmailer since i want to sent a mail to the user right after he registered for the first time. Everything kind of works since my mail is received and data is persisted but somehow this also seems to mess with the response I'm getting from my API... as soon as I add 'mail->send()' the data in my response is no longer the one from my entity but is all info from my mailserver etc.. Probably I'm breaking some rules here of which I don't know but so far I havent found anything online
All the best,
wannes!
`
//function to be executed after persisting to the database
class AfterRegistrationEmailListener
{
public function postPersist ()
{
$mail = new PHPMailer;
$mail->isSMTP();
$mail->SMTPDebug = SMTP::DEBUG_SERVER;
$mail->Host = 'smtp.gmail.com';
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = true;
$mail->Username = $_SERVER['EMAILADRESS'];
$mail->Password = $_SERVER['PASSWORDMAIL'];
$mail->setFrom('wannes@mail.com', 'wannes');
$mail->addReplyTo('wannes@hotmail.com', 'wannes');
$mail->addAddress('john@mail.com', 'John Doe');
$mail->Subject = 'PHPMailer GMail SMTP test';
$mail->msgHTML("hi there!");
$mail->send();
return;
}
}
`
Oh well I by now found out it was a problem related to phpmailer, not to my entitylistener so not so much related to the course!
In case anyone ever comes by this question with a similar issue:
The debug server of php mailer injected data in my response, malforming my entire response. simply removing the debugserver made everything work as expected :)
Hey Wannes,
Glad you were able to solve this problem yourself, well done! And thank you for sharing your solution with others!
Cheers!
// 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
}
}
Hi,
i have an Entity with a constraint on 2 fields
`#[UniqueEntity(
)]`
It works well before ading the Doctrine Listener. Now my user is auto added to my entites but my constraint is not triggered.