diff --git a/resources/js/components/fieldtypes/assets/Asset.js b/resources/js/components/fieldtypes/assets/Asset.js
index ab72d882c9..60bc3a287f 100644
--- a/resources/js/components/fieldtypes/assets/Asset.js
+++ b/resources/js/components/fieldtypes/assets/Asset.js
@@ -9,6 +9,7 @@ export default {
     props: {
         asset: Object,
         readOnly: Boolean,
+        errors: Array,
         showFilename: {
             type: Boolean,
             default: true
diff --git a/resources/js/components/fieldtypes/assets/AssetRow.vue b/resources/js/components/fieldtypes/assets/AssetRow.vue
index 664a1593b2..b19e17fa40 100644
--- a/resources/js/components/fieldtypes/assets/AssetRow.vue
+++ b/resources/js/components/fieldtypes/assets/AssetRow.vue
@@ -23,11 +23,14 @@
             <button
                 v-if="showFilename"
                 @click="editOrOpen"
-                class="flex items-center flex-1 rtl:mr-3 ltr:ml-3 text-xs rtl:text-right ltr:text-left truncate w-full"
+                class="flex flex-col justify-center gap-2 flex-1 rtl:mr-3 ltr:ml-3 text-xs rtl:text-right ltr:text-left truncate w-full"
                 :title="__('Edit')"
                 :aria-label="__('Edit Asset')"
             >
-                {{ asset.basename }}
+                <div>{{ asset.basename }}</div>
+                <template v-if="errors.length">
+                    <small class="help-block text-red-500 mb-0" v-for="(error, i) in errors" :key="i" v-text="error" />
+                </template>
             </button>
 
             <button
diff --git a/resources/js/components/fieldtypes/assets/AssetTile.vue b/resources/js/components/fieldtypes/assets/AssetTile.vue
index f4b7cb788a..74f244d09a 100644
--- a/resources/js/components/fieldtypes/assets/AssetTile.vue
+++ b/resources/js/components/fieldtypes/assets/AssetTile.vue
@@ -20,6 +20,15 @@
 
         <div class="asset-thumb-container">
             <div class="asset-thumb" :class="{ 'bg-checkerboard': canBeTransparent }">
+                <template v-if="errors.length">
+                    <div class="absolute z-10 inset-0 bg-white/75 dark:bg-dark-800/90 flex flex-col gap-2 items-center justify-center px-1 py-2">
+                        <small
+                            class="help-block text-red-500 text-center mb-0"
+                            v-text="errors[0]"
+                        />
+                    </div>
+                </template>
+
                 <!-- Solo Bard -->
                 <template v-if="isImage && isInBardField && !isInAssetBrowser">
                     <img :src="asset.url" />
@@ -38,7 +47,7 @@
                     </template>
                 </template>
 
-                <div class="asset-controls">
+                <div class="asset-controls z-10">
                     <div class="flex items-center justify-center space-x-1 rtl:space-x-reverse">
                         <template v-if="!readOnly">
                             <button @click="edit" class="btn btn-icon" :title="__('Edit')">
diff --git a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
index 8f1418dd4c..8f53e77c52 100644
--- a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
+++ b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
@@ -83,9 +83,10 @@
                     >
                         <div class="asset-grid-listing border dark:border-dark-900 rounded overflow-hidden" :class="{ 'rounded-t-none': !isReadOnly && (showPicker || uploads.length) }" ref="assets">
                             <asset-tile
-                                v-for="asset in assets"
+                                v-for="(asset, index) in assets"
                                 :key="asset.id"
                                 :asset="asset"
+                                :errors="errors[index] ?? []"
                                 :read-only="isReadOnly"
                                 :show-filename="config.show_filename"
                                 :show-set-alt="showSetAlt"
@@ -110,9 +111,10 @@
                                 <tbody ref="assets">
                                     <tr is="assetRow"
                                         class="asset-row"
-                                        v-for="asset in assets"
+                                        v-for="(asset, index) in assets"
                                         :key="asset.id"
                                         :asset="asset"
+                                        :errors="errors[index] ?? []"
                                         :read-only="isReadOnly"
                                         :show-filename="config.show_filename"
                                         :show-set-alt="showSetAlt"
@@ -191,6 +193,7 @@ export default {
 
     mixins: [Fieldtype],
 
+    inject: ['storeName'],
 
     data() {
         return {
@@ -455,6 +458,27 @@ export default {
             ];
         },
 
+        errors() {
+            const state = this.$store.state.publish[this.storeName];
+
+            console.log({ state });
+            if (! state) {
+                return {};
+            }
+
+            let errors = {}
+
+            // Filter errors to only include those for this field, and remove the field path prefix
+            // if there is one, then append it to the errors object.
+            Object.entries(state.errors)
+                .filter(([key, value]) => key.startsWith(this.fieldPathPrefix || this.handle))
+                .forEach(([key, value]) => {
+                    errors[key.split('.').pop()] = value
+                })
+
+            return errors
+        },
+
     },
 
     events: {
diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php
index bd70f18372..db20e7a923 100644
--- a/resources/lang/en/validation.php
+++ b/resources/lang/en/validation.php
@@ -44,7 +44,17 @@
     'different' => 'This field and :other must be different.',
     'digits' => 'Must be :digits digits.',
     'digits_between' => 'Must be between :min and :max digits.',
-    'dimensions' => 'Invalid image dimensions.',
+    'dimensions' => [
+        'unknown' => 'Image dimensions are unknown.',
+        'ratio' => 'Must have a ratio of :ratio.',
+        'same' => 'Must be :comparison :width×:height pixels.',
+        'different' => 'Must be :comparison_width :width pixels wide and :comparison_height :height pixels tall.',
+        'width' => 'Must be :comparison_width :width pixels wide.',
+        'height' => 'Must be :comparison_height :height pixels tall.',
+        'min' => 'at least',
+        'max' => 'at most',
+        'exact' => 'exactly',
+    ],
     'distinct' => 'This field has a duplicate value.',
     'doesnt_end_with' => 'Must not end with one of the following: :values.',
     'doesnt_start_with' => 'Must not start with one of the following: :values.',
@@ -66,7 +76,7 @@
         'numeric' => 'Must be greater than or equal :value.',
         'string' => 'Must be greater than or equal :value characters.',
     ],
-    'image' => 'Must be an image.',
+    'image' => 'Must be an image of type: :extensions.',
     'in' => 'This is invalid.',
     'in_array' => 'This field does not exist in :other.',
     'integer' => 'Must be an integer.',
diff --git a/src/Fields/Field.php b/src/Fields/Field.php
index ef9c1c9a36..8ccb33e038 100644
--- a/src/Fields/Field.php
+++ b/src/Fields/Field.php
@@ -137,11 +137,21 @@ public function alwaysSave()
 
     public function rules()
     {
-        $rules = [$this->handle => $this->addNullableRule(array_merge(
+        $temp_rules = collect($this->addNullableRule(array_merge(
             $this->get('required') ? ['required'] : [],
             Validator::explodeRules($this->fieldtype()->fieldRules()),
             Validator::explodeRules($this->fieldtype()->rules())
-        ))];
+        )));
+
+        $rules = [];
+        if ($this->type() === 'assets') {
+            $rules = [
+                $this->handle.'.*' => $temp_rules->reject(fn ($rule) => in_array($rule, ['array', 'required']))->all(),
+                $this->handle => $temp_rules->filter(fn ($rule) => in_array($rule, ['array', 'required']))->all(),
+            ];
+        } else {
+            $rules = [$this->handle => $temp_rules->all()];
+        }
 
         $extra = collect($this->fieldtype()->extraRules())->map(function ($rules) {
             return $this->addNullableRule(Validator::explodeRules($rules));
diff --git a/src/Fieldtypes/Assets/DimensionsRule.php b/src/Fieldtypes/Assets/DimensionsRule.php
index d1864601fe..aaba14ac69 100644
--- a/src/Fieldtypes/Assets/DimensionsRule.php
+++ b/src/Fieldtypes/Assets/DimensionsRule.php
@@ -2,122 +2,114 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
-use Illuminate\Contracts\Validation\Rule;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
 use Statamic\Facades\Asset;
 use Statamic\Statamic;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class DimensionsRule implements Rule
+class DimensionsRule implements ValidationRule
 {
-    protected $parameters;
-
-    public function __construct($parameters = null)
+    public function __construct(protected $parameters)
     {
-        $this->parameters = $parameters;
+        $this->parameters = array_reduce($parameters, function ($result, $item) {
+            [$key, $value] = array_pad(explode('=', $item, 2), 2, null);
+
+            $result[$key] = $value;
+
+            return $result;
+        });
     }
 
-    /**
-     * Determine if the validation rule passes.
-     *
-     * @param  string  $attribute
-     * @param  mixed  $value
-     * @return bool
-     */
-    public function passes($attribute, $value)
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        return collect($value)->every(function ($id) {
-            if ($id instanceof UploadedFile) {
-                if (in_array($id->getMimeType(), ['image/svg+xml', 'image/svg'])) {
-                    return true;
-                }
-
-                $size = getimagesize($id->getPathname());
-            } else {
-                if (! $asset = Asset::find($id)) {
-                    return false;
-                }
-
-                if ($asset->isSvg()) {
-                    return true;
-                }
-
-                $size = $asset->dimensions();
-            }
-
-            [$width, $height] = $size;
+        $size = [0, 0];
 
-            $parameters = $this->parseNamedParameters($this->parameters);
+        if ($value instanceof UploadedFile) {
+            if (in_array($value->getMimeType(), ['image/svg+xml', 'image/svg'])) {
+                return;
+            }
 
-            if ($this->failsBasicDimensionChecks($parameters, $width, $height) ||
-                $this->failsRatioCheck($parameters, $width, $height)) {
-                return false;
+            $size = getimagesize($value->getPathname());
+        } elseif ($asset = Asset::find($value)) {
+            if ($asset->isSvg()) {
+                return;
             }
 
-            return true;
-        });
-    }
+            $size = $asset->dimensions();
+        }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    public function message()
-    {
-        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.dimensions');
+        [$width, $height] = $size;
+        if ($message = $this->message($width, $height)) {
+            $fail($message);
+        }
     }
 
-    /**
-     * Parse named parameters to $key => $value items.
-     *
-     * @param  array  $parameters
-     * @return array
-     */
-    protected function parseNamedParameters($parameters)
+    public function message(int $width, int $height): ?string
     {
-        return array_reduce($parameters, function ($result, $item) {
-            [$key, $value] = array_pad(explode('=', $item, 2), 2, null);
+        $invalid_ratio = $this->validateRatio($width, $height);
+        $invalid_width = $this->validateWidth($width);
+        $invalid_height = $this->validateHeight($height);
+        $key = match (true) {
+            $invalid_ratio => 'ratio',
+            $invalid_width && $invalid_height && $invalid_width === $invalid_height => 'same',
+            $invalid_width && $invalid_height && $invalid_width !== $invalid_height => 'different',
+            (bool) $invalid_width => 'width',
+            (bool) $invalid_height => 'height',
+            default => null,
+        };
+
+        if (! $key) {
+            return null;
+        }
 
-            $result[$key] = $value;
+        $prefix = Statamic::isCpRoute() ? 'statamic::' : '';
+
+        $comparisons = [
+            'min' => __("{$prefix}validation.dimensions.min"),
+            'max' => __("{$prefix}validation.dimensions.max"),
+            'exact' => __("{$prefix}validation.dimensions.exact"),
+        ];
+
+        return __("{$prefix}validation.dimensions.{$key}", [
+            'width' => $this->parameters['width'] ?? $this->parameters['min_width'] ?? $this->parameters['max_width'] ?? null,
+            'height' => $this->parameters['height'] ?? $this->parameters['min_height'] ?? $this->parameters['max_height'] ?? null,
+            'ratio' => $this->parameters['ratio'] ?? null,
+            'comparison' => $comparisons[$invalid_width] ?? '',
+            'comparison_width' => $comparisons[$invalid_width] ?? '',
+            'comparison_height' => $comparisons[$invalid_height] ?? '',
+        ]);
+    }
 
-            return $result;
-        });
+    public function validateWidth(int $width): ?string
+    {
+        return match (true) {
+            isset($this->parameters['width']) && $this->parameters['width'] != $width => 'exact',
+            isset($this->parameters['min_width']) && $this->parameters['min_width'] > $width => 'min',
+            isset($this->parameters['max_width']) && $this->parameters['max_width'] < $width => 'max',
+            default => null,
+        };
     }
 
-    /**
-     * Test if the given width and height fail any conditions.
-     *
-     * @param  array  $parameters
-     * @param  int  $width
-     * @param  int  $height
-     * @return bool
-     */
-    protected function failsBasicDimensionChecks($parameters, $width, $height)
+    public function validateHeight(int $height): ?string
     {
-        return (isset($parameters['width']) && $parameters['width'] != $width) ||
-               (isset($parameters['min_width']) && $parameters['min_width'] > $width) ||
-               (isset($parameters['max_width']) && $parameters['max_width'] < $width) ||
-               (isset($parameters['height']) && $parameters['height'] != $height) ||
-               (isset($parameters['min_height']) && $parameters['min_height'] > $height) ||
-               (isset($parameters['max_height']) && $parameters['max_height'] < $height);
+        return match (true) {
+            isset($this->parameters['height']) && $this->parameters['height'] != $height => 'exact',
+            isset($this->parameters['min_height']) && $this->parameters['min_height'] > $height => 'min',
+            isset($this->parameters['max_height']) && $this->parameters['max_height'] < $height => 'max',
+            default => null,
+        };
     }
 
-    /**
-     * Determine if the given parameters fail a dimension ratio check.
-     *
-     * @param  array  $parameters
-     * @param  int  $width
-     * @param  int  $height
-     * @return bool
-     */
-    protected function failsRatioCheck($parameters, $width, $height)
+    public function validateRatio(int $width, int $height): bool
     {
-        if (! isset($parameters['ratio'])) {
+        if (! isset($this->parameters['ratio'])) {
             return false;
         }
 
         [$numerator, $denominator] = array_replace(
-            [1, 1], array_filter(sscanf($parameters['ratio'], '%f/%d'))
+            [1, 1],
+            array_filter(sscanf($this->parameters['ratio'], '%f/%d'))
         );
 
         $precision = 1 / (max($width, $height) + 1);
diff --git a/src/Fieldtypes/Assets/ImageRule.php b/src/Fieldtypes/Assets/ImageRule.php
index eb9955d158..c86f351008 100644
--- a/src/Fieldtypes/Assets/ImageRule.php
+++ b/src/Fieldtypes/Assets/ImageRule.php
@@ -2,51 +2,37 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
-use Illuminate\Contracts\Validation\Rule;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
 use Statamic\Facades\Asset;
 use Statamic\Statamic;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class ImageRule implements Rule
+class ImageRule implements ValidationRule
 {
-    protected $parameters;
+    public $extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'avif'];
 
-    public function __construct($parameters = null)
+    public function __construct(protected $parameters)
     {
-        $this->parameters = $parameters;
     }
 
-    /**
-     * Determine if the validation rule passes.
-     *
-     * @param  string  $attribute
-     * @param  mixed  $value
-     * @return bool
-     */
-    public function passes($attribute, $value)
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        $extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'avif'];
+        $extension = '';
 
-        return collect($value)->every(function ($id) use ($extensions) {
-            if ($id instanceof UploadedFile) {
-                return in_array($id->guessExtension(), $extensions);
-            }
+        if ($value instanceof UploadedFile) {
+            $extension = $value->guessExtension();
+        } elseif ($asset = Asset::find($value)) {
+            $extension = $asset->extension();
+        }
 
-            if (! $asset = Asset::find($id)) {
-                return false;
-            }
-
-            return $asset->guessedExtensionIsOneOf($extensions);
-        });
+        if (! in_array($extension, $this->extensions)) {
+            $fail($this->message());
+        }
     }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    public function message()
+    public function message(): string
     {
-        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.image');
+        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.image', ['extensions' => implode(', ', $this->extensions)]);
     }
 }
diff --git a/src/Fieldtypes/Assets/MaxRule.php b/src/Fieldtypes/Assets/MaxRule.php
index 8a5f04e86f..3d41b7fa2c 100644
--- a/src/Fieldtypes/Assets/MaxRule.php
+++ b/src/Fieldtypes/Assets/MaxRule.php
@@ -2,28 +2,35 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+use Statamic\Facades\Asset;
 use Statamic\Statamic;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class MaxRule extends SizeBasedRule
+class MaxRule implements ValidationRule
 {
-    /**
-     * Determine if the the rule passes for the given size.
-     *
-     * @param  int  $size
-     * @return bool
-     */
-    public function sizePasses($size)
+    public function __construct(protected $parameters)
     {
-        return $size <= $this->parameters[0];
     }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    public function message()
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        return str_replace(':max', $this->parameters[0], __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.max.file'));
+        $size = 0;
+
+        if ($value instanceof UploadedFile) {
+            $size = $value->getSize() / 1024;
+        } elseif ($asset = Asset::find($value)) {
+            $size = $asset->size() / 1024;
+        }
+
+        if ($size > $this->parameters[0]) {
+            $fail($this->message());
+        }
+    }
+
+    public function message(): string
+    {
+        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.max.file', ['max' => $this->parameters[0]]);
     }
 }
diff --git a/src/Fieldtypes/Assets/MimesRule.php b/src/Fieldtypes/Assets/MimesRule.php
index 485ac393cf..660554345b 100644
--- a/src/Fieldtypes/Assets/MimesRule.php
+++ b/src/Fieldtypes/Assets/MimesRule.php
@@ -2,12 +2,13 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
-use Illuminate\Contracts\Validation\Rule;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
 use Statamic\Facades\Asset;
 use Statamic\Statamic;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class MimesRule implements Rule
+class MimesRule implements ValidationRule
 {
     protected $parameters;
 
@@ -17,38 +18,26 @@ public function __construct($parameters)
             $parameters = array_unique(array_merge($parameters, ['jpg', 'jpeg']));
         }
 
-        $this->parameters = $parameters;
+        $this->parameters = array_map(strtolower(...), $parameters);
     }
 
-    /**
-     * Determine if the validation rule passes.
-     *
-     * @param  string  $attribute
-     * @param  mixed  $value
-     * @return bool
-     */
-    public function passes($attribute, $value)
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        return collect($value)->every(function ($id) {
-            if ($id instanceof UploadedFile) {
-                return in_array($id->guessExtension(), $this->parameters);
-            }
+        $mime = '';
 
-            if (! $asset = Asset::find($id)) {
-                return false;
-            }
+        if ($value instanceof UploadedFile) {
+            $mime = $value->guessExtension();
+        } elseif ($asset = Asset::find($value)) {
+            $mime = $asset->extension();
+        }
 
-            return $asset->guessedExtensionIsOneOf($this->parameters);
-        });
+        if (! in_array($mime, $this->parameters)) {
+            $fail($this->message());
+        }
     }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    public function message()
+    public function message(): string
     {
-        return str_replace(':values', implode(', ', $this->parameters), __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimes'));
+        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimes', ['values' => implode(', ', $this->parameters)]);
     }
 }
diff --git a/src/Fieldtypes/Assets/MimetypesRule.php b/src/Fieldtypes/Assets/MimetypesRule.php
index ad1c82acaa..f4dc92afc3 100644
--- a/src/Fieldtypes/Assets/MimetypesRule.php
+++ b/src/Fieldtypes/Assets/MimetypesRule.php
@@ -2,48 +2,35 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
-use Illuminate\Contracts\Validation\Rule;
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
 use Statamic\Facades\Asset;
 use Statamic\Statamic;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class MimetypesRule implements Rule
+class MimetypesRule implements ValidationRule
 {
-    protected $parameters;
-
-    public function __construct($parameters)
+    public function __construct(protected $parameters)
     {
-        $this->parameters = $parameters;
     }
 
-    /**
-     * Determine if the validation rule passes.
-     *
-     * @param  string  $attribute
-     * @param  mixed  $value
-     * @return bool
-     */
-    public function passes($attribute, $value)
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        return collect($value)->every(function ($id) {
-            if ($id instanceof UploadedFile) {
-                $mimeType = $id->getMimeType();
-            } elseif (! ($mimeType = optional(Asset::find($id))->mimeType())) {
-                return false;
-            }
+        $mime_type = '';
+
+        if ($value instanceof UploadedFile) {
+            $mime_type = $value->getMimeType();
+        } elseif ($asset = Asset::find($value)) {
+            $mime_type = $asset->mimeType();
+        }
 
-            return in_array($mimeType, $this->parameters) ||
-                in_array(explode('/', $mimeType)[0].'/*', $this->parameters);
-        });
+        if (! in_array($mime_type, $this->parameters) && ! in_array(explode('/', $mime_type)[0].'/*', $this->parameters)) {
+            $fail($this->message());
+        }
     }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
     public function message()
     {
-        return str_replace(':values', implode(', ', $this->parameters), __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimetypes'));
+        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.mimetypes', ['values' => implode(', ', $this->parameters)]);
     }
 }
diff --git a/src/Fieldtypes/Assets/MinRule.php b/src/Fieldtypes/Assets/MinRule.php
index b37bfaa587..64fb897768 100644
--- a/src/Fieldtypes/Assets/MinRule.php
+++ b/src/Fieldtypes/Assets/MinRule.php
@@ -2,28 +2,35 @@
 
 namespace Statamic\Fieldtypes\Assets;
 
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+use Statamic\Facades\Asset;
 use Statamic\Statamic;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class MinRule extends SizeBasedRule
+class MinRule implements ValidationRule
 {
-    /**
-     * Determine if the the rule passes for the given size.
-     *
-     * @param  int  $size
-     * @return bool
-     */
-    public function sizePasses($size)
+    public function __construct(protected $parameters)
     {
-        return $size >= $this->parameters[0];
     }
 
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    public function message()
+    public function validate(string $attribute, mixed $value, Closure $fail): void
     {
-        return str_replace(':min', $this->parameters[0], __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.min.file'));
+        $size = 0;
+
+        if ($value instanceof UploadedFile) {
+            $size = $value->getSize() / 1024;
+        } elseif ($asset = Asset::find($value)) {
+            $size = $asset->size() / 1024;
+        }
+
+        if ($size < $this->parameters[0]) {
+            $fail($this->message());
+        }
+    }
+
+    public function message(): string
+    {
+        return __((Statamic::isCpRoute() ? 'statamic::' : '').'validation.min.file', ['min' => $this->parameters[0]]);
     }
 }
diff --git a/src/Fieldtypes/Assets/SizeBasedRule.php b/src/Fieldtypes/Assets/SizeBasedRule.php
deleted file mode 100644
index 35338e010c..0000000000
--- a/src/Fieldtypes/Assets/SizeBasedRule.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-namespace Statamic\Fieldtypes\Assets;
-
-use Illuminate\Contracts\Validation\Rule;
-use Statamic\Facades\Asset;
-use Symfony\Component\HttpFoundation\File\UploadedFile;
-
-abstract class SizeBasedRule implements Rule
-{
-    protected $parameters;
-
-    public function __construct($parameters = null)
-    {
-        $this->parameters = $parameters;
-    }
-
-    /**
-     * Determine if the validation rule passes.
-     *
-     * @param  string  $attribute
-     * @param  mixed  $value
-     * @return bool
-     */
-    public function passes($attribute, $value)
-    {
-        return collect($value)->every(function ($id) {
-            if (($size = $this->getFileSize($id)) === false) {
-                return false;
-            }
-
-            return $this->sizePasses($size);
-        });
-    }
-
-    /**
-     * Determine if the the rule passes for the given size.
-     *
-     * @param  int  $size
-     * @return bool
-     */
-    abstract public function sizePasses($size);
-
-    /**
-     * Get the validation error message.
-     *
-     * @return string
-     */
-    abstract public function message();
-
-    /**
-     * Get the file size.
-     *
-     * @param  string|UploadedFile  $id
-     * @return int|false
-     */
-    protected function getFileSize($id)
-    {
-        if ($id instanceof UploadedFile) {
-            return $id->getSize() / 1024;
-        }
-
-        if ($asset = Asset::find($id)) {
-            return $asset->size() / 1024;
-        }
-
-        return false;
-    }
-}
diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php
index ccfa5ed047..67d336f7bd 100644
--- a/tests/Assets/AssetTest.php
+++ b/tests/Assets/AssetTest.php
@@ -11,6 +11,7 @@
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
 use League\Flysystem\PathTraversalDetected;
 use Mockery;
 use PHPUnit\Framework\Attributes\DataProvider;
@@ -48,8 +49,9 @@ class AssetTest extends TestCase
     use PreventSavingStacheItemsToDisk;
 
     private $container;
+    private $validator;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -938,7 +940,7 @@ public function it_saves_quietly()
     }
 
     #[Test]
-    public function when_saving_quietly_the_cached_assets_withEvents_flag_will_be_set_back_to_true()
+    public function when_saving_quietly_the_cached_assets_with_events_flag_will_be_set_back_to_true()
     {
         Event::fake();
         Storage::fake('test');
@@ -1504,7 +1506,10 @@ public function it_gets_dimensions()
     public function it_passes_the_dimensions_validation()
     {
         $file = UploadedFile::fake()->image('image.jpg', 30, 60);
-        $validDimensions = (new DimensionsRule(['max_width=10']))->passes('Image', [$file]);
+        $validDimensions = Validator::make(
+            ['Image' => $file],
+            ['Image' => [new DimensionsRule(['max_width=10'])]],
+        )->passes();
 
         $this->assertFalse($validDimensions);
     }
diff --git a/tests/Fieldtypes/AssetsTest.php b/tests/Fieldtypes/AssetsTest.php
index 19ae89d96d..72be3f6290 100644
--- a/tests/Fieldtypes/AssetsTest.php
+++ b/tests/Fieldtypes/AssetsTest.php
@@ -26,7 +26,7 @@ class AssetsTest extends TestCase
     use PreventSavingStacheItemsToDisk;
     use TestsQueryableValueWithMaxItems;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
 
@@ -138,7 +138,7 @@ public function it_replaces_dimensions_rule()
         $this->assertIsArray($replaced);
         $this->assertCount(1, $replaced);
         $this->assertInstanceOf(DimensionsRule::class, $replaced[0]);
-        $this->assertEquals(__('statamic::validation.dimensions'), $replaced[0]->message());
+        $this->assertEquals(__('statamic::validation.dimensions.same', ['width' => '180', 'height' => '180', 'comparison' => 'exactly']), $replaced[0]->message(100, 100));
     }
 
     #[Test]
@@ -151,7 +151,7 @@ public function it_replaces_image_rule()
         $this->assertIsArray($replaced);
         $this->assertCount(1, $replaced);
         $this->assertInstanceOf(ImageRule::class, $replaced[0]);
-        $this->assertEquals(__('statamic::validation.image'), $replaced[0]->message());
+        $this->assertEquals(__('statamic::validation.image', ['extensions' => implode(', ', $replaced[0]->extensions)]), $replaced[0]->message());
     }
 
     #[Test]