Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.94% covered (success)
94.94%
75 / 79
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
HasValidation
94.94% covered (success)
94.94%
75 / 79
85.71% covered (warning)
85.71%
6 / 7
39.20
0.00% covered (danger)
0.00%
0 / 1
 bootHasValidation
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 initializeHasValidation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setupRules
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
12
 buildForeignKeyRules
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUsersTable
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
7.05
 autoPopulateFields
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelEloquentPlus\Concerns;
6
7use DevToolbelt\LaravelEloquentPlus\Exceptions\ValidationException;
8use Illuminate\Support\Facades\Validator;
9use Illuminate\Support\Str;
10use ReflectionClass;
11use ReflectionException;
12
13/**
14 * Trait for automatic model validation based on defined rules.
15 *
16 * Provides validation functionality that runs before save operations.
17 * Rules can be defined in the model and are automatically merged with
18 * default rules for common columns.
19 *
20 * @package DevToolbelt\LaravelEloquentPlus\Concerns
21 */
22trait HasValidation
23{
24    /**
25     * The validation rules for the model attributes.
26     *
27     * Rules are automatically merged with default rules for common columns
28     * (primary key, timestamps, audit columns) during initialization.
29     *
30     * @var array<string, array<int, mixed>|string>
31     */
32    protected array $rules = [];
33
34    /**
35     * Boot the HasValidation trait.
36     *
37     * Registers model event listeners to validate attributes
38     * before creating and updating operations.
39     *
40     * @return void
41     * @throws ValidationException
42     */
43    protected static function bootHasValidation(): void
44    {
45        $validateCallback = static function (self $model): void {
46            $model->autoPopulateFields();
47            $model->beforeValidate();
48
49            if (empty($model->rules)) {
50                return;
51            }
52
53            $attributes = $model->getAttributes();
54            $validator = Validator::make($attributes, $model->rules);
55
56            if ($validator->passes()) {
57                return;
58            }
59
60            $errors = [];
61            $messages = $validator->errors()->toArray();
62
63            foreach ($validator->failed() as $field => $failedRules) {
64                $i = 0;
65                $fieldMessages = $messages[$field] ?? [];
66
67                foreach ($failedRules as $ruleName => $params) {
68                    $errors[] = [
69                        'field' => $field,
70                        'error' => strtolower($ruleName),
71                        'value' => $attributes[$field] ?? null,
72                        'message' => $fieldMessages[$i] ?? null,
73                    ];
74                    $i++;
75                }
76            }
77
78            throw new ValidationException($errors);
79        };
80
81        static::creating($validateCallback);
82        static::updating($validateCallback);
83    }
84
85    /**
86     * Initialize the HasValidation trait.
87     *
88     * @return void
89     */
90    protected function initializeHasValidation(): void
91    {
92        $this->setupRules();
93    }
94
95    /**
96     * Set up default validation rules based on model attributes.
97     *
98     * Automatically adds validation rules for common columns if they exist:
99     * - Primary key: nullable integer
100     * - external_id: required UUID string with 36 characters
101     * - Timestamp columns: date validation
102     * - Audit columns (created_by, updated_by, deleted_by): integer/uuid with exists rule
103     *
104     * Custom rules defined in the model are merged after default rules,
105     * allowing them to override defaults.
106     *
107     * @return void
108     */
109    private function setupRules(): void
110    {
111        $usersTable = $this->getUsersTable();
112        $defaultRules = [];
113
114        if ($this->hasAttribute($this->primaryKey)) {
115            $defaultRules[$this->primaryKey] = ['nullable', 'integer'];
116        }
117
118        if ($this->usesExternalId()) {
119            $column = $this->getExternalIdColumn();
120            $defaultRules[$column] = ['required', 'uuid', 'string', 'size:36'];
121        }
122
123        if ($this->hasAttribute(self::CREATED_AT)) {
124            $defaultRules[self::CREATED_AT] = ['required', 'date'];
125        }
126
127        if ($this->usesBlamable() && $this->hasAttribute(self::CREATED_BY)) {
128            $defaultRules[self::CREATED_BY] = $this->buildForeignKeyRules($usersTable, false);
129        }
130
131        if ($this->hasAttribute(self::UPDATED_AT)) {
132            $defaultRules[self::UPDATED_AT] = ['nullable', 'date'];
133        }
134
135        if ($this->usesBlamable() && $this->hasAttribute(self::UPDATED_BY)) {
136            $defaultRules[self::UPDATED_BY] = $this->buildForeignKeyRules($usersTable, false);
137        }
138
139        if ($this->hasAttribute(self::DELETED_AT)) {
140            $defaultRules[self::DELETED_AT] = ['nullable', 'date'];
141        }
142
143        if ($this->usesBlamable() && $this->hasAttribute(self::DELETED_BY)) {
144            $defaultRules[self::DELETED_BY] = $this->buildForeignKeyRules($usersTable, false);
145        }
146
147        $this->rules = [...$defaultRules, ...$this->rules];
148    }
149
150    /**
151     * Build validation rules for a foreign key field.
152     *
153     * When external ID is enabled, validates as UUID with exists check on external_id column.
154     * When disabled, validates as integer with exists check on primary key column.
155     *
156     * @param string $table The related table name
157     * @param bool $required Whether the field is required
158     * @return array<int, string>
159     */
160    private function buildForeignKeyRules(string $table, bool $required = true): array
161    {
162        $requiredRule = $required ? 'required' : 'nullable';
163        return [$requiredRule, 'integer', "exists:{$table},{$this->primaryKey}"];
164    }
165
166    /**
167     * Get the validation rules for the model.
168     *
169     * @return array<string, array<int, mixed>|string>
170     */
171    public function getRules(): array
172    {
173        return $this->rules;
174    }
175
176    /**
177     * Get the users table name from Laravel Auth configuration.
178     *
179     * Attempts to resolve the table name from the configured User model.
180     * Falls back to 'users' if the model is not configured or doesn't exist.
181     *
182     * @return string
183     */
184    protected function getUsersTable(): string
185    {
186        static $tableName = null;
187
188        if ($tableName !== null) {
189            return $tableName;
190        }
191
192        $userModel = config('auth.providers.users.model');
193
194        if ($userModel === null || !class_exists($userModel)) {
195            return $tableName = 'users';
196        }
197
198        try {
199            $reflection = new ReflectionClass($userModel);
200            $property = $reflection->getProperty('table');
201
202            if ($property->hasDefaultValue()) {
203                return $tableName = $property->getDefaultValue();
204            }
205        } catch (ReflectionException) {
206        }
207
208        $className = class_basename($userModel);
209
210        return $tableName = Str::snake(Str::pluralStudly($className));
211    }
212
213    /**
214     * Autopopulate fields before validation.
215     *
216     * Automatically populates required fields that have automatic mechanisms:
217     * - created_at: Set to current timestamp if timestamps are enabled
218     * - created_by: Set to authenticated user ID if available
219     * - updated_at: Set to current timestamp if timestamps are enabled
220     * - updated_by: Set to authenticated user ID if available
221     *
222     * @return void
223     */
224    protected function autoPopulateFields(): void
225    {
226        $now = $this->freshTimestamp();
227
228        // Autopopulate created_at if not set and model is new
229        if ($this->timestamps && !$this->exists && empty($this->getAttribute(static::CREATED_AT))) {
230            $this->setAttribute(static::CREATED_AT, $now);
231        }
232
233        // Autopopulate updated_at if not set
234        if ($this->timestamps && empty($this->getAttribute(static::UPDATED_AT))) {
235            $this->setAttribute(static::UPDATED_AT, $now);
236        }
237
238        // Autopopulate created_by if not set, model is new, blamable enabled, and column exists
239        if ($this->usesBlamable()) {
240            $createdByColumn = $this->getCreatedByColumn();
241            if (!$this->exists && empty($this->getAttribute($createdByColumn))) {
242                $userId = $this->getBlamableUserId();
243                if ($userId !== null) {
244                    $this->setAttribute($createdByColumn, $userId);
245                }
246            }
247
248            // Autopopulate updated_by if not set and column exists
249            $updatedByColumn = $this->getUpdatedByColumn();
250            if (empty($this->getAttribute($updatedByColumn))) {
251                $userId = $this->getBlamableUserId();
252                if ($userId !== null) {
253                    $this->setAttribute($updatedByColumn, $userId);
254                }
255            }
256        }
257    }
258}