sinemacula/laravel-resource-exporter
A Laravel package for exporting resource collections and application data to downloadable files. It provides a clean, configurable API for choosing fields and formats, reducing repetitive boilerplate and making reports, admin exports, and data handoff workflows easier to build.
This package turns Eloquent models/API Resources into downloadable or stored export files (CSV/XLSX/etc.), so the first job is to confirm the version you're pulling and wire the config into your app.
composer require sinemacula/laravel-resource-exporter:^3.1
php artisan vendor:publish --tag=resource-exporter-config
Pin ^3.1 rather than ^3.0 — Laravel 13 support landed in 3.1.0, and the minor bump is the only thing standing between you and a framework-version conflict on a fresh Laravel 13 install. There are no breaking changes in 3.1.0, so existing 3.0.x exports keep working unchanged.
Where to look first
config/resource-exporter.php): default disk, default format, chunk size, queue connection, temp-file handling. Most "why is my file empty / in the wrong place" issues are resolved here.First use case: export a filtered index listing to CSV. Keep the mapping in your existing UserResource so the export and the JSON API never drift apart:
// Typical shape — confirm the base class/namespace against the README.
class UserExporter extends ResourceExporter
{
protected function query(): Builder
{
return User::query()->with('team')->latest();
}
protected function resource(): string
{
return UserResource::class; // same resource your API returns
}
protected function filename(): string
{
return 'users-' . now()->format('Y-m-d-His') . '.csv';
}
}
Then expose it either as a streamed download (small sets) or a queued job writing to Storage (anything non-trivial). Start with the synchronous download on a few hundred rows to validate the mapping, then move to the queue once you trust the output.
Reuse API Resources as the column contract. The biggest long-term win is defining columns once, in Http/Resources, and letting the exporter render them. Header rows come from the resource keys, which means adding a field to the API automatically adds it to the export — and it means a failing export test is really an API-shape test.
Two delivery modes, chosen by row count.
// Small: stream straight to the browser.
return $exporter->download();
// Large: queue it, store it, hand back a signed URL.
ExportUsersJob::dispatch($filters, $request->user()->id);
Filters belong in a query object, not the exporter. Push request filtering into a dedicated builder method or query class and pass filters (arrays/scalars) into the job — never an Eloquent collection or a Builder instance. Serialising a builder into a queued job is the most common source of "works locally, fails on the worker" bugs.
final class ExportUsersJob implements ShouldQueue
{
use Queueable;
public function __construct(public array $filters, public int $userId) {}
public function handle(UserExporter $exporter): void
{
$path = $exporter->forFilters($this->filters)->store();
User::find($this->userId)?->notify(new ExportReady($path));
}
}
Chunk, don't collect. The package's chunk size config is the knob, but you still need to make sure the underlying query streams. Prefer chunkById()/lazyById() semantics over get(); a 200k-row export that passes CI with 500 seed rows will OOM in production.
Format strategy. CSV for machine handoff and re-import, XLSX for finance/ops users who will touch the numbers, PDF only for fixed-layout reporting. Don't build all three; pick the one the consumer actually opens.
Testing. Fake the disk and assert structure rather than bytes:
Storage::fake('exports');
Queue::fake();
$this->post('/exports/users')->assertOk();
Queue::assertPushed(ExportUsersJob::class);
// In the job test:
Storage::disk('exports')->assertExists($path);
$this->assertSame($expectedHeaders, $this->csvHeaders($path));
$this->assertCount(3, $this->csvRows($path));
Assert header names and order explicitly — that's what catches a resource change silently reordering a finance team's spreadsheet.
Authorization before querying. Exports frequently bypass the per-record scoping that your controllers apply, because the exporter builds its own query. Apply policies/whereBelongsTo($user->team) inside the exporter's query, not just on the route.
composer why-not before blaming the package for a resolution failure.config:cache, and re-run php artisan config:clear after changing the default disk. Stale config is the #1 cause of files appearing in unexpected locations.=, +, -, or @.; and comma decimals. If the package exposes a delimiter/locale option, set it per-format rather than post-processing the file.$timeout and $tries set explicitly on the job.storage/app/exports (or your configured disk) is writable by the worker, not just by you.How can I help you explore Laravel packages today?