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 have a User
entity... but it is not yet part of our API. How do we make it part of the API? Ah, we already know! Go above the class and add the ApiResource
attribute.
... lines 1 - 4 | |
use ApiPlatform\Metadata\ApiResource; | |
... lines 6 - 12 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 16 - 116 | |
} |
Refresh the docs. Look at that! Six fresh new endpoints for the User
class! And thanks to our fixtures, we should be able to see data immediately. Let's try the collection endpoint. Execute and... it's alive.
Though... it is a little weird that fields like roles
and password
show up. Ah, we'll worry about that in a minute.
Before we keep rolling forward, I want to mention one quick thing about UUIDs. As you can see, we're using auto-increment IDs for of our API - it's always /api/users/
then the entity id. But you can totally use a UUID
instead. And that's something we'll do in a future tutorial.
But... why would you use UUIDs? Well, sometimes it can make life easier in JavaScript when working with frontend frameworks. You can actually generate the UUID
in JavaScript and then send that to your API when creating a new resource. This can help because your JavaScript knows the ID of the resource immediately and can update the state... instead of waiting for the Ajax request to finish to get the new auto-increment id.
Anyways, my point is: API Platform does support UUIDs
. You could add a new UUID column, then tell API Platform that it should be your identifier. Oh, but keep in mind that some database engines - like MySQL - can have poor performance if you make the UUID the primary key. In that case, just keep id
as the primary key, and add an extra UUID column.
Anyways, back to our User
resource! Right now, it's returning way too many fields. Fortunately, we know how to fix that. Up on ApiResource
, add a normalizationContext
key with groups
set to user:read
to follow the same pattern that we used in DragonTreasure
. Also add denormalizationContext
set to user:write
.
... lines 1 - 13 | |
( | |
normalizationContext: ['groups' => ['user:read']], | |
denormalizationContext: ['groups' => ['user:write']], | |
) | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 123 | |
} |
Now we can just decorate the fields that we want in the API. We don't need id
... since we always have @id
, which is more useful. But we do want email
. So add the #Groups()
attribute, hit tab to add that use
statement and pass both user:read
and user:write
.
... lines 1 - 9 | |
use Symfony\Component\Serializer\Annotation\Groups; | |
... lines 11 - 13 | |
( | |
normalizationContext: ['groups' => ['user:read']], | |
denormalizationContext: ['groups' => ['user:write']], | |
) | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 25 | |
'user:read', 'user:write']) ([ | |
private ?string $email = null; | |
... lines 28 - 123 | |
} |
Copy that... and go down to password
. We do need the password to be writeable but not readable. So add user:write
.
... lines 1 - 9 | |
use Symfony\Component\Serializer\Annotation\Groups; | |
... lines 11 - 13 | |
( | |
normalizationContext: ['groups' => ['user:read']], | |
denormalizationContext: ['groups' => ['user:write']], | |
) | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 25 | |
'user:read', 'user:write']) ([ | |
private ?string $email = null; | |
... lines 28 - 35 | |
'user:write']) ([ | |
private ?string $password = null; | |
... lines 38 - 123 | |
} |
Now this still isn't quite correct. The password
field should hold the hashed password. But our users will, of course, send the plaintext passwords via the API when creating a user or updating their password. Then we will hash it. That's something we're going to solve in a future tutorial when we talk more about security. But this will be good enough for now.
Oh, and above username
, also add user:read
and user:write
.
... lines 1 - 9 | |
use Symfony\Component\Serializer\Annotation\Groups; | |
... lines 11 - 13 | |
( | |
normalizationContext: ['groups' => ['user:read']], | |
denormalizationContext: ['groups' => ['user:write']], | |
) | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 25 | |
'user:read', 'user:write']) ([ | |
private ?string $email = null; | |
... lines 28 - 35 | |
'user:write']) ([ | |
private ?string $password = null; | |
... lines 38 - 39 | |
'user:read', 'user:write']) ([ | |
private ?string $username = null; | |
... lines 42 - 123 | |
} |
Cool! Refresh the docs... and open up the collections endpoint to give it a go. The result... exactly what we wanted! Only email
and username
come back.
And if we were to create a new user... yup! The writable fields are email
, username
, and password
.
Ok, what else are we missing? How about validation? If we try the POST endpoint with empty data... we get that nasty 500 error. Fixing time!
Back over in the file, start above the class to make sure that both email
and username
are unique
. Add UniqueEntity
passing fields
set to email
... and we can even include a message. Repeat that same thing... but change email
to username
.
... lines 1 - 10 | |
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; | |
... lines 12 - 18 | |
fields: ['email'], message: 'There is already an account with this email') ( | |
fields: ['username'], message: 'It looks like another dragon took your username. ROAR!') ( | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 23 - 129 | |
} |
Next, down in email
, add NotBlank
... then I'll add the Assert
in front... and tweak the use
statement so it works just like last time.
... lines 1 - 10 | |
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; | |
use Symfony\Component\Validator\Constraints as Assert; | |
... lines 13 - 18 | |
fields: ['email'], message: 'There is already an account with this email') ( | |
fields: ['username'], message: 'It looks like another dragon took your username. ROAR!') ( | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 23 - 29 | |
... line 31 | |
private ?string $email = null; | |
... lines 33 - 129 | |
} |
Nice. email needs one more - Assert\Email
- and above username
, add NotBlank
.
... lines 1 - 10 | |
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; | |
use Symfony\Component\Validator\Constraints as Assert; | |
... lines 13 - 18 | |
fields: ['email'], message: 'There is already an account with this email') ( | |
fields: ['username'], message: 'It looks like another dragon took your username. ROAR!') ( | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 23 - 29 | |
private ?string $email = null; | |
... lines 33 - 45 | |
private ?string $username = null; | |
... lines 48 - 129 | |
} |
I'm not too worried about password
right now... because it's already a bit weird.
Let's try this! Scroll up and just send a password
field. And... yes! The nice 422 status code with validation errors. Try valid data now: pass an email
and username
... though I'm not sure this guy's actually a dragon... we might need a captcha.
Hit Execute. That's it! 201 status code with email
and username
returned!
Our resource has validation, pagination and contains great information! And we could even easily add filtering. In other words, we're crushing it!
And now we get to the really interesting part. We need to "relate" our two resources so that each treasure is owned by a user. What does that look like in API Platform? It's super interesting, and it's next.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.0.8
"doctrine/annotations": "^1.0", // 1.14.2
"doctrine/doctrine-bundle": "^2.8", // 2.8.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.0
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.64.1
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.15.3
"symfony/asset": "6.2.*", // v6.2.0
"symfony/console": "6.2.*", // v6.2.3
"symfony/dotenv": "6.2.*", // v6.2.0
"symfony/expression-language": "6.2.*", // v6.2.2
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.3
"symfony/property-access": "6.2.*", // v6.2.3
"symfony/property-info": "6.2.*", // v6.2.3
"symfony/runtime": "6.2.*", // v6.2.0
"symfony/security-bundle": "6.2.*", // v6.2.3
"symfony/serializer": "6.2.*", // v6.2.3
"symfony/twig-bundle": "6.2.*", // v6.2.3
"symfony/ux-react": "^2.6", // v2.6.1
"symfony/validator": "6.2.*", // v6.2.3
"symfony/webpack-encore-bundle": "^1.16", // v1.16.0
"symfony/yaml": "6.2.*" // v6.2.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "6.2.*", // v6.2.1
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/stopwatch": "6.2.*", // v6.2.0
"symfony/web-profiler-bundle": "6.2.*", // v6.2.4
"zenstruck/foundry": "^1.26" // v1.26.0
}
}