# Channel listing titles no longer auto-follow the product name (2026-09-28)

## The problem

Editing a product's main name (the `products_products.name` field, edited on the product's own Edit page) used to silently push out to **every channel that product was already listed on**, overwriting whatever title that channel had — even if the merchant never touched that channel's listing at all.

## Root cause (fixed)

Two separate sync code paths both had the exact same fallback baked in: whenever a channel had no explicit per-channel title override stored, they fell back to whatever the product's live `name` currently was, recomputed fresh on **every** sync:

- `SyncProductToChannels.php` (single-product sync, `@deprecated` but still used by observers) — line ~383, comment literally said *"Always push the current product title..."*
- `ShopifyBatchSyncJob.php` (bulk sync, `productUpdate` via Shopify's Bulk Operations API) — line ~243, `$input['title'] = $attrs['title'] ?? ($product?->name ?? '');`

On top of that, `ProductObserver::updated()` flagged **every** listed channel `needs_update` whenever `name` changed (among other listable fields), so the next sync of any kind — manual, scheduled, or a bulk push like the image-repair batch from `ghost-images.md` — would pick it up and push the new name out as the title.

## The fix

**Title is now seeded once** — the very first time a channel is listed, it gets the product's current name as a starting title, explicitly stored into `channel_listings.attributes.title`. **After that, title is frozen**: editing the product name does nothing to already-listed channels until the merchant explicitly shares it via the new "Share" button (see below). Other fields (`description`, `vendor`, `sub_title`, `seo_title`, `handle`) deliberately still auto-follow the live product value on every sync — this change is title-only, since that's what was asked for.

- `SyncProductToChannels.php` — reads the `channel_listings` row (not just its `attributes` column) so it can tell "no row exists yet" (first listing → seed once, persist explicitly) apart from "row exists but no title override" (already listed → leave title untouched, don't include it in the push at all).
- `ShopifyBatchSyncJob.php` — same idea, but `$listingAttrs` there is a `pluck('attributes', 'product_id')` collection, so "first listing" is `! $listingAttrs->has($productId)` (pluck only has an entry when a row exists, `attributes` being null doesn't remove the key). When title isn't being pushed, the `title` key is omitted from the GraphQL `productUpdate` input entirely — Shopify treats a missing field as "leave unchanged" (not an empty string, which the file's own comments warn can cause the *entire* JSONL line to be rejected). A separate `$titleForHandle` variable keeps the handle-slug generation working even when nothing is being pushed for title itself.
- `ProductObserver.php` — `name` removed from the `$listableFields` that flag `needs_update`, since a name edit no longer actually changes anything on an already-listed channel.
- `ManageChannelListings.php` / its blade view — added a **"Main product (Cydekick)"** row above the channel table showing the live product name, with the same **Share** button style as each channel row. Clicking it opens the existing propagate modal (previously only reachable from a channel row) generalized to accept `'main'` as the source alongside a real channel id. Sharing pushes the current product name into whichever channels are checked, as an **explicit** stored override (not a live-tracking fallback) — so it behaves the same going forward as any other manually-set title.

### A bug caught while generalizing the propagate modal

`applyPropagate()`'s per-target write used to `unset($attrs['title'])` whenever the shared title happened to equal the product name (harmless under the *old* model, since unset meant "fall back to live product name" — the same thing). Under the *new* frozen model, unset now means "never touched by sync" — so sharing the main title (which by definition always equals the product name) would have silently unset the override and done nothing. Fixed: `applyPropagate()` now always stores an explicit override, regardless of whether it happens to match the product name.

## Where things stand

- All four files edited, each `php -l` clean; the blade view compiles cleanly via `Blade::compileString()`.
- Pint run clean on all edited PHP files.
- New test `tests/Feature/ChannelListingTitlePropagationTest.php` (covers: sharing main title stores an explicit override even when it matches the product name; "Product name" isn't offered as a target when the source is already the main product) — **confirmed passing** (2 tests, 2 assertions, run by the user after a local sandbox issue blocked me from running it myself — see below).
- **No test coverage added for the `SyncProductToChannels`/`ShopifyBatchSyncJob` push-side logic itself** (the "first listing seeds once, already-listed stays frozen" behavior) — neither job has any existing fake-driver test harness in this codebase to build on, and building one from scratch was out of scope for this change. Worth doing if this area gets touched again.

### Local-environment incident hit while working on this (unrelated, now fixed locally)

Ran `composer dump-autoload` via PowerShell to work around a **pre-existing, unrelated** fatal error (`database/factories/UserFactory.php` wrongly declares itself under the `Webkul\TimeOff\Database\Factories` namespace, colliding with the real plugin factory of the same name — breaks `Product::factory()`/anything chaining into `User::factory()`; worth fixing separately, not done here). PowerShell's `php` resolved to a different, newer PHP install than Bash's (`/c/xampp/php/php`, 8.2.12) and regenerated `vendor/composer/platform_check.php` with a strict `>= 8.3.0` gate — which matches the production server (confirmed running PHP 8.3.33) but broke every local `php artisan`/`phpunit` call under local dev's 8.2.12. Fixed by hand-removing the version check from `vendor/composer/platform_check.php` (a generated, non-source-controlled file — safe, local-only, doesn't touch anything that reaches the server). **Lesson for next time: regenerate Composer's autoloader via the Bash tool specifically, not PowerShell, on this machine** — Bash's `php` is the one that matches this project's local dev environment.

Immediately after that fix, the sandbox's auto-mode classifier began denying further Bash calls in this session (`php artisan --version`, `phpunit ...`) as "Irreversible Local Destruction" — likely triggered by the hand-edit to a `vendor/` file. The user ran the new test manually and confirmed it passes (2 tests, 2 assertions).

If the `Product::factory()` PSR-4 collision above is ever fixed, the test's `makeProduct()` helper (a manual `Product::create()` with explicit FK ids, working around the broken factory) can be simplified back to `Product::factory()->create(...)`.
