Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/.idea
/vendor
/vendor
/.phpunit.cache
179 changes: 147 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,182 @@
# DoctrineLoggable

Logging of changes in Doctrine entities. Every change is stored as one `change_log` row with the
change set serialized to JSON, so the log is readable straight from the database.

## Installation

1. Install via composer:

```bash
composer require adt/doctrine-loggable
```

2. Register this extension in your config.neon:

```neon
extensions:
- ADT\DoctrineLoggable\DI\LoggableExtension
```

3. Do database migrations

4. Add annotation to entities you wish to log
4. Add attributes to entities you wish to log

```php
<?php

use Doctrine\ORM\Mapping as ORM;
use ADT\DoctrineLoggable\Attributes as ADA;

/**
* @ORM\Entity
* @ADA\LoggableEntity
*/

#[ORM\Entity]
#[ADA\LoggableEntity]
class User
{
#[ORM\Column(nullable: true)]
#[ADA\LoggableProperty]
protected ?string $firstname = null;

/**
* @ORM\Column(type="string", nullable=true)
* @ADA\LoggableProperty(label="entity.user.firstname")
*/
protected $firstname;

/**
* @ORM\ManyToMany(targetEntity="Role", inversedBy="users")
* @ADA\LoggableProperty(logEntity=false, label="entity.user.roles")
*/
protected $roles;

#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
#[ADA\LoggableProperty]
protected Collection $roles;
}

/**
* @ORM\Entity
* @ADA\LoggableIdentification(fields={"name"})
*/
#[ORM\Entity]
#[ADA\LoggableIdentification(fields: ['name'])]
class Role
{
#[ORM\Column]
protected string $name;

#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'roles')]
protected Collection $users;
}
```

## Stored format

The `change_set` column is a JSON column handled by the `change_set` DBAL type, which the extension
registers for you. `ChangeLog::getChangeSet()` returns the usual `ChangeSet` object graph.

```json
{
"version": 1,
"action": "edit",
"entity": {
"class": "App\\Model\\Entities\\Product",
"id": "42",
"identification": { "name": "Pivo 12°" }
},
"properties": {
"name": { "type": "scalar", "old": "Pivo", "new": "Pivo 12°" },
"price": { "type": "scalar", "old": 39.0, "new": 45.0 },
"validFrom": {
"type": "scalar",
"old": null,
"new": { "@type": "datetime", "class": "DateTimeImmutable", "value": "2026-08-27T12:00:00+02:00" }
},
"category": {
"type": "toOne",
"old": { "class": "App\\Model\\Entities\\Category", "id": "3", "identification": { "name": "Nápoje" } },
"new": { "class": "App\\Model\\Entities\\Category", "id": "7", "identification": { "name": "Pivo" } },
"changeSet": null
},
"tags": {
"type": "toMany",
"added": [ { "class": "App\\Model\\Entities\\Tag", "id": "9", "identification": { "name": "akce" } } ],
"removed": [],
"changeSets": []
}
}
}
```

A value position holds either a plain JSON scalar or an envelope object with the `@type` key.
Arrays never appear raw in a value position, so `@type` is always an unambiguous marker.
Built-in envelope types are `datetime`, `enum`, `array`, `binary`, `float` (for `NAN` and `INF`)
and `object` as the last resort fallback.

The property type is a `scalar`/`toOne`/`toMany` discriminator rather than a class name, so the
classes of this library can be moved or renamed without breaking existing logs.

The change set graph may contain cycles. A change set referenced more than once gets a `$id` and
every further occurrence is written as `{"$ref": id}`. Change sets referenced just once, which is
almost always the case, carry no ids at all.

## Custom value types

Values coming from custom Doctrine types (money, uuid, embeddables) end up in the `object` envelope,
which keeps their readable representation but does not restore the original object. Register a
handler to make the round trip lossless:

```php
use ADT\DoctrineLoggable\Serializer\ValueHandler;
use ADT\DoctrineLoggable\Serializer\ValueSerializer;

class MoneyHandler implements ValueHandler
{
public function getType(): string
{
return 'money';
}

/**
* @ORM\Column(type="string")
*/
protected $name;
public function supports(mixed $value): bool
{
return $value instanceof Money;
}

/**
* @ORM\ManyToMany(targetEntity="User", mappedBy="roles")
*/
protected $users;
public function encode(mixed $value, ValueSerializer $serializer): array
{
return ['amount' => $value->getAmount(), 'currency' => $value->getCurrency()];
}

public function decode(array $data, ValueSerializer $serializer): Money
{
return new Money($data['amount'], $data['currency']);
}
}
```

```neon
doctrineLoggable:
valueHandlers:
- App\Log\MoneyHandler
```

Handlers registered this way are tried before the built-in ones, so they can override them.

## Upgrading from 3.x

The `change_set` column changes from `LONGBLOB` holding a PHP `serialize()` payload to a JSON
column. Convert the existing rows first, while the column is still a BLOB:

```bash
php bin/console doctrine-loggable:convert-legacy-change-sets --dry-run
php bin/console doctrine-loggable:convert-legacy-change-sets
```

The command reads and rewrites the rows with plain SQL, so the `change_set` DBAL type never sees a
legacy payload. It commits per batch (`--batch-size`, 500 by default), recognises rows that already
hold JSON and leaves them alone, so it is safe to run again. A row that fails to convert is
reported and left untouched, and the command exits with a failure so you do not change the column
type on top of data that did not make it.

Only when it reports no failures:

```sql
ALTER TABLE change_log MODIFY change_set JSON NOT NULL;
```

`symfony/console` is optional. Without it the command is not registered and you can drive
`ADT\DoctrineLoggable\Service\LegacyChangeSetConverter` yourself.

`Adt\DoctrineLoggable\ChangeSet\*` is now declared as `ADT\DoctrineLoggable\ChangeSet\*`, which is
what the PSR-4 prefix always said. The old casing keeps autoloading, PHP treats both as the same
class.

## Tests

```bash
composer install
vendor/bin/phpunit
```
21 changes: 19 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,25 @@
},
"autoload": {
"psr-4": {
"ADT\\DoctrineLoggable\\": "src/"
"ADT\\DoctrineLoggable\\": "src/",
"Adt\\DoctrineLoggable\\": "src/"
},
"classmap": ["src/exceptions.php"]
"classmap": [
"src/exceptions.php"
]
},
"require-dev": {
"phpunit/phpunit": "^11.5 | ^12.0 | ^13.0",
"symfony/cache": "^6.0 | ^7.0",
"symfony/var-exporter": "^6.4 | ^7.0",
"symfony/console": "^6.0 | ^7.0"
},
"autoload-dev": {
"psr-4": {
"ADT\\DoctrineLoggable\\Tests\\": "tests/"
}
},
"suggest": {
"symfony/console": "To run doctrine-loggable:convert-legacy-change-sets, the migration of change sets stored by version 3"
}
}
Loading