Back to blog

Multi-tenant CSV imports in Laravel

Manch Minasyan · · 13 min read

A CSV import that works perfectly in a single-tenant application can become a data breach the moment you add multi-tenancy. One missed where clause, one unscoped relationship lookup, and tenant A's import silently creates records inside tenant B's workspace. The data looks correct. The row counts match. Nobody notices until a customer calls asking why they can see contacts they never uploaded.

This happens because the import pipeline has more moving parts than a typical web request: file upload, column mapping, validation jobs, entity resolution, and row execution -- often spread across multiple queue workers. Each step needs to know which tenant it belongs to, and the default mechanisms for passing that context break the moment work moves to a background job.

This post covers the full problem and the patterns that solve it: serializing tenant context into jobs, scoping every database query in the pipeline, integrating with Filament's tenant system, and testing that none of it leaks.

#The problem: HTTP context does not survive the queue boundary

In a typical multi-tenant Laravel application, you know the current tenant during a web request. It comes from the authenticated user, a URL subdomain, or Filament's tenant system. Middleware sets it early, and everything downstream can rely on it being there.

CSV imports break this. The user uploads a file during a web request, but row processing happens in queued jobs -- possibly minutes later, on a different process. Laravel's queue workers do not inherit HTTP request context. There is no authenticated user, no session, no Filament tenant. The job runs in a clean slate.

Any code that calls auth()->user()->team_id or Filament::getTenant() inside a queued import job gets null. If your queries are not explicitly scoped, they run against the entire database. A Company::where('name', 'Acme Corp')->first() that should find tenant 7's "Acme Corp" instead finds tenant 3's -- whichever row the database returns first.

The fix requires three things:

  1. Capture the tenant ID before dispatching the job.
  2. Serialize it into the job payload so it survives the queue boundary.
  3. Restore it on the worker side before any database queries run.

#The TenantAware trait: serialize on dispatch, restore on handle

The pattern for preserving tenant context across queue jobs is a trait that hooks into the job's construction and execution. Here is the implementation:

namespace Tapix\Core\Jobs\Concerns;

use Tapix\Core\Services\TenantContextService;

trait TenantAware
{
    protected int|string|null $tenantId = null;

    public function withTenant(int|string|null $tenantId): static
    {
        $this->tenantId = $tenantId;

        return $this;
    }

    protected function initializeTenantAware(): void
    {
        if (config('tapix.tenant.enabled', false)) {
            $this->tenantId ??= TenantContextService::getCurrentTenantId();
        }
    }

    protected function restoreTenantContext(): void
    {
        if ($this->tenantId !== null) {
            TenantContextService::setTenantId($this->tenantId);
        }
    }
}

Three methods, each with a specific role:

initializeTenantAware() runs in the job's constructor -- during the HTTP request that dispatches the job. It captures the current tenant ID from whatever source is active (more on that priority chain below) and stores it as a property on the job instance. Because $tenantId is a protected property, serialization is handled explicitly by the trait's initializeTenantAware() and restoreTenantContext() methods rather than relying on Laravel's automatic public property serialization.

restoreTenantContext() runs at the start of the job's handle() method -- on the queue worker, potentially minutes later on a different server. It takes the serialized tenant ID and pushes it back into the TenantContextService singleton, making it available to every query that runs during job execution.

withTenant() is an explicit override. If you are dispatching a job from a context where the automatic detection does not work -- a scheduled command, a webhook handler, an artisan command -- you can set the tenant ID directly:

ExecuteImportJob::dispatch($import->id)
    ->withTenant($team->id);

The job class uses the trait alongside Laravel's standard queue traits:

final class ExecuteImportJob implements ShouldBeUnique, ShouldQueue
{
    use Batchable;
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;
    use TenantAware;

    public function __construct(
        private readonly string $importId,
    ) {
        $this->onQueue(config('tapix.queue', 'imports'));
        $this->initializeTenantAware();
    }

    public function handle(): void
    {
        $this->restoreTenantContext();

        // Everything below this line runs with tenant context restored.
        $import = Import::query()->findOrFail($this->importId);
        // ...
    }
}

The key insight is that initializeTenantAware() is called in the constructor, not in handle(). The constructor runs at dispatch time (inside the HTTP request), so it has access to the current tenant. By the time handle() runs on the worker, the tenant ID is already serialized into the job payload. restoreTenantContext() just reads it back.

This same trait applies to every job in the import pipeline: ValidateColumnJob, ResolveMatchesJob, and ExecuteImportJob. If any of them skips the trait, that stage of the pipeline runs unscoped.

#Relationship lookups must scope by tenant

Restoring tenant context in the job is necessary but not sufficient. Every database query inside the import pipeline must actually use that context. The most dangerous place to miss this is entity resolution -- the step where a CSV value like "Acme Corp" gets resolved to a database ID.

Consider a contacts table with a company_id foreign key. The CSV contains a "Company" column with company names. The import needs to find the matching company record and use its ID. In a multi-tenant system, the same company name can exist in multiple tenants. "Acme Corp" might be tenant 5's real company and tenant 12's test data.

An unscoped lookup:

$companyId = Company::where('name', $csvValue)->value('id');

This returns whichever "Acme Corp" the database finds first. If tenant 12's row has a lower primary key, tenant 5's import silently links contacts to the wrong company. No error. No exception. Just wrong data in production.

The EntityLinkResolver class handles this by accepting a tenant ID at construction and applying it to every query:

private function resolveViaColumn(
    EntityLink $link,
    string $field,
    array $uniqueValues,
): array {
    $modelClass = $link->targetModelClass;
    $tenantColumn = config('tapix.tenant.column', 'tenant_id');

    $query = $modelClass::query()
        ->whereIn($field, $uniqueValues);

    if ($this->tenantId !== null) {
        $query->where(function (Builder $q) use ($tenantColumn): void {
            if ($q->getModel()->getConnection()->getSchemaBuilder()
                ->hasColumn($q->getModel()->getTable(), $tenantColumn)
            ) {
                $q->where($tenantColumn, $this->tenantId);
            }
        });
    }

    return $query->pluck('id', $field)->all();
}

A few things to note about this implementation:

The tenant column is configurable. It reads from config('tapix.tenant.column'), defaulting to tenant_id. Some applications use team_id, organization_id, or workspace_id. The column name should not be hardcoded anywhere in the pipeline.

It checks whether the column exists. Not every related model has a tenant column. A countries table shared across all tenants should not be filtered by tenant. The hasColumn check prevents a "column not found" error on shared lookup tables. This is a runtime schema check, so it works regardless of which tables your application has.

It applies the scope conditionally. When $tenantId is null (tenancy disabled or running in a global context), the query runs unscoped. The same code path works for both single-tenant and multi-tenant deployments without any conditional logic at the call site.

The same scoping pattern appears in the ExecuteImportJob itself when preloading existing records for updates and when finding individual records by matched ID. Every where clause that touches tenant-owned data includes the tenant column.

#TenantContextService: one source of truth with a priority chain

The TenantContextService is the single class responsible for answering "which tenant are we operating as right now?" It resolves the tenant ID through a priority chain:

  1. Custom resolver closure -- highest priority. Set via TenantContextService::setTenantResolver(). This is the escape hatch for non-standard setups: subdomain resolution, custom headers, or application-specific logic.

  2. Laravel's Context facade -- the default storage. The service uses Context::addHidden(), so the tenant ID travels with the request but is not exposed in logs or debug output. Hidden context is the right choice -- you want it available everywhere but not accidentally dumped into error reports.

  3. Filament's tenant system -- the fallback. If no custom resolver is set and nothing is in the Context store, the service checks Filament::getTenant(). This makes multi-tenant Filament panels work without additional configuration.

The priority chain matters because different parts of the application set context at different times. During a Filament web request, the tenant comes from middleware. During a queued job, the Context facade holds the value restored by TenantAware. During an artisan command, you might set a custom resolver. The service returns whichever source has a value, in priority order.

The withTenant method provides temporary context switching for operations that need to run as a different tenant:

TenantContextService::withTenant($otherTenantId, function () {
    // All queries here run scoped to $otherTenantId.
    // Original tenant is restored after the closure returns.
});

This is useful in admin tools or migration scripts that process imports across tenants sequentially. The previous tenant ID is captured before the switch and restored in a finally block, so even exceptions do not leak context.

#Filament integration: bridging the panel to the pipeline

Filament v5's multi-tenant panels resolve the current tenant during web requests. But that resolution happens inside Filament's middleware stack and does not automatically propagate to the import pipeline. If you are evaluating where Filament's native import tooling ends and Tapix begins, Filament Import Action: when it's enough and when you need more covers that boundary.

The bridge is a single middleware:

final class SetTenantContextMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        if (config('tapix.tenant.enabled', false)) {
            TenantContextService::setFromFilamentTenant();
        }

        return $next($request);
    }
}

This runs after Filament's own tenant identification middleware, so Filament::getTenant() is guaranteed to be populated. From that point forward, any call to TenantContextService::getCurrentTenantId() gets the right answer -- including initializeTenantAware() in job constructors when the import is dispatched.

The plugin registers this middleware automatically when you enable tenancy:

// config/tapix.php
return [
    'tenant' => [
        'enabled' => true,
        'column' => 'team_id',
    ],
];

Two config keys control the entire integration. enabled toggles all tenant scoping. column tells the pipeline which database column holds the tenant foreign key. This is deliberately minimal -- for CSV imports the only questions that matter are "is tenancy on?" and "what is the column called?"

If you are not using Filament -- perhaps you have a custom Livewire dashboard or a standalone import route -- you can set the tenant context directly in your controller or middleware:

TenantContextService::setTenantId($request->user()->team_id);

// Or register a resolver that runs on every call:
TenantContextService::setTenantResolver(
    fn () => auth()->user()?->current_team_id
);

Both approaches feed into the same priority chain, so the rest of the pipeline (job dispatch, entity resolution, record creation) works identically regardless of the source.

#Auto-creating related records within tenant scope

Entity resolution is not just about finding existing records. When an import encounters a company name that does not exist, it often needs to create it. In a multi-tenant system, that creation must include the tenant ID, or the new record becomes an orphan visible to everyone (or no one).

The ExecuteImportJob handles this when resolving entity link relationships. If a relationship match has a Create or MatchOrCreate behavior and no existing record is found, it creates one:

$tenantColumn = config('tapix.tenant.column', 'tenant_id');

$record = new $link->targetModelClass;
$attributes = ['name' => $creationName];

if ($context['tenant_id'] !== null) {
    $attributes[$tenantColumn] = $context['tenant_id'];
}

if (isset($context['creator_id'])) {
    $attributes['creator_id'] = $context['creator_id'];
}

$record->forceFill($attributes);
$record->save();

The $context array carries both tenant_id and creator_id through the entire job execution. It is populated once at the top of handle() and passed to every method that touches the database. This avoids repeatedly calling TenantContextService::getCurrentTenantId() deep in nested call stacks and makes the tenant dependency explicit in method signatures.

The deduplication cache ($createdRecords) also operates within tenant scope implicitly. Because the job itself is tenant-scoped, all cache keys belong to the same tenant. If the CSV references "Acme Corp" on rows 5, 12, and 47, the company is created on row 5 and reused on 12 and 47. A different tenant's import on a different worker has its own job instance, its own cache, and its own context.

#Testing multi-tenant imports

Testing multi-tenant behavior requires asserting two things: that records are created within the correct tenant, and that records from other tenants are not visible or modifiable.

The pattern is straightforward. Create data for two tenants, run an import as one tenant, and verify isolation:

it('scopes entity resolution to the current tenant', function () {
    $teamA = Team::factory()->create();
    $teamB = Team::factory()->create();

    // Both tenants have a company named "Acme Corp"
    $companyA = Company::factory()->create([
        'name' => 'Acme Corp',
        'team_id' => $teamA->id,
    ]);
    $companyB = Company::factory()->create([
        'name' => 'Acme Corp',
        'team_id' => $teamB->id,
    ]);

    $import = Import::factory()
        ->forTeam($teamA)
        ->withRows([
            ['name' => 'Jane Doe', 'company' => 'Acme Corp'],
        ])
        ->create();

    TenantContextService::setTenantId($teamA->id);

    (new ExecuteImportJob($import->id))->handle();

    $contact = Contact::where('import_id', $import->id)->first();

    // Resolves to team A's company, not team B's
    expect($contact->company_id)->toBe($companyA->id);
});

it('does not leak data across tenants during update matching', function () {
    $teamA = Team::factory()->create();
    $teamB = Team::factory()->create();

    // Team B has a contact with matching email
    Contact::factory()->create([
        'email' => 'jane@example.com',
        'team_id' => $teamB->id,
    ]);

    $import = Import::factory()
        ->forTeam($teamA)
        ->withMatchBehavior('email', MatchBehavior::MatchOrCreate)
        ->withRows([
            ['name' => 'Jane Doe', 'email' => 'jane@example.com'],
        ])
        ->create();

    TenantContextService::setTenantId($teamA->id);

    (new ExecuteImportJob($import->id))->handle();

    // Should create a new record for team A, not update team B's
    expect(Contact::where('team_id', $teamA->id)->count())->toBe(1);
    expect(Contact::where('team_id', $teamB->id)->count())->toBe(1);
});

The critical assertion in the second test is that team B's contact is untouched. An unscoped match query would find team B's record by email and update it -- merging team A's data into team B's contact.

Set the tenant context explicitly with TenantContextService::setTenantId() in tests rather than relying on Filament middleware. The service is the source of truth, and calling it directly makes test intent clear.

#Checklist: auditing your import pipeline for tenant leaks

If you are adding multi-tenancy to an existing import system, work through these points:

Missing any one of these creates a cross-tenant leak. The leak is silent -- no exceptions, no errors, just wrong data. Automated tests are the only reliable way to catch them before your customers do.

#Further reading

This post focused on the multi-tenancy layer. For the broader context of how CSV imports work in Laravel:

If you are building multi-tenant imports and want the scoping, entity resolution, and queue context handled for you, Tapix ships with all of this built in -- two config keys and it works.

Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

Almost there — confirm your subscription via email.

Related posts