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

# 在扩展包中注册字符串验证规则

> 解读 Laravel 13 的 Factory::extend，讲解字符串规则的调用约定、错误消息与翻译、Implicit 与 Dependent 的区别、已创建 Validator 的生命周期以及测试。

要从分发的扩展包中提供像 `acme_tracking:AC` 这样的字符串规则，需要将验证逻辑注册到验证的 `Factory` 中。这样，使用方应用的表单请求就可以用与内置规则相同的写法来使用它。

本页基于 Laravel Framework `v13.40.0` 的官方实现。它补充了[自定义验证规则](https://kawax.biz/zh-CN/advanced/custom-validation-rules.md)中的基础示例，讨论在将其作为扩展包的公开 API 维护时的注册、翻译以及状态的生命周期。

## 与规则对象区分使用

官方文档介绍的 `ValidationRule` 对象同样可以在扩展包中使用。如果"能够以字符串形式指定"本身并不是需求，可以考虑使用能在构造函数中接收类型化参数的对象。

| 方式 | 适用场景 | 失败的通知方式 |
| - | - | - |
| 像 `new CustomRule(...)` 这样的规则对象 | 以类的形式分发类型化配置或复杂的验证 | 调用传入 `validate()` 的 `$fail` |
| 像 `acme_tracking:AC` 这样的已注册字符串规则 | 提供也能嵌入基于字符串的规则定义中的 API | 注册的回调返回 `false` |

即使方法名同样是 `validate()`，调用约定也不同。如果将 `ValidationRule` 的实现类直接作为类字符串传给 `Factory::extend()`，第 3 个参数的类型和返回值都不匹配。

## 区分注册、创建和验证

`Validator` 门面的 `extend()` 是对 `Illuminate\Validation\Factory` 的调用。它与验证输入的 `Illuminate\Validation\Validator` 实例是不同的对象。

```mermaid theme={null}
flowchart TD
    A["扩展包的 boot"] --> B["向 Factory 注册规则和 replacer"]
    B --> C["通过 Factory::make<br>创建新的 Validator"]
    C --> D["复制已注册的扩展和<br>fallback 消息"]
    D --> E["解析各属性的规则并<br>检查执行条件"]
    E --> F["通过类或闭包进行验证"]
    F --> G{"返回值是 true?"}
    G -->|Yes| H["该规则通过"]
    G -->|No| I["选择消息并<br>替换占位符"]
```

上图展示的是字符串规则被执行时的路径。因未填写或 `nullable` 等原因而跳过的情况将在后文说明。

Factory 以 `validator` 为键注册为 singleton，但标准实现的 `make()` 每次返回的都是新创建的 Validator。**之后再向 Factory 注册的内容，不会更新已创建的 Validator 的扩展数组。**

## 注册扩展包专属的规则

作为示例，我们验证一种由大写英文字母前缀、连字符和 8 位数字组成的虚构追踪码。`AC-00000001` 只是格式示例，并非真实快递公司的单号规范。

假设扩展包的命名空间 `Acme\Courier\` 通过 Composer 的 PSR-4 映射到 `src/`，并且作为普通的非延迟 Service Provider 加载。关于 Provider 的加载，请参阅[扩展包自动发现](https://kawax.biz/zh-CN/advanced/package-discovery.md)。

### 将验证逻辑拆分到类中

在 `src/Validation/TrackingCode.php` 中准备一个用于字符串规则的处理类。该类不实现 `ValidationRule` 接口。

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

namespace Acme\Courier\Validation;

use Illuminate\Validation\Validator;
use InvalidArgumentException;

final class TrackingCode
{
    public function validate(
        string $attribute,
        mixed $value,
        array $parameters,
        Validator $validator,
    ): bool {
        if (count($parameters) !== 1
            || ! is_string($parameters[0])
            || preg_match('/\A[A-Z]{2,8}\z/', $parameters[0]) !== 1
        ) {
            throw new InvalidArgumentException(
                'acme_tracking expects one uppercase ASCII prefix of 2-8 characters.',
            );
        }

        return is_string($value)
            && preg_match(
                '/\A'.preg_quote($parameters[0], '/').'-[0-9]{8}\z/',
                $value,
            ) === 1;
    }
}
```

回调会收到 4 个参数：属性名、输入值、参数数组以及正在执行验证的 Validator。对于 `acme_tracking:AC`，参数数组为 `['AC']`。规则内部也会检查值的类型，这样即使单独使用该规则也不会产生类型错误。

不合法的前缀或参数数量不是用户的输入错误，而是被视为**规则定义的配置错误**并抛出异常。当规则因空值而被跳过时，这一配置检查也不会执行，因此还需要使用合法的非空值进行测试。

### 在 Factory 解析后注册

在 `src/CourierServiceProvider.php` 的 `boot()` 中注册规则和消息替换处理。

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

namespace Acme\Courier;

use Acme\Courier\Validation\TrackingCode;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Factory;

final class CourierServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->callAfterResolving('validator', function (Factory $factory): void {
            $factory->extend(
                'acme_tracking',
                TrackingCode::class.'@validate',
                'The :attribute must be :prefix- followed by eight digits.',
            );

            $factory->replacer(
                'acme_tracking',
                static function (
                    string $message,
                    string $attribute,
                    string $rule,
                    array $parameters,
                ): string {
                    return str_replace(':prefix', $parameters[0] ?? '', $message);
                },
            );
        });
    }
}
```

`callAfterResolving()` 在 Factory 尚未解析时会等待其创建，而对于已经解析的 Factory 则会立即注册。关于该钩子本身的详细信息，请参阅[扩展包的服务解析后钩子](https://kawax.biz/zh-CN/advanced/package-service-resolution.md)。

类字符串 `TrackingCode::class.'@validate'` 会在验证时通过容器解析该类并调用方法。即使省略 `@validate` 只写类名，默认的方法名也是 `validate`。如果存在构造函数依赖，请进行绑定，确保容器能够解析。

本示例没有以 singleton 方式注册处理类，也没有设置保存输入的属性。验证时的数据通过参数处理，设计上不要把请求特有的状态捕获进 Factory 所持有的闭包中。

### 在使用方应用中指定

```php theme={null}
$request->validate([
    'tracking_code' => ['bail', 'required', 'string', 'acme_tracking:AC'],
]);
```

`bail` 会在该属性的前置规则失败时停止后续规则。必填性交给 `required`，格式交给 `acme_tracking`，以此划分职责。

公开的名称统一使用像 `acme_tracking` 这样独有的 snake case。对同一类扩展以相同名称注册时，后注册的会覆盖之前的。另外，如果存在内置的 `validateRequired()` 等方法，会直接调用该方法，因此无法用同名扩展替换内置验证。

## 保留用户的错误消息

普通字符串规则的消息大致按以下优先级选择。尺寸规则等特殊分支不在此讨论范围内。

| 优先级 | 消息的来源 |
| - | - |
| 1 | `Validator::make()` 的第 3 个参数或表单请求的 `messages()` |
| 2 | 使用方应用中 `validation.custom.属性名.规则名` 的翻译 |
| 3 | 像 `validation.acme_tracking` 这样以规则名为键的翻译 |
| 4 | 注册到 `Factory::extend()` 第 3 个参数的 fallback 字符串 |

Laravel 替换 `:attribute` 等占位符之后，才会调用自定义 replacer。replacer 的参数有 5 个：已选择的消息、属性名、规则名、参数数组和 Validator。前面的示例省略了未使用的第 5 个参数。

> [!WARNING]
>
> 如果 replacer 总是把整条消息替换为固定文本，就会忽略用户的 `messages()` 和翻译覆盖。前面的示例只替换传入的 `$message` 中的 `:prefix`。

要在应用侧指定日语的默认文本，可以在现有 `lang/ja/validation.php` 的返回数组中添加 `acme_tracking`。下面的示例是要添加的条目示例，并不会删除其他内置消息。

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

return [
    'acme_tracking' => ':attributeは:prefix-に続く8桁の数字で入力してください。',
];
```

关于翻译文件的准备和区域设置，请参阅[本地化](https://kawax.biz/zh-CN/localization.md)。如果在注册时执行 `__('...')` 并保存为 fallback，Factory 中保留的将是当时区域设置下的字符串。在会切换区域设置的应用中，应使用生成错误时的翻译。

### 使用扩展包的命名空间翻译时

在 `boot()` 中调用 `loadTranslationsFrom(__DIR__.'/../lang', 'acme-courier')` 后，就可以通过 `acme-courier::validation.tracking_code` 引用扩展包中的 `lang/ja/validation.php` 等文件。

但是，**仅将翻译键传给 `extend()` 的第 3 个参数并不会自动翻译。** Factory 只会把它当作普通的 fallback 字符串保存。将前面钩子中的 `extend()` 和 `replacer()` 替换为下面的注册方式后，只有在选中 fallback 时才会获取扩展包的翻译。

```php theme={null}
$factory->extend(
    'acme_tracking',
    TrackingCode::class.'@validate',
    'acme-courier::validation.tracking_code',
);

$factory->replacer(
    'acme_tracking',
    static function (
        string $message,
        string $attribute,
        string $rule,
        array $parameters,
        \Illuminate\Validation\Validator $validator,
    ): string {
        if ($message === 'acme-courier::validation.tracking_code') {
            return $validator->getTranslator()->get($message, [
                'attribute' => $validator->getDisplayableAttribute($attribute),
                'prefix' => $parameters[0] ?? '',
            ]);
        }

        return str_replace(':prefix', $parameters[0] ?? '', $message);
    },
);
```

在扩展包各区域设置的 `validation.php` 中准备 `tracking_code` 条目。日语文本可以与前面的 `acme_tracking` 条目相同。

这里由于在 replacer 中获取了新的翻译字符串，因此也显式地把 `:attribute` 传给了翻译。Laravel 的标准替换不会再次应用于这个返回值。如果选中的是用户的自定义消息，则保持该文本不变。关于分发翻译的覆盖，请参阅[扩展包翻译的覆盖与更新](https://kawax.biz/zh-CN/advanced/package-translations.md)。

## 理解 Implicit 与 nullable 的执行条件

`extendImplicit()` 注册的规则，即使属性缺失或为空字符串也会成为验证候选。它并不意味着"一定失败"或"忽略所有其他跳过条件"。

| 输入与指定 | 普通的 `extend()` | `extendImplicit()` |
| - | - | - |
| 属性不存在 | 不执行 | 成为执行对象 |
| 空字符串或仅含空白的字符串 | 不执行 | 成为执行对象 |
| 存在的属性为 `null`，且无 `nullable` | 成为执行对象 | 成为执行对象 |
| 存在的属性为 `null`，且有 `nullable` | 不执行 | 成为执行对象 |
| 属性不存在，且有 `sometimes` | 不执行 | 不执行 |

上表比较的是各规则的执行条件。因 `bail`、属性排除、`stopOnFirstFailure()` 等原因而提前停止处理的情况另当别论。在 HTTP 输入中，空字符串可能会被中间件转换为 `null`，因此请分别测试空字符串和 `null`。

本例中的格式规则，使用普通的 `extend()` 与 `required` 组合就足够了。将已公开的规则改为 Implicit 后，以前不会被验证的输入可能会失败，因此应将其视为兼容性变更。

## 使用 Dependent 规则解析数组中的引用目标

对于以另一个字段名作为参数的规则，可以使用 `extendDependent()`。在验证 `items.*.confirmed_tracking` 时，参数中的 `items.*.tracking` 会被转换为同一行的 `items.0.tracking` 等。普通的 `extend()` 不会进行这种替换。

下面是用于确认这一机制的匹配规则。如果只是验证简单的相等，实际应用中可以使用内置的 `same`。请将其视为应用于扩展包特有的跨字段验证时的示例。

将其添加到从钩子中接收到的 Factory。

```php theme={null}
$factory->extendDependent(
    'acme_matches_field',
    static function (
        string $attribute,
        mixed $value,
        array $parameters,
        \Illuminate\Validation\Validator $validator,
    ): bool {
        if (count($parameters) !== 1
            || ! is_string($parameters[0])
            || $parameters[0] === ''
        ) {
            throw new \InvalidArgumentException('acme_matches_field expects one field name.');
        }

        $data = $validator->getData();

        return \Illuminate\Support\Arr::has($data, $parameters[0])
            && $value === \Illuminate\Support\Arr::get($data, $parameters[0]);
    },
    'The :attribute does not match the related field.',
);
```

```php theme={null}
$request->validate([
    'items' => ['required', 'array'],
    'items.*' => ['array:tracking,confirmed_tracking'],
    'items.*.tracking' => ['required', 'string', 'acme_tracking:AC'],
    'items.*.confirmed_tracking' => [
        'required',
        'string',
        'acme_matches_field:items.*.tracking',
    ],
]);
```

注册为 Dependent 并不能保证引用目标存在或已通过验证。第 4 个参数的 `getData()` 返回的是**验证对象的数据**，而不仅仅是已验证的数据。请为引用方和被引用方都定义所需的规则。

此外，注册为 Dependent 并不会使其成为 Implicit。针对缺失或空值的必填性需要另行指定。由于实现会对参数进行点号转义处理和 `*` 替换，因此应避免设计在字段名之外混入任意字符串的 API。

## 在应用中测试从注册到验证的全过程

在加载了扩展包和 Service Provider 的 Laravel 13 应用中，创建 `tests/Feature/TrackingRuleTest.php`。除了类的单元测试外，还要通过实际的 Factory 验证注册和消息处理。

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

namespace Tests\Feature;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Factory;
use Tests\TestCase;

final class TrackingRuleTest extends TestCase
{
    public function test_format_presence_and_null_are_separate(): void
    {
        $rules = ['code' => ['acme_tracking:AC']];

        foreach ([
            [['code' => 'AC-00000001'], true],
            [['code' => 'ZX-00000001'], false],
            [['code' => ['AC-00000001']], false],
            [['code' => ''], true],
            [['code' => null], false],
            [[], true],
        ] as [$data, $expected]) {
            $this->assertSame($expected, Validator::make($data, $rules)->passes());
        }

        $this->assertTrue(Validator::make(
            ['code' => null],
            ['code' => ['nullable', 'acme_tracking:AC']],
        )->passes());

        $this->assertFalse(Validator::make(
            [],
            ['code' => ['required', 'acme_tracking:AC']],
        )->passes());
    }

    public function test_application_message_and_display_name_are_kept(): void
    {
        $validator = Validator::make(
            ['code' => 'invalid'],
            ['code' => ['acme_tracking:AC']],
            ['code.acme_tracking' => ':attributeには:prefixのコードを入力してください。'],
            ['code' => '追跡番号'],
        );

        $this->assertSame(
            '追跡番号にはACのコードを入力してください。',
            $validator->errors()->first('code'),
        );
    }

    public function test_existing_validator_keeps_its_extensions(): void
    {
        $factory = new Factory($this->app['translator'], $this->app);
        $factory->extend('acme_probe', static fn (): bool => true);
        $before = $factory->make(['code' => 'x'], ['code' => ['acme_probe']]);

        $factory->extend('acme_probe', static fn (): bool => false, 'Rejected.');
        $after = $factory->make(['code' => 'x'], ['code' => ['acme_probe']]);

        $this->assertTrue($before->passes());
        $this->assertFalse($after->passes());
    }
}
```

```bash theme={null}
php artisan test --compact tests/Feature/TrackingRuleTest.php
```

最后一个测试不修改共享的 Factory，而是新建一个 Factory 来确认注册内容的复制行为。如果还提供命名空间翻译或 Dependent 的示例，请在包含相应注册的环境中准备额外的测试。

## 长期维护时需要确认的事项

* 将规则名、参数的含义与数量、必填性以及可接受的值类型作为公开 API 记录下来。
* 验证不合法的参数，以及缺失、空字符串、`null` 与 `nullable`、`sometimes`、`bail` 的各种组合。
* 确认用户的 `messages()`、属性显示名、区域设置切换以及扩展包翻译的覆盖。
* 对于 Dependent 规则，使用多行和嵌套数组确认不会引用其他行的值。
* 确认向未解析和已解析的 Factory 注册的行为，不要期望注册内容会追溯反映到已创建的 Validator。
* 避免与多个扩展包发生名称冲突，不要将同一名称混用于普通、Implicit 和 Dependent 注册。
* 将变更反映到长生命周期 worker 时要重启进程，不要在共享的处理类中残留输入或用户信息。

关于扩展包独立的测试环境，请参阅[扩展包测试](https://kawax.biz/zh-CN/advanced/package-testing.md)；关于兼容性的处理，也请参阅[扩展包的版本兼容性管理](https://kawax.biz/zh-CN/advanced/package-versioning.md)。

## 参考的一手资料

* [Laravel 官方文档：自定义规则与 Implicit 规则](https://github.com/laravel/docs/blob/10add0d2dade04cacfab436dcc3a8df7ca004e74/validation.md#custom-validation-rules)
* [Laravel 官方文档：扩展包的 Service Provider 与翻译](https://github.com/laravel/docs/blob/10add0d2dade04cacfab436dcc3a8df7ca004e74/packages.md)
* [Factory：make、extend、extendImplicit、extendDependent、replacer](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/Factory.php)
* [Validator：执行条件、扩展注册、类的解析](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/Validator.php)
* [ValidationRuleParser：字符串规则的解析](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/ValidationRuleParser.php)
* [FormatsMessages：消息的优先级与替换](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/Concerns/FormatsMessages.php)
* [ValidationServiceProvider：validator 的 singleton 注册](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/ValidationServiceProvider.php)
* [ServiceProvider：callAfterResolving](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Support/ServiceProvider.php#L310-L317)
* [官方测试：从 Factory 传递扩展与 fallback](https://github.com/laravel/framework/blob/v13.40.0/tests/Validation/ValidationFactoryTest.php)
* [官方测试：字符串、类、Implicit、Dependent 规则](https://github.com/laravel/framework/blob/v13.40.0/tests/Validation/ValidationValidatorTest.php#L7372-L7471)


## Related topics

- [自定义验证规则](https://kawax.biz/zh-CN/advanced/custom-validation-rules.md)
- [包的服务解析后钩子](https://kawax.biz/zh-CN/advanced/package-service-resolution.md)
- [Laravel 新代码分析生态 — surveyor / ranger / roster](https://kawax.biz/zh-CN/blog/laravel-ecosystem-analysis.md)
- [包的 Blade 扩展与编译缓存](https://kawax.biz/zh-CN/advanced/package-blade-extensions.md)
- [包的邮件传输注册与测试](https://kawax.biz/zh-CN/advanced/package-mail-transports.md)


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