Appearance
Eloquent models: part 2
PODCAST
- In previous chapters, we discussed the basics about models:
- we created a model for each database table
- used the
$fillable
or$guarded
properties to prevent mass assignment vulnerability - used the
$this->hasMany()
and$this->belongsTo()->withDefault()
relations between the database tables
- In this chapter, you will get an introduction to:
- accessors and mutators
- hide attributes in the JSON representation
- add additional properties
- query scopes
- For these example, we create our first Livewire component to display some data in the browser
DO THIS FIRST
Before starting this chapter, make sure you have configured the Debug Livewire chapter
REMARK
- Go to the menu Laravel -> Generate Helper Code to (re)generate the helper file that PhpStorm understands, so it can provide accurate autocompletion for the models
- Run this command every time you add/remove something in a model
Preparation
Create a Livewire component
- Make a new Livewire component with the terminal command
php artisan livewire:make Demo
(orphp artisan make:livewire Demo
) - This command creates two files:
- app/Livewire/Demo.php (the component class contains the logic that was previously in the controller)
- resources/views/livewire/demo.blade.php (the view)
- Open both files and look at the content:
- This is a basic skeleton of a Livewire component with only the
render()
method- The
render()
method is one of the so-called Lifecycle Hooks that will be executed every time the component is rendered for the first time AND when something in the component is updated (e.g. a public property has changed) - For now, it only returns the resources/views/livewire/demo.blade.php view
- The
php
<?php
namespace App\Livewire;
use Livewire\Component;
class Demo extends Component
{
public function render()
{
return view('livewire.demo');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
Update the route
- For the purpose of this demo example, we use the admin.records route to display the Livewire component
- Open web.php
- Line 11: delete the old route to the basic controller
- Line 10: add a new get-route where the second argument is the name of the Livewire component
- Line 3: import the Livewire component with
use App\Livewire\Demo;
php
<?php
use App\Livewire\Demo;
use Illuminate\Support\Facades\Route;
...
Route::prefix('admin')->name('admin.')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('records', Demo::class)->name('records');
Route::get('records', function () { ... })->name('records');
});
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
TIP
To import the Livewire component, alt
+ Enter
on the class name and choose Import class -> use App\Livewire\Demo
Link the Livewire component to our layout
- Liveware uses a lot of PHP 8 attributes to add metadata (like for example a reference to the correct view) to the component
- All we have to do is add the
Layout
attribute just above the render method and point to the correct layout file
- Line 10: add the
Layout
attribute with the name of the layout file - Line 5: import the
Layout
attribute withuse Livewire\Attributes\Layout;
(orAlt + Enter
on the attribute name and choose Import class
php
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Component;
class Demo extends Component
{
#[Layout('layouts.vinylshop')]
public function render()
{
return view('livewire.demo');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- The layout is ok, but the the
title
,description
andsubtitle
slots in our layout are still the default values - The Layout attribute accepts a second parameter as an associative array with the slot names as keys and the slot values as values
- Line 4 - 6: add some values to the 3 slots in the layout
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [
'title' => 'Eloquent models',
'subtitle' => 'Eloquent models: part 2',
'description' => 'Eloquent models: part 2',
])]
public function render()
{
return view('livewire.demo');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Genres table
- Let's start with querying the smallest table genres
Get all genres
- Display the resulting record set (or collection) in a browser
- Get all records from the genres table and return this to the view
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$genres = Genre::get();
return view('livewire.demo', compact('genres'));
}
}
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
REMARK
The same result (retrieving all the genres) can be achieved with $genres = Genre::all()
TIP
- Use the autocompletion of PhpStorm for automatic imports
- Start typing 'Gen..'
- Choose the proper class:
Genre [App\Models]
- Push
Enter
Order genres
- Laravel's ORM allows chaining different Eloquent methods to fine-tune the query
- One of the methods you probably always use is
orderBy()
(a -> z) ororderByDesc()
(z -> a)
- Update the query and order the genres by the
name
attribute
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$genres = Genre::orderBy('name')->get();
return view('livewire.demo', compact('genres'));
}
}
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Hide attributes
- If we don't need the
created_at
andupdated_at
attributes in our JSON representation, we can hide them by adding these fields to the$hidden
array in the Genre model
php
class Genre extends Model
{
...
// Add attributes to be hidden to the $hidden array
protected $hidden = ['created_at', 'updated_at'];
}
1
2
3
4
5
6
7
2
3
4
5
6
7
- What if you want to hide these attributes for almost every query except for this one?
- No problem, you can make hidden attributes back visible inside the controller by chaining the
makeVisible()
methode AFTER you get all the genres
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$genres = Genre::orderBy('name')->get();
$genres->makeVisible('created_at');
return view('livewire.demo', compact('genres'));
}
}
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
TIP
If you want an attribute to be visible in most of your queries, except for one, leave the $hidden
array in the model empty and chain the makeHidden()
method inside the controller.
E.g. $genres = Genre::orderBy('name')->get()->makeHidden(['created_at', 'updated_at']);
REMARK
$hidden
andmakeHidden()
only hides attributes from the JSON representation but you can still use the hidden attribute in a view!
Accessors and mutators
- An accessor transform the attribute after it has been retrieved from the database
- A mutator transforms the attribute before it is sent to database
- In our genres table we want to:
- always store the name in lowercase letter (= mutator)
- always show the name with the first letter in uppercase (= accessor)
- IMPORTANT:
- The name of the method MUST BE the name of the attribute you want to manipulate!
- Don't forget to import the
Attribute
class from theIlluminate\Database\Eloquent\Casts
namespace
- Accessor (
get
): capitalize the first letter of the value with the PHP function ucfirst - Mutator (
set
): make the value lowercase with the PHP function strtolower
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
...
class Genre extends Model
{
use HasFactory;
...
// Accessors and mutators (method name is the attribute name)
protected function name(): Attribute
{
return Attribute::make(
get: fn($value) => ucfirst($value), // accessor
set: fn($value) => strtolower($value), // mutator
);
}
// Add attributes to be hidden to the $hidden array
protected $hidden = ['created_at', 'updated_at'];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Genre with records
- The model Genre has a relation with the model Record (see Genre model):php
// Relationship between models public function records() { return $this->hasMany(Record::class); }
1
2
3
4
5 - You can include the records that belong to a genre with the
with()
method- The
with()
method takes a relation name as argument:with('records')
- The
- This time we also add some logic to the view to display the genres with their records
- Add
with('records')
to the query
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$genres = Genre::orderBy('name')
->with('records')
->get();
return view('livewire.demo', compact('genres'));
}
}
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Genres that has records
- Most of the genres have no records
- Use the
has()
method to filter on only the genres that have records- The
has()
method takes a relation name as argument:has('records')
- The
- Add
has('records')
to the query
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$genres = Genre::orderBy('name')
->with('records')
->has('records')
->get();
return view('livewire.demo', compact('genres'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Records table
Get all records with genre
- The model Record has a relation with the model Genre:php
// Relationship between models public function genre() { return $this->belongsTo(Genre::class)->withDefault(); // a record belongs to a "genre" }
1
2
3
4
5 - You can include the genre of a record with the
with()
method- The
with()
method takes a relation name as argument:with('genre')
- The
- Before we update the view, let us first inspect the JSON representation of the records
- Line 6 - 9: get all records with the genre, ordered first by the artist and secondly by the title
(if an artist has multiple records, the records for this artist are ordered by the title) - Line 14: add
records
to thecompact()
function
php
class Demo extends Component
{
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->with('genre')
->get();
$genres = Genre::orderBy('name')
->with('records')
->has('records')
->get();
return view('livewire.demo', compact('records', 'genres'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Additional attribute (genre_name)
- In the view, we can select the genre name with
$record->genre->name
and that's fine but won't it be easier to add the genre name as an additional attribute to the record? - To add additional attributes to the JSON representation of the model
- First, define an accessor for the attribute you want to add
(The name of the accessor must be different from the attribute names in the database table) - Then, add the attribute to the
$appends
array in the model
- First, define an accessor for the attribute you want to add
- The accessor
genreName
searches in the Genre table for the record withid
equal togenre_id
from the record and gets thename
attributeGenre::find($attributes['genre_id'])->name
E.g. the record Atari Teenage Riot - The Future of War has thegenre_id
of12
, so the accessor searches in the Genre table for the record withid=12
and gets thename
attributeGenre::find(12)->name
=Noise
- Next, append the accessor in snake_case to the
$appends
array
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
...
class Record extends Model
{
...
// Add additional attributes that do not have a corresponding column in your database
protected function genreName(): Attribute
{
return Attribute::make(
get: fn($value, $attributes) => isset($attributes['genre_id'])
? Genre::find($attributes['genre_id'])->name : null,
);
}
protected $appends = ['genre_name'];
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Additional attribute (price_euro)
- The second additional attribute we're going to add is the price, formatted with 2 decimals and the Euro symbol (€)
- The accessor
priceEuro
is a Euro symbol followed by the price formatted with 2 decimals - Next, append the accessor in snake_case to the
$appends
array
php
class Record extends Model
{
...
// Add additional attributes that do not have a corresponding column in your database
protected function genreName(): Attribute {...}
protected function priceEuro(): Attribute
{
return Attribute::make(
get: fn($value, $attributes) => isset($attributes['price'])
? '€ ' . number_format($attributes['price'], 2) : null,
);
}
protected $appends = ['genre_name', 'price_euro'];
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Additional attribute (cover)
- As you may have noticed, there is no cover column in the
records
table - The image refers to the record's
mb_id
attribute and can be found in thecovers
folder.
E.g. if themb_id
value isfcba15e2-3d1e-40b3-996c-be22450bda82
, the path to the cover is/storage/covers/fcba15e2-3d1e-40b3-996c-be22450bda82.jpg
- We use Laravels File Storage to:
- check if the file exists
- if the file exists, return the path to the file
- else return the path to the dummy cover (
no-cover.png
)
- Line 21: create a variable for the path, based on the
mb_id
attribute - Line 22: search in the
public
disk (= public folder) for the file with the cover path- Line 23: if the file exists, set the
cover
attribute to the URL of the file - Line 24: else set the
cover
attribute to the URL of the dummy cover
- Line 23: if the file exists, set the
- Line 29: append the accessor to the
$appends
array
php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Storage;
class Record extends Model
{
...
// Add additional attributes that do not have a corresponding column in your database
protected function genreName(): Attribute {...}
protected function priceEuro(): Attribute {...}
protected function cover(): Attribute
{
return Attribute::make(
get: function ($value, $attributes) {
if (!isset($attributes['mb_id'])) return null;
$coverPath = 'covers/' . $attributes['mb_id'] . '.jpg';
return (Storage::disk('public')->exists($coverPath))
? Storage::url($coverPath)
: Storage::url('covers/no-cover.png');
},
);
}
protected $appends = ['genre_name', 'price_euro', 'cover'];
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Storage import
Storage
is a Facade class from Laravel- Don't forget to add the correct import:
use Illuminate\Support\Facades\Storage;
oruse Storage;
Update the view
- Add, for every record, a card with the fields:
cover
,title
,artist
,genre_name
,price_euro
, andstock
- Line 7 - 23: loop over all the records
@foreach ($records as $record) ... @endforeach
- Line 8: show the cover for this record
$record->cover
- Line 11: show the artist of the record
$record->artist
- Line 12: show the title of the record
$record->title
- Line 13: show the genre name
$record->genre_name
- Line 14: show the formatted price in euro
$record->price_euro
- Line 15 - 19:
- if there are records in stock, show the stock:
$genre->stock
- else show the message
'SOLD OUT'
- if there are records in stock, show the stock:
php
<div>
<h2>Records</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-8">
@foreach ($records as $record)
<div class="flex space-x-4 bg-white shadow-md rounded-lg p-4 ">
<div class="inline flex-none w-48">
<img src="{{ $record->cover }}" alt="">
</div>
<div class="flex-1 relative">
<p class="text-lg font-medium">{{ $record->artist }}</p>
<p class="italic text-right pb-2 mb-2 border-b border-gray-300">{{ $record->title }}</p>
<p>{{ $record->genre_name }}</p>
<p>Price: {{ $record->price_euro }}</p>
@if($record->stock > 0)
<p>Stock: {{ $record->stock }}</p>
@else
<p class="absolute bottom-4 right-0 -rotate-12 font-bold text-red-500">SOLD OUT</p>
@endif
</div>
</div>
@endforeach
</div>
<h2>Genres with records</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@foreach ($genres as $genre)
...
@endforeach
</div>
<x-tmk.livewire-log :records="$records" />
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
TIP
- The code for in stock or sold out is very readable and easy to understand
- If you want a more compact code, you can use this instead 😃:
php
<p class="{{ $record->stock > 0 ? '' : 'absolute bottom-4 right-0 -rotate-12 font-bold text-red-500' }}">
{{ $record->stock > 0 ? 'Stock: '.$record->stock : 'SOLD OUT' }}
</p>
1
2
3
2
3
Query scope (price ≤ €20)
- Sometimes you want to filter the results of a query by a certain attribute value
- This can be done with the
where
method inside the controller, but you can also do it in the model with local or global query scopes, so you don't have to write the same code twice - Local scopes allow you to define common sets of query constraints that you may easily re-use throughout your application, e.g. to filter out all users with the admin role
- Let's create a filter for the records with a price less or equal to €20 (no worries, we make this dynamic in a later chapter)
- first, we filter the records inside the controller
- then we refactor the controller and move the filter to the model
- Line 3: add a public property and set the price limit to €20
$maxPrice = 20;
- Line 10: limit the result to only records up to 20 euro
where('price', '<=', $this->maxPrice)
php
class Demo extends Component
{
public $maxPrice = 20;
#[Layout('layouts.vinylshop', [ ... ])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->where('price', '<=', $this->maxPrice)
->get();
$genres = Genre::orderBy('name')->with('records')->has('records')->get();
return view('livewire.demo', compact('records', 'genres'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- Let's create a global scope to filter the records with a price less or equal to
$maxPrice
- first, we create the scope
- then we add it to the model
- Local scopes always start with the prefix
scope
, followed by the name of the scope- e.g. the scope
scopeMaxPrice($query, $price = 100)
can be used inside the controller as:maxPrice($maxPrice)
maxPrice()
(if the default value of100
is used)
- the first parameter in the function is always the query
- the second parameter (optional) is the value or values to filter by
- e.g. the scope
php
class Record extends Model
{
...
// Apply the scope to a given Eloquent query builder
public function scopeMaxPrice($query, $price = 100)
{
return $query->where('price', '<=', $price);
}
...
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
IMPORTANT
- Open the menu Laravel > Generate Helper Code to add the new scope for type hinting and auto-completion in PhpStorm
- Repeat this step for every new scope you create
- IMPORTANT: select
maxPrice
and NOTscopeMaxPrice
!
Pagination
- Because the list of records can be very long, it's better to show only a fraction of the records and add some pagination to the views
- You can limit the number of records per page by replacing
get()
withpaginate(x)
, wherex
is the number of records per view (e.g.4
) and adding theWithPaginate
trait to the controller - Add a pagination navigation to the view (e.g. one before and another one after the loop) using
$records->links()
- Line 6: add
use Livewire\WithPagination;
- Line 10: add the
use WithPagination;
trail - Line 13: set the pagination limit to 4
$perPage = 4;
- Line 20: remove the maxPrice filter
- Line 21 and 22: replace
get();
withpaginate($this->perPage);
php
<?php
namespace App\Livewire;
...
use Livewire\WithPagination;
class Demo extends Component
{
use WithPagination;
public $maxPrice = 20;
public $perPage = 4;
#[Layout('layouts.vinylshop', [
{
$maxPrice = 20;
$records = Record::orderBy('artist')
->orderBy('title')
// ->maxPrice($maxPrice)
// ->get();
->paginate($this->perPage);
$genres = Genre::orderBy('name')->with('records')->has('records')->get();
return view('livewire.demo', compact('records', 'genres'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Fast model overview
- Now that we added some extra attributes to the model, it's sometimes hard to remember all those attributes and the relations to other models
- The artisan CLI has the great command
php artisan model:show <Model>
to generate a compact overview with a lot of information about the model (table name, fillable attributes, appended attributes, relations to other models, ...)
Inspecting database information requires the Doctrine DBAL (doctrine/dbal) package. Would you like to install it?
- The first time you execute this artisan command, it might ask you to install the
Doctrine DBAL
package. - Choose
y
and press enter to continue - Re-execute the
php artisan model:show <Model>
command
- Run the command
php artisan model:show Genre
in the console
Exercise 1
- Set the price limit to € 100
- Show 8 records per page
- Make the background color of the card red if the record is out of stock
Exercise 2
- Create a new attribute
listenUrl
in theRecord
model that returns the URL where you can listen to the record- The URL is
https://listenbrainz.org/player/release/xxxxxx
wherexxxxxx
is themb_id
of the record
- The URL is
- Add a red MuicBrainz logo below the stock message.
The link on this logo should open thelistenUrl
in a new tab