• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

luttje / filament-user-attributes / 16230177663

11 Jul 2025 09:33PM UTC coverage: 80.927% (-0.3%) from 81.189%
16230177663

push

github

luttje
Simplify UserAttributeConfigResourceTest to match only expected array keys

2164 of 2674 relevant lines covered (80.93%)

8.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.74
/src/Traits/HasUserAttributes.php
1
<?php
2

3
namespace Luttje\FilamentUserAttributes\Traits;
4

5
use Illuminate\Database\Eloquent\Builder;
6
use Illuminate\Database\Eloquent\Casts\ArrayObject;
7
use Illuminate\Database\Eloquent\Relations\MorphOne;
8
use Illuminate\Support\Arr;
9
use Luttje\FilamentUserAttributes\Models\UserAttribute;
10

11
/**
12
 * @see Model
13
 * @property object $user_attributes
14
 */
15
trait HasUserAttributes
16
{
17
    /**
18
     * All user attributes that have been set on the model but not yet saved.
19
     */
20
    protected $dirtyUserAttributes = [];
21

22
    /**
23
     * Whether the user attributes should be destroyed when the model is saved.
24
     */
25
    protected $shouldDestroyUserAttributes = false;
26

27
    /**
28
     * Stores an instance of the anonymous class that is created when the user_attributes
29
     */
30
    private $__userAttributesInstance;
31

32
    /**
33
     * Setup the model to make user_attributes fillable (so they reach the 'saving' hook).
34
     *
35
     * Optionally eager load the userAttributes relationship.
36
     */
37
    protected function initializeHasUserAttributes()
43✔
38
    {
39
        if (config('filament-user-attributes.eager_load_user_attributes', false)) {
43✔
40
            $this->with[] = 'userAttribute';
×
41
        }
42

43
        // In case fillable is used, we need to ensure that the user_attributes
44
        // are included in the fillable attributes of the model or they won't
45
        // be saved when the model is saved.
46
        if (!empty($this->fillable)) {
43✔
47
            $this->mergeFillable(['user_attributes']);
42✔
48
        } else {
49
            $version = app()->version();
16✔
50
            $isHighEnoughLaravel = version_compare($version, '11.26.0', '>=');
16✔
51

52
            // Laravel 11 and higher consider user_attributes a guardable column
53
            // because we implemented the `setUserAttributesAttribute` mutator.
54
            // So we only need to perform the below hack if the Laravel version is
55
            // lower than 11.
56
            if (!$isHighEnoughLaravel) {
16✔
57
                // Otherwise guarded is used, in that case we need to ensure that all
58
                // attributes not in guarded are fillable, including user_attributes.
59
                $columns = $this->getConnection()
×
60
                            ->getSchemaBuilder()
×
61
                            ->getColumnListing($this->getTable());
×
62

63
                $fillable = array_diff($columns, $this->guarded);
×
64
                $fillable[] = 'user_attributes';
×
65

66
                // We have to fiddle with the fillable array here, because if any
67
                // attributes are in $guarded, Laravel will consider any attributes
68
                // that don't exist in the database as guarded, unless we explicitly
69
                // add them to the fillable array.
70
                // NOTE: This is quite a hack, so we mention this in the README.md!
71
                $this->mergeFillable($fillable);
×
72
            }
73
        }
74

75
        // Ensure that the user attributes are appended to the model when it is serialized.
76
        $this->append('user_attributes');
43✔
77
    }
78

79
    /**
80
     * Boots the trait and adds a saved hook to save the user attributes
81
     * when the model is saved.
82
     *
83
     * Additionally removes user_attributes so it doesn't get saved to
84
     * the database.
85
     *
86
     * @return void
87
     */
88
    protected static function bootHasUserAttributes()
43✔
89
    {
90
        static::saving(function ($model) {
43✔
91
            if (!isset($model->attributes['user_attributes'])) {
42✔
92
                return;
40✔
93
            }
94

95
            $userAttributes = $model->attributes['user_attributes'];
3✔
96

97
            // In some cases (like when Filament is saving a model), the user_attributes
98
            // may not have been added to dirtyUserAttributes yet. In this case, we
99
            // need to add them now.
100
            foreach ($userAttributes as $key => $value) {
3✔
101
                if (!array_key_exists($key, $model->dirtyUserAttributes)) {
3✔
102
                    $model->dirtyUserAttributes[$key] = $value;
3✔
103
                }
104
            }
105

106
            unset($model->attributes['user_attributes']);
3✔
107
        });
43✔
108

109
        static::saved(function ($model) {
43✔
110
            if ($model->shouldDestroyUserAttributes) {
42✔
111
                $model->userAttribute()->delete();
1✔
112
                $model->shouldDestroyUserAttributes = false;
1✔
113

114
                return;
1✔
115
            }
116

117
            if (!empty($model->dirtyUserAttributes)) {
42✔
118
                $model->saveAllUserAttributes($model->dirtyUserAttributes);
20✔
119

120
                // Clear the delayed attributes as they are now saved
121
                $model->dirtyUserAttributes = [];
20✔
122
            }
123
        });
43✔
124
    }
125

126
    /**
127
     * Saves all user attributes.
128
     *
129
     * @param  array  $attributes
130
     */
131
    public function saveAllUserAttributes(array $attributes)
20✔
132
    {
133
        // If the model already has user attributes, merge them, otherwise create a new record
134
        if ($this->userAttribute()->exists()) {
20✔
135
            $newValues = array_merge($this->userAttribute->values->toArray(), $attributes);
1✔
136
            $this->userAttribute->values = $newValues;
1✔
137
            $this->userAttribute->save();
1✔
138
        } else {
139
            $this->userAttribute()->create(['values' => $attributes]);
20✔
140

141
            // Ensure that the user attributes are dirty for the next time the model is used.
142
            //$this->unsetRelation('userAttribute'); ?/ This was here because I accidentally fetched the relationship, just before creating it. Causing it to be set as null (and then not updated after save)
143
        }
144
    }
145

146
    /**
147
     * Accessor for serializing the model including the user attributes.
148
     */
149
    public function getUserAttributesAttribute()
11✔
150
    {
151
        return $this->getUserAttributeValues()->toArray();
11✔
152
    }
153

154
    /**
155
     * Mutator for setting the user attributes.
156
     *
157
     * This is only here so that in Laravel 11 and higher, the user_attributes
158
     * attribute is considered a 'guardable' column. This causes guarded models
159
     * to still save user_attributes, even if that is not a real database column.
160
     */
161
    public function setUserAttributesAttribute($value)
3✔
162
    {
163
        $this->attributes['user_attributes'] = $value;
3✔
164
    }
165

166
    /**
167
     * Relationship to the user attributes model.
168
     */
169
    public function userAttribute(): MorphOne
34✔
170
    {
171
        return $this->morphOne(UserAttribute::class, 'model');
34✔
172
    }
173

174
    public function hasUserAttribute(string $key): bool
1✔
175
    {
176
        return $this->userAttribute()->where('values->' . $key, '!=', null)->exists();
1✔
177
    }
178

179
    public function setUserAttributeValue(string $key, $value)
8✔
180
    {
181
        if ($this->shouldDestroyUserAttributes) {
8✔
182
            throw new \Exception('Cannot set user attribute on a model that has been marked for deletion.');
1✔
183
        }
184

185
        $this->dirtyUserAttributes[$key] = $value;
7✔
186
    }
187

188
    public function setUserAttributeValues(object $values)
12✔
189
    {
190
        if ($this->shouldDestroyUserAttributes) {
12✔
191
            throw new \Exception('Cannot set user attributes on a model that has been marked for deletion.');
1✔
192
        }
193

194
        $this->dirtyUserAttributes = array_merge($this->dirtyUserAttributes, (array) $values);
12✔
195
    }
196

197
    public function destroyUserAttributes()
13✔
198
    {
199
        $this->shouldDestroyUserAttributes = true;
13✔
200
    }
201

202
    public function getUserAttributeValue(string $keyOrPath)
12✔
203
    {
204
        $userAttribute = $this->userAttribute;
12✔
205
        $array = $userAttribute?->values;
12✔
206

207
        if (!$array) {
12✔
208
            return null;
2✔
209
        }
210

211
        return Arr::get($array, $keyOrPath);
10✔
212
    }
213

214
    public function getUserAttributeValues(): ArrayObject
17✔
215
    {
216
        $userAttribute = $this->userAttribute;
17✔
217
        return $userAttribute?->values ?? new ArrayObject([]);
17✔
218
    }
219

220
    /**
221
     * Static Getters
222
     */
223
    public static function getUserAttributeQuery(): Builder
13✔
224
    {
225
        return UserAttribute::query()
13✔
226
            ->where('model_type', static::class);
13✔
227
    }
228

229
    public static function allUserAttributes(string $key)
3✔
230
    {
231
        return static::getUserAttributeQuery()
3✔
232
            ->get()
3✔
233
            ->pluck('values.' . $key);
3✔
234
    }
235

236
    /**
237
     * Aggregates
238
     */
239
    public static function userAttributeSum(string $path): int
1✔
240
    {
241
        return UserAttribute::query()
1✔
242
            ->where('model_type', static::class)
1✔
243
            ->sum('values->' . $path);
1✔
244
    }
245

246
    /**
247
     * Where filters
248
     */
249
    public static function whereUserAttribute(string $key, $value)
1✔
250
    {
251
        return static::whereHas('userAttribute', function ($query) use ($key, $value) {
1✔
252
            $query->where('values->' . $key, $value);
1✔
253
        });
1✔
254
    }
255

256
    public static function whereUserAttributeContains(string $key, $value)
1✔
257
    {
258
        return static::whereHas('userAttribute', function ($query) use ($key, $value) {
1✔
259
            $query->whereJsonContains('values->' . $key, $value);
1✔
260
        });
1✔
261
    }
262

263
    public static function whereUserAttributeLength(string $key, $operator, $value = null)
1✔
264
    {
265
        if (func_num_args() == 2) {
1✔
266
            $value = $operator;
1✔
267
            $operator = '=';
1✔
268
        }
269

270
        return static::whereHas('userAttribute', function ($query) use ($key, $operator, $value) {
1✔
271
            $query->whereJsonLength('values->' . $key, $operator, $value);
1✔
272
        });
1✔
273
    }
274

275
    /**
276
     * Magic Methods
277
     */
278

279
    /**
280
     * We want users of this trait to be able to access the user_attributes property as if it
281
     * were a real property on the model. This makes it easy to get and set user attributes.
282
     *
283
     * Creates an anonymous class when user_attributes is called for the first time and stores it
284
     * in the $__userAttributesInstance property. The anonymous class stores a reference back to
285
     * the owner object and uses the __get and __set magic methods to intercept property accesses.
286
     *
287
     * @return object
288
     */
289
    public function __get($key)
29✔
290
    {
291
        if ($key === 'user_attributes') {
29✔
292
            if (!$this->__userAttributesInstance) {
14✔
293
                $this->__userAttributesInstance = new class ($this) {
14✔
294
                    private $owner;
295

296
                    public function __construct($owner)
297
                    {
298
                        $this->owner = $owner;
14✔
299
                    }
300

301
                    public function __get($key)
302
                    {
303
                        return $this->owner->getUserAttributeValue($key);
11✔
304
                    }
305

306
                    public function __set($key, $value)
307
                    {
308
                        $this->owner->setUserAttributeValue($key, $value);
8✔
309
                    }
310

311
                    public function __unset($key)
312
                    {
313
                        $this->owner->setUserAttributeValue($key, null);
×
314
                    }
315

316
                    public function __isset($key)
317
                    {
318
                        return $this->owner->hasUserAttribute($key);
×
319
                    }
320
                };
14✔
321
            }
322

323
            return $this->__userAttributesInstance;
14✔
324
        }
325

326
        return parent::__get($key);
28✔
327
    }
328

329
    /**
330
     * When the user attempts to directly set the user_attributes property, we intercept it
331
     * and call setUserAttributeValues instead.
332
     *
333
     * @param  mixed  $key
334
     * @param  mixed  $value
335
     */
336
    public function __set($key, $value)
42✔
337
    {
338
        if ($key === 'user_attributes') {
42✔
339
            if (!is_object($value)) {
19✔
340
                // We throw, because if a developer would set `$model->user_attributes = ['key' => 'value']`, it would
341
                // mess with the IDE's ability to recognize `user_attributes` as an object (thanks to the PHPdoc and __get)
342
                throw new \Exception('The user_attributes property must be an object. Be sure to wrap arrays with UserAttribute::make($yourArray) or cast them to an object.');
7✔
343
            }
344

345
            $this->setUserAttributeValues($value);
12✔
346

347
            return;
12✔
348
        }
349

350
        parent::__set($key, $value);
42✔
351
    }
352

353
    /**
354
     * When the user attempts to unset the user_attributes property, we intercept it
355
     * and call destroyUserAttributes instead.
356
     *
357
     * @param  mixed  $key
358
     */
359
    public function __unset($key)
4✔
360
    {
361
        if ($key === 'user_attributes') {
4✔
362
            $this->destroyUserAttributes();
3✔
363

364
            return;
3✔
365
        }
366

367
        parent::__unset($key);
1✔
368
    }
369

370
    /**
371
     * When the user attempts to check if the user_attributes property is set, we intercept it
372
     * for user_attributes.
373
     *
374
     * @param  mixed  $key
375
     */
376
    public function __isset($key)
6✔
377
    {
378
        if ($key === 'user_attributes') {
6✔
379
            return true;
×
380
        }
381

382
        return parent::__isset($key);
6✔
383
    }
384
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc