> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 包的自定义 Session 驱动

> 解读 Laravel 13 的 SessionManager，讲解 SessionHandlerInterface 的存储契约、显式的驱动选择、TTL 与序列化、已保留的 Store 以及测试。

要从包中提供自己的 Session 存储位置，应将**存储处理的注册**与**应用的选择**分离开来。设计时要确保仅安装包不会切换登录状态的存储位置，让使用者在确认对现有 Session 的影响后再启用。

本页面基于对 Laravel Framework `v13.39.0` 官方源码的确认。基本操作请参阅 [Session](https://kawax.biz/zh-CN/session.md)，服务提供者的自动加载请参阅[包的自动发现](https://kawax.biz/zh-CN/advanced/package-discovery.md)。

## extend 接收的是处理器而不是 Store

`Session::extend()` 的回调返回的是实现了 PHP `SessionHandlerInterface` 的**存储处理器**。它不是直接返回实现 `Illuminate\Contracts\Session\Session` 的 Store 或缓存 Repository 的 API。

`SessionManager::callCustomCreator()` 会将回调的返回值传给 `buildSession()`，并根据 `session.encrypt` 用 `Store` 或 `EncryptedStore` 进行包装。此时还会应用 Cookie 名称和 `session.serialization`。

```mermaid theme={null}
flowchart TD
    A["在 boot() 中注册自定义驱动名称"] --> B["使用者选择 session.driver"]
    B --> C["首次调用 driver() 时执行创建回调"]
    C --> D["返回 SessionHandlerInterface"]
    D --> E["SessionManager 用 Store 或<br>EncryptedStore 包装"]
    E --> F["StartSession 开始读取"]
    F --> G["应用通过 put() 等进行操作"]
    G --> H["Store 序列化后<br>传给 handler.write()"]
```

在常规 HTTP 处理中，`StartSession` 中间件负责管理 Session 的启动与保存。为了提供处理器，包本身无需调用 `session_start()` 或 `session_set_save_handler()`。

## 分别理解存储契约

实现自定义后端时，需要满足以下职责。

| 方法 | 处理器的职责 |
| - | - |
| `open($savePath, $sessionName)` | 必要的初始化。不需要时返回 `true` |
| `close()` | 必要的收尾处理。不需要时返回 `true` |
| `read($sessionId)` | 返回已保存的字符串。未保存或已过期时返回空字符串 |
| `write($sessionId, $data)` | 保存传入的字符串，并返回成功或失败 |
| `destroy($sessionId)` | 删除指定 ID 的数据 |
| `gc($lifetime)` | 根据以秒为单位的有效期删除过期数据。如果存储位置会自动过期，则无需删除 |

由于 `Store` 负责序列化与还原，处理器中不要再次对 Session 数组执行 `serialize()` 或 `json_encode()`。启用加密时，也直接保存经 `EncryptedStore` 处理后的字符串。

> [!WARNING]
>
> 要区分数据未保存与后端故障。经确认，`Store::readFromHandler()` 在读取结果为空或 `false` 时会将其视为空 Session 。此外，`Store::save()` 不会检查处理器 `write()` 的返回值。
>
> 对于需要始终将故障作为异常处理的包，不要吞掉存储位置抛出的异常；对于返回 `false` 的后端，也应在处理器中检测并转换为异常，明确方针并进行测试。失败时 Laravel 不一定会自动重试或通知保存失败。

## 在 boot 中只注册创建处理

下面是复用内置 `CacheBasedSessionHandler` 的最小示例。它并非实现新的存储协议，而是展示如何注册使用命名缓存存储的自定义驱动。假设 `Acme\SessionBridge\` 通过 Composer 的 PSR-4 映射到 `src/`。

在 `src/SessionBridgeServiceProvider.php` 中准备服务提供者。

```php theme={null}
<?php

namespace Acme\SessionBridge;

use Illuminate\Contracts\Container\Container;
use Illuminate\Session\CacheBasedSessionHandler;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use SessionHandlerInterface;

final class SessionBridgeServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Session::extend('acme-cache', function (Container $app): SessionHandlerInterface {
            $config = $app->make('config');
            $store = $config->get('session.store');
            $minutes = (int) $config->get('session.lifetime', 120);

            if (! is_string($store) || $store === '' || $minutes <= 0) {
                throw new InvalidArgumentException(
                    'acme-cache requires a named cache store and a positive session lifetime.'
                );
            }

            return new CacheBasedSessionHandler(
                $app->make('cache')->store($store),
                $minutes,
            );
        });
    }
}
```

`Session::extend()` 会获取 SessionManager 并注册创建处理，但此时不会解析回调内的缓存存储。存储位置在该驱动首次被选中时才会创建。不要在服务提供者的 `boot()` 中执行连接检查或数据保存。

使用自定义的 `SessionHandlerInterface` 实现时，替换回调返回的处理器即可。如果连 `new Store(...)` 也在回调中完成，就会与 SessionManager 期望的返回值不一致。

* 服务提供者按常规的非延迟提供者加载。
* 在 Session 首次被使用之前完成注册。如果其他服务提供者会先解析自定义驱动，还需确认加载顺序。
* 驱动名称使用包专属的名称。同名的 `extend()` 会覆盖创建处理；如果注册内置驱动的名称，则会优先于内置实现。

## 由使用方应用显式选择

使用者修改现有 `config/session.php` 中的对应键，其他配置保持不变。

```php theme={null}
'driver' => 'acme-cache',
'store' => 'acme-sessions',
```

作为存储位置，在 `config/cache.php` 现有的 `stores` 数组中添加命名存储。下面是使用 Redis 连接 `default` 的示例。[Redis 的连接配置与所需客户端](https://kawax.biz/zh-CN/redis.md)由应用方准备。

```php theme={null}
'acme-sessions' => [
    'driver' => 'redis',
    'connection' => 'default',
    'prefix' => 'acme-sessions:',
],
```

本例中的连接与前缀由**缓存存储的配置**决定。由于不会经过内置 Redis Session 驱动的 `createRedisDriver()`，即使添加 `session.connection` 或 `session.prefix`，该回调也不会自动应用它们。

该回调不会修改 Repository 或存储位置的配置，而是直接使用命名存储。测试用的 `array` 存储在同一应用内也共享同一个存储区域。

包不应无条件改写 `session.driver`、Cookie 名称或应用全局的缓存前缀。是否采用不与其他用途共享存储区域的设计，以及能否让多台服务器使用同一存储位置，也要与使用者确认。

> [!TIP]
>
> 如果目的只是常规的 Redis 或数据库存储，请优先使用内置的 Session 驱动。自定义驱动是在需要新的存储位置或额外存储规范时使用的扩展点。

## 保持 TTL 的单位与存储格式

`CacheBasedSessionHandler` 的构造函数以**分钟**为单位接收保存期限。`write()` 每次都会将 `$minutes * 60` 传给缓存 Repository 的 `put()`，因此在上面的示例中，每次写入都会刷新 TTL。`gc()` 返回 `0`，将过期处理交给缓存端。

另一方面，`StartSession` 传给处理器 `gc($lifetime)` 的期限以**秒**为单位。不要将 `session.lifetime` 的值直接当作秒来保存。

此外，以下变更并不只是修改配置名称那么简单。

| 变更 | 对现有 Session 的影响 |
| - | - |
| 存储位置、连接、键前缀 | 旧数据在新存储位置中不可见 |
| `session.serialization` | 无法将旧格式的数据按新格式还原 |
| `session.encrypt` | 明文与密文的读写不再匹配 |
| `session.cookie` | 与浏览器发送的现有 Session ID Cookie 的对应关系发生变化 |

如果在包更新中修改这些内容，需要考虑重新认证的提示、是否需要迁移处理，以及新旧版本混合存在的部署。与[包的版本兼容性管理](https://kawax.biz/zh-CN/advanced/package-versioning.md)一样，存储格式和配置键也应纳入需要维护的公开规范。

## 已创建的 Store 不会仅因配置变更而被替换

SessionManager 的父类 `Manager` 会按名称保留已创建的驱动。此外，`SessionServiceProvider` 将 `session.store` 注册为 singleton。

| 操作 | 会发生的事 | 不会发生的事 |
| - | - | - |
| 仅修改配置值 | 之后的创建处理可以读取新值 | 已创建处理器的 TTL 或连接变更 |
| 以同名重新注册 `extend()` | 替换创建回调 | 丢弃以同名保留的 Store |
| `forgetDrivers()` | 删除 Manager 内部保留的所有驱动 | 替换已解析的 `session.store` 或外部持有的 Store 引用 |

`forgetDrivers()` 也不是删除 Session 数据的 API。在运行中的请求里重新创建 Store，会导致与已读取的数据或其他引用之间不一致。

本例中的 CacheManager 也会保留命名存储。如果修改了其连接配置，仅重新解析 SessionManager 是不够的。

在使用配置缓存的部署中，需要重建缓存，并重启长时间运行的 worker，使其加载新的代码和配置。应用重启与已保存数据的迁移是两项不同的工作。

## Session 存储与请求锁是两回事

仅实现自定义处理器，并不能让针对同一 Session 的并行请求串行化。`StartSession` 的阻塞功能使用通过 `session.block_store` 选择的缓存存储的锁，它不一定与 Session 本体的存储位置相同。

通过 `Route::post(...)->block()` 等方式控制时，需要另外验证支持锁的缓存存储、多台服务器之间的共享，以及等待时间与持有时间。包不应擅自启用应用全局的阻塞配置。

## 测试到保存与重新加载

在使用 [Orchestra Testbench](https://kawax.biz/zh-CN/advanced/package-testing.md) 的包中，应准备加载服务提供者并实际选择自定义驱动的测试。下面是 `tests/SessionDriverTest.php` 的示例。测试用的 `array` 缓存不使用外部连接。

```php theme={null}
<?php

namespace Acme\SessionBridge\Tests;

use Acme\SessionBridge\SessionBridgeServiceProvider;
use Illuminate\Session\CacheBasedSessionHandler;
use Illuminate\Session\Store;
use Orchestra\Testbench\TestCase;

final class SessionDriverTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [SessionBridgeServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('session.driver', 'acme-cache');
        $app['config']->set('session.store', 'session-test');
        $app['config']->set('session.lifetime', 2);
        $app['config']->set('session.cookie', 'acme_session');
        $app['config']->set('session.serialization', 'json');
        $app['config']->set('session.encrypt', false);
        $app['config']->set('cache.stores.session-test', ['driver' => 'array']);
    }

    public function test_it_wraps_the_registered_handler(): void
    {
        $store = $this->app->make('session')->driver();

        $this->assertInstanceOf(Store::class, $store);
        $this->assertInstanceOf(CacheBasedSessionHandler::class, $store->getHandler());
        $this->assertSame($store, $this->app->make('session.store'));
    }

    public function test_it_saves_and_reloads_the_payload(): void
    {
        $manager = $this->app->make('session');
        $first = $manager->driver();
        $first->start();
        $first->put('cart.quantity', 3);
        $first->save();

        $id = $first->getId();
        $payload = $first->getHandler()->read($id);
        $this->assertSame(3, json_decode($payload, true)['cart']['quantity']);

        $manager->forgetDrivers();
        $second = $manager->driver();
        $second->setId($id);
        $second->start();

        $this->assertNotSame($first, $second);
        $this->assertSame(3, $second->get('cart.quantity'));

        $second->invalidate();
        $this->assertSame('', $second->getHandler()->read($id));
    }

    public function test_forgetting_drivers_does_not_replace_the_store_binding(): void
    {
        $old = $this->app->make('session.store');
        $manager = $this->app->make('session');

        $this->app['config']->set('session.lifetime', 5);
        $manager->forgetDrivers();

        $this->assertNotSame($old, $manager->driver());
        $this->assertSame($old, $this->app->make('session.store'));
    }
}
```

该测试是在同一应用内重新创建 Store 的**隔离测试**。它并不验证 HTTP 中间件、Cookie 往返、跨进程的持久化，也不验证真实 Redis 的过期或锁。不要将 `array` 用作生产环境的共享存储位置。

在发布自定义处理器之前，还应添加以下测试。

* 未保存或已过期的 ID 返回空字符串
* 保存的字符串能原样还原，删除后无法再次获取
* TTL 的单位与刷新，以及真实后端中的过期
* 读取、写入、删除失败时，得到约定的返回值或异常
* 启用 `session.encrypt` 时的往返，以及所支持的序列化格式
* 仅加载服务提供者不会访问存储位置，也不会抢占内置驱动的选择
* 如有需要，经过真实 `web` 中间件与 Cookie 的 HTTP 测试，以及并行请求的锁

## 相关页面

> [card] [Session](https://kawax.biz/zh-CN/session.md)
>
> 了解 Session 的读写、闪存数据以及 ID 的重新生成。

> [card] [Manager 的内部实现](https://kawax.biz/zh-CN/advanced/manager.md)
>
> 了解自定义驱动的创建与保留，以及向默认驱动的委托。

> [card] [包的自定义缓存存储](https://kawax.biz/zh-CN/advanced/package-cache-drivers.md)
>
> 了解作为存储位置的缓存 Store 与 Repository 的分工，以及 TTL 契约。

> [card] [包服务的生命周期](https://kawax.biz/zh-CN/advanced/package-service-lifetimes.md)
>
> 了解长时间运行的 worker 中保留的依赖与状态隔离。

## 一手资料

* [官方 HTTP Session 文档：自定义驱动与阻塞](https://github.com/laravel/docs/blob/625d1dfb61018e6bc50cc7ab67fca89ae018dfa2/session.md)
* [官方包开发文档](https://github.com/laravel/docs/blob/625d1dfb61018e6bc50cc7ab67fca89ae018dfa2/packages.md)
* [SessionManager：自定义创建处理与 Store 的构建](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Session/SessionManager.php)
* [Store：读取、序列化、保存、ID 的重新生成](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Session/Store.php)
* [CacheBasedSessionHandler：TTL 转换与存储契约](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Session/CacheBasedSessionHandler.php)
* [CacheManager：命名存储与 Redis 配置](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Cache/CacheManager.php)
* [Manager：extend、driver、forgetDrivers](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Manager.php)
* [SessionServiceProvider：Manager 与 session.store 的 singleton 注册](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Session/SessionServiceProvider.php)
* [StartSession：保存处理、GC、基于缓存的锁](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Session/Middleware/StartSession.php)
* [官方 CacheBasedSessionHandlerTest：未保存、TTL、删除的验证](https://github.com/laravel/framework/blob/v13.39.0/tests/Session/CacheBasedSessionHandlerTest.php)


## Related topics

- [包的自定义缓存存储与契约](https://kawax.biz/zh-CN/advanced/package-cache-drivers.md)
- [包的自定义通知渠道与投递验证](https://kawax.biz/zh-CN/advanced/package-notification-channels.md)
- [限流的自定义](https://kawax.biz/zh-CN/advanced/rate-limiting.md)
- [包的 Storage 驱动注册与重新解析](https://kawax.biz/zh-CN/advanced/package-filesystem-drivers.md)
- [核心包与自定义驱动 - Feedable](https://kawax.biz/zh-CN/packages/feedable/core.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.