Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.74% covered (success)
95.74%
45 / 47
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportCsv
95.74% covered (success)
95.74%
45 / 47
50.00% covered (danger)
50.00%
2 / 4
15
0.00% covered (danger)
0.00%
0 / 1
 exportCsv
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
6
 getNestedValue
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 modifyExportCsvQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeCsvLine
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelFastCrud\Actions;
6
7use BackedEnum;
8use DevToolbelt\LaravelFastCrud\Traits\Limitable;
9use DevToolbelt\LaravelFastCrud\Traits\Pageable;
10use DevToolbelt\LaravelFastCrud\Traits\Searchable;
11use DevToolbelt\LaravelFastCrud\Traits\Sortable;
12use Exception;
13use Illuminate\Database\Eloquent\Builder;
14use Illuminate\Http\Request;
15use Illuminate\Support\Facades\Response;
16use Symfony\Component\HttpFoundation\StreamedResponse;
17
18/**
19 * Provides the CSV export (GET /export-csv) action for CRUD controllers.
20 *
21 * Exports filtered and sorted records to a downloadable CSV file.
22 * Supports the same filter and sort parameters as the Search action.
23 *
24 * Configure the export by setting:
25 * - $csvFileName: The output filename (default: 'export.csv')
26 * - $csvColumns: Column mapping array (key = model path, value = CSV header)
27 *
28 * @method string modelClassName() Returns the Eloquent model class name
29 *
30 * @property array $data Paginated records data (from Pageable trait)
31 *
32 * @example
33 * ```php
34 * class ProductController extends CrudController
35 * {
36 *     protected string $csvFileName = 'products.csv';
37 *     protected array $csvColumns = [
38 *         'name' => 'Product Name',
39 *         'category.name' => 'Category',
40 *         'price' => 'Price',
41 *         'created_at' => 'Created At',
42 *     ];
43 * }
44 * ```
45 */
46trait ExportCsv
47{
48    use Searchable;
49    use Sortable;
50    use Limitable;
51    use Pageable;
52
53    /**
54     * The filename for the exported CSV file.
55     * Will be prefixed with the current timestamp (Y-m-d_H-i-s_).
56     */
57    protected string $csvFileName = 'export.csv';
58
59    /**
60     * Column mapping for CSV export.
61     *
62     * Can be either:
63     * - Associative array: ['model.path' => 'CSV Header'] - maps model attributes to custom headers
64     * - Indexed array: ['column1', 'column2'] - uses column names as headers
65     *
66     * Supports dot notation for nested relationships (e.g., 'category.name', 'user.profile.avatar').
67     *
68     * @var array<string, string>|array<int, string>
69     */
70    protected array $csvColumns = [];
71
72    /**
73     * Exports records to a CSV file download.
74     *
75     * @param Request $request The HTTP request with optional filter and sort parameters
76     * @param string|null $method Model serialization method (default from config or 'toArray')
77     * @return StreamedResponse Streamed CSV file download response
78     *
79     * @throws Exception When an invalid search operator is provided
80     */
81    public function exportCsv(Request $request, ?string $method = null): StreamedResponse
82    {
83        $method = $method ?? config('devToolbelt.fast-crud.export_csv.method', 'toArray');
84        $modelName = $this->modelClassName();
85        $query = $modelName::query();
86        $isAssociative = array_keys($this->csvColumns) !== range(0, count($this->csvColumns) - 1);
87        $columnPaths = $isAssociative ? array_keys($this->csvColumns) : $this->csvColumns;
88
89        $this->modifyExportCsvQuery($query);
90
91        $this->processSearch($query, $request->get('filter', []));
92        $this->processSort($query, $request->input('sort', ''));
93        $this->buildPagination($query, (int) $request->input('perPage', 9_999_999), $method);
94
95        return Response::stream(function () use ($columnPaths): void {
96            $handle = fopen('php://output', 'w');
97
98            if (!empty($this->csvColumns)) {
99                $headers = array_values($this->csvColumns);
100                $this->writeCsvLine($handle, $headers);
101            }
102
103            foreach ($this->data as $row) {
104                $csvRow = [];
105                foreach ($columnPaths as $columnPath) {
106                    $value = $this->getNestedValue($row, $columnPath);
107
108                    if ($value instanceof BackedEnum) {
109                        $value = $value->value;
110                    }
111
112                    $csvRow[] = $value;
113                }
114                $this->writeCsvLine($handle, $csvRow);
115            }
116
117            fclose($handle);
118        }, 200, [
119            'Content-Type' => 'text/csv; charset=UTF-8',
120            'Content-Disposition' => 'attachment; filename="' . date('Y-m-d_H-i-s_') . $this->csvFileName . '"',
121            'Cache-Control' => 'max-age=0, no-cache, must-revalidate, proxy-revalidate',
122            'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT',
123            'Content-Transfer-Encoding' => 'binary',
124        ]);
125    }
126
127    /**
128     * Gets a nested value from an array using dot notation.
129     *
130     * @param array<string, mixed> $data The data array to search in
131     * @param string $path The dot-notation path (e.g., 'category.name', 'user.profile.avatar')
132     * @return mixed The value at the path, or empty string if not found
133     */
134    private function getNestedValue(array $data, string $path): mixed
135    {
136        $keys = explode('.', $path);
137        $value = $data;
138
139        foreach ($keys as $key) {
140            if (!is_array($value) || !array_key_exists($key, $value)) {
141                return '';
142            }
143            $value = $value[$key];
144        }
145
146        return $value ?? '';
147    }
148
149    /**
150     * Hook to modify the export query before filters and sorting are applied.
151     *
152     * Override this method to add base conditions or eager loading
153     * for the export query.
154     *
155     * @param Builder $query The query builder instance
156     *
157     * @example
158     * ```php
159     * protected function modifyExportCsvQuery(Builder $query): void
160     * {
161     *     $query->with(['category', 'supplier'])
162     *           ->where('is_exportable', true);
163     * }
164     * ```
165     */
166    protected function modifyExportCsvQuery(Builder $query): void
167    {
168    }
169
170    /**
171     * Writes a CSV line with proper escaping for special characters.
172     *
173     * Handles commas and newlines by wrapping in quotes.
174     * Double quotes are replaced with single quotes for cleaner output.
175     *
176     * @param resource $handle The file handle to write to
177     * @param array<int, mixed> $fields The fields to write
178     */
179    private function writeCsvLine($handle, array $fields): void
180    {
181        $line = [];
182
183        foreach ($fields as $field) {
184            $field = (string) $field;
185
186            // Replace double quotes with single quotes for cleaner output
187            $field = str_replace('"', "'", $field);
188
189            if (str_contains($field, ',') || str_contains($field, "\n")) {
190                $line[] = '"' . $field . '"';
191                continue;
192            }
193
194            $line[] = $field;
195        }
196
197        fwrite($handle, implode(',', $line) . "\n");
198    }
199}