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:

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 and Psr17Factory—dependencies needed to create an Ipdata 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.