Skip to content
AchillesBackend developer

Symfony: Add Entity Timestamp Metadata with Symfony Clock

20243 min read#php#symfony

ON THIS PAGE

Add createdAt and updatedAt to Doctrine entities without an extra bundle, using a trait, a lifecycle listener and the Symfony Clock.

When it comes to adding entity metadata timestamps like createdAt and updatedAt, there are many ways to implement it.

For this example, I will use Symfony 7 and the DoctrineBundle 2.11.3.

One could simply use the Timestampable from DoctrineExtensions and easily add the createdAt and updatedAt fields to the entities. But using libraries for such simple tasks might not always be the best solution, especially when you need control over things.

In this article, we will see how to add createdAt and updatedAt fields to the entities using the Symfony Clock component and also the same approach could be used for other similar behaviors.

Creating the Timestamp Trait

I will start by creating a simple PHP trait that will be used to add the createdAt and updatedAt fields and getters/setters to the entities.

Traits are not always the best solution, but in this case, it will be a good fit, since it will give us a simple way to add the fields to the entities and also keep the code DRY.

php
<?php

namespace App\Util;

use Doctrine\ORM\Mapping as ORM;

trait TimestampableEntityTrait
{
    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column]
    private \DateTimeImmutable $updatedAt;

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
    {
        $this->updatedAt = $updatedAt;

        return $this;
    }
}

Using the TimestampableEntityTrait

Now, let’s create a simple entity Post and use the TimestampableEntityTrait to add the createdAt and updatedAt fields.

php
<?php

namespace App\Entity;

use App\Repository\PostRepository;
use App\Util\TimestampableEntityTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    use TimestampableEntityTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $title;

    // getters and setters...
}

Since we are adding new fields, migrations will be required to update the database schema.

Setting Up the Event Listener

For the last part, we will use the Symfony Clock component to set the createdAt and updatedAt fields when the entity is created and updated. This can be easily done by hooking into the Doctrine lifecycle events, using a Doctrine Lifecycle Listener.

We will need an event listener and the AsDoctrineListener attribute from the DoctrineBundle to register the listener.

php
<?php

namespace App\EventListener;

use App\Util\TimestampableEntityTrait;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\Clock\ClockInterface;

#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
class TimestampableEntityListener
{
    public function __construct(
        private ClockInterface $clock
    ) {
    }

    public function prePersist(PrePersistEventArgs $args): void
    {
        if (in_array(TimestampableEntityTrait::class, class_uses($entity = $args->getObject()))) {
            $entity->setCreatedAt($now = $this->clock->now());
            $entity->setUpdatedAt($now);
        }
    }

    public function preUpdate(PreUpdateEventArgs $args): void
    {
        if (in_array(TimestampableEntityTrait::class, class_uses($entity = $args->getObject()))) {
            $entity->setUpdatedAt($this->clock->now());
        }
    }
}

By checking if the entity uses the TimestampableEntityTrait, we can set the createdAt and updatedAt fields in the appropriate lifecycle events, using the ClockInterface to get the current time.

php
in_array(TimestampableEntityTrait::class, class_uses($entity = $args->getObject()))

Bonus Section: Remove duplicated code

Checking if the entity uses the TimestampableEntityTrait in both methods is duplicated code. We can create a new private method to handle this check and call it in both methods:

php
private function supportsTrait(object $entity): bool
{
    return in_array(TimestampableEntityTrait::class, class_uses($entity));
}

This makes our final listener code look like:

php
<?php

namespace App\EventListener;

use App\Util\TimestampableEntityTrait;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\Clock\ClockInterface;

#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
class TimestampableEntityListener
{
    public function __construct(
        private ClockInterface $clock
    ) {
    }

    public function prePersist(PrePersistEventArgs $args): void
    {
        if ($this->supportsTrait($entity = $args->getObject())) {
            $entity->setCreatedAt($now = $this->clock->now());
            $entity->setUpdatedAt($now);
        }
    }

    public function preUpdate(PreUpdateEventArgs $args): void
    {
        if ($this->supportsTrait($entity = $args->getObject())) {
            $entity->setUpdatedAt($this->clock->now());
        }
    }

    private function supportsTrait(object $entity): bool
    {
        return in_array(TimestampableEntityTrait::class, class_uses($entity));
    }
}

This approach allows you to easily access the createdAt and updatedAt fields and also gives you control over the behavior. Plus, using the Symfony Clock component, you can easily test the behavior of the entities with MockClock, or with ClockSensitiveTrait and self::mockTime() in kernel tests.

References

Did this help?

Comments

via GitHub Discussions