Search by

16bit / easy-multitenancy

zupolgec

Drop-in multitenancy for Laravel with a SQLite database per tenant

Package info

github.com/16bitsrl/easy-multitenancy

pkg:composer/16bit/easy-multitenancy

Fund package maintenance!

16bit

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 3

v1.1.0 2026-10-18 22:35 UTC

This package is auto-updated.

Last update: 2026-10-18 22:36:51 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

A simple, drop-in Laravel package for database-per-tenant multitenancy using SQLite. Perfect for SaaS applications where each tenant gets their own isolated SQLite database with automatic URL-based tenant identification and seamless database switching.

Requirements

  • PHP 8.4 or later
  • Laravel 13

Upgrading from 0.x? See UPGRADE.md and run php artisan tenant:upgrade-check.

Installation

You can install the package via composer:

composer require 16bit/easy-multitenancy

Publish the config file:

php artisan vendor:publish --tag="easy-multitenancy-config"

This is the contents of the published config file:

return [
    'database' => [
        'path' => env('TENANT_DB_PATH', database_path('tenants')),
        'connection' => env('TENANT_DB_CONNECTION', 'tenant'),
        'extension' => '.sqlite',
    ],

    // Optional central (landlord) connection, reachable even while a tenant is active.
    'central' => [
        'enabled' => env('TENANT_CENTRAL_ENABLED', false),
        'connection' => env('TENANT_CENTRAL_CONNECTION', 'central'),
    ],

    'cache' => [
        'prefix_enabled' => env('TENANT_CACHE_PREFIX', true),
    ],

    'session' => [
        'suffix_enabled' => env('TENANT_SESSION_SUFFIX', true),
        'use_tenant_db' => env('TENANT_SESSION_USE_DB', false),
    ],

    'storage' => [
        'suffix_enabled' => env('TENANT_STORAGE_SUFFIX', true),
        'path' => env('TENANT_STORAGE_PATH', 'tenants'),
        'disks' => ['local', 'public'],
    ],

    'track_recent_tenants' => env('TENANT_TRACK_RECENT', false),

    'recent_tenants' => [
        'cookie' => env('TENANT_RECENT_COOKIE', 'em_recent_tenants'),
        'max' => (int) env('TENANT_RECENT_MAX', 5),
        'lifetime' => (int) env('TENANT_RECENT_LIFETIME', 43200),
    ],

    'queue' => [
        'tenant_aware' => env('TENANT_QUEUE_AWARE', true),
        'strict_mode' => env('TENANT_QUEUE_STRICT', true),
        'debug_logging' => env('TENANT_QUEUE_DEBUG', false),
        'excluded_jobs' => [],
        'excluded_patterns' => [],
        'exclusion_interface' => \Bit16\EasyMultitenancy\Contracts\GlobalJob::class,
    ],

    'seeders' => [
        // Seeders to run when creating a new tenant
        'on_create' => [
            //  \Database\Seeders\DatabaseSeeder::class
        ],
    ],

    'routes' => [
        'parameter' => 'tenant',
        'middleware' => ['web'],
        'auto_prefix' => env('TENANT_AUTO_PREFIX_ROUTES', true),
        'excluded_routes' => [
            'home',
        ],
        'excluded_patterns' => [
            'up',
            'horizon*',
            'telescope*',
            'api/*',
            '_debugbar/*',
            '*.js',
            '*.css',
            '*.map',
        ],
    ],
];

Features

  • Database-per-tenant architecture using SQLite
  • Automatic route prefixing with tenant identification
  • Seamless database switching based on URL
  • Tenant-isolated storage, cache, and sessions
  • Automatic tenant injection for queued jobs
  • Artisan commands for tenant management
  • Events for tenant lifecycle hooks
  • Custom URL generator for tenant-aware routing

Usage

Creating a Tenant

# Interactive creation with prompts
php artisan tenant:create

# Create with specific name
php artisan tenant:create acme

# Create without user
php artisan tenant:create acme --no-user

Tenant identifiers are 2-63 characters of lowercase letters, numbers and single hyphens, not starting or ending with a hyphen (acme, acme-corp). Names that collide with paths the app already serves (api, up, horizon, central routes) are rejected.

Listing Tenants

php artisan tenant:list

Running Migrations

# Migrate specific tenant
php artisan tenant:migrate acme

# Migrate with fresh (drop all tables)
php artisan tenant:migrate acme --fresh

# Migrate and seed
php artisan tenant:migrate acme --seed

# Migrate all tenants
php artisan tenant:migrate-all

Seeding Databases

# Seed specific tenant
php artisan tenant:seed acme

# Seed with specific seeder class
php artisan tenant:seed acme --class=DatabaseSeeder

# Seed all tenants
php artisan tenant:seed-all

Running a Command in Every Tenant

# Every tenant
php artisan tenant:run "reports:send --daily"

# Only some tenants
php artisan tenant:run "reports:send --daily" --tenant=acme --tenant=globex

The command runs once per tenant, with that tenant's database, cache and disks. If it fails in a tenant, the others still run and tenant:run exits with a failure listing them. It also works from the scheduler:

Schedule::command('tenant:run "reports:send --daily"')->dailyAt('07:00');

Deleting and Renaming Tenants

# Asks for confirmation, keeps a copy of the database in database/tenants/backups
php artisan tenant:delete acme

# Also delete the tenant folder on the tenant-scoped disks
php artisan tenant:delete acme --files

# Rename the database and move the files
php artisan tenant:rename acme acme-corp

--force skips the confirmation and tenant:delete --no-backup skips the copy. After a rename the tenant's URLs change and its users have to log in again; let the queue drain first, since jobs queued for the old name fail.

Accessing Tenants in Code

The package automatically identifies tenants from the URL and switches the database context. All routes are automatically prefixed with {tenant} parameter.

The tenant is identified before the session starts, so every tenant gets its own session cookie and route model bindings resolve against the tenant database. The tenant parameter is removed from the route parameters once identified: controllers and closures receive only their own parameters (function (Post $post) on {tenant}/posts/{post}). Use Tenant::current() to read the tenant.

use Bit16\EasyMultitenancy\Facades\Tenant;

// Get current tenant
$currentTenant = Tenant::current(); // Returns tenant identifier (e.g., 'acme')

// Get current tenant ID (alias for current())
$tenantId = Tenant::id();

// Get current database path
$database = Tenant::database();

// Check if tenant exists
if (Tenant::exists('acme')) {
    // Tenant exists
}

// Get all tenants
$tenants = Tenant::all();

// Manually switch tenant (rarely needed)
Tenant::identify('acme');

// Forget current tenant context
Tenant::forget();

Events

The package dispatches several events you can listen to:

use Bit16\EasyMultitenancy\Events\TenantIdentified;
use Bit16\EasyMultitenancy\Events\TenantNotFound;
use Bit16\EasyMultitenancy\Events\DatabaseSwitched;

// Listen to tenant identified event
Event::listen(TenantIdentified::class, function ($event) {
    // $event->tenant
    // $event->database
});

// Listen to database switched event
Event::listen(DatabaseSwitched::class, function ($event) {
    // $event->tenant
    // $event->database
    // $event->connection
});

// Listen to tenant not found event
Event::listen(TenantNotFound::class, function ($event) {
    // $event->tenant
});

Central (Landlord) Connection

Some data is shared across all tenants (e.g. the list of tenants, global users, billing). Enable the optional central connection to keep the landlord database reachable even while a tenant connection is active:

// config/easy-multitenancy.php
'central' => [
    'enabled' => env('TENANT_CENTRAL_ENABLED', true),
    'connection' => env('TENANT_CENTRAL_CONNECTION', 'central'),
],

When enabled, a central connection is registered pointing at your application's default connection as configured at boot. Add the UsesCentralConnection trait to any model that must always query the central database, regardless of the current tenant:

use Bit16\EasyMultitenancy\Traits\UsesCentralConnection;
use Illuminate\Database\Eloquent\Model;

class Organization extends Model
{
    use UsesCentralConnection;
}

Storage note: when storage isolation is enabled, every disk listed in storage.disks is scoped to the current tenant: local disks get a tenants/{tenant} folder (public URLs follow), remote disks such as S3 a tenants/{tenant} object prefix. Add your remote disks to the list to isolate them too. The default disk setting is never changed, and disks not listed are shared.

Central Routes

Declare landlord routes that must never be tenant-prefixed (marketing pages, tenant sign-up, the landlord dashboard) with Tenant::centralRoutes():

use Bit16\EasyMultitenancy\Facades\Tenant;
use Illuminate\Support\Facades\Route;

Tenant::centralRoutes(function () {
    Route::get('/', [LandingController::class, 'index']);
    Route::get('/pricing', [PricingController::class, 'index']);
});

These routes keep their original URI (no {tenant}/ prefix), run on the default/central connection, and are skipped by tenant identification.

Recently Visited Tenants

Enable track_recent_tenants to keep a per-browser list of recently visited tenants in a shared cookie. Read it (typically from a central route) with Tenant::getRecentTenants():

// config/easy-multitenancy.php
'track_recent_tenants' => env('TENANT_TRACK_RECENT', true),

// Returns ['acme' => 1717000000, 'contoso' => 1716990000] (newest first)
$recent = Tenant::getRecentTenants();

Queued Jobs

When queue.tenant_aware is enabled (default), the current tenant is automatically injected into every queued job at dispatch and restored before the job runs — no trait required. Opt a job out of tenant context by implementing the GlobalJob interface, listing it under queue.excluded_jobs / queue.excluded_patterns, or setting a public $tenantAware = false property:

use Bit16\EasyMultitenancy\Contracts\GlobalJob;

class BackupAllTenants implements ShouldQueue, GlobalJob
{
    // Runs in the central context, without a tenant.
}

Exclusions also apply to queued mailables, notifications, listeners and broadcast events (match the mailable/notification/listener class). Jobs without a tenant always run in the central context, and the previous context is restored after each attempt, so a worker never carries a tenant over to the next job and a sync job never changes the tenant of the request that dispatched it.

With queue.strict_mode enabled (default), a job whose tenant no longer exists is marked as failed without running. With strict mode off, it runs in the central context.

Queues using the database driver always store jobs on the central connection, even when dispatched from a tenant.

Route Configuration

By default, all routes are automatically prefixed with the tenant parameter. You can exclude specific routes:

// In config/easy-multitenancy.php
'routes' => [
    'parameter' => 'tenant',
    'middleware' => ['web'],
    'auto_prefix' => env('TENANT_AUTO_PREFIX_ROUTES', true),
    'excluded_routes' => [
        'home',
    ],
    'excluded_patterns' => [
        'up',
        'horizon*',
        'telescope*',
        'api/*',
        '_debugbar/*',
        '*.js',
        '*.css',
        '*.map',
    ],
],

Generating URLs

The package includes a custom URL generator that automatically includes the tenant parameter:

// Generate URL to a route
url('/dashboard'); // Automatically becomes /{tenant}/dashboard

// Named routes
route('dashboard'); // Automatically includes tenant parameter

// Generate URL for a specific tenant
route('dashboard', ['tenant' => 'acme']);

// Central routes (Tenant::centralRoutes()) are never prefixed
route('pricing'); // /pricing
url('/pricing'); // /pricing

Inside a tenant, url('/') points at the tenant home; use route() to link the central home.

AI assistants (Laravel Boost)

The package ships a Laravel Boost guideline and two skills: easy-multitenancy (how to build features in a multi-tenant app) and easy-multitenancy-upgrade (upgrading from 0.x). Run php artisan boost:install (or boost:update) in your app to add them.

Translations

The default central home follows the application locale; English and Italian are included. Publish the translations to change the texts or add a language:

php artisan vendor:publish --tag="easy-multitenancy-translations"

Octane, Livewire and Redis

The tenant context, the session, the logged-in user and the tenant URL default are reset between requests, so the package works under Octane. Livewire update requests go through the tenant URL (/{tenant}/livewire-…/update) and run in the tenant. With Redis as cache or session store, keys are prefixed per tenant.

Testing Your Application

Add the trait to your TestCase:

use Bit16\EasyMultitenancy\Testing\InteractsWithTenants;

abstract class TestCase extends BaseTestCase
{
    use InteractsWithTenants;

    protected function setUp(): void
    {
        parent::setUp();

        $this->actingAsTenant('acme');
    }
}
it('lists the invoices of the tenant', function () {
    Invoice::factory()->create(['number' => 'A-1']);

    $this->get(route('invoices.index'))->assertSee('A-1');   // GET /acme/invoices
});
  • actingAsTenant('acme') creates the tenant when needed and makes it current: models use its database and route() includes /acme.
  • Tenant databases are created in a temporary folder and removed after each test, so your real tenants are never touched. They are copied from a template that is migrated, and seeded with seeders.on_create, once per test run.
  • Each request runs in the tenant of its own URL, and central URLs run without a tenant, as in production. After the request, the tenant the test acts as is current again.
  • createTenant('globex') adds more tenants, for example to check that data stays separate.

Testing

composer test

The Redis tests run only against a Redis server dedicated to them (they write and remove their own keys):

EASY_MULTITENANCY_TEST_REDIS=127.0.0.1:6379 composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Security Vulnerabilities

If you discover a security vulnerability, please email Mattia Trapani at mt@16bit.it.

Credits

License

The MIT License (MIT). Please see License File for more information.