Models
Each domain keeps all of its Eloquent concerns under src/Domain/{Domain}/Models/, split into four sub-folders:
Models/
├── Entities/ ← Eloquent model classes
├── Scopes/ ← filter traits and global scopes
├── Abilities/ ← per-instance permission objects
└── Features/ ← Pennant-backed, domain-scoped feature flags
Traits shared only among the models of one domain go in Models/Traits/. The point of the split is that everything about a domain's data layer — the model, how it's filtered, what it permits, and which flags gate it — sits in one predictable place.
Entities
Entities are the Eloquent models, at src/Domain/{Domain}/Models/Entities/{Entity}.php under namespace Domain\{Domain}\Models\Entities.
php artisan make:model — it writes to app/Models/, the legacy location. Create entities by hand or clone a neighbour in the same domain.Extend Illuminate\Database\Eloquent\Model for a standard entity, or Illuminate\Foundation\Auth\User as Authenticatable when the entity authenticates (e.g. Driver, Owner).
Casts — use the casts() method on new entities
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'trip_type' => OrderTripType::class,
'date' => 'datetime',
'weight' => 'float',
'has_packaging' => 'boolean',
];
}
Cast every enum, datetime, boolean, and numeric column — leaving them uncast forces every caller to coerce the value, and that coupling leaks out of the model. Existing entities that still declare a $casts property stay as-is; don't flip $casts → casts() as a side effect of unrelated work.
Mass assignment
$fillableon models that accept user input.$guarded = []only on internal entities never created directly from request data — a user-input model with$guarded = []is flagged in Security.
Relationships
Declare every relationship with its return type so static analysis catches misuse:
public function owner(): BelongsTo
{
return $this->belongsTo(Owner::class);
}
public function finance(): HasMany
{
return $this->hasMany(OrderFinance::class);
}
public function waybill(): MorphOne
{
return $this->morphOne(Waybill::class, 'waybillable');
}
Add ->withTrashed() when the related entity uses SoftDeletes and the association must survive soft-deletion (e.g. cancelReason, vehicle). For polymorphic targets used in whereHasMorph, declare a class constant so the type list lives in one place:
const CONTRACTABLE_TYPES = [User::class, Branch::class];
Computed attributes
Use the Attribute API for derived values, and do the work inside the get: closure — never in the outer scope. Eloquent resolves attributes during JSON serialization, and logic run outside the closure can fire before the model is fully loaded, triggering lazy-load exceptions:
// ✅ Good — work is inside the closure
protected function transportType(): Attribute
{
return Attribute::make(
get: fn () => $this->from_city_id === $this->to_city_id
? OrderTransportType::INSIDE->value
: OrderTransportType::BETWEEN->value,
);
}
// ❌ Bad — computed on construction, before the model is fully loaded
protected function transportType(): Attribute
{
$type = $this->from_city_id === $this->to_city_id ? /* … */ : /* … */; // lazy-load risk
return Attribute::make(get: fn () => $type);
}
For read-only attributes, Attribute::get(...) is the short form.
Business methods
Plain (non-scope) methods express domain facts about a single instance. Name them as true/false questions or actions, and keep them free of HTTP, session, or Presentation concerns:
public function isOrderOwnerReceiver(): bool { /* … */ }
public function requiresInternalOrder(): bool { /* … */ }
public function sysCommission(): float { /* … */ }
A method that calls route() to build a dashboard URL is fine; one that calls request() or touches the session is not.
Wiring traits onto the entity
An entity pulls in its filter trait (from Models/Scopes/) and, when it exposes permission checks, its abilities class:
use Domain\Order\Models\Scopes\OrderFilter;
use Domain\Core\Traits\ModelAbility;
use Domain\Order\Models\Abilities\OrderAbilities;
class Order extends Model
{
use OrderFilter;
use ModelAbility;
protected string $abilityClass = OrderAbilities::class;
}
Scopes
The Scopes/ folder holds two distinct kinds of query constraint.
Filter traits — {Entity}Filter
Every list-queryable entity has a {Entity}Filter trait exposing scopeFilter(Builder $query, Filter{Entity}Data $data): void. All list queries for the entity go through this one scope, so filtering logic lives in exactly one place.
trait OrderFilter
{
public function scopeFilter(Builder $query, FilterOrderData $data): void
{
$query
->when($data->id, fn (Builder $q) => $q->whereByType('id', $data->id))
->when($data->statuses, fn (Builder $q) => $q->whereIn('status', $data->statuses))
->when($data->from_cities, fn (Builder $q) => $q->whereIn('from_city_id', $data->from_cities));
}
}
Rules:
- Accept
Filter{Entity}Data, never loose arrays or request objects. See DTOs. - Wrap every clause in
->when(...)— a null/empty DTO field skips the clause, so callers never branch. - Use
whereByType()for columns that may receive a scalar or an array, andwhereLikeText()for text search — never hand-roll aLIKE. See Eloquent and queries.
The same trait may hold additional named scopes (scopeCompleted, scopeNonExcludedOrders) when they are tightly coupled to the entity's status logic; split them into a dedicated trait once they grow large.
Global scopes — {Adjective}Scope
Reach for a global scope (implementing Illuminate\Database\Eloquent\Scope) only when a constraint must apply automatically to every query, without the caller opting in — the canonical case being rows that are logically invisible system-wide.
class ApprovedOrderCompensationScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', OrderCompensationStatus::APPROVED);
}
}
Register it in the entity's booted() method (or the domain ServiceProvider), and bypass it deliberately with ->withoutGlobalScope(...).
Abilities
Abilities answer per-instance permission questions — can this specific model instance perform this action right now? — and live at Models/Abilities/{Entity}Abilities.php. The class takes the model in its constructor as a public readonly property and exposes one bool-returning method per question, named for the action with no can prefix:
class OrderAbilities
{
public function __construct(
public Order $order,
) {}
public function cancel(): bool
{
return $this->order->stateMachine()->can(OrderTransition::CANCEL);
}
public function assignOrder(): bool
{
return ! $this->order->carrier_id
&& in_array($this->order->status, [OrderStatus::WAITING_STATUS, OrderStatus::NEW_STATUS]);
}
}
Call them from controllers, Blade (for conditional rendering), and API resources:
$order->ability('cancel'); // bool
$order->ability('issueInvoice', $user, $id); // extra args forwarded to the method
$order->hasAbility('cancel'); // abort(403) if false
$order->getAbility(); // the abilities object, for several checks at once
Abilities frequently delegate to the state machine and then layer extra business logic on top — that hybrid, and the rule for ability vs. Laravel Policy, is covered in full in State machine.
Features
Models/Features/ holds domain-scoped Pennant feature flags — behaviour specific to the domain, not a global tenant/user flag. A feature class extends Domain\Core\Abstracts\Feature and implements flag():
final class WinchBranchesFeature extends Feature
{
public static function flag(): FeatureFlag
{
return FeatureFlag::WINCH_BRANCHES;
}
}
WinchBranchesFeature::enabled(); // bool — Pennant::active(flag)
WinchBranchesFeature::disabled(); // bool — ! enabled()
Put a class here when the flag is used across the domain (entity methods, actions, blade) or carries domain-specific helper logic. Call Feature::active(FeatureFlag::X->value) directly for a one-off guard in a single action. Per-user / per-tenant Pennant scoping belongs in an Action or Service (Feature::for($user)->active(...)), not here — see Feature flags for the full decision tree.
What goes where
| Need | Location |
|---|---|
| Eloquent model | Models/Entities/{Entity}.php |
| DTO-driven filter scope | Models/Scopes/{Entity}Filter.php (trait) |
| Named / reusable scope | The same filter trait, or a dedicated trait if large |
| Auto-applied constraint | Models/Scopes/{Adjective}Scope.php (implements Scope) |
| Per-instance permission | Models/Abilities/{Entity}Abilities.php |
| Domain feature flag | Models/Features/{Name}Feature.php |
| Relations/casts shared across entities | Models/Traits/{TraitName}.php |
What to read next
- Eloquent and queries — the macros (
whereByType,whereLikeText) and eager-loading rules the filter scopes rely on. - DTOs — the
Filter{Entity}Dataclasses that drivescopeFilter. - State machine — how ability classes wrap the state machine, and ability vs. Policy.
- Feature flags — when a
Models/Features/class is the right home for a flag. - Migrations — the schema the entities map onto.
RESTful API
The project's API contract — URL pattern, ResponseBuilder envelope, CRUD verb conventions, pagination, validation errors, and how to assert against responses in Pest.
Queries
How to write Eloquent queries in this codebase — filter scopes, the project's query macros, eager loading, and chunking large result sets.