NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

A FileImporter turns each uploaded file — or each entry of a ZIP — into one record, a document for example:

use RoundlyConsulting\Imports\Definition\{FileRules, Ownership};
use RoundlyConsulting\Imports\FileImporter;
use RoundlyConsulting\Imports\Rows\{ImportContext, ImportFile};

final class DocumentsImporter extends FileImporter
{
    public function key(): string { return 'documents'; }
    public function label(): string { return 'Documents'; }
    public function ownership(): Ownership { return Ownership::column('team_id'); }
    public function accepts(): FileRules { return FileRules::extensions('pdf', 'docx')->maxSize(20_480); }

    public function authorize(?Authenticatable $user, Model $owner, ImportOperation $operation): bool
    {
        return $user?->can('manage-documents', $owner) ?? false;
    }

    public function create(ImportFile $file, ImportContext $context): Model
    {
        return Document::create(['team_id' => $context->owner->getKey(), 'name' => $file->name, 'path' => $file->storeOn('documents')]);
    }
}

Imports::for($team)->by($user)->uploadFiles('documents', $request->file('files'))->preview()->commit();

Methods

MethodRequiredDefault
key() · label()nofrom the class name, as for Importer
ownership(): Ownership · authorize(?Authenticatable, Model $owner, ImportOperation): boolyes— (fail closed)
accepts(): FileRulesyes—
create(ImportFile $file, ImportContext $context): Modelyes—
duplicateCheck(): FileDuplicatesnoFileDuplicates::ledger()
rollback() · changeDetection() · checksumAttributes()noas for Importer

FileRules::extensions('pdf', 'docx') (lower-cased match) or FileRules::any(), then ->maxSize(int $kilobytes) (default imports.files.max_file_size); allows() and maxBytes() read it back. A file importer has no fields, no mapping and no modes — Imports::importer('documents')->modes() is [] and its descriptor lists extensions.

Preview and duplicates

  • Input: one or many uploads and/or ZIP archives (a .zip name with ZIP content); each file or entry is one row, up to files.max_files. Every entry is listed in the row results (label, size, SHA-256, status).
  • Per file: valid, invalid (extension_not_allowed, file_too_large, empty_file), a duplicate in the batch (duplicate_file) or of an earlier import of this owner and importer whose record is still there (file_imported_earlier).
  • A file whose record an undo kept still counts as imported; only an undo that removed the record (deleted, handled, missing) makes the file importable again.
  • Hashes are re-checked at commit. Commit and undo work as for rows — the row number is the entry index and the ledger keeps the content hash.
  • errorReport() lists the refused files: a File and an Errors column, one row per file.
use RoundlyConsulting\Imports\Definition\FileDuplicates;

public function duplicateCheck(): FileDuplicates
{
    return FileDuplicates::using(
        fn (string $sha256, Model $owner) => Document::whereBelongsTo($owner, 'team')->where('sha256', $sha256)->first(),
    );   // or FileDuplicates::ledger() (the default), FileDuplicates::none()
}

ImportFile

$file->index;                  // the entry index (the row number)
$file->name;                   // sanitised, metadata only
$file->extension; $file->size; $file->sha256;
$file->readStream();           // a resource
$file->contents();             // small files only
$file->storeOn('s3', 'documents');   // copies under a generated ULID name + extension, returns the path

Copies made by storeOn() are outside the commit transaction: a rolled-back chunk can leave one behind, so pair it with a rollback() hook or a cleanup if that matters.

ZIP safety

  • Checked before anything is extracted: opened read-only with a consistency check; the archive within files.max_archive_size; entries within files.max_files; the declared total within files.max_uncompressed; each entry within the importer’s size cap.
  • The compression ratio within files.max_ratio, per entry and overall, for anything over files.ratio_min_size.
  • Names: no absolute paths, no .. (also with backslashes), no control characters, at most 255 bytes; no symlinks; no encrypted entries. Directories and files.ignore entries are skipped; nested archives are plain files.
  • During extraction the real inflated bytes are counted per entry and in total — a header that lies is stopped mid-stream.
  • Entries are stored under generated names; no path from an archive ever touches the disk. Any violation rejects the whole upload and deletes what was staged.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.