Courses

Re-creating Booking.com API with Laravel and PHPUnit

Filter Properties by Most Popular Facilities

Now we're getting to the facility filtering. Imagine you search for a property, you have a list, and then you want to have additional filters: swimming pool, pets allowed, etc.

Property search top facilities


Goals of This Lesson

  • Create a DB Structure for Property Facilities
  • Build a list of the most popular facilities - two ways
  • Restructure search results to include facilities
  • Property filter by facility

By the end of this lesson, we will have this test passing in Terminal:

Property search filter by facilities test


New "Type": Property Facilities

Browsing through Booking.com, I realized one more thing: these are all facilities, but they don't belong to the apartments. There are also property facilities, more global things like parking, non-smoking areas, etc.

Those are not shown at the apartment level but rather used as a filtering mechanism in the search, which is exactly the topic of the current lesson.

How to structure this in the DB? Here's my brainstorming process:

  • Option 1: No changes in facilities, but just a new pivot table facility_property? But then all the facilities will be on the same list for property owners and they wouldn't be able to differentiate when filling in the forms.
  • Option 2: New record in facility_categories with a name like "Property facilities" and then use where statements when needed by its name or ID? But hardcoding name or ID didn't feel like the right thing.
  • And then I realized: we have facilities.category_id as a nullable field! BINGO! So all such facilities with category_id = NULL will be treated as property facilities.

Let's seed a few of them, I will add them into the same already existing seeder.

database/seeders/FacilitySeeder.php:

class FacilitySeeder extends Seeder
{
public function run(): void
{
Facility::create(['category_id' => 1, 'name' => 'Linen']);
// ... other old apartment-type facilities with categories
 
// Property facilities:
Facility::create(['name' => 'Family rooms']);
Facility::create(['name' => 'Free WiFi']);
Facility::create(['name' => 'Parking']);
Facility::create(['name' => 'Pets allowed']);
Facility::create(['name' => 'Swimming pool']);
}
}

Next, the pivot table I mentioned above.

php artisan make:migration create_facility_property_table

Migration file:

public function up(): void
{
Schema::create('facility_property', function (Blueprint $table) {
$table->foreignId('facility_id')->constrained();
$table->foreignId('property_id')->constrained();
});
}

Finally, the many-to-many relationships, let's create them both ways.

app/Models/Property.php:

class Property extends Model
{
// ...
 
public function facilities()
{
return $this->belongsToMany(Facility::class);
}
}

app/Models/Facility.php:

class Facility extends Model
{
// ...
 
public function properties()
{
return $this->belongsToMany(Property::class);
}
}

Cool, we have the schema. Now, what do we do with it?


Most Popular Facilities List

Those property facilities are not just used for filtering, it's also important how those filters are presented. I saw that with the search results, we also need to return the most popular facilities among those particular properties found.

Here's how it looks on the left side of the web page:

Property search top facilities

So, we need to change the return structure of the search results and add facilities to it. This means we also need to change the automated tests to check that "new reality".

This is the updated Controller return structure:

app/Http/Controllers/Public/PropertySearchController.php:

class PropertySearchController extends Controller
{
public function __invoke(Request $request)
{
$properties = Property::query()
// ->with(...)
// ->when(...)
// ->when(...)
// ->when(...)
->get();
 
$facilities = []; // <- NEXT: HOW DO WE DO THAT?
// Expected smth like: {"Free WiFi":2,"Family rooms":1,"Parking":1}
 
return [
'properties' => PropertySearchResource::collection($properties),
'facilities' => $facilities,
];
}
}

The next question becomes, how do we get the most popular facilities of those specific properties? I thought of two ways: with Collections and with Second DB Query.

Here, I will show you both, it's your personal preference which to use. Technically, we have all the data already in properties, so we can filter them in Collections, but I haven't found a very easily-readable chain of Collection methods to get the same result. So to me personally, it's ok to launch another DB query specifically for facilities.

Option 1. Filtering with Collections

Here's what the method would look like:

app/Http/Controllers/Public/PropertySearchController.php:

public function __invoke(Request $request)
{
$properties = Property::query()
->with([
'city',
'apartments.apartment_type',
'apartments.rooms.beds.bed_type',
'facilities',
])
// ->when(...)
// ->when(...)
// ->when(...)
->get();
 
$allFacilities = $properties->pluck('facilities')->flatten();
$facilities = $allFacilities->unique('name')
->mapWithKeys(function ($facility) use ($allFacilities) {
return [$facility->name => $allFacilities->where('name', $facility->name)->count()];
})
->sortDesc();
 
return [
'properties' => PropertySearchResource::collection($properties),
'facilities' => $facilities,
];
}

Do you like this chain of pluck()->flatten() and then unique()->mapWithKeys()->sortDesc()?

To understand it better, you may try to debug it yourself and see what values you have after each method in the chain as I did for many examples in the course Laravel Collections Chains: 15 Real Examples.

But personally, I don't see this as very readable. Or, maybe there's a better way with Collection? Shoot in the comments section, then.

Option 2. Query DB Using withCount()

Here's another option - a new Eloquent query.

app/Http/Controllers/Public/PropertySearchController.php:

public function __invoke(Request $request)
{
$properties = Property::query()
->with([
'city',
'apartments.apartment_type',
'apartments.rooms.beds.bed_type',
'facilities',
])
// ->when(...)
// ->when(...)
// ->when(...)
->get();
 
$facilities = Facility::query()
->withCount(['properties' => function ($property) use ($properties) {
$property->whereIn('id', $properties->pluck('id'));
}])
->get()
->where('properties_count', '>', 0)
->sortByDesc('properties_count')
->pluck('properties_count', 'name');
 
return [
'properties' => PropertySearchResource::collection($properties),
'facilities' => $facilities,
];
}

To me, it feels much more readable. But it's a personal preference. And it's a separate DB query, so if you care about performance more then you may want to go with the Collection option.

Now, I've added some data manually behind the scenes, and if we launch this API endpoint, we have this in Postman:

Property search top facilities Postman

Great, isn't it?

But not so fast, now our tests are failing. If we launch php artisan test for the full suite...

Property search test failing

So let's fix that one. We need to go through our tests and add "properties" to the search results structure.

Here are combined examples of changes in multiple methods of the PropertySearchTest.php file.

Before:

$response = $this->getJson('/api/search?city=' . $cities[0]);
$response->assertJsonCount(1);
$response->assertJsonCount(1, '0.apartments');
$response->assertJsonPath('0.apartments.0.name', $largeApartment->name);
$response->assertJsonPath('0.apartments.0.beds_list', '...');

After:

$response = $this->getJson('/api/search?city=' . $cities[0]);
$response->assertJsonCount(1, 'properties');
$response->assertJsonCount(1, 'properties.0.apartments');
$response->assertJsonPath('properties.0.apartments.0.name', $largeApartment->name);
$response->assertJsonPath('properties.0.apartments.0.beds_list', '...');

After doing such changes, we run php artisan test again, and... we're back to the green color:

Property search tests fixed


Property Filter by Facility

Now let's use those facilities to actually filter the properties. Our search endpoint will now additionally accept the array of facility IDs.

It will be another new when() condition in the Controller, quite simple.

app/Http/Controllers/Public/PropertySearchController.php:

class PropertySearchController extends Controller
{
public function __invoke(Request $request)
{
$properties = Property::query()
// ->with(...)
// ->when(...)
// ->when(...)
// ->when(...)
->when($request->facilities, function($query) use ($request) {
$query->whereHas('facilities', function($query) use ($request) {
$query->whereIn('facilities.id', $request->facilities);
});
})
->get();
 
return [
'properties' => PropertySearchResource::collection($properties),
'facilities' => $facilities,
];
}
}

Now we will call the endpoint something like this: /api/search?city_id=1&adults=2&children=1&facilities[]=14&facilities[]=15

Also, let's write an automated test method to ensure that 0/1/2 properties are returned in different cases.

tests/Feature/PropertySearchTest.php:

class PropertySearchTest extends TestCase
{
// ...
 
public function test_property_search_filters_by_facilities()
{
$owner = User::factory()->create(['role_id' => Role::ROLE_OWNER]);
$cityId = City::value('id');
$property = Property::factory()->create([
'owner_id' => $owner->id,
'city_id' => $cityId,
]);
Apartment::factory()->create([
'name' => 'Mid size apartment',
'property_id' => $property->id,
'capacity_adults' => 2,
'capacity_children' => 1,
]);
$property2 = Property::factory()->create([
'owner_id' => $owner->id,
'city_id' => $cityId,
]);
Apartment::factory()->create([
'name' => 'Mid size apartment',
'property_id' => $property2->id,
'capacity_adults' => 2,
'capacity_children' => 1,
]);
 
// First case - no facilities exist
$response = $this->getJson('/api/search?city=' . $cityId . '&adults=2&children=1');
$response->assertStatus(200);
$response->assertJsonCount(2, 'properties');
 
// Second case - filter by facility, 0 properties returned
$facility = Facility::create(['name' => 'First facility']);
$response = $this->getJson('/api/search?city=' . $cityId . '&adults=2&children=1&facilities[]=' . $facility->id);
$response->assertStatus(200);
$response->assertJsonCount(0, 'properties');
 
// Third case - attach facility to property, filter by facility, 1 property returned
$property->facilities()->attach($facility->id);
$response = $this->getJson('/api/search?city=' . $cityId . '&adults=2&children=1&facilities[]=' . $facility->id);
$response->assertStatus(200);
$response->assertJsonCount(1, 'properties');
 
// Fourth case - attach facility to a DIFFERENT property, filter by facility, 2 properties returned
$property2->facilities()->attach($facility->id);
$response = $this->getJson('/api/search?city=' . $cityId . '&adults=2&children=1&facilities[]=' . $facility->id);
$response->assertStatus(200);
$response->assertJsonCount(2, 'properties');
}
}

And... it works!

Property search filter by facilities test

Previous: Show Facilities in Apartment Details
avatar

$allfacilities = $properties->pluck('facilities')->flatten();

$facilities = $allfacilities->groupBy('name')->mapWithKeys(function($items, $key){ return [$key => count($items)]; });

maybe a bit more readable to get the count using collection.

avatar

It really depends on how good you are with collections. Sure, it is almost the same code, but in the end - it's just another way of writing it! So you whatever you see fit :)

avatar
You can use Markdown
avatar
WILLY CHANDRA NEGARA

All my PropertySearchTest.php is failing and the intructions is very unclear. Tried to see the github repo but still failed the test.

PASS Tests\Feature\ApartmentShowTest ✓ apartment show loads apartment with facilities 0.43s

PASS Tests\Feature\AuthTest ✓ can not register with admin role 0.11s ✓ can register with owner role 0.09s ✓ can register with user role 0.10s

PASS Tests\Feature\BookingsTest ✓ user has access to bookings feature 0.09s ✓ property owner does not have access to bookings feature 0.10s

PASS Tests\Feature\PropertiesTest ✓ property owner has access to properties feature 0.09s ✓ user does not have access to properties feature 0.09s ✓ property owner can add property 0.10s

FAIL Tests\Feature\PropertySearchTest ⨯ property search by city returns correct results 0.10s ⨯ property search by country returns correct results 0.10s ⨯ property search by capacity returns correct results 0.10s ⨯ property search by capacity returns only suitable apartments 0.10s

PASS Tests\Feature\PropertyShowTest ✓ property show loads property correctly 0.11s

avatar

Can you expand a bit on how they fail? What is the error message?

avatar

The instructions are to add properties to tthe search structure.

For example in the first test in PropertySearchTest, formally we had

$response->assertJsonCount(1)

Right? but now we refactor it to

$response->assertJsonCount(1, 'properties')

avatar
You can use Markdown
avatar
You can use Markdown