Multi-tenant CSV imports in Laravel | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 TutorialsMulti-tenant CSV imports in Laravel
===================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials)

Multi-tenant CSV imports in Laravel
===================================

 Manch Minasyan ·  May 19, 2026  · 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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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](/blog/filament-import-action-when-enough) 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 "Permalink")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 "Permalink")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 "Permalink")Checklist: auditing your import pipeline for tenant leaks
------------------------------------------------------------------------------------------------------------------------------------

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

- Every queued job in the pipeline uses the `TenantAware` trait and calls `initializeTenantAware()` in its constructor.
- Every job calls `restoreTenantContext()` as the first line of `handle()`.
- Entity resolution queries include a `where($tenantColumn, $tenantId)` clause.
- Auto-created related records include the tenant column in their attributes.
- Match/merge queries (finding existing records to update) scope by tenant.
- Preloaded record batches scope by tenant.
- Tests assert that a second tenant's data is not visible or modifiable during an import.

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 "Permalink")Further reading
-------------------------------------------------

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

- [The hidden cost of building your own CSV importer](/blog/hidden-cost-building-csv-importer) -- the "Multi-tenancy" section names exactly why this problem catches most DIY importers off guard.
- [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel) covers every approach from raw PHP to dedicated packages.
- [Importing relational data from CSV files in Laravel](/blog/importing-relational-data-csv-laravel) digs into BelongsTo lookups, MorphToMany tags, and the match-or-create decision.
- [Queue-powered CSV imports for 100k+ rows](/blog/queue-powered-imports-100k-rows) covers chunked batching, progress tracking, and failure recovery.

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.

  Email address   Subscribe

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   May 22, 2026

 Parsing numbers and currencies from CSV files in Laravel
----------------------------------------------------------

1,234.56 or 1.234,56? US or European? Here's how to parse numeric and currency values from CSV files without data corruption.

 ](https://tapix.dev/blog/csv-number-currency-parsing-laravel) [  Tutorials   May 12, 2026

 Auto-detecting CSV column types in Laravel
--------------------------------------------

From emails to currencies, dates to booleans -- how to infer column types from CSV sample values and configure format-specific parsing.

 ](https://tapix.dev/blog/auto-detecting-csv-column-types) [  Tutorials   May 1, 2026

 Queue-powered imports: processing 100K rows in Laravel
--------------------------------------------------------

Direct CSV processing breaks at scale. Here's how to use Laravel queues with chunked batches, unique jobs, and progress tracking for large imports.

 ](https://tapix.dev/blog/queue-powered-imports-100k-rows)

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev)

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import)

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service)

© 2026 Tapix. All rights reserved.
