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

# Registering string validation rules in packages

> Examine Laravel 13's Factory::extend to understand the calling contract of string rules, error messages and translations, the difference between Implicit and Dependent, the lifetime of created Validators, and testing.

To provide a string rule such as `acme_tracking:AC` from a distributed package, register the validation logic on the validation `Factory`. Consuming applications can then use it in their form requests with the same syntax as the built-in rules.

This page is based on the official source of Laravel Framework `v13.40.0`. It supplements the basic examples in [Custom validation rules](https://kawax.biz/en/advanced/custom-validation-rules.md) and covers registration, translation, and state lifetime when maintaining such rules as a package's public API.

## Choose between this and rule objects

The `ValidationRule` objects introduced in the official documentation can also be used in packages. Unless being specifiable as a string is itself a requirement, consider an object that can receive typed arguments through its constructor.

| Approach | Suitable use | How failure is reported |
| - | - | - |
| A rule object such as `new CustomRule(...)` | Distributing typed configuration or complex validation as a class | Call the `$fail` passed to `validate()` |
| A registered string rule such as `acme_tracking:AC` | Providing an API that can also be embedded in string-based rule definitions | The registered callback returns `false` |

Even though both use the name `validate()`, their calling contracts differ. If you pass a class implementing `ValidationRule` directly as the class string to `Factory::extend()`, the type of the third argument and the return value will not match.

## Separate registration, creation, and validation

`extend()` on the `Validator` facade is a call to `Illuminate\Validation\Factory`. It is a different object from the `Illuminate\Validation\Validator` instance that validates input.

```mermaid theme={null}
flowchart TD
    A["Package boot"] --> B["Register rule and replacer<br>on the Factory"]
    B --> C["Create a new Validator<br>with Factory::make"]
    C --> D["Copy registered extensions<br>and fallback messages"]
    D --> E["Parse each attribute's rules<br>and check run conditions"]
    E --> F["Validate with class or closure"]
    F --> G{"Return value is true?"}
    G -->|Yes| H["This rule passes"]
    G -->|No| I["Select message and<br>replace placeholders"]
```

The diagram shows the path when a string rule is executed. Skipping due to missing input, `nullable`, and so on is covered later.

The Factory is registered as a singleton under the `validator` key, but the Validator returned by the default `make()` implementation is newly created every time. **Registering on the Factory afterward does not update the extension arrays of Validators that have already been created.**

## Register a package-specific rule

As an example, we validate a fictional tracking code consisting of an uppercase prefix, a hyphen, and eight digits. A format like `AC-00000001` is just an example and not the number specification of any real carrier.

This assumes the package namespace `Acme\Courier\` maps to `src/` via Composer PSR-4 and is loaded as a regular non-deferred service provider. For provider loading, see [Package auto-discovery](https://kawax.biz/en/advanced/package-discovery.md).

### Separate the validation logic into a class

Prepare a handler for the string rule in `src/Validation/TrackingCode.php`. This class does not implement the `ValidationRule` interface.

```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;
    }
}
```

The callback receives four arguments: the attribute name, the input value, the parameter array, and the Validator currently performing validation. For `acme_tracking:AC`, the parameter array is `['AC']`. The rule also checks the value's type itself so that it does not cause a type error when used on its own.

An invalid prefix or parameter count throws an exception as a **misconfiguration of the rule definition**, not as a user input error. When the rule is skipped for empty values, this configuration check is not executed either, so you also need tests that use valid non-empty values.

### Register after the Factory is resolved

Register the rule and the message replacer from `boot()` in `src/CourierServiceProvider.php`.

```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()` waits for creation if the Factory has not yet been resolved, and registers immediately on a Factory that has already been resolved. The hook itself is explained in detail in [Package service post-resolution hooks](https://kawax.biz/en/advanced/package-service-resolution.md).

With the class string `TrackingCode::class.'@validate'`, the class is resolved from the container at validation time and the method is called. Even if you pass only the class name without `@validate`, the default method name is `validate`. If the class has constructor dependencies, bind them so the container can resolve them.

This example does not register the handler as a singleton or include properties that store input. Handle validation-time data through the arguments, and design the closures held by the Factory so they do not capture request-specific state.

### Specify it from the consuming application

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

`bail` stops subsequent rules for that attribute once a preceding rule fails. Split responsibilities so that `required` handles presence and `acme_tracking` handles format.

Use unique snake case names such as `acme_tracking` consistently for public names. When the same name is registered for the same kind of extension, the later registration overwrites the earlier one. Also, if a built-in method such as `validateRequired()` exists, that method is called directly, so you cannot replace built-in validation with an extension of the same name.

## Preserve the user's error messages

Messages for regular string rules are selected roughly in the following order of precedence. Special branches such as size rules are out of scope here.

| Precedence | Message source |
| - | - |
| 1 | The third argument of `Validator::make()` or a form request's `messages()` |
| 2 | The consuming application's `validation.custom.attribute.rule` translation |
| 3 | A rule-name translation such as `validation.acme_tracking` |
| 4 | The fallback string registered as the third argument of `Factory::extend()` |

The custom replacer is called after Laravel replaces placeholders such as `:attribute`. The replacer receives five arguments: the selected message, the attribute name, the rule name, the parameter array, and the Validator. The earlier example omits the unused fifth argument.

> [!WARNING]
>
> If a replacer always replaces the entire message with fixed text, it ignores the user's `messages()` and translation overrides. The earlier example replaces only `:prefix` in the passed `$message`.

To specify a Japanese default message on the application side, add `acme_tracking` to the array returned by the existing `lang/ja/validation.php`. The example below shows the entry to add; it does not remove the other built-in messages.

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

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

For preparing translation files and configuring locales, see [Localization](https://kawax.biz/en/localization.md). If you call `__('...')` at registration time and store the result as the fallback, the string for the locale at that moment remains in the Factory. In applications that switch locales, use translations at the time the error is generated.

### When using namespaced package translations

If you call `loadTranslationsFrom(__DIR__.'/../lang', 'acme-courier')` in `boot()`, you can reference the package's `lang/ja/validation.php` and similar files as `acme-courier::validation.tracking_code`.

However, **simply passing a translation key as the third argument of `extend()` does not translate it automatically.** The Factory keeps it as a plain fallback string. If you replace the `extend()` and `replacer()` in the earlier hook with the following registration, the package translation is retrieved only when the fallback is selected.

```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);
    },
);
```

Provide a `tracking_code` entry in the package's `validation.php` for each locale. For Japanese, you can use the same text as the earlier `acme_tracking` entry.

Because the replacer retrieves a new translation string here, `:attribute` is also passed explicitly to the translation. Laravel's standard replacements are not applied again to this return value. When the user's own message is selected, that text is kept. For overriding distributed translations, see [Overriding and updating package translations](https://kawax.biz/en/advanced/package-translations.md).

## Understand the run conditions for Implicit and nullable

`extendImplicit()` registers a rule that becomes a validation candidate even for missing attributes or empty strings. It does not mean "always fail" or "ignore all other skip conditions."

| Input / specification | Regular `extend()` | `extendImplicit()` |
| - | - | - |
| Attribute is missing | Not run | Eligible to run |
| Empty string or whitespace-only string | Not run | Eligible to run |
| `null` for a present attribute, without `nullable` | Eligible to run | Eligible to run |
| `null` for a present attribute, with `nullable` | Not run | Eligible to run |
| Attribute is missing, with `sometimes` | Not run | Not run |

The table compares the run conditions for each rule. Cases where processing stops earlier due to `bail`, attribute exclusion, `stopOnFirstFailure()`, and so on are separate. For HTTP input, middleware may convert empty strings to `null`, so test empty strings and `null` separately.

For the format rule here, combining a regular `extend()` with `required` is sufficient. Changing an already-published rule to Implicit can cause input that was previously not validated to fail, so treat it as a compatibility change.

## Resolve references within arrays with Dependent rules

For rules that take another field's name as a parameter, you can use `extendDependent()`. When validating `items.*.confirmed_tracking`, the parameter `items.*.tracking` is converted to the same row, such as `items.0.tracking`. A regular `extend()` does not perform this replacement.

The following is a matching rule for confirming how this mechanism works. If you only need a simple equality check, real applications can use the built-in `same`. Treat this as an example for applying the mechanism to package-specific cross-field validation.

Add it to the Factory received from the hook.

```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',
    ],
]);
```

Registering as Dependent does not guarantee that the referenced field exists or has been validated. `getData()` on the fourth argument returns **the data under validation**, not only validated data. Define the necessary rules on both the referencing and referenced fields.

Also, making a rule Dependent does not make it Implicit. Specify presence requirements for missing or empty values separately. Because the implementation escapes dots and replaces `*` in the parameters, avoid APIs that mix arbitrary strings other than field names into them.

## Test from registration to validation in an application

In a Laravel 13 application that loads the package and its service provider, create `tests/Feature/TrackingRuleTest.php`. In addition to unit tests of the class, verify registration and message handling through the actual 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
```

The last test does not modify the shared Factory; it creates a new Factory and verifies how registrations are copied. If you also provide the namespaced translation or Dependent examples, add tests in an environment that includes the corresponding registrations.

## What to check for long-term maintenance

* Document the rule name, the meaning and number of parameters, presence requirements, and accepted value types as public API.
* Test invalid parameters, missing values, empty strings, `null`, and combinations with `nullable`, `sometimes`, and `bail`.
* Check the user's `messages()`, attribute display names, locale switching, and overrides of package translations.
* For Dependent rules, confirm with multiple rows and nested arrays that values from other rows are not referenced.
* Verify registration on both unresolved and resolved Factories, and do not expect changes to be applied retroactively to already-created Validators.
* Avoid name collisions with other packages, and do not mix regular, Implicit, and Dependent registrations under the same name.
* Restart processes to apply changes to long-lived workers, and do not leave input or user information in shared handlers.

For a standalone package testing environment, see [Package testing](https://kawax.biz/en/advanced/package-testing.md), and for handling compatibility, see [Package version compatibility management](https://kawax.biz/en/advanced/package-versioning.md).

## Primary sources consulted

* [Laravel official documentation: Custom rules and implicit rules](https://github.com/laravel/docs/blob/10add0d2dade04cacfab436dcc3a8df7ca004e74/validation.md#custom-validation-rules)
* [Laravel official documentation: Package service providers and translations](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: Run conditions, extension registration, and class resolution](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/Validator.php)
* [ValidationRuleParser: Parsing string rules](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/ValidationRuleParser.php)
* [FormatsMessages: Message precedence and replacement](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Validation/Concerns/FormatsMessages.php)
* [ValidationServiceProvider: Singleton registration of validator](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)
* [Official tests: Forwarding extensions and fallbacks from the Factory](https://github.com/laravel/framework/blob/v13.40.0/tests/Validation/ValidationFactoryTest.php)
* [Official tests: String, class, Implicit, and Dependent rules](https://github.com/laravel/framework/blob/v13.40.0/tests/Validation/ValidationValidatorTest.php#L7372-L7471)


## Related topics

- [Package service resolution hooks](https://kawax.biz/en/advanced/package-service-resolution.md)
- [Validation](https://kawax.biz/en/validation.md)
- [Custom Validation Rules](https://kawax.biz/en/advanced/custom-validation-rules.md)
- [Registering dynamic relationships in packages](https://kawax.biz/en/advanced/package-dynamic-relations.md)
- [Registering and testing mail transports in packages](https://kawax.biz/en/advanced/package-mail-transports.md)


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