├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── config └── multitenancy.php ├── migrations └── create_tenants_table.php.stub ├── phpunit.xml ├── src ├── Commands │ ├── AssignAdminPrivileges.php │ ├── InstallCommand.php │ ├── MigrationMakeCommand.php │ └── stubs │ │ └── add_tenancy_to_table.stub ├── Contracts │ └── Tenant.php ├── Exceptions │ ├── TenantDoesNotExist.php │ └── UnauthorizedException.php ├── Middleware │ ├── GuestTenantMiddleware.php │ └── TenantMiddleware.php ├── Models │ └── Tenant.php ├── Multitenancy.php ├── MultitenancyFacade.php ├── MultitenancyServiceProvider.php └── Traits │ ├── BelongsToTenant.php │ └── HasTenants.php └── tests ├── Databases └── MigrateDatabaseTest.php ├── Feature ├── BelongsToTenantTest.php ├── Commands │ ├── AssignAdminPrivilegesTest.php │ ├── InstallCommandTest.php │ └── MigrationMakeCommandTest.php ├── GateTest.php ├── HasTenantTest.php ├── Middleware │ ├── GuestMiddlewareTest.php │ └── TenantMiddlewareTest.php ├── MultitenancyTest.php └── TenantTest.php ├── Fixtures ├── Controllers │ ├── ProductController.php │ └── UserController.php ├── Policies │ └── ProductPolicy.php ├── Product.php └── User.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /vendor 3 | .phpunit.result.cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Romega Digital 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multitenancy Laravel Package 2 | 3 | [![Total Downloads](https://img.shields.io/packagist/dt/romegadigital/multitenancy.svg?style=flat-square)](https://packagist.org/packages/romegadigital/multitenancy) 4 | 5 | This package provides a convenient way to add multitenancy to your Laravel application. It manages models and relationships for Tenants, identifies incoming traffic by subdomain, and associates it with a corresponding tenant. Users not linked with a specific subdomain or without a matching tenant in the Tenant table are presented with a 403 error. 6 | 7 | **Note:** Any resources saved while accessing a scoped subdomain will automatically be saved against the current tenant, based on subdomain. 8 | 9 | **Note:** The `admin` subdomain is reserved for the package to remove all scopes from users with a `Super Administrator` role. 10 | 11 | ## Table of Contents 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [Console Commands](#console-commands) 15 | - [Nova Management](#managing-with-nova) 16 | - [Testing](#testing-package) 17 | 18 | ## Installation 19 | 20 | #### 1. Use composer to install the package: 21 | 22 | ``` bash 23 | composer require romegadigital/multitenancy 24 | ``` 25 | 26 | In Laravel 5.5 and newer, the service provider gets registered automatically. For older versions, add the service provider in the `config/app.php` file: 27 | 28 | ```php 29 | 'providers' => [ 30 | // ... 31 | RomegaDigital\Multitenancy\MultitenancyServiceProvider::class, 32 | ]; 33 | ``` 34 | 35 | #### 2. Publish the config file 36 | 37 | ```bash 38 | php artisan vendor:publish --provider="RomegaDigital\Multitenancy\MultitenancyServiceProvider" --tag="config" 39 | ``` 40 | 41 | #### 3. Run the setup 42 | 43 | ```bash 44 | php artisan multitenancy:install 45 | ``` 46 | 47 | This command will: 48 | - Publish and migrate required migrations 49 | - Add a `Super Administrator` role and `access admin` permission 50 | - Create an `admin` Tenant model 51 | 52 | 53 | #### 4. Update your `.env` file 54 | The package needs to know your base URL so it can determine what constitutes a tenant by the subdomain. 55 | 56 | Add this to your `.env` file: `MULTITENANCY_BASE_URL=` 57 | 58 | #### 5. Update your User model 59 | 60 | Apply the `RomegaDigital\Multitenancy\Traits\HasTenants` and `Spatie\Permission\Traits\HasRoles` traits to your User model(s): 61 | 62 | ```php 63 | use Spatie\Permission\Traits\HasRoles; 64 | use RomegaDigital\Multitenancy\Traits\HasTenants; 65 | use Illuminate\Foundation\Auth\User as Authenticatable; 66 | 67 | class User extends Authenticatable 68 | { 69 | use HasTenants, HasRoles; 70 | // ... 71 | } 72 | ``` 73 | 74 | 75 | ## Usage 76 | 77 | 78 | Tenants require a name to identify the tenant and a subdomain that is associated with that user. Example: 79 | 80 | `tenant1.example.com` 81 | 82 | `tenant2.example.com` 83 | 84 | 85 | **Note:** You define the base url `example.com` in the `config/multitenancy.php` file. 86 | 87 | 88 | These Tenants could be added to the database like so: 89 | 90 | ```php 91 | Tenant::create([ 92 | 'name' => 'An Identifying Name', 93 | 'domain' => 'tenant1' 94 | ]); 95 | Tenant::create([ 96 | 'name' => 'A Second Customer', 97 | 'domain' => 'tenant2' 98 | ]); 99 | ``` 100 | 101 | You can then attach user models to the Tenant: 102 | 103 | ```php 104 | $user = User::first(); 105 | Tenant::first()->users()->save($user); 106 | ``` 107 | 108 | Create Tenants, associate them with Users, and define access rules using provided Middleware. Check [the detailed usage guide](#detailed-usage-guide) for examples. 109 | 110 | 111 | ## Detailed Usage Guide 112 | ### 1. **Models and relationships:** 113 | Use Eloquent to access User's tenants (`User::tenants()->get()`) and Tenant's users (`Tenant::users()->get()`). Add new tenants and their associated users to the database. 114 | 115 | 116 | ### 2. **Middleware:** 117 | Add `TenantMiddleware` and `GuestTenantMiddleware` to your `app/Http/Kernel.php` file and apply them to routes. 118 | 119 | #### Tenant Middleware 120 | 121 | ```php 122 | protected $middlewareAliases = [ 123 | // ... 124 | 'tenant.auth' => \RomegaDigital\Multitenancy\Middleware\TenantMiddleware::class, 125 | ]; 126 | ``` 127 | 128 | Then you can bring multitenancy to your routes using middleware rules: 129 | 130 | ```php 131 | Route::group(['middleware' => ['tenant.auth']], function () { 132 | // ... 133 | }); 134 | ``` 135 | 136 | #### Guest Tenant Middleware 137 | 138 | This package comes with `GuestTenantMiddleware` middleware which applies the tenant scope to all models and can be used for allowing guest users to access Tenant related pages. You can add it inside your `app/Http/Kernel.php` file. 139 | 140 | ```php 141 | protected $middlewareAliases = [ 142 | // ... 143 | 'tenant.guest' => \RomegaDigital\Multitenancy\Middleware\GuestTenantMiddleware::class, 144 | ]; 145 | ``` 146 | 147 | Then you can bring multitenancy to your routes using middleware rules: 148 | 149 | ```php 150 | Route::group(['middleware' => ['tenant.guest']], function () { 151 | // ... 152 | }); 153 | ``` 154 | 155 | 156 | ### 3. **Tenant Assignment for Models:** 157 | Make models tenant-aware by adding a trait and migration. Then apply tenant scoping automatically. This allows users to access `tenant1.example.com` and return the data from `tenant1` only. 158 | 159 | For example, say you wanted Tenants to manage their own `Product`. In your `Product` model, add the `BelongsToTenant` trait. Then run the provided console command to add the necessary relationship column to your existing `products` table. 160 | 161 | ```php 162 | use Illuminate\Database\Eloquent\Model; 163 | use RomegaDigital\Multitenancy\Traits\BelongsToTenant; 164 | 165 | class Product extends Model 166 | { 167 | use BelongsToTenant; 168 | 169 | // ... 170 | } 171 | ``` 172 | **Add tenancy to a model's table:** `php artisan multitenancy:migration products` 173 | 174 | ### 4. **Access to Current Tenant:** 175 | Use `app('multitenancy')->currentTenant()` to get the current tenant model. 176 | 177 | ### 5. **Admin Domain Access:** 178 | Assign the `Super Administrator` role to a user to enable access to the `admin` subdomain. Manually create an admin portal if necessary. 179 | 180 | ### 6. **Auto-assign Users to Tenants:** 181 | Enable `ignore_tenant_on_user_creation` setting to automatically assign users to the Tenant subdomain on which they are created. 182 | 183 | ### 7. **Give a user `Super Administration` rights:** 184 | 185 | In order to access the `admin.example.com` subdomain, a user will need the `access admin` permission. This package relies on [Spatie's Laravel Permission](https://github.com/spatie/laravel-permission) package and is automatically included as a dependency when installing this package. We also provide a `Super Administrator` role on migration that has the relevant permission already associated with it. Assign the `Super Administrator` role to an admin user to provide the access they need. See the [Laravel Permission](https://github.com/spatie/laravel-permission) documentation for more on adding users to the appropriate role and permission. 186 | 187 | The Super Administrator is a special user role with privileged access. Users with this role can access all model resources, navigate across different tenants' domains, and gain entry to the `admin` subdomain where all tenant scopes are disabled. 188 | 189 | When a user is granted the `Super Administrator` role, they can freely access the `admin` subdomain. In this context, tenant scopes aren't applied. This privilege allows Super Administrators to manage data across all instances without requiring specific access to each individual tenant's account. 190 | 191 | Give a user `Super Administration` rights: `php artisan multitenancy:super-admin admin@example.com` 192 | 193 | ## Managing with Nova 194 | 195 | You can manage the resources of this package in Nova with the [MultitenancyNovaTool](https://github.com/romegadigital/MultitenancyNovaTool). 196 | 197 | ## Testing Package 198 | 199 | Run tests with the command: 200 | 201 | `php vendor/bin/testbench package:test` 202 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "romegadigital/multitenancy", 3 | "description": "Adds domain based multitenancy to Laravel applications.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Braden Keith", 8 | "email": "bkeith@romegadigital.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "RomegaDigital\\Multitenancy\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "RomegaDigital\\Multitenancy\\Tests\\": "tests/" 19 | } 20 | }, 21 | "require": { 22 | "spatie/laravel-permission": "^6.9.0" 23 | }, 24 | "extra": { 25 | "laravel": { 26 | "providers": [ 27 | "RomegaDigital\\Multitenancy\\MultitenancyServiceProvider" 28 | ] 29 | } 30 | }, 31 | "require-dev": { 32 | "orchestra/testbench": "^7", 33 | "nunomaduro/collision": "^6.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config/multitenancy.php: -------------------------------------------------------------------------------- 1 | \App\Models\User::class, 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Base URL 19 | |-------------------------------------------------------------------------- 20 | | 21 | | This is the URL you would like to serve as the base of your app. It 22 | | should not contain a scheme (ie: http://, https://). 23 | | By default, it will attempt to use the host name with the TLD and domain 24 | | name stripped. 25 | | 26 | | Default: null 27 | */ 28 | 29 | 'base_url' => env('MULTITENANCY_BASE_URL', null), 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Roles 34 | |-------------------------------------------------------------------------- 35 | | 36 | | The values (on the right) determine how the roles with 37 | | keys (on the left) are being named in the database. 38 | */ 39 | 40 | 'roles' => [ 41 | // What the Super Administrator is called in your app 42 | 'super_admin' => 'Super Administrator', 43 | ], 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Policy Files 48 | |-------------------------------------------------------------------------- 49 | | 50 | | The policy file to use when using [MultitenancyNovaTool] 51 | | (https://github.com/romegasoftware/MultitenancyNovaTool) 52 | */ 53 | 54 | 'policies' => [ 55 | 'role' => \RomegaDigital\MultitenancyNovaTool\Policies\RolePolicy::class, 56 | 'permission' => \RomegaDigital\MultitenancyNovaTool\Policies\PermissionPolicy::class, 57 | ], 58 | 59 | /* 60 | |-------------------------------------------------------------------------- 61 | | Nova Resource Files 62 | |-------------------------------------------------------------------------- 63 | | 64 | | The Nova resources to use when using [MultitenancyNovaTool] 65 | | (https://github.com/romegasoftware/MultitenancyNovaTool) 66 | */ 67 | 68 | 'resources' => [ 69 | 'role' => \Vyuldashev\NovaPermission\Role::class, 70 | 'permission' => \Vyuldashev\NovaPermission\Permission::class, 71 | ], 72 | 73 | /* 74 | |-------------------------------------------------------------------------- 75 | | Tenant Model 76 | |-------------------------------------------------------------------------- 77 | | 78 | | This is the model you are using for Tenants that will be attached to the 79 | | User instance. It would be recommended to extend the Tenant model as 80 | | defined in the package, but if you replace it, be sure to implement 81 | | the RomegaDigital\Multitenancy\Contracts\Tenant contract. 82 | */ 83 | 84 | 'tenant_model' => \RomegaDigital\Multitenancy\Models\Tenant::class, 85 | 86 | 'table_names' => [ 87 | /** 88 | * We need to know which table to setup foreign relationships on. 89 | */ 90 | 91 | 'users' => 'users', 92 | 93 | /** 94 | * If overwriting `tenant_model`, you may also wish to define a new table 95 | */ 96 | 97 | 'tenants' => 'tenants', 98 | 99 | /** 100 | * Define the relationship table for the belongsToMany relationship 101 | */ 102 | 103 | 'tenant_user' => 'tenant_user', 104 | ], 105 | 106 | /* 107 | |-------------------------------------------------------------------------- 108 | | Redirect Route 109 | |-------------------------------------------------------------------------- 110 | | 111 | | This is the name of the route users who aren't logged in will be redirected to 112 | */ 113 | 114 | 'redirect_route' => 'login', 115 | 116 | /* 117 | |-------------------------------------------------------------------------- 118 | | Ignore Tenant on User creation 119 | |-------------------------------------------------------------------------- 120 | | 121 | | By default a user is assigned the tenant it is created on. If you create 122 | | a user while being on the `admin` tenant, this would assign the created 123 | | user the `admin` tenant automatically. If you don't want to get tenants 124 | | assigned to users automatically simply disable this setting by setting 125 | | it to false. 126 | */ 127 | 128 | 'ignore_tenant_on_user_creation' => false, 129 | ]; 130 | -------------------------------------------------------------------------------- /migrations/create_tenants_table.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 21 | $table->string('name')->unique(); 22 | $table->string('domain')->unique(); 23 | $table->softDeletes(); 24 | $table->timestamps(); 25 | }); 26 | 27 | Schema::create($tableNames['tenant_user'], function (Blueprint $table) use ($tableNames) { 28 | $table->bigIncrements('id'); 29 | 30 | $table->unsignedBigInteger('tenant_id'); 31 | $table->foreign(Str::singular($tableNames['tenants']).'_id') 32 | ->references('id') 33 | ->on($tableNames['tenants']) 34 | ->onDelete('cascade'); 35 | 36 | $table->unsignedBigInteger('user_id'); 37 | $table->foreign(Str::singular($tableNames['users']).'_id') 38 | ->references('id') 39 | ->on($tableNames['users']) 40 | ->onDelete('cascade'); 41 | 42 | $table->timestamps(); 43 | $table->softDeletes(); 44 | }); 45 | } 46 | 47 | /** 48 | * Reverse the migrations. 49 | * 50 | * @return void 51 | */ 52 | public function down() 53 | { 54 | $tableNames = config('multitenancy.table_names'); 55 | 56 | Schema::table($tableNames['tenant_user'], function (Blueprint $table) use ($tableNames) { 57 | $table->dropForeign([Str::singular($tableNames['tenants']).'_id']); 58 | $table->dropForeign([Str::singular($tableNames['users']).'_id']); 59 | }); 60 | 61 | Schema::dropIfExists($tableNames['tenants']); 62 | Schema::dropIfExists($tableNames['tenant_user']); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Commands/AssignAdminPrivileges.php: -------------------------------------------------------------------------------- 1 | multitenancy = $multitenancy; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return mixed 53 | */ 54 | public function handle() 55 | { 56 | $column = $this->option('column'); 57 | $userModel = $this->option('model'); 58 | $identifier = $this->argument('identifier'); 59 | 60 | if (!class_exists($userModel)) { 61 | return $this->error('User model ' . $userModel . ' can not be found!'); 62 | } 63 | 64 | if (!$user = $this->getUser($userModel, $column, $identifier)) { 65 | return 0; 66 | } 67 | 68 | if (!$adminRole = $this->getAdminRole()) { 69 | return 0; 70 | } 71 | 72 | if (!$adminTenant = $this->getAdminTenant()) { 73 | return 0; 74 | } 75 | 76 | $user->assignRole($adminRole); 77 | $user->tenants()->save($adminTenant); 78 | 79 | $this->info('User with ' . $column . ' ' . $user->{$column} . ' granted Super-Administration rights.'); 80 | 81 | return 1; 82 | } 83 | 84 | /** 85 | * Get user model data. 86 | * 87 | * @param string $userModel 88 | * @param string $column 89 | * @param string $identifier 90 | * 91 | * @return Illuminate\Database\Eloquent\Model 92 | */ 93 | protected function getUser($userModel, $column, $identifier) 94 | { 95 | if (!$user = $userModel::where($column, $identifier)->first()) { 96 | return $this->modelNotFound('User', $column, $identifier); 97 | } 98 | 99 | return $user; 100 | } 101 | 102 | /** 103 | * Get admin role. 104 | * 105 | * @return Spatie\Permission\Contracts\Role 106 | */ 107 | protected function getAdminRole() 108 | { 109 | try { 110 | return Role::findByName(config('multitenancy.roles.super_admin')); 111 | } catch (RoleDoesNotExist $exception) { 112 | return $this->cancel('Role', 'name', config('multitenancy.roles.super_admin')); 113 | } 114 | } 115 | 116 | /** 117 | * Get admin tenant. 118 | * 119 | * @return RomegaDigital\Multitenancy\Contracts\Tenant 120 | */ 121 | protected function getAdminTenant() 122 | { 123 | try { 124 | return $this->multitenancy->getTenantClass()::findByDomain('admin'); 125 | } catch (TenantDoesNotExist $exception) { 126 | return $this->cancel('Tenant', 'domain', 'admin'); 127 | } 128 | } 129 | 130 | /** 131 | * Cancel the command due to errors. 132 | * 133 | * @param Illuminate\Database\Eloquent\Model $model 134 | * @param string $column 135 | * @param string $identifier 136 | * 137 | * @return bool 138 | */ 139 | protected function cancel($model, $column, $identifier) 140 | { 141 | $this->modelNotFound($model, $column, $identifier); 142 | $this->line(''); 143 | $this->alert('Did you already run `multitenancy:install` command?'); 144 | 145 | return false; 146 | } 147 | 148 | /** 149 | * Write an error for a model which can not be found. 150 | * 151 | * @param Illuminate\Database\Eloquent\Model $model 152 | * @param string $column 153 | * @param string $identifier 154 | * 155 | * @return void 156 | */ 157 | protected function modelNotFound($model, $column, $identifier) 158 | { 159 | $this->error("$model with $column `$identifier` can not be found!"); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | multitenancy = $multitenancy; 44 | } 45 | 46 | /** 47 | * Execute the console command. 48 | * 49 | * @return mixed 50 | */ 51 | public function handle() 52 | { 53 | $this->option('migrations') ?? $this->handleMigrations(); 54 | $this->option('roles') ?? $this->addSuperAdminRole(); 55 | $this->option('tenant') ?? $this->addAdminTenant(); 56 | 57 | return 1; 58 | } 59 | 60 | /** 61 | * Publishes and migrates required migrations. 62 | * 63 | * @return void 64 | */ 65 | protected function handleMigrations() 66 | { 67 | $this->info('Publishing required migrations...'); 68 | 69 | $this->callSilent('vendor:publish', [ 70 | '--provider' => 'Spatie\Permission\PermissionServiceProvider', 71 | '--tag' => ['permission-migrations'], 72 | ]); 73 | 74 | $this->callSilent('vendor:publish', [ 75 | '--provider' => 'RomegaDigital\Multitenancy\MultitenancyServiceProvider', 76 | '--tag' => ['migrations'], 77 | ]); 78 | 79 | $this->info('Migrations published!'); 80 | 81 | $this->line(''); 82 | $this->call('migrate'); 83 | $this->line(''); 84 | } 85 | 86 | /** 87 | * Creates a super admin role and 'access admin' 88 | * permission. 89 | * 90 | * @return void 91 | */ 92 | protected function addSuperAdminRole() 93 | { 94 | $this->info('Adding `Super Administrator` Role...'); 95 | 96 | $this->call('permission:create-role', [ 97 | 'name' => config('multitenancy.roles.super_admin'), 98 | 'permissions' => 'access admin', 99 | ]); 100 | 101 | $this->line(''); 102 | } 103 | 104 | /** 105 | * Creates the admin tenant model. 106 | * 107 | * @return void 108 | */ 109 | protected function addAdminTenant() 110 | { 111 | $this->info('Adding `admin` domain...'); 112 | 113 | $this->multitenancy->getTenantClass()::updateOrCreate([ 114 | 'name' => 'Admin Portal', 115 | 'domain' => 'admin', 116 | ]); 117 | 118 | $this->info('Admin domain added successfully!'); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Commands/MigrationMakeCommand.php: -------------------------------------------------------------------------------- 1 | composer = $composer; 53 | } 54 | 55 | /** 56 | * Execute the console command. 57 | * 58 | * @return mixed 59 | */ 60 | public function handle() 61 | { 62 | parent::handle(); 63 | 64 | $this->composer->dumpAutoloads(); 65 | 66 | $this->info('Multitenancy migration created successfully.'); 67 | 68 | return 1; 69 | } 70 | 71 | /** 72 | * Get the stub file for the generator. 73 | * 74 | * @return string 75 | */ 76 | protected function getStub() 77 | { 78 | return __DIR__.'/stubs/add_tenancy_to_table.stub'; 79 | } 80 | 81 | /** 82 | * Build the class with the given name. 83 | * 84 | * @param string $name 85 | * 86 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException 87 | * 88 | * @return string 89 | */ 90 | protected function buildClass($name) 91 | { 92 | $stub = parent::buildClass($name); 93 | 94 | return str_replace( 95 | ['DummyTable', 'DummyTenantTable'], 96 | [lcfirst($this->getNameInput()), config('multitenancy.table_names.tenants')], 97 | $stub 98 | ); 99 | } 100 | 101 | /** 102 | * Replace the class name for the given stub. 103 | * 104 | * @param string $stub 105 | * @param string $name 106 | * 107 | * @return string 108 | */ 109 | protected function replaceClass($stub, $name) 110 | { 111 | $class = 'AddTenantIDColumnTo'.Str::studly($this->getNameInput()).'Table'; 112 | 113 | return str_replace('DummyClass', $class, $stub); 114 | } 115 | 116 | /** 117 | * Get the destination class path. 118 | * 119 | * @param string $name 120 | * 121 | * @return string 122 | */ 123 | protected function getPath($name) 124 | { 125 | $timestamp = date('Y_m_d_His'); 126 | $table = lcfirst($this->getNameInput()); 127 | 128 | return $this->laravel->databasePath()."/migrations/{$timestamp}_add_tenant_id_column_to_{$table}_table.php"; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Commands/stubs/add_tenancy_to_table.stub: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('tenant_id')->nullable(); 18 | $table->foreign('tenant_id') 19 | ->references('id') 20 | ->on("DummyTenantTable") 21 | ->onDelete('cascade'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::table("DummyTable", function (Blueprint $table) { 33 | $table->dropColumn('tenant_id'); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Contracts/Tenant.php: -------------------------------------------------------------------------------- 1 | multitenancy = $multitenancy; 24 | } 25 | 26 | /** 27 | * Handle an incoming request. 28 | * 29 | * @param \Illuminate\Http\Request $request 30 | * @param \Closure $next 31 | * @param string|null $guard 32 | * 33 | * @return mixed 34 | */ 35 | public function handle($request, Closure $next) 36 | { 37 | $tenant = $this->multitenancy->receiveTenantFromRequest(); 38 | 39 | $this->multitenancy->setTenant($tenant)->applyTenantScopeToDeferredModels(); 40 | 41 | return $next($request); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Middleware/TenantMiddleware.php: -------------------------------------------------------------------------------- 1 | multitenancy = $multitenancy; 30 | } 31 | 32 | /** 33 | * Get the path the user should be redirected to when they are not authenticated. 34 | * 35 | * @param \Illuminate\Http\Request $request 36 | * 37 | * @return string 38 | */ 39 | protected function redirectTo($request) 40 | { 41 | if (! $request->expectsJson()) { 42 | return route(config('multitenancy.redirect_route')); 43 | } 44 | } 45 | 46 | /** 47 | * Handle an incoming request. 48 | * 49 | * @param \Illuminate\Http\Request $request 50 | * @param \Closure $next 51 | * @param string[] ...$guards 52 | * 53 | * @throws \RomegaDigital\Multitenancy\Exceptions\UnauthorizedException|\Illuminate\Auth\AuthenticationException 54 | * 55 | * @return mixed 56 | */ 57 | public function handle($request, Closure $next, ...$guards) 58 | { 59 | $this->authenticate($request, $guards); 60 | 61 | $tenant = $this->multitenancy->receiveTenantFromRequest(); 62 | 63 | if (! $this->authorizedToAccessTenant($tenant)) { 64 | throw UnauthorizedException::forDomain($tenant->domain); 65 | } 66 | 67 | $this->multitenancy->setTenant($tenant)->applyTenantScopeToDeferredModels(); 68 | 69 | $request->merge([Multitenancy::TENANT_SET_HEADER => true]); 70 | 71 | return $next($request); 72 | } 73 | 74 | /** 75 | * Check if user is authorized to access tenant's domain. 76 | * 77 | * @param \RomegaDigital\Multitenancy\Contracts\Tenant $tenant 78 | * 79 | * @return bool 80 | */ 81 | protected function authorizedToAccessTenant(Tenant $tenant) 82 | { 83 | return $tenant && $tenant->users->contains(auth()->user()->id); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Models/Tenant.php: -------------------------------------------------------------------------------- 1 | setTable(config('multitenancy.table_names.tenants')); 34 | } 35 | 36 | /** 37 | * A Tenant belongs to many users. 38 | * 39 | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany 40 | */ 41 | public function users(): BelongsToMany 42 | { 43 | return $this->belongsToMany(config('multitenancy.user_model')) 44 | ->withTimestamps(); 45 | } 46 | 47 | /** 48 | * Find a Tenant by its domain. 49 | * 50 | * @param string $domain 51 | * 52 | * @throws \RomegaDigital\Multitenancy\Exceptions\TenantDoesNotExist 53 | * 54 | * @return \RomegaDigital\Multitenancy\Contracts\Tenant 55 | */ 56 | public static function findByDomain(string $domain): TenantContract 57 | { 58 | $tenant = static::where(['domain' => $domain])->first(); 59 | 60 | if (! $tenant) { 61 | throw TenantDoesNotExist::forDomain($domain); 62 | } 63 | 64 | return $tenant; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Multitenancy.php: -------------------------------------------------------------------------------- 1 | tenantClass = config('multitenancy.tenant_model'); 41 | $this->deferredModels = collect(); 42 | } 43 | 44 | /** 45 | * Sets the Tenant to a Tenant Model. 46 | * 47 | * @param RomegaDigital\Multitenancy\Contracts\Tenant $tenant 48 | * 49 | * @return $this 50 | */ 51 | public function setTenant(Tenant $tenant) 52 | { 53 | $this->tenant = $tenant; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * Returns the current Tenant. 60 | * 61 | * @return \RomegaDigital\Multitenancy\Contracts\Tenant 62 | */ 63 | public function currentTenant(): Tenant 64 | { 65 | return $this->tenant ?? $this->receiveTenantFromRequest(); 66 | } 67 | 68 | /** 69 | * Applies applicable tenant scopes to model or if not booted yet 70 | * store for deferment. 71 | * 72 | * @param Illuminate\Database\Eloquent\Model $model 73 | * 74 | * @return void|null 75 | */ 76 | public function applyTenantScope(Model $model) 77 | { 78 | if (is_null($this->tenant)) { 79 | $this->deferredModels->push($model); 80 | 81 | return; 82 | } 83 | 84 | if ('admin' === $this->tenant->domain) { 85 | return; 86 | } 87 | 88 | $model->addGlobalScope('tenant', function (Builder $builder) use ($model) { 89 | $builder->where($model->qualifyColumn('tenant_id'), '=', $this->tenant->id); 90 | }); 91 | } 92 | 93 | /** 94 | * Applies applicable tenant id to model on create. 95 | * 96 | * @param Illuminate\Database\Eloquent\Model $model 97 | * 98 | * @return void|null 99 | */ 100 | public function newModel(Model $model) 101 | { 102 | if (is_null($this->tenant)) { 103 | $this->deferredModels->push($model); 104 | 105 | return; 106 | } 107 | 108 | if (! isset($model->tenant_id)) { 109 | $model->setAttribute('tenant_id', $this->tenant->id); 110 | } 111 | } 112 | 113 | /** 114 | * Applies applicable tenant scope to deferred model booted 115 | * before tenants setup. 116 | */ 117 | public function applyTenantScopeToDeferredModels() 118 | { 119 | $this->deferredModels->each(function ($model) { 120 | $this->applyTenantScope($model); 121 | }); 122 | 123 | $this->deferredModels = collect(); 124 | } 125 | 126 | /** 127 | * Get an instance of the tenant class. 128 | * 129 | * @return \RomegaDigital\Multitenancy\Contracts\Tenant 130 | */ 131 | public function getTenantClass(): Tenant 132 | { 133 | return app($this->tenantClass); 134 | } 135 | 136 | /** 137 | * Determines how best to process the URL based 138 | * on config and then returns the appropriate 139 | * subdomain text. 140 | * 141 | * @return string 142 | */ 143 | public function getCurrentSubDomain(): string 144 | { 145 | $baseURL = config('multitenancy.base_url'); 146 | 147 | if (null != $baseURL) { 148 | return $this->getSubDomainBasedOnBaseURL($baseURL); 149 | } else { 150 | return $this->getSubDomainBasedOnHTTPHost(); 151 | } 152 | } 153 | 154 | /** 155 | * Parses the request to pull out the first element separated 156 | * by `.` in the $_SERVER['HTTP_HOST']. 157 | * 158 | * ex: 159 | * test.domain.com returns test 160 | * test2.test.domain.com returns test2 161 | * 162 | * @return string 163 | */ 164 | protected function getSubDomainBasedOnHTTPHost(): string 165 | { 166 | $currentDomain = app('request')->getHost(); 167 | 168 | // Get rid of the TLD and root domain 169 | // ex: masterdomain.test.example.com returns 170 | // [ masterdomain, test ] 171 | $subdomains = explode('.', $currentDomain, -2); 172 | 173 | // Combine multiple level of domains into 1 string 174 | // ex: back to masterdomain.test 175 | $subdomain = implode('.', $subdomains); 176 | 177 | return $subdomain; 178 | } 179 | 180 | /** 181 | * Parses the request and removes the portion of the URL 182 | * that matches the Base URL as defined in the config file. 183 | * 184 | * ex: 185 | * baseURL = app.domain.com 186 | * test2.app.domain.com returns test2 187 | * 188 | * @return string 189 | */ 190 | protected function getSubDomainBasedOnBaseURL(string $baseURL): string 191 | { 192 | $currentDomain = app('request')->getHost(); 193 | 194 | //Remove the base domain from the currentDomain string 195 | $subdomain = str_replace($baseURL, '', $currentDomain); 196 | 197 | // If the last element is a period, remove it 198 | // Necessary to run this check, incase we're 199 | // processing the base domain. 200 | if ('.' == substr($subdomain, -1)) { 201 | $subdomain = substr($subdomain, 0, -1); 202 | } 203 | 204 | return $subdomain; 205 | } 206 | 207 | /** 208 | * Returns tenant from request subdomain. 209 | * 210 | * @return \RomegaDigital\Multitenancy\Contracts\Tenant 211 | */ 212 | public function receiveTenantFromRequest() 213 | { 214 | $domain = $this->getCurrentSubDomain(); 215 | 216 | return $this->getTenantClass()::findByDomain($domain); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/MultitenancyFacade.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(realpath(__DIR__ . '/../migrations')); 24 | 25 | if ($this->app->runningInConsole()) { 26 | $this->registerPublishing($filesystem); 27 | } 28 | 29 | $this->registerCommands(); 30 | $this->registerModelBindings(); 31 | 32 | Gate::before(function ($user, $ability) { 33 | if ($user->hasRole(config('multitenancy.roles.super_admin')) 34 | && 'admin' === app('multitenancy')->getCurrentSubDomain()) { 35 | return true; 36 | } 37 | }); 38 | } 39 | 40 | /** 41 | * Register the application services. 42 | */ 43 | public function register() 44 | { 45 | $this->mergeConfigFrom( 46 | __DIR__ . '/../config/multitenancy.php', 47 | 'multitenancy' 48 | ); 49 | 50 | $this->app->singleton(Multitenancy::class, function () { 51 | return new Multitenancy(); 52 | }); 53 | 54 | $this->app->alias(Multitenancy::class, 'multitenancy'); 55 | } 56 | 57 | /** 58 | * Register the package's publishable resources. 59 | * 60 | * @param Illuminate\Filesystem\Filesystem $filesystem 61 | */ 62 | protected function registerPublishing(Filesystem $filesystem) 63 | { 64 | $this->publishes([ 65 | __DIR__ . '/../migrations/create_'.config('multitenancy.table_names.tenants').'_table.php.stub' => $this->getMigrationFileName($filesystem), 66 | ], 'migrations'); 67 | 68 | $this->publishes([ 69 | __DIR__ . '/../config/multitenancy.php' => config_path('multitenancy.php'), 70 | ], 'config'); 71 | } 72 | 73 | /** 74 | * Registers all commands within the package. 75 | */ 76 | protected function registerCommands() 77 | { 78 | $this->commands([ 79 | InstallCommand::class, 80 | MigrationMakeCommand::class, 81 | AssignAdminPrivileges::class, 82 | ]); 83 | } 84 | 85 | /** 86 | * Register model bindings. 87 | */ 88 | protected function registerModelBindings() 89 | { 90 | $this->app->bind(TenantContract::class, $this->app->config['multitenancy.tenant_model']); 91 | } 92 | 93 | /** 94 | * Returns existing migration file if found, else uses the current timestamp. 95 | * 96 | * @param Illuminate\Filesystem\Filesystem $filesystem 97 | * 98 | * @return string 99 | */ 100 | protected function getMigrationFileName(Filesystem $filesystem): string 101 | { 102 | $timestamp = date('Y_m_d_His'); 103 | 104 | return Collection::make($this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR) 105 | ->flatMap(function ($path) use ($filesystem) { 106 | return $filesystem->glob($path . '*_create_tenants_table.php'); 107 | })->push($this->app->databasePath() . "/migrations/{$timestamp}_create_tenants_table.php") 108 | ->first(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Traits/BelongsToTenant.php: -------------------------------------------------------------------------------- 1 | applyTenantScope(new static()); 16 | 17 | static::creating(function ($model) { 18 | resolve(Multitenancy::class)->newModel($model); 19 | }); 20 | } 21 | 22 | /** 23 | * The model belongs to a tenant. 24 | * 25 | * @return Illuminate\Database\Eloquent\Relations\BelongsTo 26 | */ 27 | public function tenant() 28 | { 29 | return $this->belongsTo(config('multitenancy.tenant_model')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Traits/HasTenants.php: -------------------------------------------------------------------------------- 1 | has(Multitenancy::TENANT_SET_HEADER) || $ignoreTenantOnUserCreation) { 18 | return; 19 | } 20 | 21 | $model->tenants()->save( 22 | resolve(Multitenancy::class)->currentTenant() 23 | ); 24 | }); 25 | } 26 | 27 | /** 28 | * The model belongs to many tenants. 29 | * 30 | * @return Illuminate\Database\Eloquent\Relations\BelongsToMany 31 | */ 32 | public function tenants() 33 | { 34 | return $this->belongsToMany(config('multitenancy.tenant_model')) 35 | ->withTimestamps(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Databases/MigrateDatabaseTest.php: -------------------------------------------------------------------------------- 1 | assertEquals([ 14 | 'id', 15 | 'name', 16 | 'domain', 17 | 'deleted_at', 18 | 'created_at', 19 | 'updated_at', 20 | ], $columns); 21 | 22 | $columns = \Schema::getColumnListing('tenant_user'); 23 | $this->assertEquals([ 24 | 'id', 25 | 'tenant_id', 26 | 'user_id', 27 | 'created_at', 28 | 'updated_at', 29 | 'deleted_at', 30 | ], $columns); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Feature/BelongsToTenantTest.php: -------------------------------------------------------------------------------- 1 | resource('products', ProductController::class); 22 | } 23 | 24 | /** 25 | * Turn the given URI into a fully qualified URL. 26 | * 27 | * @param string $uri 28 | * 29 | * @return string 30 | */ 31 | protected function prepareUrlForRequest($uri) 32 | { 33 | $uri = "http://{$this->testTenant->domain}.localhost.com/{$uri}"; 34 | 35 | return trim($uri, '/'); 36 | } 37 | 38 | /** @test */ 39 | public function it_adds_current_tenant_id_to_model_on_create() 40 | { 41 | $this->actingAs($this->testUser); 42 | $this->testTenant->users()->save($this->testUser); 43 | 44 | $response = $this->post('products', [ 45 | 'name' => 'Another Tenants Product', 46 | ]); 47 | $response->assertStatus(201); 48 | $this->assertEquals(Product::first()->tenant_id, $this->testTenant->id); 49 | } 50 | 51 | /** @test */ 52 | public function it_only_retrieves_records_scoped_to_current_subdomain() 53 | { 54 | $this->actingAs($this->testUser); 55 | $this->testTenant->users()->save($this->testUser); 56 | 57 | Product::create([ 58 | 'name' => 'Another Tenants Product', 59 | 'tenant_id' => Tenant::create([ 60 | 'name' => 'Another Tenant', 61 | 'domain' => 'anotherdomain', 62 | ])->id, 63 | ]); 64 | 65 | $response = $this->get('products'); 66 | $response->assertStatus(200); 67 | $this->assertEquals(Product::where('tenant_id', $this->testTenant->id)->get(), $response->getContent()); 68 | } 69 | 70 | /** @test **/ 71 | public function it_retrieves_all_records_when_accessing_via_admin_subdomain() 72 | { 73 | $this->actingAs($this->testUser); 74 | $this->testAdminTenant->users()->save($this->testUser); 75 | $this->testTenant->domain = $this->testAdminTenant->domain; 76 | 77 | Product::create([ 78 | 'name' => 'Another Tenants Product', 79 | 'tenant_id' => Tenant::create([ 80 | 'name' => 'Another Tenant', 81 | 'domain' => 'anotherdomain', 82 | ])->id, 83 | ]); 84 | 85 | $response = $this->get('products'); 86 | $response->assertStatus(200); 87 | $this->assertEquals(Product::withoutGlobalScopes()->get(), $response->getContent()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Feature/Commands/AssignAdminPrivilegesTest.php: -------------------------------------------------------------------------------- 1 | artisan('multitenancy:super-admin', [ 16 | 'identifier' => 'test@user.com', 17 | ]) 18 | ->expectsOutput('User model \App\Models\User can not be found!') 19 | ->assertExitCode(0); 20 | } 21 | 22 | /** @test */ 23 | public function it_throws_an_error_and_exits_if_no_user_model_is_found() 24 | { 25 | $this->artisan('multitenancy:super-admin', [ 26 | 'identifier' => 'fail@user.com', 27 | '--model' => config('multitenancy.user_model'), 28 | ]) 29 | ->expectsOutput('User with email `fail@user.com` can not be found!') 30 | ->assertExitCode(0); 31 | } 32 | 33 | /** @test */ 34 | public function it_throws_an_error_and_exits_if_no_super_adminitration_role_is_found() 35 | { 36 | $this->artisan('multitenancy:super-admin', [ 37 | 'identifier' => 'test@user.com', 38 | '--model' => config('multitenancy.user_model'), 39 | ]) 40 | ->expectsOutput('Role with name `Super Administrator` can not be found!') 41 | ->expectsOutput('* Did you already run `multitenancy:install` command? *') 42 | ->assertExitCode(0); 43 | } 44 | 45 | /** @test */ 46 | public function it_throws_an_error_and_exits_if_no_admin_tenant_is_found() 47 | { 48 | Role::create(['name' => 'Super Administrator']); 49 | 50 | $tenant = Tenant::findByDomain('admin'); 51 | $tenant->domain = 'testadmin'; 52 | $tenant->save(); 53 | 54 | $this->artisan('multitenancy:super-admin', [ 55 | 'identifier' => 'test@user.com', 56 | '--model' => config('multitenancy.user_model'), 57 | ]) 58 | ->expectsOutput('Tenant with domain `admin` can not be found!') 59 | ->expectsOutput('* Did you already run `multitenancy:install` command? *') 60 | ->assertExitCode(0); 61 | } 62 | 63 | /** @test */ 64 | public function it_assigns_super_administrator_role_and_admin_tenant_to_given_user() 65 | { 66 | Role::create(['name' => 'Super Administrator']); 67 | 68 | $this->artisan('multitenancy:super-admin', [ 69 | 'identifier' => 'test@user.com', 70 | '--model' => config('multitenancy.user_model'), 71 | ]) 72 | ->expectsOutput('User with email test@user.com granted Super-Administration rights.') 73 | ->assertExitCode(1); 74 | 75 | $user = User::whereEmail('test@user.com')->first(); 76 | $this->assertTrue($user->hasRole('Super Administrator')); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Feature/Commands/InstallCommandTest.php: -------------------------------------------------------------------------------- 1 | artisan('multitenancy:install') 15 | ->expectsOutput('Publishing required migrations...') 16 | ->expectsOutput('Migrations published!') 17 | ->expectsOutput('Adding `Super Administrator` Role...') 18 | ->expectsOutput('Role `Super Administrator` created') 19 | ->expectsOutput('Adding `admin` domain...') 20 | ->expectsOutput('Admin domain added successfully!') 21 | ->assertExitCode(1); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Feature/Commands/MigrationMakeCommandTest.php: -------------------------------------------------------------------------------- 1 | mock(\Illuminate\Filesystem\Filesystem::class) 15 | ->makePartial() 16 | ->shouldReceive('put') 17 | ->once(); 18 | 19 | $this->artisan('multitenancy:migration', ['name' => 'testproducts']) 20 | ->expectsOutput('Multitenancy migration created successfully.') 21 | ->assertExitCode(1); 22 | } 23 | 24 | /** @test **/ 25 | public function it_can_handle_multiword_names() 26 | { 27 | $this->mock(\Illuminate\Filesystem\Filesystem::class) 28 | ->makePartial() 29 | ->shouldReceive('put') 30 | ->with(\Mockery::any(), \Mockery::pattern('/AddTenantIDColumnToTestNameTable/')) 31 | ->once(); 32 | 33 | $this->artisan('multitenancy:migration', ['name' => 'test_name']); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/GateTest.php: -------------------------------------------------------------------------------- 1 | resource('products', ProductController::class); 25 | 26 | Gate::policy(Product::class, ProductPolicy::class); 27 | } 28 | 29 | /** 30 | * Turn the given URI into a fully qualified URL. 31 | * 32 | * @param string $uri 33 | * 34 | * @return string 35 | */ 36 | protected function prepareUrlForRequest($uri) 37 | { 38 | $uri = "http://admin.localhost.com/{$uri}"; 39 | 40 | return trim($uri, '/'); 41 | } 42 | 43 | /** @test **/ 44 | public function it_does_not_allow_regular_user() 45 | { 46 | $this->actingAs($this->testUser); 47 | $this->testAdminTenant->users()->save($this->testUser); 48 | 49 | $product = Product::create([ 50 | 'name' => 'Another Tenants Product', 51 | 'tenant_id' => Tenant::create([ 52 | 'name' => 'Another Tenant', 53 | 'domain' => 'anotherdomain', 54 | ])->id, 55 | ]); 56 | 57 | $response = $this->get('products/' . $product->id); 58 | 59 | $response->assertForbidden(); 60 | } 61 | 62 | /** @test **/ 63 | public function it_does_not_allow_super_administrator_not_tied_to_admin_subdomain() 64 | { 65 | Role::create(['name' => 'Super Administrator']); 66 | $this->actingAs($this->testUser); 67 | $this->testUser->assignRole('Super Administrator'); 68 | 69 | $product = Product::create([ 70 | 'name' => 'Another Tenants Product', 71 | 'tenant_id' => Tenant::create([ 72 | 'name' => 'Another Tenant', 73 | 'domain' => 'anotherdomain', 74 | ])->id, 75 | ]); 76 | 77 | $response = $this->get('products/' . $product->id); 78 | 79 | $response->assertForbidden(); 80 | } 81 | 82 | /** @test **/ 83 | public function it_does_allow_super_administrator_tied_to_domain() 84 | { 85 | Role::create(['name' => 'Super Administrator']); 86 | $this->actingAs($this->testUser); 87 | $this->testUser->assignRole('Super Administrator'); 88 | $this->testAdminTenant->users()->save($this->testUser); 89 | 90 | $product = Product::create([ 91 | 'name' => 'Another Tenants Product', 92 | 'tenant_id' => Tenant::create([ 93 | 'name' => 'Another Tenant', 94 | 'domain' => 'anotherdomain', 95 | ])->id, 96 | ]); 97 | 98 | $response = $this->get('products/' . $product->id); 99 | 100 | $response->assertOK(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Feature/HasTenantTest.php: -------------------------------------------------------------------------------- 1 | resource('users', UserController::class); 22 | } 23 | 24 | /** 25 | * Turn the given URI into a fully qualified URL. 26 | * 27 | * @param string $uri 28 | * 29 | * @return string 30 | */ 31 | protected function prepareUrlForRequest($uri) 32 | { 33 | $uri = "http://{$this->testTenant->domain}.localhost.com/{$uri}"; 34 | 35 | return trim($uri, '/'); 36 | } 37 | 38 | /** @test */ 39 | public function it_adds_current_tenant_id_to_user_model_on_create() 40 | { 41 | $this->actingAs($this->testUser); 42 | $this->testTenant->users()->save($this->testUser); 43 | 44 | $this->post('users', [ 45 | 'email' => $email = 'another@user.com', 46 | 'name' => 'UserName', 47 | 'password' => 'PassWord', 48 | ]) 49 | ->assertStatus(201); 50 | 51 | $this->assertContains($this->testTenant->id, User::whereEmail($email)->first()->tenants->pluck('id')); 52 | } 53 | 54 | /** @test */ 55 | public function it_does_not_add_a_tenant_if_the_the_ignore_tenant_on_user_creation_is_set() 56 | { 57 | config(['multitenancy.ignore_tenant_on_user_creation' => true]); 58 | $this->testTenant->users()->save($this->testUser); 59 | $otherTenant = resolve(Tenant::class)->create([ 60 | 'name' => 'Other', 61 | 'domain' => 'other', 62 | ]); 63 | 64 | $this->actingAs($this->testUser) 65 | ->post('users', [ 66 | 'email' => $email = 'with@tenant.com', 67 | 'name' => 'UserName', 68 | 'password' => 'PassWord', 69 | 'tenant' => $otherTenant, 70 | ]) 71 | ->assertStatus(201); 72 | 73 | $this->assertContains($otherTenant->id, $tenantIds = User::whereEmail($email)->first()->tenants->pluck('id')); 74 | $this->assertNotContains($this->testTenant->id, $tenantIds); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Feature/Middleware/GuestMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | tenantMiddleware = new GuestTenantMiddleware(app('multitenancy')); 19 | } 20 | 21 | protected function buildRequest($domain) 22 | { 23 | app('request')->headers->set('HOST', $domain . '.example.com'); 24 | 25 | return $this->tenantMiddleware->handle(app('request'), function () { 26 | return (new Response())->setContent(''); 27 | }); 28 | } 29 | 30 | /** @test */ 31 | public function it_throws_error_if_domain_not_found() 32 | { 33 | try { 34 | $this->buildRequest('testdomain'); 35 | $this->fail('Expected exception not thrown'); 36 | } catch (TenantDoesNotExist $e) { //Not catching a generic Exception or the fail function is also catched 37 | $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); 38 | } 39 | } 40 | 41 | /** @test **/ 42 | public function it_allows_guest_users_to_access_tenant_scoped_requests() 43 | { 44 | $this->assertEquals( 45 | $this->buildRequest($this->testTenant->domain)->getStatusCode(), 46 | 200 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Feature/Middleware/TenantMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | get('/login', function () { 28 | return 'login'; 29 | })->name(config('multitenancy.redirect_route')); 30 | } 31 | 32 | public function setUp(): void 33 | { 34 | parent::setUp(); 35 | 36 | $this->tenantMiddleware = new TenantMiddleware(app('auth'), app('multitenancy')); 37 | } 38 | 39 | protected function buildRequest($domain) 40 | { 41 | app('request')->headers->set('HOST', $domain . '.example.com'); 42 | 43 | return $this->tenantMiddleware->handle(app('request'), function () { 44 | return (new Response())->setContent(''); 45 | }); 46 | } 47 | 48 | /** @test */ 49 | public function it_throws_error_if_domain_not_found() 50 | { 51 | $this->actingAs($this->testUser); 52 | 53 | try { 54 | $this->buildRequest('testdomain'); 55 | $this->fail('Expected exception not thrown'); 56 | } catch (TenantDoesNotExist $e) { //Not catching a generic Exception or the fail function is also catched 57 | $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); 58 | } 59 | } 60 | 61 | /** @test **/ 62 | public function it_throws_error_if_user_is_not_part_of_tenant() 63 | { 64 | $this->actingAs($this->testUser); 65 | 66 | try { 67 | $this->buildRequest($this->testTenant->domain); 68 | $this->fail('Expected exception not thrown'); 69 | } catch (UnauthorizedException $e) { //Not catching a generic Exception or the fail function is also catched 70 | $this->assertEquals(403, $e->getStatusCode()); 71 | $this->assertEquals("The authenticated user does not have access to domain `{$this->testTenant->domain}`.", $e->getMessage()); 72 | } 73 | } 74 | 75 | /** @test **/ 76 | public function it_throws_error_if_user_is_not_logged_in() 77 | { 78 | try { 79 | $this->buildRequest($this->testTenant->domain); 80 | $this->fail('Expected exception not thrown'); 81 | } catch (AuthenticationException $e) { //Not catching a generic Exception or the fail function is also catched 82 | $this->assertEquals('Unauthenticated.', $e->getMessage()); 83 | } 84 | } 85 | 86 | /** @test **/ 87 | public function it_allows_users_who_are_associated_with_a_valid_domain() 88 | { 89 | $this->actingAs($this->testUser); 90 | 91 | $this->testTenant->users()->sync($this->testUser); 92 | 93 | $this->assertEquals( 94 | $this->buildRequest($this->testTenant->domain)->getStatusCode(), 95 | 200 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Feature/MultitenancyTest.php: -------------------------------------------------------------------------------- 1 | get('/login', function () { 26 | return 'login'; 27 | })->name(config('multitenancy.redirect_route')); 28 | } 29 | 30 | public function setUp(): void 31 | { 32 | parent::setUp(); 33 | 34 | $this->tenantMiddleware = new TenantMiddleware(app('auth'), app('multitenancy')); 35 | } 36 | 37 | protected function buildRequest($domain) 38 | { 39 | app('request')->headers->set('HOST', $domain.'.example.com'); 40 | 41 | return $this->tenantMiddleware->handle(app('request'), function () { 42 | return (new Response())->setContent(''); 43 | }); 44 | } 45 | 46 | /** @test */ 47 | public function it_returns_the_current_tenant_when_set_by_middleware() 48 | { 49 | $this->actingAs($this->testUser); 50 | 51 | $this->testTenant->users()->sync($this->testUser); 52 | 53 | $this->buildRequest($this->testTenant->domain); 54 | 55 | $this->assertEquals($this->testTenant->domain, app('multitenancy')->currentTenant()->domain); 56 | } 57 | 58 | /** @test */ 59 | public function it_throws_exception_when_tenant_not_set() 60 | { 61 | $this->actingAs($this->testUser); 62 | 63 | $this->testTenant->users()->sync($this->testUser); 64 | 65 | try { 66 | $this->buildRequest('testdomain'); 67 | app('multitenancy')->currentTenant(); 68 | $this->fail('Expected exception not thrown'); 69 | } catch (TenantDoesNotExist $e) { 70 | $this->assertEquals('There is no tenant at domain `testdomain`.', $e->getMessage()); 71 | } 72 | } 73 | 74 | /** @test */ 75 | public function it_throws_exception_when_tenant_not_set_never_touched_middleware() 76 | { 77 | $this->actingAs($this->testUser); 78 | 79 | $this->testTenant->users()->sync($this->testUser); 80 | 81 | try { 82 | app('multitenancy')->currentTenant(); 83 | $this->fail('Expected exception not thrown'); 84 | } catch (TenantDoesNotExist $e) { 85 | $this->assertEquals('There is no tenant at domain ``.', $e->getMessage()); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Feature/TenantTest.php: -------------------------------------------------------------------------------- 1 | expectException(TenantDoesNotExist::class); 15 | app(Tenant::class)->findByDomain('nonexistentdomain'); 16 | } 17 | 18 | /** @test */ 19 | public function it_is_retrievable_by_domain() 20 | { 21 | $permission_by_domain = app(Tenant::class)->findByDomain($this->testTenant->domain); 22 | $this->assertEquals($this->testTenant->id, $permission_by_domain->id); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Fixtures/Controllers/ProductController.php: -------------------------------------------------------------------------------- 1 | middleware([ 20 | function ($request, Closure $next) { 21 | $route = app('router')->getCurrentRoute(); 22 | Assert::assertSame(ProductController::class, get_class($route->getController())); 23 | 24 | return $next($request); 25 | }, 26 | TenantMiddleware::class, 27 | ]); 28 | } 29 | 30 | public function index() 31 | { 32 | return Product::all(); 33 | } 34 | 35 | public function store(Request $request) 36 | { 37 | return Product::create([ 38 | 'name' => $request->name, 39 | ]); 40 | } 41 | 42 | public function show(Product $product) 43 | { 44 | app(Gate::class)->authorize('view', $product); 45 | 46 | return $product; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Fixtures/Controllers/UserController.php: -------------------------------------------------------------------------------- 1 | middleware([ 20 | function ($request, Closure $next) { 21 | $route = app('router')->getCurrentRoute(); 22 | Assert::assertSame(UserController::class, get_class($route->getController())); 23 | 24 | return $next($request); 25 | }, 26 | TenantMiddleware::class, 27 | ]); 28 | } 29 | 30 | public function store(Request $request) 31 | { 32 | if (! $request->has('tenant')) { 33 | return User::create([ 34 | 'email' => $request->email, 35 | 'name' => $request->name, 36 | 'password' => $request->password, 37 | ]); 38 | } 39 | 40 | $user = new User([ 41 | 'email' => $request->email, 42 | 'name' => $request->name, 43 | 'password' => $request->password, 44 | ]); 45 | 46 | resolve(Tenant::class)->find($request->tenant)->first()->users()->save($user); 47 | 48 | return $user; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Fixtures/Policies/ProductPolicy.php: -------------------------------------------------------------------------------- 1 | set('multitenancy.user_model', User::class); 32 | $app['config']->set('auth.providers.users.model', config('multitenancy.user_model')); 33 | $app['config']->set('auth.guards.web.provider', 'users'); 34 | } 35 | 36 | /** 37 | * Load package service provider. 38 | * 39 | * @param \Illuminate\Foundation\Application $app 40 | * 41 | * @return array 42 | */ 43 | protected function getPackageProviders($app) 44 | { 45 | return [ 46 | MultitenancyServiceProvider::class, 47 | PermissionServiceProvider::class, 48 | ]; 49 | } 50 | 51 | /** 52 | * Load package alias. 53 | * 54 | * @param \Illuminate\Foundation\Application $app 55 | * 56 | * @return array 57 | */ 58 | protected function getPackageAliases($app) 59 | { 60 | return [ 61 | 'Multitenancy' => MultitenancyFacade::class, 62 | ]; 63 | } 64 | 65 | public function setUp(): void 66 | { 67 | parent::setUp(); 68 | 69 | if ($this->setupTestDatabase) { 70 | $this->setUpDatabase($this->app); 71 | 72 | $this->testUser = User::first(); 73 | $this->testTenant = app(Tenant::class)->find(1); 74 | $this->testAdminTenant = app(Tenant::class)->find(2); 75 | $this->testProduct = Product::first(); 76 | } 77 | } 78 | 79 | /** 80 | * Define database migrations. 81 | * 82 | * @return void 83 | */ 84 | protected function defineDatabaseMigrations() 85 | { 86 | $this->loadLaravelMigrations(); 87 | } 88 | 89 | /** 90 | * Set up the database. 91 | * 92 | * @param \Illuminate\Foundation\Application $app 93 | */ 94 | protected function setUpDatabase($app) 95 | { 96 | $this->loadMigrationsFrom(realpath(__DIR__ . '/../migrations')); 97 | $this->artisan('migrate')->run(); 98 | 99 | $app[Tenant::class]->create([ 100 | 'name' => 'Tenant Name', 101 | 'domain' => 'masterdomain', 102 | ]); 103 | $app[Tenant::class]->create([ 104 | 'name' => 'Admin', 105 | 'domain' => 'admin', 106 | ]); 107 | 108 | User::create([ 109 | 'name' => "Test User", 110 | 'email' => 'test@user.com', 111 | 'password' => 'testPassword', 112 | ]); 113 | 114 | Schema::create('products', function (Blueprint $table) { 115 | $table->increments('id'); 116 | $table->string('name'); 117 | $table->unsignedInteger('tenant_id'); 118 | $table->foreign('tenant_id') 119 | ->references('id') 120 | ->on('tenants') 121 | ->onDelete('cascade'); 122 | $table->softDeletes(); 123 | }); 124 | Product::create([ 125 | 'name' => 'Product 1', 126 | 'tenant_id' => '1', 127 | ]); 128 | } 129 | } 130 | --------------------------------------------------------------------------------