Description
Symfony version(s) affected
5.4.42
Description
During the unserialization of a User
object (or any other Doctrine entity implementing Serializable
) in an asynchronous email sending process using Mailer
and Messenger
, the user entity is not reloaded from the database. As a result, if you have a custom serialization/unserialization mechanism and the user object is a relation in another object, fields not covered by your custom unserialization process will be empty when rendering the email template in the asynchronous process.
Symfony stores the User
object in the session data. If the object is large and the session data is stored in, for example, a database, you can minimize serialization (e.g., only serialize fields like email, password, roles, etc.). The remaining fields will always be reloaded from the database using the user provider.
The problem arises when we have another object, such as an Article
, which has a relation to the User
object, like an author field. If we use asynchronous email sending with Messenger
, the default behavior is that the email template is rendered only in the asynchronous process. Before sending to Messenger
, objects are serialized, and then, during the template rendering in the asynchronous process, they are unserialized. At this point, during the unserialization of such data, the user object is not fully reloaded, as it normally would be during typical Symfony operation.
How to reproduce
To illustrate the problem, let's assume we have 2 tables in the database: article and user, mapped to Doctrine
entities: Article
and User
:
src/Entity/User.php
<?php
// src/Entity/User.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ORM\Table(name="user")
*/
class User implements UserInterface, \Serializable
{
public function serialize()
{
return serialize([
$this->id,
$this->email,
$this->password,
]);
}
public function unserialize($serialized)
{
list(
$this->id,
$this->email,
$this->password,
) = unserialize($serialized);
}
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $email;
/**
* @ORM\Column(type="string", length=255)
*/
private $password;
/**
* @ORM\Column(type="string", length=255)
*/
private $slug; // <--- this field will not be unserialized
// ...
src/Entity/Article.php
<?php
// src/Entity/Article.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
* @ORM\Table(name="article")
*/
class Article
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $title;
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User")
*/
private $author; // <-- User object will not be fully unserialized
// ...
src/Controller/EmailController.php
<?php
// src/Controller/EmailController.php
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use App\Entity\Article;
class EmailController extends AbstractController
{
/**
* @Route("/send",
* name="send_email")
*
* @param Request $request
* @param EntityManagerInterface $em
* @param MailerInterface $mailer
* @return Response
*/
public function send(Request $request,
EntityManagerInterface $em,
MailerInterface $mailer): Response
{
$repository = $em->getRepository(Article::class);
$article = $repository->find(2);
// let's imagine that the retrieved Article object with ID = 2 has an associated User with ID = 1 and looks as follows:
//
// $author->id = 1;
// $author->email = "user@example.com";
// $author->password = "xxxxxx";
// $author->slug = "user-example-com";
//
// $article->id = 2;
// $article->title = "My article";
// $article->author = $author; // id = 1
$context = [];
$context['article'] = $article;
$address = $article->getAuthor()->getEmail(); // user@example.com
$email = (new TemplatedEmail())
->from("from@example.com")
->to($address)
->subject("New article")
->htmlTemplate("email/article.html.twig")
->context($context);
$mailer->send($email);
// at this point, the Article object is serialized and sent to the asynchronous process using Messenger.
return $this->render('index.html.twig');
}
}
templates/email/article.html.twig
This is your article:
{{ article.title }}
This is your email:
{{ article.author.email }}
This is your slug:
{{ article.author.slug }} {# <---- it will render an empty value #}
As a result, the email will be sent using the asynchronous process and the templates/email/article.html.twig
template. However, unfortunately, the field {{ article.author.slug }}
will be empty, even though the user object has the value user-example-com here.
Result (after rendering and sending the email):
This is your article:
My article
This is your email:
user@example.com
This is your slug:
^ it is empty where the slug should have been rendered.
The issue does not occur when the template is rendered before using the method MailerInterface::send
.
src/Controller/EmailController.php
// src/Controller/EmailController.php
<?php
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bridge\Twig\Mime\BodyRenderer;
use Twig\Environment;
use App\Entity\Article;
class EmailController extends AbstractController
{
/**
* @Route("/send",
* name="send_email")
*
* @param Request $request
* @param EntityManagerInterface $em
* @param MailerInterface $mailer
* @param Environment $twig
* @return Response
*/
public function send(Request $request,
EntityManagerInterface $em,
MailerInterface $mailer,
Environment $twig): Response
{
$repository = $em->getRepository(Article::class);
$article = $repository->find(2);
$context = [];
$context['article'] = $article;
$address = $article->getAuthor()->getEmail(); // user@example.com
$email = (new TemplatedEmail())
->from("from@example.com")
->to($address)
->subject("New article")
->htmlTemplate("email/article.html.twig")
->context($context);
// ---------------------
$renderer = new BodyRenderer($twig);
$renderer->render($email); // <--- render template before sending to Messenger
// ---------------------
$mailer->send($email);
// this time, the "slug" field of the User object will be correctly filled with "user-example-com".
return $this->render('index.html.twig');
}
}
The issue also does not occur during synchronous sending (without using Messenger
).
It only occurs during asynchronous sending when using Messenger
and when the template is rendered in the asynchronous process.
Possible Solution
If an object (even as a relation) that is to be used in rendering an email in the asynchronous Mailer
process implements Serializable
, it should be reloaded from the database beforehand, or any custom serialization should be ignored, and it should be fully serialized. This ensures the remaining fields are populated, just as they normally would be when the user object is reloaded from the database by Symfony's authentication mechanisms. Alternatively, the procedure for retrieving the full object could occur before sending it to Messenger
, just before data serialization.
Additional Context
Symfony's configuration:
.env
# .env
MAILER_DSN=smtp://user:password@smtp.example.com
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@127.0.0.1:5672/%2f/mailer
config/packages/mailer.yaml
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
envelope:
sender: 'sender@example.com'
config/packages/messenger.yaml
# config/packages/messenger.yaml
framework:
messenger:
transports:
mailer: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'Symfony\Component\Mailer\Messenger\SendEmailMessage': mailer