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 SubscribeFinally, let's look at the Swiss Army knife of form rendering: instead of using the form-rendering functions, we'll build the field entirely by hand.
For example, suppose we need to do something crazy with the "year" drop-down field. That's fine! We'll still render the label and errors normally, but let's handle the widget ourselves. Yep, I literally mean: create a select
tag and start filling in the details.
The first detail is the id
attribute. Every field has a unique id, which ties that field to its label. And this is where form variables help us out big.
Go back into the Form tab of the web profiler and click the year
field. There are a lot of variables, but there are a few that are especially important, like id
and full_name
, which normally becomes the name
attribute.
In your template, reference the id
variable with: genusForm.firstDiscoveredAt.year.vars.id
. Repeat that for the name
attribute set to genusForm.firstDiscoveredAt.year.vars.full_name
:
{{ form_start(genusForm) }} | |
... lines 2 - 20 | |
{{ form_label(genusForm.firstDiscoveredAt.year) }} | |
<select id="{{ genusForm.firstDiscoveredAt.year.vars.id }}" | |
name="{{ genusForm.firstDiscoveredAt.year.vars.full_name }}"> | |
... lines 24 - 26 | |
</select> | |
{{ form_errors(genusForm.firstDiscoveredAt.year) }} | |
{{ form_row(genusForm.firstDiscoveredAt.month) }} | |
{{ form_row(genusForm.firstDiscoveredAt.day) }} | |
... lines 32 - 33 | |
{{ form_end(genusForm) }} |
Now that we understand the FormView
tree and how variables are stored, this actually makes sense.
Next, what about the options that should go inside the select
tag? Head back to the web profiler to see which variable might help us. Ah, here's one called choices
, and each item is a ChoiceView
object. Use the Shift
+Shift
shortcut to open that file from Symfony:
... lines 1 - 11 | |
namespace Symfony\Component\Form\ChoiceList\View; | |
... lines 13 - 18 | |
class ChoiceView | |
{ | |
/** | |
* The label displayed to humans. | |
* | |
* @var string | |
*/ | |
public $label; | |
/** | |
* The view representation of the choice. | |
* | |
* @var string | |
*/ | |
public $value; | |
/** | |
* The original choice value. | |
* | |
* @var mixed | |
*/ | |
public $data; | |
/** | |
* Additional attributes for the HTML tag. | |
* | |
* @var array | |
*/ | |
public $attr; | |
/** | |
* Creates a new choice view. | |
* | |
* @param mixed $data The original choice | |
* @param string $value The view representation of the choice | |
* @param string $label The label displayed to humans | |
* @param array $attr Additional attributes for the HTML tag | |
*/ | |
public function __construct($data, $value, $label, array $attr = array()) | |
{ | |
$this->data = $data; | |
$this->value = $value; | |
$this->label = $label; | |
$this->attr = $attr; | |
} | |
} |
Cool! Each ChoiceView
is a simple object, with a public label
property and a public value
property:
... lines 1 - 18 | |
class ChoiceView | |
{ | |
/** | |
* The label displayed to humans. | |
* | |
* @var string | |
*/ | |
public $label; | |
/** | |
* The view representation of the choice. | |
* | |
* @var string | |
*/ | |
public $value; | |
... lines 34 - 63 | |
} |
That's exactly what we need.
Add a loop: for choice in genusForm.firstDiscoveredAt.year.vars.choices
:
{{ form_start(genusForm) }} | |
... lines 2 - 20 | |
{{ form_label(genusForm.firstDiscoveredAt.year) }} | |
<select id="{{ genusForm.firstDiscoveredAt.year.vars.id }}" | |
name="{{ genusForm.firstDiscoveredAt.year.vars.full_name }}"> | |
{% for choice in genusForm.firstDiscoveredAt.year.vars.choices %} | |
... line 25 | |
{% endfor %} | |
</select> | |
{{ form_errors(genusForm.firstDiscoveredAt.year) }} | |
{{ form_row(genusForm.firstDiscoveredAt.month) }} | |
{{ form_row(genusForm.firstDiscoveredAt.day) }} | |
... lines 32 - 33 | |
{{ form_end(genusForm) }} |
Inside, add <option value="">
then print choice.value
.
We also need to know if this option should be currently selected. We can do that by comparing the value to the data
variable that's attached to the year
field. Why not do this in one big giant line: choice.value == genusForm.firstDiscoveredAt.year.vars.data
. Wow. Then, ?
' selected' or empty quotes. Finally, for the option text, use choice.label
:
{{ form_start(genusForm) }} | |
... lines 2 - 20 | |
{{ form_label(genusForm.firstDiscoveredAt.year) }} | |
<select id="{{ genusForm.firstDiscoveredAt.year.vars.id }}" | |
name="{{ genusForm.firstDiscoveredAt.year.vars.full_name }}"> | |
{% for choice in genusForm.firstDiscoveredAt.year.vars.choices %} | |
<option value="{{ choice.value }}" {{ choice.value == genusForm.firstDiscoveredAt.year.vars.data ? 'selected' : '' }}>{{ choice.label }}</option> | |
{% endfor %} | |
</select> | |
{{ form_errors(genusForm.firstDiscoveredAt.year) }} | |
{{ form_row(genusForm.firstDiscoveredAt.month) }} | |
{{ form_row(genusForm.firstDiscoveredAt.day) }} | |
... lines 32 - 33 | |
{{ form_end(genusForm) }} |
That's it! Go back to your browser, then refresh. Ah, error!
That's me being careless: the sub-field is called year
, not years
:
{{ form_start(genusForm) }} | |
... lines 2 - 21 | |
<select id="{{ genusForm.firstDiscoveredAt.year.vars.id }}" | |
name="{{ genusForm.firstDiscoveredAt.year.vars.full_name }}"> | |
{% for choice in genusForm.firstDiscoveredAt.year.vars.choices %} | |
<option value="{{ choice.value }}" {{ choice.value == genusForm.firstDiscoveredAt.year.vars.data ? 'selected' : '' }}>{{ choice.label }}</option> | |
{% endfor %} | |
</select> | |
... lines 28 - 33 | |
{{ form_end(genusForm) }} |
Refresh again.
It works! It's not styled because we've taken complete control of rendering it. But you can see the errors, and the options look correct. Cool!
So, we're done! Wait... except for this random field at the bottom of my form. What the heck!? That's my year field! What's going on?
See that form_end()
at the bottom of our form?
... lines 1 - 33 | |
{{ form_end(genusForm) }} |
Remember how it renders any field that we forgot to render? Well, now it thinks that we forgot to render the year
field. The nerve!
So, could we just tell it that the field was actually rendered? Yep, and the code is both simple and strange. Use a rare do tag from Twig and say genusForm.firstDiscoveredAt.year.setRendered()
:
{{ form_start(genusForm) }} | |
... lines 2 - 28 | |
{% do genusForm.firstDiscoveredAt.year.setRendered() %} | |
... lines 30 - 34 | |
{{ form_end(genusForm) }} |
Whaaaat? Well, every field is a FormView
object. And if you open that class, it has a setRendered()
method!
... lines 1 - 18 | |
class FormView implements \ArrayAccess, \IteratorAggregate, \Countable | |
{ | |
... lines 21 - 86 | |
/** | |
* Marks the view as rendered. | |
* | |
* @return FormView The view object | |
*/ | |
public function setRendered() | |
{ | |
$this->rendered = true; | |
return $this; | |
} | |
... lines 98 - 161 | |
} |
And by calling it, we're saying:
Yo, we rendered this already. So, you know, don't try to render it again.
Refresh now! Whoops! Another Ryan mistake - make sure your variable is genusForm
, not genus
:
{{ form_start(genusForm) }} | |
... lines 2 - 28 | |
{% do genusForm.firstDiscoveredAt.year.setRendered() %} | |
... lines 30 - 34 | |
{{ form_end(genusForm) }} |
Now that extra field is gone.
Congrats team: you have the power to render your forms in whatever crazy, insane, creative way you want! But with power, comes great responsibility. I'll delete all the code we just added and go back to simply rendering genusForm.firstDiscoveredAt
:
{{ form_start(genusForm) }} | |
... lines 2 - 20 | |
{{ form_row(genusForm.firstDiscoveredAt) }} | |
... line 22 | |
{{ form_end(genusForm) }} |
... lines 1 - 15 | |
class GenusFormType extends AbstractType | |
{ | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
$builder | |
... lines 21 - 38 | |
->add('firstDiscoveredAt', DateType::class, [ | |
'widget' => 'single_text', | |
'attr' => ['class' => 'js-datepicker'], | |
'html5' => false, | |
]) | |
; | |
} | |
... lines 46 - 52 | |
} |
Don't use your new skills unless you actually need to.
Ok guys, that's it! If you still have some questions, or want to tell me about something really cool you did, or share vacation photos, whatever, you can do it in the comments - it's always great to hear from you.
All right guys, see you next time.
Hi Ryan, first id like to say your videos are great!. Also, i think that there is a mistake in the if statement on line 25 app/Resources/views/admin/genus/_form.html.twig. (choice.value == genusForm.firstDiscoveredAt... ? 'selected' : '' )
Hey Juan,
Nice catch in such a long string! Thank you, fixed in https://github.com/knpunive...
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3",
"symfony/symfony": "3.4.*", // v3.4.49
"doctrine/orm": "^2.5", // 2.7.5
"doctrine/doctrine-bundle": "^1.6", // 1.12.13
"doctrine/doctrine-cache-bundle": "^1.2", // 1.4.0
"symfony/swiftmailer-bundle": "^2.3", // v2.6.7
"symfony/monolog-bundle": "^2.8", // v2.12.1
"symfony/polyfill-apcu": "^1.0", // v1.23.0
"sensio/distribution-bundle": "^5.0", // v5.0.25
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
"incenteev/composer-parameter-handler": "^2.0", // v2.1.4
"composer/package-versions-deprecated": "^1.11", // 1.11.99.4
"knplabs/knp-markdown-bundle": "^1.4", // 1.9.0
"doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
"stof/doctrine-extensions-bundle": "^1.2" // v1.3.0
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.1.7
"symfony/phpunit-bridge": "^3.0", // v3.4.47
"nelmio/alice": "^2.1", // v2.3.6
"doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
}
}
Hello Ryan,
Thank for this cool stuff. Yours tutorials are very interesting.