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!                                                                             
Symfony 4 JSON API DB example

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 newing 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:

  1. Readability
  2. 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:

symfony-4-basic-json-api-passing-post-test

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

Episodes

# Title Duration
1 What will our JSON API actually do? 08:46
2 What needs to be in our Database for our Tests to work? 12:32
3 Cleaning up after each Test Run 02:40
4 Docker makes for Easy Databases 09:01
5 Healthcheck [Raw Symfony 4] 07:53
6 Send in JSON data using POST [Raw Symfony 4] 05:33
7 Keep your data nice and tidy using Symfony's Form [Raw Symfony 4] 10:48
8 Validating incoming JSON [Raw Symfony 4] 08:26
9 Nicer error messages [Raw Symfony 4] 06:23
10 GET'ting data from our Symfony 4 API [Raw Symfony 4] 08:11
11 GET'ting a collection of Albums [Raw Symfony 4] 01:50
12 Update existing Albums with PUT [Raw Symfony 4] 05:00
13 Upsetting Purists with PATCH [Raw Symfony 4] 02:39
14 Hitting DELETE [Raw Symfony 4] 02:11
15 How to open your API to the outside world with CORS [Raw Symfony 4] 07:48
16 Getting Setup with Symfony 4 and FOSRESTBundle [FOSRESTBundle] 09:11
17 Healthcheck [FOSRESTBundle] 06:14
18 Handling POST requests [FOSRESTBundle] 08:31
19 Saving POST data to the database [FOSRESTBundle] 09:44
20 Work with XML, or JSON, or Both [FOSRESTBundle] 04:31
21 Going far, then Too Far with the ViewResponseListener [FOSRESTBundle] 03:19
22 GET'ting data from your Symfony 4 API [FOSRESTBundle] 05:58
23 GET'ting a Collection of data from your Symfony 4 API [FOSRESTBundle] 01:27
24 Updating with PUT [FOSRESTBundle] 02:58
25 Partially Updating with PATCH [FOSRESTBundle] 02:15
26 DELETE'ing Albums [FOSRESTBundle] 01:27
27 Handling Errors [FOSRESTBundle] 08:58
28 Introducing the API Platform [API Platform] 08:19
29 The Entry Point [API Platform] 04:30
30 The Context [API Platform] 05:52
31 Healthcheck - Custom Endpoint [API Platform] 05:17
32 Starting with POST [API Platform] 07:08
33 Creating Entities with the Schema Generator [API Platform] 07:38
34 Defining A Custom POST Route [API Platform] 07:31
35 Finishing POST [API Platform] 06:29
36 GET'ting One Resource [API Platform] 02:50
37 GET'ting Multiple Resources [API Platform] 02:59
38 PUT to Update Existing Data [API Platform] 02:19
39 DELETE to Remove Data [API Platform] 01:15
40 No One Likes Errors [API Platform] 03:28