Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

EntityType Validation: Restrict Invalid programmerId

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

In the test, the Programmer is owned by weaverryan and then we authenticate as weaverryan. So, we're starting a battle using a Programmer that we own. Time to mess that up. Create a new user called someone_else:

... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
// create a Programmer owned by someone else
$this->createUser('someone_else');
... lines 49 - 67
}
}

There still is a user called weaverryan:

... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
protected function setUp()
{
parent::setUp();
$this->createUser('weaverryan');
}
... lines 15 - 68
}

But now, change the programmer to be owned by this sketchy someone_else character:

... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
// create a Programmer owned by someone else
$this->createUser('someone_else');
$programmer = $this->createProgrammer([
'nickname' => 'Fred'
], 'someone_else');
... lines 52 - 67
}
}

With this setup, weaverryan will be starting a battle with someone_else's programmer. This should cause a validation error: this is an invalid programmerId to pass.

Form Field Sanity Validation

But how do we do that? Is there some annotation we can use for this? Nope! This validation logic will live in the form. "What!?" you say - "Validation always goes in the class!". Not true! Every field type has a little bit of built-in validation logic. For example, the NumberType will fail if a mischievous - or confused - user types in a word. And the EntityType will fail if someone passes an id that's not found in the database. I call this sanity validation: the form fields at least make sure that a sane value is passed to your object.

If we could restrict the valid programmer id's to only those owned by our user, we'd be in business.

But first, add the test: assertResponsePropertyEquals() that errors.programmerId[0] should equal some dummy message:

... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
... lines 47 - 66
$this->asserter()->assertResponsePropertyEquals($response, 'errors.programmerId[0]', '???');
}
}

Run the test to see the failure:

./vendor/bin/phpunit --filter testPOSTBattleValidationErrors

Yep: there's no error for programmerId yet.

Let's fix that. Right now, the client can pass any valid programmer id, and the EntityType happily accepts it. To shrink that to a smaller list, we'll pass it a custom query to use.

Passing the User to the Form

To do that, the form needs to know who is authenticated. In BattleController, guarantee that first: add $this->denyAccessUnlessGranted('ROLE_USER'):

... lines 1 - 11
class BattleController extends BaseController
{
/**
* @Route("/api/battles")
* @Method("POST")
*/
public function newAction(Request $request)
{
$this->denyAccessUnlessGranted('ROLE_USER');
... lines 21 - 38
}
}

To pass the user to the form, add a third argument to createForm(), which is a little-known options array. Invent a new option: user set to $this->getUser():

... lines 1 - 11
class BattleController extends BaseController
{
... lines 14 - 17
public function newAction(Request $request)
{
... lines 20 - 22
$form = $this->createForm(BattleType::class, $battleModel, [
'user' => $this->getUser()
]);
... lines 26 - 38
}
}

This isn't a core Symfony thing: we're creating a new option.

To allow this, open BattleType and find configureOptions. Here, you need to say that user is an allowed option. One way is via $resolver->setRequired('user'):

... lines 1 - 11
class BattleType extends AbstractType
{
... lines 14 - 32
public function configureOptions(OptionsResolver $resolver)
{
... lines 35 - 38
$resolver->setRequired(['user']);
}
}

This means that whoever uses this form is allowed to, and in fact must, pass a user option.

With that, you can access the user object in buildForm(): $user = $options['user']:

... lines 1 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$user = $options['user'];
... lines 17 - 30
}
... lines 32 - 40
}

None of this is unique to API's: we're just giving our form more power!

Passing the query_builder Option

Let's filter the programmer query: add a query_builder option set to an anonymous function with ProgrammerRepository as the only argument. Add a use for $user so we can access it:

... lines 1 - 4
use AppBundle\Repository\ProgrammerRepository;
... lines 6 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 16 - 17
$builder
->add('programmerId', EntityType::class, [
... lines 20 - 21
'query_builder' => function(ProgrammerRepository $repo) use ($user) {
... line 23
},
])
... lines 26 - 29
;
}
... lines 32 - 40
}

We could write the query right here, but you guys know I don't like that: keep your queries in the repository! Call a new method createQueryBuilderForUser() and pass it $user:

... lines 1 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 16 - 17
$builder
->add('programmerId', EntityType::class, [
... lines 20 - 21
'query_builder' => function(ProgrammerRepository $repo) use ($user) {
return $repo->createQueryBuilderForUser($user);
},
])
... lines 26 - 29
;
}
... lines 32 - 40
}

Copy that method name and shortcut-your way to that class by holding command and clicking ProgrammerRepository. Add public function createQueryBuilderForUser() with the User $user argument:

... lines 1 - 8
class ProgrammerRepository extends EntityRepository
{
... lines 11 - 19
public function createQueryBuilderForUser(User $user)
{
... lines 22 - 24
}
... lines 26 - 46
}

Inside, return $this->createQueryBuilder() and alias the class to programmer. Then, just andWhere('programmer.user = :user') with ->setParameter('user', $user):

... lines 1 - 5
use AppBundle\Entity\User;
... lines 7 - 8
class ProgrammerRepository extends EntityRepository
{
... lines 11 - 19
public function createQueryBuilderForUser(User $user)
{
return $this->createQueryBuilder('programmer')
->andWhere('programmer.user = :user')
->setParameter('user', $user);
}
... lines 26 - 46
}

Done! The controller passes the User to the form, and the form calls the repository to create the custom query builder. Now, if someone passes a programmer id that we do not own, the EntityType will automatically cause a validation error. Security is built-in.

Head back to the terminal to try it!

./vendor/bin/phpunit --filter testPOSTBattleValidationErrors

Awesome! Well, it failed - but look! It's just because we don't have the real message yet: it returned This value is not valid.. That's the standard message if any field fails the "sanity" validation.

Tip

You can customize this message via the invalid_message form field option.

Copy that string and paste it into the test:

... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
... lines 47 - 66
$this->asserter()->assertResponsePropertyEquals($response, 'errors.programmerId[0]', 'This value is not valid.');
}
}

Run it!

./vendor/bin/phpunit --filter testPOSTBattleValidationErrors

So that's "sanity" validation: it's form fields watching your back to make sure mean users don't start sending crazy things to us. And it happens automatically.

Leave a comment!

4
Login or Register to join the conversation
Default user avatar
Default user avatar Tiago Felipe | posted 5 years ago

Hello,

I usually do this check for entity direct from the controller, this is not a good practice? For example, in cases where a user can not start a battle with the ID 10 scheduler but can start with the ID 11 scheduler, I would need to do a check for this, how would I do this using the FormType query_builder?

Reply

Hey Tiago,

Do validation inside a controller have drawbacks, for example you can't reuse this validation in another controller without code duplication. I don't fully understand what is "scheduler" you mentioned, but in query_builder you have to create a query which returns only valid result of set. There you can use different conditions in WHERE clause and even join other tables, which help you to filter result set. For more complex validation you can use custom callback constraints. Does it make sense for you?

Cheers!

Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | posted 5 years ago

Does the result of the custom query builder determine if there is a validation error? If not, then what?

Reply

Hi Vlad!

You're exactly right - the query builder determines if there is a validation error. If the user submits the id "5", and that is *not* found via the query builder (i.e. there IS a user id 5, but it is not one returned by the query builder), then the validation error is thrown.

I talk a bit more about this "sanity validation" here: http://knpuniversity.com/sc...

Cheers!

Reply
Cat in space

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

This tutorial uses an older version of Symfony. The concepts of Hypermedia & HATEOAS are still valid. But I recommend using API Platform in modern Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.0.*", // v3.0.3
        "doctrine/orm": "^2.5", // v2.5.4
        "doctrine/doctrine-bundle": "^1.6", // 1.6.2
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // v2.10.0
        "sensio/distribution-bundle": "^5.0", // v5.0.4
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.14
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.2
        "jms/serializer-bundle": "^1.1.0", // 1.1.0
        "white-october/pagerfanta-bundle": "^1.0", // v1.0.5
        "lexik/jwt-authentication-bundle": "^1.4", // v1.4.3
        "willdurand/hateoas-bundle": "^1.1" // 1.1.1
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.6
        "symfony/phpunit-bridge": "^3.0", // v3.0.3
        "behat/behat": "~3.1@dev", // dev-master
        "behat/mink-extension": "~2.2.0", // v2.2
        "behat/mink-goutte-driver": "~1.2.0", // v1.2.1
        "behat/mink-selenium2-driver": "~1.3.0", // v1.3.1
        "phpunit/phpunit": "~4.6.0", // 4.6.10
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}
userVoice