Backend Development 14 min read

Implementing Change Data Capture (CDC) in Symfony Applications

This article explains the concept of Change Data Capture, outlines its real‑time analysis, replication, audit logging and event‑driven benefits, and provides three Symfony implementation approaches—Doctrine lifecycle callbacks, entity listeners, and global listeners—complete with code examples and best‑practice recommendations.

php中文网 Courses
php中文网 Courses
php中文网 Courses
Implementing Change Data Capture (CDC) in Symfony Applications

Change Data Capture (CDC) is a powerful technique for tracking and capturing database changes in real time, enabling applications to react instantly, provide up‑to‑date insights, and trigger appropriate actions.

The article introduces CDC and its importance in scenarios such as real‑time analytics, data replication, audit logging, and event‑driven architectures.

Three Symfony‑specific methods for implementing CDC are presented:

Doctrine Lifecycle Callbacks – embedding logic directly in the entity class.

Doctrine Entity Listeners – creating separate listener classes for each entity.

Doctrine Lifecycle Listeners – attaching global listeners to the entire project.

All approaches aim to automatically update an updatedAt field whenever a User entity changes.

Example: User Entity

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\UserRepository;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: "integer")]
    private int $id;

    #[ORM\Column(type: "string", length: 255)]
    private string $name;

    #[ORM\Column(type: "string", length: 255, unique: true)]
    private string $email;

    #[ORM\Column(type: "string", length: 255)]
    private string $password;

    #[ORM\Column(type: "datetime", nullable: true)]
    private ?\DateTimeInterface $updatedAt;

    // ...
    public function getUpdatedAt(): ?\DateTimeInterface
    {
        return $this->updatedAt;
    }

    public function setUpdatedAt(?\DateTimeInterface $updatedAt): self
    {
        $this->updatedAt = $updatedAt;
        return $this;
    }
}

Doctrine Lifecycle Callbacks

By adding the #[ORM\HasLifecycleCallbacks] attribute and a #[ORM\PreUpdate] method, the updatedAt field is refreshed automatically.

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\UserRepository;

#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    // ... properties as above ...

    #[ORM\PreUpdate]
    public function setUpdatedAt(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }
}

While convenient, embedding lifecycle logic in the entity can violate the Single Responsibility Principle and increase complexity.

Using a Trait and Interface

To keep entities clean, an UpdatedAwareInterface and UpdatedAwareTrait are introduced.

declare(strict_types=1);

namespace App\Contract\Doctrine;

interface UpdatedAwareInterface
{
    public function getUpdatedAt(): ?\DateTimeInterface;
    public function setUpdatedAt(): void;
}
declare(strict_types=1);

namespace App\Trait\Doctrine;

use Doctrine\ORM\Mapping as ORM;

trait UpdatedAwareTrait
{
    #[ORM\Column(type: "datetime", nullable: true)]
    private ?\DateTimeInterface $updatedAt;

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

    #[ORM\PreUpdate]
    public function setUpdatedAt(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }
}

The User entity then uses the trait and implements the interface, preserving a single‑responsibility design.

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\UserRepository;
use App\Trait\Doctrine\UpdatedAwareTrait;
use App\Contract\Doctrine\UpdatedAwareInterface;

#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UpdatedAwareInterface
{
    use UpdatedAwareTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: "integer")]
    private int $id;

    #[ORM\Column(type: "string", length: 255)]
    private string $name;

    #[ORM\Column(type: "string", length: 255, unique: true)]
    private string $email;

    #[ORM\Column(type: "string", length: 255)]
    private string $password;

    // ...
}

Doctrine Entity Listener

A dedicated listener class can update updatedAt for a specific entity, improving decoupling.

declare(strict_types=1);

namespace App\EventListener;

use App\Entity\User;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;

#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: User::class)]
class UserUpdatedEventListener
{
    public function preUpdate(User $user, PreUpdateEventArgs $args): void
    {
        $user->setUpdatedAt(new \DateTimeImmutable());
    }
}

Global Doctrine Listener

For a reusable solution across all entities, a global listener checks for the updatedAt field or the UpdatedAwareInterface before updating.

declare(strict_types=1);

namespace App\EventListener;

use App\Contract\Doctrine\UpdatedAwareInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;

#[AsDoctrineListener(event: Events::preUpdate, priority: 500, connection: 'default')]
class EntityUpdatedEventListener
{
    public function preUpdate(PreUpdateEventArgs $args): void
    {
        $entity = $args->getObject();
        if ($entity instanceof UpdatedAwareInterface) {
            $entity->setUpdatedAt(new \DateTimeImmutable());
        }
    }
}

These implementations illustrate trade‑offs between simplicity, decoupling, and reusability, guiding developers to choose the most suitable CDC strategy for their Symfony projects.

Conclusion

The article thoroughly explores CDC concepts and demonstrates practical Symfony implementations using Doctrine ORM, highlighting how real‑time data capture enhances application responsiveness, data accuracy, and overall reliability.

backend developmentphpEvent ListenersChange Data CaptureSymfonyDoctrine
php中文网 Courses
Written by

php中文网 Courses

php中文网's platform for the latest courses and technical articles, helping PHP learners advance quickly.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.