Reusable Doctrine lifecycle features for Symfony entities.
This bundle provides a clean and focused foundation for common entity lifecycle concerns in Symfony applications using Doctrine ORM.
It currently includes:
- Timestampable support
- Blameable support
- Doctrine lifecycle listeners based on attributes
- UTC-based lifecycle timestamps
The bundle is designed to stay small, explicit and reusable.
Automatic handling of:
createdAtupdatedAt
Automatic handling of:
createdByIdentifierupdatedByIdentifier
Blameable values are stored as scalar identifiers, which keeps the bundle generic and independent from any application-specific User entity.
The bundle uses Doctrine listeners to update lifecycle fields automatically on:
prePersistpreUpdate
Lifecycle dates are generated in UTC to provide predictable and consistent storage across applications.
This bundle focuses only on entity lifecycle metadata.
It does not try to handle:
- audit trail history
- workflow/state machines
- publication systems
- translations
- business-specific ownership models
If you need full audit logging, use a dedicated audit bundle alongside this one.
- PHP 8.4+
- Symfony 7.4 or 8.0
- Doctrine Bundle
- Doctrine ORM
composer require zhortein/doctrine-lifecycle-bundleIf Symfony Flex does not register the bundle automatically, add it manually in config/bundles.php:
<?php
return [
// ...
Zhortein\DoctrineLifecycleBundle\ZhorteinDoctrineLifecycleBundle::class => ['all' => true],
];On prePersist:
createdAtis set if it is currentlynullupdatedAtis always set
On preUpdate:
updatedAtis updated
On prePersist:
createdByIdentifieris set if it is currentlynullupdatedByIdentifieris always set
On preUpdate:
updatedByIdentifieris updated
If no actor can be resolved, blameable fields remain unchanged.
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zhortein\DoctrineLifecycleBundle\Contract\TimestampableInterface;
use Zhortein\DoctrineLifecycleBundle\Trait\TimestampableTrait;
#[ORM\Entity]
final class Destination implements TimestampableInterface
{
use TimestampableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
public function __construct(string $name)
{
$this->name = $name;
}
}The bundle will automatically maintain:
createdAtupdatedAt
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zhortein\DoctrineLifecycleBundle\Contract\BlameableInterface;
use Zhortein\DoctrineLifecycleBundle\Trait\BlameableTrait;
#[ORM\Entity]
final class Article implements BlameableInterface
{
use BlameableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
public function __construct(string $title)
{
$this->title = $title;
}
}The bundle will automatically maintain:
createdByIdentifierupdatedByIdentifier
These values are provided by an ActorResolverInterface.
A single entity can use both features:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zhortein\DoctrineLifecycleBundle\Contract\BlameableInterface;
use Zhortein\DoctrineLifecycleBundle\Contract\TimestampableInterface;
use Zhortein\DoctrineLifecycleBundle\Trait\BlameableTrait;
use Zhortein\DoctrineLifecycleBundle\Trait\TimestampableTrait;
#[ORM\Entity]
final class ExpertProfile implements TimestampableInterface, BlameableInterface
{
use TimestampableTrait;
use BlameableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $displayName;
public function __construct(string $displayName)
{
$this->displayName = $displayName;
}
}The bundle relies on the following contract:
<?php
declare(strict_types=1);
namespace Zhortein\DoctrineLifecycleBundle\Resolver;
interface ActorResolverInterface
{
public function resolveActorIdentifier(): ?string;
}By default, the bundle provides a null resolver, which means blameable fields remain null unless you override the service.
In most applications, you will want to resolve the actor identifier from the authenticated user.
Example:
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Bundle\SecurityBundle\Security;
use Zhortein\DoctrineLifecycleBundle\Resolver\ActorResolverInterface;
final class SecurityActorResolver implements ActorResolverInterface
{
public function __construct(
private readonly Security $security,
) {
}
public function resolveActorIdentifier(): ?string
{
$user = $this->security->getUser();
if (null === $user) {
return null;
}
if (!method_exists($user, 'getUserIdentifier')) {
return null;
}
return $user->getUserIdentifier();
}
}Then override the resolver binding in your application:
services:
App\Security\SecurityActorResolver: ~
Zhortein\DoctrineLifecycleBundle\Resolver\ActorResolverInterface:
alias: App\Security\SecurityActorResolverThis bundle stores lifecycle timestamps as:
\DateTimeImmutable- Doctrine
datetime_immutable - generated in UTC
This is intentional.
The bundle handles technical lifecycle timestamps, not application-level timezone presentation.
If your application needs to display dates in a specific timezone, convert them at application level.
Example:
$localDate = $entity->getUpdatedAt()?->setTimezone(new \DateTimeZone('Europe/Paris'));This bundle intentionally follows a few rules:
- keep the scope narrow
- avoid application-specific assumptions
- stay independent from the host application's
Userentity - prefer predictable behavior over excessive magic
- make integration simple in real Symfony projects
The bundle is already usable for:
- timestampable metadata
- blameable metadata
- real integration into Symfony applications
Additional lifecycle helpers may come later, but only if they remain aligned with the bundle's narrow scope.
Possible future additions:
- soft delete metadata support
- optional configuration improvements
- additional integration tests
- more documentation and recipes
This bundle includes:
- PHPUnit tests
- PHPStan configuration
- PHP-CS-Fixer configuration
- CI workflow
Typical local commands:
make csfixer
make phpstan
make testWhen developing the bundle alongside a Symfony project, you can use a Composer path repository in the host application:
{
"repositories": [
{
"type": "path",
"url": "../doctrine-lifecycle-bundle",
"options": {
"symlink": true
}
}
],
"require": {
"zhortein/doctrine-lifecycle-bundle": "*@dev"
}
}This allows you to test the bundle directly in a real application without publishing it first.
This bundle is released under the MIT License.