Appearance
Model
Use
Model::query()instead of direct static calls:php// GOOD Member::query()->firstWhere('id', 42); // BAD Member::firstWhere('id', 42);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());Don't use
where{Attribute}magic methods. Use thewheremethod to reduce magic.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() */Use safe defaults for attributes:
phpfinal 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, ]; }Use custom EloquentBuilder classes for models with 3+ query scopes:
phpclass 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'); } }Use
getAttribute()for virtual attributes (e.g. fromwithSum,withCount,withAggregate). These attributes exist only at query time, so they must not be listed in model@propertyPHPDoc — 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'), ));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'); } }