Install the Bundle
composer require atournayre/historique-bundle
Register the Bundle
Add to config/bundles.php (Symfony 5+):
return [
// ...
Atournayre\Bundle\HistoriqueBundle\HistoriqueBundle::class => ['all' => true],
];
Configure Doctrine and Bundle
Update config/packages/doctrine.yaml:
doctrine:
orm:
resolve_target_entities:
Atournayre\Bundle\HistoriqueBundle\Interfaces\History: App\Entity\History
Symfony\Component\Security\Core\User\UserInterface: App\Entity\User
Update config/packages/atournayre_historique.yaml:
atournayre_historique:
history_class: App\Entity\History
Create the History Entity
// src/Entity/History.php
namespace App\Entity;
use Atournayre\Bundle\HistoriqueBundle\Entity\History as BaseHistory;
use Atournayre\Bundle\HistoriqueBundle\Interfaces\History;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class History extends BaseHistory implements History
{
// No need to extend further
}
Enable History for an Entity
// src/Entity/YourEntity.php
use Atournayre\Bundle\HistoriqueBundle\Traits\HistorycableTrait;
use Atournayre\Bundle\HistoriqueBundle\Traits\HistorycableInterface;
class YourEntity implements HistorycableInterface
{
use HistorycableTrait;
}
Log changes to a Product entity:
// src/Factory/ProductHistoryFactory.php
namespace App\Factory;
use App\Entity\Product;
use Atournayre\Bundle\HistoriqueBundle\DTO\HistoryDTO;
use Atournayre\Bundle\HistoriqueBundle\Factory\AbstractFactory;
use Atournayre\Bundle\HistoriqueBundle\Interfaces\History;
class ProductHistoryFactory extends AbstractFactory
{
public function create(array $changeSet): ?History
{
$this->name($changeSet);
$this->price($changeSet);
return parent::createHistory();
}
private function name(array $changeSet): void
{
$this->changeSet->set('name', HistoryDTO::createFromChangeSet(
'Product Name',
$changeSet['name'] ?? null,
fn(Product $product) => $product->getName()
));
}
private function price(array $changeSet): void
{
$this->changeSet->set('price', HistoryDTO::createFromChangeSet(
'Product Price',
$changeSet['price'] ?? null,
fn(Product $product) => $product->getPrice()
));
}
}
Update config/packages/atournayre_historique.yaml:
atournayre_historique:
mappings:
App\Entity\Product: App\Factory\ProductHistoryFactory
Implement HistorycableInterface
Add the trait to any entity you want to track:
use Atournayre\Bundle\HistoriqueBundle\Traits\HistorycableTrait;
class User implements HistorycableInterface
{
use HistorycableTrait;
}
Create a Factory Define how changes are logged for each field:
class UserHistoryFactory extends AbstractFactory
{
public function create(array $changeSet): ?History
{
$this->email($changeSet);
$this->role($changeSet);
return parent::createHistory();
}
private function email(array $changeSet): void
{
$this->changeSet->set('email', HistoryDTO::createFromChangeSet(
'User Email',
$changeSet['email'] ?? null,
fn(User $user) => $user->getEmail()
));
}
}
Map the Factory
atournayre_historique:
mappings:
App\Entity\User: App\Factory\UserHistoryFactory
Retrieve History
$user = $entityManager->getRepository(User::class)->find(1);
$history = $user->getEntityChangeSet(); // Collection of History entities
$historyArray = $user->getEntityChangeSetAsArray(); // Array of changes
HistoryDTO to pre-populate forms with previous values.#[Route('/entities/{id}/history', name: 'get_entity_history')]
public function getHistory(EntityManagerInterface $em, int $id): JsonResponse
{
$entity = $em->getRepository(YourEntity::class)->find($id);
return new JsonResponse($entity->getEntityChangeSetAsArray());
}
onFlush events:
$eventManager->addEventListener(
[OnFlushEvent::class],
[$this, 'logAdditionalData']
);
Empty Change Sets
$changeSet is empty (e.g., no changes), the factory must return null or handle it gracefully:
public function create(array $changeSet): ?History
{
if (empty($changeSet)) {
return null;
}
// ...
}
Circular References
HistoryDTO (e.g., logging a User that references the same User entity). Use HistoryDTOFactory::createFromChangeSet() with care.Performance
price, status).Criteria to paginate history:
$criteria = Criteria::create()
->orderBy(['createdAt' => 'DESC'])
->setLimit(10);
$history = $entity->getEntityChangeSet()->matching($criteria);
User Tracking
SecurityContext). Ensure your User entity implements UserInterface and is properly configured in resolve_target_entities.Doctrine Events
onFlush if validation fails. Catch these in your event subscriber:
public function onFlush(OnFlushEventArgs $args): void
{
try {
// ...
} catch (HistoriqueException $e) {
$this->logger->error($e->getMessage());
}
}
Verify Factory Mapping
Check config/packages/atournayre_historique.yaml for correct mappings entries. A missing or misconfigured mapping will silently ignore changes.
Inspect Change Sets
Dump $changeSet in your factory to debug what’s being passed:
public function create(array $changeSet): ?History
{
dump($changeSet); // Debug the input
// ...
}
History Entity Not Persisting Ensure:
History entity is annotated with @ORM\Entity.HistoryEventSubscriber is listed in EntityListeners:
#[ORM\EntityListeners([HistoryEventSubscriber::class])]
class History { ... }
Symfony Security Context
If the logged user is null, verify:
User entity is properly resolved in doctrine.yaml.Security component is configured to load the user (e.g., via FIREWALL in security.yaml).Custom History Fields
Extend the History entity to add metadata (e.g., ipAddress, device):
#[ORM\Column(type: 'string', nullable: true)]
private ?string $ipAddress = null;
// Set in factory:
$this->changeSet->set('ipAddress', HistoryDTO::createFromChangeSet(
'IP Address',
$_SERVER['REMOTE_ADDR'] ?? null
));
Bulk Operations
Disable history for bulk updates (e.g., batch imports) by temporarily removing the HistorycableTrait or using a decorator pattern.
Soft Deletes
Integrate with gedmo/doctrine-extensions to log soft-deleted entities:
#[ORM\EntityListeners([HistoryEventSubscriber::class, SoftDeleteableListener::class])]
class Product { ... }
Custom DTO Factories
Override HistoryDTOFactory to handle complex types (e.g
How can I help you explore Laravel packages today?