Web Frameworks [OGI06i]

05. Eloquent: the Next Level

05.1
What you already know

Recap: the Models

  • In app/Models you define ± 1 model (e.g. Smurf) per table (e.g. smurfs)
    • Having an empty model class means a set of assumptions apply on the table name, the PK, the timestamps, …
      ⟶ exceptions? override class fields, functions, …
    • 1 to 1 relationship: define by hasOne() and belongsTo()
    • 1 to many relationship: define by hasMany() and belongsTo()
    • many to many relationship: define by belongsToMany() at both sides
    • ⟶ exceptional FK column names? add parameters!

Recap: from Builder Object to results

  • Best Eloquent practices always start from the Eloquent model class!
    • Only few static methods directly cause a DB query yielding a result
      Smurf::all()     // returns an Eloquent collection of Smurf objects
      Smurf::find(666) // returns a Smurf object
      Smurf::count()   // returns an integer
    • Most static methods return a Builder object (you are just building a query!)
      Smurf::where('age', '>', 315)
      Smurf::select('id', 'age')
    • On a builder object you can chain another Builder method, returning the very same Builder object
      $smurfs = Smurf::where('age', '>', 315)->where('color', 'blue')->select('id', 'age')->orderBy('age', 'desc');
    • Following methods transform a Builder object into a result (executing a DB query)
      $smurfs->get()        // returns an Eloquent collection of Smurf objects
      $smurfs->count()      // returns an integer
      $smurfs->first()      // returns a Smurf object
      $smurfs->find(666)    // returns a Smurf object
      $smurfs->value('age') // returns a value
      $smurfs->pluck('age') // returns a plain old array

Recap: Eloquent Collections

  • Don't get confused !
    • On Eloquent collections you can chain a big number of collection methods (yes! some of them have the same name as …)
      $smurfs->get()->count()                 // Better: $smurfs->count()
      $smurfs->get()->where('age', '>', 320)  // Better: fix this in the Builder part
    • An Eloquent collection extends a collection in the sense that it has captured which field was the PK
      Smurf::all()->find(666)        // Better: Smurf::find(666)
      $smurfs->get()->contains(88);  // Better: $smurfs->where('id', 88)->exists();
    • A relation can be plugged on an Eloquent object in 2 ways:
      Smurf::find(666)->friends   // executes a query and returns an Eloquent collection
      Smurf::find(666)->friends() // only returns a Builder object ...

Fun fact: Comparing Model Instances

  • Determine if 2 model instances are the "same" = they have the same PK entry and are from the same table and database connection
if ($post->is($anotherPost)) {
    // ...
}

if ($post->isNot($anotherPost)) {
    // ...
}

if ($post->author()->is($user)) {
    // ...
}

05.2
Eager loading

Eager Loading

  • By default, related models are lazy loaded
  • When looping over a set objects and accessing their relationships as properties might generate a high number of DB queries:
    $books = Book::all(); // collection of 25 books
    
    foreach ($books as $book) {
        echo $book->author->name; // generating a DB query each time
    }
  • The method with() supports eager loading and adds a question to prefetch the related model objects in the Builder object:
    $books = Book::with('author')->get(); // generates 2 queries
    
    foreach ($books as $book) {
        echo $book->author->name; // no DB query
    }

Let's have a look …

$lazyProduct = Product::find(1);
$eagerProduct = Product::with('brand')->find(1);
dump($lazyProduct);  // internal relations field empty
dump($eagerProduct); // internal relations field with 'brand'

// no difference
dump($lazyProduct->brand->name);
dump($eagerProduct->brand->name);
                 

Specifying Relationships and Columns

  • Multiple relationships:
    Book::with(['author', 'publisher'])->get();
  • Nested relationships:
    Book::with('author.contacts')->get();
    Book::with(['author' => ['contacts', 'publisher']])->get();
  • Constraining columns:
    Book::with('author:id,name')->get(); // include internally required PK/FK columns 
  • Constraining (and/or customizing) eager loads:
    use App\Models\Book;
    use Illuminate\Contracts\Database\Eloquent\Builder;
    
    // you do not need to to this when the benefit is insignificant
    $books = Book::with(['reviews' => function (Builder $query) {
        $query->where('rating', '>', 4.0)->orderBy('created_at', 'asc');
    }])->get();
    
    // not to be confused with withWhereHas, only querying the books with rating > 4.0
    // withWhereHas = whereHas + with
    $books = Book::withWhereHas(['reviews' => function (Builder $query) {
        $query->where('rating', '>', 4.0);
    }])->get();

Exercise with Blade

  • In ProductController,
    Product::all() is passed to a product overview Blade template
  • This template loops over these products and,
    for each $product, it shows
    {{ $product->id }} {{ $product->name }} {{ $product->price }}
    {{ $product->brand->name }}
    {{ $product->categories->pluck('name')->implode(' | ') }}
  • How can we optimize Product::all()?
Product::select(['id', 'name', 'price', 'brand_id'])->with(['brand:id,name', 'categories:id,name'])->get()
                            

Eager Loading by Default

  • Add to your protected $with to the model class
    class Book extends Model
    {
        /**
         * The relationships that should always be loaded.
         *
         * @var array
         */
        protected $with = ['author'];
    
        /**
         * Get the author that wrote the book.
         */
        public function author(): BelongsTo
        {
            return $this->belongsTo(Author::class);
        }
    }
  • Do you want to undo/override eager loading defaults for a single query?
    $books = Book::without('author')->get();
    $books = Book::withOnly('genre')->get();

Lazy Eager Loading

  • Perform an eager (re)load after the model has been retrieved
    $books = Book::all();
    
    if ($someCondition) {
        $books->load('author', 'publisher');
    }
    
    if ($someOtherCondition) {
        $books->loadMissing('author'); // only when not already loaded
    }

A more structural approach?

  • As discussed, eager loading often provides significant performance benefits.
    Two more structural strategies to consider:
    • For eager students😉: prevent lazy loading in AppServiceProvider
      use Illuminate\Database\Eloquent\Model;
      
      public function boot(): void
      {
          Model::preventLazyLoading(! $this->app->isProduction());
      }
      This will throw a LazyLoadingViolationException when a lazy load is attempted.
      Next, test and fix your code.
    • For lazy students😉: enable automatic lazy eager loading in AppServiceProvider
      public function boot(): void
      {
          Model::automaticallyEagerLoadRelationships();
      }
      This is a beta feature in Laravel 12 !
      Example: when you access $book->author for the first time, Laravel will automatically eager load the author relationship for all books in the collection, so that access to $book->author (on another $book of the collection) will not trigger a DB query.
      Or, apply this feature only on a single Eloquent collection: $books->withRelationshipAutoloading();

05.3
Query Scopes

Local Query Scopes

  • Local scopes enable the reuse of frequently used query Builder functions (constraints)
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    #[Scope]
    public function active(Builder $query): void
    {
        return $query->where('active', 1);
    }

    #[Scope]
    public function ofType(Builder $query, string $type): void
    {
        return $query->where('type', $type);
    }
}
use App\Models\User;

$users = User::ofType('admin')->active()->orderBy('created_at')->get();

$users = User::ofType('admin')->orWhere->active()->get(); // orWhere specifically for scopes 
The use of the #[Scope] attribute is new. Until Laravel 11, you needed to prefix the scope method like scopeActive() (still supported).
Note that PHP attributes can be read through the Reflection API

Global Query Scopes

  • Use Global scopes if you want to apply a query Builder function on all queries for one or more models
  • Generate a new global scope class into the app/Models/Scopes directory:
    php artisan make:scope AncientScope
  • Implement the apply() method of the Scope interface:
    use Illuminate\Database\Eloquent\Builder;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Scope;
    
    class AncientScope implements Scope
    {
        public function apply(Builder $builder, Model $model): void
        {
            $builder->where('created_at', '<', now()->minus(years: 2000));
        }
    }
  • Apply the global scope to a model by using the #[ScopedBy] attribute
    use App\Models\Scopes\AncientScope;
    use Illuminate\Database\Eloquent\Attributes\ScopedBy;
    
    #[ScopedBy([AncientScope::class])]
    class Book extends Model
  • From now on, a query like Book::all() will only return books created before 2000 years ago.

05.4
Mutators & Casting

Accessors & Mutators (1)

  • 2 applications (docs)
    • Override default attribute behaviour (such as $user->first_name), typically for data transformation
    • Define custom attributes (such as $user->full_name) while there is no such column or relation
  • How? Define in your model class a protected function attributeName() returning an instance of Illuminate\Database\Eloquent\Casts\Attribute
    use Illuminate\Database\Eloquent\Casts\Attribute;
    use Illuminate\Database\Eloquent\Model;
    
    class User extends Model
    {
        protected function firstName(): Attribute
        {
            return Attribute::make(
                get: fn ($value) => ucfirst($value),
                set: fn ($value) => strtolower($value),
            );
        }
    
        protected function fullName(): Attribute
        {
            return Attribute::make(
                get: fn ($value, $attributes) => ($attributes['first_name'] . ' ' . $attributes['last_name']),
            );
        }
    }
  • Attention: function firstName() in camel case but attribute call first_name in snake case

Accessors & Mutators (2)

  • Usage
$user = User::find(1);
$firstName = $user->first_name;
$user->first_name = 'Bart';
echo($user->full_name);
$user->save();

Accessors & Mutators (3)

  • Transform multiple attributes into a "value object"
    • First, create a helper class by php artisan make:class Support/Address and write the constructor, getters and setters …
use App\Support\Address;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected function address(): Attribute
    {
        return Attribute::make(
            get: fn (mixed $value, array $attributes) => new Address(
                $attributes['address_line_one'],
                $attributes['address_line_two'],
            ),
            set: fn (Address $value) => [ // return array with database column keys
                'address_line_one' => $value->lineOne,
                'address_line_two' => $value->lineTwo,
            ],
        );
    }
}

Remark: Accessor Caching

  • When an accessor returns an object (example)
    • same object instance is returned each time the accessor(->) is invoked
    • any changes to the object are synced back to the model
      $user = User::find(1);
      $address = $user->address;
      $address->zip = 9000; // changes the zip of address inside $user
      $user->save(); // updates zip
    • undo this behaviour by Attribute::make()->withoutObjectCaching();
  • When an accessor returns a primitive value (string, int, …)
    • any changes to the returned value are not synced back to the model
      $user = User::find(1);
      $firstName = $user->first_name;
      $firstName = 'Sven';
      $user->save(); // unchanged
    • enable syncing by Attribute::make()->shouldCache();

Casting

  • You don't need accessors & mutators to implement auto-casting of attributes
  • Check the documentation for the supported cast types
class User extends Model
{
    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'is_admin' => 'boolean',
            'options' => 'array', // handy when this column contains JSON
            'date_of_birth' => 'datetime:Y-m-d',
            // auto-convert to Carbon objects (date format is only for serialization)
            // created_at and updated_at are already known as datetime
            'directory' => AsStringable::class, // cast to fluent string
        ];
    }
}
Remark: when your database column is in JSON, the cast type 'array' will not automatically sync array manipulation back to the model. $user->options['key'] = $value; will trigger a PHP error; $user->update(['options->key' => 'value']); will work, but requires 'options->enabled' in $fillable. Better use the cast type AsArrayObject::class or AsCollection::class in stead.

Questions?

Sources