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

A ready-made JSON API for a JavaScript front end or a mobile app. It is off by default:

IMPORTS_ROUTES=on
  • Prefix imports.routes.prefix (api/imports), middleware imports.routes.middleware (api, auth), route names imports.*. Publish imports-routes to own the route file.
  • The owner of a request comes from imports.routes.owner_resolver, a ResolvesImportOwner (resolve(Request $request): ?Model) — by default the authenticated user. No owner → 403.
  • Every endpoint goes through Imports::for($owner)->by($request->user()), so another owner’s session or batch is a 404 and the importer’s authorize() decides the rest — every read asks for View.
  • FormRequests validate input and only validated data is used. Responses are translated in the request locale.
  • Both upload endpoints are throttled by the imports-upload rate limiter: imports.routes.upload_limit per minute (default 10, 0 = no limit), keyed by the authenticated user’s id, or the IP for a guest.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use RoundlyConsulting\Imports\Contracts\ResolvesImportOwner;

// A team-owned API: the owner is the user's current team.
final class CurrentTeamOwner implements ResolvesImportOwner
{
    public function resolve(Request $request): ?Model
    {
        return $request->user()?->currentTeam;
    }
}

// config/imports.php
'routes' => ['enabled' => 'on', 'owner_resolver' => App\Imports\CurrentTeamOwner::class],

Endpoints

Method + pathNameInput → response
GET /importersimporters.indexImporterResource[] the user may view
GET /importers/{importer}/templateimporters.templateformat (xlsx|csv), locale (≤ 16, [A-Za-z_-]) → a file download (asks View)
GET /importers/{importer}/mappingsimporters.mappingsSavedMappingResource[]
DELETE /importers/{importer}/mappings/{name}importers.mappings.destroy{"deleted": true} 200, or {"deleted": false} 404
POST /importers/{importer}/sessionssessions.storemultipart file (required), sheet (≤ 31) or sheet_index (1 … 1000), delimiter (, ; | tab), header_row (1 … 1000), locale, mode (skip|update|create_new) → ImportSessionResource, 201 · throttled
POST /importers/{importer}/file-sessionssessions.filesfiles[] (≥ 1 file), locale → ImportSessionResource, 201 · throttled
GET /sessionssessions.indeximporter (≤ 60), status[] (≤ 20 SessionStatus values), per_page (1 … 100, default 20) → paginated ImportSessionResources, newest first
GET /sessions/{session}sessions.showImportSessionResource
PATCH /sessions/{session}/sourcesessions.sourcesheet / sheet_index, delimiter, header_row → ImportSessionResource
DELETE /sessions/{session}sessions.destroy204 (discard)
GET /sessions/{session}/mappingsessions.mappingthe mapping suggestion
PUT /sessions/{session}/mappingsessions.mapmapping (header text or 0-based index → field or null) or columns ([{column, field}] — always by index) → ImportSessionResource
POST /sessions/{session}/mapping/savesessions.mapping.savename (1 … 60) → ImportSessionResource
PUT /sessions/{session}/modesessions.modemode → ImportSessionResource
POST /sessions/{session}/previewsessions.preview.runImportSessionResource, 202
GET /sessions/{session}/previewsessions.previewImportPreviewResource
GET /sessions/{session}/rowssessions.rowsstatus (invalid|duplicates|updates|creates|skips|all), per_page (1 … 100, default 50), page → paginated ImportRowResultResources
GET /sessions/{session}/error-reportsessions.error-reportformat (default xlsx), locale (the report’s labels) → a file download
POST /sessions/{session}/commitsessions.commitImportBatchResource, 202
GET /batches/{batch}batches.showImportBatchResource
POST /batches/{batch}/cancel · /resume · /rollbackbatches.cancel · batches.resume · batches.rollbackImportBatchResource, 202
GET /batches/{batch}/recordsbatches.recordsaction (created|linked|updated), per_page (1 … 100, default 50) → ledger rows

GET /sessions lists the owner’s sessions the user may view — an importer they may not view is 403, an unknown one 404. It eager-loads owner, actor and batch, and a client that lost its session id finds its open previews here.

# Authenticated as your api + auth middleware expects; the prefix is api/imports
curl -F [email protected]  https://app.test/api/imports/importers/assets/sessions -H 'Accept: application/json'
curl -X PUT  https://app.test/api/imports/sessions/12/mapping -H 'Content-Type: application/json' \
     -d '{"columns": [{"column": 0, "field": "asset_tag"}, {"column": 1, "field": "name"}]}'
curl -X POST https://app.test/api/imports/sessions/12/preview
curl         "https://app.test/api/imports/sessions/12/rows?status=invalid&per_page=50"
curl -X POST https://app.test/api/imports/sessions/12/commit
curl         https://app.test/api/imports/batches/5
curl -X POST https://app.test/api/imports/batches/5/rollback

Errors

StatusWhen
422Request validation; a bad mapping — {message, errors}, mapping errors keyed columns.<column> / fields.<field>. Also a rejected file, a missing sheet, a mode the importer lacks, a refused or over-limit saved mapping — {message}.
403authorize() refused, or no owner resolved.
404An unknown importer, another owner’s or a missing session or batch, or an id that cannot be a key of the package tables — checked before any query.
409Wrong status, a stale preview, an expired session, a pruned file, another import running, the undo window closed.

A session

{
  "id": 12, "importer": {"key": "assets", "label": "Assets"}, "kind": "rows",
  "status": "previewed", "status_label": "Previewed", "format": "xlsx",
  "file": {"name": "assets.xlsx", "size": 18234}, "sheet": "Assets",
  "sheets": [{"name": "Assets", "index": 1, "visible": true}], "delimiter": null, "header_row": 1,
  "headers": ["Asset tag", "Name", "Column C"],
  "mapping": [{"column": 0, "field": "asset_tag"}, {"column": 1, "field": "name"}],
  "mode": "skip", "data_rows": 50,
  "counts": {"total": 50, "valid": 47, "invalid": 2, "duplicates": 1, "duplicates_in_file": 0, "matched": 1,
             "creates": 47, "updates": 0, "skips": 1, "template_rows": 0, "blank_rows": 0},
  "progress": {"processed": 50, "total": 50, "percent": 100}, "errors": 2, "batch_id": null,
  "expires_at": "2026-11-10T09:00:00+00:00", "can": {"commit": true, "rollback": false},
  "owner": {"id": 7, "type": "Team"}, "actor": {"id": 3, "type": "User"}, "created_at": "…"
}

Never a disk, path or lock key. The can flags follow the rules the actions enforce: commit — previewed and not expired; rollback — the run finished with no undo yet or a failed one to retry, inside the undo window.

Other resources

  • ImportPreviewResource — counts, plan {creates, updates, skips}, update_notice (translated, null without updates), mode, unmapped_columns (0-based indexes).
  • ImportRowResultResource — row, status, outcome, label (file batches: the entry name), errors [{field, column (letter), code, message, value}], match (the record reference) and changes [{field, from, to}] for updates.
  • ImportBatchResource — id, session_id, status, progress {processed, total, percent, phase}, summary {created, linked, updated, skipped, failed, reclassified, duplicates, invalid, duration}, undo_until, can_rollback, rollback_status, rollback (null until a rollback finished or failed), actor.
  • ImporterResource — key, label, kind, fields [{name, label, type, required, options, example, help, aliases, format, in_template}], modes, default_mode, formats, extensions (file importers).
  • SavedMappingResource — name, importer, columns (normalised header → field), updated_at.

The mapping suggestion (GET /sessions/{session}/mapping):

{"data": {
  "columns": [{"index": 0, "header": "Inventárne č.", "field": "asset_tag", "confidence": 0.98, "source": "label", "fuzzy": false}],
  "unmapped_required": [], "ignored_columns": [3], "saved_mapping": null, "complete": true
}}

Ledger rows (GET /batches/{batch}/records):

{"data": [{"row": 2, "action": "created", "record": {"id": 41, "type": "Asset"}, "rolled_back": false, "rollback_outcome": null}],
 "meta": {"current_page": 1, "last_page": 1, "total": 47}}

Reference resources — no class names over the wire

RoleConfig keyDefaultReceives → output
ownerimports.resources.ownerImportOwnerResourceThe owner model → {id, type: class basename}.
actorimports.resources.actorImportActorResourceThe actor model → {id, type}.
recordimports.resources.recordImportRecordResourceA RecordReference — the stored morph pair, no query; a deleted record keeps rendering → {id, type}.
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class TeamReference extends JsonResource
{
    public function toArray(Request $request): array
    {
        return ['id' => $this->resource->getKey(), 'name' => $this->resource->name];
    }
}

// config/imports.php
'resources' => ['owner' => App\Http\Resources\TeamReference::class],

A value that is not an instantiable JsonResource subclass throws InvalidImportsConfigurationException naming the key; blank falls back to the default. When your resource reads a relation, eager-load it on lists.

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.