22h / doctrine-garbage-collection-bundle
A Symfony bundle for automated Doctrine entity garbage collection.
Package info
gitlab.com/22h-open/doctrine-garbage-collection-bundle
Type:symfony-bundle
pkg:composer/22h/doctrine-garbage-collection-bundle
Requires
- php: >=8.2
- doctrine/doctrine-bundle: ^2.11|^3.0
- doctrine/orm: ^2.15|^3.0
- psr/clock: ^1.0
- psr/log: ^1.1|^2.0|^3.0
- symfony/console: ^6.4|^7.0|^8.0
- symfony/event-dispatcher: ^6.4|^7.0|^8.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
Requires (Dev)
- carthage-software/mago: ^1.54
- doctrine/persistence: ^3.1|^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
- symfony/clock: ^6.4|^7.0|^8.0
- symfony/doctrine-bridge: ^6.4|^7.0|^8.0
- symfony/lock: ^6.4|^7.0|^8.0
Suggests
- symfony/clock: Mockable time for the expiry date of GarbageCollectionSimple
- symfony/lock: Prevents overlapping garbage collection runs
Provides
None
Conflicts
None
Replaces
None
README
A Symfony bundle for automated Doctrine entity garbage collection using PHP 8 attributes.
Features
- Automated Cleanup: Easily define cleanup rules directly in your services.
- Simple Configuration: Use attributes to specify which entities should be deleted based on date fields.
- Complex Logic: Define custom QueryBuilders for more advanced deletion logic.
- Iterative Deletion: Support for large datasets by iterating and flushing in batches (useful for triggering Lifecycle Events).
- Dry Run Support: Safely preview what would be deleted before executing.
Installation
Install the bundle via Composer:
composer require 22h/doctrine-garbage-collection-bundle
Usage
1. Tag your Service
To enable garbage collection for a specific entity, create a service and mark it with the SupportsGarbageCollection attribute.
use TwentyTwo\DoctrineGarbageCollection\Attribute\SupportsGarbageCollection;
use TwentyTwo\DoctrineGarbageCollection\Attribute\GarbageCollectionSimple;
use App\Entity\OldLog;
#[SupportsGarbageCollection(entityClass: OldLog::class)]
#[GarbageCollectionSimple(fieldName: 'createdAt', dateInterval: 'P1M')]
class LogGarbageCollector
{
}
2. Simple Garbage Collection
The GarbageCollectionSimple attribute allows you to delete entities where a specific field is older than a given DateInterval.
fieldName: The entity field to check (e.g.,createdAt).dateInterval: A PHPDateIntervalstring (e.g.,P1Mfor 1 month,P1Yfor 1 year).iteratingResult: If set totrue, entities will be loaded and removed one by one (triggering Doctrine events). Iffalse, a singleDELETEquery is executed.batchSize: WithiteratingResult: true, the entity manager is flushed and cleared after this many removals (default20, at least1).chunkSize: Deletes in chunks of this many rows instead of a singleDELETE, see Deletion Modes.
The expiry date is relative to the clock service of Symfony (symfony/clock), so tests can mock the time with
ClockSensitiveTrait::mockTime(). Without symfony/clock, the current time of PHP is used. The date is bound with
the Doctrine type of the field: a date column compares dates only, datetime_immutable columns get a
DateTimeImmutable.
Invalid attributes (an interval like 1M instead of P1M, a batchSize or chunkSize below 1, chunkSize together
with iteratingResult, an empty fieldName) fail the container build.
3. Complex Garbage Collection
For more advanced logic, use the GarbageCollectionComplex attribute on a method that returns a Doctrine QueryBuilder.
use TwentyTwo\DoctrineGarbageCollection\Attribute\SupportsGarbageCollection;
use TwentyTwo\DoctrineGarbageCollection\Attribute\GarbageCollectionComplex;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use App\Entity\User;
#[SupportsGarbageCollection(entityClass: User::class)]
class UserGarbageCollector
{
#[GarbageCollectionComplex(iteratingResult: true)]
public function getUsersToDelete(EntityRepository $repository): QueryBuilder
{
return $repository->createQueryBuilder('u')
->where('u.lastLogin < :date')
->andWhere('u.isVerified = false')
->setParameter('date', new \DateTime('-1 year'));
}
}
The method must be public, otherwise the container build fails.
Without iteratingResult, the query builder is turned into a single DQL DELETE. A DELETE only keeps the FROM
and WHERE parts, so query builders with setMaxResults(), setFirstResult(), joins, orderBy(), groupBy() or
having() are rejected with an error instead of deleting more rows than they select. Use iteratingResult: true for
them.
With iteratingResult: true, the query must select only the entity to delete. The entity manager is flushed and
cleared after every batch: entities of other code that are managed by the same entity manager are detached, too.
Deletion Modes
| Mode | Attribute options | How it deletes | Use it for |
|---|---|---|---|
single DELETE (default) | – | one DQL DELETE with the conditions of the query | small and medium tables |
| chunks | chunkSize: 10000 | selects up to chunkSize identifiers, deletes them by identifier, repeats | large tables: short statements, no long locks, joins work |
| iterating | iteratingResult: true | loads every entity and removes it through the entity manager, in batches | lifecycle events, cascades, listeners |
A single DELETE on millions of rows locks the table for a long time and fills the undo log. Chunks keep every
statement short:
#[SupportsGarbageCollection(entityClass: RequestLog::class)]
#[GarbageCollectionSimple(fieldName: 'createdAt', dateInterval: 'P30D', chunkSize: 10000)]
class RequestLogGarbageCollector
{
}
Chunks need an entity with a single integer or string identifier. Joins and orderBy() are allowed, setMaxResults(),
setFirstResult(), groupBy() and having() are not (the chunk size is the limit). Like a single DELETE, chunks
bypass Doctrine lifecycle events and cascades. GarbageCollectionBatchEvent is dispatched after every chunk.
Events
The bundle dispatches several events during the garbage collection process, allowing you to hook into the lifecycle. All events are located in the TwentyTwo\DoctrineGarbageCollection\Event namespace.
GarbageCollectionStartEvent: Dispatched before any tasks are executed.GarbageCollectionFinishEvent: Dispatched after all tasks have been processed.PreTaskExecutionEvent: Dispatched before each task. Call$event->skip()to skip the execution of the current task.stopPropagation()skips the task as well, but also prevents all other listeners from being called.PostTaskExecutionEvent: Dispatched after a task has been successfully executed.GarbageCollectionErrorEvent: Dispatched if an error occurs during task execution.GarbageCollectionBatchEvent: Dispatched after each batch flush when usingiteratingResult: trueand after each chunk when usingchunkSize(not in dry runs).
Example Event Subscriber
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use TwentyTwo\DoctrineGarbageCollection\Event\PreTaskExecutionEvent;
use TwentyTwo\DoctrineGarbageCollection\Event\GarbageCollectionErrorEvent;
class GarbageCollectionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
PreTaskExecutionEvent::class => 'onPreTaskExecution',
GarbageCollectionErrorEvent::class => 'onError',
];
}
public function onPreTaskExecution(PreTaskExecutionEvent $event): void
{
// Custom logic to skip task
// $event->skip();
}
public function onError(GarbageCollectionErrorEvent $event): void
{
$exception = $event->getException();
// Log the error or send notification
}
}
Command Line Interface
The bundle provides a console command to execute the garbage collection.
Dry Run (Default)
See how many entities would be deleted without actually removing them. A dry run neither flushes nor clears the entity manager:
php bin/console twenty-two:garbage-collection
Execute Deletion
Use the --force flag to actually delete the entities:
php bin/console twenty-two:garbage-collection --force
Debugging SQL
To see the generated SQL queries:
php bin/console twenty-two:garbage-collection --dump-sql
Filtering
You can filter by service or entity:
php bin/console twenty-two:garbage-collection --filter-entity=OldLog
php bin/console twenty-two:garbage-collection --filter-service=LogGarbageCollector
Overlapping Runs
With symfony/lock installed (FrameworkBundle enables framework.lock with a flock store then), --force runs
hold a lock: if a run takes longer than
the interval of its cron job, the next run is skipped with a warning and exit code 0. Dry runs never lock. Use
--no-lock to run anyway. Stores like flock and semaphore hold the lock until the run ends, expiring stores
(Redis, PDO, ...) release it after one hour at the latest.
Logging
Every task and the summary of a run are logged with PSR-3 (channel garbage_collection with MonologBundle): info
for finished tasks, error with the exception for failed ones, and warning instead of info for the summary when a
task failed. The context contains service, entity, task, hits, duration_ms and dry_run. The result of every task
contains its duration in seconds as well.
Exit Codes
| Code | Meaning |
|---|---|
0 | all tasks succeeded |
1 | at least one task failed, the error messages are part of the output |
2 | invalid options, e.g. --dump-sql together with --force |
A failing task does not stop the other tasks. Every failure dispatches a GarbageCollectionErrorEvent.
Development
composer check # Mago (lint and format check), PHPStan at level max, PHPUnit
composer cs-fix # format the code with Mago
The tests use SQLite in memory. Set DATABASE_URL to run them against a real database, e.g. a throwaway MariaDB
(needs the pdo_mysql extension):
docker run -d --rm --name gc-bundle-test-mariadb -p 127.0.0.1:33306:3306 \
-e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=gc_test mariadb:11.8
DATABASE_URL=mysql://root:root@127.0.0.1:33306/gc_test vendor/bin/phpunit
The tests drop and create their tables, never point DATABASE_URL to a database with data you need.
Use DATABASE_URL=postgresql://postgres:root@127.0.0.1:35432/gc_test with a PostgreSQL container (needs pdo_pgsql).
GitLab CI runs Mago, PHPStan, the tests with coverage, every PHP version from 8.2 to 8.5 with the oldest and the newest dependencies, and the tests against MariaDB 10.11 and 11.8 and PostgreSQL 14 and 18.
License
This bundle is released under the MIT License.