From 01a06c92102ba2c5fccb91740eb8dba1855c9224 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 28 Jul 2025 12:11:09 +0200 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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'];