$headers * @param list> $rows */ public function export(array $headers, array $rows, bool $withBom = false): string { $records = []; $records[] = $this->formatRecord($headers); $headerCount = count($headers); $rowNumber = 0; foreach ($rows as $row) { $rowNumber++; if (!is_array($row)) { throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber)); } $fieldCount = count($row); if ($fieldCount !== $headerCount) { throw new InvalidArgumentException(sprintf( 'CSV row %d has %d fields; expected %d fields for %d headers.', $rowNumber, $fieldCount, $headerCount, $headerCount, )); } $records[] = $this->formatRecord($row); } $csv = implode("\r\n", $records) . "\r\n"; return $withBom ? self::UTF8_BOM . $csv : $csv; } /** Serialize an allow-listed report projection without exposing associative keys. */ public function exportRecords(array $records, bool $withBom = false): string { if ($records === []) return $withBom ? self::UTF8_BOM : ''; $headers = array_keys($records[0]); $rows = []; foreach ($records as $record) { if (!is_array($record) || array_keys($record) !== $headers) { throw new InvalidArgumentException('CSV report records must share the same ordered fields.'); } $rows[] = array_values($record); } return $this->export($headers, $rows, $withBom); } /** * @param list $fields */ private function formatRecord(array $fields): string { return implode(',', array_map( fn (mixed $field): string => $this->escapeField($field), $fields, )); } private function escapeField(mixed $field): string { if ($field === null) { $value = ''; } elseif (is_scalar($field)) { $value = (string)$field; } else { throw new InvalidArgumentException('CSV fields must be scalar values or null.'); } // Prefix formula-like values so spreadsheet programs treat them as text. if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) { $value = "'" . $value; } if (strpbrk($value, ",\"\r\n") === false) { return $value; } return '"' . str_replace('"', '""', $value) . '"'; } }