Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.61% covered (warning)
75.61%
62 / 82
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModelBase
75.61% covered (warning)
75.61%
62 / 82
81.82% covered (warning)
81.82%
9 / 11
56.86
0.00% covered (danger)
0.00%
0 / 1
 bootSoftDeletes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 initializeSoftDeletes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fill
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getAttribute
33.33% covered (danger)
33.33%
6 / 18
0.00% covered (danger)
0.00%
0 / 1
16.67
 setAttribute
38.46% covered (danger)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
7.73
 getCreatedAtColumn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getUpdatedAtColumn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 hasAttribute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 toArray
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 toSoftArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 returnOnlyFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelEloquentPlus;
6
7use DevToolbelt\LaravelEloquentPlus\Concerns\HasAutoCasting;
8use DevToolbelt\LaravelEloquentPlus\Concerns\HasBlamable;
9use DevToolbelt\LaravelEloquentPlus\Concerns\HasCastAliases;
10use DevToolbelt\LaravelEloquentPlus\Concerns\HasDateFormatting;
11use DevToolbelt\LaravelEloquentPlus\Concerns\HasExternalId;
12use DevToolbelt\LaravelEloquentPlus\Concerns\HasHiddenAttributes;
13use DevToolbelt\LaravelEloquentPlus\Concerns\HasLifecycleHooks;
14use DevToolbelt\LaravelEloquentPlus\Concerns\HasValidation;
15use DevToolbelt\LaravelEloquentPlus\Exceptions\MissingModelPropertyException;
16use DevToolbelt\LaravelEloquentPlus\Exceptions\ValidationException;
17use Illuminate\Database\Eloquent\Factories\HasFactory;
18use Illuminate\Database\Eloquent\Model;
19use Illuminate\Database\Eloquent\SoftDeletes;
20use Illuminate\Database\Eloquent\SoftDeletingScope;
21use Illuminate\Support\Carbon;
22use Illuminate\Support\Collection;
23use Illuminate\Support\Str;
24use Throwable;
25use TypeError;
26use ValueError;
27
28/**
29 * Abstract base model class that extends Laravel's Eloquent Model.
30 *
31 * Provides additional functionality including:
32 * - Automatic validation based on defined rules (HasValidation)
33 * - Automatic type casting inferred from validation rules (HasAutoCasting)
34 * - Date formatting control with string/Carbon output (HasDateFormatting)
35 * - Lifecycle hooks for custom logic (HasLifecycleHooks)
36 * - Automatic hidden attributes for soft deletes (HasHiddenAttributes)
37 * - Built-in soft deletes with user tracking (HasBlamable)
38 * - Optional external ID (UUID) support (HasExternalId)
39 * - Automatic snake_case attribute conversion on fill
40 *
41 * @package DevToolbelt\LaravelEloquentPlus
42 *
43 * @phpstan-consistent-constructor
44 */
45abstract class ModelBase extends Model
46{
47    use HasFactory;
48    use SoftDeletes;
49    use HasBlamable {
50        HasBlamable::runSoftDelete insteadof SoftDeletes;
51    }
52    use HasCastAliases;
53    use HasExternalId;
54    use HasValidation;
55    use HasDateFormatting;
56    use HasAutoCasting;
57    use HasLifecycleHooks;
58    use HasHiddenAttributes;
59
60    /**
61     * The name of the "created at" column.
62     */
63    public const string CREATED_AT = 'created_at';
64
65    /**
66     * The name of the "updated at" column.
67     */
68    public const string UPDATED_AT = 'updated_at';
69
70    /**
71     * The name of the "deleted at" column.
72     */
73    public const string DELETED_AT = 'deleted_at';
74
75    /**
76     * Indicates if the model should be timestamped.
77     *
78     * @var bool
79     */
80    public $timestamps = true;
81
82    /**
83     * The storage format of the model's date columns.
84     *
85     * @var string
86     */
87    public $dateFormat = 'Y-m-d H:i:s.u';
88
89    /**
90     * Indicates whether attributes are snake cased on arrays.
91     *
92     * @var bool
93     */
94    public static $snakeAttributes = false;
95
96    /**
97     * The primary key for the model.
98     *
99     * @var string
100     */
101    protected $primaryKey = 'id';
102
103    /**
104     * The "type" of the primary key ID.
105     *
106     * @var string
107     */
108    protected $keyType = 'int';
109
110    /**
111     * Indicates if the IDs are auto-incrementing.
112     *
113     * @var bool
114     */
115    public $incrementing = true;
116
117    /**
118     * Indicates whether loaded relations should be appended to toArray()
119     * output as soft array representations.
120     *
121     * @var bool
122     */
123    public bool $addsRelationsInToArray = false;
124
125    /**
126     * Boot the soft deleting trait for a model.
127     *
128     * @return void
129     */
130    public static function bootSoftDeletes()
131    {
132        /** @phpstan-ignore new.static */
133        $model = new static();
134
135        if ($model->hasAttribute($model->getDeletedAtColumn())) {
136            static::addGlobalScope(new SoftDeletingScope());
137        }
138    }
139
140    /**
141     * Initialize the soft deleting trait for an instance.
142     *
143     * @return void
144     */
145    public function initializeSoftDeletes()
146    {
147    }
148
149    /**
150     * Fill the model with an array of attributes.
151     *
152     * Extends the default fill behavior to automatically convert
153     * camelCase attribute names to snake_case before filling.
154     *
155     * @param array<string, mixed> $attributes Key-value pairs of attributes to fill
156     * @return static
157     */
158    public function fill(array $attributes): self
159    {
160        if (empty($attributes)) {
161            return parent::fill($attributes);
162        }
163
164        foreach ($attributes as $attributeName => $value) {
165            $snakeCaseAttr = Str::snake($attributeName);
166
167            if (!$this->hasAttribute($snakeCaseAttr)) {
168                continue;
169            }
170
171            $attributes[$snakeCaseAttr] = $value;
172        }
173
174        return parent::fill($attributes);
175    }
176
177    /**
178     * Get an attribute from the model.
179     *
180     * Overrides the default behavior to return formatted date strings
181     * instead of Carbon instances for date/datetime fields.
182     * This behavior can be disabled by setting $carbonInstanceInFieldDates to true.
183     *
184     * Also catches enum casting errors and converts them to ValidationException.
185     *
186     * @param string $key The attribute name
187     * @return mixed The attribute value (string for dates when $carbonInstanceInFieldDates is false)
188     *
189     * @throws ValidationException
190     */
191    public function getAttribute($key): mixed
192    {
193        try {
194            $value = parent::getAttribute($key);
195        } catch (ValueError $e) {
196            $rawValue = $this->getAttributes()[$key] ?? null;
197            throw new ValidationException(
198                [['field' => $key, 'value' => $rawValue, 'error' => 'valueError', 'message' => $e->getMessage()]],
199                $e->getMessage()
200            );
201        } catch (TypeError $e) {
202            $rawValue = $this->getAttributes()[$key] ?? null;
203            throw new ValidationException(
204                [['field' => $key, 'value' => $rawValue, 'error' => 'typeError', 'message' => $e->getMessage()]],
205                $e->getMessage()
206            );
207        }
208
209        if ($value instanceof Carbon && isset($this->dateFormats[$key])) {
210            if ($this->carbonInstanceInFieldDates) {
211                return $value;
212            }
213
214            return $value->format($this->dateFormats[$key]);
215        }
216
217        return $value;
218    }
219
220    /**
221     * Set a given attribute on the model.
222     *
223     * Catches enum casting errors and converts them to ValidationException.
224     *
225     * @param string $key
226     * @param mixed $value
227     * @return mixed
228     *
229     * @throws ValidationException
230     * @throws Throwable
231     */
232    public function setAttribute($key, $value)
233    {
234        try {
235            return parent::setAttribute($key, $value);
236        } catch (ValueError $e) {
237            throw new ValidationException(
238                [['field' => $key, 'value' => $value, 'error' => 'valueError', 'message' => $e->getMessage()]],
239                $e->getMessage()
240            );
241        } catch (TypeError $e) {
242            throw new ValidationException(
243                [['field' => $key, 'value' => $value, 'error' => 'typeError', 'message' => $e->getMessage()]],
244                $e->getMessage()
245            );
246        } catch (Throwable $e) {
247            throw $e;
248        }
249    }
250
251    /**
252     * Get the name of the "created at" column.
253     *
254     * Returns null when the column is not declared on the model,
255     * which prevents Laravel from auto-managing undeclared timestamp columns.
256     *
257     * @return string|null
258     * @throws MissingModelPropertyException
259     */
260    public function getCreatedAtColumn()
261    {
262        $column = static::CREATED_AT;
263
264        if (!$this->hasAttribute($column)) {
265            $strictMode = config('devToolbelt.eloquent-plus.timestamps_strict_mode', false);
266
267            if ($strictMode) {
268                throw new MissingModelPropertyException(static::class, $column);
269            }
270
271            return null;
272        }
273
274        return $column;
275    }
276
277    /**
278     * Get the name of the "updated at" column.
279     *
280     * Returns null when the column is not declared on the model,
281     * which prevents Laravel from auto-managing undeclared timestamp columns.
282     *
283     * @return string|null
284     * @throws MissingModelPropertyException
285     */
286    public function getUpdatedAtColumn()
287    {
288        $column = static::UPDATED_AT;
289
290        if (!$this->hasAttribute($column)) {
291            $strictMode = config('devToolbelt.eloquent-plus.timestamps_strict_mode', false);
292
293            if ($strictMode) {
294                throw new MissingModelPropertyException(static::class, $column);
295            }
296
297            return null;
298        }
299
300        return $column;
301    }
302
303    /**
304     * Determine whether an attribute exists on the model.
305     *
306     * @param string $key
307     * @return bool
308     */
309    public function hasAttribute($key)
310    {
311        return parent::hasAttribute($key)
312            || in_array($key, $this->fillable)
313            || array_key_exists($key, $this->rules);
314    }
315
316    /**
317     * Convert the model instance to an array.
318     *
319     * When external ID is enabled, exposes 'id' with the external ID value
320     * instead of the numeric primary key for public-facing output.
321     *
322     * @return array<string, mixed>
323     */
324    public function toArray(): array
325    {
326        $data = parent::toArray();
327
328        if ($this->usesExternalId()) {
329            $data[$this->primaryKey] = $this->getExternalId();
330        }
331
332        if ($this->addsRelationsInToArray) {
333            foreach ($this->getRelations() as $relationName => $relation) {
334                if ($relation === null) {
335                    $data[$relationName] = null;
336                    continue;
337                }
338
339                if ($relation instanceof self) {
340                    $data[$relationName] = $relation->toSoftArray();
341                    continue;
342                }
343
344                if ($relation instanceof Collection) {
345                    $data[$relationName] = $relation
346                        ->map(fn ($item) => $item instanceof self ? $item->toSoftArray() : $item->toArray())
347                        ->all();
348                }
349            }
350        }
351
352        return $data;
353    }
354
355    /**
356     * Convert the model to a minimal array representation.
357     *
358     * Returns only the primary key field (or external ID if enabled),
359     * useful for references or lightweight serialization.
360     *
361     * @return array<string, mixed> Array containing only the identifier
362     */
363    public function toSoftArray(): array
364    {
365        if ($this->usesExternalId()) {
366            return [$this->primaryKey => $this->getExternalId()];
367        }
368
369        return $this->returnOnlyFields([$this->primaryKey]);
370    }
371
372    /**
373     * Filter the model's array representation to include only specified fields.
374     *
375     * @param string[] $fieldsToReturn List of field names to include in the output
376     * @return array<string, mixed> Filtered array containing only the specified fields
377     */
378    protected function returnOnlyFields(array $fieldsToReturn): array
379    {
380        return array_filter($this->toArray(), function ($key) use ($fieldsToReturn) {
381            return in_array($key, $fieldsToReturn);
382        }, ARRAY_FILTER_USE_KEY);
383    }
384}