Showing Promotions Based on Users’ Location
The web revolutionized the way business is conducted around the world. But as with all revolutions, there are casualties. Small and family-owned businesses had to face the hard reality that most consumers prefer convenience over community, and many of those businesses closed their doors. Many others, however, embraced the opportunity and potential the Internet promises and expanded their consumer base across city, state, and even country lines.
Today, it’s easier than ever for local businesses to not only reach new customers through the Internet, but to also cater to their local customer-base by offering promotions. With tools like ipdata’s location-oriented API, local shops can identify their hometown customers and offer customized incentives.
In this article, you’ll learn how to use ipdata’s API to show promotions for a visitor based upon their location. You’ll need an API key, and if you don’t already have one, sign up for a free ipdata account.
You’ll build a service class for a Laravel application. So, if you want to follow along and actually run this code, you’ll need to setup your environment with the following:
- PHP 7.3+
- MySQL (or comparable database, such as MariaDB)
- Laravel 6+ (it’s easy to install; just follow the directions)
Note that even though this article uses PHP and the Laravel framework, the same ideas and concepts can be applied to any language and framework.
Setting up the Project
The first thing you need to do is create a new Laravel project. The easiest way to do so is by running the following command in your terminal:
laravel new <project_name>
Replace <project_name>
with your name of choice. Laravel, like many other popular frameworks, provides many command-line tools for managing different aspects of the project. So, leave your terminal window open.
Next, change the directory in your terminal to the newly created project folder, like this:
cd <project_name>
The next step is to install the official ipdata PHP library and supporting packages. Note that this is not a requirement—you can consume the ipdata API with your own code. However, the library saves a lot of time and makes it very easy to interact with the API. In your terminal window, execute the following command:
composer require ipdata/api-client
This command uses the composer package manager to download and install ipdata’s PHP client library to your project. The API client is built upon PSR standards, and you need to install some supporting packages with the following command:
composer require nyholm/psr7 symfony/http-client
Next, you need to configure the project to find your database. Open your project’s .env
file and modify the following options:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=my_awesome_store
DB_USERNAME=root
DB_PASSWORD=
Naturally, these settings depend upon your database’s settings. Be sure to input the correct information; otherwise, the database migrations (as well as the application itself) will fail.
Creating the Promotions Table
Your project will track the various promotions it provides visitors with a single database table called promotions
. While you can manually create your promotions
table with any number of database tools, you’ll use Laravel’s command-line utility called Artisan. Execute the following command in your terminal window:
php artisan make:model Promotion --migration
This command does two things. First, it creates a model class that you will use later to interact with the database table. Second, it creates a database migration file that you use to, among other things, define the promotions
table schema.
Navigate to your project’s database\migrations
\ folder and open the file that ends with create_promotions_table.php
. In it, you will find a class called CreatePromotionsTable
, and it contains an up()
method that looks like the following:
public function up()
{
Schema::create('promotions', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
}
This code uses the Schema::create()
method to create the promotions
table, and you’ll use the $table
object to define the table’s columns. For this article, your author decided to implement a solution that allows for multiple promotions based upon the date, the visitor’s region, and/or the visitor’s city. Therefore, you can define promotions that are limited to visitors within a specific city, a specific region (eg: state, province, etc), or are not limited to anyone at all.
To account for these different variables, the promotions
table needs the following columns:
- start date
- end date
- region
- city
- discount amount
The following code defines those columns using Laravel’s table builder syntax:
public function up()
{
Schema::create('promotions', function (Blueprint $table) {
$table->id();
$table->date('start_date');
$table->date('end_date');
$table->string('region_code');
$table->string('city', 100)->nullable();
$table->decimal('discount', 2, 2);
$table->timestamps();
});
}
Of course, you need some data to work with. You can manually insert records using your database management tool of choice, or you can use Laravel’s seeding capabilities to quickly populate the promotions
table.
In a large application, you would want to create a custom seeder class specifically for your promotions
table. In this case, however, you can simply add code to the default database seeder. Open the database\seeders\DatabaseSeeder.php
file and add the following use
statements:
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
The first use
statement imports the DB
facade, which provides the methods you’ll need to insert records into the promotions
table. The second statement imports Carbon
, a simple API extension for working with date and time.
Next, modify the run()
method to insert records into the promotions
table, like this:
public function run()
{
// User::factory(10)->create();
DB::table('promotions')->insert([
'start_date' => Carbon::create('2020', '07', '01'),
'end_date' => Carbon::create('2020', '10', '31'),
'region_code' => '*',
'city' => null,
'discount' => .10
]);
DB::table('promotions')->insert([
'start_date' => Carbon::create('2020', '08', '01'),
'end_date' => Carbon::create('2020', '9', '30'),
'region_code' => 'TX',
'city' => null,
'discount' => .25
]);
DB::table('promotions')->insert([
'start_date' => Carbon::create('2020', '08', '01'),
'end_date' => Carbon::create('2020', '9', '30'),
'region_code' => 'TX',
'city' => 'Houston',
'discount' => .30
]);
}
The highlighted code in this example inserts three records into the promotions
table. Naturally, make sure your start and end dates are suitable for purposes, and it is also important that any city you specify actually resides within the specified region.
Notice line 7 in the above code. The region_code
column contains a value of ‘*’. This, in conjunction with a null
city, is a “catch all” promotion. If a visitor is not in a specified city or region, then they can take advantage of the “catch all” promotion as long as they visit within the start and end date.
Your next step is to migrate and seed the database, and you can do so by executing the following commands in the terminal:
php artisan migrate
php artisan db:seed
As you’ve already guessed, the first command executes the migration and actually creates the promotions
table. The second command executes the run()
method in the DatabaseSeeder
class, therefore inserting the specified records into the table.
Now that you have a some test data in an actual database table, you can focus on writing the service class that will retrieve the most appropriate promotion for a given visitor.
Writing the Service Class
As its name implies, the app
folder in a Laravel project typically contains the application code. With that in mind, create a subdirectory in app
called Services
. Then create a new file called PromotionService.php
and start the file with the following lines of code:
namespace App\Services;
use App\Models\Promotion;
use Ipdata\ApiClient\Ipdata;
use Symfony\Component\HttpClient\Psr18Client;
use Nyholm\Psr7\Factory\Psr17Factory;
class PromotionService {
// more code to come
}
The first line in this code defines the App\Services
namespace. While not entirely necessary, using namespaces is a fantastic way of organizing your application’s code.
The next four lines of code are use
statements that import the following classes:
Promotion
—the model class you will use to query the database.Ipdata
—the client class for interacting with the ipdata API.Psr18Client
andPsr17Factory
—dependencies needed to create anIpdata
client object.
Next, define a private method called createClient()
, as shown in the following code:
class PromotionService {
private function createClient() {
$httpClient = new Psr18Client();
$psr17Factory = new Psr17Factory();
return new Ipdata('<your_ip_key>', $httpClient, $psr17Factory);
}
// more code here
}
Creating an Ipdata
client is very straight forward. Simply “new up” the class’ constructor and pass three arguments in the following order: your API key, a Psr18Client
object, and an instance of Psr17Factory
.
The next method is called getPromotion()
, and its purpose is to query the promotions
table , process the query results, and return the appropriate promotion for the provided region code and city. Start with the following code:
class PromotionService {
private function createClient() {
$httpClient = new Psr18Client();
$psr17Factory = new Psr17Factory();
return new Ipdata('<your_ip_key>', $httpClient, $psr17Factory);
}
private function getPromotion($regionCode, $city) {
$now = date('Y-m-d');
// more code here
}
}
This code defines the method and creates a variable to contain the current date. Remember, in addition to a visitor’s location, promotions also depend upon the date; so, you’ll use this $now
variable to find a current promotion.
In plain SQL, the basic query looks like the following:
SELECT *
FROM promotions
WHERE start_date < $now
AND end_date > $now
This query simply selects the promotions that have a start date before, and an end date after, the current date (represented by $now
). You also want to find promotions that match your visitor’s region, but remember that a promotion may be globally available—a “catch all” promotion. Therefore, you need to find any promotion where the region_code
column is either an asterisk (*
) or the supplied region code (represented by $regionCode
below). That can be done with the following:
SELECT *
FROM promotions
WHERE start_date < $now
AND end_date > $now
AND region_code IN ( '*', $regionCode )
The city slightly complicates things. The city
column can be NULL
, indicating that the promotion applies to anyone in the specified region, but it can also contain an actual city name.
By themselves, city names are not unique. For example, what region/country comes to mind when you think of the city name of “Paris”? Like your author, you probably thought of France—unless you happen to live in or near one of the 23+ cities in the USA that are also named Paris.
A city name is only unique when it is combined with a region. I point this out because unlike the region_code
column, you cannot use an IN
clause when comparing values for the city. Instead, you have to check if city
is null
or if city
matches the provided $city
, and you have to group those comparisons together. In case that’s clear as mud, hopefully the following SQL will clarify:
SELECT *
FROM promotions
WHERE start_date < $now
AND end_date > $now
AND region_code IN ( '*', $regionCode )
AND ( city IS NULL
OR city = $city )
This is the query you need to execute in order to find all of the possible promotions for a visitor. In Laravel applications, you can execute raw SQL statements with no problem. However, you can also use model classes, like your Promotion
class, and the query builder syntax to execute statements. The Laravel equivalent to the above SQL is the following:
Promotion::where('start_date', '<', $now)
->where('end_date', '>', $now)
->whereIn('region_code', ['*', $regionCode])
->where(function($query) use ($city) {
$query->whereNull('city')
->orWhere('city', $city);
})
->get();
Therefore, the first few lines of the getPromotion()
method looks like this:
private function getPromotion($regionCode, $city) {
$now = date('Y-m-d');
$promotions = Promotion::where('start_date', '<', $now)
->where('end_date', '>', $now)
->whereIn('region_code', ['*', $regionCode])
->where(function($query) use ($city) {
$query->whereNull('city')
->orWhere('city', $city);
})
->get();
// more code here
}
But remember: this finds all of the possible promotions for the visitor. So, you’ll need to process the results in order to find the promotion that best fits the visitor’s location. In the code above, the $promotions
variable contains a special enumerable object called a Collection
. You could iterate over the collection with a normal for
or foreach
loop. However, a Collection
exposes many useful methods that you can use to find a specific record.
In this case, you want to find a single record that matches the visitor’s city or region code, and you can easily find that object by using the first()
method, like this:
private function getPromotion($regionCode, $city) {
$now = date('Y-m-d');
$promotions = Promotion::where('start_date', '<', $now)
->where('end_date', '>', $now)
->whereIn('region_code', ['*', $regionCode])
->where(function($query) use ($city) {
$query->whereNull('city')
->orWhere('city', $city);
})
->get();
return $promotions->first(function($record) use ($city, $regionCode) {
return
$record->city == $city ||
$record->region_code == $regionCode ||
$record->region_code == '*';
});
}
A collection’s first()
method accepts a closure that executes on every item within the collection. This closure first tries to match the record’s city with the visitor’s city. If there’s a match, the city-matching record is returned. If a city-match is not found, the closure tries to match a record’s region code with the visitor. Once again, if a match is found, the matching record is returned. If a record cannot be matched by city or region code, then the closure attempts to find a “catch all” record. If no match is found, it returns null
.
The last method in the PromotionService
class brings everything together. It’s called getPromotionFor()
, and it will create the ipdata client, lookup the provided IP address, and query the database using the resulting location data. The following code is the complete PromotionService
class:
class PromotionService {
public function getPromotionFor($ipAddress) {
$client = $this->createClient();
$result = $client->lookup($ipAddress);
return $this->getPromotion($result['city'], $result['region_code']);
}
private function getPromotion($city, $regionCode) {
$now = date('Y-m-d');
$promotions = Promotion::where('start_date', '<', $now)
->where('end_date', '>', $now)
->whereIn('region_code', ['*', $regionCode])
->where(function($query) use ($city) {
$query->whereNull('city')
->orWhere('city', $city);
})
->get();
var_dump($promotions);
$selectedPromo = null;
return $promotions->first(function($item) use ($city, $regionCode) {
return
$item->city == $city ||
$item->region_code == $regionCode ||
$item->region_code == '*';
});
}
private function createClient() {
$httpClient = new Psr18Client();
$psr17Factory = new Psr17Factory();
return new Ipdata('test', $httpClient, $psr17Factory);
}
}
Using the PromotionService
Class
Now that you completed the PromotionService
class, all you have to do is use it. Open the project’s routes\web.php
file and add the following use
statement:
use App\Services\PromotionService;
Next, modify the single route defined in the file. You want to create a service object, use its getPromotionFor()
method, and pass the result to the view, like this:
Route::get('/', function () {
$service = new PromotionService();
$promotion = $service->getPromotionFor(\request()->ip());
return view('welcome', ['promotion' => $promotion]);
});
The highlighted code in this code are the additions you need to make. Save the file, and then open the welcome.blade.php
file located in your project’s resources\views\
directory. Directly after the opening <body>
tag, add the following code:
@if (isset($promotion))
<h2>Grats! You get a {{ $promotion->discount }} discount at checkout!</h2>
@endif
This code checks to see if the visitor receives a promotion (remember that if no promotion is found, $promotion
is null
), and if so, it displays a message indicating the discount amount.
Conclusion
The web revolutionized the way companies conduct business. It is the “great equalizer” because small and local businesses have the same potential customer base as large corporations. But that doesn’t mean small businesses have to eschew their local base in favor gaining global customers. It’s quite the opposite. By leveraging modern tools like ipdata’s location API, local businesses can cater to their most loyal customers, strengthening the relationship within the community.