11.09.2026・TechStuff
11.09.2026・TechStuff

Shopware 6 Hidden Gems #18: media:delete-unused — reclaim gigabytes, with an audit trail

Fabian Blechschmidt

Every shop that has lived a few years carries dead weight in its media folders: PIM re-imports that duplicated every product image, banners for campaigns from 2023, that 40 MB „final_v2_REALLY_final.psd“ someone uploaded to a product folder. Storage is cheap until backups, migrations, and S3 bills say otherwise.

media:delete-unused exists for this, and the command name shows up in a docs list. What the docs don’t tell you is everything that makes it actually usable in production: the audit report, the grace period, the folder targeting — and the event that decides what „unused“ means, which is the difference between a cleanup and a disaster.

The safety ladder

The command has three modes, and the smart workflow climbs them in order (Content/Media/Commands/DeleteNotUsedMediaCommand.php:43-72):

1. --report — CSV to stdout:

bin/console media:delete-unused --report > unused-media.csv

Columns: filename, title, upload date, human-readable file size. Send it to the shop owner with „please skim this before Friday“. That file is your audit trail — six months later, when someone asks where an image went, you have an answer instead of a shrug.

2. --dry-run — interactive preview: pages through the doomed files in the console, 50 at a time, no writes.

3. The real run — asks for confirmation, then deletes in batches: database records and physical files.

Nice touch: --report and --dry-run together are rejected with a clear error (line 58) instead of one silently winning. Small thing, good sign.

The options that make it production-grade

--grace-period-days (default 20): anything uploaded in the last N days is spared, used or not. This quietly defuses the classic false positive — an image uploaded today for a CMS page that goes live next week is technically „unused“ right now. Twenty days of protection means fresh uploads survive your cleanup cron. Tighten or loosen per shop.

--folder-entity: restrict the whole operation to the default-location folders of one entity type:

bin/console media:delete-unused --folder-entity=product --report

Clean product images without touching CMS assets, or vice versa. Combined with --limit and --offset you can chew through a huge library in controlled slices.

What does „unused“ even mean?

The interesting engineering question, and the reason to read UnusedMediaPurger before trusting any cleanup: the purger walks all entity definitions and checks media associations (one-to-one, one-to-many, many-to-many) to find media rows nothing references.

Which immediately raises the scary case: what about media referenced in ways the DAL can’t see? Media IDs inside a CMS text block’s HTML, in a plugin’s JSON config blob, in a theme setting?

That’s what UnusedMediaSearchEvent is for (Content/Media/Event/UnusedMediaSearchEvent.php) — dispatched with every candidate batch before deletion, and any subscriber can veto:

class ProtectSliderMediaSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [UnusedMediaSearchEvent::class => 'protectOurMedia'];
    }

    public function protectOurMedia(UnusedMediaSearchEvent $event): void
    {
        $usedIds = $this->ourConfigRepository->findReferencedMediaIds($event->getUnusedIds());
        $event->markAsUsed($usedIds);   // 👈 removed from the deletion list
    }
}

The core itself uses this hook to protect media referenced from CMS content. If your plugin stores media IDs anywhere non-associative, you owe it one of these subscribers. That sentence is arguably the most important undocumented fact in this whole post: the cleanup is only as safe as the ecosystem’s participation in this event.

The routine

What we run on grown shops:

# monthly, human in the loop:
bin/console media:delete-unused --report > /backup/media-cleanup-$(date +%F).csv
# skim, then:
bin/console media:delete-unused --grace-period-days=30

First time on a years-old shop, expect gigabytes. Also budget a thumbnail regeneration check afterwards, and remember part 2 of this series if the deletion triggers indexing you’d rather queue.

TL;DR

media:delete-unused is production-ready once you know the undocumented parts: --report writes a CSV audit trail, --dry-run previews interactively, --grace-period-days (default 20) protects recent uploads, --folder-entity scopes to one entity’s folders. „Unused“ is determined via DAL associations plus the UnusedMediaSearchEvent veto — subscribe to it if your plugin references media outside of associations, or your media will be collateral damage in someone’s cleanup.

Next up: framework:schema — generating an OpenAPI spec of your shop’s actual API, custom entities included.

How many gigabytes did your first run free up? Drop me an email with the number!


Found in Shopware 6.7.0.0: options and modes in Content/Media/Commands/DeleteNotUsedMediaCommand.php:43-72, CSV report at lines 145-169, association-walking and event dispatch in Content/Media/UnusedMediaPurger.php:54-96, veto API in Content/Media/Event/UnusedMediaSearchEvent.php:23-26.