Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Forms with Symfony2 - Doctrine Entity with some immutable constructor parameters and OneToMany association

I have a OneToMany association between a Server entity and Client entities in the database. One server can have many clients. I want to make a form where the user can choose a server from a dropdown, fill in some details for a new client, and submit it.

Goal

To create a form where a user can input data into fields for the Client, choose a Server from the dropdown, then click submit and have this data (and the association) persisted via Doctrine.

Simple, right? Hell no. We'll get to that. Here's the pretty form as it stands:

Server Client Form

Things of note:

  • Server is populated from the Server entities (EntityRepository::findAll())
  • Client is a dropdown with hardcoded values
  • Port, endpoint, username and password are all text fields

Client Entity

In my infinite wisdom I have declared that my Client entity has the following constructor signature:

class Client
{
    /** -- SNIP -- **/
    public function __construct($type, $port, $endPoint, $authPassword, $authUsername);
    /** -- SNIP -- **/
}

This will not change. To create a valid Client object, the above constructor parameters exist. They are not optional, and this object cannot be created without the above parameters being given upon object instantiation.

Potential Problems:

  • The type property is immutable. Once you've created a client, you cannot change the type.

  • I do not have a setter for type. It is a constructor parameter only. This is because once a client is created, you cannot change the type. Therefore I am enforcing this at the entity level. As a result, there is no setType() or changeType() method.

  • I do not have the standard setObject naming convention. I state that to change the port, for example, the method name is changePort() not setPort(). This is how I require my object API to function, before the use of an ORM.

Server Entity

I'm using __toString() to concatenate the name and ipAddress members to display in the form dropdown:

class Server 
{
    /** -- SNIP -- **/
    public function __toString()
    {
        return sprintf('%s - %s', $this->name, $this->ipAddress);
    }
    /** -- SNIP -- **/
}

Custom Form Type

I used Building Forms with Entities as a baseline for my code.

Here is the ClientType I created to build the form for me:

class ClientType extends AbstractType
{
    /**
     * @var UrlGenerator
     */
    protected $urlGenerator;

    /**
     * @constructor
     *
     * @param UrlGenerator $urlGenerator
     */
    public function __construct(UrlGenerator $urlGenerator)
    {
        $this->urlGenerator = $urlGenerator;
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        /** Dropdown box containing the server name **/
        $builder->add('server', 'entity', [
            'class' => 'App\Model\Entity\Server',
            'query_builder' => function(ServerRepository $serverRepository) {
                return $serverRepository->createQueryBuilder('s');
            },
            'empty_data' => '--- NO SERVERS ---'
        ]);

        /** Dropdown box containing the client names **/
        $builder->add('client', 'choice', [
            'choices' => [
                'transmission' => 'transmission',
                'deluge'       => 'deluge'
            ],
            'mapped' => false
        ]);

        /** The rest of the form elements **/
        $builder->add('port')
                ->add('authUsername')
                ->add('authPassword')
                ->add('endPoint')
                ->add('addClient', 'submit');

        $builder->setAction($this->urlGenerator->generate('admin_servers_add_client'))->setMethod('POST');
    }

    /**
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'App\Model\Entity\Client',
            'empty_data' => function(FormInterface $form) {
                return new Client(
                    $form->getData()['client'],
                    $form->getData()['port'],
                    $form->getData()['endPoint'],
                    $form->getData()['authPassword'],
                    $form->getData()['authUsername']
                );
            }
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'client';
    }
}

The above code is what actually generates the form to be used client-side (via twig).

The Problems

First and foremost, with the above code, submitting the form gives me:

NoSuchPropertyException in PropertyAccessor.php line 456: Neither the property "port" nor one of the methods "addPort()"/"removePort()", "setPort()", "port()", "__set()" or "__call()" exist and have public access in class "App\Model\Entity\Client".

So it can't find the port method. That's because it's changePort() as I explained earlier. How do I tell it that it should use changePort() instead? According to the docs I would have to use the entity type for port, endPoint etc. But they're just text fields. How do I go about this the right way?

I have tried:

  • Setting ['mapped' => false] on port, authUsername etc. This gives me null for all the client fields, but it does seem to have the relevant server details with it. Regardless, $form->isValid() return false. Here's what var_dump() shows me:

Nope :(

  • A combination of other things involving setting each on field to "entity", and more..

Basically, "it's not working". But this is as far as I've got. What am I doing wrong? I am reading the manual over and over but everything is so far apart that I don't know if I should be using a DataTransformer, the Entity Field Type, or otherwise. I'm close to scrapping Symfony/Forms altogether and just writing this myself in a tenth of the time.

Could someone please give me a solid answer on how to get where I want to be? Also this may help future users :-)

like image 280
Jimbo Avatar asked Jul 02 '14 12:07

Jimbo


1 Answers

There are a few problems with the above solution, so here's how I got it working!

Nulls

It turns out that in setDefaultOptions(), the code: $form->getData['key'] was returning null, hence all the nulls in the screenshot. This needed to be changed to $form->get('key')->getData()

return new Client(
    $form->get('client')->getData(),
    $form->get('port')->getData(),
    $form->get('endPoint')->getData(),
    $form->get('authPassword')->getData(),
    $form->get('authUsername')->getData()
);

As a result, the data came through as expected, with all the values intact (apart from the id).

Twig Csrf

According to the documentation you can set csrf_protection => false in your form options. If you don't do this, you will need to render the hidden csrf field in your form:

{{ form_rest(form) }}

This renders the rest of the form fields for you, including the hidden _token one:

Symfony2 has a mechanism that helps to prevent cross-site scripting: they generate a CSRF token that have to be used for form validation. Here, in your example, you're not displaying (so not submitting) it with form_rest(form). Basically form_rest(form) will "render" every field that you didn't render before but that is contained into the form object that you've passed to your view. CSRF token is one of those values.

Silex

Here's the error I was getting after solving the above issue:

The CSRF token is invalid. Please try to resubmit the form.

I'm using Silex, and when registering the FormServiceProvider, I had the following:

$app->register(new FormServiceProvider, [
    'form.secret' => uniqid(rand(), true)
]);

This Post shows how Silex is giving you some deprecated CsrfProvider code:

Turned out it was not due to my ajax, but because Silex gives you a deprecated DefaultCsrfProvider which uses the session ID itself as part of the token, and I change the ID randomly for security. Instead, explicitly telling it to use the new CsrfTokenManager fixes it, since that one generates a token and stores it in the session, such that the session ID can change without affecting the validity of the token.

As a result, I had to remove the form.secret option and also add the following to my application bootstrap, before registering the form provider:

/** Use a CSRF provider that does not depend on the session ID being constant. We change the session ID randomly */
$app['form.csrf_provider'] = $app->share(function ($app) {
    $storage = new Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage($app['session']);
    return new Symfony\Component\Security\Csrf\CsrfTokenManager(null, $storage);
});

With the above modifications, the form now posts and the data is persisted in the database correctly, including the doctrine association!

like image 76
Jimbo Avatar answered Nov 15 '22 00:11

Jimbo