Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Denying Access, access_control & Roles

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

We've now talked a lot about authentication: the process of logging in. And... we're even logged in right now. So let's get our first look at authorization. That's the fun part where we get to run around and deny access to different parts of our site.

Hello access_control

The easiest way to kick someone out of your party is actually right inside of config/packages/security.yaml. It's via access_control:

... lines 2 - 38
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

Un-comment the first entry:

... lines 2 - 40
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

The path is a regular expression. So this basically says:

If a URL starts with /admin - so /admin or /admin* - then I shall deny access unless the user has ROLE_ADMIN.

We'll talk more about roles in a minute... but I can tell you that our user does not have that role. So... let's try to go to a URL that matches this path. We actually do have a small admin section on our site. Make sure you're logged in... then go to /admin. Access denied! I've never been so happy to be rejected. We get kicked out with a 403 error.

On production, you can customize what this 403 error page looks like... in addition to customizing the 404 error page or 422.

Roles! User::getRoles()

So let's talk about these "roles" thingies. Open up the User class: src/Entity/User.php. Here's how this works. The moment we log in, Symfony calls this getRoles() method, which is part of UserInterface:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 15 - 78
* @see UserInterface
public function getRoles(): array
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
... lines 90 - 154

We return an array of whatever roles this user should have. The make:user command generated this so that we always have a role called ROLE_USER... plus any extra roles stored on the $this->roles property. That property holds an array of strings... which are stored in the database as JSON:

... lines 1 - 12
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 15 - 26
* @ORM\Column(type="json")
private $roles = [];
... lines 31 - 154

This means that we can give each user as many roles as we want. So far, when we've created our users, we haven't given them any roles yet... so our roles property is empty. But thanks to how the getRoles() method is written, every user at least has ROLE_USER. The make:user command generated the code like this because all users need to have a least one role... otherwise they wander around our site like half-dead zombie users. It's... not pretty.

So, by convention, we always give a user at least ROLE_USER. Oh, and the only rule about roles - that's a mouthful - is that they must start with ROLE_. Later in the tutorial, we'll learn why.

Anyways, the moment we log in, Symfony calls getRoles(), we return the array of roles, and it stores them. We can actually see this if we click the security icon on the web debug toolbar. Yup! Roles: ROLE_USER.

So then, when we go to /admin, this matches our first access_control entry, it checks to see if we have ROLE_ADMIN, we don't, and it denies access.

Only ONE access_control Matches

Oh, but there's one important detail to know about access_control: only one will ever be matched on a request.

For example, suppose you had two access controls like this:

    # ...
      - { path: ^/admin, roles: ROLE_ADMIN }
      - { path: ^/admin/foo, roles: ROLE_USER }

If we went to /admin, that would match the first rule and only use the first rule. It works like routing: it goes down the access control list one-by-one and as soon as it finds the first match, it stops, and uses only that entry.

This will help us later when we deny access to all of a section except for one URL. But for now, just be aware of it!

And... that's it. Access controls give us a really easy way to secure entire sections of our site. But it's just one way to deny access. Soon we'll talk about how we can deny access on a controller-by-controller basis, which I really like.

But before we do, I know that if I try to access this page without ROLE_ADMIN, I get the 403 forbidden error. But what if I try to access this page as an anonymous user? Go to /logout? We're now not logged in.

Go back to /admin and... whoa! An error!

Full authentication is required to access this resource.

Next, let's talk about the "entry point" of your firewall: the way that you help anonymous users start the login process.

Leave a comment!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", //
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3