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 SubscribeThe access control system in API Platform instantly gives you a lot of power: you can check for a simple role or write more complex logic and... it works!
But... it's also ugly. And... it can get even uglier! What if I said that a user should be able to update a CheeseListing
if they are the owner of the CheeseListing
or they are an admin user. We could... maybe add an or
to the expression... and then we might need parentheses... No, that's not something I want to hack into my annotation expression. Instead, let's use a voter!
Voters technically have nothing to do with API Platform... but they do work super well as a way to keep your API Platform access controls clean and predictable. Find your terminal and run:
php bin/console make:voter
Call it CheeseListingVoter
. I commonly have one voter for each entity or "resource" that has complex access rules. This creates src/Security/Voter/CheeseListingVoter.php
.
... lines 1 - 4 | |
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | |
use Symfony\Component\Security\Core\Authorization\Voter\Voter; | |
use Symfony\Component\Security\Core\User\UserInterface; | |
class CheeseListingVoter extends Voter | |
{ | |
protected function supports($attribute, $subject) | |
{ | |
// replace with your own logic | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, ['POST_EDIT', 'POST_VIEW']) | |
&& $subject instanceof \App\Entity\BlogPost; | |
} | |
... lines 18 - 40 | |
} |
Before we dive into the new class, go to CheeseListing
. Instead of saying is_granted('ROLE_USER') and previous_object.getOwner() == user
, simplify to is_granted('EDIT', previous_object)
.
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
* itemOperations={ | |
... lines 20 - 22 | |
* "put"={ | |
* "access_control"="is_granted('EDIT', previous_object)", | |
... line 25 | |
* }, | |
... line 27 | |
* }, | |
... lines 29 - 39 | |
* ) | |
... lines 41 - 50 | |
*/ | |
class CheeseListing | |
... lines 53 - 211 |
This... deserves some explanation. The word EDIT
... well... I just invented that. We could use EDIT
or MANAGE
or CHEESE_LISTING_EDIT
... it's any word that describes the "intention" of the "access" you want to check: I want to check to see if the current user can "edit" this CheeseListing
. This string will be passed to the voter and, in a minute, we'll see how to use it. We're also passing previous_object
as the second argument. Thanks to this, the voter will also receive the CheeseListing
that we're deciding access on.
Here's how the voter system works: whenever you call is_granted()
, Symfony loops through all of the "voters" in the system and asks each one:
Hey! Lovely request we're having, isn't it? Do you happen to know how to decide whether or not the current user has
EDIT
access to thisCheeseListing
object?
Symfony itself comes with basically two core voters. The first knows how to decide access when you call is_granted()
and pass it ROLE_
something, like ROLE_USER
or ROLE_ADMIN
. It determines that by looking at the roles on the authenticated user. The second voter knows how to decide access if you call is_granted()
and pass it one of the IS_AUTHENTICATED_
strings: IS_AUTHENTICATED_FULLY
, IS_AUTHENTICATED_REMEMBERED
or IS_AUTHENTICATED_ANONYMOUSLY
.
Now that we've created a class and made it extend Symfony's Voter
base class, our app has a third voter. This means that, whenever someone calls is_granted()
, Symfony will call the supports()
method and pass it the $attribute
- that's the string EDIT
, or ROLE_USER
- and the $subject
, which will be the CheeseListing
object in our case.
Our job here is to answer the question: do we know how to decide access for this $attribute
and $subject
combination? Or should another voter handle this?
We're going to design our voter to decide access if the $attribute
is EDIT
- and we may support other strings later... like maybe DELETE
- and if $subject
is an instanceof CheeseListing
.
... lines 1 - 9 | |
class CheeseListingVoter extends Voter | |
{ | |
protected function supports($attribute, $subject) | |
{ | |
// replace with your own logic | |
// https://symfony.com/doc/current/security/voters.html | |
return in_array($attribute, ['EDIT']) | |
&& $subject instanceof CheeseListing; | |
} | |
... lines 19 - 41 | |
} |
If anything else is passed - like ROLE_ADMIN
- supports()
will return false and Symfony will know to ask a different voter.
But if we return true
from supports()
, Symfony will call voteOnAttribute()
and pass us the same $attribute
string - EDIT
- the same $subject
- CheeseListing
object - and a $token
, which contains the authenticated User
object. Our job in this method is clear: return true if the user should have access or false if they should not.
Let's start by helping my editor: add @var CheeseListing $subject
to hint to it that $subject
will definitely be a CheeseListing
.
After this, the generated code has a switch-case statement - a nice example for a voter that handles two different attributes for the same object. I'll delete the second case, but leave the switch-case statement in case we do want to support another attribute later.
So, if $attribute
is equal to EDIT
, let's put our security business logic. If $subject->getOwner() === $user
, return true! Access granted. Otherwise, return false.
... lines 1 - 19 | |
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) | |
{ | |
... lines 22 - 27 | |
/** @var CheeseListing $subject */ | |
... line 29 | |
// ... (check conditions and return true to grant permission) ... | |
switch ($attribute) { | |
case 'EDIT': | |
if ($subject->getOwner() === $user) { | |
return true; | |
} | |
return false; | |
} | |
return false; | |
} | |
... lines 42 - 43 |
That's it! Oh, in case we make a typo and pass some other attribute to is_granted()
, the end of this function always return false
to deny access. That's cool, but let's make this mistake super obvious. Throw a big exception:
Unhandled attribute "%s"
and pass that $attribute
.
... lines 1 - 19 | |
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) | |
{ | |
... lines 22 - 39 | |
throw new \Exception(sprintf('Unhandled attribute "%s"', $attribute)); | |
} | |
... lines 42 - 43 |
I love it! Our access_control
is simple: is_granted('EDIT', previous_object)
. If we've done our job, this will call our voter and everything will work just like before. And hey! We can check that by running out test!
php bin/phpunit --filter=testUpdateCheeseListing
Scroll up... all green!
But... I had a different motivation originally for refactoring this into a voter: I want to also allow "admin" users to be able to edit any CheeseListing
. For that, we'll check to see if the user has some ROLE_ADMIN
role.
To check if a user has a role from inside a voter, we could call the getRoles()
method on the User
object... but that won't work if you're using the role hierarchy feature in security.yaml
. A more robust option - and my preferred way of doing this - is to use the Security
service.
Add public function __construct()
with one argument: Security $security
. I'll hit Alt + Enter -> Initialize Fields to create that property and set it
... lines 1 - 7 | |
use Symfony\Component\Security\Core\Security; | |
... lines 9 - 10 | |
class CheeseListingVoter extends Voter | |
{ | |
private $security; | |
public function __construct(Security $security) | |
{ | |
$this->security = $security; | |
} | |
... lines 19 - 53 | |
} |
Inside voteOnAttribute
, for the EDIT
attribute, if $this->security->isGranted('ROLE_ADMIN')
, return true.
... lines 1 - 27 | |
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) | |
{ | |
... lines 30 - 38 | |
switch ($attribute) { | |
case 'EDIT': | |
... lines 41 - 44 | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return true; | |
} | |
... lines 48 - 49 | |
} | |
... lines 51 - 52 | |
} | |
... lines 54 - 55 |
That's lovely. I don't have a test for this... but you could add one in CheeseListingResourceTest
by creating a third user, giving them ROLE_ADMIN
, logging in and trying to edit the CheeseListing
. Or you could unit test the voter itself if your logic is getting pretty crazy.
Let's at least make sure we didn't break anything. Go tests go!
php bin/phpunit --filter=testUpdateCheeseListing
All good.
I love voters, and this is the way I handle access controls in API Platform. Sure, if you're just checking for a role, no problem: use is_granted('ROLE_ADMIN')
. But if your logic gets any more complex, use a voter.
Next, our API still requires an API client to POST an encoded version of a user's password when creating a new User
resource. That's crazy! Let's learn how to "hook" into the "saving" process so we can intercept the plain text password and encode it.
Hey Tac-Tacelosky !
API Platform 3 should come out sometime this summer - we're waiting on that before we give the tutorials a facelift for newer php and symfony version. I know, it's not ideal until then :).
About the fixtures, there are no fixtures for this course: you just start with an empty database. We DO introduce data fixtures before episode 3, so you'll see some fixtures there (they load with the normal bin/console doctrine:fixtures:load
).
Cheers!
I have the start and final working locally with Symfony 6, and am going through the tutorial. A challenge is that this is about security, and security has changed between Symfony 4.3 and 6. OK if I put the source code with Symfony 6 on github?
No problem at all - in fact, that would be super great :).
> A challenge is that this is about security, and security has changed between Symfony 4.3 and 6
That's definitely true. Fortunately, the biggest changes - to custom authenticators - I think isn't something that affects this tutorial... which is lucky!
Cheers!
I've updated all the dependencies and recipes: https://github.com/tacman/a...
BUT tests don't work (likely because of the backporting of apiplatform 2.5 testing client).
Ah, cool - thanks Tac!
About the tests... yes, that's very possible. You could (if you want to) delete that src/ApiPlatform directory entirely, then update a few use statements around the app to point to the REAL API platform classes, since your app nicely uses v2.6.
But anyways, thanks - it's nice to have resources like this. Oh, and soon I can share that we will update our comments system to be "in house" - and one of the features we want to add is the ability to "highlight" extra helpful comments from users, like this one :).
Cheers!
I'm likely using the wrong version of something (PHPUnit?), as I'm getting tripped up on self::$container. Sigh. And $client. What is the correct base class for the tests? I tried use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
Change that self::$container to static::getContainer()
- that's a newer way for newer Symfony versions. I think your base class is good!
About $client, it should be, I believe, the normal $client = static::createClient();
.
Cheers!
I'm getting pretty close... BUT still not working. Perhaps I'm not setting the client correctly?
1) App\Tests\Functional\CheeseListingResourceTest::testCreateCheeseListing
not passing the correct owner
Failed asserting that the Response status code is 400.
HTTP/1.1 422 Unprocessable Content
Hmm, I think that the test line should just change to look for a 422 status code:
$this->assertResponseStatusCodeSame(422, 'not passing the correct owner');
It looks like I WAS testing for a 400 status code previously, but I can't remember why. 422 actually makes more sense: passing the wrong owner fails due to the IsValidOwner constraint, so that's a validation error. The status code changed at some point in API Platform 2.6 - https://symfonycasts.com/screencast/api-platform-security/validator-logic#comment-5574495978
So, just change that line - I think you're good!
Cheers!
Hmmm It was a bit confusing after they added security and security_post_denormalize. We need to use just "object" with security. And to be exactly the same with this video, one can use security_post_denormalize with previous_object.
Hey teh_policer!
Hmm, it sounds like maybe my description of how things should look in API Platform 2.5 was not very clear? Is that correct? I was thinking that the ACL should look like this in 2.5:
(2.4)
"access_control"="is_granted('ROLE_USER') and previous_object.getOwner() == user",
(2.5)
"security"="is_granted('ROLE_USER') and object.getOwner() == user",
Did you find that the 2.5 version code above didn't work? Or was it just confusing? Let me know - I'd like to make this more clear if I can :).
Thanks!
I wonder how to do a UserVoter. For User entity itemOperations, I can do"put" = { "access_control" = "(is_granted('ROLE_USER') and object == user) or is_granted('ROLE_ADMIN')" }
. But if I want to use Voter, I am not sure what to compare $user
object with in switch statement. I think I can compare $user->getId()
with $subject->getId()
, but I am not sure that's the best option.
// in voteOnAttribute function
switch($attribute) {
case 'EDIT':
//Is comparing ID safe???
if ($subject->getId() == $user->getId()) {
return true;
}
}
Thanks!
Hey Sung L. !
Good question. So... comparing the id's is absolutely a safe thing to do - it will check if the currently-authenticated user's id is equal to the id of the user being edited. This is totally safe. A more interesting question is actually this: is comparing the objects safe: $subject === $user
. The answer to that is also "yes" - if the currently-authenticated user is the same as the user being modified, then $subject
and $user
will literally be the same object in memory (e.g. if you changed the email on $subject
, then it would also be changed on $user
because they are actually the same object). This is true simply because this is how Doctrine works: the security system will initially query for the currently-authenticated user. Later, when API Platform queries for that same User, Doctrine returns the same object (instead of making a 2nd query and returning a separate object with the same data).
I may have over-answered your question... or missed it entirely. Let me know ;)
Cheers!
Hello, if i have an entity, lets say PrivateCheeseListing, which relates to PrivateCheese, and it outputs PrivateCheeses data when i call PrivateCheeseListing, if i have a PrivateCheese item owned by me and another one PrivateCheese owned by someone else, which i have no access to, it is still possible for me to take the IRI "/api/cheese/123", which i have no access to, create a CheeseListing with it, so the JSON contains "cheese": "/api/private_cheese/123" and API Platform will not check if i have access to the related resource and saves it anyways. Is it possible to easily implement a check so that api platform checks the security i configured before relating and saving the item? Or will i have to write a Persister for each and every entity which checks each and every relation?
Again for clarity
If i call GET /api/private_cheese/123
I will have a 404 returned.
If i POST to /api/private_cheese_listing with:
{
"cheese": "/api/private_cheese/123",
}
This will return:
{
"@id": "/api/private_cheese_listing/123"
"cheese": {
"@id": "/api/private_cheese/123",
"personal_info": "i am exposed ohn no"
}
}
Hi Alex T.!
I think I understand! I believe what you are asking about is covered in this section - https://symfonycasts.com/sc... - and the answer is "do this with a validator". This gets a bit tricky. I should not be able to create a new PirvateCheeseListing (or modify a PrivateCheeseListing) and relate it to a PrivateCheese that someone else owns. That should NOT be allowed, for security reasons. But, the tool to solve this problem is actually the validation system: the user is trying to send "invalid data"... it's just invalid due to security reasons. So, it's a situation where validation & security work together.
Specifically, I would add a custom validator, put it on PrivateCheeseListing, and then it would validate that all of the related PrivateCheese objects ARE owned by the current user.
Let me know if that helps!
Cheers!
Hello, is there a simple way to pass both object and previous_object to the Voter? (to grant by property transition)
To have something like:
<blockquote>is_granted('ROLE_PUBLISHER') and previous_object.isPusblished() == false and object.isPublished() == true
</blockquote>
(Please don't mind the example I need this for a complex case).
The way I implemented is by passing the previous_object to the Voter, then getting the object from the database using the EntityManager inside the Voter, which I don't think is the best approach.
(also I wonder if this a validation case and not security)
Thanks!
Hey Ahmed O. !
I've never done this before but, yes, you CAN do this :). But, before I say how, I would usually handle this in a custom validator because it is not a matter of "whether or not the user can perform this action". It is more of a "given this user, this is an invalid value for this field". We do something like that here: https://symfonycasts.com/screencast/api-platform-security/validator-logic. The tricky part is getting the original data so you can see if the field has changed. There is an example of how to fetch the original data from the unit of work here - https://stackoverflow.com/questions/17306635/symfony2-form-validator-comparing-old-and-new-values-before-flush#answers - the example is old, but the important part is still relevant :).
Now, about the voter (if you choose to go this direction). You can pass anything to the 2nd argument of is_granted. For example:
is_granted('CAN_PUBLISH, { "previous": "previous_object", "object": object })
(if I messed up my syntax above, apologies!). In this situation, the $subject
that's passed to your voter would be an array, which is a bit rare, but TOTALLY legal. You can then read the keys off of that.
Cheers!
How can I do filter automatic in GET collectionOperations by owner == user logged (Security) ?
Hey Cesar C. !
Excellent question! You an find info about that a bit later in this tutorial :) https://symfonycasts.com/sc...
Cheers!
Sorry, but why we should taking care about the access using Exception throwing, whether we are not handling the possible typo in the support method?
Hey Anton,
Just a sanity check. Nothing big, as we will deny access for those cases, but it's kind of an edge case, i.e. this kind of things is not implemented and so it may lead to potential bugs in the future. But as I say, it's just a sanity check to notify developer about possible problem "earlier".
Cheers!
Hi!
Is it safe and recommended to get the Request in a Voter? For example, with Ajax I'm sending a small object {user: "user IRI", object: "object IRI"}. For editing or deleting, no problem: a classic voter does the job with $object->getOwner(). But for creating...? How can I be sure that the sent user's IRI is actually the logged in user's IRI?
I read on StackOverflow that I can add in my Voter "private $requestStack" then use "$this->requestStack->getMasterRequest()->get('user')" (to get the user IRI of the sent object), but it doesn't seem to work, and a "dd($this->requestStack->getMasterRequest()" doesn't return anything so I'm not sure what to do.
Edit: ...or should I delete the user part of the sent datas and use a Custom Symfony Controller to create the relationship between the object and the user?
Hey Xav,
You can inject whatever you want in your services, just literally any services. Yeah, Request service is a bit special, and to inject it - you would need to inject RequestStack instead, and then get the actual current request via getMasterRequest() for example. So injecting the RequestStack is the correct way get the request in your services.
To get access to the current user (logged in user) - you can inject another service called Symfony\Component\Security\Core\Security service which has getUser() method that will return the current user if it exist or null.
You will be able to use those objects to do whatever business logic you need.
I hope this helps.
Cheers!
Thank you!
I had a hard time to figure what was wrong with what I was doing. Actually, I didn't need the requestStack, but I needed to add "security_post_denormalize" in my Api Platform Configuration. Not sure I've understood why, though. Anyway, it seems to work now...
Is there a simple way of debugging a voter? I wanted it to return errors at different steps of the voting process, and I couldn't manage to do that. For example, throw an explicit error if there's no $user, or return the $subject, or the "$this->requestStack->getMasterRequest()", or understand why the voter abstains whereas it shouldn't (that was my problem for a long time until I added security_post_denormalize). I tried to dump those vars but nothing appeared in the debugging tools. I must have missed something.
PS: could you please write something about Subresources Voters...?It seems that I have now to understand why "api/users/{id}/agendas" doesn't care at all about my voters....
Hey Xav,
Take a look at Symfony Web Developer Toolbar, it should help with debugging voters as well, but only those what was called, so make sure you call isGranted() in the controller or is_granted() Twig function in a twig template of the page you're on. Otherwise, simple dd(); or dump(); die; in the voter helps as well. Or just inject logger service into the voter and log things to watch them later in logs.
I hope this helps!
Cheers!
In a voter, Why does $subject->getOwner() === $user bring back true when $token->getUser() is an object and $subject->getOwner() is an Iri "/api/users/1"?
Hey Gediminas N.!
Excellent question! All of your assumptions are correct, except for:
$subject->getOwner() is an IRI "/api/users/1"
So, $subject
as you know is a CheeseListing
object. This means that $subject->getOwner()
is actually calling CheeseListing::getOwner()
which returns a User
object, not an IRI string.
The confusing part is probably that if you make a request to /api/cheeses
, you get back JSON like this:
{
"title": "..."
"owner": "/api/users/1"
}
This is a key feature of API Platform: when it sees that the CheeseListing.owner property is a User object, and it knows that User object is an API Resource, it converts the User object into an IRI string: /api/users/1.
The same thing happens when you send data. If you, for example, sent a POST request to /api/cheeses with this data:
{
"title": "..."
"owner": "/api/users/1"
}
... API Platform would transform the /api/users/1 into a User object (by querying the database for the User object with id 1) before ultimately setting that object into the owner property of CheeseListing.
Does that help? Or did I confuse thing more? ;)
Cheers!
My voters use constants for their roles.
const EDIT_POST = 'EDIT_POST';
Is there anyway to use these inside the annotation the same way we would do in a controller?
I've been trying a couple of things, but nothing worked so far.
Using Symfony 5.2.1 with PHP 7.4 and API Platform 2.5.9
Hey julien_bonnier!
Hmm. I think in annotations in general you can have things like MyClass::EDIT_POST. The problem is that, when you need to reference the constant, you're not actually writing "annotations" - you're inside a "string" in annotations - specifically the "string security expression". So, I don't think that any normal annotation tricks would work here.
So, I think what you need to do is use an expression language trick - specifically the constant function - https://symfony.com/doc/current/components/expression_language/syntax.html#working-with-functions - but I don't think it will feel very nice, unfortunately - I think you would need something like constant("My\Full\Class::EDIT_POST")
to use it. On the bright side, it would work and if you ever changed the constant (or removed it) the code would continue to function (or, explode with a clear error in the case of removing it).
Let me know if that helps!
Cheers!
Hey, not really on topic but i was wondering how you guys "styled" the command line outputs? With the nice red/ green backgorund. I thought i saw it somewhere here on SFCasts but i can't find it anymore. I'm getting annoyed by mine own simple output... aarggh. Please let me know, thanks!
Hey lexhartman
Good question, as I know we do nothing with it, I mean it's default behaviour in osx/linux terminals, there is some difficulties only with windows terminals, but I think it's possible there too
Cheers!
Heyho
Liked your post. I ran into one problem though:
On POST requests the $subject parameter on the supports method will always be null. So there seems to be now way to use this approach for create requests out of the box or did I miss something?
Cheers and looking forward to feedback on this
Phil
Hey @Phil!
Yea, "creates" are always (even outside of an API) a "different animal". That's because the only thing you know is that the user wants to "create a CheeseListing". And so, depending on your needs, you have 2 options:
1) Block access with a role - e.g. is_granted('ROLE_CHEESE_LISTING_CREATE')
. That works for simple cases where it makes sense to assign a role to any user who can create cheese listings.
2) Invent a new "attribute" (like we do in this chapter), but with no subject: is_granted('CREATE_CHEESE_LISTING')
. You would then, in a custom voter, have a supports like this:
protected function supports($attribute, $subject)
{
return in_array($attribute, ['CREATE_CHEESE_LISTING'])
&& $subject === null;
}
Then, in voteOnAttribute, you can use whatever logic you need to figure out if the current user should be able to create a cheese listing.
Let me know if that's what you were looking for!
Cheers!
Hey,
How to use Voter with paginated collection operation ? On the support method, subject is an instance of ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator and can't do anything to check if subject is an instance of my resource.
And more generally, if $subject is an array of resources, what is the best practice ? Checking if the first array element in an instance of the resource ?
Thanks !
Hey Vincent
In that case I think what you need to is to check if the user has access to such operation, instead of checking on object by object basis
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
Is source code that runs with a more current version of Symfony available? Given that this is about security, it'd be great to see code that works with 6.0 / PHP 8, neither of which you can use with this code base.
Also, I can't figure out how to load fixtures -- are they in the repo? I just want to download the code, install it, and see it run.