fix: propagate updated at timestamp to parent entity

This commit is contained in:
Björn Fromme
2026-08-18 09:52:28 +02:00
parent e8143c055b
commit 5045846eed
5 changed files with 86 additions and 3 deletions
+7 -1
View File
@@ -6,6 +6,7 @@ use App\Entity\BlameableEntity;
use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use App\Enum\Groups\PriceType;
use App\Enum\Groups\Season;
use App\Repository\Groups\AccommodationPriceRepository;
@@ -14,7 +15,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AccommodationPriceRepository::class)]
class AccommodationPrice implements BlameableEntityInterface, TimestampableEntityInterface
class AccommodationPrice implements BlameableEntityInterface, TimestampableEntityInterface, TimestampableParentAwareInterface
{
use BlameableEntity;
use TimestampableEntity;
@@ -89,6 +90,11 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit
return $this;
}
public function getTimestampableParent(): ?TimestampableEntityInterface
{
return $this->accommodation;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
+7 -1
View File
@@ -6,6 +6,7 @@ use App\Entity\BlameableEntity;
use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AdditionalServiceRepository;
use Doctrine\DBAL\Types\Types;
@@ -13,7 +14,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AdditionalServiceRepository::class)]
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface
class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface, TimestampableParentAwareInterface
{
use BlameableEntity;
use TimestampableEntity;
@@ -87,6 +88,11 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
return $this;
}
public function getTimestampableParent(): ?TimestampableEntityInterface
{
return $this->accommodation;
}
public function getLabel(): ?string
{
return $this->label;
+7 -1
View File
@@ -6,13 +6,14 @@ use App\Entity\BlameableEntity;
use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BoardServiceRepository::class)]
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface
class BoardService implements BlameableEntityInterface, TimestampableEntityInterface, TimestampableParentAwareInterface
{
use BlameableEntity;
use TimestampableEntity;
@@ -67,6 +68,11 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter
return $this;
}
public function getTimestampableParent(): ?TimestampableEntityInterface
{
return $this->accommodation;
}
public function getLabel(): ?string
{
return $this->label;
@@ -0,0 +1,11 @@
<?php
namespace App\Entity;
interface TimestampableParentAwareInterface
{
/**
* The entity whose updatedAt should be bumped whenever this entity is created, changed or removed.
*/
public function getTimestampableParent(): ?TimestampableEntityInterface;
}
@@ -3,13 +3,16 @@
namespace App\EventListener;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(Events::prePersist)]
#[AsDoctrineListener(Events::preUpdate)]
#[AsDoctrineListener(Events::onFlush)]
class TimestampableEntityListener
{
public function prePersist(PrePersistEventArgs $args): void
@@ -31,4 +34,55 @@ class TimestampableEntityListener
$entity->setUpdatedAt($now);
}
}
/**
* Doctrine has no notion of "collection member changed, so the owner changed". Entities that
* only make sense as part of their parent therefore push their own changes upwards, so the
* parent's updatedAt reflects the last change to the aggregate as a whole.
*/
public function onFlush(OnFlushEventArgs $args): void
{
$entityManager = $args->getObjectManager();
$unitOfWork = $entityManager->getUnitOfWork();
$scheduled = [
...$unitOfWork->getScheduledEntityInsertions(),
...$unitOfWork->getScheduledEntityUpdates(),
...$unitOfWork->getScheduledEntityDeletions(),
];
/** @var array<int, TimestampableEntityInterface> $parents */
$parents = [];
foreach ($scheduled as $entity) {
if (!$entity instanceof TimestampableParentAwareInterface) {
continue;
}
$parent = $entity->getTimestampableParent();
// A parent created in this very flush already gets its createdAt, and one being
// removed must not be resurrected by an update.
if (null === $parent
|| $unitOfWork->isScheduledForInsert($parent)
|| $unitOfWork->isScheduledForDelete($parent)) {
continue;
}
$parents[spl_object_id($parent)] = $parent;
}
$now = new \DateTimeImmutable();
foreach ($parents as $parent) {
$parent->setUpdatedAt($now);
// Registers the parent as an update even when it had no changeset of its own. The
// preUpdate listeners run afterwards, so updatedBy stays in sync with updatedAt.
$unitOfWork->recomputeSingleEntityChangeSet(
$entityManager->getClassMetadata($parent::class),
$parent,
);
}
}
}