# Export (Shared · list downloads)

**Backend trait:** [`App\Http\Controllers\Concerns\ExportsResource`](/app/Http/Controllers/Concerns/ExportsResource.php) · **Frontend component:** [`Components/ExportMenu.vue`](/resources/js/Components/ExportMenu.vue) · **Escaping trait:** [`App\Exports\Concerns\EscapesCsvFormulas`](/app/Exports/Concerns/EscapesCsvFormulas.php) · **Writer:** `maatwebsite/excel`

## What it does

Turns an admin list into an **`.xlsx` or `.csv` download that matches what the admin is looking
at** — same rows, same filters, same order. Every export in the app goes through the same trait,
the same menu component and the same shaped export class, so a new one is mostly configuration
and no two downloads behave differently.

**It is a PATTERN, not a service — and deliberately so.** There is no `ExportService` and there
should not be: the parts that differ per list (which rows, which columns, how each is formatted)
belong in that list's own class, and the parts that never differ (writer choice, file naming,
formula escaping, the menu) are already shared. A generic service would have to be told all three
of those anyway, and would hide the one thing each export must state plainly — its columns.

## How it works

Four pieces, in the order you build them:

1. **The route** — `GET {list-url}/export`, declared **before** any `GET {id}` in the same group,
   or the literal `export` segment is matched as an `{id}` uuid and 404s (GUIDELINES §14). A
   two-segment `GET {id}/export` — an export scoped to ONE record — is equally valid; Membership,
   Sales Projects and the VSL roster all use that shape.
2. **The controller action** — `use ExportsResource`, then
   `downloadExport($export, $baseName, $request->input('format'))`. It picks the writer
   (`?format=csv` → CSV, otherwise XLSX) and appends a timestamp to the file name, so nothing
   downstream ever names a file itself.
3. **The export class** — `App\Exports\{Module}Export`, implementing maatwebsite's
   `FromCollection` + `WithHeadings` + `WithMapping` + `ShouldAutoSize`, and using
   `EscapesCsvFormulas`. `headings()` names the columns; `map()` flattens one row.
4. **The menu** — `<ExportMenu :base-url="…" />` on the page. Its `base-url` is the LIST url with
   **no** `/export` (the component appends it), and it forwards the page's current query string so
   the file matches the view. `compact` gives the smaller button for a card or tab header.

## Reference usage — Sales Project leads (reference implementation)

The canonical consumer is [`SalesProjectsController@export`](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php)
→ [`ProjectLeadsExport`](/app/Exports/ProjectLeadsExport.php). Copy this shape:

```php
public function export(Request $request, string $id): BinaryFileResponse
{
    $project = Project::withCanonicalFacts()->where('uuid', $id)->firstOrFail();
    $user = $request->user();

    // 1. The SAME scoping rule the page applies.
    abort_unless(GroupScope::allows($user, $project->group_id), 403);

    // 2. The SAME query and the SAME row mapper as the page — get(), not paginate().
    $rows = $this->projectLeadsQuery($request, $project, $user)
        ->get()
        ->map(fn (Engagement $engagement) => $this->engagementCard($engagement, $project));

    // 3. The record's name in the file name, so three downloads are tellable apart.
    return $this->downloadExport(
        new ProjectLeadsExport($rows, $project->floorPlans()->get()->keyBy('id')),
        'project-leads-' . Str::slug($project->canonicalName()),
        $request->input('format'),
    );
}
```

```vue
<ExportMenu compact :base-url="`/manage/sales-projects/${project.uuid}`" />
```

The page's own filters live in the URL, so `ExportMenu` forwarding it is all the wiring the
filters need. **One method returns the unpaginated builder; the list paginates it and the export
`get()`s it, both running the same row mapper.**

## The rules that are not cosmetic

Each of these was learned from a file that was quietly wrong. None of them announces itself when
broken — a truncated or over-broad export looks exactly like a correct one.

- **Share the query with the page.** One method returning an **unpaginated builder**, which the
  list paginates and the export `get()`s, both running the same row mapper. A second
  hand-written query is how a file silently stops matching the screen — a filter applied in one
  place and not the other, with nothing failing.
- **Never let the list's `per_page` reach the export**, nor any page-side cap. The menu forwards
  the whole query string; honouring a rows-per-page meant for a table truncates the download with
  no sign anything is missing. The VSL roster caps its page at 500 registrations and passes
  `null` for the export precisely for this reason.
- **Keep every scoping rule the page applies** (`GroupScope`, `LeadVisibility`, the permission
  that opens the page at all). An export is a file the admin keeps, so a visibility hole here
  outlives the session. Gate the action on the **same grant that builds the list** — not a
  stricter one (requiring more to download what you can already see is theatre) and never a
  looser one.
- **Cast money and counts to `float`.** Model `decimal:x` casts and `number_format()` both yield
  strings, which land as left-aligned text no spreadsheet will total — the first thing anyone
  does with an export. Use `''`, never `null`, for blanks: the CSV and XLSX writers render a null
  cell differently.
- **Keep phone as text.** Excel eats the leading `0` of `0123456789` and the `+` of an
  international number the moment it decides a cell is numeric.
- **Escape user-typed text** with `EscapesCsvFormulas::csvSafe()`. A value starting `=`, `+`, `-`
  or `@` executes as a formula when the file is opened, and every text column in an admin export
  is ultimately customer-supplied. **TEXT columns only** — running a number through it makes the
  column unsummable, which is why amounts are cast in `map()` instead.
- **Never write a raw constant value.** A status, slot, budget or goal stored as an integer must
  leave as its name from the model's own constant table (GUIDELINES §5). A column of `1`s and
  `4`s tells the reader nothing, and a second copy of the labels is the one that drifts.
  **This binds a DERIVED state too**, which is the easier one to get wrong because no column
  holds it: the session roster's Zoom state (On Zoom / Syncing / Paused / Failed) had no
  constant table, so its labels — and the precedence rule behind them — ended up written out
  three times (the Vue, the export's filter twin, the export). The fix was to give the state a
  home: [`EventRegistration::zoomState()`](/src/Event/EventRegistration.php) resolves it and
  `ZOOM_STATES` words it, the controller ships both on the row, and all three readers quote
  them. **If an export needs a label the model does not own yet, add the constant — do not
  type the word into the export.**
- **Flatten what the screen only hints at.** A spreadsheet has no colour, tooltip, band header,
  expand row or modal. Anything conveyed that way needs its own column or the reader draws the
  wrong conclusion:
  - an "estimated" badge → a `Commission Estimated` Yes/No column, or an unconverted forecast is
    summed as money already banked;
  - a coloured band separating measurement from guess → spelled into the heading
    (`Occupation (AI Guess)`), or a guess reads as a fact and someone filters a call list on it;
  - a header switch that reflows a whole band (since-joining ⇄ lifetime) → **both** sets of
    columns, since a file cannot toggle and a reader comparing one against the other would never
    notice;
  - a modal's fields (a booking's bank and bankers, an enquiry's budget / goal / timeline) → their
    own columns;
  - a blank that means "we cannot count" rather than "we counted zero" → its own reason column,
    because five zeros beside the highest-value row read as "never contacted".
- **No silent caps.** If an export bounds coverage at all, say so — in the file name, a column, or
  a log line. A silently truncated file reads as "this is everything".
- **`FromCollection` is a bet on the row count, and the Leads list lost it (2026-09-01).** Every
  export here except one is scoped to a single session / funnel / membership and holds a few
  hundred rows, where materialising a Collection is free. `LeadsExport` is the unbounded one —
  "all the leads" — and once the CRM passed 12,000 of them the download simply stopped working:
  `$query->get()` hydrated ~11,800 leads with their profiles, registrations and enrichments and
  reached **260 MB before the writer had started, peaking at 406 MB**, so a PHP-FPM worker on the
  usual 256 MB limit was killed mid-request. From the browser that is a button that does nothing,
  which is why nobody could say what was wrong. **The fix is a smaller query, not a queue:**
  switching to **`FromQuery`** lets the writer pull `excel.exports.chunk_size` (1,000) leads at a
  time, so peak memory is set by the biggest chunk plus the finished sheet rather than by the size
  of the table — **406 MB → 158 MB for the identical 11,949-row file**. Time barely moved
  (≈21 s → ≈20 s), which is the measurement that settles the queue question: this was never a
  timeout. `ShouldAutoSize` was measured too and **kept** — once the collection is gone it costs
  about 2 MB and 3 s, so it was never the problem it looks like.
- **A chunked export needs a UNIQUE tiebreaker in its ORDER BY.** `FromQuery` walks the builder
  with `chunk()` — offset/limit over repeated queries — so rows that tie on the sort column can
  swap places between one chunk's query and the next, and a row then lands in the file twice or
  vanishes from it. Every sort the Leads export allows is non-unique (the default `latest()`, a
  status, a name resolved by subquery), so `LeadsController@export` appends `leads.id` last.
  Nothing about a download missing one row in ten thousand looks wrong, which is the whole danger.
- **Where the queue actually belongs.** Chunking bounds the *reading* side, not the sheet:
  PhpSpreadsheet still holds every written row, so memory keeps climbing with the row count, just
  far more slowly. At the current shape that is roughly 10 KB a row — comfortable at 12,000, a
  problem again somewhere north of ~30,000. That is the point to move an export onto Horizon (job
  writes to storage, admin gets a link) rather than now: a queued export costs a job, a stored
  file, a notification, a download-later route and a cleanup sweep, and it takes the file out of
  the admin's hands at the moment they asked for it.

## ⚠️ The exception: a list that filters in the browser

Almost every list filters **server-side**, so `ExportMenu` forwarding the URL is the whole
mechanism. **Two rosters do not** — the **VSL funnel's Leads tab** and the **session's
Registrations tab**. Both ship every row as an Inertia prop and filter locally, so the chips /
drawer, the search box and the problems toggle are state the server has never seen. Forwarding
nothing there would hand back the whole funnel while the screen showed the twelve people one
caller is meant to ring today.

Their answer — and the shape to copy **only** for a genuinely client-filtered list:

- the page passes its selection through `ExportMenu`'s **`params`** prop (empty values are
  dropped, so an untouched group sends nothing and the server default stands);
- the server re-applies them with a **PHP twin of the page's own filter**:
  [`VslRosterFilter`](/src/Event/Support/VslRosterFilter.php) mirrors
  [`useVslRosterFilters.js`](/resources/js/composables/useVslRosterFilters.js), and
  [`RegistrationRosterFilter`](/src/Event/Support/RegistrationRosterFilter.php) mirrors the
  `visibleRegistrations` computed in
  [`RegistrationsTab.vue`](/resources/js/Pages/Manage/Events/Partials/Tabs/RegistrationsTab.vue);
- each twin is pinned case for case — [`VslRosterFilterTest`](/tests/Unit/Event/VslRosterFilterTest.php)
  mirrors `useVslRosterFilters.test.js`, and
  [`RegistrationRosterFilterTest`](/tests/Unit/Event/RegistrationRosterFilterTest.php) states the
  tab's behaviour rather than the class's — because this is a second expression of one rule set,
  and the only thing keeping the two honest is that a divergence goes red.

It is safe only because both sides read the **same row arrays through the same keys**: there is
no second query and no second source of truth. A server-filtered list must never do this — it
shares one query with its page instead. The test earned its keep immediately: the PHP compared
the picked caller uuid directly, while the composable **widens** when the picked option no longer
exists (a caller chip disappears the moment their last row is reassigned) — the PHP would have
handed back an empty file that looked like "nobody qualifies".

⚠️ **Each twin mirrors ITS OWN screen, never its sibling.** The two rosters genuinely differ,
and copying a predicate across is how a file starts disagreeing with the count printed above its
own table: the VSL roster **widens** on an owner option that has disappeared, while Registrations
**excludes** an unknown source (its options are compared directly, so widening would hand back
rows the screen was not showing); the VSL problems toggle **exempts** a conditional rule's no-op,
while the Registrations one counts every skipped cell. What the two DO share is the Quality
buckets, which is exactly why those live in
[`LeadQuality::matchesBucket()`](/src/Lead/Support/LeadQuality.php) — one server-side definition,
beside the state resolver it reads — rather than in either filter. **A rule both screens agree on
moves into the shared resolver; a rule they don't stays in the twin.**

## Current consumers

| List | Route | Controller | Export class |
|---|---|---|---|
| Leads index | `GET manage/leads/export` | [`LeadsController@export`](/app/Http/Controllers/Manage/Leads/LeadsController.php) | [`LeadsExport`](/app/Exports/LeadsExport.php) — **`FromQuery`**, the only unbounded list |
| Membership members | `GET manage/memberships/{id}/export` | [`MembershipsController@export`](/app/Http/Controllers/Manage/Membership/MembershipsController.php) | [`MembershipMembersExport`](/app/Exports/MembershipMembersExport.php) |
| Sales Project leads | `GET manage/sales-projects/{id}/export` | [`SalesProjectsController@export`](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) | [`ProjectLeadsExport`](/app/Exports/ProjectLeadsExport.php) |
| Cross-project bookings | `GET manage/sales-projects/bookings/export` | `SalesProjectsController@exportBookings` | `ProjectLeadsExport` (`withProject`) |
| Session registrations | `GET manage/events/{id}/registrations/export` | [`EventsController@exportRegistrations`](/app/Http/Controllers/Manage/Events/EventsController.php) | [`EventRegistrationsExport`](/app/Exports/EventRegistrationsExport.php) |
| Webinar no-shows | `GET manage/events/{id}/attendance/export` | [`WebinarAttendanceController@exportNoShows`](/app/Http/Controllers/Manage/Events/WebinarAttendanceController.php) | [`WebinarNoShowExport`](/app/Exports/WebinarNoShowExport.php) |
| VSL funnel leads | `GET manage/events/funnels/{id}/vsl-leads/export` | [`FunnelsController@exportVslLeads`](/app/Http/Controllers/Manage/Events/FunnelsController.php) | [`VslLeadsExport`](/app/Exports/VslLeadsExport.php) |
| Property catalogue | `GET manage/catalog/.../export` | [`CatalogController`](/app/Http/Controllers/Manage/Property/CatalogController.php) | [`CatalogueDatabaseExport`](/app/Exports/CatalogueDatabaseExport.php) |
| WhatsApp broadcast recipients | `GET manage/whatsapp/broadcasts/{id}/export` | [`BroadcastsController`](/app/Http/Controllers/Manage/Whatsapp/BroadcastsController.php) | [`BroadcastRecipientsExport`](/app/Exports/Whatsapp/BroadcastRecipientsExport.php) |
| Zoom action-plan feedback | `GET manage/zoom/.../export` | [`ActionPlanFeedbackController`](/app/Http/Controllers/Manage/Zoom/ActionPlanFeedbackController.php) | [`ActionPlanDisagreeExport`](/app/Exports/ActionPlanDisagreeExport.php) |

## Related files

**Backend — shared**
- [app/Http/Controllers/Concerns/ExportsResource.php](/app/Http/Controllers/Concerns/ExportsResource.php) — `downloadExport()`: writer choice + timestamped file name.
- [app/Exports/Concerns/EscapesCsvFormulas.php](/app/Exports/Concerns/EscapesCsvFormulas.php) — `csvSafe()`: neutralises spreadsheet formula injection. Text columns only.

**Backend — per-list export classes**
- [app/Exports/](/app/Exports/) — one class per list, all `FromCollection` + `WithHeadings` + `WithMapping` + `ShouldAutoSize`.

**Frontend**
- [resources/js/Components/ExportMenu.vue](/resources/js/Components/ExportMenu.vue) — the Excel / CSV dropdown; forwards the page query string (minus `page`) plus any `params`, and appends `/export` to `base-url`.

**Client-filtered exception**
- [src/Event/Support/VslRosterFilter.php](/src/Event/Support/VslRosterFilter.php) + [tests/Unit/Event/VslRosterFilterTest.php](/tests/Unit/Event/VslRosterFilterTest.php) — the PHP twin of [useVslRosterFilters.js](/resources/js/composables/useVslRosterFilters.js) and the cases that keep them in step.
- [src/Event/Support/RegistrationRosterFilter.php](/src/Event/Support/RegistrationRosterFilter.php) + [tests/Unit/Event/RegistrationRosterFilterTest.php](/tests/Unit/Event/RegistrationRosterFilterTest.php) — the same for the session Registrations roster's drawer / campaign buttons / problems toggle / search.
- [src/Lead/Support/LeadQuality.php](/src/Lead/Support/LeadQuality.php) — `matchesBucket()`: the ONE server-side definition of the Agent / Fake / Clean buckets, read by both twins.

**Tests**
- [tests/Feature/Event/VslLeadsExportTest.php](/tests/Feature/Event/VslLeadsExportTest.php) — the gate, the 404, the uncapped file and the forwarded chips; the closest thing to a template for a new export's test.
- [tests/Feature/Event/EventRegistrationsExportTest.php](/tests/Feature/Event/EventRegistrationsExportTest.php) — the same four for the session roster, plus the conditional Zoom columns and the forwarded `per_page` that must not truncate it.

**See also**
- GUIDELINES §14 (Admin Index Pages) — where exports sit in the shared DataTable pattern.
