31.08.2026・TechStuff
31.08.2026・TechStuff

Shopware 6 Hidden Gems #15: ChangeSet — before/after values for every write, no second query

Fabian Blechschmidt

The requirement sounds harmless every time: „Log when a product price changes, with old and new value.“ Or: „When an order’s email changes, notify the old address.“ Or the audit-trail classic: „Who changed what, from what, to what?“

And every time, the naive implementation is the same sad shape: subscribe to product.written, load the entity again, compare against… wait, against what? The write already happened. So you start caching „before“ states in a pre-write subscriber, keyed by ID, praying the request doesn’t write twice. I have seen this pattern — including in my own git history — more often than I’d like to admit.

The DAL solves this properly. It’s called ChangeSet, the core uses it for its own bookkeeping, and the only place the docs mention it is a footnote in an order-tutorial. It deserves better.

The two-step pattern

The DAL doesn’t compute before/after diffs by default — that would cost a SELECT per write. You have to order the change set before the write executes, and collect it after. Two subscribers, same class:

class PriceAuditSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            PreWriteValidationEvent::class => 'requestChangeSets',
            'product.written' => 'logPriceChanges',
        ];
    }

    public function requestChangeSets(PreWriteValidationEvent $event): void
    {
        foreach ($event->getCommands() as $command) {
            if (!$command instanceof ChangeSetAware) {
                continue;
            }
            if ($command->getEntityName() !== ProductDefinition::ENTITY_NAME) {
                continue;
            }

            $command->requestChangeSet();   // 👈 step 1: order the diff
        }
    }

    public function logPriceChanges(EntityWrittenEvent $event): void
    {
        foreach ($event->getWriteResults() as $result) {
            $changeSet = $result->getChangeSet();  // 👈 step 2: collect it
            if ($changeSet === null || !$changeSet->hasChanged('price')) {
                continue;
            }

            $this->auditLog->record(
                productId: $result->getPrimaryKey(),
                before: $changeSet->getBefore('price'),
                after: $changeSet->getAfter('price'),
            );
        }
    }
}

Because the change set was requested, the DAL fetches the current state inside the write transaction and attaches the diff to the write result. No race window, no self-managed cache, no second query in your code.

The API

ChangeSet (Framework/DataAbstractionLayer/Write/Command/ChangeSet.php) is small and pleasant:

Two sharp edges, both learned the annoying way. First, property names are storage namescustomer_id, not customerId. The change set lives at write-command level, below the property-name mapping. Second, the changed-detection compares values as strings (ChangeSet.php:27-36) — good enough for scalars, but know it’s there.

Deletes get diffs too

UpdateCommand and DeleteCommand implement ChangeSetAware — and change sets on deletes are quietly brilliant: they hand you the row that no longer exists.

The core demonstrates it in ProductReviewSubscriber (Checkout/Customer/Subscriber/ProductReviewSubscriber.php:36-61): when a review is deleted, the „written“ payload contains only the ID — useless for updating the customer’s review count, because whose review was it? So the subscriber requests a change set in EntityDeleteEvent, and after the delete reads $changeset->getBefore('customer_id') from beyond the grave. No pre-delete SELECT, no orphaned bookkeeping.

If you’ve ever needed „notify the customer whose address was just deleted“ — this is the mechanism.

When (not) to use it

The reason change sets are opt-in per command is the reason to stay targeted: each request adds a state-fetch inside the write transaction. Requesting change sets for every command of every entity is a self-inflicted performance problem. The pattern in every core usage is the same: filter by entity (and ideally by affected fields) in the pre-event, request only what you’ll read.

For actual full audit logging across many entities, look at the EntityWriteResult::getPayload() first — it contains what was written. Change sets are for when you need the before, and that’s precisely what nothing else gives you.

TL;DR

Request a diff before the write, read it after: in PreWriteValidationEvent (or EntityDeleteEvent), call $command->requestChangeSet() on ChangeSetAware commands (updates and deletes); in the written/deleted event, $result->getChangeSet() yields getBefore(), getAfter(), hasChanged() — computed inside the write transaction, field names in snake_case. The clean solution to every „log the old value“ ticket, and the only way to know what a deleted row contained. Docs: one footnote.

Next up: CloneBehavior — duplicating entities with field overrides, and what the admin’s „duplicate“ button knows that your code doesn’t.

What’s in your audit-log backlog? Drop me an email!


Found in Shopware 6.7.0.0: Framework/DataAbstractionLayer/Write/Command/ChangeSet.php (string-compare diff at lines 27-36), ChangeSetAware on UpdateCommand.php:12 and DeleteCommand.php:14, core patterns in Checkout/Customer/Subscriber/ProductReviewSubscriber.php:36-61 and Content/Rule/DataAbstractionLayer/RuleAreaUpdater.php:52-77.