Courses

Re-creating Booking.com API with Laravel and PHPUnit

Countries, Cities, Geographical Objects

The next thing we'll work on is adding real estate properties: houses/homes to rent. In this particular lesson, we will focus on adding the geographical data for city, country, and latitude/longitude.


Goals of This Lesson

  • Build a DB schema for countries, cities, and geographical objects, seeding a few of each
  • Build a first version of DB schema for properties, with geographical data
  • Automatically set property latitude/longitude based on the address, with Observer and Google Maps API
  • First version of API endpoint to create a property, covered by PHPUnit test

By the end of this lesson, we will see this in Postman:


Geo Coordinates: Countries / Cities / Famous Objects

First, let's add the latitude and longitude columns to the DB table of countries.

php artisan make:migration add_geocoordinates_to_countries_table

Migration file:

Schema::table('countries', function (Blueprint $table) {
$table->after('name', function() use ($table) {
$table->decimal('lat', 10, 7)->nullable();
$table->decimal('long', 10, 7)->nullable();
});
});

Next, we will definitely build a search by city, so we need a model for that as well, with coordinates.

php artisan make:model City -ms

app/Models/City.php:

class City extends Model
{
use HasFactory;
 
protected $fillable = ['country_id', 'name', 'lat', 'long'];
 
public function country()
{
return $this->belongsTo(Country::class);
}
}

Migration file:

Schema::create('cities', function (Blueprint $table) {
$table->id();
$table->foreignId('country_id')->constrained();
$table->string('name');
$table->decimal('lat', 10, 7)->nullable();
$table->decimal('long', 10, 7)->nullable();
$table->timestamps();
});

Finally, let's create a separate database table for geographical objects, such as "Big Ben" or "Statue of Liberty", cause people often search by them.

Search by geoobject

php artisan make:model Geoobject -ms

app/Models/Geoobject.php:

class Geoobject extends Model
{
use HasFactory;
 
protected $fillable = ['city_id', 'name', 'lat', 'long'];
}

Migration file:

Schema::create('geoobjects', function (Blueprint $table) {
$table->id();
$table->foreignId('city_id')->nullable()->constrained();
$table->string('name');
$table->decimal('lat', 10, 7)->nullable();
$table->decimal('long', 10, 7)->nullable();
$table->timestamps();
});

Next, let's build the Seeders for all those new tables. We will use them to automatically run seeds in our automated tests, too. So, we fill in a few countries, a few cities, and a few geographical objects.

php artisan make:seeder CitySeeder
php artisan make:seeder GeoobjectSeeder

Country seeder already existed, we just need to fill it with data.

database/seeders/CountrySeeder.php:

use App\Models\Country;
 
class CountrySeeder extends Seeder
{
public function run()
{
Country::create([
'name' => 'United States',
'lat' => 37.09024,
'long' => -95.712891
]);
Country::create([
'name' => 'United Kingdom',
'lat' => 55.378051,
'long' => -3.435973
]);
}
}

database/seeders/CitySeeder.php:

use App\Models\City;
 
class CitySeeder extends Seeder
{
public function run()
{
City::create([
'country_id' => 1,
'name' => 'New York',
'lat' => 40.712776,
'long' => -74.005974,
]);
 
City::create([
'country_id' => 2,
'name' => 'London',
'lat' => 51.507351,
'long' => -0.127758,
]);
}
}

database/seeders/GeoobjectSeeder.php:

use App\Models\Geoobject;
 
class GeoobjectSeeder extends Seeder
{
public function run()
{
Geoobject::create([
'city_id' => 1,
'name' => 'Statue of Liberty',
'lat' => 40.689247,
'long' => -74.044502
]);
 
Geoobject::create([
'city_id' => 2,
'name' => 'Big Ben',
'lat' => 51.500729,
'long' => -0.124625
]);
}
}

Then we, of course, add them all to the main DatabaseSeeder, which now will look like this:

class DatabaseSeeder extends Seeder
{
public function run()
{
$this->call(RoleSeeder::class);
$this->call(AdminUserSeeder::class);
$this->call(PermissionSeeder::class);
 
$this->call(CountrySeeder::class);
$this->call(CitySeeder::class);
$this->call(GeoobjectSeeder::class);
}
}

Great, now we have some geographical entities to play around with, now let's go to the actual properties!


Properties DB Table with Auto-Coordinates

We create a model/migration/seeder for our "main" table of the project:

php artisan make:model Property -ms

And here's the schema with the main fields, for now. There will be more, but at the moment, we focus on geographical things for the search, remember?

Schema::create('properties', function (Blueprint $table) {
$table->id();
$table->foreignId('owner_id')->constrained('users');
$table->string('name');
$table->foreignId('city_id')->constrained();
$table->string('address_street');
$table->string('address_postcode')->nullable();
$table->decimal('lat', 10, 7)->nullable();
$table->decimal('long', 10, 7)->nullable();
$table->timestamps();
});

And the model should contain those fields as fillable.

app/Models/Property.php:

class Property extends Model
{
use HasFactory;
 
protected $fillable = [
'owner_id',
'name',
'city_id',
'address_street',
'address_postcode',
'lat',
'long',
];
 
public function city()
{
return $this->belongsTo(City::class);
}
}

Now, when someone enters a new property, wouldn't it be nice if lat/long fields would be automatically populated, by street address?

I have a separate tutorial on how to do that with Google Maps API (warning: API is not free!), and will make a re-cap of it here in this lesson.

We will use a package called GeocoderLaravel that allows you to easily integrate Google Maps API in your Laravel project.

composer require toin0u/geocoder-laravel
php artisan vendor:publish --provider="Geocoder\Laravel\Providers\GeocoderService"

Then, we add the Google Maps API key (read here how to get it) to the .env file:

GOOGLE_MAPS_API_KEY=AIzaSyAWRsRGOFbTXRlLHDOSudkerLjUtBfElUt

And then, we just need to call these lines to convert the address into coordinates:

$result = app('geocoder')->geocode($address)->get();
$coordinates = $result[0]->getCoordinates();
$lat = $coordinates->getLatitude();
$long = $coordinates->getLongitude();

To automate all this process, we create an Observer file, to watch for the creation of the new Properties.

php artisan make:observer PropertyObserver --model=Property

app/Observers/PropertyObserver.php:

use App\Models\Property;
 
class PropertyObserver
{
public function creating(Property $property)
{
// We also add the owner automatically
if (auth()->check()) {
$property->owner_id = auth()->id();
}
 
if (is_null($property->lat) && is_null($property->long)) {
$fullAddress = $property->address_street . ', '
. $property->address_postcode . ', '
. $property->city->name . ', '
. $property->city->country->name;
$result = app('geocoder')->geocode($fullAddress)->get();
if ($result->isNotEmpty()) {
$coordinates = $result[0]->getCoordinates();
$property->lat = $coordinates->getLatitude();
$property->long = $coordinates->getLongitude();
}
}
}
}

Finally, we register that Observer, let's do it directly in the Model.

app/Models/Property.php:

use App\Observers\PropertyObserver;
 
class Property extends Model
{
// ...
 
public static function booted()
{
parent::booted();
 
self::observe(PropertyObserver::class);
}
}

Great, now whenever someone creates a new Property, coordinates should be auto-filled.


Creating Property: Route/Controller/Request

Now, let's build a Controller/Route to create a new property, and add a Form Request immediately, too.

php artisan make:controller Owner/PropertyController
php artisan make:request StorePropertyRequest

app/Http/Controllers/Owner/PropertyController.php:

namespace App\Http\Controllers\Owner;
 
use App\Http\Requests\StorePropertyRequest;
use App\Models\Property;
 
class PropertyController extends Controller
{
// ...
 
public function store(StorePropertyRequest $request)
{
$this->authorize('properties-manage');
 
return Property::create($request->validated());
}
}

app/Http/Requests/StorePropertyRequest.php:

class StorePropertyRequest extends FormRequest
{
public function authorize()
{
return true;
}
 
public function rules()
{
return [
'name' => 'required',
'city_id' => 'required|exists:cities,id',
'address_street' => 'required',
'address_postcode' => 'required',
];
}
}

As you can see, we require only the name/city/address, as owner/lat/long will be filled automatically by the Observer.

Finally, the new route:

Route::middleware('auth:sanctum')->group(function () {
// ...
 
Route::post('owner/properties',
[\App\Http\Controllers\Owner\PropertyController::class, 'store']);
 
});

We try it out in Postman:

Property Create

Finally, in this lesson, let's add the automatic test that it actually works.

tests/Feature/PropertiesTest.php:

namespace Tests\Feature;
 
class PropertiesTest extends TestCase
{
// ...
 
public function test_property_owner_can_add_property()
{
$owner = User::factory()->create(['role_id' => Role::ROLE_OWNER]);
$response = $this->actingAs($owner)->postJson('/api/owner/properties', [
'name' => 'My property',
'city_id' => City::value('id'),
'address_street' => 'Street Address 1',
'address_postcode' => '12345',
]);
 
$response->assertSuccessful();
$response->assertJsonFragment(['name' => 'My property']);
}
}

A debatable question is whether we should leave the auto-coordinates enabled while testing. Probably not, as we don't want to get charged for Google API every time we run automated tests, right?

So, this is how I disable that part of the Observer:

app/Observers/PropertyObserver.php:

class PropertyObserver
{
public function creating(Property $property)
{
if (is_null($property->lat) && is_null($property->long) && !(app()->environment('testing'))) {
 
// ... getting the coordinates
 
}
}
}

In other words, while testing, if we don't provide the lat/long for the new property, the coordinates will remain NULL, which is fine.

Property Create Test

We may create another Unit test that will assert that the API works or Mock/Fake data, but this is outside of the scope of this course. For that, please read the tutorial Laravel Testing: Mocking/Faking External 3rd Party APIs .

Previous: Profile Fields Structure
avatar

Like this very much; however we in the United States use states more than country. I see you used the United States and New York City bot you left out the states of New York. Country_id, State_id, and city_id then the address and zip code is used to mail a letter hear the country is only used if mailing from out side of the United States.

avatar

I agree, but I think the main point of this scenario was to copy what he saw on bookings.com In either case you would be able to adapt what he has created to your specific case.

avatar

I know, and in other countries there's a different regional structure, too. But if I went that deep with every country, and other Booking.com features with all their possible details, I wouldn't release this course until 2026.

avatar

Not sure what to say about the OP lol

avatar
You can use Markdown
avatar

In (test_property_owner_can_add_property) (PropertiesTest)

You should add assignRole in: $owner = User::factory()->create(['role_id' => Role::ROLE_OWNER])

It will be like this: $owner = User::factory()->create(['role_id' => Role::ROLE_OWNER])->assignRole(Role::ROLE_OWNER);

I think you forget to add

avatar

Thanks for the comment. If you mean I should assign it with Spatie package, then getting back to the earlier lesson, I used Spatie only as a separate demo example in a separate branch, but I won't use that example for the other course lessons.

avatar
Ngozi Stephen Onyemauche

it is actually $owner = User::factory()->create()->assignRole(Role::ROLE_OWNER); not $owner = User::factory()->create(['role_id' => Role::ROLE_OWNER])->assignRole(Role::ROLE_OWNER); it will still throw an error

avatar
You can use Markdown
avatar
Luis Alberto Oliveira

At this section I got an error when I tried to do composer require toin0u/geocoder-laravel.

ERROR -> Your requirements could not be resolved to an installable set of packages.

I've been using PHP 8.1.16 and the project it was created with Laravel 10.8...

avatar

Not sure, the composer.json of that package shows PHP 8.0+ and Laravel 10 supported.

Are you sure you're on PHP 8.1? Have you tried to run phpinfo() function in your webpage? Because sometimes there are different PHP versions for CLI and websites.

avatar
Ngozi Stephen Onyemauche

I got thesame error while installing the toin0u/geocoder-laravel in my project PHP 8.1.13

avatar
Ngozi Stephen Onyemauche

Pls Have your solved our own error and how did you solve

avatar

Ok I can confirm that the underlying package doesn't support PHP 8 yet. It's very weird, so how could it work for me when preparing for this course just a few months ago...

Anyway, you can wait for them to update, or use some alternative like Spatie Geocoder, or skip that geocoding part of the course, for now.

avatar

Use the option --with-all-dependencies (-W) to allow upgrades, downgrades and removals for packages currently locked to specific versions.

composer require toin0u/geocoder-laravel -W

avatar

@haritjahjo I was getting the same error, it loaded when I added the -W parameter, thank you.

avatar
You can use Markdown
avatar
Ngozi Stephen Onyemauche

Hi, everyone pls i have been getting 500 error message; showing this General error: 1364 Field 'owner_id' doesn't have a default value (Connection: mysql, SQL: insert into properties (name, city_id, address_street, address_postcode, lat, long, updated_at, created_at) values (Central Hotel, 4, 16-18, Argyle Street Camden, WC1H 8EG, 51.529145, -0.1239401, 2023-05-21 23:08:59, 2023-05-21 23:08:59)) And why this is in property table $table->foreignId('owner_id')->constrained('users');

avatar

Hi, I replied to you via email. The field owner_id is auto-set in the creating() method of the Observer.

avatar
Ngozi Stephen Onyemauche

yes i thank you i have seen it

avatar
You can use Markdown
avatar

Hello, I have 1 question. What if I allow the owner to select property location on google map api market. and get the lat, long from the api. Is google will give me the city, country names and address ?! Because I fill in some casese the user will not know what is the full address name, so mayble google will give us wrong lat & long. What u think?

Thanks

avatar

Yes it is possible with Google Maps API but it's not free. We have an article about it: Laravel: Find Addresses with Coordinates via Google Maps API

avatar

Thanks yeah i know its paid API, but it's powerful api. The tutorial awesome.

avatar
You can use Markdown
avatar

Sorry but I have a long question here,

If I wanna scale the website to Chalets, Cars, Appartement with Yachts, as you know i will have too much duplicated columns if i wanna create table for each one.

So your Property Model will fix this issue, but here you created Appartement model with hasOne relationship. what if I want to create Chalet and Yacht Models and the Parent Model is Property. It will be very useful because all the models is rental base.

How to make the relationship here.

avatar

It's a personal preference how to structure, as only you know how many columns will be the same or different how often you would query them, etc.

Wouldn't it be better to still use Apartment for chalets/cars/yachts, just with different Apartment Type?

avatar

Yes it's possible, i will see thanks.

avatar
You can use Markdown
avatar

The Postman screenshot shows the property record inserted, but also the city details. This should be added in order to see it: return Property::create($request->validated())->load("city");

avatar
You can use Markdown
avatar

I change the PropertyObserver for Spatie Geocoder:

use Spatie\Geocoder\Facades\Geocoder;

...

$result = Geocoder::getCoordinatesForAddress($fullAddress);

if (!empty($result)) {
		$property->lat = $result['lat'];
		$property->long = $result['lng'];
}
avatar
You can use Markdown
avatar

It would probably be better to ignore the 'eloquent.creating: App\Models\Property' event in the test than to add redundant conditions to the PropertyObserver. We will be frank: altering the code being tested for the needs of the test is a bad practice.

public function test_property_owner_can_add_property()
{
	\Illuminate\Support\Facades\Event::fake([
		'eloquent.creating: App\Models\Property',
	]);

	// ...
}
avatar
You can use Markdown
avatar
You can use Markdown