Skip to content

Last updated:

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.

php
// 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

php
// 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:

php
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}:

php
// 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

vue
<!-- 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

php
// 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 array of 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

CPR - Clinical Patient Records