Search by

22h / doctrine-garbage-collection-bundle

22h

A Symfony bundle for automated Doctrine entity garbage collection.

Package info

gitlab.com/22h-open/doctrine-garbage-collection-bundle

Issues

Type:symfony-bundle

pkg:composer/22h/doctrine-garbage-collection-bundle

Statistics

Installs: 277

Dependents: 0

Suggesters: 0

Stars: 0

v0.4.0 2026-10-09 19:58 UTC

This package is auto-updated.

Last update: 2026-10-09 17:58:42 UTC


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 PHP DateInterval string (e.g., P1M for 1 month, P1Y for 1 year).
  • iteratingResult: If set to true, entities will be loaded and removed one by one (triggering Doctrine events). If false, a single DELETE query is executed.
  • batchSize: With iteratingResult: true, the entity manager is flushed and cleared after this many removals (default 20, at least 1).
  • chunkSize: Deletes in chunks of this many rows instead of a single DELETE, 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

ModeAttribute optionsHow it deletesUse it for
single DELETE (default)–one DQL DELETE with the conditions of the querysmall and medium tables
chunkschunkSize: 10000selects up to chunkSize identifiers, deletes them by identifier, repeatslarge tables: short statements, no long locks, joins work
iteratingiteratingResult: trueloads every entity and removes it through the entity manager, in batcheslifecycle 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 using iteratingResult: true and after each chunk when using chunkSize (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

CodeMeaning
0all tasks succeeded
1at least one task failed, the error messages are part of the output
2invalid 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.