> $rows * @param list $columns */ public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void { if (!is_resource($handle)) { throw new InvalidArgumentException('writeTo() expects an open stream resource'); } if ($columns === []) { throw new InvalidArgumentException('At least one column is required'); } $delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ','; if ($flavor === self::FLAVOR_EXCEL) { fwrite($handle, self::UTF8_BOM); } $headers = array_map( static fn (ExportColumn $c): string => self::escapeValue($c->header), $columns ); fputcsv($handle, $headers, $delimiter, '"', '\\'); foreach ($rows as $row) { $line = []; foreach ($columns as $column) { $value = ($column->extract)($row); $line[] = self::escapeValue($value, $column->allowSignedNumeric); } fputcsv($handle, $line, $delimiter, '"', '\\'); } } /** * Prefixes a single apostrophe to values that would be interpreted * as a formula by spreadsheet apps. Defuses the leading-character * set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR. * * When $allowSignedNumeric is true, purely numeric/phone-shaped values * starting with + or - are left intact (useful for `+49 …` columns). * * @api Called from page actions that build rows manually */ public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string { if ($value === null) { return ''; } if (is_bool($value)) { return $value ? '1' : '0'; } $string = (string) $value; if ($string === '') { return ''; } $first = $string[0]; if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") { return "'" . $string; } if ($first === '+' || $first === '-') { if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) { return $string; } return "'" . $string; } return $string; } }