Skip to content

Model

  1. Use Model::query() instead of direct static calls:

    php
    // GOOD
    Member::query()->firstWhere('id', 42);
    
    // BAD
    Member::firstWhere('id', 42);
  2. Avoid mass assignment when possible:

    php
    // PREFERRED
    $member = new Member();
    $member->name = $request->input('name');
    $member->email = $request->input('email');
    
    // AVOID
    $member->forceFill([
        'name' => $request->input('name'),
        'email' => $request->input('email'),
    ]);
    
    // NEVER DO
    $member->forceFill($request->all());
  3. Don't use where{Attribute} magic methods. Use the where method to reduce magic.

  4. Document all magic using PHPDoc:

    php
    /**
     * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Modules\Permission\Models\Role> $roles
     * @method static \Illuminate\Database\Eloquent\Builder|\App\Modules\Member\Models\Member canceled()
     */
  5. Use safe defaults for attributes:

    php
    final class CourseEnrollment extends Model
    {
        /** @var array<string, scalar|bool|null> Default values for Eloquent attributes */
        protected $attributes = [
            'graded_score' => 0,
            'potential_points' => 0,
            'completed_time_in_seconds' => 0,
        ];
    }
  6. Use custom EloquentBuilder classes for models with 3+ query scopes:

    php
    class User extends Model
    {
        #[\Override]
        public function newEloquentBuilder($query): UserEloquentBuilder
        {
            return new UserEloquentBuilder($query);
        }
    }
    
    /** @extends \Illuminate\Database\Eloquent\Builder<\App\Models\User> */
    final class UserEloquentBuilder extends Builder
    {
        public function confirmed(): self
        {
            return $this->whereNotNull('confirmed_at');
        }
    }
  7. Use getAttribute() for virtual attributes (e.g. from withSum, withCount, withAggregate). These attributes exist only at query time, so they must not be listed in model @property PHPDoc — keep PHPDoc for real (database/accessor) attributes only.

    php
    // Query adds a virtual attribute via withSum/withCount
    $users = User::query()->withSum('orders AS total_spent', 'amount')->get();
    
    // GOOD — explicit, no PHPDoc needed for virtual attributes
    $user->getAttribute('total_spent');
    
    // BAD — relies on magic __get, tempts adding @property for static analysis
    $user->total_spent;

    Important: Extract virtual attribute values as close to the query as possible and pass them explicitly (e.g. as constructor arguments) to consuming classes. Do not let virtual attributes leak across layer boundaries — this keeps the dependency on the query shape visible and prevents silent failures when the attribute is not loaded.

    php
    // GOOD — extract at the query boundary, pass explicitly
    $users = User::query()->withSum('orders AS total_spent', 'amount')->get();
    $reports = $users->map(fn (User $user) => new UserReport(
        user: $user,
        totalSpent: (int) $user->getAttribute('total_spent'),
    ));
  8. Use invokable classes for reusable scopes:

    php
    $unverifiedUsers = User::query()
        ->tap(new UnverifiedScore())
        ->get();
    
    final class UnverifiedScore
    {
        public function __invoke(Builder $builder): void
        {
            $builder->whereNull('email_verified_at');
        }
    }