The User and the Provider

What Symfony actually requires of a user class, and how the entity provider loads one from the database by its identifier.

Syntaxclass User implements UserInterface, PasswordAuthenticatedUserInterface

Symfony has no user table and no opinion about your columns. It asks for two things: a class it can treat as a user, and a user provider that can load one of those from its identifier.

The interface

In Symfony 8, UserInterface declares exactly two methods. getRoles(): array returns role strings. getUserIdentifier(): string returns the stable label for the account, usually an email address or a username. Older code also implemented eraseCredentials(); that method is no longer part of the interface, and the top-level erase_credentials option is deprecated as of Symfony 8.1.

If the user signs in with a password, also implement PasswordAuthenticatedUserInterface. Its single method, getPassword(): ?string, returns the stored hash. A user who only ever signs in through an external provider does not need it.

The provider

The entity provider ships with the framework. Give it a class and the property to query, and it loads users with one equality lookup on that column. On every later request the firewall reads the identifier out of the session and asks the provider to load the user again, so the object in the token is fresh from the database. If the provider cannot find it, that session is logged out.

When one column is not enough

To accept either an email or a username, implement UserLoaderInterface on the repository, write the query yourself with bound parameters, and remove property from the configuration.

Example

Example · php
<?php
// src/Entity/User.php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')] // user is a reserved word in PostgreSQL
#[ORM\UniqueConstraint(name: 'UNIQ_USER_EMAIL', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    private string $email = '';

    /** @var array<int, string> stored as a json column */
    #[ORM\Column]
    private array $roles = [];

    /** The hash produced by the password hasher, never the plaintext. */
    #[ORM\Column(length: 255)]
    private string $password = '';

    public function getUserIdentifier(): string
    {
        return $this->email;
    }

    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER'; // everyone who is signed in holds this

        return array_values(array_unique($roles));
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setEmail(string $email): void
    {
        $this->email = $email;
    }

    public function setPassword(string $hashedPassword): void
    {
        $this->password = $hashedPassword;
    }
}

// With $roles holding ['ROLE_EDITOR']:
// Output of getRoles(): ['ROLE_EDITOR', 'ROLE_USER']

When to use it

  • Add authentication to an application whose users table already exists with its own column names.
  • Let people sign in with either their email address or their username.
  • Keep staff accounts and customer accounts in separate tables behind separate firewalls.
  • Work out why a user is silently logged out after an administrator edits their email.

More examples

Pointing the provider at the entity

Two keys do the work. class names the entity, and property names the single column the provider queries with an equality match. The provider is a service like any other; naming it app_user_provider lets a firewall point at it, and an application with staff and customers in separate tables declares two providers and two firewalls. Set manager_name only when the entity is mapped on a second Doctrine entity manager, otherwise leave it out and the default manager is used.

Example · yaml
# config/packages/security.yaml
security:
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
                # manager_name: customer   # only if this entity lives on a
                                           # non-default Doctrine manager

    firewalls:
        main:
            lazy: true
            provider: app_user_provider

Accepting an email or a username

When the repository implements UserLoaderInterface, the entity provider calls this method instead of building its own query, and you remove the property key from security.yaml. Returning null means no such user, which the firewall turns into a failed login. Reach for this whenever the lookup is more than one column: an email-or-username form, a soft-delete flag to exclude, or a tenant id that must also match. The identifier goes in as a bound parameter; concatenating it into the DQL string would be an injection point.

Example · php
<?php
// src/Repository/UserRepository.php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;

/**
 * @extends ServiceEntityRepository<User>
 */
class UserRepository extends ServiceEntityRepository implements UserLoaderInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

    public function loadUserByIdentifier(string $identifier): ?User
    {
        return $this->getEntityManager()
            ->createQuery(
                'SELECT u FROM App\Entity\User u'
                .' WHERE u.email = :identifier OR u.username = :identifier'
            )
            // Bound, never concatenated. There is no safe way to inline it.
            ->setParameter('identifier', $identifier)
            ->getOneOrNullResult();
    }
}

Refusing a duplicate identifier at the application layer

Two layers, and both are needed. ORM\UniqueConstraint creates the database index that makes a duplicate physically impossible; the UniqueEntity constraint runs during validation and turns the same rule into a readable form error instead of a 500 from a constraint violation. Without the index, two rows can share an email and which one signs in depends on how the database happens to order them. Without the constraint, the first duplicate signup is an exception page.

Example · php
<?php
// src/Entity/User.php (attributes only, the rest of the class unchanged)

declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'UNIQ_USER_EMAIL', fields: ['email'])]
#[UniqueEntity(fields: ['email'], message: 'That email address is already registered.')]
class User
{
    #[ORM\Column(length: 180)]
    #[Assert\NotBlank]
    #[Assert\Email]
    private string $email = '';

    // ... the rest of the class
}

Discussion

  • Be the first to comment on this lesson.