Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Verifying the Signed Confirm Email URL

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We're now generating a signed URL that we would normally include in a "confirm your email address" email that we send to the user after registration. To keep things simple, we're just rendering that URL onto the page after registration.

Removing our Unused Bind

Let's... go see what it looks like. Refresh and... ah! A terrible-looking error!

A binding is configured for an argument named $formLoginAuthenticator under _defaults, but no corresponding argument has been found.

So until a few minutes ago, we had an argument to our register() action that was called $formLoginAuthenticator. Over in config/services.yaml, we set up a global "bind" that said:

Whenever an autowired service has an argument named $formLoginAuthenticator, please pass this service.

... lines 1 - 8
services:
# default configuration for services in *this* file
_defaults:
... lines 12 - 13
bind:
... line 15
$formLoginAuthenticator: '@security.authenticator.form_login.main'
... lines 17 - 32

One of the cool things about bind is that if there is no matching argument anywhere in our app, it throws an exception. It's trying to make sure that we're not making an accidental typo.

In our situation, we... just don't need that argument anymore. So, delete it. And now... our registration page is alive!

Checking out the Verify URL

Let's do this! Enter an email, some password, agree to the terms and hit register. Beautiful! Here is our email confirmation URL. You can see that it goes to /verify: that will hit our new verifyUserEmail() action. It also includes an expiration. That's something you can configure... it's how long the link is valid for. And it has a signature: that's something that will help prove that the user didn't just make up this URL: it definitely came from us.

It also includes an id=18: our user id.

Verifying the Signed URL

So our job now is to go into the verifyUserEmail controller method down here and validate that signed URL. To do that, we need a few arguments: the Request object - so we can read data from the URL - a VerifyEmailHelperInterface to help us validate the URL - and finally, our UserRepository - so we can query for the User object:

... lines 1 - 6
use App\Repository\UserRepository;
... line 8
use Symfony\Component\HttpFoundation\Request;
... lines 10 - 13
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
class RegistrationController extends AbstractController
{
... lines 18 - 60
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response
{
... lines 63 - 80
}
}

And actually, that's our first job. Say $user = $userRepository->find() and find the user that this confirmation link belongs to by reading the id query parameter. So, $request->query->get('id'). And if, for some reason, we can't find the User, let's trigger a 404 page by throwing $this->createNotFoundException():

... lines 1 - 15
class RegistrationController extends AbstractController
{
... lines 18 - 60
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response
{
$user = $userRepository->find($request->query->get('id'));
if (!$user) {
throw $this->createNotFoundException();
}
... lines 67 - 80
}
}

Now we can make sure that the signed URL hasn't been tampered with. To do that, add a try-catch block. Inside, say $verifyEmailHelper->validateEmailConfirmation() and pass in a couple of things. First, the signed URL, which... is the current URL. Get that with $request->getUri(). Next pass the user's id - $user->getId() then the user's email - $user->getEmail():

... lines 1 - 15
class RegistrationController extends AbstractController
{
... lines 18 - 60
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response
{
$user = $userRepository->find($request->query->get('id'));
if (!$user) {
throw $this->createNotFoundException();
}
try {
$verifyEmailHelper->validateEmailConfirmation(
$request->getUri(),
$user->getId(),
$user->getEmail(),
);
... lines 74 - 77
}
... lines 79 - 80
}
}

This makes sure that the id and email haven't changed in the database since the verification email was sent. Well, the id definitely hasn't changed... since we just used it to query. This part only really applies if you rely on the user being logged in to verify their email.

Anyways, if this is successful... nothing will happen! If it fails, it will throw a special exception that implements VerifyEmailExceptionInterface:

... lines 1 - 15
class RegistrationController extends AbstractController
{
... lines 18 - 60
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response
{
... lines 63 - 67
try {
$verifyEmailHelper->validateEmailConfirmation(
$request->getUri(),
$user->getId(),
$user->getEmail(),
);
} catch (VerifyEmailExceptionInterface $e) {
... lines 75 - 77
}
... lines 79 - 80
}
}

So, down here, we know that verifying the URL failed... maybe someone messed with it. Or, more likely, the link expired. Let's tell the user the reason by leveraging the flash system again. Say $this->addFlash(), but this time put it into a different category called error. Then, to say what went wrong, use $e->getReason(). Finally, use redirectToRoute() to send them somewhere. How about the registration page?

... lines 1 - 15
class RegistrationController extends AbstractController
{
... lines 18 - 60
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository): Response
{
... lines 63 - 67
try {
$verifyEmailHelper->validateEmailConfirmation(
$request->getUri(),
$user->getId(),
$user->getEmail(),
);
} catch (VerifyEmailExceptionInterface $e) {
$this->addFlash('error', $e->getReason());
return $this->redirectToRoute('app_register');
}
dd('TODO');
}
}

To render the error, back in base.html.twig, duplicate this entire block, but look for error messages and use alert-danger:

<!DOCTYPE html>
<html>
... lines 3 - 14
<body
... lines 16 - 81
{% for flash in app.flashes('success') %}
<div class="alert alert-success">{{ flash }}</div>
{% endfor %}
{% for flash in app.flashes('error') %}
<div class="alert alert-danger">{{ flash }}</div>
{% endfor %}
... lines 88 - 92
</body>
</html>

Phew! Let's try the error case. Copy the URL then open a new tab and paste. If I go to this real URL... it works. Well, we still need to do some more coding, but it hits our TODO at the bottom of the controller. Now mess with the URL, like remove a few characters... or tweak the expiration or change the id. Now... yes! It failed because our link is invalid. If the link were expired, you would see a message about that.

So, finally, let's finish the happy case! At the bottom of our controller, now that we know that the verification link is valid, we are done. For our app, we can say $user->isVerified(true) and then store that in the database:

... lines 1 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 61
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response
{
... lines 64 - 68
try {
... lines 70 - 78
}
$user->setIsVerified(true);
... lines 82 - 86
}
}

Let' see... we need one more argument: EntityManagerInterface $entityManager:

... lines 1 - 7
use Doctrine\ORM\EntityManagerInterface;
... lines 9 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 61
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response
{
... lines 64 - 86
}
}

Back down here, use $entityManager->flush() to save that change:

... lines 1 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 61
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response
{
... lines 64 - 80
$user->setIsVerified(true);
$entityManager->flush();
... lines 83 - 86
}
}

And let's give this a happy success message:

Account verified! You can now log in.

Well, the truth is, we're not yet preventing them from logging in before they verify their email. But we will soon. Anyways, finish by redirecting to the login page: app_login:

... lines 1 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 61
public function verifyUserEmail(Request $request, VerifyEmailHelperInterface $verifyEmailHelper, UserRepository $userRepository, EntityManagerInterface $entityManager): Response
{
... lines 64 - 80
$user->setIsVerified(true);
$entityManager->flush();
$this->addFlash('success', 'Account Verified! You can now log in.');
return $this->redirectToRoute('app_login');
}
}

If you wanted to be even cooler, you could manually authenticate the user in the same way that we did earlier in our registration controller. That's totally ok and up to you.

Back in my main tab... copy that link again, paste and... we are verified! Sweet!

The only thing left to do is to prevent the user from logging in until they've verified their email. To do that, we first need to learn about the events that happen inside of the security system. And to show off those, we'll leverage a really cool new feature: login throttling.

Leave a comment!

11
Login or Register to join the conversation
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | posted 8 months ago

Hello I have tried a slightly different approach. I actually have created a verification system which sends a random code via email and following your reccomendation in this chapter "If you wanted to be even cooler, you could manually authenticate the user in the same way that we did earlier in our registration controller. That's totally ok and up to you." I have tried to authenticate the user:

/**
* @Route("/verify/{user}", name="user-verify")
*/


public function userVerify(
User $user,
EntityManagerInterface $em,
Request $request,
UserAuthenticatorInterface $userAuthenticator,
FormLoginAuthenticator $formLoginAuthenticator,
) {

$form = $this->createFormBuilder()
->add('verification', TextType::class)
->add('userId', HiddenType::class, [
'data' => $user->getId()
])
->add('send', SubmitType::class)
->getForm();
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
if ($data['verification'] == $user->getVerification()) {
$user->setVerification('verified');
$em->persist($user);
$em->flush();
$userAuthenticator->authenticateUser(
$user,
$formLoginAuthenticator,
$request
);
//dd($user); -> returns $user Object
return $this->redirectToRoute('frontend_user');
}
}

My security.yaml file:


form_login:
login_path: app_login
check_path: app_login
username_parameter: email
password_parameter: password
enable_csrf: true
default_target_path: frontend_user
use_referer: true
target_path_parameter: frontend_user

After the redirection $this->getUser() returns null thus the session is not mantained, so seems that validation did not work or at least the session was not retained and I would need to send the verified user to login which is possible but not desired in my case. How can I find out what has happened during that redirection? What would be the best debugging strategy?

Reply

Hey Juan E.!

Hmm, that's a mystery! And you've asked the correct questions: how to debug / find out what happened during that redirection.

Here's what I like to do:

A) In config/packages/dev/web_profiler.yaml, you should have a intercept_redirects: false line. Change that to intercept_redirects: true.

B) Now, whenever Symfony redirects you, instead of redirecting, you will see a page that says something like "About to redirect you to http://...". But it hasn't *actually* redirected you.

How is that useful? Because now you can do this:

1) Click the link, which will hit this URL, submit the form and (should) authenticate the user (make sure you put your redirectToRoute() back)
2) But, instead of redirecting, it will stop and show you the message. The useful thing is that, on this screen, you will see the web debug toolbar for this page. The question is: do you see yourself as authenticated in the web debug toolbar?

The answer to this question will tell us where to look next. If you ARE authenticated (but then when you click the link to "follow the redirect" suddenly you are not authenticated), then you are "losing" authentication, probably because some data on your User object is being seen as "changed" (if you have this situation, I can tell you more - it is common if you, for some reason, implement a custom \Serializable interface on your User class, which you probably shouldn't have).

Let me know what you find out.

Cheers!

Reply
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | weaverryan | posted 8 months ago

Hi I just managed to make it work using the rememberMe Handler Interface and adding this line before the redirect:

$rememberMe->createRememberMeCookie($this->getUser());

Reply
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | weaverryan | posted 8 months ago

Hi, as I said in the previous reply I still get "kicked out" this is my User.php entity, I wonder if the libPhoneNumber object could be the problem.

date = new ArrayCollection();
$this->reservation = new ArrayCollection();
$this->blogTranslations = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->travellers = new ArrayCollection();
$this->reservations = new ArrayCollection();
$this->reservationData = new ArrayCollection();
$this->codespromos = new ArrayCollection();
}


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


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


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


return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string)$this->email;
}


/**
* @deprecated since Symfony 5.3, use getUserIdentifier instead
*
*/
public function getUsername(): string
{
return (string) $this->email;
}


/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';


return array_unique($roles);
}


public function setRoles(array $roles): self
{
$this->roles = $roles;


return $this;
}


/**
* @see UserInterface
*/
public function getPassword(): string
{
return (string) $this->password;
}


public function setPassword(string $password): self
{
$this->password = $password;


return $this;
}


/**
* Returning a salt is only needed, if you are not using a modern
* hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
*
* @see UserInterface
*/
public function getSalt(): ?string
{
return null;
}


/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}


public function getLangue(): ?string
{
return $this->langue;
}


public function setLangue(string $langue): self
{
$this->langue = $langue;


return $this;
}


public function getNom(): ?string
{
return $this->nom;
}


public function setNom(string $nom): self
{
$this->nom = $nom;


return $this;
}


public function getPrenom(): ?string
{
return $this->prenom;
}


public function setPrenom(string $prenom): self
{
$this->prenom = $prenom;


return $this;
}


public function getTelephone(): ?PhoneNumber
{
return $this->telephone;
}


public function setTelephone(?PhoneNumber $telephone): self
{
$this->telephone = $telephone;


return $this;
}


public function getPosition(): ?string
{
return $this->position;
}


public function setPosition(?string $position): self
{
$this->position = $position;


return $this;
}


public function getDateAjout(): ?\DateTimeInterface
{
return $this->date_ajout;
}


public function setDateAjout(\DateTimeInterface $date_ajout): self
{
$this->date_ajout = $date_ajout;


return $this;
}


/**
* @return Collection|Dates[]
*/
public function getDate(): Collection
{
return $this->date;
}


public function addDate(Dates $date): self
{
if (!$this->date->contains($date)) {
$this->date[] = $date;
}


return $this;
}


public function removeDate(Dates $date): self
{
$this->date->removeElement($date);


return $this;
}


/**
* @return Collection|Reservations[]
*/
public function getReservation(): Collection
{
return $this->reservation;
}


public function addReservation(Reservation $reservation): self
{
if (!$this->reservation->contains($reservation)) {
$this->reservation[] = $reservation;
}


return $this;
}


public function removeReservation(Reservation $reservation): self
{
$this->reservation->removeElement($reservation);


return $this;
}


/**
* @return Collection|BlogTranslation[]
*/
public function getBlogTranslations(): Collection
{
return $this->blogTranslations;
}


public function addBlogTranslation(BlogTranslation $blogTranslation): self
{
if (!$this->blogTranslations->contains($blogTranslation)) {
$this->blogTranslations[] = $blogTranslation;
$blogTranslation->setUser($this);
}


return $this;
}


public function removeBlogTranslation(BlogTranslation $blogTranslation): self
{
if ($this->blogTranslations->removeElement($blogTranslation)) {
// set the owning side to null (unless already changed)
if ($blogTranslation->getUser() === $this) {
$blogTranslation->setUser(null);
}
}


return $this;
}


/**
* @return Collection|Document[]
*/
public function getDocuments(): Collection
{
return $this->documents;
}


public function addDocument(Document $document): self
{
if (!$this->documents->contains($document)) {
$this->documents[] = $document;
$document->setUser($this);
}


return $this;
}


public function removeDocument(Document $document): self
{
if ($this->documents->removeElement($document)) {
// set the owning side to null (unless already changed)
if ($document->getUser() === $this) {
$document->setUser(null);
}
}


return $this;
}


/**
* @return Collection|Travellers[]
*/
public function getTravellers(): Collection
{
return $this->travellers;
}


public function addTraveller(Travellers $traveller): self
{
if (!$this->travellers->contains($traveller)) {
$this->travellers[] = $traveller;
$traveller->setUser($this);
}


return $this;
}


public function removeTraveller(Travellers $traveller): self
{
if ($this->travellers->removeElement($traveller)) {
// set the owning side to null (unless already changed)
if ($traveller->getUser() === $this) {
$traveller->setUser(null);
}
}


return $this;
}


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


/**
* @return Collection|Reservation[]
*/
public function getReservations(): Collection
{
return $this->reservation;
}


public function getAddress(): ?string
{
return $this->address;
}


public function setAddress(?string $address): self
{
$this->address = $address;


return $this;
}


public function getPostcode(): ?string
{
return $this->postcode;
}


public function setPostcode(?string $postcode): self
{
$this->postcode = $postcode;


return $this;
}


public function getCity(): ?string
{
return $this->city;
}


public function setCity(?string $city): self
{
$this->city = $city;


return $this;
}


public function getCountry(): ?string
{
return $this->country;
}


public function setCountry(?string $country): self
{
$this->country = $country;


return $this;
}


public function getNationality(): ?string
{
return $this->nationality;
}


public function setNationality(?string $nationality): self
{
$this->nationality = $nationality;


return $this;
}


public function getSizes(): ?string
{
return $this->sizes;
}


public function setSizes(?string $sizes): self
{
$this->sizes = $sizes;


return $this;
}


public function getVerification(): ?string
{
return $this->verification;
}


public function setVerification(?string $verification): self
{
$this->verification = $verification;


return $this;
}


/**
* @return Collection|ReservationData[]
*/
public function getReservationData(): Collection
{
return $this->reservationData;
}


public function addReservationData(ReservationData $reservationData): self
{
if (!$this->reservationData->contains($reservationData)) {
$this->reservationData[] = $reservationData;
$reservationData->setUser($this);
}


return $this;
}


public function removeReservationData(ReservationData $reservationData): self
{
if ($this->reservationData->removeElement($reservationData)) {
// set the owning side to null (unless already changed)
if ($reservationData->getUser() === $this) {
$reservationData->setUser(null);
}
}


return $this;
}


/**
* @return Collection|Codespromo[]
*/
public function getCodespromos(): Collection
{
return $this->codespromos;
}


public function addCodespromo(Codespromo $codespromo): self
{
if (!$this->codespromos->contains($codespromo)) {
$this->codespromos[] = $codespromo;
$codespromo->setUser($this);
}


return $this;
}


public function removeCodespromo(Codespromo $codespromo): self
{
if ($this->codespromos->removeElement($codespromo)) {
// set the owning side to null (unless already changed)
if ($codespromo->getUser() === $this) {
$codespromo->setUser(null);
}
}


return $this;
}


public function getIsVerified(): ?bool
{
return $this->isVerified;
}


public function setIsVerified(bool $isVerified): self
{
$this->isVerified = $isVerified;


return $this;
}
}

Reply
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | weaverryan | posted 8 months ago

Hi thank you, I have proceeded following your indications and so far I find my self in the place we expected. By preventing direct redirection I got to that "intermediate" page and the user was still logged in, but after redirection it disappeared. Since the project I am managing "inherits" many elements from a former project I will clean up the data model of the user class and then test again, if I get no progress I may send here the User.php entity class with the hope you may find something I couldn't, I will try tomorrow and whatever the outcome I will post it here. Thanks so much.

Reply

Hey Juan!

I see that you got this working, I just wanted to apologize for not replying - I'm not sure how I lost this message! Overall, your solution is probably fine. If you'd like to learn more about the underlying issue and solution, you can read about it on this thread: https://symfonycasts.com/sc...

Cheers!

Reply
Ruslan Avatar

Hi,
How to translate $e->getReason() ? I don't see any domain in debug bar.

Thank you.

Reply

Hey Ruslan,

I think you need to translate the exact text if the error is from core Symfony. If the error message is your custom - you can use a translation message key, translate it in translation files, and use the translator in that spot to translate.

I hope this helps!

Cheers!

Reply
Ruslan Avatar

Hi Victor,
In this tutorial, we have changed Error messages for Login form (lesson 10) in translations\security.en.yaml. As I understand "security" is domain and I can see it in debug bar "Translation".
$e->getReason() is from VerifyEmailExceptionInterface. - but here I miss understand how to translate (I mean translation file name)

Thank you.

Reply
Ruslan Avatar

Looks like I understand my fault.
in this code I need to pass $error
-----
} catch (VerifyEmailExceptionInterface $e){
$this->addFlash('error', $e->getReason());

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

and in twig use the same construction like in login.html.twig.
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>

Am I right?

Reply

Hey Ruslan,

I'm not sure you can pass the error object, IIRC you can only pass string to the addFlash() method. So, either you need to translate the message in PHP using trans() method before pass the string to the addFlash(), or you can pass the untranslated string to the addFlash() and translate it later in the template, i.e. "<div class="alert alert-success">{{ flash|trans }}</div>" as you mentioned, but yeah, if the message requires some messageData - it you do need to pass it into the template somehow, so probably better to translate in PHP where you have access to the object.

But if it's possible to pass the object - yeah, you can try your solution as well then. But you would need one more check in the template - you would need to know if you passed a string or an object and so render it a bit differently.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}