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

Owners and permissions

Every import runs for an owner — a team, a company, or the user in a single-tenant app — and every lookup and write stays inside it: natural-key matches, reference lookups, the records an import creates or updates, and what undo loads.

$imports = Imports::for($team)->by($user);

$imports->session($id);            // another owner's id → ImportNotFoundException (404)
$imports->batch($id);
$imports->sessions('assets', SessionStatus::Previewed)->paginate();   // newest first
$imports->running('assets');       // ?SessionHandle — committing or rolling back

Imports::session($id);             // trusted path: no owner check — never from HTTP code
Imports::batch($id);

Imports::session() and ::batch() without an owner are the trusted PHP path; HTTP code never uses them.

Ownership — declared, fail closed

use RoundlyConsulting\Imports\Definition\Ownership;

Ownership::column('team_id');           // where team_id = owner key; new records: $context->ownerAttributes()
Ownership::belongsTo('team');           // whereBelongsTo($owner, 'team')
Ownership::morph('owner');              // owner_type + owner_id
Ownership::using(
    query: fn (Builder $q, Model $owner) => $q->whereHas('project', fn ($p) => $p->whereBelongsTo($owner)),
    owns: fn (Model $record, Model $owner) => $record->project->team_id === $owner->getKey(),
);
Ownership::global();                    // the explicit opt-out
  • Ownership::inherit() is a reference field’s default — the importer’s rule.
  • Each rule offers attributes(Model $owner) — what ImportContext::ownerAttributes() returns: ['team_id' => 7] for a column, the <name>_type / <name>_id pair for a morph, [] otherwise — plus isGlobal() and describe().
  • The importer’s rule constrains natural-key matches and rollback loads, and after every create or update OwnerScope::owns() must accept the record, or the row fails as outside_owner.

OwnerScope

interface OwnerScope
{
    public function run(Model $owner, Closure $callback): mixed;                     // ambient context
    public function constrain(Builder $query, Model $owner, Ownership $rule): Builder;
    public function owns(Model $record, Model $owner, Ownership $rule): bool;
}

imports.owner_scope defaults to Scoping\ExplicitOwnerScope: no ambient context — run() just calls back — and it applies the declared rules (the column, whereBelongsTo, the morph pair, your using() closures, or nothing for global()). A using() closure is applied inside a nested where, and whatever a custom scope’s constrain() adds is grouped as one condition, so an orWhere in it can never widen a lookup. Every pipeline step that reads your data and every background entry point — the preview job, chunk and rollback jobs, the batch callbacks — runs inside run($owner).

Recipe: teams-for-laravel

teams-for-laravel is optional. With it, run every import inside the owner team:

use RoundlyConsulting\Imports\Contracts\OwnerScope;
use RoundlyConsulting\Imports\Definition\Ownership;
use RoundlyConsulting\Imports\Scoping\ExplicitOwnerScope;
use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Team;

final class TeamsOwnerScope implements OwnerScope
{
    public function run(Model $owner, Closure $callback): mixed
    {
        if (! $owner instanceof Team) {
            throw new InvalidArgumentException('Imports owned by teams need a team as their owner.');
        }

        return Teams::runAs($owner, static fn (): mixed => $callback());
    }

    public function constrain(Builder $query, Model $owner, Ownership $rule): Builder
    {
        return (new ExplicitOwnerScope)->constrain($query, $owner, $rule);
    }

    public function owns(Model $record, Model $owner, Ownership $rule): bool
    {
        return (new ExplicitOwnerScope)->owns($record, $owner, $rule);
    }
}
// config/imports.php
'owner_scope' => App\Imports\TeamsOwnerScope::class,
'routes' => ['owner_resolver' => App\Imports\CurrentTeamOwner::class],   // ResolvesImportOwner → the current team

Inside Teams::runAs() the teams package’s TeamScope and TeamWriteGuard isolate every Eloquent read and write, and the import owner’s team replaces whatever team a queued job’s dispatcher had. This exact class is tested against the real teams package.

Authorization

authorize(?Authenticatable $user, Model $owner, ImportOperation $operation) is required — fail closed. Every handle operation asks it for the handle’s actor: reads as View, writes as their own operation.

public function authorize(?Authenticatable $user, Model $owner, ImportOperation $operation): bool
{
    return match ($operation) {
        ImportOperation::View => $user?->can('view-imports', $owner) ?? false,
        ImportOperation::Upload, ImportOperation::Commit => $user?->can('import-assets', $owner) ?? false,
        ImportOperation::Rollback => $user?->can('undo-imports', $owner) ?? false,
    };
}

// or a Gate one-liner
return $user?->can('import-assets', $owner) ?? false;
  • Upload — at upload, before the file is read or stored, and for mapping, mode, source, preview and saved-mapping changes.
  • Commit — at commit and resume, and again when each run of the batch starts; a committer who lost access fails the batch (unauthorized).
  • Rollback — at undo.
  • View — every read: status, sheets, headers, counts, progress, preview data, rows, the mapping suggestion and the error report on a session; status, progress, summary, records, canRollback, the rollback report and the predicates on a batch; the session list, running(), saved mappings and importers()->available(). sessions() without an importer lists only importers whose authorize() allows View.

A null actor (no by(), or Imports::session() / ::batch()) is system code: your authorize() decides what it may do — there is no hidden bypass. The verdict is cached per handle instance and operation, so a polling loop on one handle asks once.

Audit recipes

Every import event carries an ImportAudit — session, batch, importer, owner, the actor of that operation, the file reference and the counts:

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Imports\Events\ImportCompleted;

Event::listen(fn (ImportCompleted $e) => AuditLog::create([
    'action' => 'import.completed', 'team_id' => $e->audit->owner->getKey(), 'user_id' => $e->audit->actor?->getKey(),
    'details' => ['importer' => $e->audit->importer, 'file' => $e->audit->file->originalName, 'created' => $e->summary->created],
]));

With sentinel-for-laravel (optional), seal the imported records from the ledger in an ImportCompleted listener ($batch->records(RecordAction::Created)) or record the event in your own sealed log.

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.