How-To: Add an Excel Export Button
Add an Export to Excel action on an admin index page that respects the current search/sort filters.
We use spatie/simple-excel (already in composer.json). The reference implementation is the users export in app/Http/Controllers/Admin/UserController.php.
1. Add a service method that returns rows
Each row is an associative array. The keys become the column headers, so name them as they should appear in Excel.
// app/Services/User/AdminUserService.php
public function getExportRows(array $filters): array
{
$users = $this->repository->queryFiltered($filters)
->with(['roles', 'defaultBranch', 'branches'])
->get();
return $users->map(fn (User $user) => [
'Name' => $user->name,
'Username' => $user->username,
'Email' => $user->email,
'Employee ID' => $user->employee_id,
'Position' => $user->position,
'Phone' => $user->phone,
'Roles' => $user->roles->pluck('name')->join(', '),
'Default Branch' => $user->defaultBranch?->name,
'Branches' => $user->branches->pluck('name')->join(', '),
'Status' => $user->is_active ? 'Active' : 'Inactive',
])->all();
}Reuse the same filter logic as getIndexData() so the export and the table stay in sync.
2. Add an export controller method
// app/Http/Controllers/Admin/UserController.php
use Spatie\SimpleExcel\SimpleExcelWriter;
use Symfony\Component\HttpFoundation\StreamedResponse;
public function export(Request $request): StreamedResponse
{
$rows = $this->adminUserService->getExportRows([
'sort' => $request->input('sort', 'name'),
'direction' => $request->input('direction', 'asc'),
'search' => $request->input('search', ''),
]);
$filename = 'users-'.now()->format('Y-m-d').'.xlsx';
// SimpleExcelWriter for .xlsx needs a real file path — it cannot
// stream binary OOXML directly. Write to a temp file, then stream
// the bytes to the client and clean up.
$path = sys_get_temp_dir().'/'.uniqid('users-export-', true).'.xlsx';
SimpleExcelWriter::create($path)->addRows($rows)->close();
return response()->streamDownload(function () use ($path) {
readfile($path);
@unlink($path);
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}.xlsx is binary OOXML
Do not try to echo SimpleExcelWriter output directly into streamDownload. The writer compresses XML into a ZIP container and needs fclose()/unlink() boundaries. Always write to a temp file first.
For CSV, you can stream straight out:
return response()->streamDownload(function () use ($rows) {
$writer = SimpleExcelWriter::streamDownload('users.csv')->addRows($rows);
}, 'users.csv');3. Register the route
Add to the existing admin group, before the resource macro so it isn't captured by {user}:
// routes/web.php — admin group
Route::get('users/export', [UserController::class, 'export'])->name('users.export');
Route::resource('users', UserController::class);4. Add the button in the Vue page
<!-- resources/js/pages/admin/users/Index.vue -->
<a
v-if="$can('users.access')"
:href="route('admin.users.export', { search, sort, direction })"
class="btn btn-secondary"
>
Export to Excel
</a>Pass the current filters in the query string so the export matches what the user is looking at.
5. Test
// tests/Feature/Admin/UserExportTest.php
it('downloads users as xlsx respecting the search filter', function () {
$admin = User::factory()->create();
$admin->assignRole('admin');
User::factory()->create(['name' => 'Alice']);
User::factory()->create(['name' => 'Bob']);
$response = $this->actingAs($admin)
->get(route('admin.users.export', ['search' => 'Alice']));
$response->assertOk();
expect($response->headers->get('content-type'))
->toContain('officedocument.spreadsheetml.sheet');
});Checklist
- [ ] Service method returns
arrayof associative arrays - [ ] Headers are the array keys (human-readable)
- [ ] Route registered before the resource macro
- [ ] Filename includes a date stamp
- [ ] Feature test asserts 200 + Content-Type
- [ ] Button in the Vue page respects
$can()permission
