diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md
index a2936ddf1a..e2484b9820 100644
--- a/docs/1-essentials/01-routing.md
+++ b/docs/1-essentials/01-routing.md
@@ -919,7 +919,9 @@ public function store(Todo $todo): Redirect
### Session configuration
-Tempest supports file and database-based sessions, the former being the default option. Sessions can be configured by creating a `session.config.php` file, in which the expiration time and the session driver can be specified.
+Tempest supports file, Redis and database-based sessions, the former being the default option.
+
+Sessions can be configured by creating a `session.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files), in which the expiration time and the [clean up strategy](#session-cleaning) can be configured.
#### File sessions
@@ -939,13 +941,13 @@ return new FileSessionConfig(
Tempest provides a database-based session driver, particularly useful for applications that run on multiple servers, as session data can be shared across all instances.
-Before using database sessions, a dedicated table is needed. Tempest provides a migration that can be installed using its installer:
+Before using database sessions, a dedicated table is needed. Tempest provides a dedicated sessions installer that can publish file, database, or Redis session configuration:
```sh
-./tempest install sessions:database
+./tempest install sessions
```
-This installer also suggests creating the configuration file that sets up database sessions, with a default expiration of 30 days:
+When choosing the database strategy, the installer can also publish a migration and the configuration file that sets up database sessions, with a default expiration of 30 days:
```php app/Sessions/session.config.php
use Tempest\Http\Session\Config\DatabaseSessionConfig;
@@ -960,7 +962,61 @@ return new DatabaseSessionConfig(
Sessions expire based on the last activity time. This means that as long as a user is actively using the application, their session remains valid.
-Outdated sessions must occasionally be cleaned up. Tempest provides a built-in command to do so, `session:clean`. This command uses the [scheduler](../2-features/11-scheduling.md): with scheduling enabled, it automatically runs behind the scenes.
+By default, Tempest removes expired session randomly at the end of a request, in a deferred task. This can be configured by specifying a {b`Tempest\Http\Session\CleanupStrategy`} in the [session configuration](#session-configuration).
+
+```php app/Sessions/session.config.php
+use Tempest\Http\Session\Config\DatabaseSessionConfig;
+use Tempest\DateTime\Duration;
+
+return new DatabaseSessionConfig(
+ expiration: Duration::days(30),
+ cleanupStrategy: CleanupStrategy::EVERY_REQUEST,
+);
+```
+
+The default behavior is great for most applications. However, at a certain scale, the performance of random cleanup can decrease as the number of sessions grows. In that case, it is recommended to disable request-based session cleaning and switch to a scheduled cleanup strategy:
+
+:::code-group
+
+```php app/Sessions/session.config.php
+use Tempest\Http\Session\Config\DatabaseSessionConfig;
+use Tempest\DateTime\Duration;
+
+return new DatabaseSessionConfig(
+ expiration: Duration::days(30),
+ cleanupStrategy: CleanupStrategy::DISABLED,
+);
+```
+
+```php app/Sessions/CleanupSessionsCommand.php
+namespace App\Sessions;
+
+use Tempest\Console\ConsoleCommand;
+use Tempest\Console\Schedule;
+use Tempest\Console\Scheduler\Every;
+use Tempest\Http\Session\SessionManager;
+
+final readonly class CleanupSessionsCommand
+{
+ public function __construct(
+ private SessionManager $sessionManager,
+ ) {
+ }
+
+ #[ConsoleCommand(name: 'session:clean')]
+ #[Schedule(Every::MINUTE)]
+ public function __invoke(): void
+ {
+ $this->sessionManager->deleteExpiredSessions();
+ }
+}
+```
+
+:::
+
+:::info
+This cleanup command can be installed in your codebase by running the `./tempest install sessions` command.
+:::
## Deferring tasks
diff --git a/docs/1-essentials/06-configuration.md b/docs/1-essentials/06-configuration.md
index 8cc1d6dbbb..a72ec77b14 100644
--- a/docs/1-essentials/06-configuration.md
+++ b/docs/1-essentials/06-configuration.md
@@ -33,18 +33,18 @@ The configuration object above instructs Tempest to use PostgreSQL as its databa
To access a configuration object, you may inject it from the container like any other dependency.
```php
-use Tempest\Core\Environment;
+use Tempest\Core\AppConfig;
final readonly class AboutController
{
public function __construct(
- private Environment $environment,
+ private AppConfig $config,
) {}
#[Get('/')]
public function __invoke(): View
{
- return view('about.view.php', environment: $this->environment);
+ return view('about.view.php', name: $this->config->name);
}
}
```
diff --git a/packages/core/src/PublishesFiles.php b/packages/core/src/PublishesFiles.php
index 439fce69dc..c6443c942a 100644
--- a/packages/core/src/PublishesFiles.php
+++ b/packages/core/src/PublishesFiles.php
@@ -58,15 +58,20 @@ trait PublishesFiles
* @param string $source The path to the source file.
* @param string $destination The path to the destination file.
* @param Closure(string $source, string $destination): void|null $callback A callback to run after the file is published.
+ * @param bool $confirm Whether to ask for create confirmation before publishing.
*/
- public function publish(string $source, string $destination, ?Closure $callback = null): string|false
+ public function publish(string $source, string $destination, ?Closure $callback = null, bool $confirm = true): string|false
{
try {
- if (! $this->console->confirm(
- question: sprintf('Do you want to create ?', $this->friendlyFileName($destination)),
- default: true,
- )) {
- throw new FileGenerationWasAborted('Skipped.');
+ if ($confirm) {
+ $shouldContinue = $this->console->confirm(
+ question: sprintf('Do you want to create ?', $this->friendlyFileName($destination)),
+ default: true,
+ );
+
+ if (! $shouldContinue) {
+ throw new FileGenerationWasAborted('Skipped.');
+ }
}
if (! $this->askForOverride($destination)) {
diff --git a/packages/http/src/Session/CleanupSessionsCommand.php b/packages/http/src/Session/CleanupSessionsCommand.php
index a7335ceda3..2ec64f03e6 100644
--- a/packages/http/src/Session/CleanupSessionsCommand.php
+++ b/packages/http/src/Session/CleanupSessionsCommand.php
@@ -8,8 +8,10 @@
use Tempest\Console\ConsoleCommand;
use Tempest\Console\Schedule;
use Tempest\Console\Scheduler\Every;
+use Tempest\Discovery\SkipDiscovery;
use Tempest\EventBus\EventBus;
+#[SkipDiscovery]
final readonly class CleanupSessionsCommand
{
public function __construct(
diff --git a/packages/http/src/Session/CleanupStrategy.php b/packages/http/src/Session/CleanupStrategy.php
new file mode 100644
index 0000000000..e08b709e33
--- /dev/null
+++ b/packages/http/src/Session/CleanupStrategy.php
@@ -0,0 +1,21 @@
+publish(
- source: __DIR__ . '/CreateSessionsTable.php',
- destination: src_path('Sessions/CreateSessionsTable.php'),
- );
-
- $this->publish(
- source: __DIR__ . '/session.config.stub.php',
- destination: src_path('Sessions/session.config.php'),
- );
-
- $this->publishImports();
-
- if ($migration && $this->shouldMigrate()) {
- $this->migrationManager->up();
- }
- }
-
- private function shouldMigrate(): bool
- {
- $argument = $this->consoleArgumentBag->get('migrate');
-
- if (! $argument instanceof ConsoleInputArgument || ! is_bool($argument->value)) {
- return $this->console->confirm('Do you want to execute migrations?');
- }
-
- return (bool) $argument->value;
- }
-}
diff --git a/packages/http/src/Session/Installer/SessionInstaller.php b/packages/http/src/Session/Installer/SessionInstaller.php
new file mode 100644
index 0000000000..a4f22e6a7f
--- /dev/null
+++ b/packages/http/src/Session/Installer/SessionInstaller.php
@@ -0,0 +1,200 @@
+resolveSessionDriver();
+ $cleanupStrategy = $sessionStrategy === self::REDIS
+ ? null
+ : $this->resolveCleanupStrategy();
+
+ $configPath = $this->resolveTargetPath(
+ argumentName: 'config-path',
+ defaultPath: src_path('Sessions/session.config.php'),
+ );
+
+ $migration = $sessionStrategy === self::DATABASE
+ ? $this->publish(
+ source: __DIR__ . '/CreateSessionsTable.php',
+ destination: src_path('Sessions/CreateSessionsTable.php'),
+ )
+ : null;
+
+ $this->publish(
+ source: $this->resolveSessionConfigStub($sessionStrategy, $cleanupStrategy),
+ destination: $configPath,
+ confirm: false,
+ );
+
+ if ($cleanupStrategy && $this->shouldPublishCleanupCommand($cleanupStrategy)) {
+ $this->publish(
+ source: __DIR__ . '/../CleanupSessionsCommand.php',
+ destination: $this->resolveTargetPath(
+ argumentName: 'cleanup-command-path',
+ defaultPath: src_path('Sessions/CleanupSessionsCommand.php'),
+ ),
+ confirm: false,
+ );
+ }
+
+ $this->publishImports();
+
+ if ($migration && $this->shouldMigrate()) {
+ $this->migrationManager->up();
+ }
+ }
+
+ private function resolveSessionDriver(): string
+ {
+ $argument = $this->consoleArgumentBag->get('strategy');
+
+ if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) {
+ return match (strtolower(trim($argument->value))) {
+ self::FILE => self::FILE,
+ self::DATABASE => self::DATABASE,
+ self::REDIS => self::REDIS,
+ default => throw new InvalidArgumentException('Invalid session storage strategy: ' . $argument->value),
+ };
+ }
+
+ return $this->ask(
+ question: 'Which session storage strategy do you want to use?',
+ options: [
+ self::FILE => 'File',
+ self::DATABASE => 'Database',
+ self::REDIS => 'Redis',
+ ],
+ default: self::FILE,
+ );
+ }
+
+ private function resolveTargetPath(string $argumentName, string $defaultPath): string
+ {
+ $argument = $this->consoleArgumentBag->get($argumentName);
+
+ if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) {
+ return to_absolute_path(root_path(), $argument->value);
+ }
+
+ return $this->promptTargetPath($defaultPath);
+ }
+
+ private function resolveCleanupStrategy(): CleanupStrategy
+ {
+ $argument = $this->consoleArgumentBag->get('cleanup-strategy');
+
+ if ($argument instanceof ConsoleInputArgument && is_string($argument->value)) {
+ $strategy = $this->parseCleanupStrategy($argument->value);
+
+ if ($strategy instanceof CleanupStrategy) {
+ return $strategy;
+ }
+ }
+
+ /** @var string $selection */
+ $selection = $this->ask(
+ question: 'Which session cleanup strategy do you want to use?',
+ options: [
+ CleanupStrategy::RANDOM_REQUESTS->name => 'Random requests',
+ CleanupStrategy::EVERY_REQUEST->name => 'Every request',
+ CleanupStrategy::DISABLED->name => 'Disabled (schedule the cleanup command)',
+ ],
+ default: CleanupStrategy::RANDOM_REQUESTS->name,
+ );
+
+ return $this->parseCleanupStrategy($selection);
+ }
+
+ private function parseCleanupStrategy(string $input): ?CleanupStrategy
+ {
+ $normalized = strtoupper(str_replace(['-', ' '], '_', $input));
+
+ return match ($normalized) {
+ CleanupStrategy::EVERY_REQUEST->name => CleanupStrategy::EVERY_REQUEST,
+ CleanupStrategy::RANDOM_REQUESTS->name => CleanupStrategy::RANDOM_REQUESTS,
+ CleanupStrategy::DISABLED->name => CleanupStrategy::DISABLED,
+ default => null,
+ };
+ }
+
+ private function resolveSessionConfigStub(string $sessionStrategy, ?CleanupStrategy $cleanupStrategy): string
+ {
+ if ($sessionStrategy === self::REDIS) {
+ return __DIR__ . '/session.redis.config.stub.php';
+ }
+
+ return match ([$sessionStrategy, $cleanupStrategy]) {
+ [self::FILE, CleanupStrategy::EVERY_REQUEST] => __DIR__ . '/session.file.every-request.config.stub.php',
+ [self::FILE, CleanupStrategy::RANDOM_REQUESTS] => __DIR__ . '/session.file.random-requests.config.stub.php',
+ [self::FILE, CleanupStrategy::DISABLED] => __DIR__ . '/session.file.disabled.config.stub.php',
+ [self::DATABASE, CleanupStrategy::EVERY_REQUEST] => __DIR__ . '/session.database.every-request.config.stub.php',
+ [self::DATABASE, CleanupStrategy::RANDOM_REQUESTS] => __DIR__ . '/session.database.random-requests.config.stub.php',
+ [self::DATABASE, CleanupStrategy::DISABLED] => __DIR__ . '/session.database.disabled.config.stub.php',
+ default => throw new LogicException('Cleanup strategy must be provided for non-Redis session drivers.'),
+ };
+ }
+
+ private function shouldMigrate(): bool
+ {
+ $argument = $this->consoleArgumentBag->get('migrate');
+
+ if (! $argument instanceof ConsoleInputArgument || ! is_bool($argument->value)) {
+ return $this->console->confirm('Do you want to execute migrations?', default: false);
+ }
+
+ return (bool) $argument->value;
+ }
+
+ private function shouldPublishCleanupCommand(CleanupStrategy $cleanupStrategy): bool
+ {
+ $argument = $this->consoleArgumentBag->get('cleanup-command');
+
+ if ($argument instanceof ConsoleInputArgument && is_bool($argument->value)) {
+ return (bool) $argument->value;
+ }
+
+ if ($cleanupStrategy !== CleanupStrategy::DISABLED) {
+ return false;
+ }
+
+ return $this->console->confirm(
+ 'Session cleanup is disabled. Do you want to publish a session cleanup command?',
+ default: true,
+ );
+ }
+}
diff --git a/packages/http/src/Session/Installer/session.config.stub.php b/packages/http/src/Session/Installer/session.database.disabled.config.stub.php
similarity index 69%
rename from packages/http/src/Session/Installer/session.config.stub.php
rename to packages/http/src/Session/Installer/session.database.disabled.config.stub.php
index e2feecf0ed..162c7390cf 100644
--- a/packages/http/src/Session/Installer/session.config.stub.php
+++ b/packages/http/src/Session/Installer/session.database.disabled.config.stub.php
@@ -1,9 +1,11 @@
session->cleanup();
$this->sessionManager->save($this->session);
- $this->sessionManager->deleteExpiredSessions();
+
+ match ($this->config->cleanupStrategy) {
+ CleanupStrategy::EVERY_REQUEST => $this->scheduleSessionCleanup(),
+ CleanupStrategy::RANDOM_REQUESTS => $this->maybeScheduleSessionCleanup(),
+ default => null,
+ };
+ }
+ }
+
+ private function scheduleSessionCleanup(): void
+ {
+ $this->deferredTasks->add(
+ task: $this->sessionManager->deleteExpiredSessions(...),
+ name: 'tempest:session-cleanup',
+ );
+ }
+
+ private function maybeScheduleSessionCleanup(): void
+ {
+ if (random_int(min: 1, max: 50) === 1) {
+ $this->scheduleSessionCleanup();
}
}
}
diff --git a/packages/http/src/Session/SessionConfig.php b/packages/http/src/Session/SessionConfig.php
index 425d34025a..50a82639a8 100644
--- a/packages/http/src/Session/SessionConfig.php
+++ b/packages/http/src/Session/SessionConfig.php
@@ -12,5 +12,10 @@ interface SessionConfig
*/
public Duration $expiration { get; }
+ /**
+ * Defines the strategy used to clean up expired sessions. The default strategy is `RANDOM_REQUESTS`, which provides a good balance between performance and cleanup frequency.
+ */
+ public CleanupStrategy $cleanupStrategy { get; }
+
public function createManager(Container $container): SessionManager;
}
diff --git a/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php b/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php
index 09c4955b3f..4b366eedcc 100644
--- a/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php
+++ b/tests/Integration/Console/Middleware/ResolveOrRescueMiddlewareTest.php
@@ -43,13 +43,11 @@ public function test_similar_commands(): void
->call('clear')
->assertSee('cache:clear')
->assertSee('discovery:clear')
- ->assertSee('static:clean')
- ->assertSee('session:clean');
+ ->assertSee('static:clean');
$this->console
->call('clean')
->assertSee('static:clean')
- ->assertSee('session:clean')
->assertSee('cache:clear')
->assertSee('discovery:clear');
}
diff --git a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
index 1a81311caf..836bc2ea4b 100644
--- a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
+++ b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
@@ -21,13 +21,11 @@ public function test_invoke(): void
$this->console
->call('schedule:run')
->assertSee('scheduled')
- ->assertSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method')
- ->assertSee('session:clean');
+ ->assertSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method');
$this->console
->call('schedule:run')
->assertNotSee('scheduled')
- ->assertNotSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method')
- ->assertNotSee('session:clean');
+ ->assertNotSee('schedule:task Tests\\\\Tempest\\\\Integration\\\\Console\\\\Fixtures\\\\ScheduledCommand::method');
}
}
diff --git a/tests/Integration/Http/CleanupSessionsCommandTest.php b/tests/Integration/Http/CleanupSessionsCommandTest.php
deleted file mode 100644
index 982297fb45..0000000000
--- a/tests/Integration/Http/CleanupSessionsCommandTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-clock('2024-01-01 00:00:00');
-
- $this->container->config(new FileSessionConfig(
- expiration: Duration::seconds(10),
- path: 'tests/sessions',
- ));
-
- $sessionManager = $this->container->get(SessionManager::class);
-
- $sessionA = $sessionManager->getOrCreate(new SessionId('session_a'));
- $sessionA->set('test', 'value');
-
- $sessionManager->save($sessionA);
-
- $clock->plus(Duration::seconds(9));
-
- $sessionB = $sessionManager->getOrCreate(new SessionId('session_b'));
- $sessionB->set('test', 'value');
-
- $sessionManager->save($sessionB);
-
- $clock->plus(Duration::seconds(2));
-
- $this->console
- ->call(CleanupSessionsCommand::class)
- ->assertContains('session_a')
- ->assertDoesNotContain('session_b');
-
- $this->assertFileDoesNotExist(internal_storage_path('/tests/sessions/session_a'));
- $this->assertFileExists(internal_storage_path('/tests/sessions/session_b'));
- }
-}
diff --git a/tests/Integration/Http/DatabaseSessionInstallerTest.php b/tests/Integration/Http/DatabaseSessionInstallerTest.php
deleted file mode 100644
index a085634ee5..0000000000
--- a/tests/Integration/Http/DatabaseSessionInstallerTest.php
+++ /dev/null
@@ -1,41 +0,0 @@
-installer
- ->configure($this->internalStorage . '/install', new Psr4Namespace('App\\', $this->internalStorage . '/install/App'))
- ->setRoot($this->internalStorage . '/install');
- }
-
- #[PostCondition]
- protected function cleanup(): void
- {
- $this->installer->clean();
- }
-
- #[Test]
- public function installer(): void
- {
- $this->console->call('install sessions:database -f --no-migrate');
-
- $this->installer
- ->assertFileExists('App/Sessions/CreateSessionsTable.php')
- ->assertFileExists('App/Sessions/session.config.php');
- }
-}
diff --git a/tests/Integration/Http/SessionCleanupStrategyTest.php b/tests/Integration/Http/SessionCleanupStrategyTest.php
new file mode 100644
index 0000000000..444ac468f2
--- /dev/null
+++ b/tests/Integration/Http/SessionCleanupStrategyTest.php
@@ -0,0 +1,139 @@
+clearDeferredTasks();
+ }
+
+ #[Test]
+ public function every_request_schedules_cleanup_task(): void
+ {
+ $this->invokeMiddlewareWith(new FileSessionConfig(
+ expiration: Duration::minutes(30),
+ cleanupStrategy: CleanupStrategy::EVERY_REQUEST,
+ path: 'tests/sessions',
+ ));
+
+ $this->assertArrayHasKey(
+ 'tempest:session-cleanup',
+ $this->container->get(DeferredTasks::class)->getTasks(),
+ );
+ }
+
+ #[Test]
+ public function disabled_does_not_schedule_cleanup_task(): void
+ {
+ $this->invokeMiddlewareWith(new FileSessionConfig(
+ expiration: Duration::minutes(30),
+ cleanupStrategy: CleanupStrategy::DISABLED,
+ path: 'tests/sessions',
+ ));
+
+ $this->assertArrayNotHasKey(
+ 'tempest:session-cleanup',
+ $this->container->get(DeferredTasks::class)->getTasks(),
+ );
+ }
+
+ private function invokeMiddlewareWith(FileSessionConfig $config): void
+ {
+ $this->clearDeferredTasks();
+
+ $clock = $this->container->get(Clock::class);
+ $now = $clock->now();
+
+ $session = new Session(
+ id: new SessionId('strategy-test'),
+ createdAt: $now,
+ lastActiveAt: $now,
+ );
+
+ $manager = new TestingSessionManager($clock);
+
+ $middleware = new ManageSessionMiddleware(
+ sessionManager: $manager,
+ session: $session,
+ config: $config,
+ deferredTasks: $this->container->get(DeferredTasks::class),
+ );
+
+ $response = $middleware(
+ request: new GenericRequest(method: Method::GET, uri: '/'),
+ next: new HttpMiddlewareCallable(fn () => new GenericResponse(Status::OK)),
+ );
+
+ $this->assertSame(Status::OK, $response->status);
+ $this->assertSame(1, $manager->saveCalls);
+ }
+
+ private function clearDeferredTasks(): void
+ {
+ $deferredTasks = $this->container->get(DeferredTasks::class);
+
+ foreach (array_keys($deferredTasks->getTasks()) as $name) {
+ $deferredTasks->forget($name);
+ }
+ }
+}
+
+final class TestingSessionManager implements SessionManager
+{
+ public int $saveCalls = 0;
+
+ public function __construct(
+ private readonly Clock $clock,
+ ) {}
+
+ public function getOrCreate(SessionId $id): Session
+ {
+ $now = $this->clock->now();
+
+ return new Session(
+ id: $id,
+ createdAt: $now,
+ lastActiveAt: $now,
+ );
+ }
+
+ public function save(Session $session): void
+ {
+ $this->saveCalls++;
+ }
+
+ public function delete(Session $session): void {}
+
+ public function isValid(Session $session): bool
+ {
+ return true;
+ }
+
+ public function deleteExpiredSessions(): void {}
+}
diff --git a/tests/Integration/Http/SessionInstallerTest.php b/tests/Integration/Http/SessionInstallerTest.php
new file mode 100644
index 0000000000..ba75dfcdd5
--- /dev/null
+++ b/tests/Integration/Http/SessionInstallerTest.php
@@ -0,0 +1,119 @@
+installer
+ ->configure(__DIR__ . '/install', new Psr4Namespace('App\\', __DIR__ . '/install/App'))
+ ->setRoot(__DIR__ . '/install');
+ }
+
+ #[PostCondition]
+ protected function cleanup(): void
+ {
+ $this->installer->clean();
+ }
+
+ #[Test]
+ public function installs_database_session_config_and_migration(): void
+ {
+ $this->console
+ ->call('install sessions')
+ ->confirm()
+ ->input(1)
+ ->input(0)
+ ->input('App/Sessions/session.config.php')
+ ->confirm()
+ ->deny()
+ ->assertSuccess();
+
+ $this->installer
+ ->assertFileExists('App/Sessions/CreateSessionsTable.php')
+ ->assertFileExists('App/Sessions/session.config.php');
+
+ $this->installer->assertFileContains('App/Sessions/session.config.php', 'DatabaseSessionConfig');
+ }
+
+ #[Test]
+ public function installs_file_session_config(): void
+ {
+ $this->console
+ ->call('install sessions')
+ ->confirm()
+ ->input(0)
+ ->input(0)
+ ->input('App/Sessions/session.config.php')
+ ->assertSuccess();
+
+ $this->installer
+ ->assertFileExists('App/Sessions/session.config.php')
+ ->assertFileContains('App/Sessions/session.config.php', 'FileSessionConfig');
+ }
+
+ #[Test]
+ public function installs_redis_session_config(): void
+ {
+ $this->console
+ ->call('install sessions')
+ ->confirm()
+ ->input(2)
+ ->input('App/Sessions/session.config.php')
+ ->assertSuccess();
+
+ $this->installer
+ ->assertFileExists('App/Sessions/session.config.php')
+ ->assertFileContains('App/Sessions/session.config.php', 'RedisSessionConfig')
+ ->assertFileContains('App/Sessions/session.config.php', 'expiration: Duration::days(30),');
+
+ $this->installer
+ ->assertFileContains('App/Sessions/session.config.php', 'return new RedisSessionConfig(')
+ ->assertFileNotContains('App/Sessions/session.config.php', 'cleanupStrategy:');
+
+ $this->assertFileDoesNotExist($this->installer->path('App/Sessions/CleanupSessionsCommand.php'));
+ }
+
+ #[Test]
+ public function publishes_cleanup_command_when_requested(): void
+ {
+ $this->console
+ ->call('install sessions')
+ ->confirm()
+ ->input(0)
+ ->input(2)
+ ->input('App/Sessions/session.config.php')
+ ->confirm()
+ ->input('App/Sessions/CleanupSessionsCommand.php')
+ ->assertSuccess();
+
+ $this->installer->assertFileExists('App/Sessions/CleanupSessionsCommand.php');
+ }
+
+ #[Test]
+ public function writes_selected_cleanup_strategy_to_the_config(): void
+ {
+ $this->console
+ ->call('install sessions')
+ ->confirm()
+ ->input(0)
+ ->input(1)
+ ->input('App/Sessions/session.config.php')
+ ->assertSuccess();
+
+ $this->installer->assertFileContains('App/Sessions/session.config.php', 'cleanupStrategy: CleanupStrategy::EVERY_REQUEST');
+ }
+}