Hello everyone and welcome to this new article on Laravel.
Today I want to show you an interesting way to deal with any third-party services or external API you need to integrate in your Laravel projects.
- Use case: get the user IP info
- Creating the contract
- Implement IpInfo as a Geolocator
- Tell Laravel what is our current Geolocator
- Use the Geolocator on your controller
- Swapping the Geolocator
- Conclusion
Use case: get the user IP info
We will start from a freshly installed Laravel project which allows user to register and authenticate to their dashboard.
On my case I will say I used Laravel Breeze to implement such feature, but it can be anything else you want (including your custom authentication logic).
The need is, everytime one user has authenticated, we want to know from where and store this information.
After a while, you found IPInfo to have all the requirement you need and a generous free plan.
Creating the contract
Laravel provides a pattern to easily "swap" between different services. For the moment it sounds opaque, so let's follow along to find out how it will help us in the long term.
We will create an interface that will describe all the feature we seek in IPInfo.
I generally try to find a verb that defines my service, so let's call this interface a "Geolocator".
Create a new "app/Contracts" folder, and create a new "Geolocator.php" file in it.
namespace App\Contracts;
use App\DataTransferObjects\Geolocation;
interface Geolocator
{
public function locate(string $ip): Geolocation;
}
Let's break it down:
- We define a "locate" method in the "Geolocator" interface
- This method must take a string representing the IP we want to scan
- This method must return a "Geolocation" object
The reason we force the method to return our own representation of the geolocation of the IP is for any future implementation to stick with this constraint so we do not have to change anything in our code when we swap with a new implementation.
And that is the force of this pattern: easy service swapping with a minimum of code change.
For information, here is what the "Geolocation" data transfer object look like in "app/DataTransferObjects/Geolocation.php".
namespace App\DataTransferObjects;
class Geolocation
{
private $ip;
private $city;
private $country;
private $timezone;
private $internetProvider;
public function __construct($ip, $city, $country, $timezone, $internetProvider)
{
$this->ip = $ip;
$this->city = $city;
$this->country = $country;
$this->timezone = $timezone;
$this->internetProvider = $internetProvider;
}
public function ip()
{
return $this->ip;
}
public function city()
{
return $this->city;
}
public function country()
{
return $this->country;
}
public function timezone()
{
return $this->timezone;
}
public function internetProvider()
{
return $this->internetProvider;
}
}
Implement IpInfo as a Geolocator
Let us create a new implementation of Geolocator.
First, let us install the package (IPInfo Github page):
composer require ipinfo/ipinfo
Next, we will create a config file to store our secure token (available once you create an account on the official website). Let us create a file "config/ipinfo.php" with this content.
return [
"access_token" => env("IPINFO_ACCESS_TOKEN"),
];
And let us add the dot env variable on our file ".env".
...
IPINFO_ACCESS_TOKEN=your-secure-token-here
I like to store implementations on a "Services" folder. Create a folder "app/Services/Geolocator", and add a new "IpInfo.php" file within it:
namespace App\Services\Geolocator;
use App\Contracts\Geolocator;
use App\DataTransferObjects\Geolocation;
use ipinfo\ipinfo\IPinfo as BaseIpInfo;
class IpInfo implements Geolocator
{
public function locate(string $ip): Geolocation
{
$ipInfo = new BaseIpInfo(config("ipinfo.access_token"));
$details = $ipInfo->getDetails($ip);
return new Geolocation($ip, $details->city, $details->country, $details->timezone, $details->org);
}
}
Tell Laravel what is our current Geolocator
Let us wrap up and instruct Laravel to "bind" our Geolocator to our current implementation. This is done on the "AppServiceProvider" file available by default on "app/Providers/AppServiceProvider.php".
namespace App\Providers;
use App\Contracts\Geolocator;
use App\Services\Geolocator\Ipinfo;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(Geolocator::class, IpInfo::class);
}
public function boot()
{
// ...
}
}
Use the Geolocator on your controller
Let us now find the user geolocation right after logged on the app.
Using Laravel Breeze, this is done on the "AuthenticatedSessionController" controller, so let us head into this file and intercept the moment just before redirecting to the dashboard.
namespace App\Http\Controllers\Auth;
use App\Contracts\Geolocator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
public function create(Request $request)
{
// ...
}
public function store(LoginRequest $request, Geolocator $geolocator)
{
$request->authenticate();
$request->session()->regenerate();
$ip = $request->ip();
// Here is where we get the geolocation
$geolocation = $geolocator->locate($ip);
$contry = $geolocation->country();
// Save the country on a separate table linked to the user for example...
return redirect()->intended(RouteServiceProvider::HOME);
}
public function destroy(Request $request)
{
// ...
}
}
If you notice, I did not add "IpInfo" class on the method parameter, but the contract. And yet, Laravel was able to give me an instance of the IpInfo class.
This is the power of Laravel: the ability to inject dependencies and resolve them on controller parameters (more on this on the conclusion).
Swapping the geolocator
Let us imagine some months have passed, and your app is a big success!
You want to find a cheaper geolocation service, and you found IP API to suit your needs.
Thanks to our system, swapping them will just require you to do 2 things:
- To create an implementation of IP API as a "Geolocator"
- To ask Laravel to load IP API as your current Geolocator
Let us create the "IpApi.php" file under "app/Services/Geolocator" folder.
namespace App\Services\Geolocator;
use App\Contracts\Geolocator;
use App\DTOs\Geolocation;
class IpApi implements Geolocator
{
public function locate(string $ip): Geolocation
{
$response = file_get_contents("https://ipapi.co/$ip/json/");
$location = json_decode($response, true);
return new Geolocation($ip, $location["city"], $location["country"], $location["timezone"], $location["org"]);
}
}
Let us change our current implementation in "app/Providers/AppServiceProviders.php".
namespace App\Providers;
use App\Contracts\Geolocator;
- use App\Services\Geolocator\IpInfo;
+ use App\Services\Geolocator\IpApi;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
- $this->app->bind(Geolocator::class, IpInfo::class);
+ $this->app->bind(Geolocator::class, IpApi::class);
}
public function boot()
{
// ...
}
}
Your controller code does not need to be touched at this point, which is the reason why this pattern is so powerful.
Conclusion
Let us sum up what we have done.
- We defined a new concept: Geolocator
- We have 2 available Geolocator: IpInfo and IpApi
- At this point we currently use IpApi
All the added value resides in the ability to flexibly swap from one implementation to another, without having to do heavy refactors on our controller (or anywhere else the Geolocator is needed).
If the concept of contracts and binding (or service container) is still fuzzy for you, I highly recommand this video from Laracast (it is actually when things clicked for me, and I hope this will help you too).
Limit
One limit with the current implementation is it is not "scalable": if your app is really becoming a success, you are forcing your user to wait a few seconds before we get the response of our Geolocator, and a few milliseconds to save this info in database before actually redirecting the user to its dashboard.
In this case the preferable way would be to use Queue jobs. If you want to know more about it, I've covered it in this article
Tests
Not only this pattern helps having a future-proof code base, but it actually also helps with testing!
Since our Geolocator is a bound (e.g. resolvable) dependency, we can ask Laravel to mock it within our tests.
Here is how to test that our ip is well saved in our "geolocations" table.
namespace Tests\Feature\Http\Controllers;
use App\Models\User;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthenticatedSessionControllerTest extends TestCase
{
use WithFaker;
public function testCountryIsStoredWhenUserAuthenticates()
{
$ip = $this->faker->ip();
$country = $this->faker->country();
$city = $this->faker->city();
$timezone = $this->faker->timezone();
$internetProvider = $this->faker->name();
$email = $this->faker->email();
$password = $this->faker->password();
$this->mock(Geolocator::class)
->shouldReceive("locate")
->andReturn(new Geolocation($ip, $city, $country, $timezone, $internetProvider));
$user = User::factory()
->create(["email" => $email, "password" => Hash::make($password)]);
$this->assertDatabaseCount("geolocations", 0);
$this->post(route("login"), [
"email" => $email,
"password" => $password,
])
->assertValid()
->assertRedirect(route("dashboard"));
$this->assertDatabaseHas("geolocations", [
"user_id" => $user->id,
"country" => $country,
]);
}
}
Bonus
By the way, here is a most succint way to write our Geolocation data transfer object using all the latest PHP 8.1 goodies:
namespace App\DataTransferObjects;
class Geolocation
{
public function __construct(
public readonly string $ip,
public readonly string $city,
public readonly string $country,
public readonly string $timezone,
public readonly string $internetProvider,
) {}
}
And when PHP 8.2 will be out, it will become even elegant.
namespace App\DataTransferObjects;
readonly class Geolocation
{
public function __construct(
public string $ip,
public string $city,
public string $country,
public string $timezone,
public string $internetProvider,
) {}
}
Cool huh?
Before leaving
That is all I have for today, I hope you will leave with some new ideas when implementing your next external service!
Happy third-party implementation 🛰️
Top comments (1)
That is a helpful blog! Thanks!