Tweak your Form based on the Underlying Data

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

New goal team! Remember this author field? It's where we added all this nice auto-complete magic. I want this field to be fully functional on the "new form", but disabled on the edit form: as wonderful as they are, some of our alien authors get nervous and sometimes try to change an article to look like it was written by someone else.

This is the first time that we want the same form to behave in two different ways, based on where it is used.

Let's see: on our new endpoint, the form creates the new Article object behind the scenes for us. But on the edit page, the form is modifying an existing Article: we pass this to the form.

So, hmm, in the buildForm() method of our form class, if we could get access to the data that was passed to the form - either the existing Article object or maybe nothing - then we could use that info to build the fields differently.

Accessing Data via $options

Fortunately... that's easy. The secret is the $options argument that's passed to us. Let's see what this looks like: dd($options) and then go back and refresh the edit page.

... lines 1 - 14
class ArticleFormType extends AbstractType
... lines 17 - 23
public function buildForm(FormBuilderInterface $builder, array $options)
... lines 27 - 38
... lines 40 - 46

Wow! There are a ton of options. And all of these are things that we could configure down in configureOptions(). But, the majority of this stuff isn't all that important. However, there is one super-helpful key: data. It's set to our Article object! Bingo!

Now, open another tab and go to /admin/article/new.

Oh. This time there is no data... which makes sense because we never passed anything to the form. That's great! We can use the data key to get the underlying data. How about: $article = $options['data'] ?? null;

... lines 1 - 23
public function buildForm(FormBuilderInterface $builder, array $options)
$article = $options['data'] ?? null;
... lines 28 - 39
... lines 41 - 49

If you don't know that syntax, it basically says that I want the $article variable to be equal to $options['data'] if it exists and is not null. But if it does not exist, set it to null. Let's dump that and make sure it's what we expect.

Refresh the new article page - yep - null. Try the edit page... there's the Article object. Now, we are dangerous. Remove the dd() and create a new variable: $isEdit = $article && $article->getId().

... lines 1 - 23
public function buildForm(FormBuilderInterface $builder, array $options)
$article = $options['data'] ?? null;
$isEdit = $article && $article->getId();
... lines 28 - 42
... lines 44 - 52

You might think that it's enough just to check whether $article is an object. But actually, on our new endpoint, if we wanted, we could instantiate a new Article() object and pass it as the second argument to createForm(). You do this sometimes if you want to pre-fill a "new" form with some default data. The form system would update that Article object, but Doctrine would still be smart enough to insert a new row when we save.

Anyways, that's why I'm checking not only that the Article is an object, but that it also has an id.

Dynamically disabling a Field

This is great, because, our goal was to disable the author field on the edit form. To do that, we can take advantage of an option that every field type has: disabled. Set it to $isEdit.

... lines 1 - 23
public function buildForm(FormBuilderInterface $builder, array $options)
... lines 26 - 38
->add('author', UserSelectTextType::class, [
'disabled' => $isEdit
... lines 44 - 52

Ok, let's try that out! Refresh the edit page. Disabled! Now try the new page: not disabled. Perfect!

Oh, by the way, this disabled option does two things. First, obviously, it adds a disabled attribute so that the browser prevents the user from modifying it. But it also now ignores any submitted data for this field. So, if a nasty user removed the disabled attribute and updated the field, meh - no problem - our form will ignore that submitted data.

Conditionally Hiding / Showing a Field

I want to do one more thing. The publishedAt field: I want to only show that on the edit page. Because, when we're creating a new article, I don't want the admin to be able to publish it immediately. To do that, instead of just disabling it, I want to remove the field entirely from the new form.

So, yea - we could leverage this $isEdit variable: that would totally work. But, let's make things more interesting: I want the ability to choose whether or not the publishedAt field should be shown when we create our form in the controller.

Here's the trick: go down to the edit form. The createForm() method actually has a third argument: an array of options that you can pass to your form. Let's invent a new one called include_published_at set to true.

... lines 1 - 14
class ArticleAdminController extends AbstractController
... lines 17 - 46
public function edit(Article $article, Request $request, EntityManagerInterface $em)
$form = $this->createForm(ArticleFormType::class, $article, [
'include_published_at' => true
... lines 52 - 67
... lines 69 - 80

Before doing anything else, try this. A huge error! Just like with the options you pass to an individual field, you can't just invent new options to pass to your form! The error says: look - the form does not have this option!

So... we'll add it! Copy the option name, go into ArticleFormType and, down in configureOptions(), add include_published_at set to false. This is enough to make this a valid option... with a default value.

... lines 1 - 14
class ArticleFormType extends AbstractType
... lines 17 - 47
public function configureOptions(OptionsResolver $resolver)
... line 51
'include_published_at' => false,

Now, up in buildForm(), the $options array will always have an include_published_at key. We can use that below to say if ($options['include_published_at']), then we want that field. Remove it from above, then say $builder paste and... clean that up a little bit.

... lines 1 - 23
public function buildForm(FormBuilderInterface $builder, array $options)
... lines 26 - 40
if ($options['include_published_at']) {
$builder->add('publishedAt', null, [
'widget' => 'single_text',
... lines 47 - 56

I love it! On the edit form, because we've overridden that option to be true, when we refresh... yes! We have the field! Open up the profiler for your form and click on the top level. Nice! You can see that a passed option include_published_at was set to true.

For the new page, we should not have that field. Try it! Woh! An error from Twig:

Neither the property publishedAt nor one of the methods publishedAt(), blah blah blah, exist in some FormView class.

It's blowing up inside form_row() because we're trying to render a field that doesn't exist! Go open that template: templates/article_admin/_form.html.twig, and wrap this in an if statement: {% if articleForm.publishedAt is defined %}, then we'll render the field.

{{ form_start(articleForm) }}
... lines 2 - 6
{% if articleForm.publishedAt is defined %}
{{ form_row(articleForm.publishedAt) }}
{% endif %}
... lines 10 - 11
{{ form_end(articleForm) }}

Try it again. The field is gone! And because it's completely gone from the form, when we submit, the form system will not call the setPublishedAt() method at all.

Next: let's talk about another approach to handling the situation where your form looks different than your entity class: data transfer objects.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.6
        "symfony/console": "^4.0", // v4.1.6
        "symfony/flex": "^1.0", // v1.2.7
        "symfony/form": "^4.0", // v4.1.6
        "symfony/framework-bundle": "^4.0", // v4.1.6
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.6
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.6
        "symfony/validator": "^4.0", // v4.1.6
        "symfony/web-server-bundle": "^4.0", // v4.1.6
        "symfony/yaml": "^4.0", // v4.1.6
        "twig/extensions": "^1.5" // v1.5.2
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
        "symfony/dotenv": "^4.0", // v4.1.6
        "symfony/maker-bundle": "^1.0", // v1.8.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.6