From 01a06c92102ba2c5fccb91740eb8dba1855c9224 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 28 Jul 2025 12:11:09 +0200 Subject: [PATCH 01/46] Add LogTenancyBootstrapper --- src/Bootstrappers/LogTenancyBootstrapper.php | 105 +++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Bootstrappers/LogTenancyBootstrapper.php diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php new file mode 100644 index 000000000..6484cb03c --- /dev/null +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -0,0 +1,105 @@ + ['url' => 'webhookUrl'] + // or 'slack' => function ($config, $tenant) { ... } + public static array $channelOverrides = []; + + public function __construct( + protected Config $config, + protected LogManager $logManager, + ) {} + + public function bootstrap(Tenant $tenant): void + { + $this->defaultConfig = $this->config->get('logging.channels'); + $this->channels = $this->getChannels(); + + $this->configureChannels($this->channels, $tenant); + $this->forgetChannels(); + } + + public function revert(): void + { + $this->config->set('logging.channels', $this->defaultConfig); + + $this->forgetChannels(); + + $this->channels = []; + } + + protected function getChannels(): array { + $channels = [$this->config->get('logging.default')]; + + // If the default channel is stack, also get all the channels it contains + if ($channels[0] === 'stack') { + $channels = array_merge($channels, $this->config->get('logging.channels.stack.channels')); + } + + return $channels; + } + + protected function configureChannels(array $channels, Tenant $tenant): void { + foreach ($channels as $channel) { + if (in_array($channel, array_keys(static::$channelOverrides))) { + // Override specified channel's config as specified in the $channelOverrides property + // Takes precedence over the storage path channels handling + // The override is an array, use tenant property for overriding the channel config (the default approach) + if (is_array(static::$channelOverrides[$channel])) { + foreach (static::$channelOverrides[$channel] as $channelConfigKey => $tenantProperty) { + // E.g. set 'slack' channel's 'url' to $tenant->webhookUrl + $this->config->set("logging.channels.{$channel}.{$channelConfigKey}", $tenant->$tenantProperty); + } + } + + // If the override is a closure, call it with the config and tenant + // This allows for more custom configurations + if (static::$channelOverrides[$channel] instanceof Closure) { + static::$channelOverrides[$channel]($this->config, $tenant); + } + } else if (in_array($channel, static::$storagePathChannels)) { + // Default handling for storage path channels ('single', 'daily') + // Can be overriden by the $channelOverrides property + // Set the log path to storage_path('logs/laravel.log') for the tenant + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" + $this->config->set("logging.channels.{$channel}.path", storage_path("logs/laravel.log")); + } + } + } + + protected function forgetChannels(): void { + // Forget the channels so that they can be re-resolved with the new config on the next log attempt + foreach ($this->channels as $channel) { + $this->logManager->forgetChannel($channel); + } + } +} From 96a05cdce8f05cf053dd9dfc0861de52112838ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 28 Jul 2025 10:11:35 +0000 Subject: [PATCH 02/46] Fix code style (php-cs-fixer) --- src/Bootstrappers/LogTenancyBootstrapper.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 6484cb03c..63ba2ee50 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -5,10 +5,10 @@ namespace Stancl\Tenancy\Bootstrappers; use Closure; +use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Log\LogManager; -use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\TenancyBootstrapper; -use Illuminate\Contracts\Config\Repository as Config; +use Stancl\Tenancy\Contracts\Tenant; /** * This bootstrapper allows modifying the logs so that they're tenant-specific. @@ -57,7 +57,8 @@ public function revert(): void $this->channels = []; } - protected function getChannels(): array { + protected function getChannels(): array + { $channels = [$this->config->get('logging.default')]; // If the default channel is stack, also get all the channels it contains @@ -68,7 +69,8 @@ protected function getChannels(): array { return $channels; } - protected function configureChannels(array $channels, Tenant $tenant): void { + protected function configureChannels(array $channels, Tenant $tenant): void + { foreach ($channels as $channel) { if (in_array($channel, array_keys(static::$channelOverrides))) { // Override specified channel's config as specified in the $channelOverrides property @@ -86,17 +88,18 @@ protected function configureChannels(array $channels, Tenant $tenant): void { if (static::$channelOverrides[$channel] instanceof Closure) { static::$channelOverrides[$channel]($this->config, $tenant); } - } else if (in_array($channel, static::$storagePathChannels)) { + } elseif (in_array($channel, static::$storagePathChannels)) { // Default handling for storage path channels ('single', 'daily') // Can be overriden by the $channelOverrides property // Set the log path to storage_path('logs/laravel.log') for the tenant // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" - $this->config->set("logging.channels.{$channel}.path", storage_path("logs/laravel.log")); + $this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log')); } } } - protected function forgetChannels(): void { + protected function forgetChannels(): void + { // Forget the channels so that they can be re-resolved with the new config on the next log attempt foreach ($this->channels as $channel) { $this->logManager->forgetChannel($channel); From 50853a3c4509e405d3f967488dde5b789164629f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 10:59:56 +0200 Subject: [PATCH 03/46] Test LogTenancyBootstrapper logic (low-level tests) --- .../LogTenancyBootstrapperTest.php | 227 ++++++++++++++++++ tests/TestCase.php | 2 + 2 files changed, 229 insertions(+) create mode 100644 tests/Bootstrappers/LogTenancyBootstrapperTest.php diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php new file mode 100644 index 000000000..cd74d1aef --- /dev/null +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -0,0 +1,227 @@ + [ + // FilesystemTenancyBootstrapper needed for storage path channels (added in tests that check the storage path channel logic) + LogTenancyBootstrapper::class, + ], + ]); + + // Reset static properties + LogTenancyBootstrapper::$channelOverrides = []; + LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +afterEach(function () { + LogTenancyBootstrapper::$channelOverrides = []; + LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; +}); + +test('storage path channels get tenant-specific paths', function () { + // Note that for LogTenancyBootstrapper to change the paths correctly, + // the bootstrapper MUST run after FilesystemTenancyBootstrapper. + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + ]); + + $tenant = Tenant::create(); + + // Storage path channels are 'single' and 'daily' by default. + // This can be customized via LogTenancyBootstrapper::$storagePathChannels. + foreach (LogTenancyBootstrapper::$storagePathChannels as $channel) { + config(['logging.default' => $channel]); + + $originalPath = config("logging.channels.{$channel}.path"); + + tenancy()->initialize($tenant); + + // Path should now point to the log in the tenant's storage directory + $tenantLogPath = "storage/tenant{$tenant->id}/logs/laravel.log"; + expect(config("logging.channels.{$channel}.path")) + ->not()->toBe($originalPath) + ->toEndWith($tenantLogPath); + + tenancy()->end(); + + // Path should be reverted + expect(config("logging.channels.{$channel}.path"))->toBe($originalPath); + } +}); + +test('all channels included in the log stack get processed', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + 'logging.default' => 'stack', + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + ]); + + $originalSinglePath = config('logging.channels.single.path'); + $originalDailyPath = config('logging.channels.daily.path'); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Both channels in the stack should be updated + expect(config('logging.channels.single.path'))->not()->toBe($originalSinglePath); + expect(config('logging.channels.daily.path'))->not()->toBe($originalDailyPath); + + tenancy()->end(); + + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); + expect(config('logging.channels.daily.path'))->toBe($originalDailyPath); +}); + +test('channel overrides work correctly with both arrays and closures', function () { + config([ + 'logging.default' => 'slack', + 'logging.channels.slack' => [ + 'driver' => 'slack', + 'url' => $originalSlackUrl = 'https://default-webhook.example.com', + 'username' => 'Default', + ], + ]); + + $tenant = Tenant::create(['id' => 'tenant1', 'webhookUrl' => 'https://tenant-webhook.example.com']); + + // Specify channel override for 'slack' channel using an array + LogTenancyBootstrapper::$channelOverrides = [ + 'slack' => [ + 'url' => 'webhookUrl', // $tenant->webhookUrl will be used + ], + ]; + + tenancy()->initialize($tenant); + + expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl); + expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username -- remains default unless specified + + tenancy()->end(); + + // After tenancy ends, the original config should be restored + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + + // Now, use closure to set the slack username to $tenant->id (tenant1) + LogTenancyBootstrapper::$channelOverrides['slack'] = function ($config, $tenant) { + $config->set('logging.channels.slack.username', $tenant->id); + }; + + tenancy()->initialize($tenant); + + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); // Unchanged + expect(config('logging.channels.slack.username'))->toBe($tenant->id); + + tenancy()->end(); + + // Config reverted back to original + expect(config('logging.channels.slack.username'))->toBe('Default'); +}); + +test('channel overrides take precedence over the default storage path channel updating logic', function () { + config(['logging.default' => 'single']); + + $tenant = Tenant::create(['id' => 'tenant1']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'single' => function ($config, $tenant) { + $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); + }, + ]; + + tenancy()->initialize($tenant); + + // Should use override, not the default storage path updating behavior + expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); +}); + +test('multiple channel overrides work together', function () { + config([ + 'logging.default' => 'stack', + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['slack', 'single'], + ], + ]); + + $originalSinglePath = config('logging.channels.single.path'); + $originalSlackUrl = config('logging.channels.slack.url'); + + $tenant = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'https://tenant-slack.example.com']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'slack' => ['url' => 'slackUrl'], + 'single' => function ($config, $tenant) { + $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); + }, + ]; + + tenancy()->initialize($tenant); + + expect(config('logging.channels.slack.url'))->toBe('https://tenant-slack.example.com'); + expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); + + tenancy()->end(); + + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); +}); + +test('channels are forgotten and re-resolved during bootstrap and revert', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + 'logging.default' => 'single' + ]); + + $logManager = app('log'); + $originalChannel = $logManager->channel('single'); + $originalSinglePath = config('logging.channels.single.path'); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // After bootstrap, the channel should be a new instance with the updated config + $tenantChannel = $logManager->channel('single'); + $tenantSingleChannelPath = $tenantChannel->getLogger()->getHandlers()[0]->getUrl(); + + expect($tenantChannel)->not()->toBe($originalChannel); + expect($tenantSingleChannelPath) + ->not()->toBe($originalSinglePath) + ->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log"); + + tenancy()->end(); + + // After revert, the channel should get re-resolved with the original config + $currentChannel = $logManager->channel('single'); + $currentChannelPath = $currentChannel->getLogger()->getHandlers()[0]->getUrl(); + + expect($currentChannel)->not()->toBe($tenantChannel); + expect($currentChannelPath)->toBe($originalSinglePath); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 47af9e7db..0999c117a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -24,6 +24,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -187,6 +188,7 @@ protected function getEnvironmentSetUp($app) $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(LogTenancyBootstrapper::class); } protected function getPackageProviders($app) From b80d7b3996459eda5655bf27ff1d744b89752092 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 11:44:32 +0200 Subject: [PATCH 04/46] Test real usage with storage path-based channels --- .../LogTenancyBootstrapperTest.php | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index cd74d1aef..70f775f2b 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -225,3 +225,125 @@ expect($currentChannel)->not()->toBe($tenantChannel); expect($currentChannelPath)->toBe($originalSinglePath); }); + +// Test real usage +test('logs are written to tenant-specific files and do not leak between contexts', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + 'logging.default' => 'single', + ]); + + $centralLogPath = storage_path('logs/laravel.log'); + + logger('central'); + + expect(file_get_contents($centralLogPath))->toContain('central'); + + [$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])]; + + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) { + logger($tenant->id); + + $tenantLogPath = storage_path('logs/laravel.log'); + + // The log gets saved to the tenant's storage directory (default behavior) + expect($tenantLogPath) + ->not()->toBe($centralLogPath) + ->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log"); + + expect(file_get_contents($tenantLogPath)) + ->toContain($tenant->id) + ->not()->toContain('central'); + }); + + // Tenant log messages didn't leak into central log + expect(file_get_contents($centralLogPath)) + ->toContain('central') + ->not()->toContain('tenant1') + ->not()->toContain('tenant2'); + + // Tenant log messages didn't leak to logs of other tenants + tenancy()->initialize($tenant1); + + expect(file_get_contents(storage_path('logs/laravel.log'))) + ->toContain('tenant1') + ->not()->toContain('central') + ->not()->toContain('tenant2'); + + tenancy()->initialize($tenant2); + + expect(file_get_contents(storage_path('logs/laravel.log'))) + ->toContain('tenant2') + ->not()->toContain('central') + ->not()->toContain('tenant1'); + + // Overriding the channels also works + // Channel overrides also override the default behavior for the storage path-based channels + $tenant = Tenant::create(['id' => 'override-tenant']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'single' => function ($config, $tenant) { + // The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log + $config->set('logging.channels.single.path', storage_path("logs/custom-{$tenant->id}.log")); + }, + ]; + + // Tenant context log (should use custom path due to override) + tenancy()->initialize($tenant); + + logger('tenant-override'); + + expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override'); +}); + +test('stack logs are written to all configured channels with tenant-specific paths', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + 'logging.default' => 'stack', + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + ]); + + $tenant = Tenant::create(['id' => 'stack-tenant']); + $today = now()->format('Y-m-d'); + + // Central context stack log + logger('central'); + $centralSingleLogPath = storage_path('logs/laravel.log'); + $centralDailyLogPath = storage_path("logs/laravel-{$today}.log"); + + expect(file_get_contents($centralSingleLogPath))->toContain('central'); + expect(file_get_contents($centralDailyLogPath))->toContain('central'); + + // Tenant context stack log + tenancy()->initialize($tenant); + logger('tenant'); + $tenantSingleLogPath = storage_path('logs/laravel.log'); + $tenantDailyLogPath = storage_path("logs/laravel-{$today}.log"); + + expect(file_get_contents($tenantSingleLogPath))->toContain('tenant'); + expect(file_get_contents($tenantDailyLogPath))->toContain('tenant'); + + // Verify tenant logs don't contain central messages + expect(file_get_contents($tenantSingleLogPath))->not()->toContain('central'); + expect(file_get_contents($tenantDailyLogPath))->not()->toContain('central'); + + tenancy()->end(); + + // Verify central logs still only contain the central messages + expect(file_get_contents($centralSingleLogPath)) + ->toContain('central') + ->not()->toContain('tenant'); + + expect(file_get_contents($centralDailyLogPath)) + ->toContain('central') + ->not()->toContain('tenant'); +}); From a13110c88002ca9bbbc4a843199b972e663e1e37 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 12:19:04 +0200 Subject: [PATCH 05/46] Test real usage with slack channel (the bootstrapper updates the webhook used by the slack channel correctly) --- .../LogTenancyBootstrapperTest.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 70f775f2b..c5613b9e0 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -347,3 +347,54 @@ ->toContain('central') ->not()->toContain('tenant'); }); + +test('slack channel uses correct webhook urls', function () { + config([ + 'logging.default' => 'slack', + 'logging.channels.slack.url' => 'central-webhook', + 'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical' + ]); + + $tenant1 = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'tenant1-webhook']); + $tenant2 = Tenant::create(['id' => 'tenant2', 'slackUrl' => 'tenant2-webhook']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'slack' => ['url' => 'slackUrl'], + ]; + + // Test central context - should attempt to use central webhook + try { + logger('central'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('central-webhook'); + } + + // Test tenant 1 context - should attempt to use tenant1 webhook + tenancy()->initialize($tenant1); + + try { + logger('tenant1'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('tenant1-webhook'); + } + + tenancy()->end(); + + // Test tenant 2 context - should attempt to use tenant2 webhook + tenancy()->initialize($tenant2); + + try { + logger('tenant2'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('tenant2-webhook'); + } + + tenancy()->end(); + + // Back to central - should use central webhook again + try { + logger('central'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('central-webhook'); + } +}); From 718afd306933243b4b7ce34adf6c9ee85ad21c3e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 12:24:06 +0200 Subject: [PATCH 06/46] Simplify the slack channel usage test --- .../LogTenancyBootstrapperTest.php | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index c5613b9e0..743d7124b 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -363,35 +363,24 @@ ]; // Test central context - should attempt to use central webhook + // Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here. + // Instead, we catch the exception and check the error message which contains the actual webhook URL. try { logger('central'); } catch (Exception $e) { expect($e->getMessage())->toContain('central-webhook'); } - // Test tenant 1 context - should attempt to use tenant1 webhook - tenancy()->initialize($tenant1); - - try { - logger('tenant1'); - } catch (Exception $e) { - expect($e->getMessage())->toContain('tenant1-webhook'); - } - - tenancy()->end(); - - // Test tenant 2 context - should attempt to use tenant2 webhook - tenancy()->initialize($tenant2); - - try { - logger('tenant2'); - } catch (Exception $e) { - expect($e->getMessage())->toContain('tenant2-webhook'); - } - - tenancy()->end(); + // Slack channel should attempt to use the tenant-specific webhooks + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) { + try { + logger($tenant->id); + } catch (Exception $e) { + expect($e->getMessage())->toContain($tenant->slackUrl); + } + }); - // Back to central - should use central webhook again + // Central context, central webhook should be used again try { logger('central'); } catch (Exception $e) { From a806df063d3b8d8e65b2c4b5014b41cc8b8dc6b0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 12:25:02 +0200 Subject: [PATCH 07/46] Stop using real domains in the tests --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 743d7124b..93709b53b 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -101,12 +101,12 @@ 'logging.default' => 'slack', 'logging.channels.slack' => [ 'driver' => 'slack', - 'url' => $originalSlackUrl = 'https://default-webhook.example.com', + 'url' => $originalSlackUrl = 'default-webhook', 'username' => 'Default', ], ]); - $tenant = Tenant::create(['id' => 'tenant1', 'webhookUrl' => 'https://tenant-webhook.example.com']); + $tenant = Tenant::create(['id' => 'tenant1', 'webhookUrl' => 'tenant-webhook']); // Specify channel override for 'slack' channel using an array LogTenancyBootstrapper::$channelOverrides = [ @@ -170,7 +170,7 @@ $originalSinglePath = config('logging.channels.single.path'); $originalSlackUrl = config('logging.channels.slack.url'); - $tenant = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'https://tenant-slack.example.com']); + $tenant = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'tenant-slack']); LogTenancyBootstrapper::$channelOverrides = [ 'slack' => ['url' => 'slackUrl'], @@ -181,7 +181,7 @@ tenancy()->initialize($tenant); - expect(config('logging.channels.slack.url'))->toBe('https://tenant-slack.example.com'); + expect(config('logging.channels.slack.url'))->toBe('tenant-slack'); expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); tenancy()->end(); From ec4752881ce993b5fe080839648092e260b89c99 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 12:48:11 +0200 Subject: [PATCH 08/46] Refactor bootstrapper, make comments more concise --- src/Bootstrappers/LogTenancyBootstrapper.php | 71 +++++++++++--------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 63ba2ee50..1493a6109 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -11,27 +11,33 @@ use Stancl\Tenancy\Contracts\Tenant; /** - * This bootstrapper allows modifying the logs so that they're tenant-specific. + * Bootstrapper for tenant-specific logging configuration. * - * If the used channel is 'single' or 'daily', it will set the log path to - * storage_path('logs/laravel.log') for the tenant (so the tenant log will be located at storage/tenantX/logs/laravel.log). - * For this to work correctly, the bootstrapper needs to run after FilesystemTenancyBootstrapper. + * Automatically configures storage path channels (single, daily) to use tenant storage directories. + * Supports custom channel overrides via the $channelOverrides property. * - * Channels that don't use the storage path (e.g. 'slack') will be modified as specified in the $channelOverrides property. - * - * You can also completely override configuration of specific channels by specifying a closure in the $channelOverrides property. + * Note: Must run after FilesystemTenancyBootstrapper for storage path channels to work correctly. */ class LogTenancyBootstrapper implements TenancyBootstrapper { protected array $defaultConfig = []; - // The channels that were modified (set during bootstrap so that they can be reverted later) + /** Channels that were modified during bootstrap (for reverting later) */ protected array $channels = []; + /** + * Log channels that use storage paths for storing the logs. + * Requires FilesystemTenancyBootstrapper to run first. + */ public static array $storagePathChannels = ['single', 'daily']; - // E.g. 'slack' => ['url' => 'webhookUrl'] - // or 'slack' => function ($config, $tenant) { ... } + /** + * Custom channel configuration overrides. + * + * Examples: + * - Array mapping: ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url + * - Closure: ['slack' => fn ($config, $tenant) => $config->set('logging.channels.slack.url', $tenant->slackUrl)] + */ public static array $channelOverrides = []; public function __construct( @@ -57,6 +63,7 @@ public function revert(): void $this->channels = []; } + /** Get all channels that need to be configured, including channels in the log stack. */ protected function getChannels(): array { $channels = [$this->config->get('logging.default')]; @@ -69,38 +76,42 @@ protected function getChannels(): array return $channels; } + /** Configure channels for the tenant context. */ protected function configureChannels(array $channels, Tenant $tenant): void { foreach ($channels as $channel) { - if (in_array($channel, array_keys(static::$channelOverrides))) { - // Override specified channel's config as specified in the $channelOverrides property - // Takes precedence over the storage path channels handling - // The override is an array, use tenant property for overriding the channel config (the default approach) - if (is_array(static::$channelOverrides[$channel])) { - foreach (static::$channelOverrides[$channel] as $channelConfigKey => $tenantProperty) { - // E.g. set 'slack' channel's 'url' to $tenant->webhookUrl - $this->config->set("logging.channels.{$channel}.{$channelConfigKey}", $tenant->$tenantProperty); - } - } - - // If the override is a closure, call it with the config and tenant - // This allows for more custom configurations - if (static::$channelOverrides[$channel] instanceof Closure) { - static::$channelOverrides[$channel]($this->config, $tenant); - } + if (isset(static::$channelOverrides[$channel])) { + $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); } elseif (in_array($channel, static::$storagePathChannels)) { - // Default handling for storage path channels ('single', 'daily') - // Can be overriden by the $channelOverrides property - // Set the log path to storage_path('logs/laravel.log') for the tenant + // Set storage path channels to use tenant-specific directory (default behavior) // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" $this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log')); } } } + /** + * Apply channel override configuration. + */ + protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void + { + if (is_array($override)) { + // Map tenant properties to channel config keys + foreach ($override as $configKey => $tenantProperty) { + $this->config->set("logging.channels.{$channel}.{$configKey}", $tenant->$tenantProperty); + } + } elseif ($override instanceof Closure) { + // Execute custom configuration closure + $override($this->config, $tenant); + } + } + + /** + * Forget channels so they can be re-resolved + * with updated configuration on the next log attempt. + */ protected function forgetChannels(): void { - // Forget the channels so that they can be re-resolved with the new config on the next log attempt foreach ($this->channels as $channel) { $this->logManager->forgetChannel($channel); } From 8cd35d39857b8d84ef275f565ecb12cf0bd12129 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 12:50:18 +0200 Subject: [PATCH 09/46] Add @see to bootstrapper docblock --- src/Bootstrappers/LogTenancyBootstrapper.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 1493a6109..9c51c6027 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -17,6 +17,8 @@ * Supports custom channel overrides via the $channelOverrides property. * * Note: Must run after FilesystemTenancyBootstrapper for storage path channels to work correctly. + * + * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper */ class LogTenancyBootstrapper implements TenancyBootstrapper { From 62a0e395c37fd21048230f67162b5de4ce61e6e1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 13:13:31 +0200 Subject: [PATCH 10/46] Delete redundant test, test the same logic in the one larger test --- .../LogTenancyBootstrapperTest.php | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 93709b53b..906a81ef6 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -98,46 +98,55 @@ test('channel overrides work correctly with both arrays and closures', function () { config([ - 'logging.default' => 'slack', + 'logging.default' => 'stack', + 'logging.channels.stack.channels' => ['slack', 'single'], 'logging.channels.slack' => [ - 'driver' => 'slack', 'url' => $originalSlackUrl = 'default-webhook', 'username' => 'Default', ], ]); + $originalSinglePath = config('logging.channels.single.path'); + $tenant = Tenant::create(['id' => 'tenant1', 'webhookUrl' => 'tenant-webhook']); - // Specify channel override for 'slack' channel using an array + // Test both array mapping and closure-based overrides LogTenancyBootstrapper::$channelOverrides = [ - 'slack' => [ - 'url' => 'webhookUrl', // $tenant->webhookUrl will be used - ], + 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl + 'single' => function ($config, $tenant) { + $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); + }, ]; tenancy()->initialize($tenant); + // Array mapping overrides work expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl); - expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username -- remains default unless specified + expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden + + // Closure overrides work + expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); tenancy()->end(); // After tenancy ends, the original config should be restored expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); - // Now, use closure to set the slack username to $tenant->id (tenant1) - LogTenancyBootstrapper::$channelOverrides['slack'] = function ($config, $tenant) { - $config->set('logging.channels.slack.username', $tenant->id); - }; + // Test that we can also change array mappings to different properties + $tenant->update(['slackUrl' => 'tenant-slack']); - tenancy()->initialize($tenant); + LogTenancyBootstrapper::$channelOverrides = [ + 'slack' => ['url' => 'slackUrl'], + ]; - expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); // Unchanged - expect(config('logging.channels.slack.username'))->toBe($tenant->id); + tenancy()->initialize($tenant); + expect(config('logging.channels.slack.url'))->toBe($tenant->slackUrl); + expect(config('logging.channels.slack.username'))->toBe('Default'); // Still remains default since we only override url tenancy()->end(); - // Config reverted back to original + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); expect(config('logging.channels.slack.username'))->toBe('Default'); }); @@ -158,38 +167,6 @@ expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); }); -test('multiple channel overrides work together', function () { - config([ - 'logging.default' => 'stack', - 'logging.channels.stack' => [ - 'driver' => 'stack', - 'channels' => ['slack', 'single'], - ], - ]); - - $originalSinglePath = config('logging.channels.single.path'); - $originalSlackUrl = config('logging.channels.slack.url'); - - $tenant = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'tenant-slack']); - - LogTenancyBootstrapper::$channelOverrides = [ - 'slack' => ['url' => 'slackUrl'], - 'single' => function ($config, $tenant) { - $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); - }, - ]; - - tenancy()->initialize($tenant); - - expect(config('logging.channels.slack.url'))->toBe('tenant-slack'); - expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); - - tenancy()->end(); - - expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); - expect(config('logging.channels.single.path'))->toBe($originalSinglePath); -}); - test('channels are forgotten and re-resolved during bootstrap and revert', function () { config([ 'tenancy.bootstrappers' => [ From 582243c53f79bd776837306a540a9440e22a7bcb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 29 Jul 2025 13:22:02 +0200 Subject: [PATCH 11/46] Clarify test name --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 906a81ef6..e5282b9aa 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -32,7 +32,7 @@ LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; }); -test('storage path channels get tenant-specific paths', function () { +test('storage path channels get tenant-specific paths by default', function () { // Note that for LogTenancyBootstrapper to change the paths correctly, // the bootstrapper MUST run after FilesystemTenancyBootstrapper. config([ From bd44036a9fc00244859f2e58371dd3d933f40a4b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 15:15:33 +0200 Subject: [PATCH 12/46] By default, only override the config if the override tenant property is set (otherwise, just skip the override and keep the default config value) --- src/Bootstrappers/LogTenancyBootstrapper.php | 4 +++- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 9c51c6027..31f75667d 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -100,7 +100,9 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid if (is_array($override)) { // Map tenant properties to channel config keys foreach ($override as $configKey => $tenantProperty) { - $this->config->set("logging.channels.{$channel}.{$configKey}", $tenant->$tenantProperty); + if ($tenant->$tenantProperty) { + $this->config->set("logging.channels.{$channel}.{$configKey}", $tenant->$tenantProperty); + } } } elseif ($override instanceof Closure) { // Execute custom configuration closure diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index e5282b9aa..1efef35dc 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -150,6 +150,20 @@ expect(config('logging.channels.slack.username'))->toBe('Default'); }); +test('channel config keys remains unchanged if the specified tenant override property is not set', function() { + config(['logging.default' => 'slack']); + config(['logging.channels.slack.username' => 'Default username']); + + LogTenancyBootstrapper::$channelOverrides = [ + 'slack' => ['username' => 'nonExistentProperty'], // $tenant->nonExistentProperty + ]; + + tenancy()->initialize(Tenant::create()); + + // The username should remain unchanged since the tenant property is not set + expect(config('logging.channels.slack.username'))->toBe('Default username'); +}); + test('channel overrides take precedence over the default storage path channel updating logic', function () { config(['logging.default' => 'single']); From 63bf4bf80ef6377b191ca9020bdd4cb2571fe6ce Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 15:30:14 +0200 Subject: [PATCH 13/46] Clarify bootstrapper comments --- src/Bootstrappers/LogTenancyBootstrapper.php | 27 ++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 31f75667d..6d2ec164d 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -11,12 +11,14 @@ use Stancl\Tenancy\Contracts\Tenant; /** - * Bootstrapper for tenant-specific logging configuration. + * This bootstrapper makes it possible to configure tenant-specific logging. * - * Automatically configures storage path channels (single, daily) to use tenant storage directories. - * Supports custom channel overrides via the $channelOverrides property. + * By default, the storage path channels ('single' and 'daily' by default, feel free to configure that using the $storagePathChannels property) + * are configured to use tenant storage directories. For this to work correctly, + * this bootstrapper must run *after* FilesystemTenancyBootstrapper. + * + * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the ). * - * Note: Must run after FilesystemTenancyBootstrapper for storage path channels to work correctly. * * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper */ @@ -28,8 +30,8 @@ class LogTenancyBootstrapper implements TenancyBootstrapper protected array $channels = []; /** - * Log channels that use storage paths for storing the logs. - * Requires FilesystemTenancyBootstrapper to run first. + * Log channels that use the storage_path() helper for storing the logs. + * Requires FilesystemTenancyBootstrapper to run before this bootstrapper. */ public static array $storagePathChannels = ['single', 'daily']; @@ -37,7 +39,7 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * Custom channel configuration overrides. * * Examples: - * - Array mapping: ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url + * - Array mapping: ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is set, otherwise, the override is ignored) * - Closure: ['slack' => fn ($config, $tenant) => $config->set('logging.channels.slack.url', $tenant->slackUrl)] */ public static array $channelOverrides = []; @@ -65,9 +67,10 @@ public function revert(): void $this->channels = []; } - /** Get all channels that need to be configured, including channels in the log stack. */ + /** Channels to configure (including the channels in the log stack). */ protected function getChannels(): array { + // Get the currently used (default) logging channel $channels = [$this->config->get('logging.default')]; // If the default channel is stack, also get all the channels it contains @@ -92,20 +95,18 @@ protected function configureChannels(array $channels, Tenant $tenant): void } } - /** - * Apply channel override configuration. - */ protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void { if (is_array($override)) { - // Map tenant properties to channel config keys + // Map tenant properties to channel config keys. + // If the tenant property is not set, + // the override is ignored and the channel config key's value remains unchanged. foreach ($override as $configKey => $tenantProperty) { if ($tenant->$tenantProperty) { $this->config->set("logging.channels.{$channel}.{$configKey}", $tenant->$tenantProperty); } } } elseif ($override instanceof Closure) { - // Execute custom configuration closure $override($this->config, $tenant); } } From 81daa9d0544ad10294970d8ea086584bda898a24 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 16:16:32 +0200 Subject: [PATCH 14/46] Simplify test --- .../LogTenancyBootstrapperTest.php | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 1efef35dc..bebbb8412 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -132,22 +132,7 @@ // After tenancy ends, the original config should be restored expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); expect(config('logging.channels.single.path'))->toBe($originalSinglePath); - - // Test that we can also change array mappings to different properties - $tenant->update(['slackUrl' => 'tenant-slack']); - - LogTenancyBootstrapper::$channelOverrides = [ - 'slack' => ['url' => 'slackUrl'], - ]; - - tenancy()->initialize($tenant); - expect(config('logging.channels.slack.url'))->toBe($tenant->slackUrl); - expect(config('logging.channels.slack.username'))->toBe('Default'); // Still remains default since we only override url - - tenancy()->end(); - - expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); - expect(config('logging.channels.slack.username'))->toBe('Default'); + expect(config('logging.channels.slack.username'))->toBe('Default'); // Not changed at all }); test('channel config keys remains unchanged if the specified tenant override property is not set', function() { From 42c837d96735dae5cb484d7e7a1089f7c8b53adb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 16:25:40 +0200 Subject: [PATCH 15/46] Refactor bootstrapper, provide more info in comments --- src/Bootstrappers/LogTenancyBootstrapper.php | 67 ++++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 6d2ec164d..05619bb01 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -13,12 +13,12 @@ /** * This bootstrapper makes it possible to configure tenant-specific logging. * - * By default, the storage path channels ('single' and 'daily' by default, feel free to configure that using the $storagePathChannels property) - * are configured to use tenant storage directories. For this to work correctly, - * this bootstrapper must run *after* FilesystemTenancyBootstrapper. - * - * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the ). + * By default, the storage path channels ('single' and 'daily' by default, + * but feel free to customize that using the $storagePathChannels property) + * are configured to use tenant storage directories. + * For this to work correctly, this bootstrapper must run *after* FilesystemTenancyBootstrapper. * + * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the property's docblock). * * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper */ @@ -26,9 +26,6 @@ class LogTenancyBootstrapper implements TenancyBootstrapper { protected array $defaultConfig = []; - /** Channels that were modified during bootstrap (for reverting later) */ - protected array $channels = []; - /** * Log channels that use the storage_path() helper for storing the logs. * Requires FilesystemTenancyBootstrapper to run before this bootstrapper. @@ -39,7 +36,7 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * Custom channel configuration overrides. * * Examples: - * - Array mapping: ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is set, otherwise, the override is ignored) + * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is set, otherwise, the override is ignored) * - Closure: ['slack' => fn ($config, $tenant) => $config->set('logging.channels.slack.url', $tenant->slackUrl)] */ public static array $channelOverrides = []; @@ -52,36 +49,50 @@ public function __construct( public function bootstrap(Tenant $tenant): void { $this->defaultConfig = $this->config->get('logging.channels'); - $this->channels = $this->getChannels(); + $channels = $this->getChannels(); - $this->configureChannels($this->channels, $tenant); - $this->forgetChannels(); + $this->configureChannels($channels, $tenant); + $this->forgetChannels($channels); } public function revert(): void { $this->config->set('logging.channels', $this->defaultConfig); - $this->forgetChannels(); - - $this->channels = []; + $this->forgetChannels($this->getChannels()); } - /** Channels to configure (including the channels in the log stack). */ + /** + * Channels to configure and re-resolve afterwards (including the channels in the log stack). + */ protected function getChannels(): array { // Get the currently used (default) logging channel - $channels = [$this->config->get('logging.default')]; - - // If the default channel is stack, also get all the channels it contains - if ($channels[0] === 'stack') { - $channels = array_merge($channels, $this->config->get('logging.channels.stack.channels')); - } + $defaultChannel = $this->config->get('logging.default'); + $channelIsStack = $this->config->get("logging.channels.{$defaultChannel}.driver") === 'stack'; + + // If the default channel is stack, also get all the channels it contains. + // The stack channel also has to be included in the list of channels + // since the channel will be resolved and saved in the log manager, + // and its config could accidentally be used instead of the underlying channels. + // + // For example, when you use 'stack' with the 'slack' channel and you want to configure the webhook URL, + // both the 'stack' and the 'slack' must be re-resolved after updating the config for the channels to use the correct webhook URLs. + // If only one of the mentioned channels would be re-resolved, the other's webhook URL would be used for logging. + $channels = $channelIsStack + ? [$defaultChannel, ...$this->config->get("logging.channels.{$defaultChannel}.channels")] + : [$defaultChannel]; return $channels; } - /** Configure channels for the tenant context. */ + /** + * Configure channels for the tenant context. + * + * Only the channels that are in the $storagePathChannels array + * or have custom overrides in the $channelOverrides property + * will be configured. + */ protected function configureChannels(array $channels, Tenant $tenant): void { foreach ($channels as $channel) { @@ -99,7 +110,7 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid { if (is_array($override)) { // Map tenant properties to channel config keys. - // If the tenant property is not set, + // If the tenant property is not set (= is null), // the override is ignored and the channel config key's value remains unchanged. foreach ($override as $configKey => $tenantProperty) { if ($tenant->$tenantProperty) { @@ -112,12 +123,12 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid } /** - * Forget channels so they can be re-resolved - * with updated configuration on the next log attempt. + * Forget all passed channels so they can be re-resolved + * with updated config on the next logging attempt. */ - protected function forgetChannels(): void + protected function forgetChannels(array $channels): void { - foreach ($this->channels as $channel) { + foreach ($channels as $channel) { $this->logManager->forgetChannel($channel); } } From 7bdbe9d880d3cad8faea01ef0346d0410aaf13f2 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 16:52:06 +0200 Subject: [PATCH 16/46] Improve checking if tenant attribute is set --- src/Bootstrappers/LogTenancyBootstrapper.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 05619bb01..6089500ef 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -112,9 +112,11 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid // Map tenant properties to channel config keys. // If the tenant property is not set (= is null), // the override is ignored and the channel config key's value remains unchanged. - foreach ($override as $configKey => $tenantProperty) { - if ($tenant->$tenantProperty) { - $this->config->set("logging.channels.{$channel}.{$configKey}", $tenant->$tenantProperty); + foreach ($override as $configKey => $tenantAttributeName) { + $tenantAttribute = $tenant->getAttribute($tenantAttributeName); + + if ($tenantAttribute !== null) { + $this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute); } } } elseif ($override instanceof Closure) { From c180c2c54e1e6cac61ffcf6c4e7f7f778950bbc0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 31 Jul 2025 16:54:08 +0200 Subject: [PATCH 17/46] Use more accurate terminology --- src/Bootstrappers/LogTenancyBootstrapper.php | 6 +++--- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 6089500ef..9bc1899ea 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -36,7 +36,7 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * Custom channel configuration overrides. * * Examples: - * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is set, otherwise, the override is ignored) + * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) * - Closure: ['slack' => fn ($config, $tenant) => $config->set('logging.channels.slack.url', $tenant->slackUrl)] */ public static array $channelOverrides = []; @@ -109,8 +109,8 @@ protected function configureChannels(array $channels, Tenant $tenant): void protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void { if (is_array($override)) { - // Map tenant properties to channel config keys. - // If the tenant property is not set (= is null), + // Map tenant attributes to channel config keys. + // If the tenant attribute is null, // the override is ignored and the channel config key's value remains unchanged. foreach ($override as $configKey => $tenantAttributeName) { $tenantAttribute = $tenant->getAttribute($tenantAttributeName); diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index bebbb8412..609418920 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -135,17 +135,17 @@ expect(config('logging.channels.slack.username'))->toBe('Default'); // Not changed at all }); -test('channel config keys remains unchanged if the specified tenant override property is not set', function() { +test('channel config keys remains unchanged if the specified tenant override attribute is null', function() { config(['logging.default' => 'slack']); config(['logging.channels.slack.username' => 'Default username']); LogTenancyBootstrapper::$channelOverrides = [ - 'slack' => ['username' => 'nonExistentProperty'], // $tenant->nonExistentProperty + 'slack' => ['username' => 'nonExistentAttribute'], // $tenant->nonExistentAttribute ]; tenancy()->initialize(Tenant::create()); - // The username should remain unchanged since the tenant property is not set + // The username should remain unchanged since the tenant attribute is null expect(config('logging.channels.slack.username'))->toBe('Default username'); }); From f878aaf4e4f3abd1f072139220f79402d31dde56 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 10:21:08 +0100 Subject: [PATCH 18/46] Improve closure overrides --- src/Bootstrappers/LogTenancyBootstrapper.php | 4 +++- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 9bc1899ea..6785a80a9 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -120,7 +120,9 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid } } } elseif ($override instanceof Closure) { - $override($this->config, $tenant); + $channelConfigKey = "logging.channels.{$channel}"; + + $this->config->set($channelConfigKey, $override($this->config->get($channelConfigKey), $tenant)); } } diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 609418920..55c68dfc9 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -113,8 +113,8 @@ // Test both array mapping and closure-based overrides LogTenancyBootstrapper::$channelOverrides = [ 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl - 'single' => function ($config, $tenant) { - $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); + 'single' => function (array $channel, Tenant $tenant) { + return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); }, ]; @@ -155,8 +155,8 @@ $tenant = Tenant::create(['id' => 'tenant1']); LogTenancyBootstrapper::$channelOverrides = [ - 'single' => function ($config, $tenant) { - $config->set('logging.channels.single.path', storage_path("logs/override-{$tenant->id}.log")); + 'single' => function (array $channel, Tenant $tenant) { + return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); }, ]; @@ -261,9 +261,9 @@ $tenant = Tenant::create(['id' => 'override-tenant']); LogTenancyBootstrapper::$channelOverrides = [ - 'single' => function ($config, $tenant) { + 'single' => function (array $channel, Tenant $tenant) { // The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log - $config->set('logging.channels.single.path', storage_path("logs/custom-{$tenant->id}.log")); + return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]); }, ]; From b36f3ce4ee46836b47e29bc6190b2e6ad9f65c24 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 13:36:55 +0100 Subject: [PATCH 19/46] Fix typo --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 55c68dfc9..86b112f1f 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -135,7 +135,7 @@ expect(config('logging.channels.slack.username'))->toBe('Default'); // Not changed at all }); -test('channel config keys remains unchanged if the specified tenant override attribute is null', function() { +test('channel config keys remain unchanged if the specified tenant override attribute is null', function() { config(['logging.default' => 'slack']); config(['logging.channels.slack.username' => 'Default username']); From 108e0d13637d5fee3e8b80b660da566f961a2b23 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 14:12:53 +0100 Subject: [PATCH 20/46] Swap closure param order, add/update comments --- src/Bootstrappers/LogTenancyBootstrapper.php | 6 +++--- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 6785a80a9..0355dd7ed 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -37,7 +37,7 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * * Examples: * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) - * - Closure: ['slack' => fn ($config, $tenant) => $config->set('logging.channels.slack.url', $tenant->slackUrl)] + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] */ public static array $channelOverrides = []; @@ -100,7 +100,7 @@ protected function configureChannels(array $channels, Tenant $tenant): void $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); } elseif (in_array($channel, static::$storagePathChannels)) { // Set storage path channels to use tenant-specific directory (default behavior) - // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" (assuming FilesystemTenancyBootstrapper is used before this bootstrapper) $this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log')); } } @@ -122,7 +122,7 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid } elseif ($override instanceof Closure) { $channelConfigKey = "logging.channels.{$channel}"; - $this->config->set($channelConfigKey, $override($this->config->get($channelConfigKey), $tenant)); + $this->config->set($channelConfigKey, $override($tenant, $this->config->get($channelConfigKey))); } } diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 86b112f1f..eb43e605e 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -14,7 +14,7 @@ beforeEach(function () { config([ 'tenancy.bootstrappers' => [ - // FilesystemTenancyBootstrapper needed for storage path channels (added in tests that check the storage path channel logic) + // FilesystemTenancyBootstrapper needed for LogTenancyBootstrapper to work with storage path channels BY DEFAULT (note that this can be completely overridden) LogTenancyBootstrapper::class, ], ]); @@ -33,7 +33,7 @@ }); test('storage path channels get tenant-specific paths by default', function () { - // Note that for LogTenancyBootstrapper to change the paths correctly, + // Note that for LogTenancyBootstrapper to change the paths correctly by default, // the bootstrapper MUST run after FilesystemTenancyBootstrapper. config([ 'tenancy.bootstrappers' => [ @@ -113,7 +113,7 @@ // Test both array mapping and closure-based overrides LogTenancyBootstrapper::$channelOverrides = [ 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl - 'single' => function (array $channel, Tenant $tenant) { + 'single' => function (Tenant $tenant, array $channel) { return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); }, ]; @@ -155,7 +155,7 @@ $tenant = Tenant::create(['id' => 'tenant1']); LogTenancyBootstrapper::$channelOverrides = [ - 'single' => function (array $channel, Tenant $tenant) { + 'single' => function (Tenant $tenant, array $channel) { return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); }, ]; @@ -261,7 +261,7 @@ $tenant = Tenant::create(['id' => 'override-tenant']); LogTenancyBootstrapper::$channelOverrides = [ - 'single' => function (array $channel, Tenant $tenant) { + 'single' => function (Tenant $tenant, array $channel) { // The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]); }, From e133c87c666b3a0d58321ec4dcd116d4f6b8f4dd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 14:15:06 +0100 Subject: [PATCH 21/46] Make test priovide sufficient context for understanding the default behavior, improve test by making assertions more specific --- .../LogTenancyBootstrapperTest.php | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index eb43e605e..e1519cb18 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -66,7 +66,7 @@ } }); -test('all channels included in the log stack get processed', function () { +test('all channels included in the log stack get processed correctly', function () { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -79,16 +79,29 @@ ], ]); + $centralStoragePath = storage_path(); + $centralLogPath = $centralStoragePath . '/logs/laravel.log'; $originalSinglePath = config('logging.channels.single.path'); $originalDailyPath = config('logging.channels.daily.path'); + // By default, both paths are the same in the config. + // Note that in actual usage, the daily log file name is parsed differently from the path in the config, + // but the paths *in the config* are the same. + expect($centralLogPath) + ->toBe($centralStoragePath . '/logs/laravel.log') + ->toBe($originalSinglePath) + ->toBe($originalDailyPath); + $tenant = Tenant::create(); tenancy()->initialize($tenant); - // Both channels in the stack should be updated - expect(config('logging.channels.single.path'))->not()->toBe($originalSinglePath); - expect(config('logging.channels.daily.path'))->not()->toBe($originalDailyPath); + // Both channels in the stack are updated correctly + expect("{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log") + ->not()->toBe($originalSinglePath) + ->not()->toBe($originalDailyPath) + ->toBe(config('logging.channels.single.path')) + ->toBe(config('logging.channels.daily.path')); tenancy()->end(); From 58a2447adc3d8c192e141a55d40f0fd729ba5b00 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 14:47:07 +0100 Subject: [PATCH 22/46] Use more direct assertions in the tests that assert the actual behavior, keep simpler/less direct assertions in tests that don't require direct assertions, add comments to clarify the behavior --- .../Bootstrappers/LogTenancyBootstrapperTest.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index e1519cb18..5900beb23 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -42,6 +42,7 @@ ], ]); + $centralStoragePath = storage_path(); $tenant = Tenant::create(); // Storage path channels are 'single' and 'daily' by default. @@ -54,10 +55,10 @@ tenancy()->initialize($tenant); // Path should now point to the log in the tenant's storage directory - $tenantLogPath = "storage/tenant{$tenant->id}/logs/laravel.log"; + $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log"; expect(config("logging.channels.{$channel}.path")) ->not()->toBe($originalPath) - ->toEndWith($tenantLogPath); + ->toBe($tenantLogPath); tenancy()->end(); @@ -119,15 +120,16 @@ ], ]); + $centralStoragePath = storage_path(); $originalSinglePath = config('logging.channels.single.path'); - $tenant = Tenant::create(['id' => 'tenant1', 'webhookUrl' => 'tenant-webhook']); + $tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']); // Test both array mapping and closure-based overrides LogTenancyBootstrapper::$channelOverrides = [ 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl - 'single' => function (Tenant $tenant, array $channel) { - return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); + 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) { + return array_merge($channel, ['path' => $centralStoragePath . "/logs/override-{$tenant->id}.log"]); }, ]; @@ -138,7 +140,7 @@ expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden // Closure overrides work - expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); + expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-{$tenant->id}.log"); tenancy()->end(); @@ -307,6 +309,8 @@ // Central context stack log logger('central'); $centralSingleLogPath = storage_path('logs/laravel.log'); + + // The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name $centralDailyLogPath = storage_path("logs/laravel-{$today}.log"); expect(file_get_contents($centralSingleLogPath))->toContain('central'); From ae39e4dfd49bfbb209faccd8802d1ad3e8441420 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Oct 2025 14:56:37 +0100 Subject: [PATCH 23/46] Clarify behavior in log bootstrapper comments --- src/Bootstrappers/LogTenancyBootstrapper.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 0355dd7ed..aec260840 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -17,6 +17,7 @@ * but feel free to customize that using the $storagePathChannels property) * are configured to use tenant storage directories. * For this to work correctly, this bootstrapper must run *after* FilesystemTenancyBootstrapper. + * FilesystemTenancyBootstrapper alters how storage_path() works in the tenant context. * * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the property's docblock). * @@ -27,8 +28,8 @@ class LogTenancyBootstrapper implements TenancyBootstrapper protected array $defaultConfig = []; /** - * Log channels that use the storage_path() helper for storing the logs. - * Requires FilesystemTenancyBootstrapper to run before this bootstrapper. + * Log channels that use the storage_path() helper for storing the logs. Requires FilesystemTenancyBootstrapper to run before this bootstrapper. + * Or you can bypass this default behavior by using overrides, since they take precedence over the default behavior. */ public static array $storagePathChannels = ['single', 'daily']; From aedb33bb3a23c442a1296749d8e5a46cd507ac04 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 12:45:53 +0200 Subject: [PATCH 24/46] Clean up log files in before/afterEach --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 5900beb23..45ef9e798 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -19,6 +19,12 @@ ], ]); + @unlink(storage_path('logs/laravel.log')); + @unlink(storage_path('logs/laravel-' . now()->format('Y-m-d') . '.log')); + foreach (glob(storage_path('tenant*/logs/*.log')) ?: [] as $path) { + @unlink($path); + } + // Reset static properties LogTenancyBootstrapper::$channelOverrides = []; LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; @@ -30,6 +36,12 @@ afterEach(function () { LogTenancyBootstrapper::$channelOverrides = []; LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; + + @unlink(storage_path('logs/laravel.log')); + @unlink(storage_path('logs/laravel-' . now()->format('Y-m-d') . '.log')); + foreach (glob(storage_path('tenant*/logs/*.log')) ?: [] as $path) { + @unlink($path); + } }); test('storage path channels get tenant-specific paths by default', function () { From c68b91cd43ca4675628cabd03985ef5d07881155 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 13:54:20 +0200 Subject: [PATCH 25/46] Make tests not depend on setting the default logging channel --- .../LogTenancyBootstrapperTest.php | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 45ef9e798..469fed6f1 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Illuminate\Support\Facades\Log; beforeEach(function () { config([ @@ -60,8 +61,6 @@ // Storage path channels are 'single' and 'daily' by default. // This can be customized via LogTenancyBootstrapper::$storagePathChannels. foreach (LogTenancyBootstrapper::$storagePathChannels as $channel) { - config(['logging.default' => $channel]); - $originalPath = config("logging.channels.{$channel}.path"); tenancy()->initialize($tenant); @@ -85,7 +84,6 @@ FilesystemTenancyBootstrapper::class, LogTenancyBootstrapper::class, ], - 'logging.default' => 'stack', 'logging.channels.stack' => [ 'driver' => 'stack', 'channels' => ['single', 'daily'], @@ -124,7 +122,6 @@ test('channel overrides work correctly with both arrays and closures', function () { config([ - 'logging.default' => 'stack', 'logging.channels.stack.channels' => ['slack', 'single'], 'logging.channels.slack' => [ 'url' => $originalSlackUrl = 'default-webhook', @@ -163,7 +160,6 @@ }); test('channel config keys remain unchanged if the specified tenant override attribute is null', function() { - config(['logging.default' => 'slack']); config(['logging.channels.slack.username' => 'Default username']); LogTenancyBootstrapper::$channelOverrides = [ @@ -177,8 +173,6 @@ }); test('channel overrides take precedence over the default storage path channel updating logic', function () { - config(['logging.default' => 'single']); - $tenant = Tenant::create(['id' => 'tenant1']); LogTenancyBootstrapper::$channelOverrides = [ @@ -199,7 +193,6 @@ FilesystemTenancyBootstrapper::class, LogTenancyBootstrapper::class, ], - 'logging.default' => 'single' ]); $logManager = app('log'); @@ -236,19 +229,18 @@ FilesystemTenancyBootstrapper::class, LogTenancyBootstrapper::class, ], - 'logging.default' => 'single', ]); $centralLogPath = storage_path('logs/laravel.log'); - logger('central'); + Log::channel('single')->info('central'); expect(file_get_contents($centralLogPath))->toContain('central'); [$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])]; tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) { - logger($tenant->id); + Log::channel('single')->info($tenant->id); $tenantLogPath = storage_path('logs/laravel.log'); @@ -297,7 +289,7 @@ // Tenant context log (should use custom path due to override) tenancy()->initialize($tenant); - logger('tenant-override'); + Log::channel('single')->info('tenant-override'); expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override'); }); @@ -308,7 +300,6 @@ FilesystemTenancyBootstrapper::class, LogTenancyBootstrapper::class, ], - 'logging.default' => 'stack', 'logging.channels.stack' => [ 'driver' => 'stack', 'channels' => ['single', 'daily'], @@ -319,7 +310,7 @@ $today = now()->format('Y-m-d'); // Central context stack log - logger('central'); + Log::channel('stack')->info('central'); $centralSingleLogPath = storage_path('logs/laravel.log'); // The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name @@ -330,7 +321,7 @@ // Tenant context stack log tenancy()->initialize($tenant); - logger('tenant'); + Log::channel('stack')->info('tenant'); $tenantSingleLogPath = storage_path('logs/laravel.log'); $tenantDailyLogPath = storage_path("logs/laravel-{$today}.log"); @@ -355,7 +346,6 @@ test('slack channel uses correct webhook urls', function () { config([ - 'logging.default' => 'slack', 'logging.channels.slack.url' => 'central-webhook', 'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical' ]); @@ -371,7 +361,7 @@ // Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here. // Instead, we catch the exception and check the error message which contains the actual webhook URL. try { - logger('central'); + Log::channel('slack')->info('central'); } catch (Exception $e) { expect($e->getMessage())->toContain('central-webhook'); } @@ -379,7 +369,7 @@ // Slack channel should attempt to use the tenant-specific webhooks tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) { try { - logger($tenant->id); + Log::channel('slack')->info($tenant->id); } catch (Exception $e) { expect($e->getMessage())->toContain($tenant->slackUrl); } @@ -387,7 +377,7 @@ // Central context, central webhook should be used again try { - logger('central'); + Log::channel('slack')->info('central'); } catch (Exception $e) { expect($e->getMessage())->toContain('central-webhook'); } From 89b0d1cb4bf58ab706cc57a14e67ab59338c436d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 13:55:59 +0200 Subject: [PATCH 26/46] Include all storage path channels and overrides in `getChannels()` Instead of handling just the default channel, make LogTenancyBootstrapper handle all the channels that should be affected (= $storagePathChannels, channels from $channelOverrides and the default channel in case 'stack' is the default) --- src/Bootstrappers/LogTenancyBootstrapper.php | 48 +++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index aec260840..64d0cb951 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -30,12 +30,16 @@ class LogTenancyBootstrapper implements TenancyBootstrapper /** * Log channels that use the storage_path() helper for storing the logs. Requires FilesystemTenancyBootstrapper to run before this bootstrapper. * Or you can bypass this default behavior by using overrides, since they take precedence over the default behavior. + * + * All channels included here will be configured to use tenant-specific storage paths. */ public static array $storagePathChannels = ['single', 'daily']; /** * Custom channel configuration overrides. * + * All channels included here will be configured using the provided override. + * * Examples: * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] @@ -64,27 +68,37 @@ public function revert(): void } /** - * Channels to configure and re-resolve afterwards (including the channels in the log stack). + * Channels to configure and forget so they can be re-resolved afterwards. + * + * Includes: + * - the default channel + * - all channels in the $storagePathChannels array + * - all channels that have custom overrides in the $channelOverrides property */ protected function getChannels(): array { - // Get the currently used (default) logging channel + /** + * Include the default channel in the list of channels to configure/re-resolve. + * + * Including the default channel is harmless (if it's not overridden or not in $storagePathChannels, + * it'll just be forgotten and re-resolved on the next use), and for the case where 'stack' is the default, + * this is necessary since the 'stack' channel will be resolved and saved in the log manager, + * and its stale config could accidentally be used instead of the stack member channels. + * + * For example, when you use 'stack' with the 'slack' channel and you want to configure the webhook URL, + * both 'stack' and 'slack' must be re-resolved after updating the config for the channels to use the correct webhook URLs. + * If only one of the mentioned channels would be re-resolved, the other's (stale) webhook URL could be used for logging. + */ $defaultChannel = $this->config->get('logging.default'); - $channelIsStack = $this->config->get("logging.channels.{$defaultChannel}.driver") === 'stack'; - - // If the default channel is stack, also get all the channels it contains. - // The stack channel also has to be included in the list of channels - // since the channel will be resolved and saved in the log manager, - // and its config could accidentally be used instead of the underlying channels. - // - // For example, when you use 'stack' with the 'slack' channel and you want to configure the webhook URL, - // both the 'stack' and the 'slack' must be re-resolved after updating the config for the channels to use the correct webhook URLs. - // If only one of the mentioned channels would be re-resolved, the other's webhook URL would be used for logging. - $channels = $channelIsStack - ? [$defaultChannel, ...$this->config->get("logging.channels.{$defaultChannel}.channels")] - : [$defaultChannel]; - - return $channels; + + return array_filter( + array_unique([ + $defaultChannel, + ...static::$storagePathChannels, + ...array_keys(static::$channelOverrides), + ]), + fn (string $channel): bool => $this->config->has("logging.channels.{$channel}") + ); } /** From 9660faf2f93501ed905a8f26d272b3f02d336e1c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 14:10:05 +0200 Subject: [PATCH 27/46] Ensure Slack throws cURL exception in test --- .../LogTenancyBootstrapperTest.php | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 469fed6f1..e83b2710c 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -350,6 +350,22 @@ 'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical' ]); + $assertWebhook = function (string $expectedWebhook, string $message): void { + $thrown = false; + + // Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here. + // Instead, we catch the exception and check the error message which contains the actual webhook URL + // (logging always throws "Curl error (code 6): Could not resolve host: {webhook_url}"). + try { + Log::channel('slack')->info($message); + } catch (Exception $e) { + $thrown = true; + expect($e->getMessage())->toContain($expectedWebhook); + } + + expect($thrown)->toBeTrue(); + }; + $tenant1 = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'tenant1-webhook']); $tenant2 = Tenant::create(['id' => 'tenant2', 'slackUrl' => 'tenant2-webhook']); @@ -358,27 +374,13 @@ ]; // Test central context - should attempt to use central webhook - // Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here. - // Instead, we catch the exception and check the error message which contains the actual webhook URL. - try { - Log::channel('slack')->info('central'); - } catch (Exception $e) { - expect($e->getMessage())->toContain('central-webhook'); - } + $assertWebhook('central-webhook', 'central'); // Slack channel should attempt to use the tenant-specific webhooks - tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) { - try { - Log::channel('slack')->info($tenant->id); - } catch (Exception $e) { - expect($e->getMessage())->toContain($tenant->slackUrl); - } + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) { + $assertWebhook($tenant->slackUrl, $tenant->id); }); // Central context, central webhook should be used again - try { - Log::channel('slack')->info('central'); - } catch (Exception $e) { - expect($e->getMessage())->toContain('central-webhook'); - } + $assertWebhook('central-webhook', 'central'); }); From 221a9950c2f1023e36e3c9f5a1b13f223bc70999 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 14:15:27 +0200 Subject: [PATCH 28/46] Support channel overrides using dot notation --- src/Bootstrappers/LogTenancyBootstrapper.php | 3 ++- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 64d0cb951..9c8812af1 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -9,6 +9,7 @@ use Illuminate\Log\LogManager; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +use Illuminate\Support\Arr; /** * This bootstrapper makes it possible to configure tenant-specific logging. @@ -128,7 +129,7 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid // If the tenant attribute is null, // the override is ignored and the channel config key's value remains unchanged. foreach ($override as $configKey => $tenantAttributeName) { - $tenantAttribute = $tenant->getAttribute($tenantAttributeName); + $tenantAttribute = Arr::get($tenant, $tenantAttributeName); if ($tenantAttribute !== null) { $this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute); diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index e83b2710c..c1c2854b7 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -366,11 +366,12 @@ expect($thrown)->toBeTrue(); }; - $tenant1 = Tenant::create(['id' => 'tenant1', 'slackUrl' => 'tenant1-webhook']); - $tenant2 = Tenant::create(['id' => 'tenant2', 'slackUrl' => 'tenant2-webhook']); + $tenant1 = Tenant::create(['id' => 'tenant1', 'logging' => ['slackUrl' => 'tenant1-webhook']]); + $tenant2 = Tenant::create(['id' => 'tenant2', 'logging' => ['slackUrl' => 'tenant2-webhook']]); + // Attribute mapping using nested attributes (dot notation) works LogTenancyBootstrapper::$channelOverrides = [ - 'slack' => ['url' => 'slackUrl'], + 'slack' => ['url' => 'logging.slackUrl'], ]; // Test central context - should attempt to use central webhook @@ -378,7 +379,7 @@ // Slack channel should attempt to use the tenant-specific webhooks tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) { - $assertWebhook($tenant->slackUrl, $tenant->id); + $assertWebhook($tenant->logging['slackUrl'], $tenant->id); }); // Central context, central webhook should be used again From f705f5849f64069afadf800a7a5d55dce305bdff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 12:15:54 +0000 Subject: [PATCH 29/46] Fix code style (php-cs-fixer) --- src/Bootstrappers/LogTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 9c8812af1..42ac2fa63 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -7,9 +7,9 @@ use Closure; use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Log\LogManager; +use Illuminate\Support\Arr; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Illuminate\Support\Arr; /** * This bootstrapper makes it possible to configure tenant-specific logging. From 697ba6592b13bff9962d1100e1ba71f5e2aa79c9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 14:23:39 +0200 Subject: [PATCH 30/46] Correct log file cleanup --- .../LogTenancyBootstrapperTest.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index c1c2854b7..dd530dfc3 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -20,9 +20,12 @@ ], ]); - @unlink(storage_path('logs/laravel.log')); - @unlink(storage_path('logs/laravel-' . now()->format('Y-m-d') . '.log')); - foreach (glob(storage_path('tenant*/logs/*.log')) ?: [] as $path) { + $logFiles = array_merge( + glob(storage_path('logs/*.log')) ?: [], + glob(storage_path('tenant*/logs/*.log')) ?: [] + ); + + foreach ($logFiles as $path) { @unlink($path); } @@ -38,9 +41,12 @@ LogTenancyBootstrapper::$channelOverrides = []; LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; - @unlink(storage_path('logs/laravel.log')); - @unlink(storage_path('logs/laravel-' . now()->format('Y-m-d') . '.log')); - foreach (glob(storage_path('tenant*/logs/*.log')) ?: [] as $path) { + $logFiles = array_merge( + glob(storage_path('logs/*.log')) ?: [], + glob(storage_path('tenant*/logs/*.log')) ?: [] + ); + + foreach ($logFiles as $path) { @unlink($path); } }); From b74416721a98faecb3794870d41cac2f0bb8a981 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 14:26:41 +0200 Subject: [PATCH 31/46] Fix PHPStan error --- src/Bootstrappers/LogTenancyBootstrapper.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 42ac2fa63..c09437729 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -10,6 +10,7 @@ use Illuminate\Support\Arr; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +use Illuminate\Database\Eloquent\Model; /** * This bootstrapper makes it possible to configure tenant-specific logging. @@ -129,6 +130,7 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid // If the tenant attribute is null, // the override is ignored and the channel config key's value remains unchanged. foreach ($override as $configKey => $tenantAttributeName) { + /** @var Tenant&Model $tenant */ $tenantAttribute = Arr::get($tenant, $tenantAttributeName); if ($tenantAttribute !== null) { From cdea112ad5b320d590dba1d5b9bd4854a13247f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 12:26:58 +0000 Subject: [PATCH 32/46] Fix code style (php-cs-fixer) --- src/Bootstrappers/LogTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index c09437729..5631e5bc3 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -6,11 +6,11 @@ use Closure; use Illuminate\Contracts\Config\Repository as Config; +use Illuminate\Database\Eloquent\Model; use Illuminate\Log\LogManager; use Illuminate\Support\Arr; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Illuminate\Database\Eloquent\Model; /** * This bootstrapper makes it possible to configure tenant-specific logging. From 8fda84fceea020e3cc2cfb7485a4dca3da771794 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 14:39:42 +0200 Subject: [PATCH 33/46] Throw exception if override closure doesn't return array --- src/Bootstrappers/LogTenancyBootstrapper.php | 12 ++++++++++-- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 5631e5bc3..ea1f33932 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -44,7 +44,9 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * * Examples: * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) - * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] (the closure should return the whole channel's config) + * + * In both cases, the override should be an array. */ public static array $channelOverrides = []; @@ -140,7 +142,13 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid } elseif ($override instanceof Closure) { $channelConfigKey = "logging.channels.{$channel}"; - $this->config->set($channelConfigKey, $override($tenant, $this->config->get($channelConfigKey))); + $result = $override($tenant, $this->config->get($channelConfigKey)); + + if (! is_array($result)) { + throw new \InvalidArgumentException("Channel override closure for '{$channel}' must return an array."); + } + + $this->config->set($channelConfigKey, $result); } } diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index dd530dfc3..d738d1d5d 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -140,6 +140,11 @@ $tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']); + // Channel override closures must return an array, otherwise an exception is thrown + LogTenancyBootstrapper::$channelOverrides['slack'] = fn (Tenant $tenant, array $channel) => 'invalid override'; + + expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class); + // Test both array mapping and closure-based overrides LogTenancyBootstrapper::$channelOverrides = [ 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl @@ -148,7 +153,8 @@ }, ]; - tenancy()->initialize($tenant); + // Reinitialize tenancy to apply the new overrides + tenancy()->reinitialize(); // Array mapping overrides work expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl); From 34115e84c716f72cc3b7f863062ef23d5939c7eb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 15:23:04 +0200 Subject: [PATCH 34/46] Rollback config if bootstrap fails --- src/Bootstrappers/LogTenancyBootstrapper.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index ea1f33932..64ee4a684 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -60,8 +60,15 @@ public function bootstrap(Tenant $tenant): void $this->defaultConfig = $this->config->get('logging.channels'); $channels = $this->getChannels(); - $this->configureChannels($channels, $tenant); - $this->forgetChannels($channels); + try { + $this->configureChannels($channels, $tenant); + $this->forgetChannels($channels); + } catch (\Throwable $exception) { + $this->config->set('logging.channels', $this->defaultConfig); + $this->forgetChannels($channels); + + throw $exception; + } } public function revert(): void From 1ae418c8b3fbe2dbfbfad0dfc2371e8ba0887d6f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 09:31:32 +0200 Subject: [PATCH 35/46] Store configured channels in a property, forget only the stored channels --- src/Bootstrappers/LogTenancyBootstrapper.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 64ee4a684..9504b1ba3 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -29,6 +29,8 @@ class LogTenancyBootstrapper implements TenancyBootstrapper { protected array $defaultConfig = []; + protected array $configuredChannels = []; + /** * Log channels that use the storage_path() helper for storing the logs. Requires FilesystemTenancyBootstrapper to run before this bootstrapper. * Or you can bypass this default behavior by using overrides, since they take precedence over the default behavior. @@ -58,14 +60,15 @@ public function __construct( public function bootstrap(Tenant $tenant): void { $this->defaultConfig = $this->config->get('logging.channels'); - $channels = $this->getChannels(); + $this->configuredChannels = $this->getChannels(); try { - $this->configureChannels($channels, $tenant); - $this->forgetChannels($channels); + $this->configureChannels($this->configuredChannels, $tenant); + $this->forgetChannels($this->configuredChannels); } catch (\Throwable $exception) { + // Revert to default config if anything goes wrong during channel configuration $this->config->set('logging.channels', $this->defaultConfig); - $this->forgetChannels($channels); + $this->forgetChannels($this->configuredChannels); throw $exception; } @@ -75,7 +78,7 @@ public function revert(): void { $this->config->set('logging.channels', $this->defaultConfig); - $this->forgetChannels($this->getChannels()); + $this->forgetChannels($this->configuredChannels); } /** From 95fd0462e657da689fe07dec4e7d7bed2631ce16 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 09:53:25 +0200 Subject: [PATCH 36/46] Import InvalidArgumentException --- src/Bootstrappers/LogTenancyBootstrapper.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 9504b1ba3..f5ac2255b 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -11,6 +11,7 @@ use Illuminate\Support\Arr; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +use InvalidArgumentException; /** * This bootstrapper makes it possible to configure tenant-specific logging. @@ -155,7 +156,7 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid $result = $override($tenant, $this->config->get($channelConfigKey)); if (! is_array($result)) { - throw new \InvalidArgumentException("Channel override closure for '{$channel}' must return an array."); + throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array."); } $this->config->set($channelConfigKey, $result); From 06472d5cae56a9b6226c71a0d765b363742c7117 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 07:53:45 +0000 Subject: [PATCH 37/46] Fix code style (php-cs-fixer) --- src/Bootstrappers/LogTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index f5ac2255b..31235df12 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -9,9 +9,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Log\LogManager; use Illuminate\Support\Arr; +use InvalidArgumentException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use InvalidArgumentException; /** * This bootstrapper makes it possible to configure tenant-specific logging. From 9ea3813d28028afcf71eb1162f9b6a4e4053ca52 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 10:07:16 +0200 Subject: [PATCH 38/46] Improve $channelOverrides docblock --- src/Bootstrappers/LogTenancyBootstrapper.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 31235df12..f0794a76b 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -46,10 +46,12 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * All channels included here will be configured using the provided override. * * Examples: - * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) - * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] (the closure should return the whole channel's config) + * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] + * - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is not null, otherwise, the override is ignored) + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] + * - this merges ['url' => $tenant->slackUrl] into the channel's config. * - * In both cases, the override should be an array. + * So the channel overrides can be arrays and closures that return arrays. */ public static array $channelOverrides = []; From 23ae15a8f145d3bbf414b9caf111fd434d3c0719 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 10:46:15 +0200 Subject: [PATCH 39/46] Preserve filename from central log path in tenant context --- src/Bootstrappers/LogTenancyBootstrapper.php | 4 +- .../LogTenancyBootstrapperTest.php | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index f0794a76b..0007ce61c 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -133,7 +133,9 @@ protected function configureChannels(array $channels, Tenant $tenant): void } elseif (in_array($channel, static::$storagePathChannels)) { // Set storage path channels to use tenant-specific directory (default behavior) // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" (assuming FilesystemTenancyBootstrapper is used before this bootstrapper) - $this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log')); + $path = $this->config->get("logging.channels.{$channel}.path"); + + $this->config->set("logging.channels.{$channel}.path", storage_path('logs/' . ($path ? basename($path) : 'laravel.log'))); } } } diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index d738d1d5d..4b62005e9 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -397,3 +397,44 @@ // Central context, central webhook should be used again $assertWebhook('central-webhook', 'central'); }); + +test('tenant logs inherit the filename from the central log path config', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogTenancyBootstrapper::class, + ], + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + 'logging.channels.single.path' => storage_path('logs/custom-name.log'), + 'logging.channels.daily.path' => storage_path('logs/custom-name.log'), + ]); + + $tenant = Tenant::create(); + $today = now()->format('Y-m-d'); + + // Central log is located at storage/logs/custom-name.log + Log::channel('stack')->info('central'); + + expect(file_get_contents(storage_path('logs/custom-name.log')))->toContain('central'); + expect(file_get_contents(storage_path("logs/custom-name-{$today}.log")))->toContain('central'); + + tenancy()->initialize($tenant); + + // Tenant log is located at storage/tenantX/logs/custom-name.log + Log::channel('stack')->info($tenant->id); + + // The filename from the central config is preserved in tenant context + expect(config('logging.channels.single.path'))->toEndWith('custom-name.log'); + expect(config('logging.channels.daily.path'))->toEndWith('custom-name.log'); + + expect(file_get_contents(storage_path('logs/custom-name.log'))) + ->toContain($tenant->id) + ->not()->toContain('central'); + + expect(file_get_contents(storage_path("logs/custom-name-{$today}.log"))) + ->toContain($tenant->id) + ->not()->toContain('central'); +}); From b234308e26eea61d379c94891194b3dd25ab5b3f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 11:17:31 +0200 Subject: [PATCH 40/46] Add comment about log path customization --- src/Bootstrappers/LogTenancyBootstrapper.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index 0007ce61c..eb1dc55bb 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -131,10 +131,13 @@ protected function configureChannels(array $channels, Tenant $tenant): void if (isset(static::$channelOverrides[$channel])) { $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); } elseif (in_array($channel, static::$storagePathChannels)) { - // Set storage path channels to use tenant-specific directory (default behavior) - // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" (assuming FilesystemTenancyBootstrapper is used before this bootstrapper) + // Set storage path channels to use tenant-specific directory (default behavior). + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" + // (assuming FilesystemTenancyBootstrapper is used before this bootstrapper). $path = $this->config->get("logging.channels.{$channel}.path"); + // The tenant log will inherit the central log filename from the central channel path config. + // For better customization, e.g. using custom paths for tenant logs, look into channel overrides. $this->config->set("logging.channels.{$channel}.path", storage_path('logs/' . ($path ? basename($path) : 'laravel.log'))); } } From 42d60e90851b260a551233e6e4c22cce49ff204e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 14:06:51 +0200 Subject: [PATCH 41/46] Make tenant log channels inherit paths from central config, improve comments --- src/Bootstrappers/LogTenancyBootstrapper.php | 37 +++++++++++++------ .../LogTenancyBootstrapperTest.php | 19 +++++----- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index eb1dc55bb..bb3b4ba0b 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -9,18 +9,23 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Log\LogManager; use Illuminate\Support\Arr; +use Exception; use InvalidArgumentException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; +use Illuminate\Support\Str; /** * This bootstrapper makes it possible to configure tenant-specific logging. * - * By default, the storage path channels ('single' and 'daily' by default, - * but feel free to customize that using the $storagePathChannels property) - * are configured to use tenant storage directories. - * For this to work correctly, this bootstrapper must run *after* FilesystemTenancyBootstrapper. - * FilesystemTenancyBootstrapper alters how storage_path() works in the tenant context. + * By default, all the storage path channels are configured to use tenant + * storage directories (see the $storagePathChannels property). + * + * For this to work correctly: + * - this bootstrapper must run *after* FilesystemTenancyBootstrapper, + * since FilesystemTenancyBootstrapper alters how storage_path() works in the tenant context + * - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path') + * has to be true), since the storage path suffix is what separates logs by tenant * * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the property's docblock). * @@ -33,10 +38,16 @@ class LogTenancyBootstrapper implements TenancyBootstrapper protected array $configuredChannels = []; /** - * Log channels that use the storage_path() helper for storing the logs. Requires FilesystemTenancyBootstrapper to run before this bootstrapper. - * Or you can bypass this default behavior by using overrides, since they take precedence over the default behavior. + * Logging channels that use the storage_path() helper for storing the logs. + * Or you can bypass this default behavior by using overrides, since they take + * precedence over the default behavior. * * All channels included here will be configured to use tenant-specific storage paths. + * + * Requires FilesystemTenancyBootstrapper to run before this bootstrapper, + * and storage path suffixing to be enabled. + * + * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper */ public static array $storagePathChannels = ['single', 'daily']; @@ -44,6 +55,8 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * Custom channel configuration overrides. * * All channels included here will be configured using the provided override. + * The overrides take precedence over the default storage path channels + * behavior. * * Examples: * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] @@ -134,11 +147,13 @@ protected function configureChannels(array $channels, Tenant $tenant): void // Set storage path channels to use tenant-specific directory (default behavior). // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" // (assuming FilesystemTenancyBootstrapper is used before this bootstrapper). - $path = $this->config->get("logging.channels.{$channel}.path"); + $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); + $centralStoragePath = Str::before(storage_path(), $this->config->get('tenancy.filesystem.suffix_base') . $tenant->getTenantKey()); - // The tenant log will inherit the central log filename from the central channel path config. - // For better customization, e.g. using custom paths for tenant logs, look into channel overrides. - $this->config->set("logging.channels.{$channel}.path", storage_path('logs/' . ($path ? basename($path) : 'laravel.log'))); + // The tenant log will inherit the segment that follows the storage path from the central channel path config. + // For example, if a channel's path is configured to storage_path('custom/logs/path.log') (storage/custom/logs/path.log), + // the 'custom/logs/path.log' segment will be passed to storage_path() in the tenant context (storage/tenantfoo/custom/logs/path.log). + $this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); } } } diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 4b62005e9..d087c1e84 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -398,7 +398,7 @@ $assertWebhook('central-webhook', 'central'); }); -test('tenant logs inherit the filename from the central log path config', function () { +test('tenant logs inherit the path from the central log path config', function () { config([ 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, @@ -408,18 +408,17 @@ 'driver' => 'stack', 'channels' => ['single', 'daily'], ], - 'logging.channels.single.path' => storage_path('logs/custom-name.log'), - 'logging.channels.daily.path' => storage_path('logs/custom-name.log'), + 'logging.channels.single.path' => storage_path('logs/single/custom-name.log'), + 'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'), ]); $tenant = Tenant::create(); $today = now()->format('Y-m-d'); - // Central log is located at storage/logs/custom-name.log Log::channel('stack')->info('central'); - expect(file_get_contents(storage_path('logs/custom-name.log')))->toContain('central'); - expect(file_get_contents(storage_path("logs/custom-name-{$today}.log")))->toContain('central'); + expect(file_get_contents(storage_path('logs/single/custom-name.log')))->toContain('central'); + expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))->toContain('central'); tenancy()->initialize($tenant); @@ -427,14 +426,14 @@ Log::channel('stack')->info($tenant->id); // The filename from the central config is preserved in tenant context - expect(config('logging.channels.single.path'))->toEndWith('custom-name.log'); - expect(config('logging.channels.daily.path'))->toEndWith('custom-name.log'); + expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log'); + expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log'); - expect(file_get_contents(storage_path('logs/custom-name.log'))) + expect(file_get_contents(storage_path('logs/single/custom-name.log'))) ->toContain($tenant->id) ->not()->toContain('central'); - expect(file_get_contents(storage_path("logs/custom-name-{$today}.log"))) + expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log"))) ->toContain($tenant->id) ->not()->toContain('central'); }); From c2a80c248f19b022364c2e3634928a3fb0055d87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 12:07:13 +0000 Subject: [PATCH 42/46] Fix code style (php-cs-fixer) --- src/Bootstrappers/LogTenancyBootstrapper.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index bb3b4ba0b..bd23c79ec 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -9,11 +9,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Log\LogManager; use Illuminate\Support\Arr; -use Exception; +use Illuminate\Support\Str; use InvalidArgumentException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Illuminate\Support\Str; /** * This bootstrapper makes it possible to configure tenant-specific logging. From 2f60e7672e5538f9fb0ed8a66a4bba13731aa11c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 14:40:00 +0200 Subject: [PATCH 43/46] Clean up nested log files created by tests --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index d087c1e84..72664080a 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -22,7 +22,9 @@ $logFiles = array_merge( glob(storage_path('logs/*.log')) ?: [], - glob(storage_path('tenant*/logs/*.log')) ?: [] + glob(storage_path('logs/*/*.log')) ?: [], + glob(storage_path('tenant*/logs/*.log')) ?: [], + glob(storage_path('tenant*/logs/*/*.log')) ?: [] ); foreach ($logFiles as $path) { @@ -43,7 +45,9 @@ $logFiles = array_merge( glob(storage_path('logs/*.log')) ?: [], - glob(storage_path('tenant*/logs/*.log')) ?: [] + glob(storage_path('logs/*/*.log')) ?: [], + glob(storage_path('tenant*/logs/*.log')) ?: [], + glob(storage_path('tenant*/logs/*/*.log')) ?: [] ); foreach ($logFiles as $path) { From c5683d8e00d127ced386301f6cf11d3e39f448be Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 21 Apr 2026 12:02:52 +0200 Subject: [PATCH 44/46] Extract cleanup in test file --- .../LogTenancyBootstrapperTest.php | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 72664080a..65d64180c 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -12,13 +12,9 @@ use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Illuminate\Support\Facades\Log; -beforeEach(function () { - config([ - 'tenancy.bootstrappers' => [ - // FilesystemTenancyBootstrapper needed for LogTenancyBootstrapper to work with storage path channels BY DEFAULT (note that this can be completely overridden) - LogTenancyBootstrapper::class, - ], - ]); +$cleanup = function () { + LogTenancyBootstrapper::$channelOverrides = []; + LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; $logFiles = array_merge( glob(storage_path('logs/*.log')) ?: [], @@ -30,30 +26,23 @@ foreach ($logFiles as $path) { @unlink($path); } +}; - // Reset static properties - LogTenancyBootstrapper::$channelOverrides = []; - LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; +beforeEach(function () use ($cleanup) { + config([ + 'tenancy.bootstrappers' => [ + // FilesystemTenancyBootstrapper needed for LogTenancyBootstrapper to work with storage path channels BY DEFAULT + LogTenancyBootstrapper::class, + ], + ]); + + $cleanup(); Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); -afterEach(function () { - LogTenancyBootstrapper::$channelOverrides = []; - LogTenancyBootstrapper::$storagePathChannels = ['single', 'daily']; - - $logFiles = array_merge( - glob(storage_path('logs/*.log')) ?: [], - glob(storage_path('logs/*/*.log')) ?: [], - glob(storage_path('tenant*/logs/*.log')) ?: [], - glob(storage_path('tenant*/logs/*/*.log')) ?: [] - ); - - foreach ($logFiles as $path) { - @unlink($path); - } -}); +afterEach($cleanup); test('storage path channels get tenant-specific paths by default', function () { // Note that for LogTenancyBootstrapper to change the paths correctly by default, From 8276f3b0083d59c0a31c41cb17c2261196ad66be Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 21 Apr 2026 12:03:11 +0200 Subject: [PATCH 45/46] Improve comments in tests --- tests/Bootstrappers/LogTenancyBootstrapperTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index 65d64180c..e90ad0606 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -96,7 +96,8 @@ // By default, both paths are the same in the config. // Note that in actual usage, the daily log file name is parsed differently from the path in the config, - // but the paths *in the config* are the same. + // e.g. if daily channel has 'path' => storage_path('logs/laravel.log') in config, the log will be + // located at storage_path('logs/laravel-2026-01-01.log'). But the paths *in the config* are the same. expect($centralLogPath) ->toBe($centralStoragePath . '/logs/laravel.log') ->toBe($originalSinglePath) @@ -161,7 +162,7 @@ // After tenancy ends, the original config should be restored expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); expect(config('logging.channels.single.path'))->toBe($originalSinglePath); - expect(config('logging.channels.slack.username'))->toBe('Default'); // Not changed at all + expect(config('logging.channels.slack.username'))->toBe('Default'); // Unchanged }); test('channel config keys remain unchanged if the specified tenant override attribute is null', function() { From 6e474aca801d41e26c5056961f990722a2ad58f8 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 21 Apr 2026 12:25:44 +0200 Subject: [PATCH 46/46] Improve comments, test reverting on failure during configuration --- src/Bootstrappers/LogTenancyBootstrapper.php | 64 +++++++++++-------- .../LogTenancyBootstrapperTest.php | 29 +++++++++ 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/Bootstrappers/LogTenancyBootstrapper.php b/src/Bootstrappers/LogTenancyBootstrapper.php index bd23c79ec..9f74e835c 100644 --- a/src/Bootstrappers/LogTenancyBootstrapper.php +++ b/src/Bootstrappers/LogTenancyBootstrapper.php @@ -15,18 +15,18 @@ use Stancl\Tenancy\Contracts\Tenant; /** - * This bootstrapper makes it possible to configure tenant-specific logging. + * Enable tenant-specific logging. * - * By default, all the storage path channels are configured to use tenant - * storage directories (see the $storagePathChannels property). + * All the storage path channels are configured to use tenant + * directories by default (see the $storagePathChannels property). * * For this to work correctly: * - this bootstrapper must run *after* FilesystemTenancyBootstrapper, - * since FilesystemTenancyBootstrapper alters how storage_path() works in the tenant context + * since FilesystemTenancyBootstrapper makes storage_path() return the tenant-specific storage path * - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path') - * has to be true), since the storage path suffix is what separates logs by tenant + * has to be true), since the storage path suffix is what separates logs by tenant * - * The bootstrapper also supports custom channel overrides via the $channelOverrides property (see the property's docblock). + * Also supports custom channel overrides (see the $channelOverrides property). * * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper */ @@ -37,11 +37,13 @@ class LogTenancyBootstrapper implements TenancyBootstrapper protected array $configuredChannels = []; /** - * Logging channels that use the storage_path() helper for storing the logs. - * Or you can bypass this default behavior by using overrides, since they take - * precedence over the default behavior. + * Logging channels whose paths use storage_path() by default in the logging config. * - * All channels included here will be configured to use tenant-specific storage paths. + * All channels included here will be configured to use tenant-specific storage paths + * generated using storage_path() in the tenant context. + * + * This is the default behavior. The $channelOverrides property can be used to override + * this behavior (the overrides take precedence over $storagePathChannels). * * Requires FilesystemTenancyBootstrapper to run before this bootstrapper, * and storage path suffixing to be enabled. @@ -54,8 +56,10 @@ class LogTenancyBootstrapper implements TenancyBootstrapper * Custom channel configuration overrides. * * All channels included here will be configured using the provided override. - * The overrides take precedence over the default storage path channels - * behavior. + * The overrides take precedence over the default ($storagePathChannels) behavior. + * + * You can either map tenant attributes to channel config keys using an array, + * or provide a closure that returns the full channel config array. * * Examples: * - Array mapping (the default approach): ['slack' => ['url' => 'webhookUrl']] @@ -81,9 +85,11 @@ public function bootstrap(Tenant $tenant): void $this->configureChannels($this->configuredChannels, $tenant); $this->forgetChannels($this->configuredChannels); } catch (\Throwable $exception) { - // Revert to default config if anything goes wrong during channel configuration - $this->config->set('logging.channels', $this->defaultConfig); - $this->forgetChannels($this->configuredChannels); + // If an exception is thrown while updating the logging config, the logging config + // could be left in a corrupt (tenant) state, so we revert to the original config + // to e.g. avoid logging the failure in the tenant log (which would happen if + // the channel wasn't resolved before). + $this->revert(); throw $exception; } @@ -97,10 +103,11 @@ public function revert(): void } /** - * Channels to configure and forget so they can be re-resolved afterwards. + * Channels to configure and forget from the log manager so they can be + * re-resolved with the new, tenant-specific config on the next use. * * Includes: - * - the default channel + * - the default channel (primarily because it can be 'stack') * - all channels in the $storagePathChannels array * - all channels that have custom overrides in the $channelOverrides property */ @@ -110,13 +117,13 @@ protected function getChannels(): array * Include the default channel in the list of channels to configure/re-resolve. * * Including the default channel is harmless (if it's not overridden or not in $storagePathChannels, - * it'll just be forgotten and re-resolved on the next use), and for the case where 'stack' is the default, - * this is necessary since the 'stack' channel will be resolved and saved in the log manager, - * and its stale config could accidentally be used instead of the stack member channels. + * it'll just be forgotten and re-resolved on the next use with the original config), and for the + * case where 'stack' is the default, this is necessary since the 'stack' channel will be resolved + * and saved in the log manager, and its stale config could accidentally be used instead of the stack member channels. * - * For example, when you use 'stack' with the 'slack' channel and you want to configure the webhook URL, - * both 'stack' and 'slack' must be re-resolved after updating the config for the channels to use the correct webhook URLs. - * If only one of the mentioned channels would be re-resolved, the other's (stale) webhook URL could be used for logging. + * For example, when you use 'stack' with the 'slack' channel, + * if only 'slack' is forgotten, 'stack' would still use the stale cached 'slack' driver, + * and if only 'stack' is forgotten, the 'slack' channel's config would remain unchanged (central). */ $defaultChannel = $this->config->get('logging.default'); @@ -135,7 +142,7 @@ protected function getChannels(): array * * Only the channels that are in the $storagePathChannels array * or have custom overrides in the $channelOverrides property - * will be configured. + * will be configured (overrides take precedence over storage path channels). */ protected function configureChannels(array $channels, Tenant $tenant): void { @@ -144,8 +151,8 @@ protected function configureChannels(array $channels, Tenant $tenant): void $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); } elseif (in_array($channel, static::$storagePathChannels)) { // Set storage path channels to use tenant-specific directory (default behavior). - // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log" - // (assuming FilesystemTenancyBootstrapper is used before this bootstrapper). + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log", + // assuming FilesystemTenancyBootstrapper is used before this bootstrapper. $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); $centralStoragePath = Str::before(storage_path(), $this->config->get('tenancy.filesystem.suffix_base') . $tenant->getTenantKey()); @@ -185,8 +192,9 @@ protected function overrideChannelConfig(string $channel, array|Closure $overrid } /** - * Forget all passed channels so they can be re-resolved - * with updated config on the next logging attempt. + * Forget all passed channels from the log manager so that + * they can be re-resolved with the updated (tenant-specific) + * config on the next logging attempt. */ protected function forgetChannels(array $channels): void { diff --git a/tests/Bootstrappers/LogTenancyBootstrapperTest.php b/tests/Bootstrappers/LogTenancyBootstrapperTest.php index e90ad0606..6c73fdbb1 100644 --- a/tests/Bootstrappers/LogTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/LogTenancyBootstrapperTest.php @@ -431,3 +431,32 @@ ->toContain($tenant->id) ->not()->toContain('central'); }); + +test('logging config is reverted to the original state if configuration fails', function() { + config([ + 'logging.channels.slack.url' => $originalSlackUrl = 'default-webhook', + 'logging.channels.single.path' => $originalSinglePath = storage_path('logs/default-single-path.log'), + ]); + + $tenant = Tenant::create(['loggingPath' => storage_path('logs/tenant-single-path.log')]); + + // Valid override first, the config will be updated properly, + // then an invalid override that will cause the configuration to fail and throw an exception. + LogTenancyBootstrapper::$channelOverrides = [ + 'single' => ['path' => 'loggingPath'], // Valid override + 'slack' => fn () => 'invalid override', + ]; + + expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class); + + // Single channel config reverted to original state after the exception was thrown + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); + + // Exception thrown before slack config got changed + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + + // The single channel uses the original path for logging + Log::channel('single')->info('bootstrap failed'); + expect(file_exists($originalSinglePath))->toBeTrue(); + expect(file_get_contents($originalSinglePath))->toContain('bootstrap failed'); +});