Eloquent -- самая большая тема на экзамене (16%). Здесь собраны самые каверзные вопросы и типичные ошибки.
Ловушки Mass Assignment
$fillable vs $guarded: пустые массивы
<?php
// TRAP 1: Empty arrays have opposite meanings!
class Model1 extends Model
{
protected $fillable = []; // DEFAULT: NOTHING is mass assignable
}
class Model2 extends Model
{
protected $guarded = []; // EVERYTHING is mass assignable (dangerous!)
}
class Model3 extends Model
{
protected $fillable = ['name'];
protected $guarded = ['id'];
// WRONG: Using both throws LogicException in Laravel 11+
}
Mass assignment vs Direct assignment
<?php
$user = new User();
// Mass assignment: checked against $fillable/$guarded
$user->fill(['is_admin' => true]); // IGNORED if not in $fillable
User::create(['is_admin' => true]); // IGNORED if not in $fillable
$user->update(['is_admin' => true]); // IGNORED if not in $fillable
// Direct assignment: NOT checked!
$user->is_admin = true; // WORKS regardless of $fillable
$user->save(); // Saves is_admin = true
// forceFill: bypasses mass assignment protection
$user->forceFill(['is_admin' => true])->save(); // WORKS
Экзаменационный вопрос: "Если is_admin НЕ в $fillable, можно ли установить его через $user->is_admin = true?"
Ответ: ДА. Mass assignment protection работает ТОЛЬКО для fill(), create(), update() с массивом. Прямое присваивание через -> ВСЕГДА работает.
Ловушки Relationships
Foreign Key на ПРАВИЛЬНОЙ стороне
<?php
// TRAP 2: Where does the foreign key live?
// hasOne: foreign key on RELATED model's table
// User hasOne Phone -> phones.user_id
$this->hasOne(Phone::class); // phones table has user_id
// belongsTo: foreign key on CURRENT model's table
// Phone belongsTo User -> phones.user_id
$this->belongsTo(User::class); // current table (phones) has user_id
// COMMON MISTAKE:
// "User hasOne Phone" does NOT mean users table has phone_id
// It means phones table has user_id
hasOneThrough порядок аргументов
<?php
// TRAP 3: Argument order is counter-intuitive
// mechanics -> cars -> owners
// "A mechanic has one owner through car"
$this->hasOneThrough(
Owner::class, // 1st: FINAL model (not intermediate!)
Car::class, // 2nd: INTERMEDIATE model
'mechanic_id', // FK on intermediate (cars) table
'car_id', // FK on final (owners) table
'id', // Local key on current (mechanics) table
'id' // Local key on intermediate (cars) table
);
// MISTAKE: Many people swap Owner and Car positions
// First argument is always the FINAL destination model
belongsToMany pivot-таблица
<?php
// TRAP 4: Pivot table naming convention
// User <-> Role
// Table name: role_user (alphabetical order: R before U)
// NOT user_role, NOT users_roles
// Post <-> Tag
// Table name: post_tag (P before T)
// Category <-> Product
// Table name: category_product (C before P)
// What if model has two words?
// OrderItem <-> Tag
// Table name: order_item_tag (snake_case of both)
// CUSTOM pivot table:
$this->belongsToMany(Role::class, 'user_roles'); // Override
Polymorphic отношения и morph map
<?php
// TRAP 5: Without morph map, full class name is stored
// In database without morph map:
// commentable_type = "App\Models\Post"
// commentable_type = "App\Models\Video"
// Problem: if you rename/move the class, all references break!
// Solution: enforceMorphMap
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
]);
// Now in database:
// commentable_type = "post"
// commentable_type = "video"
// enforceMorphMap vs morphMap:
// morphMap: allows unmapped types (stores full class name)
// enforceMorphMap: throws exception for unmapped types (Laravel 11+)
Ловушки Eager Loading
N+1 и свойство vs метод
<?php
// TRAP 6: Property vs Method access
$post = Post::first();
// Property access: triggers lazy loading (SQL query!)
$comments = $post->comments; // SELECT * FROM comments WHERE post_id = 1
// Method access: returns query builder (NO SQL yet)
$query = $post->comments(); // No SQL query
$comments = $post->comments()->where('approved', true)->get(); // NOW SQL
// The difference matters for N+1:
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
$post->comments; // N queries! (one per post)
}
// Fix:
$posts = Post::with('comments')->get(); // 2 queries total
Eager load constraints не фильтруют родителей
<?php
// TRAP 7: with() constraint does NOT filter parent models
$posts = Post::with([
'comments' => fn ($q) => $q->where('approved', true),
])->get();
// This returns ALL posts, but each post only has approved comments loaded
// Posts without approved comments still appear (with empty comments collection)
// If you want to filter parents TOO, use whereHas:
$posts = Post::whereHas('comments', fn ($q) => $q->where('approved', true))
->with(['comments' => fn ($q) => $q->where('approved', true)])
->get();
// Or use withWhereHas (Laravel 11+ shorthand):
$posts = Post::withWhereHas('comments', fn ($q) => $q->where('approved', true))
->get();
// Filters parents AND constrains eager loaded relation
Default eager loading ($with)
<?php
// TRAP 8: $with and its interactions
class Post extends Model
{
protected $with = ['author']; // Always eager load author
}
// Post::all() automatically includes author (no N+1)
// But what if you don't want author?
Post::without('author')->get(); // Remove default eager load
// TRAP: $with and explicit with() are ADDITIVE
Post::with('comments')->get();
// Loads BOTH author (from $with) AND comments (from with())
// TRAP: withCount does NOT conflict with $with
Post::withCount('comments')->get();
// Loads author (from $with) AND adds comments_count
Ловушки Soft Deletes
SoftDeletes это Global Scope
<?php
// TRAP 9: SoftDeletes is implemented as a global scope
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
// Normal query: automatically adds WHERE deleted_at IS NULL
Post::all(); // SELECT * FROM posts WHERE deleted_at IS NULL
// withTrashed: removes the SoftDeletingScope
Post::withTrashed()->get(); // SELECT * FROM posts
// onlyTrashed: changes scope to WHERE deleted_at IS NOT NULL
Post::onlyTrashed()->get(); // SELECT * FROM posts WHERE deleted_at IS NOT NULL
// TRAP: delete() on soft-delete model sets deleted_at, does NOT delete
$post->delete(); // UPDATE posts SET deleted_at = NOW() WHERE id = ?
// forceDelete() actually deletes
$post->forceDelete(); // DELETE FROM posts WHERE id = ?
Soft deletes и relationships
<?php
// TRAP 10: Relationships do NOT automatically exclude soft-deleted
class User extends Model
{
use SoftDeletes;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
class Post extends Model
{
use SoftDeletes;
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// If the user is soft-deleted:
$post = Post::first();
$post->user; // Returns null! (because of SoftDeletingScope on User)
// To include soft-deleted parent:
$post->user()->withTrashed()->first(); // Returns the soft-deleted user
// Or define the relationship to always include trashed:
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
Ловушки Query Scopes
Local scope prefix
<?php
// TRAP 11: Scope method name vs usage name
class Post extends Model
{
// Method name: scopePublished (with 'scope' prefix)
public function scopePublished(Builder $query): void
{
$query->where('published_at', '<=', now());
}
// Method name: scopeOfType (with 'scope' prefix)
public function scopeOfType(Builder $query, string $type): void
{
$query->where('type', $type);
}
}
// Usage: WITHOUT 'scope' prefix!
Post::published()->get();
Post::ofType('news')->get();
// WRONG:
// Post::scopePublished()->get(); // This will NOT work as expected!
Global scope и when to remove
<?php
// TRAP 12: Removing global scope in specific queries
// Global scope automatically applied:
#[ScopedBy(ActiveScope::class)]
class User extends Model {}
// All queries automatically filter active users
User::all(); // Only active
// Remove for specific query:
User::withoutGlobalScope(ActiveScope::class)->get();
// Remove ALL global scopes:
User::withoutGlobalScopes()->get();
// Remove specific list:
User::withoutGlobalScopes([ActiveScope::class, TenantScope::class])->get();
// TRAP: withoutGlobalScope() returns Builder, can be chained
User::withoutGlobalScope(ActiveScope::class)
->where('role', 'admin')
->get();
Ловушки Model Events
Порядок событий
<?php
// TRAP 13: Event firing order
// Creating new model:
// 1. creating (before INSERT)
// 2. created (after INSERT)
// 3. saving (wraps creating/created)
// 4. saved (wraps creating/created)
// Full order for NEW model:
// saving -> creating -> INSERT -> created -> saved
// Full order for EXISTING model:
// saving -> updating -> UPDATE -> updated -> saved
// Deleting:
// deleting -> DELETE -> deleted
// TRAP: 'saving' fires for BOTH create and update
// Use 'creating'/'updating' to distinguish
Остановка операции из события
<?php
class User extends Model
{
protected static function booted(): void
{
static::creating(function (User $user) {
if ($user->email === '[email protected]') {
return false; // PREVENTS creation!
}
});
}
}
// TRAP 14: Returning false from creating/updating/saving/deleting
// CANCELS the operation! No exception, just silently stops.
$user = User::create(['email' => '[email protected]']);
// $user will be an unsaved model instance
// No exception thrown, but not saved to database
Silent operations (без событий)
<?php
// TRAP 15: Some operations do NOT fire model events
// These DO fire events:
$user = User::create([...]); // creating, created
$user->save(); // saving, updating/creating
$user->delete(); // deleting, deleted
$user->update([...]); // saving, updating
// These do NOT fire events:
User::where('active', false)->delete(); // Mass delete: NO events
User::where('role', 'user')->update([...]); // Mass update: NO events
User::insert([...]); // Raw insert: NO events
User::upsert([...], [...]); // Upsert: NO events
DB::table('users')->update([...]); // Query builder: NO events
// To fire events for mass operations, iterate:
User::where('active', false)->each(function ($user) {
$user->delete(); // This DOES fire events
});
Ловушки Query Builder vs Eloquent
<?php
// TRAP 16: Eloquent returns Collection, Query Builder returns stdClass
// Eloquent: returns Model instances
$user = User::find(1); // User model instance
$users = User::all(); // Eloquent Collection of User models
// Query Builder: returns stdClass
$user = DB::table('users')->find(1); // stdClass
$users = DB::table('users')->get(); // Base Collection of stdClass
// TRAP: pluck() returns different types
$names = User::pluck('name'); // Support\Collection of strings
$names = DB::table('users')->pluck('name'); // Support\Collection of strings
// Same result, but Eloquent pluck goes through model accessors/casts!
// TRAP: Eloquent applies accessors, casts, scopes
// Query Builder does NOT
$user = User::find(1);
$user->name; // Accessor applied: "JOHN DOE" (if accessor uppercases)
$user = DB::table('users')->find(1);
$user->name; // Raw value: "john doe"
Ловушки Collections
<?php
// TRAP 17: Methods that change collection type
$users = User::all(); // Eloquent Collection
// Returns Eloquent Collection (models preserved):
$users->filter(fn ($u) => $u->is_active); // EloquentCollection
$users->reject(fn ($u) => $u->is_admin); // EloquentCollection
$users->unique('email'); // EloquentCollection
$users->diff($otherUsers); // EloquentCollection
// Returns BASE Collection (models lost):
$users->pluck('name'); // Support\Collection
$users->map(fn ($u) => $u->name); // Support\Collection
$users->flatMap(fn ($u) => $u->roles); // Support\Collection
$users->groupBy('role'); // Support\Collection
// TRAP: After map/pluck, you lose Eloquent Collection methods
$mapped = User::all()->map(fn ($u) => ['id' => $u->id]);
$mapped->find(1); // ERROR! find() is Eloquent Collection method
Финальные ловушки
<?php
// TRAP 18: fresh() vs refresh()
$user = User::find(1);
$user->name = 'Changed';
$fresh = $user->fresh(); // Returns NEW model from DB (original unchanged)
$user->name; // Still 'Changed'
$user->refresh(); // Refreshes THIS model from DB (in-place)
$user->name; // Original DB value
// TRAP 19: is() vs isNot() for model comparison
$user1 = User::find(1);
$user2 = User::find(1);
$user1 === $user2; // false (different PHP objects!)
$user1->is($user2); // true (same model: same key + same table + same connection)
// TRAP 20: chunk() and modification issues
User::orderBy('id')->chunk(100, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
// If 'processed' was in ORDER BY, this could skip records!
// Use chunkById() instead for safe updates