Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
01a06c9
Add LogTenancyBootstrapper
lukinovec Jul 28, 2025
96a05cd
Fix code style (php-cs-fixer)
github-actions[bot] Jul 28, 2025
43cf6d2
Merge branch 'master' into add-log-bootstrapper
lukinovec Jul 29, 2025
50853a3
Test LogTenancyBootstrapper logic (low-level tests)
lukinovec Jul 29, 2025
b80d7b3
Test real usage with storage path-based channels
lukinovec Jul 29, 2025
a13110c
Test real usage with slack channel (the bootstrapper updates the webh…
lukinovec Jul 29, 2025
718afd3
Simplify the slack channel usage test
lukinovec Jul 29, 2025
a806df0
Stop using real domains in the tests
lukinovec Jul 29, 2025
ec47528
Refactor bootstrapper, make comments more concise
lukinovec Jul 29, 2025
8cd35d3
Add @see to bootstrapper docblock
lukinovec Jul 29, 2025
62a0e39
Delete redundant test, test the same logic in the one larger test
lukinovec Jul 29, 2025
582243c
Clarify test name
lukinovec Jul 29, 2025
bd44036
By default, only override the config if the override tenant property …
lukinovec Jul 31, 2025
63bf4bf
Clarify bootstrapper comments
lukinovec Jul 31, 2025
81daa9d
Simplify test
lukinovec Jul 31, 2025
42c837d
Refactor bootstrapper, provide more info in comments
lukinovec Jul 31, 2025
7bdbe9d
Improve checking if tenant attribute is set
lukinovec Jul 31, 2025
c180c2c
Use more accurate terminology
lukinovec Jul 31, 2025
412c1d0
Merge branch 'master' into add-log-bootstrapper
stancl Aug 25, 2025
0b3f698
Merge branch 'master' into add-log-bootstrapper
lukinovec Oct 28, 2025
f878aaf
Improve closure overrides
lukinovec Oct 29, 2025
b36f3ce
Fix typo
lukinovec Oct 29, 2025
108e0d1
Swap closure param order, add/update comments
lukinovec Oct 29, 2025
e133c87
Make test priovide sufficient context for understanding the default b…
lukinovec Oct 29, 2025
58a2447
Use more direct assertions in the tests that assert the actual behavi…
lukinovec Oct 29, 2025
ae39e4d
Clarify behavior in log bootstrapper comments
lukinovec Oct 29, 2025
39fc72b
Merge branch 'master' into add-log-bootstrapper
stancl Apr 12, 2026
aedb33b
Clean up log files in before/afterEach
lukinovec Apr 13, 2026
c68b91c
Make tests not depend on setting the default logging channel
lukinovec Apr 13, 2026
89b0d1c
Include all storage path channels and overrides in `getChannels()`
lukinovec Apr 13, 2026
9660faf
Ensure Slack throws cURL exception in test
lukinovec Apr 13, 2026
221a995
Support channel overrides using dot notation
lukinovec Apr 13, 2026
f705f58
Fix code style (php-cs-fixer)
github-actions[bot] Apr 13, 2026
697ba65
Correct log file cleanup
lukinovec Apr 13, 2026
b744167
Fix PHPStan error
lukinovec Apr 13, 2026
cdea112
Fix code style (php-cs-fixer)
github-actions[bot] Apr 13, 2026
8fda84f
Throw exception if override closure doesn't return array
lukinovec Apr 13, 2026
34115e8
Rollback config if bootstrap fails
lukinovec Apr 13, 2026
1ae418c
Store configured channels in a property, forget only the stored channels
lukinovec Apr 14, 2026
95fd046
Import InvalidArgumentException
lukinovec Apr 14, 2026
06472d5
Fix code style (php-cs-fixer)
github-actions[bot] Apr 14, 2026
9ea3813
Improve $channelOverrides docblock
lukinovec Apr 14, 2026
23ae15a
Preserve filename from central log path in tenant context
lukinovec Apr 14, 2026
b234308
Add comment about log path customization
lukinovec Apr 14, 2026
42d60e9
Make tenant log channels inherit paths from central config, improve c…
lukinovec Apr 14, 2026
c2a80c2
Fix code style (php-cs-fixer)
github-actions[bot] Apr 14, 2026
2f60e76
Clean up nested log files created by tests
lukinovec Apr 14, 2026
fa075ef
Merge branch 'master' into add-log-bootstrapper
lukinovec Apr 15, 2026
c5683d8
Extract cleanup in test file
lukinovec Apr 21, 2026
8276f3b
Improve comments in tests
lukinovec Apr 21, 2026
6e474ac
Improve comments, test reverting on failure during configuration
lukinovec Apr 21, 2026
55ee7d8
Merge branch 'master' into add-log-bootstrapper
stancl Jun 7, 2026
b51d5ca
clarify language in docblocks
stancl Jun 11, 2026
8107ed4
Fix code style (php-cs-fixer)
github-actions[bot] Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions src/Bootstrappers/LogTenancyBootstrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

declare(strict_types=1);

namespace Stancl\Tenancy\Bootstrappers;

use Closure;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Log\LogManager;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;

/**
* Enable tenant-specific logging.
*
* All the storage path channels are configured to use tenant
* directories by default (see the $storagePathChannels property).
Comment on lines +19 to +20

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This language seems weird. single and daily may be the default in a fresh Laravel app but they're just usual channels, there may be many more or different ones.

Would phrase this differently.

*
* For this to work correctly:
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper,
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
* must be true), since the storage path suffix is what separates filesystem-based logs
*
* For logging channels that are not filesystem-based, see the $channelOverrides logic.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
class LogTenancyBootstrapper implements TenancyBootstrapper
{
protected array $defaultConfig = [];

protected array $configuredChannels = [];

/**
* 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
* 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).
Comment on lines +44 to +45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I clarified the language in b51d5ca (too many mentions of "defaults" which don't make sense to me) but still why are we calling the storage approach default when it just serves a different purpose than the approach for overriding specific channels?

It might be "default" in the sense that Laravel apps would by default use one of the two filesystem based logs, but that's more of a default configuration of this bootstrapper rather than being something like the default mode of this bootstrapper when there are just two separate config options for different purposes.

It's likely that even apps using filesystem based logs would still be customizing this config. So we do want to have sane default values for these static props but I'd avoid saying something is the default approach or default behavior. The default value of the property speaks for itself but the docblock is intended for people customizing that value.

*
* 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'];

/**
* Custom channel configuration overrides.
*
* All channels included here will be configured using the provided override.
* The overrides take precedence over the $storagePathChannels behavior
* when both approaches are used for the same channel.
*
* 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: ['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.
*
* So the channel overrides can be arrays and closures that return arrays.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
public static array $channelOverrides = [];
Comment on lines +54 to +72

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the key in this array is e.g. slack, and we can provide either a "partial array override" or a closure that returns the override dynamically based on $tenant, why would the closure approach be:

function (Config\Repository $config, Tenant $tenant): void {
    $config->set('something', something based on $tenant);
}

As opposed to returning a value that'd directly override the channel:

function (array $channel, Tenant $tenant): array {
    return array_merge($channel, [overrides based on $tenant]);
}

The current approach would let you do for instance $channelOverrides['foo'] = fn ($config, $tenant) => $config->set('bar', ...) which doesn't make sense. And requiring the user to do $config->set() manually in the first place is unnecessary complexity for the user.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be resolved now f878aaf

@lukinovec lukinovec Oct 29, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapped the parameter order. Example for current usage of overrides:

LogTenancyBootstrapper::$channelOverrides = [
        'single' => function (Tenant $tenant, array $channel) {
            return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]);
        },
];

Also updated the comments to clarify the bootstrapper's behavior. So I think this review can be resolved now


public function __construct(
protected Repository $config,
protected LogManager $logManager,
) {}

public function bootstrap(Tenant $tenant): void
{
$this->defaultConfig = $this->config->get('logging.channels');
$this->configuredChannels = $this->getChannels();

try {
$this->configureChannels($this->configuredChannels, $tenant);
$this->forgetChannels($this->configuredChannels);
} catch (\Throwable $exception) {
// If an exception is thrown while updating the logging config, the logging config
// could be left in a corrupt state, so we revert to the original config to
// to avoid logging the exception in a tenant channel or a broken channel.
$this->revert();

// We re-throw the exception after having reverted the logging config to central.
throw $exception;
}
}

public function revert(): void
{
$this->config->set('logging.channels', $this->defaultConfig);

$this->forgetChannels($this->configuredChannels);
}

/**
* 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 (primarily because it can be 'stack')
* - all channels in the $storagePathChannels array
* - all channels that have custom overrides in the $channelOverrides property
*/
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 and not in $storagePathChannels,
* 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,
* 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).
*/
Comment on lines +105 to +127

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we assuming that the only way a stack channel might show up is that it'd be the default channel and so we only forget the default channel in addition to the specific channels here. It's a bit of a crude assumption about what the default channel might be. And it seems to entirely miss the possibility of other stack channels including our scoped channels?

To me it'd make way more sense and be much cleaner to simply look at all the stack-based channels and forget those that include the channels in either of our static props.

There might be an extra complication there where perhaps stack channels could support a higher depth than 1 (would require a recursive solution) but we can probably ignore that case and simply assume the depth of any stack is just 1. Not sure if stack supports anything deeper even if it does it seems unlikely that many would use it so I'd go with the simple implementation. And if higher depth stacks are supported, just add a comment documenting that we only look 1 level deep.

$defaultChannel = $this->config->get('logging.default');

return array_filter(
array_unique([
$defaultChannel,
...static::$storagePathChannels,
...array_keys(static::$channelOverrides),
]),
fn (string $channel): bool => $this->config->has("logging.channels.{$channel}")
);
}

/**
* 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 (overrides take precedence over storage path channels).
*/
protected function configureChannels(array $channels, Tenant $tenant): void
{
foreach ($channels as $channel) {
if (isset(static::$channelOverrides[$channel])) {
$this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
} elseif (in_array($channel, static::$storagePathChannels)) {
// Set storage path channels to use a tenant-specific directory (default behavior).
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
$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 segment that follows the storage path from the central channel path config.
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log).
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath)));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void
{
if (is_array($override)) {
// 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) {
/** @var Tenant&Model $tenant */
$tenantAttribute = $tenant->getAttribute($tenantAttributeName);

if ($tenantAttribute !== null) {
$this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} elseif ($override instanceof Closure) {
$channelConfigKey = "logging.channels.{$channel}";

$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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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
{
foreach ($channels as $channel) {
$this->logManager->forgetChannel($channel);
}
}
}
Loading