Repository: kbond/starter-kit
Pull request #12: Verification
Author: login: kbond
Created: 2025-04-15T16:33:09Z | Merged: 2025-05-09T14:15:39Z

Built on #11
Ref: #2 

TODO:
- [x] Resend email



Diff:
diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml
index ffa0893..4cdbcc2 100644
--- a/config/packages/rate_limiter.yaml
+++ b/config/packages/rate_limiter.yaml
@@ -5,7 +5,11 @@ framework:
             limit: 1
             interval: 30 minutes
             lock_factory: null # todo remove in Symfony 7.3
-
+        verify_email:
+            policy: sliding_window
+            limit: 1
+            interval: 5 minutes
+            lock_factory: null # todo remove in Symfony 7.3
         forgot_password_email:
             policy: sliding_window
             limit: 1
diff --git a/src/Controller/User/RegistrationController.php b/src/Controller/User/RegistrationController.php
index 5dd1016..d949a65 100644
--- a/src/Controller/User/RegistrationController.php
+++ b/src/Controller/User/RegistrationController.php
@@ -47,7 +47,9 @@ public function register(
             $em->flush();
             $this->addFlash('success', 'Registration successful! You are now logged in.');
 
-            return $security->login($user, 'form_login');
+            $security->login($user, 'form_login');
+
+            return $this->forward(VerificationController::class.'::sendVerification');
         }
 
         return $this->render('user/register.html.twig', [
diff --git a/src/Controller/User/VerificationController.php b/src/Controller/User/VerificationController.php
new file mode 100644
index 0000000..6b28e9d
--- /dev/null
+++ b/src/Controller/User/VerificationController.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Repository\UserRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\UriSigner;
+use Symfony\Component\Mailer\Header\MetadataHeader;
+use Symfony\Component\Mailer\Header\TagHeader;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Http\Attribute\CurrentUser;
+
+use function Symfony\Component\Clock\now;
+
+final class VerificationController extends AbstractController
+{
+    private const EXPIRES_AFTER = 3600 * 24; // 24 hours
+    private const SESSION_USER_ID = 'verification_id';
+
+    #[Route('/send-verification', name: 'send_verification', methods: 'POST')]
+    public function sendVerification(
+        UriSigner $uriSigner,
+        MailerInterface $mailer,
+
+        #[CurrentUser]
+        User $user,
+
+        #[Target('verifyEmailLimiter')]
+        RateLimiterFactory $rateLimiter, // todo switch to RateLimiterInterface in 7.3
+    ): Response {
+        if ($user->isVerified()) {
+            return $this->redirectToRoute('homepage');
+        }
+
+        if (!$rateLimiter->create($user->getEmail())->consume()->isAccepted()) {
+            $this->addFlash('error', 'You recently requested an account verification. Please check your email for the link to verify your account.');
+
+            return $this->redirectToRoute('homepage');
+        }
+
+        $this->sendVerificationLink($uriSigner, $mailer, $user);
+        $this->addFlash('success', sprintf('A verification link has been sent to %s.', $user->getEmail()));
+
+        return $this->redirectToRoute('homepage');
+    }
+
+    #[Route('/verify-email/{id<\d+>}', name: 'verify_email')]
+    public function verify(
+        Request $request,
+        UserRepository $users,
+        EntityManagerInterface $em,
+        Security $security,
+        UriSigner $uriSigner,
+        ?int $id = null,
+    ): Response {
+        if (null !== $id) {
+            return $this->addUserIdToSessionAndRedirect($uriSigner, $request, $id);
+        }
+
+        if (!$user = $users->find($request->getSession()->get(self::SESSION_USER_ID, 0))) {
+            throw $this->createNotFoundException('User not found');
+        }
+
+        if ($user->isVerified()) {
+            $this->addFlash('error', 'This verification link has already been used.');
+
+            return $this->redirectToRoute('homepage');
+        }
+
+        $user->markVerified();
+        $em->flush();
+        $this->addFlash('success', 'Your email has been verified successfully.');
+        $request->getSession()->remove(self::SESSION_USER_ID);
+
+        return $security->login($user, 'form_login');
+    }
+
+    /**
+     * Session-based redirection to the password reset form.
+     *
+     * This ensures only browser sessions can access the reset form.
+     */
+    private function addUserIdToSessionAndRedirect(UriSigner $uriSigner, Request $request, int $id): Response
+    {
+        if (!$uriSigner->checkRequest($request)) {
+            $this->addFlash('error', 'The verification link is invalid or has expired, try resending.');
+
+            return $this->redirectToRoute('homepage');
+        }
+
+        $request->getSession()->set(self::SESSION_USER_ID, $id);
+
+        return $this->redirectToRoute('verify_email');
+    }
+
+    private function sendVerificationLink(
+        UriSigner $uriSigner,
+        MailerInterface $mailer,
+        User $user,
+    ): void {
+        $expires = now()->modify(sprintf('+%d seconds', self::EXPIRES_AFTER));
+        $link = $uriSigner->sign(
+            $this->generateUrl(
+                'verify_email',
+                ['id' => $user->getId()],
+                UrlGeneratorInterface::ABSOLUTE_URL
+            ),
+            $expires
+        );
+
+        $message = (new TemplatedEmail())
+            ->to(new Address($user->getEmail(), $user->getName()))
+            ->subject('Email Verification')
+            ->htmlTemplate('email/verify_email.html.twig')
+            ->context([
+                'user' => $user,
+                'link' => $link,
+                'expires' => $expires,
+            ])
+        ;
+
+        // add Mailer tag header (many email services use these to filter emails)
+        $message->getHeaders()->add(new TagHeader('verify-email'));
+
+        // add Mailer metadata with the link (many email services display these in their UI)
+        $message->getHeaders()->add(new MetadataHeader('link', $link));
+
+        $mailer->send($message);
+    }
+}
diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php
index 39fa424..383a0fc 100644
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -17,7 +17,7 @@ public function load(ObjectManager $manager): void
         ]);
 
         // admin user
-        UserFactory::new()->admin()->create([
+        UserFactory::new()->admin()->verified()->create([
             'email' => 'mom@example.com',
             'password' => 'password',
         ]);
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 097fdf9..2c3c7b6 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -40,6 +40,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column]
     private ?string $password = null;
 
+    #[ORM\Column(nullable: true)]
+    private ?\DateTimeImmutable $verifiedAt = null;
+
     public function getId(): ?int
     {
         return $this->id;
@@ -95,6 +98,10 @@ public function getRoles(): array
         // guarantee every user at least has ROLE_USER
         $roles[] = 'ROLE_USER';
 
+        if ($this->isVerified()) {
+            $roles[] = 'ROLE_VERIFIED';
+        }
+
         return array_unique($roles);
     }
 
@@ -144,4 +151,28 @@ public function getAvatarUrl(): string
             ))
         );
     }
+
+    public function markVerified(\DateTimeImmutable $at = new \DateTimeImmutable('now')): static
+    {
+        $this->verifiedAt = $at;
+
+        return $this;
+    }
+
+    public function isVerified(): bool
+    {
+        return null !== $this->verifiedAt;
+    }
+
+    public function getVerifiedAt(): ?\DateTimeImmutable
+    {
+        return $this->verifiedAt;
+    }
+
+    public function setVerifiedAt(?\DateTimeImmutable $verifiedAt): static
+    {
+        $this->verifiedAt = $verifiedAt;
+
+        return $this;
+    }
 }
diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php
index 621e82e..dfda447 100644
--- a/src/Factory/UserFactory.php
+++ b/src/Factory/UserFactory.php
@@ -26,6 +26,11 @@ public function admin(): self
         return $this->withRole('ROLE_ADMIN');
     }
 
+    public function verified(\DateTimeImmutable $at = new \DateTimeImmutable('now')): self
+    {
+        return $this->with(['verifiedAt' => $at]);
+    }
+
     public function withRole(string $value): self
     {
         return $this->beforeInstantiate(function (array $parameters) use ($value): array {
diff --git a/templates/base.html.twig b/templates/base.html.twig
index f536307..1d5a580 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -17,6 +17,14 @@
         {% else %}
             <a href="{{ path('login') }}">Login</a>
         {% endif %}
+        {% if app.user and not app.user.verified %}
+            <div class="alert alert-warning">
+                Your account isn't verified. Check your email for a verification link.
+                <form action="{{ path('send_verification') }}" method="post">
+                    <button type="submit">Resend Email</button>
+                </form>
+            </div>
+        {% endif %}
         {% for type, messages in app.flashes %}
             {% for message in messages %}
                 <div class="alert alert-{{ type }}">
diff --git a/templates/email/verify_email.html.twig b/templates/email/verify_email.html.twig
new file mode 100644
index 0000000..7f170f5
--- /dev/null
+++ b/templates/email/verify_email.html.twig
@@ -0,0 +1,11 @@
+{% extends 'email/base.html.twig' %}
+
+{% block body %}
+    <h1>Hi {{ user.firstName }}!</h1>
+
+    <p>To verify your email, please visit the following link</p>
+
+    <a href="{{ link }}">{{ link }}</a>
+
+    <p>Cheers!</p>
+{% endblock %}
diff --git a/tests/Functional/User/RegistrationTest.php b/tests/Functional/User/RegistrationTest.php
index fcd37ee..47a6427 100644
--- a/tests/Functional/User/RegistrationTest.php
+++ b/tests/Functional/User/RegistrationTest.php
@@ -4,9 +4,13 @@
 
 use App\Factory\UserFactory;
 use App\Tests\FunctionalTestCase;
+use Zenstruck\Mailer\Test\InteractsWithMailer;
+use Zenstruck\Mailer\Test\TestEmail;
 
 class RegistrationTest extends FunctionalTestCase
 {
+    use InteractsWithMailer;
+
     public function testRegistration(): void
     {
         $this->browser()
@@ -17,7 +21,26 @@ public function testRegistration(): void
             ->click('Submit')
             ->assertOn('/')
             ->assertSee('Registration successful! You are now logged in.')
+            ->assertSee('A verification link has been sent to ksmith@example.com')
+            ->assertSee('Your account isn\'t verified. Check your email for a verification link.')
             ->assertAuthenticated(as: 'ksmith@example.com')
+        ;
+
+        $this->mailer()
+            ->assertSentEmailCount(1)
+            ->assertEmailSentTo('ksmith@example.com', function (TestEmail $email) use (&$link) {
+                $link = $email->metadata()['link'] ?? self::fail('Link metadata not set');
+
+                $email
+                    ->assertSubject('Email Verification')
+                    ->assertHasTag('verify-email')
+                    ->assertHtmlContains(htmlspecialchars($link))
+                    ->assertTextContains($link)
+                ;
+            })
+        ;
+
+        $this->browser()
             ->visit('/logout')
             ->assertNotAuthenticated()
             ->visit('/login')
@@ -26,12 +49,23 @@ public function testRegistration(): void
             ->click('Sign in')
             ->assertOn('/')
             ->assertAuthenticated(as: 'ksmith@example.com')
+            ->assertSee('Your account isn\'t verified. Check your email for a verification link.')
         ;
 
-        UserFactory::assert()->exists([
-            'name' => 'Karen Smith',
-            'email' => 'ksmith@example.com',
-        ]);
+        $user = UserFactory::find(['email' => 'ksmith@example.com']);
+
+        $this->assertSame('Karen Smith', $user->getName());
+        $this->assertFalse($user->isVerified());
+
+        $this->browser()
+            ->visit($link)
+            ->assertOn('/')
+            ->assertSee('Your email has been verified successfully.')
+            ->assertAuthenticated(as: 'ksmith@example.com')
+            ->assertNotSee('Your account isn\'t verified. Check your email for a verification link.')
+        ;
+
+        $this->assertTrue($user->isVerified());
     }
 
     /**
@@ -86,4 +120,44 @@ public static function registrationFormValidationProvider(): iterable
             'message' => 'The password strength is too low. Please use a stronger password',
         ];
     }
+
+    public function testCanResendVerificationEmail(): void
+    {
+        $user = UserFactory::createOne(['email' => 'jane@example.com']);
+
+        $this->assertFalse($user->isVerified());
+
+        $this->browser()
+            ->actingAs($user)
+            ->visit('/')
+            ->assertSee('Your account isn\'t verified. Check your email for a verification link. Resend Email')
+            ->post('/send-verification')
+            ->assertOn('/')
+            ->assertSee('A verification link has been sent to jane@example.com')
+        ;
+
+        $this->mailer()
+            ->assertSentEmailCount(1)
+            ->assertEmailSentTo('jane@example.com', function (TestEmail $email) use (&$link) {
+                $link = $email->metadata()['link'] ?? self::fail('Link metadata not set');
+
+                $email
+                    ->assertSubject('Email Verification')
+                    ->assertHasTag('verify-email')
+                    ->assertHtmlContains(htmlspecialchars($link))
+                    ->assertTextContains($link)
+                ;
+            })
+        ;
+
+        $this->browser()
+            ->visit($link)
+            ->assertOn('/')
+            ->assertSee('Your email has been verified successfully.')
+            ->assertAuthenticated(as: 'jane@example.com')
+            ->assertNotSee('Your account isn\'t verified. Check your email for a verification link.')
+        ;
+
+        $this->assertTrue($user->isVerified());
+    }
 }
diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php
index 81c312d..a57d881 100644
--- a/tests/Unit/Entity/UserTest.php
+++ b/tests/Unit/Entity/UserTest.php
@@ -25,4 +25,17 @@ public function testGetAvatarUrl(): void
             $user->getAvatarUrl(),
         );
     }
+
+    public function testVerificationRole(): void
+    {
+        $user = UserFactory::createOne();
+
+        $this->assertFalse($user->isVerified());
+        $this->assertSame(['ROLE_USER'], $user->getRoles());
+
+        $user->markVerified();
+
+        $this->assertSame(['ROLE_USER', 'ROLE_VERIFIED'], $user->getRoles());
+        $this->assertTrue($user->isVerified());
+    }
 }


Reviews:
user: login: weaverryan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:29:44Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T18:35:27Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T14:00:23Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T14:15:20Z

Review comments:
diff_hunk: @@ -17,6 +17,12 @@
         {% else %}
             <a href="{{ path('login') }}">Login</a>
         {% endif %}
+        {% if app.user and not app.user.verified %}
+            <div class="alert alert-warning">
+                Your account isn't verified. Check your email for a verification link.
+                <a href="{{ path('send_verification') }}" data-controller="post-link" data-action="post-link#click">Resend Email</a>.
path: templates/base.html.twig
user: login: weaverryan
body: Instead, I'd make this a `<form>` (but not a Symfony form) with a button: nofsncy needed
created_at: 2025-05-08T18:18:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,8 @@
 when@dev: &dev
     # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
     zenstruck_foundry:
+        instantiator:
+            use_constructor: false
+            always_force_properties: true
path: config/packages/zenstruck_foundry.yaml
user: login: weaverryan
body: what for?
created_at: 2025-05-08T18:21:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,7 +34,9 @@ public function __invoke(
             $em->flush();
             $this->addFlash('success', 'Registration successful! You are now logged in.');
 
-            return $security->login($user, 'form_login');
+            $security->login($user, 'form_login');
+
+            return $this->forward(VerificationController::class.'::sendVerification');
path: src/Controller/User/RegistrationController.php
user: login: weaverryan
body: I don't like `forward`, but this is probably a good use, though `::` is ugly
created_at: 2025-05-08T18:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Repository\UserRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\UriSigner;
+use Symfony\Component\Mailer\Header\MetadataHeader;
+use Symfony\Component\Mailer\Header\TagHeader;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Http\Attribute\CurrentUser;
+
+use function Symfony\Component\Clock\now;
+
+final class VerificationController extends AbstractController
+{
+    private const EXPIRES_AFTER = 3600 * 24; // 24 hours
+    private const SESSION_ID = 'verification_id';
path: src/Controller/User/VerificationController.php
user: login: weaverryan
body: ```suggestion
    private const SESSION_USER_ID = 'verification_id';
```
created_at: 2025-05-08T18:24:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Repository\UserRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\UriSigner;
+use Symfony\Component\Mailer\Header\MetadataHeader;
+use Symfony\Component\Mailer\Header\TagHeader;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Http\Attribute\CurrentUser;
+
+use function Symfony\Component\Clock\now;
+
+final class VerificationController extends AbstractController
+{
+    private const EXPIRES_AFTER = 3600 * 24; // 24 hours
+    private const SESSION_ID = 'verification_id';
+
+    public function __construct(
+        private UriSigner $uriSigner,
+        private MailerInterface $mailer,
+    ) {
+    }
+
+    #[Route('/send-verification', name: 'send_verification', methods: 'POST')]
+    public function sendVerification(
+        #[CurrentUser]
+        User $user,
+
+        #[Target('verifyEmailLimiter')]
+        RateLimiterFactory $rateLimiter, // todo switch to RateLimiterInterface in 7.3
+    ): Response {
+        if ($user->isVerified()) {
+            return $this->redirectToRoute('homepage');
+        }
+
+        if (!$rateLimiter->create($user->getEmail())->consume()->isAccepted()) {
+            $this->addFlash('warning', 'You recently requested an account verification. Please check your email for the link to verify your account.');
+
+            return $this->redirectToRoute('homepage');
+        }
+
+        $this->sendVerificationLink($user);
+        $this->addFlash('note', sprintf('A verification link has been sent to %s.', $user->getEmail()));
path: src/Controller/User/VerificationController.php
user: login: weaverryan
body: ```suggestion
        $this->addFlash('success', sprintf('A verification link has been sent to %s.', $user->getEmail()));
```

?
created_at: 2025-05-08T18:25:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Repository\UserRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\UriSigner;
+use Symfony\Component\Mailer\Header\MetadataHeader;
+use Symfony\Component\Mailer\Header\TagHeader;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Http\Attribute\CurrentUser;
+
+use function Symfony\Component\Clock\now;
+
+final class VerificationController extends AbstractController
+{
+    private const EXPIRES_AFTER = 3600 * 24; // 24 hours
+    private const SESSION_ID = 'verification_id';
+
+    public function __construct(
+        private UriSigner $uriSigner,
+        private MailerInterface $mailer,
+    ) {
+    }
+
+    #[Route('/send-verification', name: 'send_verification', methods: 'POST')]
+    public function sendVerification(
+        #[CurrentUser]
+        User $user,
+
+        #[Target('verifyEmailLimiter')]
+        RateLimiterFactory $rateLimiter, // todo switch to RateLimiterInterface in 7.3
+    ): Response {
+        if ($user->isVerified()) {
+            return $this->redirectToRoute('homepage');
+        }
+
+        if (!$rateLimiter->create($user->getEmail())->consume()->isAccepted()) {
+            $this->addFlash('warning', 'You recently requested an account verification. Please check your email for the link to verify your account.');
+
+            return $this->redirectToRoute('homepage');
+        }
+
+        $this->sendVerificationLink($user);
+        $this->addFlash('note', sprintf('A verification link has been sent to %s.', $user->getEmail()));
+
+        return $this->redirectToRoute('homepage');
+    }
+
+    #[Route('/verify/{id<\d+>}', name: 'verify_email')]
path: src/Controller/User/VerificationController.php
user: login: weaverryan
body: ```suggestion
    #[Route('/verify/-email{id<\d+>}', name: 'verify_email')]
```
created_at: 2025-05-08T18:26:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,8 @@
 when@dev: &dev
     # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
     zenstruck_foundry:
+        instantiator:
+            use_constructor: false
+            always_force_properties: true
path: config/packages/zenstruck_foundry.yaml
user: login: kbond
body: This is how I personally think foundry should be used but in this specific case, I added because I don't have a `setVerifiedAt()` - rich entities and all that.

I don't feel strongly so I can add the setter.
created_at: 2025-05-08T18:35:27Z
author_association: COLLABORATOR
diff_hunk: @@ -34,7 +34,9 @@ public function __invoke(
             $em->flush();
             $this->addFlash('success', 'Registration successful! You are now logged in.');
 
-            return $security->login($user, 'form_login');
+            $security->login($user, 'form_login');
+
+            return $this->forward(VerificationController::class.'::sendVerification');
path: src/Controller/User/RegistrationController.php
user: login: kbond
body: I'm hoping we'll be able to adjust this when we extract logic into a bundle.
created_at: 2025-05-09T14:00:22Z
author_association: COLLABORATOR
diff_hunk: @@ -17,6 +17,12 @@
         {% else %}
             <a href="{{ path('login') }}">Login</a>
         {% endif %}
+        {% if app.user and not app.user.verified %}
+            <div class="alert alert-warning">
+                Your account isn't verified. Check your email for a verification link.
+                <a href="{{ path('send_verification') }}" data-controller="post-link" data-action="post-link#click">Resend Email</a>.
path: templates/base.html.twig
user: login: kbond
body: Good opportunity for a twig component!
created_at: 2025-05-09T14:15:20Z
author_association: COLLABORATOR

PR comments:
user: login: seb-jean
created_at: 2025-05-08T14:04:17Z
body: Is there any reason not to use https://github.com/SymfonyCasts/verify-email-bundle?
author_association: NONE
user: login: kbond
created_at: 2025-05-08T14:21:16Z
body: > Is there any reason not to use https://github.com/SymfonyCasts/verify-email-bundle?

I find that bundle has a lot of code to do a simple thing and you still need a bunch of boilerplate.

I am thinking about how to extract some of this code (same with the stateless pw reset) but... to allow the most customization, I'm not finding good extension points. That being said, this is a security-related thing so I'm leery.
author_association: COLLABORATOR
user: login: seb-jean
created_at: 2025-05-08T14:34:38Z
body: > > Y a-t-il une raison de ne pas utiliser https://github.com/SymfonyCasts/verify-email-bundle ?
> 
> Je trouve que le bundle contient beaucoup de code pour faire une chose simple et qu'il faut toujours un tas de code standard.
> 
> Je réfléchis à la manière d'extraire une partie de ce code (idem pour la réinitialisation du mot de passe sans état), mais… pour permettre une personnalisation maximale, je ne trouve pas de bonnes extensions. Ceci dit, c'est une question de sécurité, donc je suis méfiant.

Thanks :blush:
author_association: NONE