Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

App & Test Setup

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Hey friends. Yea! It's time for part 3 of our API Platform tutorial series. First, let's do a status check, because we have been busy!

In part 1, we got everything we needed for a pretty sweet API. We talked about JSON-LD and OpenAPI, operations and config, serialization groups, validation, relations, IRIs, filtering and more.

In part 2, we talked about security, logging in, adding authorization checks to operations, making it so that certain fields can be read or written only by specific users and some pretty serious work related to custom normalizers for even more control over exactly which fields each user sees.

So what's in part 3? It's time to take customizations to the next level, like making it possible to publish an item and run code when that happens... kind of like a custom "publish" operation... but with a RESTful twist. We'll also add complex security rules around who can publish an item under different conditions. Then we'll do everything custom: add completely custom fields, completely custom API resources that aren't backed by Doctrine, custom filters and we'll even dive deep into API Platform's input and output DTO system. Whoa.

To be honest, this was not an easy tutorial to write. Not because it was hard to figure out how to make these customizations - though, some of this is tricky - but because we're getting so deep into API Platform, that sometimes there are multiple ways to accomplish something. We'll find the best paths and learn why. Oh, and a huge thanks to Kévin Dunglas and Antoine Bluchet - core API platform developers - who helped me a ton to find those best paths.

Project Setup

So... let's go! To POST the most new data to your brain, you should totally code along with me! Download the course code from this page and, after unzipping it, you'll find a start/ directory with the same code that you see here. Pop open up the README.md file for all the details about getting this project working on your machine.

Now, it's optional, but, for this tutorial, I'm using a special Docker and symfony binary integration to spin up and configure my database. The details are in the README and you can learn more about it in our Symfony Doctrine Tutorial.

Anyways, one of the steps in the README is to find a terminal, move into the project and run:

symfony serve -d

to start a dev web server at Copy that URL, find your browser and say hello to... CheeseWhiz! Our peer-to-peer cheese selling site where the world can find - and purchase - the cheesy treasures that you forgot were in your fridge.

Our site does have a tiny frontend - this is build in Vue.js - but mostly it's just for logging in. The vast majority of the site is an API. Go to /api to see the interactive documentation generated by Swagger.

Updates since Episode 2

Now, if you coded along with me in episodes one and two, first: you rock, and second, this is the same app, but with some changes... so make sure to download the latest code. First, we upgraded our dependencies to use Symfony 5.1 and API platform 2.5:

79 lines composer.json
... lines 2 - 3
"require": {
... lines 5 - 7
"api-platform/core": "^2.1",
... lines 9 - 16
"symfony/asset": "5.1.*",
"symfony/console": "5.1.*",
"symfony/dotenv": "5.1.*",
"symfony/expression-language": "5.1.*",
"symfony/flex": "^1.1",
"symfony/framework-bundle": "5.1.*",
"symfony/http-client": "5.1.*",
"symfony/monolog-bundle": "^3.4",
"symfony/security-bundle": "5.1.*",
"symfony/twig-bundle": "5.1.*",
"symfony/validator": "5.1.*",
"symfony/webpack-encore-bundle": "^1.6",
"symfony/yaml": "5.1.*",
... line 30
... lines 32 - 71
"extra": {
"symfony": {
... line 74
"require": "5.1.*"

I also added Foundry to our system, which is a new library that makes it really easy to create dummy data:

79 lines composer.json
... lines 2 - 3
"require": {
... lines 5 - 29
"zenstruck/foundry": "^1.1"
... lines 32 - 77

We're using Foundry inside of our data fixtures - so src/DataFixtures/AppFixtures.php - as a quick way to create a bunch of users and cheese listings:

... lines 1 - 12
class AppFixtures extends Fixture
... lines 15 - 21
public function load(ObjectManager $manager)
$user = UserFactory::new()->create([
'email' => 'cheesefan@example.com',
'username' => 'cheesefan',
'password' => $this->passwordEncoder->encodePassword(new User(), 'cheese'),
$listingFactory = CheeseListingFactory::new([
'owner' => $user,
'title' => 'Mysterious munster',
'description' => 'Origin date: unknown. Actual origin... also unknown.',
'price' => 1500,
'title' => 'Block of cheddar the size of your face!',
'description' => 'When I drive it to your house, it will sit in the passenger seat of my car.',
'price' => 5000,
// then create 30 more

We also use this inside our test suite. You don't need to know too much about Foundry, but if you're interested, check out our Symfony Doctrine tutorial for more details.

Running the Tests

Now, before we start, we already have a test suite in our project with some basic functional tests for our 2 API resources: CheeseListing:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
public function testCreateCheeseListing()
... lines 14 - 43
public function testUpdateCheeseListing()
... lines 48 - 67
public function testGetCheeseListingCollection()
... lines 72 - 102
public function testGetCheeseListingItem()
... lines 107 - 119

and User:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
public function testCreateUser()
... lines 13 - 24
public function testUpdateUser()
... lines 29 - 45
public function testGetUser()
... lines 50 - 72

We're going to rely on tests a lot in this tutorial... in part because, as cool as this interactive documentation is - and we will use it - it gets a bit tedious to constantly fill out the boxes to test manually. And, of course, having tests will guarantee our API doesn't break.

So before we start breaking things on purpose, let's make sure the tests pass. At your terminal, run:

symfony run bin/phpunit


Using symfony php bin/phpunit is even better because it can intelligently find your PHP executable.

First, symfony run is just a shortcut to execute PHP. So this mean:

php bin/phpunit

The difference is that symfony run will inject the Docker environment variables, which will give my script access to the database.

Anyways, the first time you run this, it will download PHPUnit in the background... and once it finishes... boom! At the bottom, it executes our tests and they are working. We have permission to start breaking things!

The Database Test Config

By the way, our tests - of course - run in the test environment... and I do have a little bit of special configuration for the test environment in this app. Open up config/packages/test/ - so the files that are only loaded in the test environment - /doctrine.yaml:

# config/packages/test/doctrine.yaml
url: '%env(resolve:DATABASE_TEST_URL)%'

I've overridden the database config to read an environment variable called DATABASE_TEST_URL. If you're using the Docker Integration that I describe in the README, then this environment variable is being automatically exposed. If you look in docker-compose.yaml, there are two database containers: database and database_test:

version: '3.7'
... lines 4 - 13
... lines 15 - 20

Thanks to this, the symfony binary exposes a DATABASE_URL environment variable that points to the first container and a DATABASE_TEST_URL env var that points to the second.

In other words, in the test environment, we're using the database_test container:

... line 1
url: '%env(resolve:DATABASE_TEST_URL)%'

And, if you open the main doctrine.yaml, in the other environments, we're reading DATABASE_URL, which points to the first container:

url: '%env(resolve:DATABASE_URL)%'
... lines 4 - 19

That's a long way of saying that our tests use a different container than our dev environment. If you're not using this same Docker setup, feel free to customize things. In .env.test, I am setting DATABASE_TEST_URL to DATABASE_URL:

7 lines .env.test
... lines 1 - 4
... lines 6 - 7

Which means that it falls back to using your normal connection config. Feel free to tweak any of these files and let us know if you have questions.

Next, one of the things that we did in the last course was create a custom data persister: UserDataPersister:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
... lines 12 - 20
public function supports($data): bool
return $data instanceof User;
* @param User $data
public function persist($data)
if ($data->getPlainPassword()) {
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
public function remove($data)

Let's see why we did this, use decoration to make it more powerful and learn how data persisters are different than some other parts of ApiPlatform, like context builders.

Leave a comment!

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.4.0 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0