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