Marks personal data on Doctrine entities and generates anonymized read-only database views from them. Useful whenever something needs to read your data but must not see personal details - reporting, analytics, a demo environment, an external contractor, or an LLM.
composer require adt/doctrine-anonymizationAnonymization happens in the database, in the definition of the generated views - loaded entities are never rewritten at runtime:
- You mark personal columns with
#[Anonymize(...)]. anonymization:generate-viewsreads the entity metadata and creates<dbname>_anon.<table>views where marked columns are replaced by an SQL expression.- Consumers connect with a read-only account that has
GRANT SELECTonly on the anon schema, so unmasked data never reaches them.
Views are created with SQL SECURITY DEFINER, so the read-only account needs no
privileges on the source tables.
use ADT\DoctrineAnonymization\AnonymizationType;
use ADT\DoctrineAnonymization\Attributes\Anonymize;
use ADT\DoctrineAnonymization\Attributes\Description;
class Client
{
#[ORM\Column]
#[Anonymize(AnonymizationType::EMAIL)]
public ?string $email = null;
#[ORM\Column]
#[Anonymize(AnonymizationType::NOTE)]
#[Description('Free-text note about the client written by staff.')]
public ?string $note = null;
#[ORM\Column]
public ?float $weight = null; // not personal data, passes through 1:1
}Not every entity is a data subject. Staff, branches and companies are things you report by, so their identity has to stay readable:
use ADT\DoctrineAnonymization\Attributes\AnonymizationExempt;
#[AnonymizationExempt] // everything stays readable
class Branch { ... }
#[AnonymizationExempt(AnonymizationType::FULL_NAME)] // only the name stays readable
class User { ... } // contact details still maskedSECRET and BANK_ACCOUNT are masked even on exempt entities - they are never a
reporting dimension and leaking them is a security problem on its own. Any
credential (login name, password, salt, token, session id) should therefore use
SECRET, otherwise it stays readable on an exempt entity.
The built-in AnonymizationType covers the domain-neutral cases. For domain types
implement AnonymizationTypeInterface with your own enum - both can be mixed, since
#[Anonymize] accepts any implementation:
enum MyType: string implements AnonymizationTypeInterface
{
case GLYCAEMIA = 'glycaemia';
case MEDICAL_NOTE = 'medical_note';
public function key(): string { return $this->value; }
public function isAnonymized(): bool
{
// Keep measurements real so reports over them make sense,
// but never expose free-text notes - they contain names and diagnoses.
return $this === self::MEDICAL_NOTE;
}
public function alwaysAnonymize(): bool { return false; }
public function isDirectIdentifier(): bool { return false; }
}If a type needs a specific shape of masking (e.g. keep only the year of a date),
implement MaskingStrategy - see DefaultMaskingStrategy, which does exactly that
for DATE_OF_BIRTH.
services:
- ADT\DoctrineAnonymization\AnonymizationPolicy
- ADT\DoctrineAnonymization\AnonymizedViewsGenerator
- ADT\DoctrineAnonymization\GenerateAnonymizedViewsCommand# fixed mask, plus a read-only account with SELECT on the anon schema only
php bin/console anonymization:generate-views --mask \
--schema=myapp_anon --grant-user=myapp_ro --grant-password=secret
# realistic (deterministic) values instead of a fixed mask - JOIN/GROUP BY keep working
php bin/console anonymization:generate-views --salt=... --schema=myapp_anon
# print the SQL without touching the database
php bin/console anonymization:generate-views --mask --dry-runRun it on every deploy, right after migrations. The column list of each view is fixed at generation time, so a newly added source column is simply absent from the view until you regenerate - fail-closed, but also means annotating alone changes nothing on live views.
| Mode | Value becomes | JOIN / GROUP BY | Use for |
|---|---|---|---|
--mask |
***** |
no | just hide the data |
| default | deterministic hash (SHA2 + salt), shape preserved per type |
yes | analytics that group by a person |
NULL always stays NULL. Keep the salt secret and stable - changing it changes all
pseudonyms.
With --federated the source database is mirrored on --host through FEDERATED
tables and the views are built on top of that mirror, so the views can live on a
different server than the data (requires the FEDERATED engine). Note the mirror
schema contains readable data - never grant anything on it.
Generated views do not carry over MySQL column comments, so anything reading the anon
schema sees bare column names. SchemaDescriber turns the #[Description]
attributes into [table => [column => text]] and always appends the target table for
foreign keys:
$describer->getColumnDescriptions()['users']['DISABLED'];
// 'Account status: 1 = active, 2 = suspended, 3 = deleted. Not a boolean despite the
// column name. Foreign key referencing table "cl_states".'Without it consumers guess, and legacy names make them guess wrong - a DISABLED
column holding a status id looks like a boolean, so WHERE DISABLED = 0 silently
returns nothing.
PersonalDataColumns::getDirectIdentifierColumns() lists column names that always
carry a masked direct identifier, for consumers that need to redact identifiers from
free-form output. Column names readable anywhere else are excluded, so redaction
cannot strip legitimate values (e.g. a masked client NAME vs. a readable branch
NAME).
composer install
vendor/bin/codecept run unitThe suite covers the metadata driven parts (policy, masking expressions, schema
descriptions, which columns end up masked) and needs no database - the entity
manager is built over the fixtures in tests/unit/Fixtures and only reads mapping
metadata. Generating the views themselves requires MySQL and belongs to an
integration test in the consuming project.
ReadOnlyQueryExecutor connects with the dedicated read-only account and runs
validated read-only queries, which is what you want whenever an untrusted or
semi-trusted consumer writes the SQL - ad-hoc reporting, a BI tool, or an LLM:
services:
- ADT\DoctrineAnonymization\ReadOnlyConnectionOptions::fromArray(%reporting%)
- ADT\DoctrineAnonymization\ReadOnlyQueryExecutor$executor->execute('SELECT city, COUNT(*) FROM person GROUP BY city'); // capped at maxRows
$executor->streamQuery($sql, $onRow); // for exports, no interactive row limit
$executor->getTables(); // views available in the schema
$executor->getTableColumns('person'); // columns incl. DESCRIPTIONRejected queries throw QueryNotAllowedException, database failures
QueryFailedException. Only SELECT/WITH/SHOW/DESCRIBE/EXPLAIN pass, no
multi-statement, and a qualified reference to the source database is refused.
This validation is a second line of defence, not the boundary. The boundary is
the grant of the read-only account - keep it to SELECT on the anonymized schema.