Key Concepts

Models

Everything Eloquent for a domain lives under `Models/`, split into Entities, Scopes, Abilities, and Features. What each sub-folder holds, the conventions for entities, and where a new model concern belongs.

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.

Never run 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

  • $fillable on 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, and whereLikeText() for text search — never hand-roll a LIKE. 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(...).

Don't turn DTO-driven filters into global scopes — that hides filtering from the call site and silently breaks every bulk query that legitimately needs all rows.

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

NeedLocation
Eloquent modelModels/Entities/{Entity}.php
DTO-driven filter scopeModels/Scopes/{Entity}Filter.php (trait)
Named / reusable scopeThe same filter trait, or a dedicated trait if large
Auto-applied constraintModels/Scopes/{Adjective}Scope.php (implements Scope)
Per-instance permissionModels/Abilities/{Entity}Abilities.php
Domain feature flagModels/Features/{Name}Feature.php
Relations/casts shared across entitiesModels/Traits/{TraitName}.php
  • Eloquent and queries — the macros (whereByType, whereLikeText) and eager-loading rules the filter scopes rely on.
  • DTOs — the Filter{Entity}Data classes that drive scopeFilter.
  • 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.