Daravelv1.25.0
ENID GitHub

Excel and CSV

daravel_excel exports and imports spreadsheets with the concerns of Laravel Excel 3.1 (FromQuery, WithHeadings, ToModel, WithValidation, ...). A sheet is written as it is read: rows come from the database a chunk at a time and leave as deflated XLSX (or CSV) straight to the HTTP client or a disk, so an export of 500 000 rows uses the memory of an export of 10.

dartisan pub:add excel

pub:add adds the dependency and the commands; with dart pub add daravel_excel you add the commands yourself. No other setup is needed: Excel uses the application's database, storage and validation.

Exports#

import 'package:daravel_excel/daravel_excel.dart';

final class StudentsExport
    implements FromQuery, WithHeadings, WithMapping, WithColumnFormatting {
  StudentsExport(this.schoolId);
  final int schoolId;

  @override
  Object query() => Student.query()
      .where('school_id', schoolId)
      .with_(['classroom'])
      .orderBy('name');

  @override
  List<Object?> headings() => ['NISN', 'Name', 'Class', 'Average', 'Registered'];

  @override
  List<Object?> map(Object? row) {
    final student = row! as Student;
    return [
      student.nisn,
      student.name.toUpperCase(),
      student.classroom?.name ?? '-',
      student.averageScore,
      student.createdAt, // a DateTime becomes an Excel date
    ];
  }

  @override
  Map<String, String> columnFormats() => {'D': NumberFormat.number00};
}
// A streamed download (the type follows the extension)
router.get('/reports/students.xlsx', (Request request) =>
    Excel.download(StudentsExport(1), 'students-2026.xlsx'));

// A file on a disk, local or S3 (daravel_s3), streamed too
await Excel.store(StudentsExport(1), 'reports/students.xlsx', disk: 's3');

// Bytes or a stream
final bytes = await Excel.raw(StudentsExport(1), ExcelType.csv);
Concern
FromQuery a ModelQuery (models, with their eager loads per chunk) or a QueryBuilder (maps), read 1 000 rows at a time (WithCustomChunkSize)
FromCollection a list or any iterable
FromGenerator a Stream, consumed as the file is written
WithHeadings the first row (a list of lists for several)
WithMapping a source row to its cells (a list of lists for several rows)
WithTitle the sheet's name (made valid: 31 characters, no []:*?/\)
WithMultipleSheets one sheet per export in sheets()
WithColumnFormatting number formats by column, 'D' or 'B:D' (NumberFormat)
WithColumnWidths widths by column
WithCustomCsvSettings delimiter, enclosure, line ending, byte order mark

Without WithMapping, a model is written as its JSON values, a map as its values, a list as it is. Numbers, booleans and dates keep their type in XLSX (a DateTime at midnight is formatted as a date, otherwise as date and time); text is written inline, so no shared-strings table grows in memory. A CSV encloses every field, as PhpSpreadsheet's writer does, and holds the first sheet.

Unlike Laravel Excel there is no ShouldAutoSize: a column's widest value is only known once every row is written, and the file is streamed. Use WithColumnWidths.

Imports#

final class StudentsImport
    with WithHeadingRow, SkipsEmptyRows
    implements ToModel, WithValidation, WithBatchInserts {
  StudentsImport(this.schoolId);
  final int schoolId;

  @override
  Model? model(Map<String, Object?> row) => Student.definition.newInstance({
    'school_id': schoolId,
    'nisn': '${row['nisn']}',
    'name': row['full_name'],
    'average_score': row['average'],
  });

  @override
  Map<String, Object> rules() => {
    'nisn': 'required|unique:students,nisn',
    'full_name': 'required|string|max:150',
    'average': 'nullable|numeric|between:0,100',
  };

  @override
  int get batchSize => 500;
}
router.post('/students/import', (Request request) async {
  await Excel.import(StudentsImport(1), (await request.file('spreadsheet'))!);
  return {'message': 'Imported.'};
});

WithHeadingRow keys each row by the first row's cells, slugged with _ (Full Name → full_name); an empty heading is keyed by its column index. Without it rows are keyed '0', '1', .... The source is an UploadedFile, a path on a disk (Excel.import(import, 'in/file.xlsx', disk: 's3')) or bytes.

The import runs in one database transaction. When a row fails validation it throws ExcelValidationException, a ValidationException whose errors are keyed <row>.<attribute> with the row's number in the sheet, so the exception handler answers a Laravel-style 422 and nothing is saved:

{
  "message": "The email field must be a valid email address. (and 1 more error)",
  "errors": {
    "3.email": ["The email field must be a valid email address."],
    "3.average": ["The average field must be between 0 and 100."]
  }
}

e.failures lists Failure(row, attribute, errors, values), as Laravel Excel's failures(). With SkipsOnFailure (or the SkipsFailures mixin, which keeps them in failures()) failing rows are skipped and the others saved.

Concern
ToModel a row to a model; an unsaved one is saved, null skips the row
ToCollection all rows at once, or a chunk at a time with WithChunkReading
OnEachRow each row with its sheet row number (ExcelRow.index)
WithHeadingRow rows keyed by headings (headingRow() is 1 by default)
WithStartRow / WithLimit where to start, how many rows
WithValidation Daravel's rules per row ('*.email' is accepted too)
WithCustomValidationMessages / WithCustomValidationAttributes messages and attribute names
WithChunkReading rows handled chunkSize at a time
WithBatchInserts one INSERT per batchSize models (no model events)
SkipsEmptyRows rows whose cells are all empty
SkipsOnFailure / SkipsFailures skip failing rows instead of stopping
WithSheetSelection / WithMultipleSheetImports a sheet by index or name, or an import per sheet

Cells come as int, double, bool or String. A date cell is its Excel serial number, as Laravel Excel returns it: convert it with ExcelDate.toDateTime(row['date']! as num). A formula cell gives the value Excel computed when the file was saved. In a CSV, true and false (in any case) become booleans and numeric text becomes a number unless it has a leading zero (0123 stays text), as PhpSpreadsheet reads it, and the delimiter is detected among ,, ;, tab and |.

Excel.toArray(source, headingRow: true) returns every sheet's rows.

Commands#

// bootstrap/commands.dart
import 'package:daravel_excel/commands.dart';

final List<CommandFactory> commands = [...excelCommands];
dartisan make:export StudentsExport --model=Student   # lib/app/exports/
dartisan make:import StudentsImport --model=Student   # lib/app/imports/

Queues#

Exports are classes of your application, so a queued export is a job of yours that calls Excel.store (registered in bootstrap/jobs.dart like any job):

final class ExportStudents extends Job {
  const ExportStudents(this.schoolId);
  final int schoolId;

  factory ExportStudents.fromJson(Map<String, Object?> json) =>
      ExportStudents(json['school_id']! as int);

  @override
  String get jobName => 'export-students';

  @override
  Map<String, Object?> toJson() => {'school_id': schoolId};

  @override
  Duration get timeout => const Duration(minutes: 30);

  @override
  Future<void> handle(JobContext ctx) =>
      Excel.store(StudentsExport(schoolId), 'reports/$schoolId.xlsx', disk: 's3');
}

Memory#

bench/bin/excel_memory.dart writes ten columns of mixed types (an AOT build, Windows 11, Dart 3.13.4):

Rows Type File Time Peak memory
10 000 XLSX 0.5 MB 0.2 s 24 MB
100 000 XLSX 5.0 MB 1.9 s 24 MB
500 000 XLSX 25.1 MB 9.9 s 24 MB
500 000 CSV 82.4 MB 2.4 s 24 MB

The memory is the process's whole peak, the runtime included; it does not grow with the rows. Reading streams a sheet the same way; the shared strings of an XLSX (text that Excel stores once per workbook) are held in memory while the workbook is read.

Laravel Excel files#

bench/interop/excel.sh runs Laravel 12 with maatwebsite/excel 3.1 and Daravel: each reads the XLSX and CSV files the other writes, with text, numbers, booleans, empty cells and dates.

Laravel equivalents#

More of Laravel that this works with, and the API to look up on pub.dev.

Laravel Daravel API Note
php artisan make:export UsersExport --model=User dartisan make:export UsersExport --model=User MakeExportCommand