Time My Time is a planning and time registration tool written in PHP, with Symfony as framework.
- PHP: https://www.php.net/
- Symfony: https://symfony.com/
- FrankenPHP: https://frankenphp.dev/
- API Platform: https://api-platform.com/
- Docker: https://www.docker.com/
- Vite: https://vite.dev/, https://symfony-vite.pentatrion.com/
- Stimulus: https://stimulus.hotwired.dev/
- Turbo: https://turbo.hotwired.dev/
- Projects, milestones, issues, worklogs.
- Planning overview.
- Data export through API Platform.
- Users with different permission levels.
- Audit log.
- GDPR compliance.
- Easy data anonymization handled by rules.
- Follows TDD - Test Driven Development.
- 100% test coverage.
- Minimize frontend logic and dependencies.
- Use an iterative approach.
- Use soft deletable.
- All changes should be tracked in an audit log.
- Be GDPR-compliant.
All entities have a ULID as ID, createdBy, modifiedBy, createdAt, updatedAt.
All entities should be archivable (not appearing unless "Show archived" is active).
- Title
- Avatar
- Status: Active, Inactive
- Issues
- Milestones
- Title
- Description
- Expected date of delivery
- Delivery date (when Done)
- Issues in the milestone
- Status: Not Done, Done
- Title
- Description
- Tags
- Delivery
- Attached worklogs
- Description
- Started Datetime
- Hours spent (float, .25 granularity)
Every mutation of an entity inheriting App\Entity\AbstractEntity is recorded via
damienharper/auditor-bundle. For
each audited entity, the bundle creates a sibling <table>_audit table that
captures inserts, updates, association changes, and the user who triggered them.
Soft-deletes ($em->remove() on a SoftDeletableInterface) are intercepted by
App\Doctrine\Listener\SoftDeleteListener before the auditor runs, so they appear
in the audit log as an update of deletedAt (null → timestamp), not as a
remove. The full lifecycle of a soft-deleted row stays in history.
Audited entities are discovered at container compile time by
App\DependencyInjection\AppExtension: every concrete class extending
AbstractEntity under src/Entity/ is registered with the auditor automatically.
A new domain entity is audited as soon as bin/console cache:clear runs — no
config edits required. The same mechanism picks up tests/Fixtures/Entity/ in the
test environment.
The auditor's DH\Auditor\Provider\Doctrine\Persistence\Reader\Reader service is
autowirable. To list every change to a given entity class:
$audits = $reader->createQuery(Project::class)->execute();Each entry exposes the action type (insert / update / remove / associate /
dissociate), the user id, the timestamp, and a field-level getDiffs() array.
Audit rows hold personal data (field diffs, IPs, blame display names) and must
be aged out under GDPR Art. 5(1)(e). The retention policy is configured in
config/packages/app_gdpr.yaml:
parameters:
app.gdpr.audit_retention: P1Y # ISO-8601 duration, default
app.gdpr.audit_retention_overrides:
App\Entity\FinancialTransaction: P5Y # per-entity overrides for
App\Entity\KycVerification: P5Y # legal-retention obligationsA sweep is run by:
bin/console app:gdpr:anonymize-stale --older-than=P2Y [--dry-run]This does two things in one pass:
- Entity anonymization — rows where
createdAt < now - --older-thanget their#[Anonymize]fields scrubbed andanonymizedAtset. Idempotent. - Audit retention sweep — for every registered audited class, audit rows
older than the configured retention period have all
old/newvalues nulled in theirdiffsJSON (field-key structure is preserved for "what changed when" analytics), andip/blame_user/blame_user_fqdn/blame_user_firewallare nulled.blame_id,type,object_id,transaction_hash, andcreated_atare kept (structural, opaque ULIDs are not PII on their own).
Cron-friendly: schedule the command nightly with a chosen --older-than. The
audit retention runs unconditionally on every invocation regardless of
--older-than — keep the schedule cadence shorter than the retention window.
The right-to-erasure command (app:gdpr:anonymize <subjectId>) additionally
nulls the ip column on every audit row where the subject was the actor
(blame_id = subject.id), independent of retention age.
The auditor bundle writes created_at = new \DateTimeImmutable('now') inline
(see DH\Auditor\Provider\Doctrine\Auditing\Transaction\TransactionProcessor);
it doesn't accept an injectable ClockInterface, and the surrounding classes
are final. Production is unaffected — both retention threshold and audit
created_at use real wall-clock time. Tests, however, can't make audit rows
"appear old" through MockClock alone.
Workaround: backdate audit rows manually after persist. See
tests/Integration/Gdpr/AuditRetentionSweepTest::backdateAuditRows() for the
helper pattern:
$this->conn->executeStatement(
'UPDATE <table>_audit SET created_at = :when WHERE object_id = :oid',
['when' => '2024-01-01 00:00:00', 'oid' => (string) $entity->getId()],
);A cleaner solution would require either an upstream PR adding ClockInterface
support to the bundle, or replacing the whole TransactionManagerInterface
chain in DI — both more invasive than the helper for the current testing
surface.