# MarketingV2Query Decomposition Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Shrink `app/Http/Queries/MarketingV2Query.php` (1,418 lines, 8 methods >100 lines, 0 interfaces, no `strict_types`) into a thin facade over five focused, interfaced query classes — **with byte-identical generated SQL** — improving the AI-Compatibility "Method Complexity" score without changing any query behavior.

**Architecture:** Facade + delegation (the pattern used for `ChapterService`/`BaseAuthService`). `MarketingV2Query` has a single consumer, `MarketingV2Controller`; it keeps its class name and all 12 public method signatures verbatim (so the controller and its 3 routes are untouched) and delegates each to one of five extracted cluster query classes. The long methods are shortened **during** extraction by pulling their sequential phases into private methods *inside the cluster class* — never by consolidating logic across methods (that would risk behavior). A leading SQL-snapshot golden-master test is the safety net, since the query logic has zero direct test coverage today.

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4 (`--parallel`), PHPStan level 5 (larastan) + `phpstan-baseline.neon`, Pint (Laravel preset). CI enforces all three.

**Spec:** this plan (self-contained); derived from `AI-COMPATIBILITY-REPORT.md` Rec 3.1 / Phase-4 roadmap (line 753, `MarketingV2Query 1,418`, +2 projected) and its explicit lesson (line 654): *split the long methods as part of the extraction, or the complexity just changes address.*

## Global Constraints

- **Behavior-preserving is the hard gate — proven by SQL, not just review.** For every representative filter input, the **generated SQL + bindings must be byte-identical** before and after every task (Task 1 captures the golden master; every later task re-asserts it). Also: the two Excel routes' outputs and `getFilterResult`'s response shape must not change.
- **Do NOT "improve" behavior-adjacent details:** do NOT unify the three date-normalization techniques (`strtotime` vs `Carbon::parse` vs raw string concat) — they may differ on edge inputs; keep each method's exact date logic. Do NOT change a method's base model (`UserProgram::` vs `User::`), its join type, its `whereExists`-vs-`join` choice, or its return shape (`int` / mapped `array` / raw `Collection<stdClass>` / `?Model` / `->first()->toArray() ?: []`). The one allowed no-op cleanup: the dead ternary at `completedD1M6` (`$operator = $excelDownload ? '<' : '<';` → `$operator = '<';`) is SQL-identical — optional, and only if the snapshot confirms identical SQL.
- **Preserve the public contract.** `MarketingV2Query` keeps its class name and all 12 method signatures **verbatim** (including the inconsistent casing like `$FromDate`/`$SignUpOnly` in `getUserPaidSubscribedSubScreenWise`/`getAllData`) — they are positional, and the controller depends on them. Casing normalization is out of scope.
- **Every new class:** `declare(strict_types=1);`, class-level PHPDoc, implements an interface (repo has 0 interfaces here — a scored Architecture win), bound in `AppServiceProvider`. New files under `app/Http/Queries/Marketing/`.
- **Shorten, don't relocate:** each moved method >100 lines must be split into private phase methods *inside its cluster class* so the method census actually drops (the score lever). Same statements, same order — the snapshot proves it.
- **Per-task quality gate (all must pass before commit):**
  - `XDEBUG_MODE=off vendor/bin/pest --parallel` → 0 failures (host xdebug segfaults an unrelated test under plain `pest`)
  - `vendor/bin/phpstan analyse --memory-limit=2G` → `[OK] No errors` (this file needs a raised memory limit)
  - `vendor/bin/pint --test <changed files>` → clean
  - **plus** the SQL-snapshot test from Task 1 stays green.
- **PHPStan baseline:** 3 pre-existing `property.notFound` entries (`User::$CompletedTotal`, `User::$Total`, `UserProgram::$cnt` — raw-query dynamic props, Larastan false-positives) will relocate from `MarketingV2Query.php` to the new cluster files as their methods move. Remove the old-path entries; relocate to the new path (relocation, never new suppression). Report any baseline edit.
- **One task = one PR.** Each task is independently shippable; CI must be green before merge.
- **Commits:** per the run's setup (new branch + per-task commits if authorized; otherwise commit checkpoints for the user).

---

## File Structure

New files:

| File | Responsibility |
|---|---|
| `tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php` | Golden-master: emitted SQL+bindings per method per filter set (the safety net) |
| `app/Http/Queries/Marketing/AcquisitionTotalsQueryInterface.php` + `AcquisitionTotalsQuery.php` | Top-of-funnel scalar counts |
| `app/Http/Queries/Marketing/SubscriptionBreakdownQueryInterface.php` + `SubscriptionBreakdownQuery.php` | Ever-subscribed / product-label chart series |
| `app/Http/Queries/Marketing/PaidSubscriberBreakdownQueryInterface.php` + `PaidSubscriberBreakdownQuery.php` | Paid subscribers by program / sub-screen |
| `app/Http/Queries/Marketing/EngagementProgressionQueryInterface.php` + `EngagementProgressionQuery.php` | Onboarding / D1M1 / D1M6 progression |
| `app/Http/Queries/Marketing/SubscriptionFunnelQueryInterface.php` + `SubscriptionFunnelQuery.php` | Single-row subscription + intro-open aggregates |

Modified files:

| File | Change |
|---|---|
| `app/Http/Queries/MarketingV2Query.php` | Gains a constructor injecting the 5 cluster interfaces; 12 methods become delegators; ends as a ~120-line facade |
| `app/Providers/AppServiceProvider.php` | Bind each new interface → implementation |
| `phpstan-baseline.neon` | Relocate the 3 `property.notFound` entries to the new cluster files as methods move |

Method → cluster map (verified line numbers in current file):

| Method (verbatim signature) | Lines | Cluster class |
|---|---|---|
| `getTotalSignups(...): int` | 31–117 (87) | `AcquisitionTotalsQuery` |
| `getTotalDownloads(...): int` | 133–166 (34) | `AcquisitionTotalsQuery` |
| `getSubscriptionType(...): array` | 183–277 (95) | `SubscriptionBreakdownQuery` |
| `getFreeSubscriptionType(...): array` | 294–414 (**121**) | `SubscriptionBreakdownQuery` |
| `getProductLabelData(...): array` | 1313–1417 (**105**) | `SubscriptionBreakdownQuery` |
| `getUserPaidSubscribedProgramWise(...): Collection` | 432–547 (**116**) | `PaidSubscriberBreakdownQuery` |
| `getUserPaidSubscribedSubScreenWise(...): Collection` | 564–671 (**108**) | `PaidSubscriberBreakdownQuery` |
| `getAllData(...): ?Model` | 688–789 (**102**) | `EngagementProgressionQuery` |
| `openedCompletedD1M1(...): array` | 807–929 (**123**) | `EngagementProgressionQuery` |
| `completedD1M6(...): array` | 947–1066 (**120**) | `EngagementProgressionQuery` |
| `getUserSubscriptionData(...): array` | 1083–1183 (**101**) | `SubscriptionFunnelQuery` |
| `getIntroOpenCount(...): array` | 1200–1296 (97) | `SubscriptionFunnelQuery` |

---

## Task 0: Establish the baseline

**Files:** none (verification only).

- [ ] **Step 1: Suite green** — Run: `XDEBUG_MODE=off vendor/bin/pest --parallel` → `0 failed`.
- [ ] **Step 2: PHPStan green** — Run: `vendor/bin/phpstan analyse --memory-limit=2G` → `[OK] No errors`.
- [ ] **Step 3: Record consumer facts** — `grep -n marketingV2Query app/Http/Controllers/MarketingV2Controller.php`. Confirm the only consumer is `MarketingV2Controller` (constructor-injected `protected MarketingV2Query $marketingV2Query`) reached by `POST marketingV2/getFilterResult`, `GET marketingV2/box-excel`, `GET marketingV2/campaign-excel`.

> No commit — Task 0 is a gate.

---

## Task 1: SQL-snapshot golden-master (the safety net) — do first

There is **zero** direct test coverage of `MarketingV2Query`'s query logic (the controller test mocks it entirely). Before touching the class, capture the exact SQL each method emits so every later task can prove "nothing changed."

**Files:**
- Create: `tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php`

**Interfaces:**
- Produces: a committed test that, for each of the 12 methods across a representative filter matrix, asserts the emitted SQL+bindings equal a stored baseline captured from the CURRENT (pre-refactor) code.

- [ ] **Step 1: Write the capture helper** — In the test file, add a helper that captures emitted SQL without executing it, tolerating result-transforms that assume non-empty rows:

```php
/** @return array<int,array{query:string,bindings:array<mixed>}> */
function captureSql(\Closure $call): array
{
    // DB::pretend logs SQL without running it; the try/catch swallows the
    // post-execution transform (e.g. ->first()->toArray()) that would blow up
    // on pretend's empty result — the SQL is already logged by then.
    return \Illuminate\Support\Facades\DB::pretend(function () use ($call) {
        try { $call(); } catch (\Throwable $e) { /* SQL already captured */ }
    });
}
```

- [ ] **Step 2: Stabilise non-deterministic inputs** — In a `beforeEach`, freeze time and set the config the methods read, so captured SQL/bindings are reproducible:

```php
beforeEach(function () {
    \Carbon\Carbon::setTestNow('2026-08-21 00:00:00');
    config(['constants.d1m1' => json_encode([['iProgramId' => 3, 'name' => 'A']])]);
    config(['constants.d1m6' => json_encode([['iProgramId' => 3, 'name' => 'A']])]);
    // add any other config('constants.*') the methods dereference (grep the file
    // for config( before finalizing this list).
});
afterEach(fn () => \Carbon\Carbon::setTestNow());
```

- [ ] **Step 3: Define the filter matrix** — A `dataset` (or inline array) of representative arg-sets per method that exercises the branch points found in analysis: `vSource='all'` vs `'facebook'` (conditional sources join), `excelDownload` false vs true (date-boundary branch), `platform='app'` vs `'web'` (platform filter, onboarding methods), `signUpOnly=0` vs `1`, `latestSource=0` vs `1`. A handful per method — not full cartesian.

- [ ] **Step 4: Capture the baseline snapshot** — For each `(method, args)`, `captureSql(fn () => app(MarketingV2Query::class)->method(...$args))` and write the result to a committed fixture (`__snapshots__` via a snapshot lib, or a `tests/Unit/Http/Queries/Marketing/__sql__/marketingv2.php` array). The fixture is generated from the **current** code and IS the golden master.

- [ ] **Step 5: Assert current == baseline** — The test asserts, for each `(method, args)`, `captureSql(...)` equals the stored fixture. Run: `XDEBUG_MODE=off vendor/bin/pest tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php` → PASS (it must pass against the code it was generated from).

- [ ] **Step 6: Document the caveat in the test header** — a docblock noting: this asserts SQL construction, not results; time is frozen and config is fixed; DB::pretend does not execute, so any (currently none) result-dependent control flow is not covered. This is the refactor safety net, not a correctness spec.

- [ ] **Step 7: Full gate + commit checkpoint** — `XDEBUG_MODE=off vendor/bin/pest --parallel && vendor/bin/phpstan analyse --memory-limit=2G && vendor/bin/pint --test tests/Unit/Http/Queries/Marketing`. Commit: `test(marketing): SQL-snapshot golden master for MarketingV2Query`.

---

## Task 2: Extract `AcquisitionTotalsQuery` (smallest cluster — proves the pattern)

**Files:**
- Create: `app/Http/Queries/Marketing/AcquisitionTotalsQueryInterface.php`, `AcquisitionTotalsQuery.php`
- Modify: `app/Http/Queries/MarketingV2Query.php`, `app/Providers/AppServiceProvider.php`, `phpstan-baseline.neon` (if entries move)

**Interfaces:**
- Produces (signatures copied verbatim from current `MarketingV2Query`):
```php
interface AcquisitionTotalsQueryInterface
{
    public function getTotalSignups($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload): int;
    public function getTotalDownloads($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $deviceType, $vCountry, $excelDownload): int;
}
```

- [ ] **Step 1: Confirm baseline + snapshot green** — `XDEBUG_MODE=off vendor/bin/pest tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php && vendor/bin/phpstan analyse --memory-limit=2G`.
- [ ] **Step 2: Create the interface** (`declare(strict_types=1);`, namespace `App\Http\Queries\Marketing`, class PHPDoc, the 2 signatures above).
- [ ] **Step 3: Create the implementation** — `AcquisitionTotalsQuery` (`declare(strict_types=1);`, implements the interface, class PHPDoc). Move `getTotalSignups` and `getTotalDownloads` bodies **verbatim** (same `use` imports for `User`/`UserSource`/`Carbon` as needed). `getTotalSignups` (87 lines) is under the god threshold — leave as-is; no phase-split needed.
- [ ] **Step 4: Convert `MarketingV2Query` to delegate** — add a constructor injecting `AcquisitionTotalsQueryInterface $acquisitionTotals` (keep it alongside future cluster deps); replace the two methods' bodies with `return $this->acquisitionTotals->METHOD(...same args...);`. Keep the exact facade signatures.
- [ ] **Step 5: Bind** — `AppServiceProvider::register()`: `$this->app->bind(AcquisitionTotalsQueryInterface::class, AcquisitionTotalsQuery::class);`.
- [ ] **Step 6: Baseline** — run phpstan; if a `MarketingV2Query.php` `property.notFound` entry now fires in `AcquisitionTotalsQuery.php`, remove the old-path entry and add the new-path one (relocation). (The 3 known entries are on `User`/`UserProgram` dynamic props — likely land in later clusters, but check.)
- [ ] **Step 7: Prove no behavior change** — Run: `XDEBUG_MODE=off vendor/bin/pest tests/Unit/Http/Queries/Marketing/MarketingV2QuerySqlSnapshotTest.php` → the two methods' SQL is byte-identical (PASS).
- [ ] **Step 8: Full gate** — `pest --parallel` + `phpstan` + `pint --test app/Http/Queries app/Providers/AppServiceProvider.php`.
- [ ] **Step 9: Commit checkpoint** — `refactor(marketing): extract AcquisitionTotalsQuery from MarketingV2Query`.

---

## Task 3: Extract `SubscriptionFunnelQuery`

Methods: `getUserSubscriptionData` (101 — GOD), `getIntroOpenCount` (97).

**Files:** Create `SubscriptionFunnelQueryInterface.php` + `SubscriptionFunnelQuery.php`; modify `MarketingV2Query.php`, `AppServiceProvider.php`, baseline as needed.

**Interfaces:**
- Produces (verbatim signatures):
```php
interface SubscriptionFunnelQueryInterface
{
    /** @return array<string,mixed> */
    public function getUserSubscriptionData($vSource, $vCampaign, $vMedium, $signUpFromDate, $signUpToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload, $platform): array;
    /** @return array<string,mixed> */
    public function getIntroOpenCount($vSource, $vCampaign, $vMedium, $signUpFromDate, $signUpToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload, $platform): array;
}
```

- [ ] **Step 1: Confirm snapshot + baseline green.**
- [ ] **Step 2: Create interface** (strict types, PHPDoc, signatures above).
- [ ] **Step 3: Create implementation** — move both bodies verbatim.
- [ ] **Step 4: Shorten `getUserSubscriptionData` (101 lines) by intra-method phase-split** — extract its sequential phases (base select+joins → conditional sources join → latest-source subquery → state/device/country/source-campaign-medium filters → signUpOnly → platform → date-range) into `private` methods on `SubscriptionFunnelQuery` that each mutate the query builder, called in the SAME order. Goal: `getUserSubscriptionData` orchestrator < 100 lines; each private phase < ~40. Do NOT change any statement or its order.
- [ ] **Step 5: Delegate** — inject `SubscriptionFunnelQueryInterface` into `MarketingV2Query`; the 2 facade methods delegate.
- [ ] **Step 6: Bind + baseline relocate as needed.**
- [ ] **Step 7: Snapshot** — `XDEBUG_MODE=off vendor/bin/pest …SqlSnapshotTest.php` → byte-identical SQL for both methods (this is the proof the phase-split changed nothing).
- [ ] **Step 8: Full gate.**
- [ ] **Step 9: Commit checkpoint** — `refactor(marketing): extract SubscriptionFunnelQuery; split getUserSubscriptionData into phases`.

---

## Task 4: Extract `SubscriptionBreakdownQuery`

Methods: `getSubscriptionType` (95), `getFreeSubscriptionType` (121 — GOD), `getProductLabelData` (105 — GOD). All three build chart series `array{name,y}` and (per analysis) start from `UserProgram::` — preserve that base model.

**Files:** Create interface + impl; modify facade, provider, baseline.

**Interfaces:**
```php
interface SubscriptionBreakdownQueryInterface
{
    /** @return array<int,array{name:string,y:int}> */
    public function getSubscriptionType($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $signUpOnly, $latestSource, $deviceType, $vCountry): array;
    /** @return array<int,array{name:string,y:int}> */
    public function getFreeSubscriptionType($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $signUpOnly, $latestSource, $deviceType, $vCountry): array;
    /** @return array<int,array{name:string,y:int}> */
    public function getProductLabelData($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $signUpOnly, $latestSource, $deviceType, $vCountry): array;
}
```
- [ ] **Step 1: Confirm snapshot + baseline green.**
- [ ] **Step 2: Create interface.**
- [ ] **Step 3: Move the 3 bodies verbatim** into the new class.
- [ ] **Step 4: Phase-split the 2 god methods** (`getFreeSubscriptionType`, `getProductLabelData`) into private phase methods within the class — same statements/order. Preserve each method's `whereExists`-vs-`join` sub-history technique and date-normalization exactly (do not unify).
- [ ] **Step 5: Delegate + bind + baseline relocate.**
- [ ] **Step 6: Snapshot** → byte-identical SQL for all 3 methods.
- [ ] **Step 7: Full gate.**
- [ ] **Step 8: Commit checkpoint** — `refactor(marketing): extract SubscriptionBreakdownQuery; split god methods into phases`.

---

## Task 5: Extract `PaidSubscriberBreakdownQuery`

Methods: `getUserPaidSubscribedProgramWise` (116 — GOD), `getUserPaidSubscribedSubScreenWise` (108 — GOD). Both return raw `Collection<stdClass>` and start from `User::` — preserve base model and raw return shape (do NOT map/normalize). `SubScreenWise` uses PascalCase params — keep verbatim.

**Files:** Create interface + impl; modify facade, provider, baseline.

**Interfaces:**
```php
use Illuminate\Database\Eloquent\Collection;
interface PaidSubscriberBreakdownQueryInterface
{
    public function getUserPaidSubscribedProgramWise($vSource, $vCampaign, $vMedium, $fromDate, $toDate, $subsFromDate, $subsToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload): Collection;
    public function getUserPaidSubscribedSubScreenWise($vSource, $vCampaign, $vMedium, $FromDate, $toDate, $SubsFromDate, $SubsToDate, $SignUpOnly, $LatestSource, $DeviceType, $vCountry): Collection;
}
```
- [ ] **Step 1: Confirm snapshot + baseline green.**
- [ ] **Step 2: Create interface** (note the exact `Collection` return type import + the verbatim PascalCase params on the 2nd method).
- [ ] **Step 3: Move both bodies verbatim.**
- [ ] **Step 4: Phase-split both god methods** into private phases — same order; preserve the raw `->get()` (no `.map()`) return.
- [ ] **Step 5: Delegate + bind + baseline relocate** — the `UserProgram::$cnt` `property.notFound` entry (count 3) likely lives here; relocate it to `PaidSubscriberBreakdownQuery.php`.
- [ ] **Step 6: Snapshot** → byte-identical SQL.
- [ ] **Step 7: Full gate.**
- [ ] **Step 8: Commit checkpoint** — `refactor(marketing): extract PaidSubscriberBreakdownQuery; split god methods into phases`.

---

## Task 6: Extract `EngagementProgressionQuery` (the foreach-loop methods)

Methods: `getAllData` (102 — GOD, returns `?Model`), `openedCompletedD1M1` (123 — GOD), `completedD1M6` (120 — GOD). The latter two wrap the whole pipeline in a `foreach` over `config('constants.d1m1'|'d1m6')` and touch dynamic `tbl_User{programId}Chapters` tables.

**Files:** Create interface + impl; modify facade, provider, baseline.

**Interfaces:**
```php
use Illuminate\Database\Eloquent\Model;
interface EngagementProgressionQueryInterface
{
    public function getAllData($vSource, $vCampaign, $vMedium, $FromDate, $toDate, $SignUpOnly, $LatestSource, $DeviceType, $vCountry, $excelDownload, $platform): ?Model;
    /** @return array<string,mixed> */
    public function openedCompletedD1M1($vSource, $vCampaign, $vMedium, $signUpFromDate, $signUpToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload, $platform): array;
    /** @return array<string,mixed> */
    public function completedD1M6($vSource, $vCampaign, $vMedium, $signUpFromDate, $signUpToDate, $signUpOnly, $latestSource, $deviceType, $vCountry, $excelDownload, $platform): array;
}
```
- [ ] **Step 1: Confirm snapshot + baseline green.**
- [ ] **Step 2: Create interface** (verbatim signatures incl. PascalCase params on `getAllData`; `?Model` return).
- [ ] **Step 3: Move the 3 bodies verbatim.**
- [ ] **Step 4: Shorten the two foreach methods** — extract the **per-program pipeline body** (everything inside the `foreach ($D1M1 as $item)` loop) into ONE private method (e.g. `private function d1m1RowForProgram($item, ...filters): array`), called from the loop. That alone takes both methods well under 100 lines. Also phase-split `getAllData` (102). Preserve the loop, its accumulation, the dynamic table-name construction, and statement order exactly.
- [ ] **Step 5: Delegate + bind + baseline relocate** — the `User::$CompletedTotal` / `User::$Total` `property.notFound` entries likely live here; relocate them.
- [ ] **Step 6: Snapshot** — the golden master captures all `foreach` iterations (config is fixed in `beforeEach`); assert byte-identical SQL across every iteration.
- [ ] **Step 7: Full gate.**
- [ ] **Step 8: Commit checkpoint** — `refactor(marketing): extract EngagementProgressionQuery; extract per-program loop body`.

---

## Task 7: Finalize `MarketingV2Query` as a facade

**Files:** `app/Http/Queries/MarketingV2Query.php` (cleanup only).

- [ ] **Step 1: Measure** — `wc -l app/Http/Queries/MarketingV2Query.php` — expect ~110–140 lines (constructor + 12 one-line delegators).
- [ ] **Step 2: Confirm it's a pure facade** — all 12 methods delegate to one of the 5 injected cluster interfaces; no query logic, no `use` of `User`/`UserSource`/`UserProgram`/`OnlyDownload`/`Carbon` remains (remove now-unused imports). Constructor injects exactly the 5 cluster interfaces.
- [ ] **Step 3: Add `declare(strict_types=1)` + a class-level PHPDoc** describing it as a facade over the five Marketing query classes. (The facade signatures stay untyped/verbatim for controller compatibility; only the file-level `strict_types` and class doc are added.)
- [ ] **Step 4: Confirm the contract** — the 12 public signatures are unchanged; `MarketingV2Controller` is NOT in the diff; `grep -rn "new MarketingV2Query(" app tests` shows no direct instantiation that breaks (it's container-resolved).
- [ ] **Step 5: Full gate** — `pest --parallel` + `phpstan --memory-limit=2G` + the SQL snapshot + `pint --test`.
- [ ] **Step 6: Commit checkpoint** — `refactor(marketing): slim MarketingV2Query to a facade over 5 query classes`.

> **Out of scope (deliberately deferred as a separate, higher-risk follow-up):** cross-method de-duplication (Axis A — a shared latest-source-subquery / sources-join / platform-filter builder, and a typed `MarketingFilters` DTO). These consolidate logic *across* methods, which the strict behavior-preservation goal makes risky (and would require unifying the divergent date/base-model handling). They can follow later, each guarded by the same SQL snapshot. This plan intentionally shortens methods via *intra-class* phase-splitting only.

---

## Self-Review

- **Spec coverage:** Rec 3.1 / Phase-4 target (`MarketingV2Query` 1,418) is covered by Tasks 2–7; the report's "shorten, don't relocate" lesson is enforced by the per-cluster phase-split steps (Tasks 3–6 Step 4) + the method-census intent; `strict_types`/interfaces/class-PHPDoc are in Global Constraints and every cluster task. ✅
- **Behavior-preservation:** the SQL-snapshot golden master (Task 1) is asserted after every task; Global Constraints forbid unifying date logic / base models / return shapes; facade keeps all 12 signatures verbatim so the controller + 3 routes are untouched. ✅
- **Type consistency:** every "Produces" interface block copies the exact current signature (including inconsistent casing and `Collection`/`?Model`/`int`/`array` returns) from the verified inventory. ✅
- **Safety-net hazard flagged:** no existing query-logic tests → Task 1 builds the net first; the `DB::pretend`+`try/catch`+frozen-time+fixed-config mechanics are specified so it works for the result-transforming and foreach methods. ✅
- **Baseline hazard flagged:** the 3 `property.notFound` entries relocate with their methods (Tasks 5/6), reduction/relocation only. ✅
- **Ordering:** smallest cluster first (Task 2) to prove the pattern; foreach methods last (Task 6, highest complexity); facade finalize last (Task 7). ✅
