Decoupling your user model from Symfony Security

A Symfony security approach that separates application users from framework-specific security models.
Hexagonal architecture keeps business rules separate from framework code. Symfony's security interfaces can blur that boundary when the domain User also becomes the framework's authenticated user.
Symfony's documentation describes the usual setup:
permissions are always linked to a user object. If you need to secure (parts of) your application, you need to create a user class. This is a class that implements
UserInterface. Often, this is a Doctrine entity [4].
This approach is direct, but it makes the domain model depend on Symfony.
Several Symfony developers have documented another approach: create a class used only by the security system [1, 2, 3].
Symfony's security system has changed since some of those articles appeared. This post applies the same separation to the newer APIs.
Separate the two user models
-
Create a security user class in
SecurityUser.php. -
Create its provider in
SecurityUserProvider.php. -
Register both in
config/packages/security.yaml.
Create SecurityUser
This class contains only the data Symfony needs for authentication and authorization. It implements UserInterface but remains separate from the domain User.
Create SecurityUserProvider
The user provider loads a SecurityUser from an identifier such as an email address. It can query a database or another source through the application's domain model.
<?php
declare(strict_types=1);
namespace Infrastructure\Framework\Symfony\Security;
use Domain\Model\User\Repository\UserRepository;
use Symfony\Component\Security\Core\{
Exception\UserNotFoundException,
User\UserInterface,
User\UserProviderInterface
};
final readonly class SecurityUserProvider implements UserProviderInterface
{
public function __construct(
private UserRepository $userRepository
) {
}
#[\Override]
public function refreshUser(UserInterface $user): UserInterface
{
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
#[\Override]
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->getByEmail($email);
if ($user === null) {
throw new UserNotFoundException();
}
return SecurityUser::create($user);
}
#[\Override]
public function supportsClass(string $class): bool
{
return $class === SecurityUser::class;
}
}Register the provider in config/packages/security.yaml:
security:
# ...
providers:
app_user_provider:
id: Infrastructure\Framework\Symfony\Security\SecurityUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
target: app_login
access_control:
- { path: ^/login$, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }Keep Symfony security at the boundary
Symfony includes authenticators such as form_login. Applications with different authentication rules can use a custom authenticator and load users through this SecurityUserProvider.
public function authenticate(Request $request): Passport
{
$token = $request->request->get('_csrf_token');
$email = $request->request->get('email');
$password = $request->request->get('password');
$request
->getSession()
->set(SecurityRequestAttributes::LAST_USERNAME, $email);
$passport = new Passport(
new UserBadge($email, $this->securityUserProvider->loadUserByIdentifier(...)),
new PasswordCredentials($password),
[
new CsrfTokenBadge('authenticate', $token),
new RememberMeBadge(),
]
);
return $passport;
}When code fetches the authenticated user through getUser() or app.user, Symfony returns SecurityUser, not the domain User. SecurityUser exists only for authentication and authorization. The domain model therefore stays independent of Symfony's interfaces.
If a page needs profile or other domain data, load it into a view model. The view model can expose the required fields without making the domain User double as Symfony's security user.