Let's take a look at another side of the user system: profile fields. Some of the fields are in this screenshot:
The main question is where do we store them and how to structure the tables, for multiple user roles?
In other words, should the property owner profile fields be in the same table as simple user fields, or do we need separate tables?
By the end of this lesson, we will have this DB structure:
After browsing through the booking.com website, mobile app, and help docs, I've identified these profile fields for each user role.
Notice: we don't really need any profile fields for administrators, as they are not visible anywhere on the app/website.
Common fields for both users/owners:
Simple users have these extra "individual" fields:
Then, property owners don't have to specify gender/nationality/birth date, but they have only one "special" extra field: description about themselves.
Yeah, it's pretty simple. Property owners have only one personal field, everything else is related to their properties, which we will cover in the upcoming lessons.
So, let's try to think: all the common fields should probably be in the users
DB table, just some of them nullable?
Ok, let's try to create that. First, we clearly need the migration for the countries
table:
php artisan make:model Country -m
Migration:
Schema::create('countries', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();});
php artisan make:migration add_profile_fields_to_users_table
Schema::table('users', function (Blueprint $table) { $table->string('display_name')->nullable(); $table->string('phone_number')->nullable(); $table->timestamp('phone_verified_at')->nullable(); $table->string('photo')->nullable(); $table->string('invoice_address')->nullable(); $table->string('invoice_postcode')->nullable(); $table->string('invoice_city')->nullable(); $table->foreignId('invoice_country_id')->nullable() ->constrained('countries');});
But, hmm, isn't the users
table becoming too big, then? Should ALL of those fields be here? How often would we actually use them to query the data?
And this is where we come back to the word "simulation" again. Let's simulate what queries would there be, and what fields would actually be selected?
For viewing a user's profile somewhere, we may need display_name
and photo
- those should be pretty common.
But what about the data for the invoices? Isn't it used ONLY for invoices? And how often do we generate the invoices, once a month? Or maybe even less than that, if the user is not actively paying for anything.
So we can leave the invoice fields in the users table, but then we're risking that some other developer would blindly do User::all()
or a similar statement, without specifying the fields, and then it would unnecessarily download all those fields into memory.
So, what I would suggest is to create a separate DB table "user_profiles" with more rarely used fields. For now, we will put only invoice-related fields there but later may add more.
So, if we change the migration above, my suggestion is this:
php artisan make:model UserProfile -m
app/Models/User.php:
class User extends Authenticatable{ public function profile() { return $this->hasOne(UserProfile::class); }}
Migration for Users:
Schema::table('users', function (Blueprint $table) { $table->string('display_name')->nullable(); $table->string('phone_number')->nullable(); $table->timestamp('phone_verified_at')->nullable(); $table->string('photo')->nullable();});
Migration for User Profiles:
Schema::create('user_profiles', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained(); $table->string('invoice_address')->nullable(); $table->string('invoice_postcode')->nullable(); $table->string('invoice_city')->nullable(); $table->foreignId('invoice_country_id')->nullable()->constrained('countries'); $table->timestamps();});
Now, the User::all()
in the code would return only the main User fields, and if someone wants to know the invoice details, they would do User::with('profile')->get()
.
Another open discussion could be around the phone
and phone_verified_at
fields: should they be in users
or user_profiles
? It depends on how you actually use them in the application. For Booking.com, they are often used as an authentication/verification mechanism, so pretty important as a user field. But if in your app you use them only sometimes for displaying them in the profile, then I would move them into the profile.
Finally, you may want to ask why I called the DB table user_profiles
instead of something like user_invoice_details
. To answer that, let's talk about extra individual fields.
The next question is about those extra fields, different for every role:
Quite often, I see people want to create separate DB tables for every role, like "doctors" and "patients", like "teachers" and "students", because their profile fields are very different.
I would give two pieces of advice/opinion about this:
A little step-back, but: you should almost never replace the users
table with multiple tables for authentication, repeating email/password in each of them. While it's technically possible, with Guards, it becomes a huge headache to later maintain the same functionality in more than one place. Any doctor/patient/teacher is a User of the system, first and foremost, and individual profiles are implemented in other DB tables, with a relationship to the "users" table.
It makes sense to separate the profile tables only if they have many fields (10+), with most of them different per role. Otherwise, for a few fields, it's totally fine to use the same table, making some fields nullable.
With that in mind, for our specific case, it makes sense to just add those fields into the same user_profiles
DB table, just make them nullable.
The full migration for the user_profiles
would look like this:
Schema::create('user_profiles', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained(); $table->string('invoice_address')->nullable(); $table->string('invoice_postcode')->nullable(); $table->string('invoice_city')->nullable(); $table->foreignId('invoice_country_id')->nullable()->constrained('countries'); $table->string('gender')->nullable(); $table->date('birth_date')->nullable(); $table->foreignId('nationality_country_id')->nullable()->constrained('countries'); $table->text('description')->nullable(); $table->timestamps();});
If we put it visually:
For this feature, I don't think it's worth creating API endpoints and tests, as it's just a simple DB table to query.
Instead, I want to dedicate some extra time to...
While writing this lesson, I thought it is too simple: just one profile DB table. So decided to go a bit "off script" of this particular Booking.com project and discuss other, more complex, scenarios.
After all, the goal of this course is to discuss alternatives and explain the way to think about solutions, instead of just going "waterfall" with creating the project.
So, let's try to simulate what we would do for a typical doctor appointment system.
Again, first and foremost, both doctors and patients are users of the system, so we don't change anything in the users
DB table, except for maybe adding more common and often used fields like phone_number
.
Then, there will be two profiles with a lot of extra fields: some will be the same, and some will be different for patients and doctors.
Patients:
Doctors:
There may be more fields, but let's stop at those, you get the picture: 10+ fields for each of the roles.
In this case, we obviously shouldn't make one user_profile
table, for two reasons:
So, what I would suggest:
php artisan make:model Patient -mphp artisan make:model Doctor -m
Migrations:
Schema::create('patients', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained(); $table->date('birth_date')->nullable(); $table->string('gender')->nullable(); $table->string('photo')->nullable(); $table->string('social_security_number')->nullable(); $table->string('emergency_contact_name')->nullable(); $table->string('emergency_contact_phone_number')->nullable(); $table->string('blood_group')->nullable(); $table->string('address_street')->nullable(); $table->string('address_city')->nullable(); $table->string('address_country')->nullable(); $table->timestamps();}); Schema::create('doctors', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained(); $table->date('birth_date')->nullable(); $table->string('gender')->nullable(); $table->string('photo')->nullable(); $table->string('doctor_id_number')->nullable(); $table->string('description')->nullable(); $table->unsignedBigInteger('speciality_id')->nullable(); $table->text('experience')->nullable(); $table->text('education')->nullable(); $table->unsignedBigInteger('clinic_id')->nullable(); $table->timestamps();});
Visually:
You may ask, why not separate a table for repeating profile fields like birth_date / gender / photo?
Those ones are debatable and personal preference, you may even save them in the users
table if you use them often. I don't see anything wrong if the fields with the same names are repeated in a few tables if they naturally belong there.
Final thought: I would probably suggest the same DB schema for similar situations like teachers/students, employees/employers, and others. I mean, have one users
DB table and separate profile tables for each of the roles.