├── resources
└── lang
│ ├── en
│ └── validation.php
│ └── de
│ └── validation.php
├── phpunit-10.xml
├── src
├── ModelValidationServiceProvider.php
└── Rules
│ ├── ExistsEloquent.php
│ └── UniqueEloquent.php
├── license.md
├── composer.json
└── readme.md
/resources/lang/en/validation.php:
--------------------------------------------------------------------------------
1 | 'The resource does not exist.',
5 | 'unique_model' => 'The resource already exists.',
6 | ];
7 |
--------------------------------------------------------------------------------
/resources/lang/de/validation.php:
--------------------------------------------------------------------------------
1 | 'Die Ressource existiert nicht.',
5 | 'unique_model' => 'Die Ressource existiert bereits.',
6 | ];
7 |
--------------------------------------------------------------------------------
/phpunit-10.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | src/
9 |
10 |
11 |
12 |
13 | tests/Feature
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/ModelValidationServiceProvider.php:
--------------------------------------------------------------------------------
1 | publishes([
27 | __DIR__ . '/../resources/lang' => resource_path('lang/vendor/modelValidationRules'),
28 | ]);
29 | $this->loadTranslationsFrom(__DIR__ . '/../resources/lang/', 'modelValidationRules');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | =====================
3 |
4 | Copyright © `2023` `korridor`
5 |
6 | Permission is hereby granted, free of charge, to any person
7 | obtaining a copy of this software and associated documentation
8 | files (the “Software”), to deal in the Software without
9 | restriction, including without limitation the rights to use,
10 | copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the
12 | Software is furnished to do so, subject to the following
13 | conditions:
14 |
15 | The above copyright notice and this permission notice shall be
16 | included in all copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25 | OTHER DEALINGS IN THE SOFTWARE.
26 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "korridor/laravel-model-validation-rules",
3 | "description": "A laravel validation rule that uses eloquent to validate if a model exists",
4 | "keywords": [
5 | "validation",
6 | "laravel",
7 | "rule",
8 | "model",
9 | "exist",
10 | "eloquent"
11 | ],
12 | "homepage": "https://github.com/korridor/laravel-model-validation-rules",
13 | "authors": [
14 | {
15 | "name": "korridor",
16 | "email": "26689068+korridor@users.noreply.github.com"
17 | }
18 | ],
19 | "license": "MIT",
20 | "require": {
21 | "php": ">=8.1",
22 | "illuminate/support": "^10|^11|^12.0",
23 | "illuminate/database": "^10|^11|^12.0"
24 | },
25 | "require-dev": {
26 | "orchestra/testbench": "^8.0|^9.0|^10.0",
27 | "phpunit/phpunit": "^10|^11.5.3",
28 | "friendsofphp/php-cs-fixer": "^3.6",
29 | "squizlabs/php_codesniffer": "^3.5"
30 | },
31 | "autoload": {
32 | "psr-4": {
33 | "Korridor\\LaravelModelValidationRules\\": "src"
34 | }
35 | },
36 | "autoload-dev": {
37 | "psr-4": {
38 | "Korridor\\LaravelModelValidationRules\\Tests\\": "tests/"
39 | }
40 | },
41 | "scripts": {
42 | "test": "vendor/bin/phpunit",
43 | "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-html coverage",
44 | "fix": "./vendor/bin/php-cs-fixer fix",
45 | "lint": "./vendor/bin/phpcs --extensions=php"
46 | },
47 | "extra": {
48 | "laravel": {
49 | "providers": [
50 | "Korridor\\LaravelModelValidationRules\\ModelValidationServiceProvider"
51 | ]
52 | }
53 | },
54 | "config": {
55 | "sort-packages": true
56 | },
57 | "minimum-stability": "dev",
58 | "prefer-stable": true
59 | }
60 |
--------------------------------------------------------------------------------
/src/Rules/ExistsEloquent.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | private string $model;
21 |
22 | /**
23 | * Relevant key in the model.
24 | *
25 | * @var string|null
26 | */
27 | private ?string $key;
28 |
29 | /**
30 | * Closure that can extend the eloquent builder.
31 | *
32 | * @var Closure|null
33 | */
34 | private ?Closure $builderClosure;
35 |
36 | /**
37 | * Custom validation message.
38 | *
39 | * @var string|null
40 | */
41 | private ?string $customMessage = null;
42 |
43 | /**
44 | * Custom translation key for message.
45 | *
46 | * @var string|null
47 | */
48 | private ?string $customMessageTranslationKey = null;
49 |
50 | /**
51 | * Include soft deleted models in the query.
52 | *
53 | * @var bool
54 | */
55 | private bool $includeSoftDeleted = false;
56 |
57 | /**
58 | * @var bool Whether the key field is of type UUID
59 | */
60 | private bool $isFieldUuid = false;
61 |
62 | /**
63 | * Create a new rule instance.
64 | *
65 | * @param class-string $model Class name of model
66 | * @param string|null $key Relevant key in the model
67 | * @param Closure|null $builderClosure Closure that can extend the eloquent builder
68 | */
69 | public function __construct(string $model, ?string $key = null, ?Closure $builderClosure = null)
70 | {
71 | $this->model = $model;
72 | $this->key = $key;
73 | $this->setBuilderClosure($builderClosure);
74 | }
75 |
76 | /**
77 | * Create a new rule instance.
78 | *
79 | * @param class-string $model Class name of model
80 | * @param string|null $key Relevant key in the model
81 | * @param Closure|null $builderClosure Closure that can extend the eloquent builder
82 | */
83 | public static function make(string $model, ?string $key = null, ?Closure $builderClosure = null): self
84 | {
85 | return new self($model, $key, $builderClosure);
86 | }
87 |
88 | /**
89 | * Set a custom validation message.
90 | *
91 | * @param string $message
92 | * @return $this
93 | */
94 | public function withMessage(string $message): self
95 | {
96 | $this->customMessage = $message;
97 |
98 | return $this;
99 | }
100 |
101 | /**
102 | * Set a translated custom validation message.
103 | *
104 | * @param string $translationKey
105 | * @return $this
106 | */
107 | public function withCustomTranslation(string $translationKey): self
108 | {
109 | $this->customMessageTranslationKey = $translationKey;
110 |
111 | return $this;
112 | }
113 |
114 | /**
115 | * The field has the data type UUID.
116 | * If the field is not a UUID, the validation will fail, before the query is executed.
117 | * This is useful for example for Postgres databases where queries fail if a field with UUID data type is queried with a non-UUID value.
118 | *
119 | * @return $this
120 | */
121 | public function uuid(): self
122 | {
123 | $this->isFieldUuid = true;
124 |
125 | return $this;
126 | }
127 |
128 | /**
129 | * Determine if the validation rule passes.
130 | *
131 | * @param string $attribute
132 | * @param mixed $value
133 | * @param Closure $fail
134 | *
135 | * @return void
136 | */
137 | public function validate(string $attribute, mixed $value, Closure $fail): void
138 | {
139 | if ($this->isFieldUuid) {
140 | if (!is_string($value) || !Str::isUuid($value)) {
141 | $this->fail($attribute, $value, $fail);
142 | return;
143 | }
144 | }
145 | /** @var Model|Builder $builder */
146 | $builder = new $this->model();
147 | $modelKeyName = $builder->getKeyName();
148 | if (null === $this->key) {
149 | $builder = $builder->where($modelKeyName, $value);
150 | } else {
151 | $builder = $builder->where($this->key, $value);
152 | }
153 | if (null !== $this->builderClosure) {
154 | $builderClosure = $this->builderClosure;
155 | $builder = $builderClosure($builder);
156 | }
157 |
158 | if ($this->includeSoftDeleted) {
159 | $builder = $builder->withTrashed();
160 | }
161 |
162 | if ($builder->doesntExist()) {
163 | $this->fail($attribute, $value, $fail);
164 | return;
165 | }
166 | }
167 |
168 | private function fail(string $attribute, mixed $value, Closure $fail): void
169 | {
170 | if ($this->customMessage !== null) {
171 | $fail($this->customMessage);
172 | } else {
173 | $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.exists_model')->translate([
174 | 'attribute' => $attribute,
175 | 'model' => strtolower(class_basename($this->model)),
176 | 'value' => $value,
177 | ]);
178 | }
179 | }
180 |
181 | /**
182 | * @param Closure|null $builderClosure
183 | */
184 | public function setBuilderClosure(?Closure $builderClosure): void
185 | {
186 | $this->builderClosure = $builderClosure;
187 | }
188 |
189 | /**
190 | * @param Closure $builderClosure
191 | * @return $this
192 | */
193 | public function query(Closure $builderClosure): self
194 | {
195 | $this->setBuilderClosure($builderClosure);
196 |
197 | return $this;
198 | }
199 |
200 | /**
201 | * Activate or deactivate including soft deleted models in the query.
202 | *
203 | * @param bool $includeSoftDeleted
204 | * @return void
205 | */
206 | public function setIncludeSoftDeleted(bool $includeSoftDeleted): void
207 | {
208 | $this->includeSoftDeleted = $includeSoftDeleted;
209 | }
210 |
211 | /**
212 | * Activate including soft deleted models in the query.
213 | * @return $this
214 | */
215 | public function includeSoftDeleted(): self
216 | {
217 | $this->setIncludeSoftDeleted(true);
218 |
219 | return $this;
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/src/Rules/UniqueEloquent.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | private string $model;
21 |
22 | /**
23 | * Relevant key in the model.
24 | *
25 | * @var string|null
26 | */
27 | private ?string $key;
28 |
29 | /**
30 | * Closure that can extend the eloquent builder
31 | *
32 | * @var Closure|null
33 | */
34 | private ?Closure $builderClosure;
35 |
36 | /**
37 | * @var mixed
38 | */
39 | private mixed $ignoreId = null;
40 |
41 | /**
42 | * @var string|null
43 | */
44 | private ?string $ignoreColumn = null;
45 |
46 | /**
47 | * Custom validation message.
48 | *
49 | * @var string|null
50 | */
51 | private ?string $customMessage = null;
52 |
53 | /**
54 | * Translation key for custom validation message.
55 | *
56 | * @var string|null
57 | */
58 | private ?string $customMessageTranslationKey = null;
59 |
60 | /**
61 | * Include soft deleted models in the query.
62 | *
63 | * @var bool
64 | */
65 | private bool $includeSoftDeleted = false;
66 |
67 | /**
68 | * @var bool Whether the ID is a UUID
69 | */
70 | private bool $isFieldUuid = false;
71 |
72 | /**
73 | * UniqueEloquent constructor.
74 | *
75 | * @param class-string $model Class name of model.
76 | * @param string|null $key Relevant key in the model.
77 | * @param Closure|null $builderClosure Closure that can extend the eloquent builder
78 | */
79 | public function __construct(string $model, ?string $key = null, ?Closure $builderClosure = null)
80 | {
81 | $this->model = $model;
82 | $this->key = $key;
83 | $this->setBuilderClosure($builderClosure);
84 | }
85 |
86 | /**
87 | * @param class-string $model Class name of model.
88 | * @param string|null $key Relevant key in the model.
89 | * @param Closure|null $builderClosure Closure that can extend the eloquent builder
90 | */
91 | public static function make(string $model, ?string $key = null, ?Closure $builderClosure = null): self
92 | {
93 | return new self($model, $key, $builderClosure);
94 | }
95 |
96 | /**
97 | * Determine if the validation rule passes.
98 | *
99 | * @param string $attribute
100 | * @param mixed $value
101 | * @param Closure $fail
102 | *
103 | * @return void
104 | */
105 | public function validate(string $attribute, mixed $value, Closure $fail): void
106 | {
107 | if ($this->isFieldUuid) {
108 | if (!is_string($value) || !Str::isUuid($value)) {
109 | return;
110 | }
111 | }
112 | /** @var Model|Builder $builder */
113 | $builder = new $this->model();
114 | $modelKeyName = $builder->getKeyName();
115 | $builder = $builder->where(null === $this->key ? $modelKeyName : $this->key, $value);
116 | if (null !== $this->builderClosure) {
117 | $builderClosure = $this->builderClosure;
118 | $builder = $builderClosure($builder);
119 | }
120 | if (null !== $this->ignoreId) {
121 | $builder = $builder->where(
122 | null === $this->ignoreColumn ? $modelKeyName : $this->ignoreColumn,
123 | '!=',
124 | $this->ignoreId
125 | );
126 | }
127 |
128 | if ($this->includeSoftDeleted) {
129 | $builder = $builder->withTrashed();
130 | }
131 |
132 | if ($builder->exists()) {
133 | if ($this->customMessage !== null) {
134 | $fail($this->customMessage);
135 | } else {
136 | $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.unique_model')
137 | ->translate([
138 | 'attribute' => $attribute,
139 | 'model' => strtolower(class_basename($this->model)),
140 | 'value' => $value,
141 | ]);
142 | }
143 | }
144 | }
145 |
146 | /**
147 | * Set a custom validation message.
148 | *
149 | * @param string $message
150 | * @return $this
151 | */
152 | public function withMessage(string $message): self
153 | {
154 | $this->customMessage = $message;
155 |
156 | return $this;
157 | }
158 |
159 | /**
160 | * Set a translated custom validation message.
161 | *
162 | * @param string $translationKey
163 | * @return $this
164 | */
165 | public function withCustomTranslation(string $translationKey): self
166 | {
167 | $this->customMessageTranslationKey = $translationKey;
168 |
169 | return $this;
170 | }
171 |
172 | /**
173 | * Set a closure that can extend the eloquent builder.
174 | *
175 | * @param Closure|null $builderClosure
176 | */
177 | public function setBuilderClosure(?Closure $builderClosure): void
178 | {
179 | $this->builderClosure = $builderClosure;
180 | }
181 |
182 | /**
183 | * @param Closure $builderClosure
184 | * @return $this
185 | */
186 | public function query(Closure $builderClosure): self
187 | {
188 | $this->setBuilderClosure($builderClosure);
189 |
190 | return $this;
191 | }
192 |
193 | /**
194 | * @param mixed $id
195 | * @param string|null $column
196 | */
197 | public function setIgnore(mixed $id, ?string $column = null): void
198 | {
199 | $this->ignoreId = $id;
200 | $this->ignoreColumn = $column;
201 | }
202 |
203 | /**
204 | * @param mixed $id
205 | * @param string|null $column
206 | * @return UniqueEloquent
207 | */
208 | public function ignore(mixed $id, ?string $column = null): self
209 | {
210 | $this->setIgnore($id, $column);
211 |
212 | return $this;
213 | }
214 |
215 | /**
216 | * Activate or deactivate including soft deleted models in the query.
217 | *
218 | * @param bool $includeSoftDeleted
219 | * @return void
220 | */
221 | public function setIncludeSoftDeleted(bool $includeSoftDeleted): void
222 | {
223 | $this->includeSoftDeleted = $includeSoftDeleted;
224 | }
225 |
226 | /**
227 | * The field has the data type UUID.
228 | * If a value is not a UUID, the validation will be skipped.
229 | * This is useful for example for Postgres databases where queries fail if a field with UUID data type is queried with a non-UUID value.
230 | *
231 | * @return $this
232 | */
233 | public function uuid(): self
234 | {
235 | $this->isFieldUuid = true;
236 |
237 | return $this;
238 | }
239 |
240 | /**
241 | * Activate including soft deleted models in the query.
242 | *
243 | * @return $this
244 | */
245 | public function includeSoftDeleted(): self
246 | {
247 | $this->setIncludeSoftDeleted(true);
248 |
249 | return $this;
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Laravel model validation rules
2 |
3 | [](https://packagist.org/packages/korridor/laravel-model-validation-rules)
4 | [](license.md)
5 | [](https://packagist.org/packages/korridor/laravel-model-validation-rules)
6 | [](https://github.com/korridor/laravel-model-validation-rules/actions/workflows/lint.yml)
7 | [](https://github.com/korridor/laravel-model-validation-rules/actions/workflows/unittests.yml)
8 | [](https://codecov.io/gh/korridor/laravel-model-validation-rules)
9 |
10 | This package is an alternative to the Laravel built-in validation rules `exists` and `unique`.
11 | It uses Eloquent models instead of directly querying the database.
12 |
13 | **Advantages**
14 | - The rule can be easily extended with the Eloquent builder. (scopes etc.)
15 | - Soft deletes are working out of the box.
16 | - Logic implemented into the models work in the validation as well. (multi tenancy system, etc.)
17 |
18 | > [!NOTE]
19 | > Check out **solidtime - The modern Open Source Time-Tracker** at [solidtime.io](https://www.solidtime.io)
20 |
21 | ## Installation
22 |
23 | You can install the package via composer with following command:
24 |
25 | ```bash
26 | composer require korridor/laravel-model-validation-rules
27 | ```
28 |
29 | If you want to use this package with older Laravel/PHP version please install the 2.1.* version.
30 |
31 | ```bash
32 | composer require korridor/laravel-model-validation-rules "^2.1"
33 | ```
34 |
35 | ### Requirements
36 |
37 | This package is tested for the following Laravel and PHP versions:
38 |
39 | - 12.* (PHP 8.2, 8.3, 8.4)
40 | - 11.* (PHP 8.2, 8.3, 8.4)
41 | - 10.* (PHP 8.1, 8.2, 8.3)
42 |
43 | ## Usage examples
44 |
45 | **PostStoreRequest**
46 |
47 | ```php
48 | use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
49 | use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
50 | // ...
51 | public function rules(): array
52 | {
53 | $postId = $this->post->id;
54 |
55 | return [
56 | 'username' => [new UniqueEloquent(User::class, 'username')],
57 | 'title' => ['string'],
58 | 'content' => ['string'],
59 | 'comments.*.id' => [
60 | 'nullable',
61 | new ExistsEloquent(Comment::class, null, function (Builder $builder) use ($postId) {
62 | return $builder->where('post_id', $postId);
63 | }),
64 | ],
65 | 'comments.*.content' => ['string']
66 | ];
67 | }
68 | ```
69 |
70 | **PostUpdateRequest**
71 |
72 | ```php
73 | use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
74 | use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
75 | // ...
76 | public function rules(): array
77 | {
78 | $postId = $this->post->id;
79 |
80 | return [
81 | 'id' => [new ExistsEloquent(Post::class)],
82 | 'username' => [(new UniqueEloquent(User::class, 'username'))->ignore($postId)],
83 | 'title' => ['string'],
84 | 'content' => ['string'],
85 | 'comments.*.id' => [
86 | 'nullable',
87 | new ExistsEloquent(Comment::class, null, function (Builder $builder) use ($postId) {
88 | return $builder->where('post_id', $postId);
89 | }),
90 | ],
91 | 'comments.*.content' => ['string']
92 | ];
93 | }
94 | ```
95 |
96 | ### Custom validation message
97 |
98 | If you want to change the validation message for one specific case, you can use the `withMessage(...)` function to add a custom validation message.
99 | With `withCustomTranslation(...)` you can set a custom translation key for the validation message.
100 | As described in detail in the next example ([Customize default validation message](#customize-default-validation-message)), it is possible to use `:attribute`, `:model` and `:value` in the translation.
101 |
102 | ```php
103 | use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
104 | use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
105 | // ...
106 | public function rules(): array
107 | {
108 | $postId = $this->post->id;
109 |
110 | return [
111 | 'id' => [(new ExistsEloquent(Post::class))->withMessage('The ID already exists.')],
112 | 'username' => [
113 | (new UniqueEloquent(User::class, 'username'))
114 | ->ignore($postId)
115 | ->withCustomTranslation('validation.custom.username.unique_eloquent')
116 | ],
117 | 'title' => ['string'],
118 | 'content' => ['string'],
119 | 'comments.*.id' => [
120 | 'nullable',
121 | new ExistsEloquent(Comment::class, null, function (Builder $builder) use ($postId) {
122 | return $builder->where('post_id', $postId);
123 | }),
124 | ],
125 | 'comments.*.content' => ['string']
126 | ];
127 | }
128 | ```
129 |
130 | ### Customize default validation message
131 |
132 | If you want to customize the translations of the default validation errors you can publish the translations
133 | of the package to the `resources/lang/vendor/modelValidationRules` folder.
134 |
135 | ```bash
136 | php artisan vendor:publish --provider="Korridor\LaravelModelValidationRules\ModelValidationServiceProvider"
137 | ```
138 |
139 | You can use the following attributes in the validation message:
140 |
141 | - `attribute`
142 | - `model`
143 | - `value`
144 |
145 | ```php
146 | return [
147 | 'exists_model' => 'A :model with the :attribute ":value" does not exist.',
148 | 'unique_model' => 'A :model with the :attribute ":value" already exists.',
149 | ];
150 | ```
151 |
152 | Example outputs would be:
153 |
154 | - `A user with the id "2" does not exist.`
155 | - `A user with the id "2" already exists.`
156 |
157 | ## Contributing
158 |
159 | I am open for suggestions and contributions. Just create an issue or a pull request.
160 |
161 | ### Local docker environment
162 |
163 | The `docker` folder contains a local docker environment for development.
164 | The docker workspace has composer and xdebug installed.
165 |
166 | ```bash
167 | docker-compose run workspace bash
168 | ```
169 |
170 | ### Testing
171 |
172 | The `composer test` command runs all tests with [phpunit](https://phpunit.de/).
173 | The `composer test-coverage` command runs all tests with phpunit and creates a coverage report into the `coverage` folder.
174 |
175 | ### Codeformatting/Linting
176 |
177 | The `composer fix` command formats the code with [php-cs-fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer).
178 | The `composer lint` command checks the code with [phpcs](https://github.com/squizlabs/PHP_CodeSniffer).
179 |
180 | ## Credits
181 |
182 | The structure of the repository and the TestClass is inspired by the
183 | project [laravel-validation-rules](https://github.com/spatie/laravel-validation-rules) by [spatie](https://github.com/spatie).
184 |
185 | ## License
186 |
187 | This package is licensed under the MIT License (MIT). Please see [license file](license.md) for more information.
188 |
--------------------------------------------------------------------------------