04/05/2023 Tech
Symfony UUID, Doctrine and type-hinting: everything you should know
And why you should look at how everything work under the hood
In this article, we will talk about wrong type-hinting and how using Doctrine ORM can lead to some unexpected behavior. We’ll start from finding of the bug, then trying to understand why it happens and finally trying to resolve it.
The beginning
First, let’s set the technical context and everything you need to know to understand the rest of the article.
The tech stack is a classic one: Symfony 6.2, Doctrine ORM with PostgreSQL, PHPUnit and finally Foundry.
Now, let me introduce you the base code we’ll need:
namespace AppEntity;
use DoctrineORMMapping as ORM;
#[ORMEntity]
#[ORMHasLifecycleCallbacks]
final class Member
{
#[ORMId]
#[ORMColumn(type: 'uuid', unique: true)]
#[ORMGeneratedValue(strategy: 'CUSTOM')]
#[ORMCustomIdGenerator(class: 'doctrine.uuid_generator')]
public string $id;
#[ORMColumn(type: 'datetime_immutable')]
public DateTimeImmutable $createdAt;
#[ORMColumn(type: 'datetime_immutable')]
public DateTimeImmutable $updatedAt;
#[ORMPrePersist]
public function prePersist(): void
{
$this->createdAt = $this->createdAt ?? new DateTimeImmutable();
$this->updatedAt = $this->createdAt;
}
#[ORMPreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}
namespace AppEntity;
use DoctrineORMMapping as ORM;
#[ORMEntity]
class Message
{
#[ORMId]
#[ORMColumn(type: 'uuid', unique: true)]
#[ORMGeneratedValue(strategy: 'CUSTOM')]
#[ORMCustomIdGenerator(class: 'doctrine.uuid_generator')]
public string $id;
#[ORMManyToOne(targetEntity: Member::class)]
#[ORMJoinColumn(nullable: false)]
public Member $author;
}
For the sake of simplicity, I use public fields. The real code use the good old getter/setter methods.
Nothing really fancy with those snippets.
We have an entity Member that represents a kind of user in our application. It has 3 simple fields: id, createdAt and updatedAt. This entity has Doctrine callback on prePersist and preUpdate events.
The second entity Message has two fields: id and author. This entity represents a simple message in the application.
The investigation
In our functionnal tests, assertions like this failed:
self::assertSame($member, $message->author)
Because $member->updatedAt is different from $message->getAuthor->updatedAt, even we don’t modified $member in the tests.
After investigations, we found that preUpdate doctrine event is fired even if we don’t modify $member. Our first guess was that Foundry fires the event, but we had no idea why and when.
The first step was to find a minimal block of code to reproduce the issue. So then it is easier to understand why the issue is produced.
After some time, we managed to reduce the code to the following snippet:
$member = MemberFactory::createOne()->object();
MessageFactory::createOne([
'author' => $member
]);
self::assertEquals($member->createdAt, $member->updatedAt);
The snippet uses some Foundry functionnalities, especialy the createOne method.
So we need to get rid of it, and translate the Foundry code to plain Doctrine instructions. And after reading the code of Foundry and lot of trials and errors, the following minimal code reproduces the issue with only doctrine calls.
$member = new Member();
$recommendationrequest = new Message();
$recommendationrequest->author = $member;
$em = $this->getEntityManager()
$em->persist($member);
$em->flush();
$em->refresh($member);
$em->persist($recommendationrequest);
$em->flush();
self::assertEquals($member->createdAt, $member->updatedAt);
Now, we try to find which line produces the bug. It appears when we call the refresh method the event is fired. So we answer to the question when.
Let’s find now why!
A quick and simple way to find it, is to dump the event received in preUpdate method. Let see what we found:
^ DoctrineORMEventPreUpdateEventArgs^ {#185
-objectManager: DoctrineORMEntityManager^ {#109 …11}
-object: AppEntityMember^ {#312
+id: "1edac68e-0f54-6828-b519-0b4d85d941b5"
+createdAt: DateTimeImmutable @1676380196 {#167
date: 2023-02-14 13:09:56.0 UTC (+00:00)
}
+updatedAt: DateTimeImmutable @1676380196 {#169
date: 2023-02-14 13:09:56.0 UTC (+00:00)
}
}
-entityChangeSet: & array:1 [
"id" => array:2 [
0 => SymfonyComponentUidUuidV6^ {#549
#uid: "1edac68e-0f54-6828-b519-0b4d85d941b5"
toBase58: "4oz2m7gHUaB5R1EjB45meL"
toBase32: "0YVB38W3TMD0MBA68B9P2XJGDN"
time: "2023-02-14 13:09:56.910084 UTC"
}
1 => "1edac68e-0f54-6828-b519-0b4d85d941b5"
]
]
}
So according the entityChangeSet Doctrine find that the id type has changed from SymfonyComponentUidUuidV6 to string. Then Doctrine fires the event.
The resolution
How to solve it and why this happens?
We found that the event is fired because the id type is changed. But why?
Let’s see what’s happening when we first create the member entity:
AppEntityMember^ {#630
+id: "1edae008-54be-67ae-a260-d1c92065d6bf"
+createdAt: DateTimeImmutable @1676555277 {#609
date: 2023-02-16 13:47:57.793062 UTC (+00:00)
}
+updatedAt: DateTimeImmutable @1676555277 {#609}
}
The id field is a string, as we expected. But doctrine.uuid_generator returns an Uuid object. So this object must be able to be cast down to a string. And the answer is yes, Uuid inherits from SymfonyComponentUidAbstractUid which implements a __toString function.
And then, to save the data to the database, Doctrine will call the convertToDatabaseValue of AbstractUiType (which is inherit by Uuid class) which will re-cast our string to a new Uuid object and then to a string.
Here the snippet of convertToDatabaseValue function:
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
$toString = $this->hasNativeGuidType($platform) ? 'toRfc4122' : 'toBinary';
if ($value instanceof AbstractUid) {
return $value->$toString();
}
if (null === $value || '' === $value) {
return null;
}
if (!is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]);
}
try {
return $this->getUidClass()::fromString($value)->$toString();
} catch (InvalidArgumentException) {
throw ConversionException::conversionFailed($value, $this->getName());
}
}
Everything happens in the return instruction. $value is converted to AbstractUuid then converted to string.
If we resume what happen during the creation of the member entity:
- doctrine.uuid_generator return a freshly generated Uuid object
- This object is cast down to a string by AbstractUiType
After the creation, refresh function is called on the entity, here what happens:
- Doctrine compares the current value of id which is a string to the PHP value obtain after calling convertToPHPValue of AbstractUuid class. It was what we obtain from the PreUpdateEventArgs argument from preUpdate method !
- Then the id field is updated with the string value and the event preUpdate is fired
Code of convertToPHPValue:
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?AbstractUid
{
if ($value instanceof AbstractUid || null === $value) {
return $value;
}
if (!is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', AbstractUid::class]);
}
try {
return $this->getUidClass()::fromString($value);
} catch (InvalidArgumentException $e) {
throw ConversionException::conversionFailed($value, $this->getName(), $e);
}
}
Finally, as we deeply understand what happens, we can fix our code. The fix is simple: we have to change the type-hinting of id from string to Uuid|string.
End of the journey
As simple as the resolution is, keep in mind the investigation of this kind of bug take roughtly 2–3 months, dealing with day-to-day dev tasks.
This article tries to hightlight that with a good undestanding of how works Doctrine, this bug could be prevent. But we have to keep in mind, this is with this kind of experience we improve ourself and the most important is to have a good debug method to solve those bugs.
About ekino
The ekino group has been supporting major groups and start-ups in their transformation for over 10 years, helping them to imagine and implement their digital services and deploying new methodologies within their project teams. A pioneer in its holistic approach, ekino draws on the synergy of its expertise to build coherent, long-term solutions.
To find out more, visit our website — ekino.com.
Symfony UUID, Doctrine and type-hinting : everything you should know was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.