Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Validation Groups & Patch Formats

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

Now that the plainPassword property is a legitimate part of our API, let's add some validation... because you can't create a new user without a password! Add Assert\NotBlank:

... lines 1 - 67
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 70 - 94
private ?string $plainPassword = null;
... lines 97 - 293

Piece of cake! Well, that just created a new problem... but let's blindly move forward and pretend that everything is fine.

Copy the first test and paste to create a second method that will make sure we can update users. Call it testPatchToUpdateUser(). This one is simple: make a new user - $user = UserFactory::createOne(), add actingAs($user) then ->patch() to /api/users/ then $user->getId() to edit ourselves.

For the json, just send username, add assertStatus(200).... then we don't need any of this other stuff:

... lines 1 - 7
class UserResourceTest extends ApiTestCase
... lines 10 - 32
public function testPatchToUpdateUser(): void
$user = UserFactory::createOne();
->patch('/api/users/' . $user->getId(), [
'json' => [
'username' => 'changed',

As a reminder, up on the Patch operation for User... here it is, we're requiring that the user has ROLE_USER_EDIT. Because we're logging in as a "full" user, we should have that... and everything should work fine... famous last words.


symfony php bin/phpunit --filter=testPatchToUpdateUser

PATCH: The Most Interesting HTTP Method in the World

And... oh! 200 expected, got 415. That's a new one! Click to open the last response... then I'll View Source to make it more clear. Interesting:

The content-Type: application/json is not supported. Supported MIME types are application/merge-patch+json.

Let's unpack this. We're making a PATCH request... and PATCH requests are quite simple: we send a subset of fields, and only those fields are updated.

Whelp, it turns out that the PATCH HTTP method can get a whole heck of a lot more interesting than this. In the greater interwebs, there are competing formats for how the data should look when using a PATCH request and each format means something different.

Currently, API Platform supports only one of these formats: application/merge-patch+json. This format is... kind of what you expect. It says: if you send a single field, only that single field will be changed. But it also has other rules, like how you could set email to null... and that would actually remove the email field. That doesn't really make sense in our API, but the point is: the format defines rules about how your JSON should look for a PATCH request and what that means. If you want to know more, there's a document that describes everything: it's quite short and readable.

So, API platform only supports one format for PATCH requests at the moment. But, in the future, they might support more. And so, when you make a PATCH request, API Platform requires you to send a Content-Type header set to application/merge-patch+json... so that you're explicitly telling API platform which format your JSON is using.

In other words, to fix our error, pass a headers key with Content-Type set to application/merge-patch+json:

... lines 1 - 7
class UserResourceTest extends ApiTestCase
... lines 10 - 32
public function testPatchToUpdateUser(): void
... lines 35 - 36
... line 38
->patch('/api/users/' . $user->getId(), [
... lines 40 - 42
'headers' => ['Content-Type' => 'application/merge-patch+json']
... line 45

Try this now:

symfony php bin/phpunit --filter=testPatchToUpdateUser

It still fails, but now it's a validation error! The takeaway is simple: PATCH requests require this Content-Type header.

But wait! We did a bunch of PATCH requests over in DragonTreasureResourceTest and those worked fine without the header! What the what?

That... was kind of on accident. Inside DragonTreasure, in the first tutorial... here it is, we added a formats key so that we could add CSV support:

... lines 1 - 28
... lines 30 - 49
formats: [
'csv' => 'text/csv',
... lines 57 - 66
... lines 68 - 252

It turns out that, for some complex internal reasons, by adding formats, we removed the requirement for needing that header. So we were "getting away" with not setting the header in DragonTreasureResourceTest... even though we should be setting it. It may have been better to set formats on the GetCollection operation only... since that's the only spot we need CSV.

Anyway, that's why we didn't need it before, but we do need it now. By the way, if adding this header every time you call ->patch is annoying, this is another situation where you could add a custom method to browser - like ->apiPatch() - which would work the same, but add that header automatically.

Fixing the Validation Groups

Ok, back to the test! It's failing with a 422. Open the error response. Ah, it's from plainPassword: this field should not be blank!

The plainPassword property is not persisted to the database. So, it's always empty at the start of an API request. When we create a User, we absolutely do want this field to be required. But when we're editing a User, we don't need this field to be set. They can set it in order to change their password, but that's optional.

This is the first spot where we need conditional validation: validation should happen on one operation, but not on others. The way to fix this is with validation groups, which is very similar to serialization groups.

Find the Post operation and pass a new option called validationContext with, you guessed it, groups! Set this to an array with a group called Default with a capital D. Then invent a second group: postValidation:

... lines 1 - 26
... line 28
operations: [
... lines 30 - 31
new Post(
... line 33
validationContext: ['groups' => ['Default', 'postValidation']],
... lines 36 - 42
... lines 44 - 49
... lines 51 - 296

When the validator validates an object, by default, it validates everything that's in a group called Default. And any time you have a constraint, by default that constraint is in that Default group. So what we're saying here is:

We want to validate all the normal constraints plus any constraints that are in the postValidation group.

Now we can take that postValidation, go down to plainPassword and set groups to postValidation:

... lines 1 - 68
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 71 - 95
#[Assert\NotBlank(groups: ['postValidation'])]
private ?string $plainPassword = null;
... lines 98 - 294

That removes this constraint from the Default group and only includes it in the postValidation group. Thanks to this, other operations like Patch will not run this, but the Post operation will.

Run the test now:

symfony php bin/phpunit --filter=testPatchToUpdateUser

We're unstoppable! In fact, all of our tests are passing!

Careful: PUT Can Create Objects

But head's up! In User, we still have both Put and Patch. I haven't played with it much yet, but the new Put behavior, in theory, does support creating objects. This can make things tricky: do we need to require the password or not? It depends! This might be another reason for removing the Put operation to keep life simple. That gives us one operation for creating and one operation for editing.

Next: let's explore making our serialization groups dynamic based on the user. This will give us another way to include or not include fields based on who is logged in. And it'll lead us towards adding super custom fields.

Leave a comment!

Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0