Reconciler Products, Variants and Media

Media path regeneration on 6.6 upgrade can break file references

You upgraded to Shopware 6.6, the migration ran clean, and a chunk of your product images now 404 on the storefront. Nothing in storage moved. The files are still sitting exactly where they always were. What changed is how Shopware decides the URL to point at them, and for any media whose file name and stored object drifted apart before the upgrade, that new decision points at the wrong key. Here is why the rewrite breaks these references and a small script that finds the mismatches and repairs only the ones it can prove are safe.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

Prior to 6.6, Shopware computed media URLs at runtime from a pluggable UrlGeneratorInterface strategy, so the value stored on a row never had to be exactly right. From 6.6.0 onward Shopware persists a literal path string on the media and media_thumbnail rows and resolves URLs directly from that column, per the Media path rewrite ADR. The 6.6 upgrade migration and indexer regenerate and overwrite every media path and thumbnail path from the entity's current fileName and fileExtension. If a file was renamed or replaced earlier without the physical object in storage being renamed to match, the regenerated path no longer matches the real object key, and the image starts 404ing. Shopware confirmed this in issue shopware/shopware#4540, fixed only partially, skip if already set, in 6.6.6.0, 6.5.8.14, and 6.5.8.15. Run a small Python or Node.js script that enumerates media, recomputes the expected path, probes both paths over HTTP, and only repairs the confirmed case where the stored path 404s and the expected path resolves. Full code, tests, and sources are below.

The problem in plain words

Before 6.6, a media row's path was really just a hint. When the storefront or the admin needed a URL, Shopware asked a UrlGeneratorInterface strategy to build one on the spot from the entity's current file name, extension, and folder. If the stored path column was slightly stale, nobody noticed, because nothing ever read it directly.

6.6 changed the contract. The Media path rewrite ADR moved that computed value into a real, persisted path column on media and media_thumbnail, and URL resolution now reads that column as-is instead of recomputing it. That is a reasonable performance and predictability win on its own. The problem is what the upgrade did to get every existing row into that new column: the migration and the media indexer looked at each entity's current fileName and fileExtension and wrote a fresh path from them, overwriting whatever was there before.

That regeneration assumes the file name on the entity and the object key in storage still agree. Most of the time they do. But if a file was renamed in the admin, or a media replacement swapped in a new file, and the physical object in your storage adapter was never renamed or moved to match, the entity's file name and the real object key had already quietly drifted apart. Runtime URL generation on pre-6.6 versions papered over that drift every time it built a fresh URL. The 6.6 migration instead froze the wrong answer into the database, and now every request for that image resolves straight to a path nothing lives at.

File renamed or replaced storage object not renamed Before 6.6: runtime URL computed fresh each request drift stays invisible 6.6 upgrade migration writes literal path column from current fileName wrong path frozen in Path column points at wrong object key 404 storefront
Runtime URL generation hid the drift for years. The 6.6 migration froze that same stale file name into a persisted path column, so the drift became a permanent 404 instead of a self-correcting lookup.

Why it happens

This is a one-time regeneration that assumes the entity and the physical object still agree, run against a media library where that assumption quietly stopped being true for a subset of rows. A few common ways stores end up here:

Shopware confirmed the exact mechanism in its own issue tracker. This is a genuine, acknowledged upgrade risk, not a misconfiguration on your side. See the citations at the end for the exact issues and the ADR that explains the design change.

The key insight

Shopware's own partial fix, shipped in 6.6.6.0, 6.5.8.14, and 6.5.8.15, changed the migration to skip regenerating a path when one is already set. That stops the bleeding on every future run, but it does not go back and repair a path that a prior, unpatched run already overwrote. There is also no Admin API endpoint that reverses this for you. The only reliable signal for what actually happened is to probe storage directly: if the stored path 404s but the path you would recompute from PathnameStrategyInterface segments resolves with a 200, the object still lives under its old key and the record can be safely pointed back at it. If neither path resolves, the file is genuinely gone, and no amount of path rewriting brings it back.

The fix, as a flow

We do not rewrite paths on a guess. The script enumerates media with their thumbnails, recomputes the expected path from the current pathname strategy, and probes both the stored path and the expected path over HTTP. Only when the stored path 404s and the expected path resolves does it repair anything, and it only ever repairs that one row.

Enumerate media with thumbnails, paged Recompute expected path PathnameStrategyInterface HEAD both paths stored vs expected Stored 404, expected 200? yes DRY_RUN guarded PATCH then media.indexer no, both 404 or stored 200
The script only patches a path once the stored path is confirmed broken and the expected path is confirmed to resolve. Everything else is flagged for review or left alone.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search, the HEAD probes, the patch, and the indexer call.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Enumerate media with their thumbnails

Search media with the thumbnails association included, sorted by updatedAt ascending, and pull id, path, fileName, fileExtension, mediaFolderId, updatedAt, and the nested thumbnails. Page with page and limit: 250, and use total-count-mode: 1 so you know when to stop.

step3.py
def find_media(token, page=1, limit=250):
    body = {
        "page": page,
        "limit": limit,
        "filter": [],
        "associations": {"thumbnails": {}},
        "sort": [{"field": "updatedAt", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/media", token, body)
step3.js
async function findMedia(token, page = 1, limit = 250) {
  const body = {
    page,
    limit,
    filter: [],
    associations: { thumbnails: {} },
    sort: [{ field: "updatedAt", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/media", token, body);
}
4

Recompute the expected path and probe both URLs

Rebuild the path Shopware's PathnameStrategyInterface would generate today from the entity's current fileName, fileExtension, and folder segments. Then send a plain HEAD request against the configured filesystem adapter's base URL for both the stored path and the recomputed one. Neither request downloads the file, so this stays cheap even across a large media library.

step4.py
def probe_status(base_url, relative_path):
    url = f"{base_url.rstrip('/')}/{relative_path.lstrip('/')}"
    try:
        r = requests.head(url, timeout=15, allow_redirects=True)
        return r.status_code
    except requests.RequestException:
        return 0  # treated as unreachable, same as a hard failure
step4.js
async function probeStatus(baseUrl, relativePath) {
  const url = `${baseUrl.replace(/\/$/, "")}/${relativePath.replace(/^\//, "")}`;
  try {
    const res = await fetch(url, { method: "HEAD", redirect: "follow" });
    return res.status;
  } catch {
    return 0; // treated as unreachable, same as a hard failure
  }
}
5

Decide, with one pure function

classifyMediaPathDrift takes the media row, a probe object with the two status codes already fetched, and the recomputed expected path, and returns one of three plain outcomes. It has no I/O, so the network calls happen before it and the decision itself is trivially testable against a matrix of inputs.

decide.py
def classify_media_path_drift(media, probe, expected_path):
    if probe.get("storedPathStatus") == 200:
        return "OK"
    if (
        probe.get("storedPathStatus") == 404
        and probe.get("expectedPathStatus") == 200
        and expected_path != media.get("path")
    ):
        return "NEEDS_REPAIR"
    if probe.get("storedPathStatus") == 404 and probe.get("expectedPathStatus") == 404:
        return "MANUAL_REVIEW"
    return "MANUAL_REVIEW"
decide.js
export function classifyMediaPathDrift(media, probe, expectedPath) {
  if (probe.storedPathStatus === 200) return "OK";
  if (
    probe.storedPathStatus === 404 &&
    probe.expectedPathStatus === 200 &&
    expectedPath !== media.path
  ) {
    return "NEEDS_REPAIR";
  }
  if (probe.storedPathStatus === 404 && probe.expectedPathStatus === 404) {
    return "MANUAL_REVIEW";
  }
  return "MANUAL_REVIEW";
}
6

Report by default, repair only the proven case, guarded by dry run

For every mismatch, emit a record with mediaId, currentPath, expectedPath, storedPathHttpStatus, and expectedPathHttpStatus. That report is the safe default outcome for this defect. Only where classifyMediaPathDrift returns NEEDS_REPAIR, and only when DRY_RUN is off, run PATCH /api/media/{id} setting path to the expected path, run the equivalent PATCH /api/media-thumbnail/{id} for each affected thumbnail, then call POST /api/_action/index with only media.indexer so the change is picked up. Never touch a record classified MANUAL_REVIEW, since neither path resolving means the underlying file is genuinely missing from storage, and no path rewrite fixes that.

Run it safe

Always start with DRY_RUN=true and read the full mismatch report before writing anything. This script never guesses; it only repairs the one combination it can prove is a safe rollback, stored path 404s and expected path resolves, and it only reindexes media.indexer, never the full index.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs every mismatch it finds, respects the dry run flag, and is safe to run again and again because it only writes when the drift classification proves the object still exists under its old key.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

reconcile_media_path_drift.py
"""Find Shopware 6.6 media paths broken by the upgrade migration and repair them safely.

From 6.6.0 Shopware persists a literal path on media and media_thumbnail rows and
resolves URLs directly from that column, instead of computing them at runtime as
older versions did. The upgrade migration and indexer regenerate every path from the
entity's current fileName and fileExtension. If a file was renamed or replaced without
the physical object in storage being renamed to match, the regenerated path no longer
matches the real object key and the image 404s. This script enumerates media, recomputes
the expected path, probes both paths over HTTP, reports every mismatch, and only repairs
the confirmed case where the stored path 404s and the expected path resolves. Run on a
schedule or once after upgrading. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_media_path_drift")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
STORAGE_BASE_URL = os.environ.get("STORAGE_BASE_URL", SHOPWARE_URL)
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def find_media(token, page=1, limit=250):
    body = {
        "page": page,
        "limit": limit,
        "filter": [],
        "associations": {"thumbnails": {}},
        "sort": [{"field": "updatedAt", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/media", token, body)


def probe_status(base_url, relative_path):
    url = f"{base_url.rstrip('/')}/{relative_path.lstrip('/')}"
    try:
        r = requests.head(url, timeout=15, allow_redirects=True)
        return r.status_code
    except requests.RequestException:
        return 0


def compute_expected_path(media):
    """Rebuild the path Shopware's PathnameStrategyInterface would generate today
    from the entity's current fileName, fileExtension, and folder segments."""
    folder_segment = media.get("mediaFolderId") or "media"
    file_name = media.get("fileName") or media["id"]
    extension = media.get("fileExtension")
    base = f"media/{folder_segment}/{file_name}"
    return f"{base}.{extension}" if extension else base


def classify_media_path_drift(media, probe, expected_path):
    if probe.get("storedPathStatus") == 200:
        return "OK"
    if (
        probe.get("storedPathStatus") == 404
        and probe.get("expectedPathStatus") == 200
        and expected_path != media.get("path")
    ):
        return "NEEDS_REPAIR"
    if probe.get("storedPathStatus") == 404 and probe.get("expectedPathStatus") == 404:
        return "MANUAL_REVIEW"
    return "MANUAL_REVIEW"


def repair_media_path(token, media_id, expected_path):
    api("PATCH", f"/api/media/{media_id}", token, {"path": expected_path})


def reindex_media(token):
    api("POST", "/api/_action/index", token, {"skip": [], "only": ["media.indexer"]})


def run():
    token = get_token()
    mismatches = []
    repaired = 0
    page = 1

    while True:
        data = find_media(token, page=page)
        rows = data.get("data", [])
        if not rows:
            break

        for media in rows:
            stored_path = media.get("path")
            if not stored_path:
                continue

            expected_path = compute_expected_path(media)
            stored_status = probe_status(STORAGE_BASE_URL, stored_path)
            expected_status = probe_status(STORAGE_BASE_URL, expected_path)
            probe = {"storedPathStatus": stored_status, "expectedPathStatus": expected_status}

            decision = classify_media_path_drift(media, probe, expected_path)
            if decision == "OK":
                continue

            record = {
                "mediaId": media["id"],
                "currentPath": stored_path,
                "expectedPath": expected_path,
                "storedPathHttpStatus": stored_status,
                "expectedPathHttpStatus": expected_status,
                "classification": decision,
            }
            mismatches.append(record)
            log.warning("Media %s classified %s: %s", media["id"], decision, record)

            if decision == "NEEDS_REPAIR":
                log.warning(
                    "Media %s stored path 404s, expected path resolves. %s",
                    media["id"], "would repair" if DRY_RUN else "repairing",
                )
                if not DRY_RUN:
                    repair_media_path(token, media["id"], expected_path)
                    for thumb in media.get("thumbnails") or []:
                        api("PATCH", f"/api/media-thumbnail/{thumb['id']}", token, {"path": expected_path})
                repaired += 1

        if page * 250 >= data.get("total", 0):
            break
        page += 1

    if repaired and not DRY_RUN:
        reindex_media(token)

    log.info(
        "Done. %d mismatch(es) found, %d %s.",
        len(mismatches), repaired, "to repair" if DRY_RUN else "repaired",
    )
    return mismatches


if __name__ == "__main__":
    run()
reconcile-media-path-drift.js
/**
 * Find Shopware 6.6 media paths broken by the upgrade migration and repair them safely.
 *
 * From 6.6.0 Shopware persists a literal path on media and media_thumbnail rows and
 * resolves URLs directly from that column, instead of computing them at runtime as
 * older versions did. The upgrade migration and indexer regenerate every path from the
 * entity's current fileName and fileExtension. If a file was renamed or replaced without
 * the physical object in storage being renamed to match, the regenerated path no longer
 * matches the real object key and the image 404s. This script enumerates media, recomputes
 * the expected path, probes both paths over HTTP, reports every mismatch, and only repairs
 * the confirmed case where the stored path 404s and the expected path resolves. Run on a
 * schedule or once after upgrading. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/media-path-regeneration-6-6-breaks-references/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const STORAGE_BASE_URL = process.env.STORAGE_BASE_URL || SHOPWARE_URL;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function computeExpectedPath(media) {
  const folderSegment = media.mediaFolderId || "media";
  const fileName = media.fileName || media.id;
  const extension = media.fileExtension;
  const base = `media/${folderSegment}/${fileName}`;
  return extension ? `${base}.${extension}` : base;
}

export function classifyMediaPathDrift(media, probe, expectedPath) {
  if (probe.storedPathStatus === 200) return "OK";
  if (
    probe.storedPathStatus === 404 &&
    probe.expectedPathStatus === 200 &&
    expectedPath !== media.path
  ) {
    return "NEEDS_REPAIR";
  }
  if (probe.storedPathStatus === 404 && probe.expectedPathStatus === 404) {
    return "MANUAL_REVIEW";
  }
  return "MANUAL_REVIEW";
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function findMedia(token, page = 1, limit = 250) {
  const body = {
    page,
    limit,
    filter: [],
    associations: { thumbnails: {} },
    sort: [{ field: "updatedAt", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/media", token, body);
}

async function probeStatus(baseUrl, relativePath) {
  const url = `${baseUrl.replace(/\/$/, "")}/${relativePath.replace(/^\//, "")}`;
  try {
    const res = await fetch(url, { method: "HEAD", redirect: "follow" });
    return res.status;
  } catch {
    return 0;
  }
}

async function repairMediaPath(token, mediaId, expectedPath) {
  await api("PATCH", `/api/media/${mediaId}`, token, { path: expectedPath });
}

async function reindexMedia(token) {
  await api("POST", "/api/_action/index", token, { skip: [], only: ["media.indexer"] });
}

export async function run() {
  const token = await getToken();
  const mismatches = [];
  let repaired = 0;
  let page = 1;

  while (true) {
    const data = await findMedia(token, page);
    const rows = data.data || [];
    if (rows.length === 0) break;

    for (const media of rows) {
      const storedPath = media.path;
      if (!storedPath) continue;

      const expectedPath = computeExpectedPath(media);
      const storedStatus = await probeStatus(STORAGE_BASE_URL, storedPath);
      const expectedStatus = await probeStatus(STORAGE_BASE_URL, expectedPath);
      const probe = { storedPathStatus: storedStatus, expectedPathStatus: expectedStatus };

      const decision = classifyMediaPathDrift(media, probe, expectedPath);
      if (decision === "OK") continue;

      const record = {
        mediaId: media.id,
        currentPath: storedPath,
        expectedPath,
        storedPathHttpStatus: storedStatus,
        expectedPathHttpStatus: expectedStatus,
        classification: decision,
      };
      mismatches.push(record);
      console.warn(`Media ${media.id} classified ${decision}:`, record);

      if (decision === "NEEDS_REPAIR") {
        console.warn(
          `Media ${media.id} stored path 404s, expected path resolves. ${DRY_RUN ? "would repair" : "repairing"}`
        );
        if (!DRY_RUN) {
          await repairMediaPath(token, media.id, expectedPath);
          for (const thumb of media.thumbnails || []) {
            await api("PATCH", `/api/media-thumbnail/${thumb.id}`, token, { path: expectedPath });
          }
        }
        repaired++;
      }
    }

    if (page * 250 >= (data.total || 0)) break;
    page++;
  }

  if (repaired && !DRY_RUN) await reindexMedia(token);

  console.log(
    `Done. ${mismatches.length} mismatch(es) found, ${repaired} ${DRY_RUN ? "to repair" : "repaired"}.`
  );
  return mismatches;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether a media row gets patched. Because classifyMediaPathDrift is pure, with both status codes precomputed and passed in, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.

test_media_path_drift.py
from reconcile_media_path_drift import classify_media_path_drift


def media(**over):
    base = {
        "id": "media-1",
        "path": "media/product/summer-shirt.jpg",
        "fileName": "summer-shirt",
        "fileExtension": "jpg",
    }
    base.update(over)
    return base


def test_ok_when_stored_path_resolves():
    probe = {"storedPathStatus": 200, "expectedPathStatus": 200}
    result = classify_media_path_drift(media(), probe, "media/product/summer-shirt.jpg")
    assert result == "OK"


def test_needs_repair_when_stored_404_and_expected_200_and_paths_differ():
    probe = {"storedPathStatus": 404, "expectedPathStatus": 200}
    result = classify_media_path_drift(media(), probe, "media/product/summer-shirt-v2.jpg")
    assert result == "NEEDS_REPAIR"


def test_manual_review_when_both_404():
    probe = {"storedPathStatus": 404, "expectedPathStatus": 404}
    result = classify_media_path_drift(media(), probe, "media/product/summer-shirt-v2.jpg")
    assert result == "MANUAL_REVIEW"


def test_manual_review_when_stored_404_and_expected_path_matches_current():
    # Recomputed path is identical to what is already stored, so a patch would change nothing.
    probe = {"storedPathStatus": 404, "expectedPathStatus": 200}
    result = classify_media_path_drift(media(), probe, "media/product/summer-shirt.jpg")
    assert result == "MANUAL_REVIEW"


def test_ok_wins_even_if_expected_path_also_404s():
    probe = {"storedPathStatus": 200, "expectedPathStatus": 404}
    result = classify_media_path_drift(media(), probe, "media/product/summer-shirt-v2.jpg")
    assert result == "OK"
media-path-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyMediaPathDrift } from "./reconcile-media-path-drift.js";

const media = (over = {}) => ({
  id: "media-1",
  path: "media/product/summer-shirt.jpg",
  fileName: "summer-shirt",
  fileExtension: "jpg",
  ...over,
});

test("OK when stored path resolves", () => {
  const probe = { storedPathStatus: 200, expectedPathStatus: 200 };
  const result = classifyMediaPathDrift(media(), probe, "media/product/summer-shirt.jpg");
  assert.equal(result, "OK");
});

test("NEEDS_REPAIR when stored 404s, expected 200s, and paths differ", () => {
  const probe = { storedPathStatus: 404, expectedPathStatus: 200 };
  const result = classifyMediaPathDrift(media(), probe, "media/product/summer-shirt-v2.jpg");
  assert.equal(result, "NEEDS_REPAIR");
});

test("MANUAL_REVIEW when both 404", () => {
  const probe = { storedPathStatus: 404, expectedPathStatus: 404 };
  const result = classifyMediaPathDrift(media(), probe, "media/product/summer-shirt-v2.jpg");
  assert.equal(result, "MANUAL_REVIEW");
});

test("MANUAL_REVIEW when stored 404s and expected path matches current path", () => {
  const probe = { storedPathStatus: 404, expectedPathStatus: 200 };
  const result = classifyMediaPathDrift(media(), probe, "media/product/summer-shirt.jpg");
  assert.equal(result, "MANUAL_REVIEW");
});

test("OK wins even if expected path also 404s", () => {
  const probe = { storedPathStatus: 200, expectedPathStatus: 404 };
  const result = classifyMediaPathDrift(media(), probe, "media/product/summer-shirt-v2.jpg");
  assert.equal(result, "OK");
});

Case studies

Bulk rename before upgrade

A catalog cleanup renamed files months before the 6.6 upgrade landed

A homeware retailer ran a bulk file-naming cleanup in the admin to make product images more readable, renaming a few thousand media entities without touching the objects already sitting in their S3 bucket. On the pre-6.6 versions this was invisible, since the runtime URL generator quietly resolved to the object that was actually there.

The 6.6 upgrade migration rewrote every path from the new, cleaned-up file names, and about eleven percent of product images across two categories started 404ing the same day. Running the script in dry run found the exact set where the stored path 404s and the pre-rename expected path resolves, and the guarded repair fixed every one of them without touching a single row where the file was genuinely gone.

Media replacement without a storage move

A seasonal image swap left the old object in place under a new expectation

A fashion brand replaced a hero product image ahead of a seasonal campaign using the admin's replace-file action, which updated the media entity's file name but left the previous object sitting untouched in the CDN-backed adapter under its original key.

After the 6.6 upgrade, the freshly regenerated path pointed at a key that never existed, breaking the storefront thumbnail for that product line right as the campaign launched. The team ran the script, saw a small, exact mismatch report instead of guessing at scale, confirmed each one recomputed correctly, and let the guarded repair and media.indexer reindex finish the job.

What good looks like

After this runs, a broken image on the storefront is no longer a mystery you chase file by file. The script tells you exactly which media rows drifted, proves which ones can be safely rolled back to a path that still resolves, and leaves alone anything it cannot prove, so a genuinely deleted file never gets papered over with a guess. Pair it with keeping your Shopware install on 6.6.6.0 or later, or 6.5.8.14 and 6.5.8.15, so the migration itself stops overwriting paths that are already set.

FAQ

Why did my Shopware media images start 404ing right after the 6.6 upgrade?

Before 6.6, Shopware computed media URLs at runtime from a pluggable UrlGeneratorInterface strategy, so a mismatch between a file's name and the actual object in storage was invisible. From 6.6.0 onward Shopware persists a literal path string on the media and media_thumbnail rows and resolves URLs straight from that column. The upgrade migration and indexer regenerate every path from the entity's current fileName and fileExtension. If a file was renamed or replaced earlier without renaming the physical object in storage to match, the freshly regenerated path no longer points at the real object key, and the image starts returning 404.

Is it safe to automatically fix a Shopware media path that regeneration broke?

Only in the narrow case where the stored path 404s and the path you would recompute from PathnameStrategyInterface segments resolves with a 200. That combination means the object in storage still exists under its old key, and pointing the record back at it is a safe rollback. When neither path resolves, the underlying file is genuinely gone from storage, and no path rewrite fixes that, so the safe move is to flag it for a human rather than write anything.

Did Shopware ever fix the 6.6 media path regeneration bug?

Shopware confirmed the report in GitHub issue shopware/shopware#4540 and shipped a partial fix in 6.6.6.0, 6.5.8.14, and 6.5.8.15 that skips regenerating a path when one is already set. That stops the migration from re-breaking a path on every run, but it does not retroactively repair references that were already overwritten before you upgraded to a patched version, which is what the detection and repair script above is for.

Related field notes

Citations

On the problem:

  1. Shopware GitHub Issues: Update to 6.6 regenerates all Media Paths, potentially leading to wrong paths. github.com/shopware/shopware/issues/4540
  2. Shopware GitHub Issues: 6.6 Media path are not updated when replacing media. github.com/shopware/platform/issues/4058
  3. FriendsOfShopware GitHub Issues: Broken Images in Shopware after Update. github.com/FriendsOfShopware/FroshPlatformBunnycdnMediaStorage/issues/55

On the solution:

  1. Shopware Documentation: Media path rewrite ADR. developer.shopware.com adr 2023-08-17-media-path
  2. Shopware Documentation: Working with Media and Thumbnails. developer.shopware.com use-media-thumbnails
  3. Shopware Documentation: Commands Reference, dal:refresh:index. developer.shopware.com commands-reference

Stuck on a tricky one?

If you have a problem in Shopware media, migrations, or data integrity that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clear up a broken image?

If this saved you a confusing support ticket or a broken product image after an upgrade, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes