Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.50% covered (success)
94.50%
103 / 109
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
HasBlamable
94.50% covered (success)
94.50%
103 / 109
75.00% covered (warning)
75.00%
6 / 8
45.34
0.00% covered (danger)
0.00%
0 / 1
 bootHasBlamable
94.20% covered (success)
94.20%
65 / 69
0.00% covered (danger)
0.00%
0 / 1
27.14
 getBlamableUserId
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getCreatedByColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUpdatedByColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDeletedByColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usesSoftDeletes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usesBlamable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runSoftDelete
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
9.04
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelEloquentPlus\Concerns;
6
7use DevToolbelt\LaravelEloquentPlus\Exceptions\MissingModelPropertyException;
8use DevToolbelt\LaravelEloquentPlus\Exceptions\ValidationException;
9use DevToolbelt\LaravelEloquentPlus\ModelBase;
10use Throwable;
11
12/**
13 * Trait for automatic user audit tracking on Eloquent models.
14 *
15 * Automatically sets the created_by, updated_by, and deleted_by columns
16 * based on the currently authenticated user. Works with Laravel's
17 * authentication system via the auth() helper.
18 *
19 * Required constants in the model:
20 * - CREATED_BY: Column name for tracking who created the record
21 * - UPDATED_BY: Column name for tracking who last updated the record
22 * - DELETED_BY: Column name for tracking who deleted the record (soft deletes)
23 *
24 * @package DevToolbelt\LaravelEloquentPlus\Concerns
25 */
26trait HasBlamable
27{
28    /**
29     * The name of the "created by" column for audit tracking.
30     */
31    public const string CREATED_BY = 'created_by';
32
33    /**
34     * The name of the "updated by" column for audit tracking.
35     */
36    public const string UPDATED_BY = 'updated_by';
37
38    /**
39     * The name of the "deleted by" column for audit tracking.
40     */
41    public const string DELETED_BY = 'deleted_by';
42
43    /**
44     * Indicates if the model should use blamable (audit tracking).
45     *
46     * @var bool
47     */
48    protected bool $usesBlamable = false;
49
50    /**
51     * Boot the HasBlamable trait.
52     *
53     * Registers model event listeners to automatically set audit columns
54     * on creating, updating, deleting, and restoring events.
55     *
56     * @return void
57     * @throws ValidationException
58     * @throws Throwable
59     */
60    protected static function bootHasBlamable(): void
61    {
62        static::creating(static function (ModelBase $model): void {
63            if (!$model->usesBlamable()) {
64                return;
65            }
66
67            $userId = $model->getBlamableUserId();
68            if ($userId === null) {
69                return;
70            }
71
72            $createdByColumn = $model->getCreatedByColumn();
73            $updatedByColumn = $model->getUpdatedByColumn();
74            $strictMode = config('devToolbelt.eloquent-plus.blamable_strict_mode', false);
75
76            if (!$model->hasAttribute($createdByColumn)) {
77                if ($strictMode) {
78                    throw new MissingModelPropertyException($model::class, $createdByColumn);
79                }
80            } elseif (!$model->getAttribute($createdByColumn)) {
81                $model->setAttribute($createdByColumn, $userId);
82            }
83
84            if (!$model->hasAttribute($updatedByColumn)) {
85                if ($strictMode) {
86                    throw new MissingModelPropertyException($model::class, $updatedByColumn);
87                }
88            } elseif (!$model->getAttribute($updatedByColumn)) {
89                $model->setAttribute($updatedByColumn, $userId);
90            }
91        });
92
93        static::updating(static function (ModelBase $model): void {
94            if (!$model->usesBlamable()) {
95                return;
96            }
97
98            $userId = $model->getBlamableUserId();
99            if ($userId === null) {
100                return;
101            }
102
103            $updatedByColumn = $model->getUpdatedByColumn();
104
105            if (!$model->hasAttribute($updatedByColumn)) {
106                if (config('devToolbelt.eloquent-plus.blamable_strict_mode', false)) {
107                    throw new MissingModelPropertyException($model::class, $updatedByColumn);
108                }
109            } else {
110                $model->setAttribute($updatedByColumn, $userId);
111            }
112        });
113
114        static::deleting(static function (ModelBase $model): void {
115            if (!$model->usesBlamable()) {
116                return;
117            }
118
119            $userId = $model->getBlamableUserId();
120            if ($userId === null) {
121                return;
122            }
123
124            if ($model->usesSoftDeletes() && !$model->isForceDeleting()) {
125                $deletedByColumn = $model->getDeletedByColumn();
126                $updatedByColumn = $model->getUpdatedByColumn();
127                $strictMode = config('devToolbelt.eloquent-plus.blamable_strict_mode', false);
128
129                if (!$model->hasAttribute($deletedByColumn)) {
130                    if ($strictMode) {
131                        throw new MissingModelPropertyException($model::class, $deletedByColumn);
132                    }
133                } else {
134                    $model->setAttribute($deletedByColumn, $userId);
135                }
136
137                if (!$model->hasAttribute($updatedByColumn)) {
138                    if ($strictMode) {
139                        throw new MissingModelPropertyException($model::class, $updatedByColumn);
140                    }
141                } else {
142                    $model->setAttribute($updatedByColumn, $userId);
143                }
144            }
145        });
146
147        static::restoring(static function (ModelBase $model): void {
148            if (!$model->usesBlamable()) {
149                return;
150            }
151
152            $userId = $model->getBlamableUserId();
153            if ($userId === null) {
154                return;
155            }
156
157            $deletedByColumn = $model->getDeletedByColumn();
158            $updatedByColumn = $model->getUpdatedByColumn();
159            $strictMode = config('devToolbelt.eloquent-plus.blamable_strict_mode', false);
160
161            if (!$model->hasAttribute($deletedByColumn)) {
162                if ($strictMode) {
163                    throw new MissingModelPropertyException($model::class, $deletedByColumn);
164                }
165            } else {
166                $model->setAttribute($deletedByColumn, null);
167            }
168
169            if (!$model->hasAttribute($updatedByColumn)) {
170                if ($strictMode) {
171                    throw new MissingModelPropertyException($model::class, $updatedByColumn);
172                }
173            } else {
174                $model->setAttribute($updatedByColumn, $userId);
175            }
176        });
177    }
178
179    /**
180     * Get the authenticated user's identifier for audit tracking.
181     *
182     * When blamable_field_type is 'string':
183     * - If blamable_field_value callable is set, it will be used to get the value
184     * - Otherwise, the user ID will be cast to string
185     *
186     * @return int|string|null The user ID, or null if not authenticated
187     */
188    protected function getBlamableUserId(): int|string|null
189    {
190        $user = auth()->user();
191
192        if ($user === null) {
193            return null;
194        }
195
196        $fieldType = config('devToolbelt.eloquent-plus.blamable_field_type', 'integer');
197
198        if ($fieldType === 'string') {
199            $fieldValueCallable = config('devToolbelt.eloquent-plus.blamable_field_value');
200
201            if (is_callable($fieldValueCallable)) {
202                return $fieldValueCallable($user);
203            }
204
205            return (string) $user->getAuthIdentifier();
206        }
207
208        return $user->getAuthIdentifier();
209    }
210
211    /**
212     * Get the name of the "created by" column.
213     *
214     * Looks for the CREATED_BY constant in the model class.
215     *
216     * @return string The column name, or null if constant is not defined
217     */
218    protected function getCreatedByColumn(): string
219    {
220        return $this::CREATED_BY;
221    }
222
223    /**
224     * Get the name of the "updated by" column.
225     *
226     * Looks for the UPDATED_BY constant in the model class.
227     *
228     * @return string The column name, or null if constant is not defined
229     */
230    protected function getUpdatedByColumn(): string
231    {
232        return $this::UPDATED_BY;
233    }
234
235    /**
236     * Get the name of the "deleted by" column.
237     *
238     * Looks for the DELETED_BY constant in the model class.
239     *
240     * @return string The column name, or null if constant is not defined
241     */
242    protected function getDeletedByColumn(): string
243    {
244        return $this::DELETED_BY;
245    }
246
247    /**
248     * Determine if the model uses soft deletes.
249     *
250     * Checks for the presence of the isForceDeleting method,
251     * which is added by the SoftDeletes trait.
252     *
253     * @return bool True if the model uses soft deletes, false otherwise
254     */
255    protected function usesSoftDeletes(): bool
256    {
257        return method_exists($this, 'isForceDeleting');
258    }
259
260    /**
261     * Determine if the model uses blamable (audit tracking).
262     *
263     * @return bool True if the model uses blamable, false otherwise
264     */
265    public function usesBlamable(): bool
266    {
267        return $this->usesBlamable;
268    }
269
270    /**
271     * Perform the actual soft delete query on the database.
272     *
273     * Overrides Laravel's runSoftDelete to include blamable columns
274     * (deleted_by, updated_by) in the soft delete query.
275     *
276     * @return void
277     * @throws MissingModelPropertyException
278     */
279    protected function runSoftDelete()
280    {
281        /** @phpstan-ignore argument.type */
282        $query = $this->setKeysForSaveQuery($this->newModelQuery());
283
284        $time = $this->freshTimestamp();
285
286        $columns = [$this->getDeletedAtColumn() => $this->fromDateTime($time)];
287
288        $this->{$this->getDeletedAtColumn()} = $time;
289
290        if ($this->usesTimestamps() && !is_null($this->getUpdatedAtColumn())) {
291            $this->{$this->getUpdatedAtColumn()} = $time;
292
293            $columns[$this->getUpdatedAtColumn()] = $this->fromDateTime($time);
294        }
295
296        // Include blamable columns if blamable is enabled
297        if ($this->usesBlamable()) {
298            $userId = $this->getBlamableUserId();
299
300            if ($userId !== null) {
301                $deletedByColumn = $this->getDeletedByColumn();
302                $updatedByColumn = $this->getUpdatedByColumn();
303                $strictMode = config('devToolbelt.eloquent-plus.blamable_strict_mode', false);
304
305                if (!$this->hasAttribute($deletedByColumn)) {
306                    if ($strictMode) {
307                        throw new MissingModelPropertyException(static::class, $deletedByColumn);
308                    }
309                } else {
310                    $columns[$deletedByColumn] = $userId;
311                    $this->{$deletedByColumn} = $userId;
312                }
313
314                if (!$this->hasAttribute($updatedByColumn)) {
315                    if ($strictMode) {
316                        throw new MissingModelPropertyException(static::class, $updatedByColumn);
317                    }
318                } else {
319                    $columns[$updatedByColumn] = $userId;
320                    $this->{$updatedByColumn} = $userId;
321                }
322            }
323        }
324
325        $query->update($columns);
326
327        /** @phpstan-ignore method.notFound */
328        $this->syncOriginalAttributes(array_keys($columns));
329    }
330}