Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Auto Setting the "owner"

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

Every DragonTreasure must have an owner... and to set that, when you POST to create a treasure, we require that field. I think we should make that optional. So, in the test, stop sending the owner field:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 41
public function testPostToCreateTreasure(): void
{
... lines 44 - 45
$this->browser()
... lines 47 - 51
->post('/api/treasures', HttpOptions::json([
... lines 53 - 56
'owner' => '/api/users/'.$user->getId(),
]))
... lines 59 - 60
;
}
... lines 63 - 179
}

When this happens, let's automatically set it to the currently-authenticated user.

Make sure the test fails. Copy the method name... and run it:

symfony php bin/phpunit --filter=testPostToCreateTreasure

Nailed it. Got a 422, 201 expected. That 422 is a validation error from the owner property: this value should not be null.

Removing the Owner Validation

If we're going to make it optional, we need to remove that Assert\NotNull:

... lines 1 - 88
class DragonTreasure
{
... lines 91 - 136
#[Assert\NotNull]
... line 138
private ?User $owner = null;
... lines 140 - 251
}

And now when we try the test:

symfony php bin/phpunit --filter=testPostToCreateTreasure

Well hello there gorgeous 500 error! Probably it's because the null owner_id is going kaboom when it hits the database. Yup!

Using the State Processors

So: how can we automatically set this field when it's not sent? In the previous API Platform 2 tutorial, I did this with an entity listener, which is a fine solution. But in API Platform 3, just like when we hashed the user password, there's now a really nice system for this: the state processor system.

As a reminder, our POST and PATCH endpoints for DragonTreasure already have a state processor that comes from Doctrine: it's responsible for saving the object to the database. Our goal will feel familiar at this point: to decorate that state process so we can run extra code before saving.

Like before, start by running:

php bin/console make:state-processor

Call it DragonTreasureSetOwnerProcessor:

... lines 1 - 2
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
// Handle the state
}
}

Over in src/State/, open that up. Ok, let's decorate! Add the construct method with private ProcessorInterface $innerProcessor:

... lines 1 - 5
use ApiPlatform\State\ProcessorInterface;
... lines 7 - 9
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
public function __construct(private ProcessorInterface $innerProcessor)
{
}
... lines 15 - 19
}

Then down in process(), call that! This method doesn't return anything - it has a void return - so we just need $this->innerProcessor - don't forget that part like I am - ->process() passing $data, $operation, $uriVariables and $context:

... lines 1 - 9
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
... lines 12 - 15
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

Now, to make Symfony use our state processor instead of the normal one from Doctrine, add #[AsDecorator]... and the id of the service is api_platform.doctrine.orm.state.persist_processor:

... lines 1 - 6
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
... lines 12 - 19
}

Cool! Now, everything that uses that service in the system will be passed our service instead... and then the original will be passed into us.

Decorating Multiple Times is Ok!

Oh, and there's something cool going on. Look at UserHashPasswordStateProcessor. We're decorating the same thing there! Yea, we're decorating that service twice, which is totally allowed! Internally, this will create a, sort of, chain of decorated services.

Ok, let's get to work setting the owner. Autowire our favorite Security service so we can figure out who is logged in:

... lines 1 - 7
use Symfony\Bundle\SecurityBundle\Security;
... lines 9 - 11
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
public function __construct(private ProcessorInterface $innerProcessor, private Security $security)
{
}
... lines 17 - 25
}

Then, before we do the saving, if $data is an instanceof DragonTreasure and $data->getOwner() is null and $this->security->getUser() - making sure the user is logged in - then $data->setOwner($this->security->getUser()):

... lines 1 - 11
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if ($data instanceof DragonTreasure && $data->getOwner() === null && $this->security->getUser()) {
$data->setOwner($this->security->getUser());
}
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

That should do it! Run that test:

symfony php bin/phpunit --filter=testPostToCreateTreasure

Yikes! Allowed memory size exhausted. I smell recursion! Because... I'm calling process() on myself: I need $this->innerProcessor->process():

... lines 1 - 11
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
{
... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
... lines 20 - 23
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

Now:

symfony php bin/phpunit --filter=testPostToCreateTreasure

A passing test is so much cooler than recursion. And the owner field is now optional!

Next: we currently return all treasures from our GET collection endpoint, including unpublished treasures. Let's fix that by modifying the query behind that endpoint to hide them.

Leave a comment!

5
Login or Register to join the conversation
Rsteuber Avatar
Rsteuber Avatar Rsteuber | posted 4 months ago

When will this finally be ready?

Reply

Hey @Rsteuber!

I JUST recorded the audio today - it'll probably be a week or two before the video is out, but you refresh, you'll see the final "script" for this chapter (though, code blocks will come later). Also, if you download the code, what we do in this chapter is already in there. I hope that helps!

Cheers!

Reply
Rsteuber Avatar

Hmm, it seems I can't download the source code yet. Guess i have to wait when it is released.

Cheers :)

Reply

Hey @Rsteuber

Yea, the download button it's disabled for non-published chapters, but if you only want to download the course code, you can go to any other chapter and download it there

Cheers!

Reply
Rsteuber Avatar

Hey Ryan, Yes thank you so very much! It really helps :)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice