Before writing any code, confirm what you actually installed. The package name (whereof/think-csv) and the repository name (tp5er/think-csvs) disagree, which is common for forks/renames of small ThinkPHP packages, so the vendor directory may not be where you expect:
composer require whereof/think-csv
composer show whereof/think-csv # confirm the install path and version
ls vendor/whereof/think-csv/src # READ THIS before trusting the README
At 1 star and 0 dependents there is effectively no community documentation trail, so the source is the documentation. Read three files first, in this order:
src/Service.php) — tells you whether it registers a singleton, a config, or a facade alias.src/Facade/Csv.php) — tells you the public method surface.src/config/csv.php (if present) — delimiter, enclosure, charset, BOM, temp path.If the service is not auto-discovered (ThinkPHP 5.1 style), register it manually in config/app.php:
'services' => [
\Whereof\ThinkCsv\Service::class,
],
First use case: stream a modest export from a ThinkPHP model in a controller. The idiomatic shape for this family of packages is a fluent builder that accepts a collection/array of rows plus a header definition, then returns a download response:
// app/controller/User.php
public function export()
{
$users = \app\model\User::where('status', 1)->select();
return \Whereof\ThinkCsv\Facade\Csv::header(['ID', 'Name', 'Email', 'Created'])
->rows($users->toArray())
->download('users-' . date('Ymd') . '.csv');
}
Verify the exact method names against the facade — header/rows/download are the conventional trio, but a package this small may only expose create($data)->export(). Whatever the surface is, treat it as a thin wrapper over fputcsv and plan to bypass it for anything large (see below).
Keep the column mapping on the model, not the controller. The single biggest day-to-day win is making export columns a first-class property of the model so headers, ordering, and formatting do not drift between screens:
// app/model/User.php
class User extends Model
{
public static function csvColumns(): array
{
return [
'id' => 'ID',
'name' => 'Name',
'email' => 'Email',
'created_at' => 'Created',
];
}
}
Then a reusable exporter maps rows through that definition. Never feed toArray() straight into the writer — appended accessors, hidden-field leakage, and relation arrays all end up in the CSV.
Stream, don't hydrate, past a few thousand rows. The package-level API almost certainly materialises the whole dataset into memory. For real reports, skip it and write directly to php://output while chunking the query:
public function export()
{
$columns = User::csvColumns();
return \think\Response::create(function () use ($columns) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
fputcsv($out, array_values($columns), ',', '"', '\\');
User::where('status', 1)->chunk(1000, function ($users) use ($out, $columns) {
foreach ($users as $user) {
$row = [];
foreach (array_keys($columns) as $key) {
$row[] = $user->{$key};
}
fputcsv($out, $row, ',', '"', '\\');
}
});
fclose($out);
}, 200, ['Content-Type' => 'text/csv']);
}
Put exports behind a service class (app/export/UserExport.php) rather than a controller method. You get one place to change delimiters, one place to test, and the option of running the same code from a queue job or a CLI command (php think user:export) without duplication.
Queue the heavy ones. ThinkPHP's queue integration (think-queue) pairs well here: the job writes to runtime/export/{uuid}.csv and the browser polls a status endpoint, then downloads. This is the difference between a 3-second request and a 90-second one that times out behind Nginx.
Compose with the response object, not echo. Always return a think\Response (or use download()), so middleware, CORS headers, and request logging still apply.
Excel and UTF-8. Without the \xEF\xBB\xBF BOM, Chinese/accented characters render as mojibake the moment a user double-clicks the file. Given the package's Chinese-language origins, expect this to be either handled for you or a config flag — check before shipping.
Excel mangles long identifiers. Phone numbers, order IDs, and 18-digit national IDs become 1.23E+18. Prefix with a tab ("\t" . $value) or force text formatting.
CSV formula injection. Values beginning with =, +, -, or @ execute as formulas in Excel. Sanitise any user-supplied string column:
if (in_array($value[0] ?? '', ['=', '+', '-', '@'], true)) {
$value = "'" . $value;
}
fputcsv escape parameter. PHP 8.4 deprecates the default $escape. Pass it explicitly ('\\') now, or the package's internals will start emitting deprecation notices on upgrade even though your code is clean.
Vendor/namespace mismatch. If use Whereof\ThinkCsv\... fails, run composer dump-autoload and inspect vendor/composer/autoload_psr4.php — a renamed fork frequently leaves the PSR-4 prefix pointing at Tp5er\ThinkCsv.
Facade alias collisions. Registering Csv as a global class alias can clash with another exporter. Use the fully-qualified facade, or override the alias in config.
Config caching. In production ThinkPHP caches config/, so changes to csv.php need php think clear or a deploy restart.
Version pinning. With one star and zero dependents there is no ecosystem acting as a regression suite. Pin an exact version ("whereof/think-csv": "1.0.*" → exact) and, if the package is under ~300 lines, consider vendoring the writer into app/export/ — it removes supply-chain risk and lets you fix bugs yourself.
Testing. Assert on the streamed body rather than on the facade return value:
ob_start();
$response->send();
$csv = ob_get_clean();
$this->assertStringContainsString('ID,Name,Email', $csv);
Extension points worth knowing: the config file (delimiter/enclosure/charset/BOM/temp dir) is the safest customisation surface; if the package binds a writer class in the container, rebinding it lets you swap in your own implementation without forking. Anything beyond that — chunking, queueing, column mapping — is better handled in your own export layer than by patching a low-traffic dependency.
How can I help you explore Laravel packages today?