Eloquent & Polymorphic Relations: Overview and Usage Guide
While I was working on an application for a client, I had to implement a new module that entails the following:
- Users ask for a budget quotation for a certain task.
- Every task has a location.
- Professionals can subscribe to different zones.
- A zone can be a region or a city.
Now, let’s neglect the core application and try to implement this single module to see what we can achieve here.
This article was peer reviewed by Christopher Vundi and Christopher Pitt. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!
Scaffolding Application
I assume you have your development environment already set up. If not, you can check this Homestead Improved Quick Tip or just use the official Homestead Box.
Go ahead and create a new Laravel project using the Laravel installer or via Composer.
laravel new demo
Or
composer create-project --prefer-dist laravel/laravel demo
Edit your .env
file with your database credentials.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
Creating Migrations
Before creating our migrations, we should talk about Eloquent polymorphic relations and how they can benefit us in this case!
Polymorphism is often used when an object can have different forms (shapes). In our case, professional users subscribe to different zones and they get notified whenever a new job is posted in this area.
Lets start by creating the zones
table.
php artisan make:model Zone --migration
This creates a migration file, but we do need to add a bit of code to it before it’s complete, as demonstrated by:
// database/migrations/2016_12_02_130436_create_zones_table.php
class CreateZonesTable extends Migration
{
public function up()
{
Schema::create('zones', function (Blueprint $table) {
$table->integer('user_id')->unsigned();
$table->integer('zone_id')->unsigned();
$table->string('zone_type');
});
}
public function down()
{
Schema::dropIfExists('zones');
}
}
Next, we create the cities and regions tables.
php artisan make:model Region --migration
// database/migrations/2016_12_02_130701_create_regions_table.php
class CreateRegionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('regions', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('regions');
}
}
php artisan make:model City --migration
// database/migrations/2016_12_02_130709_create_cities_table.php
class CreateCitiesTable extends Migration
{
public function up()
{
Schema::create('cities', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 255);
$table->integer('postal_code')->unsigned();
$table->integer('region_id')->unsigned()->nullable();
});
}
public function down()
{
Schema::drop('cities');
}
}
We could’ve achieved the same result by making a many to many relation with the cities and regions table. However, the zones table will act as an abstract class for the two other tables. The zone_id
will hold an id for a city or a region and using the zone_type
value Eloquent will decide which model instance to return.
Creating Models
Eloquent has the ability to guess the related table fields, but I decided not to use it just to explain how to map database fields to models. I will explain them as we go along!
// app/User.php
class User extends Authenticatable
{
// ...
public function cities()
{
return $this->morphedByMany(City::class, 'zone', 'zones', 'user_id', 'zone_id');
}
public function regions()
{
return $this->morphedByMany(Region::class, 'zone', 'zones', 'user_id', 'zone_id');
}
}
The morphedByMany
method is similar to the manyToMany
one. We specify the related model, the mapping field name (used for zone_type
and zone_id
), related table name, current model foreign key and the morphed relation key.
We could’ve automated this by letting Eloquent guess field names, if you take a look at the documentation you’ll see that we can name the fields as zoneable_id
and zoneable_type
, then only specify the mapped field name (return $this->morphedByMany(City::class, 'zoneable'
).
// app/Region.php
class Region extends Model
{
// ...
public function cities()
{
return $this->hasMany(City::class);
}
public function users()
{
return $this->morphMany(User::class, 'zones', 'zone_type', 'zone_id');
}
}
We can quickly guess the parameters definition from the above code. We specify the related table name, morphed relation type and ID.
// app/City.php
class City extends Model
{
// ...
public function users()
{
return $this->morphMany(User::class, 'zones', 'zone_type', 'zone_id');
}
}
Now that everything is set up, we can start testing our relations and see if everything is working as expected. We should first seed our tables to save some time. You can grab the database seeder’s code from the GitHub repository.
Using Relations
We can attach cities and regions to users using syncing, attaching, and detaching Eloquent methods. Here’s an example:
$user = App\User::find(1);
$user->cities()->sync(App\City::limit(3)->get());
This will attach three cities to the selected user, and we can do the same for regions.
$user = App\User::find(1);
$user->regions()->sync(App\Region::limit(3)->get());
If we inspect our database now to see what was saved, we can see the following:
mysql> select * from zones;
+---------+---------+------------+
| user_id | zone_id | zone_type |
+---------+---------+------------+
| 1 | 1 | App\City |
| 1 | 2 | App\City |
| 1 | 3 | App\City |
| 1 | 1 | App\Region |
| 1 | 2 | App\Region |
| 1 | 3 | App\Region |
+---------+---------+------------+
6 rows in set (0.00 sec)
We can also detach regions if they exist.
$user = App\User::find(1);
$user->regions()->detach(App\Region::limit(3)->get());
Now, we can fetch user cities and regions as we would do on a normal many to many relationship:
$user = App\User::find(1);
dd($user->regions, $user->cities);
We could add a new method called zones
which will merge cities from regions with individual selected cities.
class User extends Authenticatable
{
// ...
public function zones()
{
return $this->regions->pluck("cities")->flatten()->merge($this->cities);
}
}
The pluck method will get a collection of cities from each region, which be then flattened (merge all the collections into one) and merged with the user selected cities. You can read more about collections in the documentation, and if you want to learn more, I recommend this Refactoring to Collections book from Adam Wathan.
Conclusion
Even though polymorphic relations are rarely used in applications, Eloquent makes it’s easy to deal with related tables and returning the correct data. If you have any questions or comments about Eloquent or Laravel in general, you can post them below and I’ll do my best to answer them!