Changes to entities with readonly identifier are not persisted #7585

Closed
opened 2026-01-22 15:53:52 +01:00 by admin · 1 comment
Owner

Originally created by @adrav on GitHub (Jan 1, 2026).

Problem when using readonly property for ID in Entity

Q A
Version affected 3.2.2 (but also 3.1.1)
Works fine in 2.18.2

Summary

When using a readonly property for the identifier (id) in an entity, Doctrine fails to properly manage entity state. After flushing changes to a related entity (updating House::city through a HouseFloor reference), subsequent queries return stale data: the House entity is reloaded with its previous state instead of the updated one. Data is not persisted to the database.

This issue does not occur in doctrine-bundle 2.18.2 and disappears if the readonly modifier is removed from the entity ID property, indicating a regression in how Doctrine handles entities with readonly identifiers.

composer:

package version
doctrine/collections 2.4.0
doctrine/data-fixtures 2.2.0
doctrine/dbal 4.4.1
doctrine/deprecations 1.1.5
doctrine/doctrine-bundle 3.2.2
doctrine/doctrine-fixtures-bundle 4.3.1
doctrine/doctrine-migrations-bundle 3.7.0
doctrine/event-manager 2.0.1
doctrine/inflector 2.1.0
doctrine/instantiator 2.0.0
doctrine/lexer 3.0.1
doctrine/migrations 3.9.5
doctrine/orm 3.6.0
doctrine/persistence 4.1.1
doctrine/sql-formatter 1.5.3
phpstan/phpstan-doctrine 2.0.12
ramsey/uuid-doctrine 2.1.0
symfony/doctrine-bridge 6.4.26
symfony/doctrine-messenger 6.4.31

How to reproduce

I have two entites:

House.php

<?php declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

#[ORM\Table(name: 'house')]
#[ORM\Entity()]
class House
{

    #[ORM\Id]
    #[ORM\Column(type: Types::GUID, unique: true)]
    private readonly string $id;

    #[ORM\Column()]
    private string $city;

    /**
     * @var Collection<int, HouseFloor>
     */
    #[ORM\OneToMany(targetEntity: HouseFloor::class, mappedBy: 'house')]
    private Collection $floors;

    public function __construct(
    ) {
        $this->id = Uuid::uuid4()->toString();
        $this->floors = new ArrayCollection();
    }
    public function getId(): string
    {
        return $this->id;
    }
    public function getCity(): string
    {
        return $this->city;
    }
    public function setCity(string $city): void
    {
        $this->city = $city;
    }
    public function getFloors(): Collection
    {
        return $this->floors;
    }
    public function setFloors(Collection $floors): void
    {
        $this->floors = $floors;
    }
}

HouseFloor.php

<?php declare(strict_types=1);

namespace App\Entity;

use App\Repository\HouseFloorRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

#[ORM\Table(name: 'house_floor')]
#[ORM\Entity(repositoryClass: HouseFloorRepository::class)]
class HouseFloor
{

    #[ORM\Id]
    #[ORM\Column(type: Types::GUID, unique: true)]
    private readonly string $id;

    #[ORM\Column(type: Types::INTEGER)]
    private int $number;

    #[ORM\ManyToOne(inversedBy: 'floors')]
    #[ORM\JoinColumn(nullable: false)]
    private House $house;

    public function __construct(House $house, int $number)
    {
        $this->id = Uuid::uuid4()->toString();
        $this->house = $house;
        $this->number = $number;
    }
    public function getNumber(): int
    {
        return $this->number;
    }
    public function setNumber(int $number): void
    {
        $this->number = $number;
    }
    public function getId(): string
    {
        return $this->id;
    }
    public function getHouse(): House
    {
        return $this->house;
    }
}

And simple controller:

    #[Route('/test/house/add', name: 'test_house_add')]
    public function testAddAction(): Response
    {
        $house = new House();
        $house->setCity('Krakow');
        $this->entityManager->persist($house);

        $houseFloor1 = new HouseFloor($house, 1);
        $this->entityManager->persist($houseFloor1);

        $houseFloor2 = new HouseFloor($house, 2);
        $this->entityManager->persist($houseFloor2);

        $this->entityManager->flush();

        return new Response('OK.');
    }

    #[Route('/test/house/listFloors', name: 'test_house_list')]
    public function testListAction(): Response
    {
        $list = $this->houseFloorRepository->findAll();

        foreach ($list as $floor) {
            $floor->getHouse()->getCity();
        }
        dump($list);

        die;

    }

    #[Route('/test/house/testFloor/{houseFloor}', name: 'test_house_test_floor')]
    public function testFloorAction(HouseFloor $houseFloor): Response
    {
        $houseFloor->getHouse()->setCity('Paris');
        $this->entityManager->flush();

        return new Response('OK. Flushed. New city:'.$houseFloor->getHouse()->getCity());
    }

Then reproduce as below:

  1. https://localhost/test/house/add
  2. https://localhost/test/house/listFloors
  3. Get one housefloor GUID
  4. Use it here: https://localhost/test/house/testFloor/{houseFloorGUID}
  5. New "City name" will be displayed.
  6. The new "city name" in House object is expected, but the old one is still here: https://localhost/test/house/listFloors

If you remove readonly property from House entity it works fine.

Expected behavior:
Modifying a managed entity and calling flush() should result in an UPDATE statement and persisted changes in the database.

Actual behavior:
With a readonly identifier, Doctrine does not detect the entity as dirty and silently skips persisting the change.

Workaround

This issue does not occur in doctrine-bundle 2.18.2 and disappears if the readonly modifier is removed from the ID property, indicating a regression in change-tracking when entities use PHP readonly identifiers.

Downgrade the doctrine-bundle package:

composer require doctrine/doctrine-bundle:2.18.2

Then it works fine with the readonly property on $id field.

Originally created by @adrav on GitHub (Jan 1, 2026). ### Problem when using readonly property for ID in Entity | Q | A |-------------------------------------------- | ------ | Version affected | 3.2.2 (but also 3.1.1) | Works fine in | 2.18.2 #### Summary When using a `readonly` property for the identifier (`id`) in an entity, Doctrine fails to properly manage entity state. After flushing changes to a related entity (updating `House::city` through a `HouseFloor` reference), subsequent queries return stale data: the `House` entity is reloaded with its previous state instead of the updated one. Data is not persisted to the database. This issue does not occur in `doctrine-bundle` 2.18.2 and disappears if the `readonly` modifier is removed from the entity ID property, indicating a regression in how Doctrine handles entities with `readonly` identifiers. #### composer: | package | version |-------------------------------------------- | ------ | doctrine/collections | 2.4.0 | doctrine/data-fixtures | 2.2.0 | doctrine/dbal | 4.4.1 | doctrine/deprecations | 1.1.5 | doctrine/doctrine-bundle | 3.2.2 | doctrine/doctrine-fixtures-bundle | 4.3.1 | doctrine/doctrine-migrations-bundle| 3.7.0 | doctrine/event-manager | 2.0.1 | doctrine/inflector | 2.1.0 | doctrine/instantiator | 2.0.0 | doctrine/lexer | 3.0.1 | doctrine/migrations | 3.9.5 | doctrine/orm | 3.6.0 | doctrine/persistence | 4.1.1 | doctrine/sql-formatter | 1.5.3 | phpstan/phpstan-doctrine | 2.0.12 | ramsey/uuid-doctrine | 2.1.0 | symfony/doctrine-bridge | 6.4.26 | symfony/doctrine-messenger | 6.4.31 #### How to reproduce I have two entites: House.php ``` <?php declare(strict_types=1); namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; #[ORM\Table(name: 'house')] #[ORM\Entity()] class House { #[ORM\Id] #[ORM\Column(type: Types::GUID, unique: true)] private readonly string $id; #[ORM\Column()] private string $city; /** * @var Collection<int, HouseFloor> */ #[ORM\OneToMany(targetEntity: HouseFloor::class, mappedBy: 'house')] private Collection $floors; public function __construct( ) { $this->id = Uuid::uuid4()->toString(); $this->floors = new ArrayCollection(); } public function getId(): string { return $this->id; } public function getCity(): string { return $this->city; } public function setCity(string $city): void { $this->city = $city; } public function getFloors(): Collection { return $this->floors; } public function setFloors(Collection $floors): void { $this->floors = $floors; } } ``` HouseFloor.php ``` <?php declare(strict_types=1); namespace App\Entity; use App\Repository\HouseFloorRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; #[ORM\Table(name: 'house_floor')] #[ORM\Entity(repositoryClass: HouseFloorRepository::class)] class HouseFloor { #[ORM\Id] #[ORM\Column(type: Types::GUID, unique: true)] private readonly string $id; #[ORM\Column(type: Types::INTEGER)] private int $number; #[ORM\ManyToOne(inversedBy: 'floors')] #[ORM\JoinColumn(nullable: false)] private House $house; public function __construct(House $house, int $number) { $this->id = Uuid::uuid4()->toString(); $this->house = $house; $this->number = $number; } public function getNumber(): int { return $this->number; } public function setNumber(int $number): void { $this->number = $number; } public function getId(): string { return $this->id; } public function getHouse(): House { return $this->house; } } ``` And simple controller: ``` #[Route('/test/house/add', name: 'test_house_add')] public function testAddAction(): Response { $house = new House(); $house->setCity('Krakow'); $this->entityManager->persist($house); $houseFloor1 = new HouseFloor($house, 1); $this->entityManager->persist($houseFloor1); $houseFloor2 = new HouseFloor($house, 2); $this->entityManager->persist($houseFloor2); $this->entityManager->flush(); return new Response('OK.'); } #[Route('/test/house/listFloors', name: 'test_house_list')] public function testListAction(): Response { $list = $this->houseFloorRepository->findAll(); foreach ($list as $floor) { $floor->getHouse()->getCity(); } dump($list); die; } #[Route('/test/house/testFloor/{houseFloor}', name: 'test_house_test_floor')] public function testFloorAction(HouseFloor $houseFloor): Response { $houseFloor->getHouse()->setCity('Paris'); $this->entityManager->flush(); return new Response('OK. Flushed. New city:'.$houseFloor->getHouse()->getCity()); } ``` Then reproduce as below: 1. https://localhost/test/house/add 2. https://localhost/test/house/listFloors 3. Get one housefloor GUID 4. Use it here: https://localhost/test/house/testFloor/{houseFloorGUID} 5. New "City name" will be displayed. 6. The new "city name" in House object is expected, but the old one is still here: https://localhost/test/house/listFloors If you remove **readonly** property from **House** entity it works fine. **Expected behavior:** Modifying a managed entity and calling `flush()` should result in an UPDATE statement and persisted changes in the database. **Actual behavior:** With a `readonly` identifier, Doctrine does not detect the entity as dirty and silently skips persisting the change. #### Workaround This issue does not occur in `doctrine-bundle` 2.18.2 and disappears if the `readonly` modifier is removed from the ID property, indicating a regression in change-tracking when entities use PHP `readonly` identifiers. Downgrade the **doctrine-bundle** package: ``` composer require doctrine/doctrine-bundle:2.18.2 ``` Then it works fine with the **readonly** property on $id field.
admin closed this issue 2026-01-22 15:53:52 +01:00
Author
Owner

@stof commented on GitHub (Jan 3, 2026):

This is much more likely to be related to versions of the orm than to the version of the bundle (downgrading the bundle might force a change of version of the ORM as well)

@stof commented on GitHub (Jan 3, 2026): This is much more likely to be related to versions of the orm than to the version of the bundle (downgrading the bundle might force a change of version of the ORM as well)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: doctrine/archived-orm#7585