From d8af9b4b43e58b319b271e011d7b0befec7b937d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 10 Jul 2025 01:08:49 +0200 Subject: [PATCH 1/7] remove JobBatchBootstrapper --- src/Bootstrappers/JobBatchBootstrapper.php | 39 ---------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/Bootstrappers/JobBatchBootstrapper.php diff --git a/src/Bootstrappers/JobBatchBootstrapper.php b/src/Bootstrappers/JobBatchBootstrapper.php deleted file mode 100644 index db4d8157..00000000 --- a/src/Bootstrappers/JobBatchBootstrapper.php +++ /dev/null @@ -1,39 +0,0 @@ -deprecatedNotice(); - } - - protected function deprecatedNotice(): void - { - if ($this->app->environment() == 'local' && $this->app->hasDebugModeEnabled()) { - throw new Exception("JobBatchBootstrapper is not supported anymore, please remove it from your tenancy config. Job batches should work out of the box in Laravel 11. If they don't, please open a bug report."); - } - } - - public function revert(): void - { - $this->deprecatedNotice(); - } -} From 91295f01e221b8a44a6e36a2d7c613d60a2d42ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 14 Jul 2025 21:44:12 +0200 Subject: [PATCH 2/7] fix origin identification: parse hostname when full URL is used --- src/Middleware/InitializeTenancyByOriginHeader.php | 6 +++++- tests/OriginHeaderIdentificationTest.php | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Middleware/InitializeTenancyByOriginHeader.php b/src/Middleware/InitializeTenancyByOriginHeader.php index 4d85dc5c..4aa4f342 100644 --- a/src/Middleware/InitializeTenancyByOriginHeader.php +++ b/src/Middleware/InitializeTenancyByOriginHeader.php @@ -10,6 +10,10 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma { public function getDomain(Request $request): string { - return $request->header('Origin', ''); + if ($origin = $request->header('Origin', '')) { + return parse_url($origin, PHP_URL_HOST) ?? $origin; + } + + return ''; } } diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index 071aa493..1d2eb4dc 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -36,6 +36,12 @@ test('origin identification works', function () { ->withHeader('Origin', 'foo.localhost') ->post('home') ->assertSee($tenant->id); + + // Test with a full URL - not just a hostname + pest() + ->withHeader('Origin', 'https://foo.localhost') + ->post('home') + ->assertSee($tenant->id); }); test('tenant routes are not accessible on central domains while using origin identification', function () { From 62624275cc5ebf136808e31448941931a974f5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 14 Jul 2025 21:48:30 +0200 Subject: [PATCH 3/7] phpstan fix --- src/Middleware/InitializeTenancyByOriginHeader.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Middleware/InitializeTenancyByOriginHeader.php b/src/Middleware/InitializeTenancyByOriginHeader.php index 4aa4f342..de016fca 100644 --- a/src/Middleware/InitializeTenancyByOriginHeader.php +++ b/src/Middleware/InitializeTenancyByOriginHeader.php @@ -11,7 +11,10 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma public function getDomain(Request $request): string { if ($origin = $request->header('Origin', '')) { - return parse_url($origin, PHP_URL_HOST) ?? $origin; + $host = parse_url($origin, PHP_URL_HOST) ?? $origin; + assert(is_string($host) && strlen($host) > 0); + + return $host; } return ''; From 0ef0104355584a2242fd55113710ffa747087d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 28 Jul 2025 17:08:07 +0200 Subject: [PATCH 4/7] Add MariaDB database manager config --- assets/config.php | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/config.php b/assets/config.php index 73becdee..5e231f2a 100644 --- a/assets/config.php +++ b/assets/config.php @@ -203,6 +203,7 @@ return [ 'managers' => [ 'sqlite' => Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager::class, 'mysql' => Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class, + 'mariadb' => Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class, 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, 'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class, From b2f2669885c932ccfc2c3d1be7047198e4985216 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 17:17:32 +0200 Subject: [PATCH 5/7] [4.x] Cloning: addTenantParameter(bool), domain(string|null) (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add test for the new clone action addTenantParameter property * Add $addTenantParameter properyt to the clone action * Fix code style (php-cs-fixer) * Update clone action annotation * Make addTenantParameter(false) sound by adding domain() logic to route cloning --------- Co-authored-by: github-actions[bot] Co-authored-by: Samuel Ć tancl --- .php-cs-fixer.php | 1 - src/Actions/CloneRoutesAsTenant.php | 91 ++++++++++++++++++++++------- tests/CloneActionTest.php | 52 +++++++++++++++++ 3 files changed, 122 insertions(+), 22 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index c0fe775c..d4ce4e41 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -21,7 +21,6 @@ $rules = [ 'blank_line_before_statement' => [ 'statements' => ['return'] ], - 'braces' => true, 'cast_spaces' => true, 'class_definition' => true, 'concat_space' => [ diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index c5818878..ec60d880 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -30,6 +30,11 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * By providing a callback to shouldClone(), you can change how it's determined if a route should be cloned if you don't want to use middleware flags. * * Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'. + * + * The addition of the tenant parameter can be controlled using addTenantParameter(true|false). Note that if you decide to disable + * tenant parameter addition, the routes MUST differ in domains. This can be controlled using the domain(string|null) method. The + * default behavior is to NOT set any domains on cloned routes -- unless specified otherwise using that method. + * * The parameter name and prefix can be changed e.g. to `/{team}` and `team.` by configuring the path resolver (tenantParameterName and tenantRouteNamePrefix). * Routes with names that are already prefixed won't be cloned - but that's just the default behavior. * The cloneUsing() method allows you to completely override the default behavior and customize how the cloned routes will be defined. @@ -43,8 +48,8 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * * Example usage: * ``` - * Route::get('/foo', fn () => true)->name('foo')->middleware('clone'); - * Route::get('/bar', fn () => true)->name('bar')->middleware('universal'); + * Route::get('/foo', ...)->name('foo')->middleware('clone'); + * Route::get('/bar', ...)->name('bar')->middleware('universal'); * * $cloneAction = app(CloneRoutesAsTenant::class); * @@ -54,10 +59,20 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * // Clone bar route as /{tenant}/bar and name it tenant.bar ('universal' middleware won't be present in the cloned route) * $cloneAction->cloneRoutesWithMiddleware(['universal'])->handle(); * - * Route::get('/baz', fn () => true)->name('baz'); + * Route::get('/baz', ...)->name('baz'); * * // Clone baz route as /{tenant}/bar and name it tenant.baz ('universal' middleware won't be present in the cloned route) * $cloneAction->cloneRoute('baz')->handle(); + * + * Route::domain('central.localhost')->get('/no-tenant-parameter', ...)->name('no-tenant-parameter')->middleware('clone'); + * + * // Clone baz route as /no-tenant-parameter and name it tenant.no-tenant-parameter (the route won't have the tenant parameter) + * // This can be useful with domain identification. Importantly, the original route MUST have a domain set. The domain of the + * // cloned route can be customized using domain(string|null). By default, the cloned route will not be scoped to a domain, + * // unless a domain() call is used. It's important to keep in mind that: + * // 1. When addTenantParameter(false) is used, the paths will be the same, thus domains must differ. + * // 2. If the original route (with the same path) has no domain, the cloned route will never be used due to registration order. + * $cloneAction->addTenantParameter(false)->cloneRoutesWithMiddleware(['clone'])->cloneRoute('no-tenant-parameter')->handle(); * ``` * * Calling handle() will also clear the $routesToClone array. @@ -70,6 +85,8 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; class CloneRoutesAsTenant { protected array $routesToClone = []; + protected bool $addTenantParameter = true; + protected string|null $domain = null; protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) protected Closure|null $shouldClone = null; protected array $cloneRoutesWithMiddleware = ['clone']; @@ -78,6 +95,7 @@ class CloneRoutesAsTenant protected Router $router, ) {} + /** Clone routes. This resets routesToClone() but not other config. */ public function handle(): void { // If no routes were specified using cloneRoute(), get all routes @@ -102,15 +120,37 @@ class CloneRoutesAsTenant $route = $this->router->getRoutes()->getByName($route); } - $this->copyMiscRouteProperties($route, $this->createNewRoute($route)); + $this->createNewRoute($route); } // Clean up the routesToClone array after cloning so that subsequent calls aren't affected $this->routesToClone = []; $this->router->getRoutes()->refreshNameLookups(); + $this->router->getRoutes()->refreshActionLookups(); } + /** + * Should a tenant parameter be added to the cloned route. + * + * The name of the parameter is controlled using PathTenantResolver::tenantParameterName(). + */ + public function addTenantParameter(bool $addTenantParameter): static + { + $this->addTenantParameter = $addTenantParameter; + + return $this; + } + + /** The domain the cloned route should use. Set to null if it shouldn't be scoped to a domain. */ + public function domain(string|null $domain): static + { + $this->domain = $domain; + + return $this; + } + + /** Provide a custom callback for cloning routes, instead of the default behavior. */ public function cloneUsing(Closure|null $cloneUsing): static { $this->cloneUsing = $cloneUsing; @@ -118,6 +158,7 @@ class CloneRoutesAsTenant return $this; } + /** Specify which middleware should serve as "flags" telling this action to clone those routes. */ public function cloneRoutesWithMiddleware(array $middleware): static { $this->cloneRoutesWithMiddleware = $middleware; @@ -125,6 +166,10 @@ class CloneRoutesAsTenant return $this; } + /** + * Provide a custom callback for determining whether a route should be cloned. + * Overrides the default middleware-based detection. + * */ public function shouldClone(Closure|null $shouldClone): static { $this->shouldClone = $shouldClone; @@ -132,6 +177,7 @@ class CloneRoutesAsTenant return $this; } + /** Clone an individual route. */ public function cloneRoute(Route|string $route): static { $this->routesToClone[] = $route; @@ -171,28 +217,31 @@ class CloneRoutesAsTenant $action->put('as', PathTenantResolver::tenantRouteNamePrefix() . $name); } - $action - ->put('middleware', $middleware) - ->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); + if ($this->domain) { + $action->put('domain', $this->domain); + } elseif ($action->offsetExists('domain')) { + $action->offsetUnset('domain'); + } + + $action->put('middleware', $middleware); + + if ($this->addTenantParameter) { + $action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); + } /** @var Route $newRoute */ $newRoute = $this->router->$method($uri, $action->toArray()); - return $newRoute; - } - - /** - * Copy misc properties of the original route to the new route. - */ - protected function copyMiscRouteProperties(Route $originalRoute, Route $newRoute): void - { + // Copy misc properties of the original route to the new route. $newRoute - ->setBindingFields($originalRoute->bindingFields()) - ->setFallback($originalRoute->isFallback) - ->setWheres($originalRoute->wheres) - ->block($originalRoute->locksFor(), $originalRoute->waitsFor()) - ->withTrashed($originalRoute->allowsTrashedBindings()) - ->setDefaults($originalRoute->defaults); + ->setBindingFields($route->bindingFields()) + ->setFallback($route->isFallback) + ->setWheres($route->wheres) + ->block($route->locksFor(), $route->waitsFor()) + ->withTrashed($route->allowsTrashedBindings()) + ->setDefaults($route->defaults); + + return $newRoute; } /** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */ diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 866babb5..656ad327 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -6,6 +6,7 @@ use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Illuminate\Support\Facades\Route as RouteFacade; use function Stancl\Tenancy\Tests\pest; +use Illuminate\Routing\Exceptions\UrlGenerationException; test('CloneRoutesAsTenant action clones routes with clone middleware by default', function () { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']); @@ -337,3 +338,54 @@ test('clone action can be used fluently', function() { expect(collect(RouteFacade::getRoutes()->get())->map->getName()) ->toContain('tenant.foo', 'tenant.bar', 'tenant.baz'); }); + +test('the cloned route can be scoped to a specified domain', function () { + RouteFacade::domain('foo.localhost')->get('/foo', fn () => in_array('tenant', request()->route()->middleware()) ? 'tenant' : 'central')->name('foo')->middleware('clone'); + + // Importantly, we CANNOT add a domain to the cloned route *if the original route didn't have a domain*. + // This is due to the route registration order - the more strongly scoped route (= route with a domain) + // must be registered first, so that Laravel tries that route first and only moves on if the domain check fails. + $cloneAction = app(CloneRoutesAsTenant::class); + // To keep the test simple we don't even need a tenant parameter + $cloneAction->domain('bar.localhost')->addTenantParameter(false)->handle(); + + expect(route('foo'))->toBe('http://foo.localhost/foo'); + expect(route('tenant.foo'))->toBe('http://bar.localhost/foo'); +}); + +test('tenant parameter addition can be controlled by setting addTenantParameter', function (bool $addTenantParameter) { + RouteFacade::domain('central.localhost') + ->get('/foo', fn () => in_array('tenant', request()->route()->middleware()) ? 'tenant' : 'central') + ->name('foo') + ->middleware('clone'); + + // By default this action also removes the domain + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction->addTenantParameter($addTenantParameter)->handle(); + + $clonedRoute = RouteFacade::getRoutes()->getByName('tenant.foo'); + + // We only use the route() helper here, since once a request is made + // the URL generator caches the request's domain and it affects route + // generation for routes that do not have domain() specified (tenant.foo) + expect(route('foo'))->toBe('http://central.localhost/foo'); + if ($addTenantParameter) + expect(route('tenant.foo', ['tenant' => 'abc']))->toBe('http://localhost/abc/foo'); + else + expect(route('tenant.foo'))->toBe('http://localhost/foo'); + + // Original route still works + $this->withoutExceptionHandling()->get(route('foo'))->assertSee('central'); + + if ($addTenantParameter) { + expect($clonedRoute->uri())->toContain('{tenant}'); + + $this->withoutExceptionHandling()->get('http://localhost/abc/foo')->assertSee('tenant'); + $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central'); + } else { + expect($clonedRoute->uri())->not()->toContain('{tenant}'); + + $this->withoutExceptionHandling()->get('http://localhost/foo')->assertSee('tenant'); + $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central'); + } +})->with([true, false]); From 475ea10ae906480dd7fa5c6ee74411f5330f3913 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 17:18:14 +0200 Subject: [PATCH 6/7] Delete unused import (#1382) --- tests/DatabasePreparationTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/DatabasePreparationTest.php b/tests/DatabasePreparationTest.php index d5641af4..1f3a4f09 100644 --- a/tests/DatabasePreparationTest.php +++ b/tests/DatabasePreparationTest.php @@ -9,7 +9,6 @@ use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\MigrateDatabase; use Stancl\Tenancy\Jobs\SeedDatabase; -use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Foundation\Auth\User as Authenticable; use Stancl\Tenancy\Tests\Etc\TestSeeder; From 0a48767c32be40eee2898ed1f3efc60b4cb1bd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 29 Jul 2025 22:25:07 +0200 Subject: [PATCH 7/7] Bump jobpipeline dependency to rc6 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3d3bd3eb..2ee61fb0 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "illuminate/support": "^12.0", "laravel/tinker": "^2.0", "ramsey/uuid": "^4.7.3", - "stancl/jobpipeline": "2.0.0-rc5", + "stancl/jobpipeline": "2.0.0-rc6", "stancl/virtualcolumn": "^1.5.0", "spatie/invade": "*", "laravel/prompts": "0.*"