HardТеория9 min

Подвохи: Eloquent ORM

Каверзные вопросы по relationships, eager/lazy loading, query scopes, mass assignment, soft deletes, events

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

Проверь себя

Чем fresh() отличается от refresh()?

Что вернёт `Post::with(['comments' => fn($q) => $q->where('approved', true)])->get()` для постов без одобренных комментариев?

Что произойдёт, если из события creating вернуть false?

Какие операции НЕ вызывают Model Events?

Если is_admin НЕ в $fillable, можно ли установить его через `$user->is_admin = true; $user->save();`?