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.
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.
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:
- A media file was renamed through the admin at some point, and whatever process renamed it did not also rename the object in the underlying filesystem adapter, whether that is local disk, S3, or a CDN-backed store.
- A media replacement swapped in a new file for an existing media entity, updating
fileNameandfileExtensionon the row, while the old object key in storage was left in place or the new object was written under a different key than the regenerated path expects. - A migration or import tool wrote media rows directly, bypassing the normal upload flow that keeps the entity and storage object in lockstep.
- Any of the above happened long enough ago, and long enough before 6.6, that the pre-6.6 runtime URL generation had already been quietly serving the correct file by recomputing from a slightly different source of truth, so nobody had a reason to notice.
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.
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.
Build it step by step
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.
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
// 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
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.
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 {}
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) : {};
}
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.
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)
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);
}
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.
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
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
}
}
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.
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"
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";
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- Shopware GitHub Issues: Update to 6.6 regenerates all Media Paths, potentially leading to wrong paths. github.com/shopware/shopware/issues/4540
- Shopware GitHub Issues: 6.6 Media path are not updated when replacing media. github.com/shopware/platform/issues/4058
- FriendsOfShopware GitHub Issues: Broken Images in Shopware after Update. github.com/FriendsOfShopware/FroshPlatformBunnycdnMediaStorage/issues/55
On the solution:
- Shopware Documentation: Media path rewrite ADR. developer.shopware.com adr 2023-08-17-media-path
- Shopware Documentation: Working with Media and Thumbnails. developer.shopware.com use-media-thumbnails
- 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.
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