Skip to content

Admin Account Management & Domain-Restricted Registration - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Embed account management (change email, change password) into the admin backend, and restrict registration to pre-approved email domains managed via admin CRUD.

Architecture: Two independent features. Feature 1 adds an AdminAccountController that reuses existing account forms/services but renders in the admin layout. Feature 2 adds an AllowedDomain entity with admin CRUD and a custom Symfony validator constraint on registration.

Tech Stack: Symfony 8.0, PHP 8.4, Doctrine ORM, Twig, PHPUnit


Feature 1: Admin Account Management

Task 1: Admin Account Controller

Files: - Create: src/Controller/Admin/AccountController.php - Test: tests/Controller/Admin/AccountControllerTest.php

Step 1: Write the failing test

Create tests/Controller/Admin/AccountControllerTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Controller\Admin;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class AccountControllerTest extends WebTestCase
{
    public function testAccountPageRequiresAdmin(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin/account');

        $this->assertResponseRedirects('/login');
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Controller/Admin/AccountControllerTest.php -v Expected: FAIL (route not found)

Step 3: Write the controller

Create src/Controller/Admin/AccountController.php:

<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\User;
use App\Form\AccountChangePasswordFormType;
use App\Form\ChangeEmailFormType;
use App\Repository\UserRepository;
use App\Service\EmailVerificationService;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/account')]
class AccountController extends AbstractController
{
    public function __construct(
        private readonly EmailVerificationService $emailVerificationService,
        private readonly UserPasswordHasherInterface $passwordHasher,
        private readonly UserRepository $userRepository,
        private readonly MailerInterface $mailer,
        private readonly UrlGeneratorInterface $urlGenerator,
        #[Autowire('%env(MAIL_FROM)%')]
        private readonly string $mailFrom,
    ) {
    }

    #[Route('', name: 'admin_account', methods: ['GET'])]
    public function index(): Response
    {
        /** @var User $user */
        $user = $this->getUser();

        return $this->renderAccountPage($user);
    }

    #[Route('/change-email', name: 'admin_account_change_email', methods: ['POST'])]
    public function changeEmail(Request $request): Response
    {
        /** @var User $user */
        $user = $this->getUser();

        $form = $this->createForm(ChangeEmailFormType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $newEmail = $form->get('email')->getData();

            if ($newEmail === $user->getEmail()) {
                $this->addFlash('error', 'This is already your current email address.');

                return $this->redirectToRoute('admin_account');
            }

            if (null !== $this->userRepository->findByEmail($newEmail)) {
                $this->addFlash('error', 'This email address is already in use.');

                return $this->redirectToRoute('admin_account');
            }

            $this->emailVerificationService->initiatePendingEmailChange($user, $newEmail);

            $verificationUrl = $this->urlGenerator->generate('admin_account_verify_email', [
                'token' => $user->getVerificationToken(),
            ], UrlGeneratorInterface::ABSOLUTE_URL);

            $email = (new TemplatedEmail())
                ->from($this->mailFrom)
                ->to(new Address($newEmail))
                ->subject('Verify your new email address')
                ->htmlTemplate('email/email_change_verification.html.twig')
                ->context([
                    'verificationUrl' => $verificationUrl,
                    'tokenLifetimeHours' => 24,
                ]);

            $this->mailer->send($email);

            $this->addFlash('success', sprintf('A verification link has been sent to %s. Your email won\'t change until you confirm.', $newEmail));

            return $this->redirectToRoute('admin_account');
        }

        return $this->renderAccountPage($user, changeEmailForm: $form);
    }

    #[Route('/verify-email/{token}', name: 'admin_account_verify_email', methods: ['GET'])]
    public function verifyEmail(string $token): Response
    {
        try {
            $this->emailVerificationService->verifyEmailChangeToken($token);
            $this->addFlash('success', 'Your email has been updated.');
        } catch (\Exception $e) {
            $this->addFlash('error', $e->getMessage());
        }

        return $this->redirectToRoute('admin_account');
    }

    #[Route('/cancel-email-change', name: 'admin_account_cancel_email_change', methods: ['POST'])]
    public function cancelEmailChange(Request $request): Response
    {
        /** @var User $user */
        $user = $this->getUser();

        if (!$this->isCsrfTokenValid('cancel-email-change', $request->getPayload()->getString('_token'))) {
            $this->addFlash('error', 'Invalid CSRF token.');

            return $this->redirectToRoute('admin_account');
        }

        $this->emailVerificationService->cancelPendingEmailChange($user);

        $this->addFlash('success', 'Email change cancelled.');

        return $this->redirectToRoute('admin_account');
    }

    #[Route('/change-password', name: 'admin_account_change_password', methods: ['POST'])]
    public function changePassword(Request $request): Response
    {
        /** @var User $user */
        $user = $this->getUser();

        $form = $this->createForm(AccountChangePasswordFormType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $currentPassword = $form->get('currentPassword')->getData();

            if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
                $form->get('currentPassword')->addError(new FormError('The current password is incorrect.'));

                return $this->renderAccountPage($user, changePasswordForm: $form);
            }

            $user->setPassword($this->passwordHasher->hashPassword(
                $user,
                $form->get('plainPassword')->getData()
            ));
            $this->userRepository->save($user);

            $this->addFlash('success', 'Your password has been updated.');

            return $this->redirectToRoute('admin_account');
        }

        return $this->renderAccountPage($user, changePasswordForm: $form);
    }

    private function renderAccountPage(
        User $user,
        ?FormInterface $changeEmailForm = null,
        ?FormInterface $changePasswordForm = null,
    ): Response {
        return $this->render('admin/account/index.html.twig', [
            'changeEmailForm' => $changeEmailForm ?? $this->createForm(ChangeEmailFormType::class, ['email' => $user->getEmail()]),
            'changePasswordForm' => $changePasswordForm ?? $this->createForm(AccountChangePasswordFormType::class),
        ]);
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Controller/Admin/AccountControllerTest.php -v Expected: PASS

Step 5: Commit

git add src/Controller/Admin/AccountController.php tests/Controller/Admin/AccountControllerTest.php
git commit -m "Add admin account controller with email/password management"

Task 2: Admin Account Template

Files: - Create: templates/admin/account/index.html.twig

Step 1: Create the template

Create templates/admin/account/index.html.twig:

{% extends 'admin/base.html.twig' %}

{% block admin_title %}My Account{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <span>My Account</span>
{% endblock %}

{% block admin_content %}
    {% import 'admin/components/flash.html.twig' as flash %}

    {{ flash.render_flash_messages() }}

    <div class="admin-page-header">
        <h1 class="admin-page-title">My Account</h1>
    </div>

    {# Change Email #}
    <div class="admin-card">
        <div class="admin-card-header">
            <h2 class="admin-card-title">Email Address</h2>
        </div>
        <div class="admin-card-body">
            {% if app.user.pendingEmail %}
                <div class="admin-flash admin-flash-info">
                    <span>&#8505;</span>
                    <div>
                        Pending email change to <strong>{{ app.user.pendingEmail }}</strong>. Check your inbox.
                        <form method="post" action="{{ path('admin_account_cancel_email_change') }}" style="display: inline; margin-left: 10px;">
                            <input type="hidden" name="_token" value="{{ csrf_token('cancel-email-change') }}">
                            <button type="submit" class="admin-btn admin-btn-sm admin-btn-secondary">Cancel</button>
                        </form>
                    </div>
                </div>
            {% else %}
                {{ form_start(changeEmailForm, {action: path('admin_account_change_email'), attr: {class: 'admin-form'}}) }}
                    <div class="admin-form-group">
                        {{ form_label(changeEmailForm.email, 'Email Address', {'label_attr': {'class': 'admin-form-label'}}) }}
                        {{ form_widget(changeEmailForm.email, {'attr': {'class': 'admin-form-input'}}) }}
                        {% if changeEmailForm.email.vars.errors|length > 0 %}
                            <div class="admin-form-error">{{ form_errors(changeEmailForm.email) }}</div>
                        {% endif %}
                    </div>
                    <div class="admin-form-actions">
                        <button type="submit" class="admin-btn admin-btn-primary">Change Email</button>
                    </div>
                {{ form_end(changeEmailForm) }}
            {% endif %}
        </div>
    </div>

    {# Change Password #}
    <div class="admin-card">
        <div class="admin-card-header">
            <h2 class="admin-card-title">Password</h2>
        </div>
        <div class="admin-card-body">
            {{ form_start(changePasswordForm, {action: path('admin_account_change_password'), attr: {class: 'admin-form'}}) }}
                <div class="admin-form-group">
                    {{ form_label(changePasswordForm.currentPassword, null, {'label_attr': {'class': 'admin-form-label'}}) }}
                    {{ form_widget(changePasswordForm.currentPassword, {'attr': {'class': 'admin-form-input'}}) }}
                    {% if changePasswordForm.currentPassword.vars.errors|length > 0 %}
                        <div class="admin-form-error">{{ form_errors(changePasswordForm.currentPassword) }}</div>
                    {% endif %}
                </div>
                <div class="admin-form-group">
                    {{ form_label(changePasswordForm.plainPassword.first, null, {'label_attr': {'class': 'admin-form-label'}}) }}
                    {{ form_widget(changePasswordForm.plainPassword.first, {'attr': {'class': 'admin-form-input'}}) }}
                    {% if changePasswordForm.plainPassword.first.vars.errors|length > 0 %}
                        <div class="admin-form-error">{{ form_errors(changePasswordForm.plainPassword.first) }}</div>
                    {% endif %}
                </div>
                <div class="admin-form-group">
                    {{ form_label(changePasswordForm.plainPassword.second, null, {'label_attr': {'class': 'admin-form-label'}}) }}
                    {{ form_widget(changePasswordForm.plainPassword.second, {'attr': {'class': 'admin-form-input'}}) }}
                    {% if changePasswordForm.plainPassword.second.vars.errors|length > 0 %}
                        <div class="admin-form-error">{{ form_errors(changePasswordForm.plainPassword.second) }}</div>
                    {% endif %}
                </div>
                <div class="admin-form-actions">
                    <button type="submit" class="admin-btn admin-btn-primary">Change Password</button>
                </div>
            {{ form_end(changePasswordForm) }}
        </div>
    </div>
{% endblock %}

Step 2: Verify visually

Run: php -d variables_order=EGPCS bin/console cache:clear Navigate to /admin/account in the browser and verify the page renders correctly.

Step 3: Commit

git add templates/admin/account/index.html.twig
git commit -m "Add admin account template with email and password forms"

Files: - Modify: templates/admin/base.html.twig

Step 1: Add sidebar link

In templates/admin/base.html.twig, add a "My Account" link in the sidebar under the "Main" nav section, after the Users link (around line 730):

                <a href="{{ path('admin_account') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_account' ? 'active' : '' }}">
                    <span class="admin-nav-icon">&#9881;</span>
                    My Account
                </a>

Step 2: Add user dropdown in header

In templates/admin/base.html.twig, modify the admin-header-actions div (around line 780) to add user info and logout link after the {% block admin_actions %} block:

                <div class="admin-header-actions">
                    {% block admin_actions %}{% endblock %}
                    <span style="color: var(--admin-secondary); font-size: 14px;">{{ app.user.email }}</span>
                    <a href="{{ path('admin_account') }}" class="admin-btn admin-btn-sm admin-btn-secondary">My Account</a>
                    <form action="{{ path('app_logout') }}" method="post" style="display: inline;">
                        <input type="hidden" name="_token" value="{{ csrf_token('logout') }}">
                        <button type="submit" class="admin-btn admin-btn-sm admin-btn-secondary">Log out</button>
                    </form>
                </div>

Step 3: Verify visually

Clear cache and verify the sidebar link and header dropdown appear correctly in the admin panel.

Step 4: Commit

git add templates/admin/base.html.twig
git commit -m "Add My Account link and user info to admin sidebar and header"

Feature 2: Domain-Restricted Registration

Task 4: AllowedDomain Entity

Files: - Create: src/Entity/AllowedDomain.php - Create: src/Repository/AllowedDomainRepository.php - Test: tests/Unit/Entity/AllowedDomainTest.php

Step 1: Write the failing test

Create tests/Unit/Entity/AllowedDomainTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Entity\AllowedDomain;
use PHPUnit\Framework\TestCase;

class AllowedDomainTest extends TestCase
{
    public function testNewAllowedDomainHasCorrectDefaults(): void
    {
        $domain = new AllowedDomain();

        self::assertNull($domain->getId());
        self::assertNull($domain->getDomain());
        self::assertNull($domain->getCreatedAt());
    }

    public function testSetDomain(): void
    {
        $domain = new AllowedDomain();
        $result = $domain->setDomain('atraxion.com');

        self::assertSame($domain, $result);
        self::assertSame('atraxion.com', $domain->getDomain());
    }

    public function testSetDomainNormalizesToLowercase(): void
    {
        $domain = new AllowedDomain();
        $domain->setDomain('Atraxion.COM');

        self::assertSame('atraxion.com', $domain->getDomain());
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/Entity/AllowedDomainTest.php -v Expected: FAIL (class not found)

Step 3: Create the entity

Create src/Entity/AllowedDomain.php:

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\AllowedDomainRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AllowedDomainRepository::class)]
#[ORM\Table(name: 'allowed_domain')]
#[ORM\HasLifecycleCallbacks]
class AllowedDomain
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $domain = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getDomain(): ?string
    {
        return $this->domain;
    }

    public function setDomain(string $domain): static
    {
        $this->domain = strtolower($domain);

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    #[ORM\PrePersist]
    public function setCreatedAtValue(): void
    {
        $this->createdAt = new \DateTimeImmutable();
    }
}

Create src/Repository/AllowedDomainRepository.php:

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\AllowedDomain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<AllowedDomain>
 */
class AllowedDomainRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, AllowedDomain::class);
    }

    public function isDomainAllowed(string $domain): bool
    {
        $totalCount = $this->count([]);

        if (0 === $totalCount) {
            return true;
        }

        return null !== $this->findOneBy(['domain' => strtolower($domain)]);
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/Entity/AllowedDomainTest.php -v Expected: PASS

Step 5: Generate migration

Run: php -d variables_order=EGPCS bin/console doctrine:migrations:diff Review the generated migration file.

Step 6: Commit

git add src/Entity/AllowedDomain.php src/Repository/AllowedDomainRepository.php tests/Unit/Entity/AllowedDomainTest.php migrations/
git commit -m "Add AllowedDomain entity with repository"

Task 5: AllowedEmailDomain Validator Constraint

Files: - Create: src/Validator/AllowedEmailDomain.php - Create: src/Validator/AllowedEmailDomainValidator.php - Test: tests/Unit/Validator/AllowedEmailDomainValidatorTest.php

Step 1: Write the failing test

Create tests/Unit/Validator/AllowedEmailDomainValidatorTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Validator;

use App\Repository\AllowedDomainRepository;
use App\Validator\AllowedEmailDomain;
use App\Validator\AllowedEmailDomainValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;

class AllowedEmailDomainValidatorTest extends TestCase
{
    private AllowedDomainRepository $repository;
    private ExecutionContextInterface $context;
    private AllowedEmailDomainValidator $validator;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(AllowedDomainRepository::class);
        $this->context = $this->createMock(ExecutionContextInterface::class);
        $this->validator = new AllowedEmailDomainValidator($this->repository);
        $this->validator->initialize($this->context);
    }

    public function testNullValueIsValid(): void
    {
        $this->context->expects(self::never())->method('buildViolation');

        $this->validator->validate(null, new AllowedEmailDomain());
    }

    public function testEmptyStringIsValid(): void
    {
        $this->context->expects(self::never())->method('buildViolation');

        $this->validator->validate('', new AllowedEmailDomain());
    }

    public function testAllowedDomainPasses(): void
    {
        $this->repository
            ->method('isDomainAllowed')
            ->with('atraxion.com')
            ->willReturn(true);

        $this->context->expects(self::never())->method('buildViolation');

        $this->validator->validate('user@atraxion.com', new AllowedEmailDomain());
    }

    public function testDisallowedDomainFails(): void
    {
        $this->repository
            ->method('isDomainAllowed')
            ->with('evil.com')
            ->willReturn(false);

        $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class);
        $violationBuilder->expects(self::once())->method('addViolation');

        $this->context
            ->expects(self::once())
            ->method('buildViolation')
            ->with('Registration is restricted to approved email domains.')
            ->willReturn($violationBuilder);

        $this->validator->validate('hacker@evil.com', new AllowedEmailDomain());
    }

    public function testEmptyAllowedDomainsTableAllowsAll(): void
    {
        $this->repository
            ->method('isDomainAllowed')
            ->willReturn(true);

        $this->context->expects(self::never())->method('buildViolation');

        $this->validator->validate('anyone@anywhere.com', new AllowedEmailDomain());
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/Validator/AllowedEmailDomainValidatorTest.php -v Expected: FAIL (classes not found)

Step 3: Create the constraint and validator

Create src/Validator/AllowedEmailDomain.php:

<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class AllowedEmailDomain extends Constraint
{
    public string $message = 'Registration is restricted to approved email domains.';
}

Create src/Validator/AllowedEmailDomainValidator.php:

<?php

declare(strict_types=1);

namespace App\Validator;

use App\Repository\AllowedDomainRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class AllowedEmailDomainValidator extends ConstraintValidator
{
    public function __construct(
        private readonly AllowedDomainRepository $allowedDomainRepository,
    ) {
    }

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof AllowedEmailDomain) {
            throw new UnexpectedTypeException($constraint, AllowedEmailDomain::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        $domain = substr((string) $value, strrpos((string) $value, '@') + 1);

        if (!$this->allowedDomainRepository->isDomainAllowed($domain)) {
            $this->context->buildViolation($constraint->message)->addViolation();
        }
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/Validator/AllowedEmailDomainValidatorTest.php -v Expected: PASS

Step 5: Commit

git add src/Validator/AllowedEmailDomain.php src/Validator/AllowedEmailDomainValidator.php tests/Unit/Validator/AllowedEmailDomainValidatorTest.php
git commit -m "Add AllowedEmailDomain validator constraint"

Task 6: Wire Validator into Registration Form

Files: - Modify: src/Form/RegistrationFormType.php

Step 1: Add the constraint to the email field

In src/Form/RegistrationFormType.php, add AllowedEmailDomain to the constraints array on the email field:

Add import: use App\Validator\AllowedEmailDomain;

Modify the email field constraints to:

'constraints' => [
    new NotBlank(message: 'Please enter your email address.'),
    new Email(message: 'Please enter a valid email address.'),
    new AllowedEmailDomain(),
],

Step 2: Run full test suite to verify nothing breaks

Run: bin/phpunit -v Expected: All tests PASS

Step 3: Commit

git add src/Form/RegistrationFormType.php
git commit -m "Wire AllowedEmailDomain validator into registration form"

Task 7: AllowedDomain Admin CRUD Controller

Files: - Create: src/Controller/Admin/AllowedDomainController.php - Create: src/Form/Admin/AllowedDomainFormType.php

Step 1: Create the form type

Create src/Form/Admin/AllowedDomainFormType.php:

<?php

declare(strict_types=1);

namespace App\Form\Admin;

use App\Entity\AllowedDomain;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;

/**
 * @extends AbstractType<AllowedDomain>
 */
class AllowedDomainFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('domain', TextType::class, [
                'label' => 'Domain',
                'constraints' => [
                    new NotBlank(message: 'Please enter a domain.'),
                    new Regex(
                        pattern: '/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/',
                        message: 'Please enter a valid domain (e.g., example.com).',
                    ),
                ],
                'attr' => [
                    'class' => 'admin-form-input',
                    'placeholder' => 'example.com',
                ],
                'help' => 'Enter the domain name without @ or protocol (e.g., atraxion.com).',
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => AllowedDomain::class,
        ]);
    }
}

Step 2: Create the controller

Create src/Controller/Admin/AllowedDomainController.php:

<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\AllowedDomain;
use App\Form\Admin\AllowedDomainFormType;
use App\Service\Admin\AdminPaginationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
 * @extends AbstractAdminController<AllowedDomain>
 */
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/allowed-domains')]
class AllowedDomainController extends AbstractAdminController
{
    public function __construct(
        EntityManagerInterface $entityManager,
        AdminPaginationService $paginationService,
    ) {
        parent::__construct($entityManager, $paginationService);
    }

    protected function getEntityClass(): string
    {
        return AllowedDomain::class;
    }

    protected function getFormType(): ?string
    {
        return AllowedDomainFormType::class;
    }

    protected function getEntityLabel(): string
    {
        return 'Allowed Domain';
    }

    protected function getRoutePrefix(): string
    {
        return 'admin_allowed_domains';
    }

    protected function getTemplateDirectory(): string
    {
        return 'admin/allowed_domains';
    }

    protected function getSearchFields(): array
    {
        return ['domain'];
    }

    #[Route('', name: 'admin_allowed_domains', methods: ['GET'])]
    #[Route('', name: 'admin_allowed_domains_index', methods: ['GET'])]
    public function index(Request $request): Response
    {
        $result = $this->listEntities($request);

        return $this->renderIndex($request, $result);
    }

    #[Route('/new', name: 'admin_allowed_domains_new', methods: ['GET', 'POST'])]
    public function new(Request $request): Response
    {
        return $this->handleCreateForm($request);
    }

    #[Route('/{id}/delete', name: 'admin_allowed_domains_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
    public function delete(Request $request, int $id): Response
    {
        $entity = $this->findEntityOr404($id);

        return $this->handleDelete($request, $entity);
    }
}

Step 3: Run PHPStan and CS fixer

Run: vendor/bin/phpstan analyse src/Controller/Admin/AllowedDomainController.php src/Form/Admin/AllowedDomainFormType.php Run: vendor/bin/php-cs-fixer fix

Step 4: Commit

git add src/Controller/Admin/AllowedDomainController.php src/Form/Admin/AllowedDomainFormType.php
git commit -m "Add AllowedDomain admin CRUD controller and form"

Task 8: AllowedDomain Admin Templates

Files: - Create: templates/admin/allowed_domains/index.html.twig - Create: templates/admin/allowed_domains/new.html.twig

Step 1: Create index template

Create templates/admin/allowed_domains/index.html.twig:

{% extends 'admin/base.html.twig' %}

{% block admin_title %}Allowed Domains{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <span>Allowed Domains</span>
{% endblock %}

{% block admin_actions %}
    <a href="{{ path('admin_allowed_domains_new') }}" class="admin-btn admin-btn-primary">
        &#43; Add Domain
    </a>
{% endblock %}

{% block admin_content %}
    {% import 'admin/components/flash.html.twig' as flash %}
    {% import 'admin/components/pagination.html.twig' as pagination %}

    {{ flash.render_flash_messages() }}

    <div class="admin-page-header">
        <h1 class="admin-page-title">{{ entity_label }}s</h1>
    </div>

    <div class="admin-flash admin-flash-info" style="margin-bottom: 20px;">
        <span>&#8505;</span>
        <div>
            When domains are configured, only users with email addresses matching these domains can register.
            If no domains are configured, registration is open to everyone.
        </div>
    </div>

    {# Search and Filters #}
    <div class="admin-filters">
        <form method="get" action="{{ path(route_prefix ~ '_index') }}" class="admin-filter-group" style="flex: 1;">
            <label class="admin-filter-label" for="search">Search</label>
            <div style="display: flex; gap: 10px;">
                <input type="text" name="q" id="search" value="{{ search }}" class="admin-filter-input" placeholder="Search by domain...">
                <button type="submit" class="admin-btn admin-btn-primary">Search</button>
                {% if search %}
                    <a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">Clear</a>
                {% endif %}
            </div>
        </form>
    </div>

    {# Table #}
    {% if result.items is empty %}
        <div class="admin-card">
            <div class="admin-empty-state">
                <div class="admin-empty-state-icon">&#127760;</div>
                <div class="admin-empty-state-title">No allowed domains configured</div>
                <p>Registration is currently open to all email domains.</p>
                <a href="{{ path('admin_allowed_domains_new') }}" class="admin-btn admin-btn-primary" style="margin-top: 15px;">
                    &#43; Add Domain
                </a>
            </div>
        </div>
    {% else %}
        <div class="admin-card">
            <div class="admin-card-body" style="padding: 0; overflow-x: auto;">
                <table class="admin-table">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>Domain</th>
                            <th>Created At</th>
                            <th style="width: 1%; white-space: nowrap;">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for domain in result.items %}
                            <tr>
                                <td>{{ domain.id }}</td>
                                <td>{{ domain.domain }}</td>
                                <td>{{ domain.createdAt|date('Y-m-d H:i') }}</td>
                                <td>
                                    <div class="admin-table-actions">
                                        <form action="{{ path('admin_allowed_domains_delete', {id: domain.id}) }}" method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to remove this domain? Users with this email domain will no longer be able to register.');">
                                            <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ domain.id) }}">
                                            <button type="submit" class="admin-btn admin-btn-sm admin-btn-danger" title="Delete">
                                                &#128465; Remove
                                            </button>
                                        </form>
                                    </div>
                                </td>
                            </tr>
                        {% endfor %}
                    </tbody>
                </table>
            </div>
        </div>

        {{ pagination.render_pagination(result, route_prefix ~ '_index', {q: search}) }}
    {% endif %}
{% endblock %}

Step 2: Create new template

Create templates/admin/allowed_domains/new.html.twig:

{% extends 'admin/base.html.twig' %}

{% block admin_title %}Add {{ entity_label }}{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <a href="{{ path(route_prefix ~ '_index') }}">Allowed Domains</a>
    <span class="admin-breadcrumb-separator">/</span>
    <span>Add New</span>
{% endblock %}

{% block admin_actions %}
    <a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
        &#8592; Back to List
    </a>
{% endblock %}

{% block admin_content %}
    {% import 'admin/components/flash.html.twig' as flash %}

    {{ flash.render_flash_messages() }}

    <div class="admin-page-header">
        <h1 class="admin-page-title">Add {{ entity_label }}</h1>
    </div>

    <div class="admin-card">
        <div class="admin-card-header">
            <h2 class="admin-card-title">Domain Information</h2>
        </div>
        <div class="admin-card-body">
            {{ form_start(form, {'attr': {'class': 'admin-form'}}) }}

            <div class="admin-form-group">
                {{ form_label(form.domain, null, {'label_attr': {'class': 'admin-form-label'}}) }}
                {{ form_widget(form.domain) }}
                {% if form.domain.vars.help %}
                    <div class="admin-form-help">{{ form.domain.vars.help }}</div>
                {% endif %}
                {% if form.domain.vars.errors|length > 0 %}
                    <div class="admin-form-error">{{ form_errors(form.domain) }}</div>
                {% endif %}
            </div>

            <div class="admin-form-actions">
                <button type="submit" class="admin-btn admin-btn-primary">
                    &#43; Add Domain
                </button>
                <a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
                    Cancel
                </a>
            </div>

            {{ form_end(form) }}
        </div>
    </div>
{% endblock %}

Step 3: Commit

git add templates/admin/allowed_domains/index.html.twig templates/admin/allowed_domains/new.html.twig
git commit -m "Add AllowedDomain admin templates"

Files: - Modify: templates/admin/base.html.twig

Step 1: Add sidebar link

In templates/admin/base.html.twig, add an "Allowed Domains" link in the sidebar under the "Main" nav section, after the "My Account" link added in Task 3:

                <a href="{{ path('admin_allowed_domains') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_allowed_domains' ? 'active' : '' }}">
                    <span class="admin-nav-icon">&#127760;</span>
                    Allowed Domains
                </a>

Step 2: Commit

git add templates/admin/base.html.twig
git commit -m "Add Allowed Domains link to admin sidebar"

Task 10: Run Database Migration & Full Test Suite

Step 1: Run migrations

Run: php -d variables_order=EGPCS bin/console doctrine:migrations:migrate

Step 2: Run full test suite

Run: bin/phpunit -v Expected: All tests PASS

Step 3: Run static analysis

Run: vendor/bin/phpstan analyse Expected: No errors

Step 4: Run code style fixer

Run: vendor/bin/php-cs-fixer fix --dry-run --diff If any issues: vendor/bin/php-cs-fixer fix

Step 5: Commit any fixes

git add -A
git commit -m "Fix code style issues"

Task 11: AllowedDomainRepository Unit Test

Files: - Test: tests/Unit/Repository/AllowedDomainRepositoryTest.php

Step 1: Write the test

Note: Since AllowedDomainRepository::isDomainAllowed hits the database, test the logic separately. We can test the repository's isDomainAllowed method via a functional/integration approach, or keep it simple and trust the unit test of the validator covers the flow.

If you want a pure unit test, test the validator more thoroughly (already done in Task 5).

Skip this task if the validator tests are sufficient. The validator test already covers the "allowed" vs "denied" vs "empty table" scenarios.


Summary

Task What Files
1 Admin Account Controller src/Controller/Admin/AccountController.php, test
2 Admin Account Template templates/admin/account/index.html.twig
3 Sidebar + Header links templates/admin/base.html.twig
4 AllowedDomain Entity src/Entity/AllowedDomain.php, repository, test
5 Validator Constraint src/Validator/AllowedEmailDomain*.php, test
6 Wire into Registration src/Form/RegistrationFormType.php
7 Admin CRUD Controller src/Controller/Admin/AllowedDomainController.php, form
8 Admin CRUD Templates templates/admin/allowed_domains/*.html.twig
9 Sidebar link templates/admin/base.html.twig
10 Migration & verification Run migrations, tests, phpstan, cs-fixer