├── app ├── Models │ └── .gitkeep ├── Console │ └── Commands │ │ ├── SaasTenantListCommand.php │ │ ├── SaasTenantDelCommand.php │ │ ├── SaasTenantAddCommand.php │ │ ├── stubs │ │ ├── AuthenticateTenantSession.stub │ │ └── tenant_model.stub │ │ ├── SaasTenantStorageLinkCommand.php │ │ ├── SaasCommand.php │ │ └── SaasInstallCommand.php ├── Listeners │ └── AddTenant.php ├── Support │ └── Installer.php ├── Services │ └── CmdWordService.php ├── Providers │ ├── CmdWordServiceProvider.php │ ├── ExceptionServiceProvider.php │ ├── EventServiceProvider.php │ ├── CommandServiceProvider.php │ ├── LaravelSaasServiceProvider.php │ └── RouteServiceProvider.php └── Http │ └── Controllers │ └── LaravelSaasController.php ├── resources ├── lang │ └── .gitkeep ├── assets │ ├── js │ │ └── app.js │ └── sass │ │ └── app.scss └── views │ ├── index.blade.php │ ├── layouts │ └── master.blade.php │ ├── commons │ ├── toast.blade.php │ └── head.blade.php │ └── setting.blade.php ├── tests ├── Feature │ └── .gitkeep └── Unit │ └── .gitkeep ├── database ├── factories │ └── .gitkeep ├── seeders │ └── DatabaseSeeder.php └── migrations │ └── init_laravel_saas_config.php ├── .gitignore ├── config └── laravel-saas.php ├── webpack.mix.js ├── plugin.json ├── composer.json ├── routes ├── api.php └── web.php ├── package.json └── README.md /app/Models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/lang/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Feature/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/factories/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/assets/js/app.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | yarn-error.log 4 | yarn.lock 5 | .temp 6 | .cache 7 | .settings/ 8 | .idea 9 | .vscode 10 | .phpunit.result.cache 11 | .DS_Store 12 | dist 13 | -------------------------------------------------------------------------------- /config/laravel-saas.php: -------------------------------------------------------------------------------- 1 | 'LaravelSaas', 11 | ]; 12 | -------------------------------------------------------------------------------- /resources/views/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('LaravelSaas::layouts.master') 2 | 3 | @section('content') 4 |
5 |

Plugin: LaravelSaas

6 | 7 |

8 | This view is loaded from plugin: {!! config('laravel-saas.name') !!} 9 |

10 | 11 | Go to the LaravelSaas plugin settings page. 12 |
13 | @endsection 14 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call("OthersTableSeeder"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const dotenvExpand = require('dotenv-expand'); 2 | dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/})); 3 | 4 | const mix = require('laravel-mix'); 5 | require('laravel-mix-merge-manifest'); 6 | 7 | mix.setPublicPath('../../public').mergeManifest(); 8 | 9 | mix.js(__dirname + '/resources/assets/js/app.js', 'assets/plugins/LaravelSaas/js/laravel-saas.js') 10 | .sass( __dirname + '/resources/assets/sass/app.scss', 'assets/plugins/LaravelSaas/css/laravel-saas.css'); 11 | 12 | if (mix.inProduction()) { 13 | mix.version(); 14 | } 15 | -------------------------------------------------------------------------------- /resources/views/layouts/master.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @include('LaravelSaas::commons.head', [ 5 | 'title' => 'LaravelSaas', 6 | ]) 7 | 8 | {{-- Laravel Mix - CSS File --}} 9 | {{-- --}} 10 | 11 | 12 | 13 |
14 | @yield('content') 15 | 16 | @include('LaravelSaas::commons.toast') 17 |
18 | 19 | @yield('bodyjs') 20 | 21 | {{-- Laravel Mix - JS File --}} 22 | {{-- --}} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/Console/Commands/SaasTenantListCommand.php: -------------------------------------------------------------------------------- 1 | call('tenants:list'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "fskey": "LaravelSaas", 3 | "name": "LaravelSaas", 4 | "description": "LaravelSaas plugin made by mouyong", 5 | "author": "mouyong", 6 | "authorLink": "https://plugins-world.cn", 7 | "version": "1.0.0", 8 | "scene": [], 9 | "accessPath": "/laravel-saas", 10 | "settingsPath": "/laravel-saas/setting", 11 | "providers": [ 12 | "Plugins\\LaravelSaas\\Providers\\LaravelSaasServiceProvider", 13 | "Plugins\\LaravelSaas\\Providers\\CmdWordServiceProvider", 14 | "Plugins\\LaravelSaas\\Providers\\EventServiceProvider", 15 | "Plugins\\LaravelSaas\\Providers\\ExceptionServiceProvider" 16 | ], 17 | "autoloadFiles": [], 18 | "aliases": [] 19 | } -------------------------------------------------------------------------------- /app/Console/Commands/SaasTenantDelCommand.php: -------------------------------------------------------------------------------- 1 | argument('tenant'); 29 | 30 | $tenant = \App\Models\Tenant::find($tenantId); 31 | $tenant?->delete(); 32 | 33 | $this->info("{$tenantId} 删除成功"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Listeners/AddTenant.php: -------------------------------------------------------------------------------- 1 | addTenant([ 32 | 'user' => $event->user?->toArray(), 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Console/Commands/SaasTenantAddCommand.php: -------------------------------------------------------------------------------- 1 | argument('tenant'); 29 | 30 | $tenant = \App\Models\Tenant::create(['id' => $tenantId]); 31 | $tenant->domains()->create(['domain' => "{$tenantId}.".str_replace(['http://', 'https://'], '', config('app.url'))]); 32 | 33 | $this->info("{$tenantId} 创建成功"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugins-world/laravel-saas", 3 | "description": "Tenant plugin made by fresns", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "mouyong", 8 | "email": "my24251325@gmail.com", 9 | "homepage": "https://github.com/mouyong", 10 | "role": "Creator & Developer" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "Plugins\\LaravelSaas\\": "app", 16 | "Plugins\\LaravelSaas\\Database\\Factories\\": "database/factories/", 17 | "Plugins\\LaravelSaas\\Database\\Seeders\\": "database/seeders/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Plugins\\LaravelSaas\\Tests\\": "tests/" 23 | } 24 | }, 25 | "require": { 26 | "php": ">= 8.0", 27 | "mouyong/laravel-config": "dev-master", 28 | "fresns/cmd-word-manager": "^1.0", 29 | "stancl/tenancy": "^3.7" 30 | } 31 | } -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('/tenant', function (Request $request) { 25 | // return $request->user(); 26 | // }); 27 | 28 | // Route::prefix('laravel-saas')->group(function() { 29 | // Route::get('/', [ApiController\LaravelSaasController::class, 'index']); 30 | // }); 31 | -------------------------------------------------------------------------------- /app/Support/Installer.php: -------------------------------------------------------------------------------- 1 | 'tenant', 16 | // 'item_key' => 'access_key', 17 | // 'item_type' => 'string', 18 | // 'item_value' => null, 19 | // ], 20 | ]; 21 | 22 | public function process(callable $callable) 23 | { 24 | foreach ($this->config as $configItem) { 25 | $callable($configItem); 26 | } 27 | } 28 | 29 | // plugin install 30 | public function install() 31 | { 32 | $this->process(function ($configItem) { 33 | // add config 34 | }); 35 | } 36 | 37 | /// plugin uninstall 38 | public function uninstall() 39 | { 40 | $this->process(function ($configItem) { 41 | // remove config 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "cross-env": "^7.0", 14 | "laravel-mix": "^5.0.1", 15 | "laravel-mix-merge-manifest": "^0.1.2" 16 | } 17 | } -------------------------------------------------------------------------------- /resources/views/commons/toast.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 | 8 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /app/Services/CmdWordService.php: -------------------------------------------------------------------------------- 1 | $tenantId, 22 | ]); 23 | 24 | $tenant = Tenant::find($tenantId); 25 | $tenant?->update([ 26 | 'name' => $user['name'], 27 | 'email' => $user['email'], 28 | ]); 29 | 30 | $userModel = User::where('name', $user['name'])->firstOrFail(); 31 | 32 | $tenant?->run(function () use ($userModel) { 33 | User::create([ 34 | 'name' => $userModel->name, 35 | 'password' => $userModel->password, 36 | 'email' => $userModel->email, 37 | ]); 38 | }); 39 | 40 | return redirect('http://'.$tenantId.'.translate-merit-based.hecs.iwnweb.com'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Console/Commands/stubs/AuthenticateTenantSession.stub: -------------------------------------------------------------------------------- 1 | check()) { 22 | /** @var \App\Models\User */ 23 | $user = auth()->user(); 24 | 25 | $tenantModel = Tenant::where('data->name', auth()->user()->name)->first(); 26 | $domain = $tenantModel?->domains()->value('domain'); 27 | 28 | // TODO: 租户信息验证 29 | 30 | 31 | // 租户自动登录 32 | if ($tenantModel && ! tenant()) { 33 | $data = encrypt(['user' => $user->toArray()]); 34 | $tenantUrl = "http://{$domain}/login?encryptData=".$data; 35 | 36 | auth()->logout($user); 37 | 38 | return Inertia::location($tenantUrl); 39 | } 40 | } 41 | 42 | return $next($request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | group(function() { 24 | Route::get('/', [WebController\LaravelSaasController::class, 'index'])->name('laravel-saas.index'); 25 | Route::get('setting', [WebController\LaravelSaasController::class, 'showSettingView'])->name('laravel-saas.setting'); 26 | Route::post('setting', [WebController\LaravelSaasController::class, 'saveSetting']); 27 | }); 28 | 29 | // without VerifyCsrfToken 30 | // Route::prefix('laravel-saas')->withoutMiddleware([ 31 | // \App\Http\Middleware\EncryptCookies::class, 32 | // \App\Http\Middleware\VerifyCsrfToken::class, 33 | // ])->group(function() { 34 | // Route::get('/', [WebController\LaravelSaasController::class, 'index']); 35 | // }); 36 | -------------------------------------------------------------------------------- /app/Providers/CmdWordServiceProvider.php: -------------------------------------------------------------------------------- 1 | AWordService::CMD_TEST, 'provider' => [AWordService::class, 'handleTest']], 29 | // ['word' => BWordService::CMD_STATIC_TEST, 'provider' => [BWordService::class, 'handleStaticTest']], 30 | // ['word' => TestModel::CMD_MODEL_TEST, 'provider' => [TestModel::class, 'handleModelTest']], 31 | // ['word' => 'addTenant', 'provider' => [CmdWordService::class, 'addTenant']], 32 | ['word' => 'addTenant', 'provider' => [CmdWordService::class, 'addTenant']], 33 | ]; 34 | 35 | /** 36 | * Register the service provider. 37 | */ 38 | public function register(): void 39 | { 40 | $this->registerCmdWordProvider(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Providers/ExceptionServiceProvider.php: -------------------------------------------------------------------------------- 1 | reportable($this->reportable()); 25 | } 26 | 27 | if (method_exists($handler, 'renderable')) { 28 | $handler->renderable($this->renderable()); 29 | } 30 | } 31 | 32 | /** 33 | * Register a reportable callback. 34 | * 35 | * @param callable $reportUsing 36 | * @return \Illuminate\Foundation\Exceptions\ReportableHandler 37 | */ 38 | public function reportable() 39 | { 40 | return function (\Throwable $e) { 41 | // 42 | }; 43 | } 44 | 45 | /** 46 | * Register a renderable callback. 47 | * 48 | * @param callable $renderUsing 49 | * @return $this 50 | */ 51 | public function renderable() 52 | { 53 | return function (\Throwable $e) { 54 | // 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/Http/Controllers/LaravelSaasController.php: -------------------------------------------------------------------------------- 1 | has('is_enabled')) { 21 | $where['is_enabled'] = \request('is_enabled'); 22 | } 23 | 24 | // $data = Tenant::query()->where($where)->get(); 25 | 26 | return view('LaravelSaas::index', [ 27 | 'configs' => $configs, 28 | ]); 29 | } 30 | 31 | public function showSettingView() 32 | { 33 | $configs = Config::getValueByKeys([ 34 | // 'item_key1', 35 | // 'item_key2', 36 | ]); 37 | 38 | return view('LaravelSaas::setting', [ 39 | 'configs' => $configs, 40 | ]); 41 | } 42 | 43 | public function saveSetting() 44 | { 45 | \request()->validate([ 46 | // 'item_key1' => 'required|url', 47 | // 'item_key2' => 'nullable|url', 48 | ]); 49 | 50 | $itemKeys = [ 51 | // 'item_key1', 52 | // 'item_key2', 53 | ]; 54 | 55 | // Config::updateConfigs($itemKeys, 'laravel-saas'); 56 | 57 | return redirect(route('laravel-saas.setting')); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /database/migrations/init_laravel_saas_config.php: -------------------------------------------------------------------------------- 1 | SubscribeUtility::TYPE_USER_ACTIVITY, 18 | // 'fskey' => 'laravel-saas', 19 | // 'cmdWord' => 'stats', 20 | ]; 21 | 22 | protected $fresnsConfigItems = [ 23 | [ 24 | 'item_tag' => 'laravel-saas', 25 | 'item_key' => 'tenant_user_register_service', 26 | 'item_type' => 'string', 27 | 'item_value' => 'LaravelSaas', 28 | ], 29 | ]; 30 | 31 | /** 32 | * Run the migrations. 33 | */ 34 | public function up(): void 35 | { 36 | // addSubscribeItem 37 | // \FresnsCmdWord::plugin()->addSubscribeItem($this->fresnsWordBody); 38 | 39 | // addKeyValues to Config table 40 | Config::addKeyValues($this->fresnsConfigItems); 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | */ 46 | public function down(): void 47 | { 48 | // removeSubscribeItem 49 | // \FresnsCmdWord::plugin()->removeSubscribeItem($this->fresnsWordBody); 50 | 51 | // removeKeyValues from Config table 52 | Config::removeKeyValues($this->fresnsConfigItems); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | > 22 | */ 23 | protected $listen = [ 24 | Registered::class => [ 25 | // SendEmailVerificationNotification::class, 26 | \Plugins\LaravelSaas\Listeners\AddTenant::class, 27 | ], 28 | "plugins.clean_data" => [ 29 | // When the user uninstalls, if the data needs to be deleted, the listener is configured here. 30 | ], 31 | ]; 32 | 33 | /** 34 | * The subscribers to register. 35 | * 36 | * @var array 37 | */ 38 | protected $subscribe = [ 39 | // 40 | ]; 41 | 42 | /** 43 | * Register any events for your application. 44 | */ 45 | public function boot(): void 46 | { 47 | // 48 | } 49 | 50 | /** 51 | * Determine if events and listeners should be automatically discovered. 52 | */ 53 | public function shouldDiscoverEvents(): bool 54 | { 55 | return false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/Console/Commands/SaasTenantStorageLinkCommand.php: -------------------------------------------------------------------------------- 1 | runForEach(function ($tenant) { 35 | $target = base_path(sprintf("storage/%s%s/app/public", 36 | config('tenancy.filesystem.suffix_base'), 37 | $tenant->id)); 38 | $link = str_replace('%tenant_id%', $tenant->id, config('tenancy.filesystem.url_override.public', 'public-%tenant_id%')); 39 | 40 | $message = "fix storage:link $link ========> $target"; 41 | dump($message); 42 | 43 | chdir(public_path()); 44 | \Illuminate\Support\Facades\File::ensureDirectoryExists(dirname($target)); 45 | \Illuminate\Support\Facades\File::link($target, $link); 46 | }); 47 | 48 | return 0; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Providers/CommandServiceProvider.php: -------------------------------------------------------------------------------- 1 | load($commandsDirectory); 27 | } 28 | } 29 | 30 | /** 31 | * Register all of the commands in the given directory. 32 | * 33 | * @param array|string $paths 34 | */ 35 | protected function load($paths): void 36 | { 37 | $paths = array_unique(Arr::wrap($paths)); 38 | 39 | $paths = array_filter($paths, function ($path) { 40 | return is_dir($path); 41 | }); 42 | 43 | if (empty($paths)) { 44 | return; 45 | } 46 | 47 | $commands = []; 48 | foreach ((new Finder)->in($paths)->files() as $command) { 49 | $commandClass = Str::before(self::class, 'Providers\\') . 'Console\\Commands\\' . str_replace('.php', '', $command->getBasename()); 50 | if (class_exists($commandClass)) { 51 | $commands[] = $commandClass; 52 | } 53 | } 54 | 55 | $this->commands($commands); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/Console/Commands/stubs/tenant_model.stub: -------------------------------------------------------------------------------- 1 | id)); 34 | $link = str_replace('%tenant_id%', $tenant->id, config('tenancy.filesystem.url_override.public', 'public-%tenant_id%')); 35 | 36 | chdir(public_path()); 37 | \Illuminate\Support\Facades\File::ensureDirectoryExists($target); 38 | \Illuminate\Support\Facades\File::ensureDirectoryExists(dirname($link)); 39 | \Illuminate\Support\Facades\File::link($target, $link); 40 | } 41 | 42 | public static function removeStorageLink($tenant) 43 | { 44 | $target = base_path(sprintf("storage/tenants/%s%s/app/public", 45 | config('tenancy.filesystem.suffix_base'), 46 | $tenant->id)); 47 | $link = str_replace('%tenant_id%', $tenant->id, config('tenancy.filesystem.url_override.public', 'public-%tenant_id%')); 48 | 49 | chdir(public_path()); 50 | \Illuminate\Support\Facades\File::delete($link); 51 | \Illuminate\Support\Facades\File::deleteDirectory(dirname(dirname($target))); 52 | } 53 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LaravelSaas 2 | 3 | [![Latest Stable Version](http://poser.pugx.org/plugins-world/laravel-saas/v)](https://packagist.org/packages/plugins-world/laravel-saas) 4 | [![Total Downloads](http://poser.pugx.org/plugins-world/laravel-saas/downloads)](https://packagist.org/packages/plugins-world/laravel-saas) 5 | [![Latest Unstable Version](http://poser.pugx.org/plugins-world/laravel-saas/v/unstable)](https://packagist.org/packages/plugins-world/laravel-saas) [![License](http://poser.pugx.org/plugins-world/laravel-saas/license)](https://packagist.org/packages/plugins-world/laravel-saas) 6 | [![PHP Version Require](http://poser.pugx.org/plugins-world/laravel-saas/require/php)](https://packagist.org/packages/plugins-world/laravel-saas) 7 | 8 | 在最新的 laravel 框架中使用 saas 功能的项目。 9 | 10 | 依赖项目: 11 | - [插件管理器 fresns/plugin-manager](https://pm.fresns.org/zh-Hans/) 12 | - [应用市场管理器 plugins-world/market-manager](https://github.com/plugins-world/MarketManager) 13 | - [Tenancy 3.x](https://tenancyforlaravel.com/) 14 | - [Laravel](https://laravel.com/) 15 | 16 | ## 前置要求 17 | 18 | - Laravel 9+ 19 | - Tenancy 3+ 20 | - fresns/plugin-manager ^2 21 | - fresns/market-manager ^1 22 | - fresns/cmd-word-manager ^1 23 | - 项目已完成 plugins-world/market-manager 的安装。点击查看[Laravel 插件管理器,安装指南](https://discuss.plugins-world.cn/post/9S19kdNL) 24 | 25 | 26 | ## 安装 27 | 28 | 1. 在插件管理后台安装并启用租户插件 29 | 2. 初始化插件 30 | 31 | ```bash 32 | php artisan saas:install # 需要配置数据库的 root 账号密码 33 | ``` 34 | 35 | 36 | ## 命令行 37 | 38 | ``` php 39 | php artisan saas # 查看当前可以使用的与 saas 相关的指令 40 | php artisan saas:tenant-add --tenant=foo # 添加租户,默认添加名称为 foo 的租户 41 | php artisan saas:tenant-del --tenant=foo # 删除租户,默认删除名称为 foo 的租户 42 | php artisan saas:tenant-list # 当前 saas 列表 43 | php artisan tenants:migrate --tenants=foo # 执行 foo 租户的迁移,开发阶段建议指定租户,部署阶段可不指定,以批量运行租户迁移 44 | php artisan tenants:migrate-rollback --tenants=foo # 回滚 foo 租户的迁移,开发阶段建议指定租户,部署阶段可不指定,以批量运行租户迁移的回滚操作 45 | php artisan ... 46 | ``` 47 | 48 | 49 | ## 使用 50 | 51 | 参考 [tencentforlaravel](https://doc.wyz.xyz/pages/30ee05/) 翻译文档 52 | -------------------------------------------------------------------------------- /app/Providers/LaravelSaasServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerTranslations(); 21 | $this->registerConfig(); 22 | $this->registerViews(); 23 | 24 | $this->loadMigrationsFrom(dirname(__DIR__, 2) . '/database/migrations'); 25 | 26 | $this->app->register(RouteServiceProvider::class); 27 | 28 | // Event::listen(UserCreated::class, UserCreatedListener::class); 29 | } 30 | 31 | /** 32 | * Register the service provider. 33 | */ 34 | public function register(): void 35 | { 36 | // if ($this->app->runningInConsole()) { 37 | $this->app->register(CommandServiceProvider::class); 38 | // } 39 | } 40 | 41 | /** 42 | * Register config. 43 | */ 44 | protected function registerConfig(): void 45 | { 46 | $this->mergeConfigFrom( 47 | dirname(__DIR__, 2) . '/config/laravel-saas.php', 'laravel-saas' 48 | ); 49 | } 50 | 51 | /** 52 | * Register views. 53 | */ 54 | public function registerViews(): void 55 | { 56 | $this->loadViewsFrom(dirname(__DIR__, 2) . '/resources/views', 'LaravelSaas'); 57 | } 58 | 59 | /** 60 | * Register translations. 61 | */ 62 | public function registerTranslations(): void 63 | { 64 | $this->loadTranslationsFrom(dirname(__DIR__, 2) . '/resources/lang', 'LaravelSaas'); 65 | } 66 | 67 | /** 68 | * Get the services provided by the provider. 69 | */ 70 | public function provides(): array 71 | { 72 | return []; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /resources/views/setting.blade.php: -------------------------------------------------------------------------------- 1 | @extends('LaravelSaas::layouts.master') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |

LaravelSaas Settings

8 | Back to Tenant plugin homepage. 9 | 10 |
11 | @csrf 12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 | 20 |
21 | 22 |
23 | 24 | 31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 | @endsection 40 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | where('fskey', $fskey)->first(); 43 | 44 | // Cache::put($cacheKey, $pluginModel, now()->addMinutes(30)); 45 | // } 46 | 47 | // $pluginHost = $pluginModel?->plugin_host ?? ''; 48 | 49 | // $host = str_replace(['http://', 'https://'], '', rtrim($pluginHost, '/')); 50 | // } 51 | // } catch (\Throwable $e) { 52 | // info("get plugin host failed: " . $e->getMessage()); 53 | // } 54 | 55 | Route::group([ 56 | 'domain' => $host, 57 | ], function () { 58 | $this->mapApiRoutes(); 59 | 60 | $this->mapWebRoutes(); 61 | }); 62 | } 63 | 64 | /** 65 | * Define the "web" routes for the application. 66 | * 67 | * These routes all receive session state, CSRF protection, etc. 68 | */ 69 | protected function mapWebRoutes(): void 70 | { 71 | Route::middleware('web') 72 | ->group(dirname(__DIR__, 2) . '/routes/web.php'); 73 | } 74 | 75 | /** 76 | * Define the "api" routes for the application. 77 | * 78 | * These routes are typically stateless. 79 | */ 80 | protected function mapApiRoutes(): void 81 | { 82 | Route::prefix('api') 83 | ->middleware('api') 84 | ->group(dirname(__DIR__, 2) . '/routes/api.php'); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/Console/Commands/SaasCommand.php: -------------------------------------------------------------------------------- 1 | info(static::$logo); 42 | 43 | $this->comment(''); 44 | $this->comment('Available commands:'); 45 | 46 | $this->comment(''); 47 | $this->comment('saas'); 48 | $this->listAdminCommands(); 49 | } 50 | 51 | protected function listAdminCommands(): void 52 | { 53 | $commands = collect(\Illuminate\Support\Facades\Artisan::all())->mapWithKeys(function ($command, $key) { 54 | if ( 55 | \Illuminate\Support\Str::startsWith($key, 'saas') 56 | || \Illuminate\Support\Str::startsWith($key, 'tenants') 57 | ) { 58 | return [$key => $command]; 59 | } 60 | 61 | return []; 62 | })->toArray(); 63 | 64 | \ksort($commands); 65 | 66 | $width = $this->getColumnWidth($commands); 67 | 68 | /** @var Command $command */ 69 | foreach ($commands as $command) { 70 | $this->info(sprintf(" %-{$width}s %s", $command->getName(), $command->getDescription())); 71 | } 72 | } 73 | 74 | private function getColumnWidth(array $commands): int 75 | { 76 | $widths = []; 77 | 78 | foreach ($commands as $command) { 79 | $widths[] = static::strlen($command->getName()); 80 | foreach ($command->getAliases() as $alias) { 81 | $widths[] = static::strlen($alias); 82 | } 83 | } 84 | 85 | return $widths ? max($widths) + 2 : 0; 86 | } 87 | 88 | /** 89 | * Returns the length of a string, using mb_strwidth if it is available. 90 | * 91 | * @param string $string The string to check its length 92 | * @return int The length of the string 93 | */ 94 | public static function strlen($string): int 95 | { 96 | if (false === $encoding = mb_detect_encoding($string, null, true)) { 97 | return strlen($string); 98 | } 99 | 100 | return mb_strwidth($string, $encoding); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /resources/views/commons/head.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ $title ?? '' }} 7 | 8 | @stack('headcss') 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 100 | 101 | @stack('headjs') 102 | -------------------------------------------------------------------------------- /app/Console/Commands/SaasInstallCommand.php: -------------------------------------------------------------------------------- 1 | call('tenancy:install'); 33 | 34 | $this->initTenantMigrations(); 35 | $this->initTenantPublicAssets(); 36 | $this->setTenantPrefix(); 37 | // $this->registerDomains($this->option('domain')); # 已经通过 setTenantPrefix 初始化了 38 | $this->addInitTenantAction(); 39 | $this->resetRegisterPasswordLength(); 40 | $this->createTenantMigrationsDir(); 41 | $this->resetLoginLogic(); 42 | $this->registerInteriaFlashMessage(); 43 | $this->registerProvider(); 44 | $this->registerRoutes(); 45 | 46 | $this->call('migrate'); 47 | $this->call('tenants:migrate'); 48 | } 49 | 50 | /** 51 | * Replace a given string within a given file. 52 | * 53 | * @param string $search 54 | * @param string $replace 55 | * @param string $path 56 | * @return void 57 | */ 58 | protected function replaceInFile($search, $replace, $path) 59 | { 60 | if (!is_file($path)) { 61 | return; 62 | } 63 | 64 | $content = file_get_contents($path); 65 | if (! str_contains($content, $replace)) { 66 | file_put_contents($path, str_replace($search, $replace, $content)); 67 | } 68 | } 69 | 70 | /** 71 | * Install the provider in the plugin.json file. 72 | * 73 | * @param string $after 74 | * @param string $name 75 | * @param string $group 76 | */ 77 | protected function installPluginProviderAfter(string $after, string $name, string $appConfigPath): void 78 | { 79 | $appConfig = file_get_contents($appConfigPath); 80 | 81 | $providers = Str::before(Str::after($appConfig, '\'providers\' => ServiceProvider::defaultProviders()->merge(['), sprintf('])->toArray(),', PHP_EOL)); 82 | 83 | if (! Str::contains($providers, $name)) { 84 | $modifiedProviders = str_replace( 85 | sprintf('%s,', $after), 86 | sprintf('%s,', $after).PHP_EOL.' '.sprintf('%s', $name), 87 | $providers, 88 | ); 89 | 90 | $this->replaceInFile( 91 | $providers, 92 | $modifiedProviders, 93 | $appConfigPath, 94 | ); 95 | } 96 | } 97 | 98 | /** 99 | * Install the provider in the plugin.json file. 100 | * 101 | * @param string $after 102 | * @param string $name 103 | * @param string $group 104 | */ 105 | protected function installBuildCommandAfter(string $after, string $name, string $packageJsonPath): void 106 | { 107 | $packageJson = file_get_contents($packageJsonPath); 108 | 109 | $scripts = Str::before(Str::after($packageJson, '"build": "'), sprintf('"', PHP_EOL)); 110 | 111 | if (! Str::contains($scripts, $name)) { 112 | $modifiedScripts = preg_replace( 113 | sprintf('/"?%s/', $after), 114 | sprintf('%s', $after).' && '.sprintf('%s', $name), 115 | $scripts, 116 | 1, 117 | ); 118 | 119 | $this->replaceInFile( 120 | $scripts, 121 | $modifiedScripts, 122 | $packageJsonPath, 123 | ); 124 | } 125 | } 126 | 127 | public function registerProvider() 128 | { 129 | $this->installPluginProviderAfter('App\Providers\RouteServiceProvider::class', 'App\Providers\TenancyServiceProvider::class, // <-- here', config_path('app.php')); 130 | } 131 | 132 | public function registerRoutes() 133 | { 134 | if (! is_file($tenantSessionMiddlewareFile = app_path('Http/Middleware/AuthenticateTenantSession.php'))) { 135 | copy(__DIR__.'/stubs/AuthenticateTenantSession.stub', $tenantSessionMiddlewareFile); 136 | } 137 | 138 | $this->replaceInFile(<<<'TXT' 139 | Route::middleware([ 140 | 'web', 141 | InitializeTenancyByDomain::class, 142 | PreventAccessFromCentralDomains::class, 143 | ])->group(function () { 144 | Route::get('/', function () { 145 | return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); 146 | }); 147 | }); 148 | TXT, 149 | <<<'TXT' 150 | \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::$onFail = function ($exception, $request, $next) { 151 | return redirect(config('app.url')); 152 | }; 153 | 154 | Route::middleware([ 155 | 'web', 156 | InitializeTenancyByDomain::class, 157 | PreventAccessFromCentralDomains::class, 158 | ])->group(function () { 159 | // Route::get('/', function () { 160 | // return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); 161 | // }); 162 | 163 | require __DIR__.'/web.php'; 164 | }); 165 | TXT, base_path('routes/tenant.php')); 166 | 167 | $this->replaceInFile(<<<'TXT' 168 | public function boot(): void 169 | { 170 | RateLimiter::for('api', function (Request $request) { 171 | return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); 172 | }); 173 | 174 | $this->routes(function () { 175 | Route::middleware('api') 176 | ->prefix('api') 177 | ->group(base_path('routes/api.php')); 178 | 179 | Route::middleware('web') 180 | ->group(base_path('routes/web.php')); 181 | }); 182 | } 183 | TXT, 184 | <<<'TXT' 185 | public function boot(): void 186 | { 187 | RateLimiter::for('api', function (Request $request) { 188 | return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); 189 | }); 190 | 191 | $this->routes(function () { 192 | foreach ($this->centralDomains() as $domain) { 193 | Route::middleware(['api', \App\Http\Middleware\AuthenticateTenantSession::class]) 194 | ->domain($domain) 195 | ->prefix('api') 196 | ->group(base_path('routes/api.php')); 197 | 198 | Route::middleware(['web', \App\Http\Middleware\AuthenticateTenantSession::class]) 199 | ->domain($domain) 200 | ->group(base_path('routes/web.php')); 201 | } 202 | }); 203 | } 204 | 205 | protected function centralDomains(): array 206 | { 207 | return config('tenancy.central_domains'); 208 | } 209 | TXT, app_path('Providers/RouteServiceProvider.php')); 210 | } 211 | 212 | public function registerDomains($domain = null) 213 | { 214 | if (! $domain) { 215 | $urlInfo = parse_url(config('app.url')); 216 | 217 | $domain = $urlInfo['host']; 218 | } 219 | 220 | if (! $domain) { 221 | return; 222 | } 223 | 224 | $this->replaceInFile(<<<'TXT' 225 | 'central_domains' => [ 226 | '127.0.0.1', 227 | 'localhost', 228 | ], 229 | TXT, 230 | <<<'TXT' 231 | 'central_domains' => [ 232 | '$domain', 233 | '127.0.0.1', 234 | 'localhost', 235 | ], 236 | TXT, config_path('tenancy.php')); 237 | } 238 | 239 | public function createTenantMigrationsDir() 240 | { 241 | $path = database_path('migrations/tenant'); 242 | 243 | if (!is_dir($path)) { 244 | @mkdir($path, 0755, true); 245 | @touch($path.'/.gitkeep'); 246 | } 247 | } 248 | 249 | public function setTenantPrefix() 250 | { 251 | $content = file_get_contents($filePath = config_path('tenancy.php')); 252 | 253 | if (str_contains($content, "url_override")) { 254 | return; 255 | } 256 | 257 | $newContent = str_replace( 258 | [ 259 | "use Stancl\Tenancy\Database\Models\Tenant;\n\nreturn [", 260 | "'tenant_model' => Tenant::class,", 261 | "'localhost',\n ],", 262 | "'prefix' => 'tenant',", 263 | "'suffix_base' => 'tenant',", 264 | "_base' => 'tenant',", 265 | "// '--force' => true,", 266 | "'public' => '%storage_path%/app/public/',\n ],", 267 | ], 268 | [ 269 | "use Stancl\Tenancy\Database\Models\Tenant;\n\n\$prefix = env('DB_DATABASE') . '_';\n\nreturn [", 270 | "'tenant_model' => \App\Models\Tenant::class,", 271 | "'localhost',\n\t\tstr_replace(['http://', 'https://'], '', trim(env('APP_URL', ''), '/')),\n ],", 272 | "'prefix' => \$prefix,", 273 | "'suffix_base' => \"tenants/\".\$prefix,", 274 | "_base' => \$prefix,", 275 | "'--force' => true,", 276 | "'public' => '%storage_path%/app/public/', 277 | ], 278 | 279 | /* 280 | * Use this to support Storage url method on local driver disks. 281 | * You should create a symbolic link which points to the public directory using command: artisan tenants:link 282 | * Then you can use tenant aware Storage url: Storage::disk('public')->url('file.jpg') 283 | * 284 | * See https://github.com/archtechx/tenancy/pull/689 285 | */ 286 | 'url_override' => [ 287 | // The array key is local disk (must exist in root_override) and value is public directory (%tenant_id% will be replaced with actual tenant id). 288 | 'public' => 'tenants/public-%tenant_id%', 289 | ],", 290 | ], 291 | $content 292 | ); 293 | file_put_contents($filePath, $newContent); 294 | } 295 | 296 | public function addInitTenantAction() 297 | { 298 | $tenantModelFile = app_path('Models/Tenant.php'); 299 | 300 | if (file_exists($tenantModelFile)) { 301 | return; 302 | } 303 | 304 | copy(__DIR__.'/stubs/tenant_model.stub', app_path('Models/Tenant.php')); 305 | 306 | $content = file_get_contents($filePath = base_path('app/Providers/TenancyServiceProvider.php')); 307 | 308 | $newContent = str_replace( 309 | [ 310 | "// Jobs\SeedDatabase::class,", 311 | "send(function (Events\TenantCreated \$event) { 312 | return \$event->tenant; 313 | })", 314 | "send(function (Events\TenantDeleted \$event) { 315 | return \$event->tenant; 316 | })", 317 | ], 318 | [ 319 | "Jobs\SeedDatabase::class,", 320 | "send(function (Events\TenantCreated \$event) { 321 | \App\Models\Tenant::createStorageLink(\$event->tenant); // <-- here. 322 | return \$event->tenant; 323 | })", 324 | "send(function (Events\TenantDeleted \$event) { 325 | \App\Models\Tenant::removeStorageLink(\$event->tenant); // <-- here. 326 | return \$event->tenant; 327 | })", 328 | ], 329 | $content 330 | ); 331 | file_put_contents($filePath, $newContent); 332 | } 333 | 334 | public function resetRegisterPasswordLength() 335 | { 336 | if (! class_exists(\App\Http\Controllers\Auth\RegisteredUserController::class)) { 337 | return; 338 | } 339 | 340 | $this->replaceInFile('Rules\Password::defaults()', "'min:6'", app_path('Http/Controllers/Auth/RegisteredUserController.php')); 341 | } 342 | 343 | public function initTenantMigrations() 344 | { 345 | $path = database_path('migrations/tenant'); 346 | 347 | $files = glob(database_path('migrations/*create_users_table*')); 348 | $createUsersTableMigration = $files ? $files[0] : null; 349 | if (! $createUsersTableMigration) { 350 | return; 351 | } 352 | 353 | $migrationFile = basename($createUsersTableMigration); 354 | $createUsersTableMigrationWithTenant = database_path("migrations/tenant/{$migrationFile}"); 355 | 356 | if (! is_file($createUsersTableMigrationWithTenant)) { 357 | copy($createUsersTableMigration, $createUsersTableMigrationWithTenant); 358 | } 359 | 360 | if (file_exists($path.'/.gitkeep')) { 361 | unlink($path.'/.gitkeep'); 362 | } 363 | } 364 | 365 | public function initTenantPublicAssets() 366 | { 367 | if (is_dir(base_path('public/build')) && is_dir(base_path('public/tenancy/assets'))) { 368 | $this->installBuildCommandAfter('vite build', 'cp -r public/build public/tenancy/assets/build', base_path('package.json')); 369 | } 370 | 371 | tap(new Filesystem, function ($files) { 372 | $path = public_path('tenancy/assets'); 373 | $files->ensureDirectoryExists($path); 374 | 375 | // build assets 376 | $build = public_path('build'); 377 | $buildWithTenancy = public_path('tenancy/assets/build'); 378 | 379 | $files->copyDirectory($build, $buildWithTenancy); 380 | }); 381 | 382 | 383 | } 384 | 385 | public function resetLoginLogic() 386 | { 387 | $this->replaceInFile(<<<'TXT' 388 | public function create(): Response 389 | { 390 | return Inertia::render('Auth/Login', [ 391 | 'canResetPassword' => Route::has('password.request'), 392 | 'status' => session('status'), 393 | ]); 394 | } 395 | TXT, 396 | <<<'TXT' 397 | public function create(): Response|RedirectResponse 398 | { 399 | if ($encryptData = \request('encryptData')) { 400 | $data = decrypt($encryptData); 401 | $userArray = $data['user'] ?? null; 402 | $user = \App\Models\User::where('name', $userArray['name'])->first(); 403 | auth()->login($user); 404 | 405 | return redirect('/login'); 406 | } 407 | 408 | return Inertia::render('Auth/Login', [ 409 | 'canResetPassword' => Route::has('password.request'), 410 | 'status' => session('status'), 411 | ]); 412 | } 413 | TXT, app_path('Http/Controllers/Auth/AuthenticatedSessionController.php')); 414 | } 415 | 416 | public function registerInteriaFlashMessage() 417 | { 418 | $this->replaceInFile(<<<'TXT' 419 | public function share(Request $request): array 420 | { 421 | return array_merge(parent::share($request), [ 422 | 'auth' => [ 423 | 'user' => $request->user(), 424 | ], 425 | 'ziggy' => function () use ($request) { 426 | return array_merge((new Ziggy)->toArray(), [ 427 | 'location' => $request->url(), 428 | ]); 429 | }, 430 | ]); 431 | } 432 | TXT, 433 | <<<'TXT' 434 | public function share(Request $request): array 435 | { 436 | return array_merge(parent::share($request), [ 437 | 'auth' => [ 438 | 'user' => $request->user(), 439 | ], 440 | 'ziggy' => function () use ($request) { 441 | return array_merge((new Ziggy)->toArray(), [ 442 | 'location' => $request->url(), 443 | ]); 444 | }, 445 | 'flash' => session()->all(), 446 | ]); 447 | } 448 | TXT, app_path('Http/Middleware/HandleInertiaRequests.php')); 449 | } 450 | } 451 | --------------------------------------------------------------------------------