26

I have a User entity:

use Doctrine\ORM\Mapping as ORM;

/**
 * ExampleBundle\Entity\User
 *
 * @ORM\Entity()
 */
class User
{
    // ...

    /**
     * @ORM\Column(type="service_expires_at", type="date", nullable=true)
     */
    private $service_expires_at;

    public function getServiceExpiresAt()
    {
        return $this->service_expires_at;
    }

    public function setServiceExpiresAt(\DateTime $service_expires_at)
    {
        $this->service_expires_at = $service_expires_at;
    }
}

When i update the User's service_expires_at as following, the updated service_expires_at value is NOT saved back into the database:

$date = $user->getServiceExpiresAt(); 

var_dump($date->format('Y-m-d')); // 2013-03-08

$date->modify('+10 days');

var_dump($date->format('Y-m-d')); // 2013-03-18

$user->setServiceExpiresAt($date);

$em->persist($user);
$em->flush();

However if i pass a new DateTime object to service_expires_at, the updated value is saved correctly:

$date = $user->getServiceExpiresAt(); 

$date->modify('+10 days');

$user->setServiceExpiresAt(new \DateTime($date->format('Y-m-d'));

$em->persist($user);
$em->flush();

Why is this happening?

Ocramius
  • 25,171
  • 7
  • 103
  • 107
Laurynas Mališauskas
  • 1,909
  • 1
  • 19
  • 34

3 Answers3

90

The DateTime instances returned by ExampleBundle\Entity\User#getServiceExpiresAt() are the same objects stored in the entity itself, which breaks encapsulation.

The UnitOfWork in Doctrine ORM applies strict comparison for changesets, which basically means that in the case of properties of entities containing objects, if the object instance hasn't changed, the ORM does not detect a change.

In strict comparison, following is true:

$dateTime1 = new \DateTime('@0');
$dateTime2 = new \DateTime('@0');
$dateTime3 = $dateTime1;

var_dump($dateTime1 !== $dateTime2); // true
var_dump($dateTime1 === $dateTime3); // true

$dateTime1->modify('+1 day');

var_dump($dateTime1 === $dateTime3); // true

This is a very common mistake among newcomers in OOP programming, and it can be solved quickly by fixing your getters and setters so that the original instance is never shared outside of your object, like in following example:

public function getServiceExpiresAt()
{
    return clone $this->service_expires_at;
}

public function setServiceExpiresAt(\DateTime $service_expires_at)
{
    $this->service_expires_at = clone $service_expires_at;
}

This will also fix your problem with Doctrine ORM.

Also, please note that this fixes possible leaks in your logic. For example, following code is buggy and hard to debug (when applying your currently broken getters/setters):

$bankTransaction1 = $someService->getTransaction(1);
$bankTransaction2 = $someService->getTransaction(2);

// leak! Now both objects reference the same DateTime instance!
$bankTransaction2->setDateTime($bankTransaction1->getDateTime());

// bug! now both your objects were modified!
$bankTransaction1->getDateTime()->modify('+1 day');

So, regardless of the ORM part in the question, please don't break encapsulation.

Ocramius
  • 25,171
  • 7
  • 103
  • 107
  • The answer is really detailed and points out the exact problem ! Thanks for the effort. This was driving me nuts ! – Jan May 19 '15 at 13:01
  • You should take into account that this is not a good idea for all fields. If you've got a relationship between two entities cloning the object will trigger lazy-loading as soon as you touch the proxy. That could ruin your performance – Nicolas Reynis Sep 29 '17 at 15:27
  • Performance is almost always secondary in these scenarios: if you want performance out of this sort of operations, perform the copying operation at DB level directly (via `INSERT ... SELECT ...`) – Ocramius Oct 03 '17 at 17:33
  • I had the issue, and thought of wrapping persist-flush in transaction (`beginTransaction`/`commit`) - it worked. Also - there was no problem in local env (xampp), only in prod linux. Could it be that this behavior depends on env-settings? – Taz Feb 11 '21 at 08:20
1

Consider using DateTimeImmutable class for your date/time properties. Thereby, note that DateTimeImmutable is not an instance of DateTime.

Nikola Poša
  • 153
  • 1
  • 1
  • 13
0

I have exactly the same problem when i am trying to insert an entity with a past date (i'm trying to migrate an old database to new schema with it's data too).

I tried to clone the object in both setter and getter and it's useless. Doctrine 2 saves the current date. Checked the schema, the field is date time not time stamp and default is null.

How can this be?

EDIT:

please excuse my lack of attention, my colleague dev added a prePersist event:

/**
 * @ORM\PrePersist
 */
function onPrePersist() {
    $this->created_at = new \DateTime('now');
}
Andrew Starlike
  • 379
  • 4
  • 14