Keep your data nice and tidy using Symfony's Form [Raw Symfony 4]
Towards the end of the previous video we had a working Symfony controller class that could handle our POST
data submission, and get us to a point where we had a plain PHP array
containing the raw data that had been submitted.
An array is useful. However the data inside is not very "tidy".
The submitted date is a raw string. We can get Symfony to convert this for us to an instance of \DateTime
, or \DateTimeImmutable
, if that's your thing.
Once we've tidied up this submission, we need this data to "stick around". Which means we need to store it somewhere.
Sounds like a good use case for our pal Doctrine, no? It sure does, Kent.
If we're working with Doctrine, that means we need an entity to represent our data structure.
Don't be put off by the terminology, an Entity is simply a plain old PHP class on which one of the properties is an ID field.
Let's make our Album entity:
bin/console make:entity Album
created: src/Entity/Album.php
created: src/Repository/AlbumRepository.php
Success!
Next: Add more fields to your entity and start using it.
Find the documentation at https://symfony.com/doc/current/doctrine.html#creating-an-entity-class
Ok, as it says we've got two new files - the Album
entity class itself, and the associated Repository, which we shouldn't need as our use case is very simple.
We know from our Behat test setup that our Album
entity needs to keep track of three different properties:
- Title
- Number of tracks
- Release date / time
We also know that our Album
entity needs an ID - it is an entity after all - and that the ID property should be an auto incrementing integer. Or, to put it another way, the first entity gets the ID of 1, then the next 2, the next gets ID 3, and so on.
Here's our entity:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass="App\Repository\AlbumRepository")
*/
class Album
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string")
*/
private $title;
/**
* @ORM\Column(type="datetime")
*/
private $releaseDate;
/**
* @ORM\Column(type="integer")
*/
private $trackCount;
/**
* @return int
*/
public function getId(): ?int
{
return $this->id;
}
/**
* @return string|null
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
*
* @return Album
*/
public function setTitle($title): Album
{
$this->title = $title;
return $this;
}
/**
* @return \DateTime|null
*/
public function getReleaseDate(): ?\DateTime
{
return $this->releaseDate;
}
/**
* @param \DateTime $releaseDate
*
* @return Album
*/
public function setReleaseDate($releaseDate): Album
{
$this->releaseDate = $releaseDate;
return $this;
}
/**
* @return int|null
*/
public function getTrackCount(): ?int
{
return $this->trackCount;
}
/**
* @param int $trackCount
*
* @return Album
*/
public function setTrackCount($trackCount): Album
{
$this->trackCount = $trackCount;
return $this;
}
}
Not the world's most beautiful entity, but sufficient for our needs. It would be nicer to move away from using setters and instead rely solely on the constructor, or named constructor methods to set properties of our entities. However, this plays merry hell with Symfony's form, and would lead to more layered architecture than an application of this complexity requires.
If this topic interests you, please leave a comment as I'm happy to go into more depth to a more real world approach in a different video.
Note that each of the setters and getters has a defined return type. This is a PHP 7 feature that we can take advantage of inside a Symfony 4 application, where the minimum version to run Symfony 4 is now 7.1.3.
One last thing - note that each of our class properties, releaseDate
, and trackCount
use camel case. And our incoming Behat test data doesn't quite map to these field names. We will need to address this.
Docker For Our Database
We now have our entity, and thanks to our use of the symfony/orm-pack
in the previous video, we have most of Doctrine set up and behaving.
For this project I'm going to use MySQL. You can use Postgres, there's no difference at this point. We will use Postgres in a different implementation in a few videos time.
We're going to use Docker for our database. You do not need to. It just makes life easier, in my opinion.
Note that you do not need to use Docker. If you have an existing database server up and running, you can use that just fine.
Please watch this video on setting up Docker for this project.
With your database up, we need to instruct Symfony on how to connect. We do this by changing the .env
file:
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
We just need to update this line to reflect the values we set in our docker-compose.yml
file:
DATABASE_URL=mysql://dbuser:dbpassword@127.0.0.1:3306/basic_json_api
Remember that if using Docker, your basic_json_api
database will already be created when you start the container. No need to run any extra commands.
# bin/console doctrine:database:create # not needed in Docker
bin/console doctrine:schema:update --force
Updating database schema...
1 query was executed
[OK] Database schema updated successfully!

This is enough to populate our basic_json_api
database with a single table: album
, containing four columns:
id
title
release_date
track_count
That's our DB ready and able to save (persist
) data.
Making Use of Symfony Form
The best way that I know to turn arrays into entities is to use a Symfony form. This way we gain access to all the structuring, validation, and flexibility that Symfony's Form Component brings to the table.
Unfortunately, this does add complexity over just new
ing up an entity and whacking our raw data in via setters.
Fortunately with Symfony 4's Maker Bundle, we do reduce a bunch of boilerplate typing.
Note: In Symfony 4.0.6 the Maker Bundle was improved yet further still, with the Form generator now taking in an Entity name, and producing a form with the field names already pre-set. Super nice.
We can make
a new form type really rather easily:
bin/console make:form AlbumType
created: src/Form/AlbumType.php
Success!
Next: Add fields to your form and start using it.
Find the documentation at https://symfony.com/doc/current/forms.html
Forms can be a complex subject. Fortunately our form only has three form fields. Let's add these in:
<?php
// src/Form/AlbumType.php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add('releaseDate', DateTimeType::class)
->add('trackCount', NumberType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Album::class,
]);
}
}
As a heads up, this doesn't quite meet our needs just yet.
The Behat test expects to be able to send in data looking like this:
{
"title": "Awesome new Album",
"track_count": 7,
"release_date": "2030-12-05T01:02:03+00:00"
}
But as it stands, our form would only work with data looking like this:
{
"title": "Awesome new Album",
"trackCount": 7,
"releaseDate": {
"date": {
"year": 2019,
"month": 12,
"day": 5
},
"time": {
"hour": 13,
"minute": 42
}
}
}
Can you imagine the look on your front end devs face if you told them to work with this?
Now, aside from the date / releaseDate
property being an object, rather than a string, the less obvious change is Symfony expects the field releaseDate
, but our Behat test wants us to use release_date
.
Fixing both of these problems is a case of telling Symfony to use our own options, rather than using the defaults.
To use options in a form field, we pass in the options as the third argument to a call to add
:
<?php
// src/Form/AlbumType.php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
- ->add('releaseDate', DateTimeType::class)
+ ->add(
+ 'releaseDate',
+ DateTimeType::class,
+ [
+ 'widget' => 'single_text',
+ 'format' => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
+ ]
+ )
->add('trackCount', NumberType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
]
);
}
}
You can find all the form options here.
Simply provide the option name as the key, and the required value and you're able to use any of the available options very easily. Sometimes the hard part is knowing the options' value - such as with the date format to accept an ISO8601 compatible date. Why ISO8061? Because that's what JavaScript works with.
This fixes the datetime
issue.
To fix the form field name issues we need to use the property_path
option.
The property path allows us to say hey, Symfony, we are going to be sending in data that looks like this: release_date
, but on the entity, we use this: releaseDate
, so do me a favour and seamlessly convert between the two, ok? And Symfony is all like, yeah bro, cool.
<?php
// src/Form/AlbumType.php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add(
- 'releaseDate',
+ 'release_date',
DateTimeType::class,
[
'widget' => 'single_text',
'format' => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
+ 'property_path' => 'releaseDate',
]
)
- ->add('trackCount', NumberType::class);
+ ->add(
+ 'track_count',
+ NumberType::class,
+ [
+ 'property_path' => 'trackCount',
+ ]
+ );
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
]
);
}
}
I've spanned these things out over multiple lines for two reasons:
- Readability
- I use the PhpStorm code formatter as a PHP substitute for Prettier.
The unusual / unintuitive part of using the property_path
is in that you need to change the form property name (technically called the child
).
In other words, you need to change the ->add('trackCount', ...)
to ->add('track_count', ...)
.
To me, I instinctively want to set the property_path
to track_count
, and leave the first argument as-is. Anyway, that's not how it works, so don't do that :)
Lastly, there's one further change we need to make. And we need to make this on any form that will be exposed by our Symfony 4 JSON API:
<?php
// src/Form/AlbumType.php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add(
'release_date',
DateTimeType::class,
[
'widget' => 'single_text',
'format' => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
'property_path' => 'releaseDate',
]
)
->add(
'track_count',
NumberType::class,
[
'property_path' => 'trackCount',
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
+ 'csrf_protection' => false,
]
);
}
}
This is a JSON API. We are expecting data from other sites. CSRF doesn't apply here.
Saving Album Data To The Database
Given what we have described in our Behat POST
test, the incoming data submission should now be happily meeting the expectations of the AlbumType
.
We'll use our AlbumController
to pass the incoming POST
through our form.
The form will transform the array of data into a populated Album
instance.
As the Album
has all the Doctrine annotations we can then save / persist
our newly populated Album
entity off to our database:
<?php
namespace App\Controller;
use App\Entity\Album;
use App\Form\AlbumType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class AlbumController extends AbstractController
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(
EntityManagerInterface $entityManager
) {
$this->entityManager = $entityManager;
}
/**
* @Route("/album", name="post_album", methods={"POST"})
*/
public function post(
Request $request
) {
$data = json_decode(
$request->getContent(),
true
);
$form = $this->createForm(AlbumType::class, new Album());
$form->submit($data);
if (false === $form->isValid()) {
return new JsonResponse(
[
'status' => 'error',
]
);
}
$this->entityManager->persist($form->getData());
$this->entityManager->flush();
return new JsonResponse(
[
'status' => 'ok',
],
JsonResponse::HTTP_CREATED
);
}
}
Notice that unlike how things typically worked in Symfony 2, or Symfony 3, we now use constructor injection to pass in the required services. Dependency injection, rather than service location.
This is good.
Our controller dependencies are now much more explicit.
And thanks to service autowiring, there's no extra work required on our part. We tell Symfony what our controller needs, and Symfony gets it for us.
Once we have access to the entity manager, we can save off our changes to the DB:
$this->entityManager->persist($form->getData());
$this->entityManager->flush();
This gives us a working form submission.
Hoorah, A Passing Behat Test!
With all of these steps completed we have our second passing Behat test.
Second?
Yes, our Healthcheck feature was already passing :)
This is the first passing scenario in the album.feature
file.
php vendor/bin/behat --tags=t
Feature: Provide a consistent standard JSON API endpoint
In order to build interchangeable front ends
As a JSON API developer
I need to allow Create, Read, Update, and Delete functionality
Background: # features/album.feature:7
Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
@t
Scenario: Can add a new Album # features/album.feature:56
Given the request body is: # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
"""
{
"title": "Awesome new Album",
"track_count": 7,
"release_date": "2030-12-05T01:02:03+00:00"
}
"""
When I request "/album" using HTTP POST # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
Then the response code is 201 # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
1 scenario (1 passed)
4 steps (4 passed)
0m0.12s (9.60Mb)
And the database:

Notice the three records from our Background
step are added in, and then our fourth scenario specific data. Kinda nice.