Models
Read when: writing or reviewing an Entity, its casts or accessors, a filter or global scope, an
{Entity}Abilities class, a model trait, a translated name, or a domain feature-flag wrapper.
Owns: everything under src/Domain/{Domain}/Models/: Entities/, Scopes/, Abilities/, Traits/,
Features/, and how a model stores translated text.
TL;DR
- Entities live in
Models/Entities/, created by hand. New models use thecasts()method. $fillableon any model that takes request data;$guarded = []only on internal models.- No state-changing or workflow methods on a model. Writes are Actions. Read-only questions about the
row itself are fine (
needsActivation()). Attributeaccessors do all their work inside theget:closure.- One
{Entity}Filtertrait per listable entity, exposingscopeFilterBy(). Every list query goes through it. - Abilities answer "can this record do X now?" with a
bool. Role checks are Policies. - A name shown in several languages is rows in a
translationstable, not columns. Search and sort it with the translation scopes, neverwhere('name').
Folder map
| Need | Location |
|---|---|
| Eloquent model | Models/Entities/{Entity}.php |
| DTO-driven filter + named scopes | Models/Scopes/{Entity}Filter.php (trait) |
| Auto-applied constraint | Models/Scopes/{Adjective}Scope.php (implements Scope) |
| Per-record "can it do X now?" | Models/Abilities/{Entity}Abilities.php |
| Relations, casts or accessors grouped out of a large entity, or shared by several | Models/Traits/{Name}.php |
| Domain feature-flag wrapper (option) | Models/Features/{Name}Feature.php |
Abilities/ is a sibling of Entities/, never inside it.
Entities
namespace Domain\Employee\Models\Entities;
class Employee extends Model
{
use EmployeeFilter, HasFactory, ModelAbility, SoftDeletes;
protected string $abilityClass = EmployeeAbilities::class;
protected $fillable = ['user_id', 'employee_number', 'name', 'email', 'job_title', 'status', 'hired_at', 'notes'];
protected function casts(): array
{
return [
'status' => EmployeeStatus::class,
'activated_at' => 'datetime',
'archived_at' => 'datetime',
'hired_at' => 'date',
'is_locked' => 'boolean',
'options' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function contacts(): HasMany
{
return $this->hasMany(EmployeeContact::class);
}
public function needsActivation(): bool
{
return $this->status === EmployeeStatus::PENDING && $this->hired_at?->isPast();
}
protected static function newFactory(): EmployeeFactory
{
return EmployeeFactory::new();
}
}
- Never
php artisan make:model: it writes toapp/Models/. Create by hand or clone a neighbour. - Extend
Model, orIlluminate\Foundation\Auth\Useronly when the entity signs in. - Type every relation's return so static analysis catches misuse. Follow Laravel's key names
(
user_id); override them only for a column that already exists under another name. - Add
->withTrashed()to a relation when the related row soft-deletes but the link must still resolve (an archive reason, a closed department on an old employee). - Keep a
morphTo's target list as a constant on the model that owns it (Attachment::ATTACHABLE_TYPES), sowhereHasMorph()uses one list (queries.md). - Entities under
src/Domain/are invisible to factory name guessing. AddnewFactory()per model, or map once withFactory::guessFactoryNamesUsing(). - Relations to another domain's entity are fine for eager loading and display; querying or writing through them from outside the owning domain is a boundary breach (architecture.md).
Casts
- Cast every enum, date, boolean, number and JSON column. Why: an uncast column makes every caller remember to coerce, and one of them will not.
- New models use
protected function casts(): array. A model that still has the$castsproperty keeps it until someone refactors it on purpose, not as a side effect of other work. - A JSON list of enum values:
AsEnumCollection::of(EmployeeFlag::class)(Laravel 12, checked 2026-11-03). It throws on a value the enum no longer has; if old rows can hold retired values, use a custom cast that maps withtryFrom()and drops the nulls. - A JSON object with known keys gets an options DTO accessor (dtos.md).
- Secrets use the
encryptedcast and stay out oftoArray()through$hidden.
Mass assignment
$fillableon every model that is created or updated from request data. Why:$guarded = []turns a forgotten validation rule into a writable column.$guarded = []only on internal models that never receive request input (logs, pivots, snapshots).- Values that are not columns (translated names, files) never go in
$fillable.
Accessors
// ✅ the work runs on read
protected function displayName(): Attribute
{
return Attribute::get(fn () => "{$this->name} ({$this->department->name})");
}
// ❌ the work runs when the accessor is built, before the model is ready
protected function displayName(): Attribute
{
$label = "{$this->name} ({$this->department->name})";
return Attribute::get(fn () => $label);
}
Why: Eloquent builds accessors during serialization; code outside the closure runs then and can trigger a
lazy load, which fails hard when Model::preventLazyLoading() is on outside production (it should be).
Use Attribute::get() for read-only values and Attribute::make(get:, set:) only when you also write.
Model methods
// ❌ a workflow hidden on the model
public function markArchived(): void
{
$this->update(['status' => EmployeeStatus::ARCHIVED, 'archived_at' => now()]);
app(AuditContract::class)->record('employee.archived', $this->id);
}
// ✅ the model answers questions about itself; an Action owns the write
public function isArchived(): bool
{
return $this->status === EmployeeStatus::ARCHIVED;
}
Why: a model method runs from anywhere (views, serialization, other domains) with no transaction and no
Contract in sight. Plain methods read as questions (needsActivation(), requiresApproval()) or derived
values (initials()). A method may call route() to build a link; it never reads request(), the
session, auth() or another domain's data.
Model traits
Models/Traits/ holds traits that group one concern of a large entity (EmployeeAddresses,
HasEmployeePhoto) or share relations, casts and accessors between entities. The same limits apply as
on the entity: relations, accessors, scopes and read-only questions; no writes, no workflows. Name a
shared trait Has{Thing}; name a grouping trait after the concern.
Scopes
The filter trait
namespace Domain\Employee\Models\Scopes;
trait EmployeeFilter
{
public function scopeFilterBy(Builder $query, ?FilterEmployeeData $filters): void
{
$query->when($filters?->id, fn (Builder $q) => $q->whereByType('id', $filters->id))
->when($filters?->user_id, fn (Builder $q) => $q->whereByType('user_id', $filters->user_id))
->when($filters?->status, fn (Builder $q) => $q->whereByType('status', $filters->status))
->when($filters?->employee_number, fn (Builder $q) => $q->whereLikeText('employee_number', $filters->employee_number))
->when($filters?->hired_from, fn (Builder $q) => $q->whereDate('hired_at', '>=', $filters->hired_from))
->when($filters?->hired_to, fn (Builder $q) => $q->whereDate('hired_at', '<=', $filters->hired_to))
->when($filters?->with_trashed, fn (Builder $q) => $q->withTrashed());
}
public function scopeCurrent(Builder $query): void
{
$query->whereIn('status', [EmployeeStatus::PENDING, EmployeeStatus::ACTIVE]);
}
}
- The entry scope is
scopeFilterBy()(called asEmployee::query()->filterBy($filters)). It takes the nullable Filter DTO, never a request or a loose array (dtos.md). - Every clause sits inside
when(), so a null field is skipped and the Action has one code path. when($filters?->flag, …)skipsfalse. For a boolean filter wherefalseis a real value, test! is_null($filters?->is_locked).- Every property the DTO declares is read here.
- A where-chain that names a domain idea and appears twice becomes a named scope in the same trait
(
scopeCurrent). Why: three copies of "current employee" drift apart. Move named scopes to their own trait only when the filter trait grows large. - The macros, eager loading and the rest of the query rules: queries.md. A self-evident
when()chain takes no comment.
Global scopes
Models/Scopes/{Adjective}Scope.php implementing Scope, registered in booted() or with #[ScopedBy].
Use one only when a constraint must apply to every query with no opt-in; bypass it with
withoutGlobalScope(...). Never turn a DTO filter into a global scope: it hides filtering from the call
site and breaks every query that needs all rows.
A global scope that limits rows to "what the current user may see" decides what no user means (a queued job, a command). Returning everything for a null actor is a leak; decide it and test it.
Heavy columns
For large JSON or text columns that list queries never need (shapes, documents, blobs), a small trait
(ExcludesHiddenColumns) registers a global scope that selects every column except a $hiddenColumns
list, with a withHiddenColumns() opt-in. It also applies inside eager loads, so a list of parents with
with('region') stays light. Why: one wide column across a few thousand rows is enough to run out of
memory on a dropdown.
Abilities
namespace Domain\Employee\Models\Abilities;
final readonly class EmployeeAbilities
{
public function __construct(public Employee $employee) {}
public function update(): bool
{
return $this->employee->status === EmployeeStatus::PENDING && ! $this->employee->is_locked;
}
public function archive(User $user): bool
{
return $this->employee->canTransition(EmployeeTransition::ARCHIVE, ['user' => $user]);
}
}
- The entity uses
ModelAbilityand declaresprotected string $abilityClass = EmployeeAbilities::class;(core-kernel.md). - The constructor takes the model as a public property. One public method per question, named for the
action (
update,archive,activate), nocanprefix: the call reads$employee->ability('archive', $user). - Returns
bool, no reason and no message. Private helpers for shared sub-checks; extra arguments are forwarded. getAbility()returns the object when you need several flags;hasAbility()aborts 403 and is rare, since HTTP authorization belongs to the Form Request or a Policy.- An entity with a lifecycle lets the ability delegate to its state machine, then add business rules on top (enums-and-states.md).
| Question | Where |
|---|---|
| Can this employee be edited now, given its status? | EmployeeAbilities::update() |
| May this role edit employees at all? | app/Policies/EmployeePolicy.php or a permission middleware |
| Why exactly can it not be archived? (shown to the user) | the state machine's result (enums-and-states.md) |
Policies may call abilities; abilities never check roles. Send the reachable actions to the page from the server, so the UI never offers a move the Action will refuse.
Translated names
A name or description shown in more than one language is stored as rows of one translations table
(transable_type, transable_id, language_code, name, description, unique on type + id + language),
through a Transable trait on the entity. Why: a column per language multiplies with every language and
every translated field; one morph table does not.
The trait gives:
| Member | Does |
|---|---|
$with = ['translates'], $appends |
eager-loads the rows on every query, so ->name never runs its own query; adds name / description to JSON |
$model->name |
the reader's language, else the other one when it is missing or blank |
$model->name('en') |
that language only, null when missing; for a form field of that language |
getTranslation('en', withoutFallbackLocale: false) |
that language, else the other; for text in a language other than the reader's |
touchTranslation(names: [...], descriptions: [...]) |
upserts one row per language |
searchTranslation('name', $term, ?$language) |
scope: matches in any language, or one |
orderByTranslation('name', 'asc', ?$locale) |
scope: sorts as ->name reads, as a correlated subquery so pagination stays correct |
Rules:
- The entity has no
namecolumn.where('name', …)andorderBy('name')fail; call the two scopes from the filter trait. Never jointranslationsby hand: a row with two languages comes back twice, and the fallback is lost. - Write
searchTranslation()to escape%and_the waywhereLikeTextdoes. Why: a scope that skips the escape lets a search for100%match every row. - Sort in SQL with
orderByTranslation(), never->sortBy('name')afterpaginate(): that orders one page. - The DTO carries
name(anddescription) asarray<string, ?string>keyed by language code. The Action cuts it out of thecreate()/update()payload withonly([...])and writes it withtouchTranslation()in the same transaction. touchTranslation()writes each language's row whole: pass every language and both arrays each time. A call withnamesonly clears every description;'en' => nullclears the English name.- A form sends
name[ar],name[en]; the Form Request validatesnameas an array, the required languagerequired, the othersnullable. - A queued notification, an export or a payload to another system reads the name in an explicit
language, never
->name. Why:->namefollows the job's locale, not the recipient's. - A force delete removes the translations; a soft delete keeps them.
Moving an existing column into translations is a data migration: migrations.md.
Feature flags (option)
- Option 1: wrappers in
Models/Features/{Name}Feature.phpextending a smallFeaturebase with a staticflag()returning a flag enum case, plusenabled()/disabled()and domain helpers. Use when one flag is read in many places or needs helper logic around it. - Option 2: call Pennant directly and keep no folder, when a flag is a one-off guard in one Action.
Per-user or per-tenant scoping (Feature::for($user)) lives in the Action or Service that needs it, not in
the wrapper.
Verify
# Entities generated outside the tree
ls app/Models/ 2>/dev/null
# $guarded = [] on models (prefer $fillable where request data reaches the model)
grep -rn 'guarded = \[\]' src/Domain/*/Models/Entities/
# Accessors doing work before the closure
grep -rn -B3 "return Attribute::" src/Domain/*/Models/Entities/ | grep '\$this->'
# Writes inside entities and model traits
grep -rnE '\->(update|save|delete)\(' src/Domain/*/Models/Entities/ src/Domain/*/Models/Traits/
# where / orderBy on a translated column, and hand-written joins on translations
grep -rnE "(orderBy|where)\('name'" src/Domain/*/Models/Scopes/
grep -rn "join('translations'" src/Domain/