ChangeUntilValidMapper
Mappers::changeUntilValid($retryValueChanger, ...$mappers) runs the given mappers/validators as a
pipe, then asks the $retryValueChanger whether the resulting value is acceptable (typically: unique).
If it is not, the changer produces a new candidate value — for example by appending a number suffix — and the pipe
re-runs on that candidate. The loop repeats until the value passes the changer's check, or until $maxRetryNo is
exhausted (in which case a MisconfiguredMapperException is thrown).
The changer also enforces length bounds before its check: it pads values shorter than $min with a $fillStr and
crops values longer than $max. The mappers in the pipe run on every iteration, so a typical setup is
changeUntilValid($changer, Mappers::noSpecialChars(), Mappers::cleanString()) — sanitize first, then test the
sanitized value for uniqueness.
Built-in changer: RetryValueChangers::numberSuffixOnRetry
RetryValueChangers::numberSuffixOnRetry(
\Closure|null $closure, // (string $value): bool — the validity/uniqueness test
int $min, // minimum length (value is padded with $fillStr)
int $max, // maximum length (value is cropped)
string|\Stringable|null $fallBackOnNullValue, // used when the input is null
string $fillStr = 'path',
string $valueNumberSuffixSeparator = ' ', // inserted before the retry number
int $maxRetryNo = 9999
): RetryValueChanger
The $closure returns true when the value is acceptable. On false, the changer appends
$valueNumberSuffixSeparator plus an incrementing number (starting at 2) and retries. Already-taken numbers are
skipped, so the first free number is used. The constructor throws if $max < $min or if $max is not long
enough to hold the separator plus $maxRetryNo.
Note:
changeUntilValidruns its inner mappers as a pipe.pathPartis a preconfigured shortcut of this mapper for generating unique URL path segments.
Example: generating unique slugs
$taken = [];
$isUnique = function (string $value) use (&$taken) {
if (in_array($value, $taken, true)) return false;
$taken[] = $value;
return true;
};
$retry = RetryValueChangers::numberSuffixOnRetry(closure: $isUnique, min: 4, max: 30,
fallBackOnNullValue: 'slug', valueNumberSuffixSeparator: '-');
$target = new DataMap();
$result = Bind::attrs(['a' => null, 'b' => null])->toAttrs($target)
->props(['a', 'b'], Mappers::changeUntilValid($retry, Mappers::noSpecialChars(), Mappers::cleanString()))
->exec();
var_dump($result->isValid(), $target->reqString('a', true, true), $target->reqString('b', true, true));
Output:
bool(true)
string(4) "slug"
string(6) "slug-2"
Both inputs are null, so the changer substitutes the fallback 'slug'. The first prop passes the uniqueness
check ('slug'), so it stays 'slug'. By the time the second prop is processed 'slug' is already taken, so the
changer appends -2 → 'slug-2'.
Example: sanitizing a dirty input before testing uniqueness
$taken = ['asdf'];
$isUnique = function (string $value) use (&$taken) {
if (in_array($value, $taken, true)) return false;
$taken[] = $value;
return true;
};
$retry = RetryValueChangers::numberSuffixOnRetry(closure: $isUnique, min: 4, max: 30,
fallBackOnNullValue: 'slug', valueNumberSuffixSeparator: '-');
$target = new DataMap();
$result = Bind::attrs(['c' => 'a§%sdf'])->toAttrs($target)
->prop('c', Mappers::changeUntilValid($retry, Mappers::noSpecialChars(), Mappers::cleanString()))
->exec();
var_dump($result->isValid(), $target->reqString('c', true, true));
Output:
bool(true)
string(6) "asdf-2"
The pipe's noSpecialChars() turns 'a§%sdf' into 'asdf'. That sanitized value is already in $taken, so the
changer retries with 'asdf-2', which is unique.
When no acceptable value is found
If the changer cannot produce an acceptable value within $maxRetryNo attempts (for example because every
candidate is already taken), the mapper throws MisconfiguredMapperException with a message like
"could not find a unique value after N retries. Please increase maxRetryNo or review the mapper configuration."
Raise $maxRetryNo or widen $max if legitimate workloads can exhaust the retry budget.