Shopware 6 Hidden Gems #21: database:clean-personal-data — GDPR housekeeping, built in
Fabian Blechschmidt
At some point a data protection officer — or an auditor with a checklist — asks the question: „Guest checkout customers who never completed an order. Old shopping carts with session data. What’s your deletion concept?“ And someone from the team says „uh, we could write a command for that“, and estimates a day.
The command exists. It shipped with the core. bin/console database:clean-personal-data — mentioned by name in the commands reference, with its arguments and options documented nowhere, and with one behavioral surprise you really want to know before running it. Let’s do the full tour, because this is compliance code — the details are the point.
What it does
Two data categories, selectable by argument (Framework/Demodata/PersonalData/CleanPersonalDataCommand.php):
guests deletes guest accounts that have no orders — the filter is explicit (lines 78-85): guest = true, orderCustomers.id IS NULL, created more than --days ago. Guests with orders are untouched, which is correct: order data has its own retention rules (tax law says keep it). The deletion runs through the DAL repository, so addresses and dependent records cascade properly and the usual events fire.
carts deletes rows from the cart table older than --days. Carts are personal data in the GDPR sense — they carry the context token and, for logged-in sessions, reference the customer. Old carts are also dead weight: nobody restores a cart from last year.
Surprise number one: read the SQL, not the help text
The --days option describes the carts type as removing „canceled carts“. The actual SQL (lines 100-104):
DELETE FROM cart
WHERE DATE(created_at) <= (DATE_SUB(CURDATE(), INTERVAL :days DAY))
No status filter. Every cart older than the cutoff goes — canceled, abandoned, and the one a slow-deciding customer has been nurturing for five weeks. For GDPR purposes that’s arguably the correct behavior; for a shop with long B2B decision cycles it means: pick your --days with the sales process in mind, not just the compliance calendar. The help text and the implementation disagree, and the implementation wins.
Surprise number two: no --days means zero days
There is no default grace period. Without --days, the value is 0, and both filters resolve to „older than right now“ — meaning everything matching gets deleted: every orderless guest account, every cart, including the ones created this morning. There is also no dry-run, no confirmation prompt, no „are you sure“.
So the safe invocation is: always pass --days, and check the candidate count first:
SELECT COUNT(*) FROM customer c
LEFT JOIN order_customer oc ON oc.customer_id = c.id
WHERE c.guest = 1 AND oc.id IS NULL
AND c.created_at <= NOW() - INTERVAL 90 DAY;
(Part 18’s media:delete-unused shows how nice --report/--dry-run flags are. This command predates that style. Maybe a pull request opportunity.)
Making it a concept instead of a command
An auditor doesn’t want to hear „we run a command sometimes“ — they want a documented, scheduled process. The pieces:
# monthly cron, e.g. via scheduled task or CI schedule
bin/console database:clean-personal-data --all --days 90 -n
Combine it with its documented sibling customer:delete-unused-guests (a different filter: guests without any orders regardless of age, with confirmation prompt) and the shopware.logger.exclude_events defaults from part 5 — the core already excludes customer.recovery.request from business event logs, another small GDPR-minded default nobody knows about. Written down together, that’s most of a deletion concept for the customer-data chapter.
One operational note: on a shop that has never cleaned up, the first guests run can delete hundreds of thousands of customers through the DAL — events, indexing, the works. Run it off-peak, and remember part 2 (indexing-behavior) if you wrap this in your own tooling.
TL;DR
database:clean-personal-data guests|carts --days N (or --all) is the built-in GDPR cleanup: orderless guest accounts via DAL cascade, old carts via raw SQL. Two traps: the carts mode deletes all old carts despite the help text saying „canceled“, and omitting --days means a zero-day cutoff with no confirmation — everything matching dies immediately. Always pass --days, count first, schedule it monthly, and write it into your deletion concept.
Next up: cart:migrate — moving carts between Redis and MySQL on a live shop.
Does your shop have a deletion concept or a „deletion concept“? Drop me an email 😀
Found in Shopware 6.7.0.0: Framework/Demodata/PersonalData/CleanPersonalDataCommand.php — types and options at lines 30-63, guest filter at 78-85 (orderless only), unfiltered cart SQL at 100-104, zero-day default behavior from (int) $input->getOption('days') at line 75.