Skip to content

Commit b4e4c86

Browse files
committed
Make file size messages human-readable
1 parent 92acabd commit b4e4c86

5 files changed

Lines changed: 140 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## 2.6.1 under development
44

5-
- no changes in this release.
5+
- Bug #802: Use human-readable file sizes in `File` validation messages (@samdark)
66

77
## 2.6.0 June 02, 2026
88

docs/guide/en/built-in-rules-file.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ This option should be used with care because the client can send any media type
7979
For filesystem-backed uploads, size checks use the actual file size on disk. For pathless streams, size checks use the
8080
PSR-7 upload size when available. If a size constraint is configured and the size can't be determined, validation fails.
8181

82+
Size placeholders in error messages are formatted in a human-readable form, for example `{limit}` is `50 MB` when
83+
`maxSize` is `50 * 1024 * 1024`. Use `{limitBytes}` or `{exactlyBytes}` when a custom message needs the raw byte value
84+
for ICU number or plural formatting.
85+
8286
`size` is mutually exclusive with `minSize` and `maxSize`. When both `minSize` and `maxSize` are set, `minSize` must be
8387
less than or equal to `maxSize`.
8488

src/Rule/File.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,23 +127,26 @@ final class File implements DumpedRuleInterface, SkipOnErrorInterface, WhenInter
127127
* - `{property}`: the translated label of the property being validated.
128128
* - `{Property}`: the translated label of the property being validated, capitalized.
129129
* - `{file}`: the validated file name when it is available.
130-
* - `{exactly}`: expected exact size in bytes.
130+
* - `{exactly}`: expected exact size in a human-readable format.
131+
* - `{exactlyBytes}`: expected exact size in bytes.
131132
* @param string $tooSmallMessage A message used when the file size is less than {@see $minSize}.
132133
*
133134
* You may use the following placeholders in the message:
134135
*
135136
* - `{property}`: the translated label of the property being validated.
136137
* - `{Property}`: the translated label of the property being validated, capitalized.
137138
* - `{file}`: the validated file name when it is available.
138-
* - `{limit}`: expected minimum size in bytes.
139+
* - `{limit}`: expected minimum size in a human-readable format.
140+
* - `{limitBytes}`: expected minimum size in bytes.
139141
* @param string $tooBigMessage A message used when the file size is greater than {@see $maxSize}.
140142
*
141143
* You may use the following placeholders in the message:
142144
*
143145
* - `{property}`: the translated label of the property being validated.
144146
* - `{Property}`: the translated label of the property being validated, capitalized.
145147
* - `{file}`: the validated file name when it is available.
146-
* - `{limit}`: expected maximum size in bytes.
148+
* - `{limit}`: expected maximum size in a human-readable format.
149+
* - `{limitBytes}`: expected maximum size in bytes.
147150
* @param string $unableToDetermineSizeMessage A message used when file size constraints are configured, but the
148151
* file size can't be determined.
149152
*
@@ -200,6 +203,7 @@ public function __construct(
200203
$this->extensions = $this->normalizeList($extensions);
201204
$this->mimeTypes = $this->normalizeList($mimeTypes);
202205
$this->skipOnEmpty = $skipOnEmpty;
206+
$this->normalizeDefaultSizeMessages();
203207
}
204208

205209
public function getName(): string
@@ -485,6 +489,30 @@ private function normalizeList(array|string|null $value): ?array
485489
return $value;
486490
}
487491

492+
private function normalizeDefaultSizeMessages(): void
493+
{
494+
if (
495+
$this->notExactSizeMessage
496+
=== 'The size of {property} must be exactly {exactly, number} {exactly, plural, one{byte} other{bytes}}.'
497+
) {
498+
$this->notExactSizeMessage = 'The size of {property} must be exactly {exactly}.';
499+
}
500+
501+
if (
502+
$this->tooSmallMessage
503+
=== 'The size of {property} cannot be smaller than {limit, number} {limit, plural, one{byte} other{bytes}}.'
504+
) {
505+
$this->tooSmallMessage = 'The size of {property} cannot be smaller than {limit}.';
506+
}
507+
508+
if (
509+
$this->tooBigMessage
510+
=== 'The size of {property} cannot be larger than {limit, number} {limit, plural, one{byte} other{bytes}}.'
511+
) {
512+
$this->tooBigMessage = 'The size of {property} cannot be larger than {limit}.';
513+
}
514+
}
515+
488516
private static function isUploadMissing(mixed $value): bool
489517
{
490518
return $value instanceof UploadedFileInterface && $value->getError() === UPLOAD_ERR_NO_FILE;

src/Rule/FileHandler.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
use function is_readable;
2121
use function is_string;
2222
use function mime_content_type;
23+
use function number_format;
2324
use function pathinfo;
25+
use function round;
26+
use function rtrim;
2427
use function str_contains;
2528
use function str_ends_with;
2629
use function str_replace;
@@ -279,27 +282,76 @@ private function validateSize(array $file, File $rule, ValidationContext $contex
279282
}
280283

281284
if ($rule->getSize() !== null && $size !== $rule->getSize()) {
285+
$ruleSize = $rule->getSize();
282286
$result->addError(
283287
$rule->getNotExactSizeMessage(),
284-
$this->getParameters($context, $file, ['exactly' => $rule->getSize()]),
288+
$this->getParameters(
289+
$context,
290+
$file,
291+
[
292+
'exactly' => $this->formatSize($ruleSize),
293+
'exactlyBytes' => $ruleSize,
294+
],
295+
),
285296
);
286297
}
287298

288299
if ($rule->getMinSize() !== null && $size < $rule->getMinSize()) {
300+
$minSize = $rule->getMinSize();
289301
$result->addError(
290302
$rule->getTooSmallMessage(),
291-
$this->getParameters($context, $file, ['limit' => $rule->getMinSize()]),
303+
$this->getParameters(
304+
$context,
305+
$file,
306+
[
307+
'limit' => $this->formatSize($minSize),
308+
'limitBytes' => $minSize,
309+
],
310+
),
292311
);
293312
}
294313

295314
if ($rule->getMaxSize() !== null && $size > $rule->getMaxSize()) {
315+
$maxSize = $rule->getMaxSize();
296316
$result->addError(
297317
$rule->getTooBigMessage(),
298-
$this->getParameters($context, $file, ['limit' => $rule->getMaxSize()]),
318+
$this->getParameters(
319+
$context,
320+
$file,
321+
[
322+
'limit' => $this->formatSize($maxSize),
323+
'limitBytes' => $maxSize,
324+
],
325+
),
299326
);
300327
}
301328
}
302329

330+
private function formatSize(int $size): string
331+
{
332+
if ($size < 1024) {
333+
return $size . ' ' . ($size === 1 ? 'byte' : 'bytes');
334+
}
335+
336+
$value = (float) $size;
337+
foreach (['KB', 'MB', 'GB', 'TB'] as $unit) {
338+
$value /= 1024;
339+
if (round($value, 2) >= 1024) {
340+
continue;
341+
}
342+
343+
return $this->formatSizeValue($value) . ' ' . $unit;
344+
}
345+
346+
$value /= 1024;
347+
return $this->formatSizeValue($value) . ' PB';
348+
}
349+
350+
private function formatSizeValue(float $value): string
351+
{
352+
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
353+
}
354+
303355
/**
304356
* @psalm-param FileData $file
305357
*/

tests/Rule/FileTest.php

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,15 @@ public static function dataOptions(): array
149149
'parameters' => [],
150150
],
151151
'notExactSizeMessage' => [
152-
'template' => 'The size of {property} must be exactly {exactly, number} {exactly, plural, one{byte} other{bytes}}.',
152+
'template' => 'The size of {property} must be exactly {exactly}.',
153153
'parameters' => [],
154154
],
155155
'tooSmallMessage' => [
156-
'template' => 'The size of {property} cannot be smaller than {limit, number} {limit, plural, one{byte} other{bytes}}.',
156+
'template' => 'The size of {property} cannot be smaller than {limit}.',
157157
'parameters' => [],
158158
],
159159
'tooBigMessage' => [
160-
'template' => 'The size of {property} cannot be larger than {limit, number} {limit, plural, one{byte} other{bytes}}.',
160+
'template' => 'The size of {property} cannot be larger than {limit}.',
161161
'parameters' => [],
162162
],
163163
'unableToDetermineSizeMessage' => [
@@ -369,6 +369,52 @@ public static function dataValidationFailed(): array
369369
new File(maxSize: 920),
370370
['' => ['The size of value cannot be larger than 920 bytes.']],
371371
],
372+
'exact size mismatch uses human-readable size' => [
373+
self::TEXT_FILE,
374+
new File(size: 50 * 1024 * 1024),
375+
['' => ['The size of value must be exactly 50 MB.']],
376+
],
377+
'too small uses human-readable size' => [
378+
self::TEXT_FILE,
379+
new File(minSize: 1536),
380+
['' => ['The size of value cannot be smaller than 1.5 KB.']],
381+
],
382+
'too big uses human-readable size' => [
383+
self::JPG_FILE,
384+
new File(maxSize: 512),
385+
['' => ['The size of value cannot be larger than 512 bytes.']],
386+
],
387+
'size boundary rounds to next unit' => [
388+
self::createStreamUpload('large.bin', 'application/octet-stream', 1024 * 1024),
389+
new File(maxSize: 1024 * 1024 - 1),
390+
['' => ['The size of value cannot be larger than 1 MB.']],
391+
],
392+
'custom message uses human-readable size placeholder' => [
393+
self::createStreamUpload('large.bin', 'application/octet-stream', 60 * 1024 * 1024),
394+
new File(
395+
maxSize: 50 * 1024 * 1024,
396+
tooBigMessage: '{file} is larger than {limit}.',
397+
),
398+
['' => ['large.bin is larger than 50 MB.']],
399+
],
400+
'custom message can use raw byte size placeholder' => [
401+
self::JPG_FILE,
402+
new File(
403+
maxSize: 512,
404+
tooBigMessage: '{file} is larger than {limit}; raw limit is {limitBytes, number} '
405+
. '{limitBytes, plural, one{byte} other{bytes}}.',
406+
),
407+
['' => ['16x18.jpg is larger than 512 bytes; raw limit is 512 bytes.']],
408+
],
409+
'custom exact size message can use raw byte size placeholder' => [
410+
self::JPG_FILE,
411+
new File(
412+
size: 512,
413+
notExactSizeMessage: '{file} must be {exactly}; raw size is {exactlyBytes, number} '
414+
. '{exactlyBytes, plural, one{byte} other{bytes}}.',
415+
),
416+
['' => ['16x18.jpg must be 512 bytes; raw size is 512 bytes.']],
417+
],
372418
'stream upload unknown exact size' => [
373419
self::createStreamUpload('resume.txt', 'text/plain', null),
374420
new File(extensions: 'txt', mimeTypes: 'text/plain', size: 22, trustClientMediaType: true),

0 commit comments

Comments
 (0)