JSON API
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 + path | Name | Input → response |
|---|---|---|
GET /importers | importers.index | ImporterResource[] the user may view |
GET /importers/{importer}/template | importers.template | format (xlsx|csv), locale (≤ 16, [A-Za-z_-]) → a file download (asks View) |
GET /importers/{importer}/mappings | importers.mappings | SavedMappingResource[] |
DELETE /importers/{importer}/mappings/{name} | importers.mappings.destroy | {"deleted": true} 200, or {"deleted": false} 404 |
POST /importers/{importer}/sessions | sessions.store | multipart 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-sessions | sessions.files | files[] (≥ 1 file), locale → ImportSessionResource, 201 · throttled |
GET /sessions | sessions.index | importer (≤ 60), status[] (≤ 20 SessionStatus values), per_page (1 … 100, default 20) → paginated ImportSessionResources, newest first |
GET /sessions/{session} | sessions.show | ImportSessionResource |
PATCH /sessions/{session}/source | sessions.source | sheet / sheet_index, delimiter, header_row → ImportSessionResource |
DELETE /sessions/{session} | sessions.destroy | 204 (discard) |
GET /sessions/{session}/mapping | sessions.mapping | the mapping suggestion |
PUT /sessions/{session}/mapping | sessions.map | mapping (header text or 0-based index → field or null) or columns ([{column, field}] — always by index) → ImportSessionResource |
POST /sessions/{session}/mapping/save | sessions.mapping.save | name (1 … 60) → ImportSessionResource |
PUT /sessions/{session}/mode | sessions.mode | mode → ImportSessionResource |
POST /sessions/{session}/preview | sessions.preview.run | ImportSessionResource, 202 |
GET /sessions/{session}/preview | sessions.preview | ImportPreviewResource |
GET /sessions/{session}/rows | sessions.rows | status (invalid|duplicates|updates|creates|skips|all), per_page (1 … 100, default 50), page → paginated ImportRowResultResources |
GET /sessions/{session}/error-report | sessions.error-report | format (default xlsx), locale (the report’s labels) → a file download |
POST /sessions/{session}/commit | sessions.commit | ImportBatchResource, 202 |
GET /batches/{batch} | batches.show | ImportBatchResource |
POST /batches/{batch}/cancel · /resume · /rollback | batches.cancel · batches.resume · batches.rollback | ImportBatchResource, 202 |
GET /batches/{batch}/records | batches.records | action (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/rollbackErrors
| Status | When |
|---|---|
| 422 | Request 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}. |
| 403 | authorize() refused, or no owner resolved. |
| 404 | An 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. |
| 409 | Wrong 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
| Role | Config key | Default | Receives → output |
|---|---|---|---|
| owner | imports.resources.owner | ImportOwnerResource | The owner model → {id, type: class basename}. |
| actor | imports.resources.actor | ImportActorResource | The actor model → {id, type}. |
| record | imports.resources.record | ImportRecordResource | A 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 cryptoBy 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.