# Eloquent models (part 2)
- 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
$hasMany
and$belongsTo
relations between the database tables
- In this chapter, you will get an introduction to:
- accessors and mutators
- hide attributes from the database table
- add additional properties
- query scopes
- For these examples, we work in the controller app/Http/Controllers/Admin/RecordController.php and the related view resources/views/admin/records/index.blade.php
do this first
- Before starting this chapter, make sure you have configured the debug middleware
# Genres table
- Let's start with querying the smallest table genres
# Get all genres
- Replace the code in the index method of the controller
- Display the resulting record set (or collection (opens new window)) in a browser
RecordController.php
result
- Get all records from the genres table and return this as JSON
public function index() { $genres = Genre::get(); return $genres; }
Copied!
1
2
3
4
5
2
3
4
5
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
- By doing so, you do not have to add the import statement (
use App\Models\Genre;
) manually
# 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)
RecordController.php
result
- Update the query and order the genres by the
name
attribute
public function index() { $genres = Genre::orderBy('name')->get(); return $genres; }
Copied!
1
2
3
4
5
2
3
4
5
# 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
App/Models/Genre.php
result
class Genre extends Model { ... /** Add attributes that should be hidden to the $hidden array */ protected $hidden = ['created_at', 'updated_at']; }
Copied!
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
RecordController.php
result
public function index() { $genres = Genre::orderBy('name')->get(); $genres->makeVisible('created_at'); return $genres; }
Copied!
1
2
3
4
5
6
2
3
4
5
6
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 retrieved from database
- A mutator transform the attribute before it is sent to database
- In our genres table we want:
- 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!
App/Models/Genre.php
RecordController.php
result
- Accessor (
get
): capitalize the first letter of the value with the PHP function ucfirst (opens new window) - Mutator (
set
): make the value lowercase with the PHP function strtolower (opens new window)
class Genre extends Model { ... /** 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 ); } ... }
Copied!
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
# Genre with records
- The model Genre has a relation with the model Record
- 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
- Also update the view to show all the genres with the records
TIP
Open a second browser tab and go to http://vinyl_shop.test/admin/records?json (opens new window) to see the JSON representation of the data
RecordController.php
resources/views/admin/records/index.blade.php
result
- For future purposes, we pass the data collection to the view by wrapping it into an associative array using the PHP function
compact()
compact('records')
is the same as['records' => $records]
like we did in basic controllers
public function index() { $genres = Genre::orderBy('name')->with('records')->get(); return view('admin.records.index', compact('genres')); }
Copied!
1
2
3
4
5
2
3
4
5
# 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
RecordController.php
result
public function index() { $genres = Genre::orderBy('name')->with('records')->has('records')->get(); return view('admin.records.index', compact('genres')); }
Copied!
1
2
3
4
5
2
3
4
5
# Records table
# Get all records with genre
- The model Record has a relation with the model Genre
- 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 data at http://vinyl_shop.test/admin/records?json (opens new window)
RecordController.php
result
- Line 3: 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 5: add
records
to thecompact()
function
public function index() { $records = Record::orderBy('artist')->orderBy('title')->with('genre')->get(); $genres = Genre::orderBy('name')->with('records')->has('records')->get(); return view('admin.records.index', compact('records','genres')); }
Copied!
1
2
3
4
5
6
2
3
4
5
6
# 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
App/Models/Record.php
RecordController.php
result
- 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
attribute
Genre::find(12)->name
=Noise
- Next, append the accessor in snake_case to the
$appends
array
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) => Genre::find($attributes['genre_id'])->name, ); } protected $appends = ['genre_name']; ... }
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Additional attribute (price_euro)
- The second additional attribute we're going to add is the price, formatted with 2 decimals and the Euro symbol (€)
App/Models/Record.php
result
- The accessor
priceEuro
is a Euro symbol followed by the price formatted with 2 decimals (opens new window) - Next, append the accessor in snake_case to the
$appends
array
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) => '€ ' . number_format($attributes['price'],2), ); } protected $appends = ['genre_name', 'price_euro']; ... }
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 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 System (opens new window) 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
)
App/Models/Record.php
result
- Line 13: search in the public disk (= public folder) for the file with the path
/storage/covers/{$attributes['mb_id']}.jpg
- Line 14 - 17: if the file exists, return an associative array with:
exists
:true
url
: the path to the file
- Line 14 - 17: if the file doesn't exist, return an associative array with:
exists
:false
url
: the path to the dummy cover
- Next, append the accessor to the
$appends
array
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 (Storage::disk('public')->exists('covers/' . $attributes['mb_id'] . '.jpg')) { return [ 'exists' => true, 'url' => Storage::url('covers/' . $attributes['mb_id'] . '.jpg'), ]; } else { return [ 'exists' => false, 'url' => Storage::url('covers/no-cover.png'), ]; } }, ); } protected $appends = ['genre_name', 'price_euro', 'cover']; ... }
Copied!
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
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
Storage import
Storage
(opens new window) 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['url']
,title
,artist
,genre_name
,price_euro
, andstock
resources/views/admin/records/index.blade.php
result
- Line 7: loop over all the records
@foreach ($records as $record) ... @endforeach
- Line 10: show the cover for this record
$record->cover['url']
(remember thecover
attribute is an associative array with the keysexists
andurl
) - Line 13: show the artist of the record
$record->artist
- Line 14: show the title of the record
$record->title
- Line 15: show the genre name
$record->genre_name
- Line 16: show the formatted price in euro
$record->price_euro
- Line 17 - 21:
- 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:
<x-vinylshop-layout> <x-slot name="description">Admin records</x-slot> <x-slot name="title">Records</x-slot> <h2>Records with a price ≤ € 20</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['url'] }}" 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 <p></p> </div> </div> @endforeach </div> <h2>Genres with records</h2> ... </x-vinylshop-layout>
Copied!
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
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
# 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
- first, we filter the records inside the controller
- then we refactor the controller and move the filter to the model
RecordController.php
result
- Line 3: set the price limit to € 20
$maxPrice = 20;
- Line 4: limit the result to only records up to 20 euro
where('price', '<=', $maxPrice)
public function index() { $maxPrice = 20; $records = Record::where('price', '<=', $maxPrice) ->orderBy('artist') ->orderBy('title') ->get(); $genres = Genre::orderBy('name')->with('records')->has('records')->get(); return view('admin.records.index', compact('records','genres')); }
Copied!
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
- 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
App/Models/Record.php
RecordController.php
result
class Record extends Model { ... /** Apply the scope to a given Eloquent query builder */ public function scopeMaxPrice($query, $price = 100) { return $query->where('price', '<=', $price); } ... }
Copied!
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
# 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 (opens new window) 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. 6) - Add a pagination navigation to the view (e.g. one before and another one after the loop) using
$records->withQueryString()->links()
RecordController.php
resources/views/admin/records/index.blade.php
result
- Line 5: set the number of records to display per page
$perPage = 6;
- Line 9: replace
get()
withpaginate($perPage)
public function index() { $maxPrice = 20; $perPage = 6; $records = Record::where('price', '<=', $maxPrice) ->orderBy('artist') ->orderBy('title') ->paginate($perPage); $genres = Genre::orderBy('name')->with('records')->has('records')->get(); return view('admin.records.index', compact('records','genres')); }
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
REMARKS
- By default, the views rendered to display the pagination links are styled with Tailwind, but of course you can customize the pagination view (opens new window)
- Laravel adds some extra attributes (
current_page
,first_page_url
, ...) that eventually can be used inside the view
# 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
Genres model
Record model
User model
- Run the command
php artisan model:show Genre
in the console
# Exercise
- Make the background color of the card red if the record is out of stock