Laravel 13 CSV 处理完全指南

CSV 是最常用的数据交换格式之一。本文将深入探讨 Laravel 13 中 CSV 处理的各种方法和最佳实践。

CSV 导出

基础 CSV 导出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php

namespace App\Services;

use Illuminate\Support\Facades\Storage;

class CsvExportService
{
public function export(array $data, string $filename, array $headers = null): string
{
$handle = fopen('php://temp', 'r+');

if ($headers) {
fputcsv($handle, $headers);
}

foreach ($data as $row) {
fputcsv($handle, $row);
}

rewind($handle);
$content = stream_get_contents($handle);
fclose($handle);

$path = "exports/{$filename}.csv";
Storage::put($path, $content);

return $path;
}

public function download(array $data, string $filename, array $headers = null)
{
$callback = function () use ($data, $headers) {
$handle = fopen('php://output', 'w');

if ($headers) {
fputcsv($handle, $headers);
}

foreach ($data as $row) {
fputcsv($handle, $row);
}

fclose($handle);
};

return response()->stream($callback, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '.csv"',
]);
}
}

流式 CSV 导出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php

namespace App\Services;

use Illuminate\Support\Collection;

class StreamingCsvExport
{
public function exportLargeDataset($query, string $filename, array $headers = null)
{
$callback = function () use ($query, $headers) {
$handle = fopen('php://output', 'w');

if ($headers) {
fputcsv($handle, $headers);
}

$query->chunk(1000, function ($items) use ($handle) {
foreach ($items as $item) {
fputcsv($handle, $this->formatRow($item));
}
});

fclose($handle);
};

return response()->stream($callback, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '.csv"',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}

protected function formatRow($item): array
{
return [
$item->id,
$item->name,
$item->email,
$item->created_at->format('Y-m-d H:i:s'),
];
}
}

CSV 导入

基础 CSV 导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

namespace App\Services;

use Illuminate\Support\Facades\Storage;

class CsvImportService
{
protected array $config = [
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
'has_header' => true,
];

public function import(string $path, callable $callback, array $options = []): array
{
$config = array_merge($this->config, $options);
$fullPath = Storage::path($path);

$handle = fopen($fullPath, 'r');
$results = [
'total' => 0,
'success' => 0,
'failed' => 0,
'errors' => [],
];

if ($config['has_header']) {
fgetcsv($handle, 0, $config['delimiter'], $config['enclosure'], $config['escape']);
}

$lineNumber = $config['has_header'] ? 1 : 0;

while (($row = fgetcsv($handle, 0, $config['delimiter'], $config['enclosure'], $config['escape'])) !== false) {
$lineNumber++;
$results['total']++;

try {
$callback($row, $lineNumber);
$results['success']++;
} catch (\Exception $e) {
$results['failed']++;
$results['errors'][] = [
'line' => $lineNumber,
'error' => $e->getMessage(),
'data' => $row,
];
}
}

fclose($handle);

return $results;
}
}

带验证的导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Validator;

class UserCsvImport
{
protected CsvImportService $importService;

public function __construct(CsvImportService $importService)
{
$this->importService = $importService;
}

public function import(string $path): array
{
return $this->importService->import($path, function ($row, $lineNumber) {
$data = $this->mapRow($row);

$this->validate($data);

User::create($data);
});
}

protected function mapRow(array $row): array
{
return [
'name' => $row[0] ?? null,
'email' => $row[1] ?? null,
'password' => $row[2] ?? null,
];
}

protected function validate(array $data): void
{
$validator = Validator::make($data, [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
]);

if ($validator->fails()) {
throw new \Exception($validator->errors()->first());
}
}
}

CSV 解析器

高级 CSV 解析器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php

namespace App\Services;

class CsvParser
{
protected array $options;

public function __construct(array $options = [])
{
$this->options = array_merge([
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
'has_header' => true,
'encoding' => 'UTF-8',
], $options);
}

public function parse(string $content): array
{
$lines = $this->splitLines($content);
$headers = null;
$data = [];

foreach ($lines as $index => $line) {
if (empty(trim($line))) {
continue;
}

$row = $this->parseLine($line);

if ($index === 0 && $this->options['has_header']) {
$headers = $row;
continue;
}

if ($headers) {
$data[] = array_combine($headers, $row);
} else {
$data[] = $row;
}
}

return $data;
}

public function parseFile(string $path): array
{
$content = file_get_contents($path);

if ($this->options['encoding'] !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $this->options['encoding']);
}

return $this->parse($content);
}

protected function splitLines(string $content): array
{
return preg_split('/\r\n|\r|\n/', $content);
}

protected function parseLine(string $line): array
{
$result = [];
$field = '';
$inEnclosure = false;
$len = strlen($line);

for ($i = 0; $i < $len; $i++) {
$char = $line[$i];

if ($char === $this->options['enclosure']) {
if ($inEnclosure && isset($line[$i + 1]) && $line[$i + 1] === $this->options['enclosure']) {
$field .= $char;
$i++;
} else {
$inEnclosure = !$inEnclosure;
}
} elseif ($char === $this->options['delimiter'] && !$inEnclosure) {
$result[] = $field;
$field = '';
} else {
$field .= $char;
}
}

$result[] = $field;

return $result;
}
}

CSV 构建

CSV 构建器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php

namespace App\Services;

class CsvBuilder
{
protected array $headers = [];
protected array $rows = [];
protected string $delimiter = ',';
protected string $enclosure = '"';
protected string $lineEnding = PHP_EOL;

public function setHeaders(array $headers): self
{
$this->headers = $headers;
return $this;
}

public function addRow(array $row): self
{
$this->rows[] = $row;
return $this;
}

public function addRows(array $rows): self
{
foreach ($rows as $row) {
$this->addRow($row);
}
return $this;
}

public function setDelimiter(string $delimiter): self
{
$this->delimiter = $delimiter;
return $this;
}

public function build(): string
{
$output = '';

if (!empty($this->headers)) {
$output .= $this->buildRow($this->headers);
}

foreach ($this->rows as $row) {
$output .= $this->buildRow($row);
}

return $output;
}

protected function buildRow(array $row): string
{
$fields = array_map(function ($field) {
return $this->encloseField($field);
}, $row);

return implode($this->delimiter, $fields) . $this->lineEnding;
}

protected function encloseField($field): string
{
$field = (string) $field;

$needsEnclosure = str_contains($field, $this->delimiter) ||
str_contains($field, $this->enclosure) ||
str_contains($field, "\n") ||
str_contains($field, "\r");

if ($needsEnclosure) {
$field = str_replace($this->enclosure, $this->enclosure . $this->enclosure, $field);
return $this->enclosure . $field . $this->enclosure;
}

return $field;
}

public function download(string $filename)
{
return response($this->build(), 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
}

CSV 验证

CSV 结构验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
<?php

namespace App\Services;

class CsvValidator
{
protected array $rules;
protected array $errors = [];

public function __construct(array $rules)
{
$this->rules = $rules;
}

public function validate(string $path): array
{
$handle = fopen($path, 'r');
$headers = fgetcsv($handle);

$this->validateHeaders($headers);

$lineNumber = 1;
$validRows = [];
$invalidRows = [];

while (($row = fgetcsv($handle)) !== false) {
$lineNumber++;

$result = $this->validateRow($row, $headers, $lineNumber);

if ($result['valid']) {
$validRows[] = $result['data'];
} else {
$invalidRows[] = $result;
}
}

fclose($handle);

return [
'valid' => empty($this->errors),
'headers_valid' => empty($this->errors['headers'] ?? []),
'errors' => $this->errors,
'valid_rows' => $validRows,
'invalid_rows' => $invalidRows,
'total_rows' => $lineNumber - 1,
];
}

protected function validateHeaders(array $headers): void
{
$expected = $this->rules['headers'] ?? [];

if (count($expected) !== count($headers)) {
$this->errors['headers'][] = '列数不匹配';
}

foreach ($expected as $index => $name) {
if (!isset($headers[$index]) || $headers[$index] !== $name) {
$this->errors['headers'][] = "第 " . ($index + 1) . " 列应该是 '{$name}'";
}
}
}

protected function validateRow(array $row, array $headers, int $lineNumber): array
{
$data = array_combine($headers, $row);
$errors = [];

foreach ($this->rules['columns'] ?? [] as $column => $rules) {
$value = $data[$column] ?? null;

foreach ($rules as $rule) {
$error = $this->validateField($column, $value, $rule);

if ($error) {
$errors[] = $error;
}
}
}

return [
'line' => $lineNumber,
'data' => $data,
'valid' => empty($errors),
'errors' => $errors,
];
}

protected function validateField(string $column, $value, string $rule): ?string
{
if ($rule === 'required' && empty($value)) {
return "{$column} 不能为空";
}

if ($rule === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
return "{$column} 不是有效的邮箱地址";
}

if ($rule === 'numeric' && !is_numeric($value)) {
return "{$column} 必须是数字";
}

return null;
}
}

总结

Laravel 13 的 CSV 处理提供了:

  • 灵活的 CSV 导出功能
  • 流式大数据导出
  • 带验证的导入处理
  • 高级 CSV 解析器
  • CSV 构建工具
  • 结构验证支持

掌握 CSV 处理技巧可以高效处理数据交换需求。