Skip to content

Commit cc8b0b6

Browse files
committed
feat: support {field}, {param}, {value} placeholders in rule $error messages
1 parent 07fa170 commit cc8b0b6

3 files changed

Lines changed: 171 additions & 15 deletions

File tree

system/Validation/Validation.php

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -369,14 +369,16 @@ protected function processRules(
369369
$fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
370370

371371
// @phpstan-ignore-next-line $error may be set by rule methods.
372-
$this->errors[$fieldForErrors] = $error ?? $this->getErrorMessage(
373-
($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
374-
$field,
375-
$label,
376-
$param,
377-
(string) $value,
378-
$originalField,
379-
);
372+
$this->errors[$fieldForErrors] = $error !== null
373+
? $this->parseErrorMessage($error, $field, $label, $param, (string) $value)
374+
: $this->getErrorMessage(
375+
($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
376+
$field,
377+
$label,
378+
$param,
379+
(string) $value,
380+
$originalField,
381+
);
380382

381383
return false;
382384
}
@@ -933,13 +935,7 @@ protected function getErrorMessage(
933935
?string $value = null,
934936
?string $originalField = null,
935937
): string {
936-
$param ??= '';
937-
938-
$args = [
939-
'field' => ($label === null || $label === '') ? $field : lang($label),
940-
'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
941-
'value' => $value ?? '',
942-
];
938+
$args = $this->buildErrorArgs($field, $label, $param, $value);
943939

944940
// Check if custom message has been defined by user
945941
if (isset($this->customErrors[$field][$rule])) {
@@ -955,6 +951,49 @@ protected function getErrorMessage(
955951
return lang('Validation.' . $rule, $args);
956952
}
957953

954+
/**
955+
* Substitutes {field}, {param}, and {value} placeholders in an error message
956+
* set directly by a rule method via the $error reference parameter.
957+
*
958+
* Uses simple string replacement rather than lang() to avoid ICU MessageFormatter
959+
* warnings on unrecognised patterns and to leave any other {xyz} content untouched.
960+
*/
961+
private function parseErrorMessage(
962+
string $message,
963+
string $field,
964+
?string $label = null,
965+
?string $param = null,
966+
?string $value = null,
967+
): string {
968+
$args = $this->buildErrorArgs($field, $label, $param, $value);
969+
970+
return str_replace(
971+
['{field}', '{param}', '{value}'],
972+
[$args['field'], $args['param'], $args['value']],
973+
$message,
974+
);
975+
}
976+
977+
/**
978+
* Builds the placeholder arguments array used for error message substitution.
979+
*
980+
* @return array{field: string, param: string, value: string}
981+
*/
982+
private function buildErrorArgs(
983+
string $field,
984+
?string $label = null,
985+
?string $param = null,
986+
?string $value = null,
987+
): array {
988+
$param ??= '';
989+
990+
return [
991+
'field' => ($label === null || $label === '') ? $field : lang($label),
992+
'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
993+
'value' => $value ?? '',
994+
];
995+
}
996+
958997
/**
959998
* Split rules string by pipe operator.
960999
*/

tests/_support/Validation/TestRules.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ public function customError(string $str, ?string &$error = null)
2525
return false;
2626
}
2727

28+
/**
29+
* @param-out string $error
30+
*/
31+
public function custom_error_with_param(mixed $str, string $param, array $data, ?string &$error = null, string $field = ''): bool
32+
{
33+
$error = 'The {field} must be one of: {param}. Got: {value}';
34+
35+
return false;
36+
}
37+
2838
public function check_object_rule(object $value, ?string $fields, array $data = [])
2939
{
3040
$find = false;

tests/system/Validation/ValidationTest.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,35 @@ static function ($value, $data, &$error, $field): bool {
342342
$this->assertSame([], $this->validation->getValidated());
343343
}
344344

345+
public function testClosureRuleWithParamErrorPlaceholders(): void
346+
{
347+
$this->validation->setRules([
348+
'status' => [
349+
'label' => 'Status',
350+
'rules' => [
351+
static function ($value, $data, &$error, $field): bool {
352+
if ($value !== 'active') {
353+
$error = 'The field {field} must be one of: {param}. Received: {value}';
354+
355+
return false;
356+
}
357+
358+
return true;
359+
},
360+
],
361+
],
362+
]);
363+
364+
$data = ['status' => 'invalid'];
365+
$result = $this->validation->run($data);
366+
367+
$this->assertFalse($result);
368+
$this->assertSame(
369+
['status' => 'The field Status must be one of: . Received: invalid'],
370+
$this->validation->getErrors(),
371+
);
372+
}
373+
345374
public function testClosureRuleWithLabel(): void
346375
{
347376
$this->validation->setRules([
@@ -415,6 +444,22 @@ public function rule2(mixed $value, array $data, ?string &$error, string $field)
415444
return true;
416445
}
417446

447+
/**
448+
* Validation rule3
449+
*
450+
* @param array<string, mixed> $data
451+
*/
452+
public function rule3(mixed $value, array $data, ?string &$error, string $field): bool
453+
{
454+
if ($value !== 'active') {
455+
$error = 'The field {field} must be one of: {param}. Received: {value}';
456+
457+
return false;
458+
}
459+
460+
return true;
461+
}
462+
418463
public function testCallableRuleWithParamError(): void
419464
{
420465
$this->validation->setRules([
@@ -435,6 +480,68 @@ public function testCallableRuleWithParamError(): void
435480
$this->assertSame([], $this->validation->getValidated());
436481
}
437482

483+
public function testCallableRuleWithParamErrorPlaceholders(): void
484+
{
485+
$this->validation->setRules([
486+
'status' => [
487+
'label' => 'Status',
488+
'rules' => [$this->rule3(...)],
489+
],
490+
]);
491+
492+
$data = ['status' => 'invalid'];
493+
$result = $this->validation->run($data);
494+
495+
$this->assertFalse($result);
496+
$this->assertSame(
497+
['status' => 'The field Status must be one of: . Received: invalid'],
498+
$this->validation->getErrors(),
499+
);
500+
}
501+
502+
public function testRuleSetRuleWithParamErrorPlaceholders(): void
503+
{
504+
$this->validation->setRules([
505+
'status' => [
506+
'label' => 'Status',
507+
'rules' => 'custom_error_with_param[active,inactive]',
508+
],
509+
]);
510+
511+
$data = ['status' => 'invalid'];
512+
$result = $this->validation->run($data);
513+
514+
$this->assertFalse($result);
515+
$this->assertSame(
516+
['status' => 'The Status must be one of: active,inactive. Got: invalid'],
517+
$this->validation->getErrors(),
518+
);
519+
}
520+
521+
public function testClosureRuleErrorWithUnknownPlaceholderPreserved(): void
522+
{
523+
$this->validation->setRules([
524+
'status' => [
525+
'rules' => [
526+
static function ($value, $data, &$error, $field): bool {
527+
$error = 'Value {value} is invalid. See {link} for details.';
528+
529+
return false;
530+
},
531+
],
532+
],
533+
]);
534+
535+
$data = ['status' => 'bad'];
536+
$result = $this->validation->run($data);
537+
538+
$this->assertFalse($result);
539+
$this->assertSame(
540+
['status' => 'Value bad is invalid. See {link} for details.'],
541+
$this->validation->getErrors(),
542+
);
543+
}
544+
438545
public function testCallableRuleWithLabel(): void
439546
{
440547
$this->validation->setRules([

0 commit comments

Comments
 (0)