Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.78% covered (success)
94.78%
109 / 115
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
HasBlamable
94.78% covered (success)
94.78%
109 / 115
75.00% covered (warning)
75.00%
6 / 8
47.31
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
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
11.03
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 = [];
287
288        $deletedAtColumn = $this->getDeletedAtColumn();
289        if ($this->hasAttribute($deletedAtColumn)) {
290            $columns[$deletedAtColumn] = $this->fromDateTime($time);
291            $this->{$deletedAtColumn} = $time;
292        }
293
294        $updatedAtColumn = $this->getUpdatedAtColumn();
295        if (
296            $this->usesTimestamps()
297            && !is_null($updatedAtColumn)
298            && $this->hasAttribute($updatedAtColumn)
299        ) {
300            $this->{$updatedAtColumn} = $time;
301            $columns[$updatedAtColumn] = $this->fromDateTime($time);
302        }
303
304        // Include blamable columns if blamable is enabled
305        if ($this->usesBlamable()) {
306            $userId = $this->getBlamableUserId();
307
308            if ($userId !== null) {
309                $deletedByColumn = $this->getDeletedByColumn();
310                $updatedByColumn = $this->getUpdatedByColumn();
311                $strictMode = config('devToolbelt.eloquent-plus.blamable_strict_mode', false);
312
313                if (!$this->hasAttribute($deletedByColumn)) {
314                    if ($strictMode) {
315                        throw new MissingModelPropertyException(static::class, $deletedByColumn);
316                    }
317                } else {
318                    $columns[$deletedByColumn] = $userId;
319                    $this->{$deletedByColumn} = $userId;
320                }
321
322                if (!$this->hasAttribute($updatedByColumn)) {
323                    if ($strictMode) {
324                        throw new MissingModelPropertyException(static::class, $updatedByColumn);
325                    }
326                } else {
327                    $columns[$updatedByColumn] = $userId;
328                    $this->{$updatedByColumn} = $userId;
329                }
330            }
331        }
332
333        $query->update($columns);
334
335        /** @phpstan-ignore method.notFound */
336        $this->syncOriginalAttributes(array_keys($columns));
337    }
338}