Roles and access_control
Roles are plain strings on the user; role_hierarchy expands them and access_control rules are matched in order, first match wins.
access_control: - { path: '^/admin', roles: ROLE_ADMIN }A role is a plain string carried by the logged-in user, by convention prefixed ROLE_. It is not an object and has no definition anywhere: a user either has the string or does not. Authorization is the question "may this user do this", and roles are the coarsest way to answer it.
Where roles come from
Your user class returns them from getRoles(). Most applications store them in a JSON column and append ROLE_USER in code, so every authenticated user has at least one role even when the column is empty.
role_hierarchy in config/packages/security.yaml lets one role imply others. With ROLE_ADMIN: ROLE_USER, a check for ROLE_USER passes for an admin although that string is not stored. ROLE_ADMIN: ROLE_USER is shorthand for ROLE_ADMIN: [ROLE_USER]; they are the same entry. The hierarchy is expanded while the access decision is being made, by the voter that answers role checks, so getRoles() still returns only the stored list.
access_control
access_control is an ordered list of rules matched against the request path. Rules are tried top to bottom and the first rule that matches decides; the rest are never consulted. The specific rule therefore goes above the general one. A rule for ^/admin/users placed below ^/admin never runs. Here too roles: ROLE_ADMIN is shorthand for roles: [ROLE_ADMIN].
Two attributes are not roles. PUBLIC_ACCESS matches everybody, including visitors who are not logged in. IS_AUTHENTICATED_FULLY matches a user who authenticated in this session rather than one restored from a remember-me cookie.
Example
<?php
// src/Entity/User.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity]
class User implements UserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private string $email = '';
/** @var list<string> the roles stored in the database */
#[ORM\Column]
private array $roles = [];
// UserInterface asks for exactly two methods: getRoles() and getUserIdentifier()
public function getRoles(): array
{
$roles = $this->roles;
// guarantee one role for every authenticated user, even if the column is empty
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
public function getUserIdentifier(): string
{
return $this->email;
}
public function setRoles(array $roles): void
{
$this->roles = $roles;
}
}
// $user->setRoles(['ROLE_ADMIN', 'ROLE_ADMIN']);
// $user->getRoles();
// Output: ['ROLE_ADMIN', 'ROLE_USER']When to use it
- Open the login page to visitors while every other page requires a logged-in user.
- Give an admin section one rule and its user-management sub-section a stricter one.
- Let editors write to an API endpoint that everyone may read.
More examples
Hierarchy and ordered rules
A ROLE_SUPER_ADMIN user reaching /admin/users is checked against the first matching rule only; a ROLE_ADMIN user reaching the same path is denied, because the broader /admin rule below it is never consulted. Reach for this shape when a section has a stricter sub-section.
# config/packages/security.yaml
security:
role_hierarchy:
# shorthand: a single role instead of a list
ROLE_ADMIN: ROLE_USER
# longhand: the same thing written as an array
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
access_control:
# the login page must stay reachable by people who are not logged in
- { path: '^/login', roles: PUBLIC_ACCESS }
# most specific first: this rule would never run if it came second
- { path: '^/admin/users', roles: ROLE_SUPER_ADMIN }
- { path: '^/admin', roles: ROLE_ADMIN }
# roles: ROLE_USER is shorthand for roles: [ROLE_USER]
- { path: '^/account', roles: [ROLE_USER] }Checking a role outside a controller
The Security service answers role questions anywhere, not only in a controller. It expands the hierarchy, which is why comparing against the raw getRoles() list by hand gives a different and usually wrong answer. Use it in anything that builds navigation or a report that varies by role.
<?php
// src/Service/MenuBuilder.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Bundle\SecurityBundle\Security;
final class MenuBuilder
{
// Security is injected through the constructor, never fetched from the container
public function __construct(private readonly Security $security)
{
}
/** @return list<string> */
public function build(): array
{
$items = ['Dashboard'];
// isGranted() applies role_hierarchy, so ROLE_SUPER_ADMIN passes this check
if ($this->security->isGranted('ROLE_ADMIN')) {
$items[] = 'Admin';
}
return $items;
}
}
// For a user whose stored roles are ['ROLE_SUPER_ADMIN']:
// Output: ['Dashboard', 'Admin']Extra conditions on a rule
A rule can also test methods, ips, host and requires_channel. Every condition on one rule must match for that rule to be the one that decides, which is how a single path can carry different requirements per HTTP method.
# config/packages/security.yaml
security:
access_control:
# restrict by HTTP method as well as path
- { path: '^/api/articles', roles: ROLE_EDITOR, methods: [POST, PUT, DELETE] }
- { path: '^/api/articles', roles: PUBLIC_ACCESS, methods: [GET] }
# only reachable from the office network
- { path: '^/internal', roles: ROLE_USER, ips: ['10.0.0.0/8'] }
# force the whole site onto HTTPS
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
Discussion