Integrations · Laravel

IPAPI.GR for Laravel

A middleware looks up the visitor with Laravel's Http client, caches the answer for an hour, and puts it on the request for your controllers and views. On paid plans it also refuses self-declared AI crawlers whose address is not a known AI crawler.

Written for
Laravel 11 and later, PHP 8.2+
Runs in
Web middleware group
Client IP from
$request->ip() with TrustProxies

01 · Setup

4 steps, copy and paste.

Every lookup runs on your server or edge, so the key never reaches a browser. Each example times out quickly, caches answers per IP and lets the visitor through if the lookup fails.

  1. Add the key to config

    Read the key through config() so config caching keeps working.

    config/services.phpPHP
    // config/services.php
    'ipapi' => [
        'key' => env('IPAPI_KEY'),
    ],
    
    // .env
    // IPAPI_KEY=your_api_key_here
  2. Create the middleware

    Private and reserved addresses are skipped, timeouts fail open, and "No Data" becomes null.

    app/Http/Middleware/IpapiGeo.phpPHP
    <?php
    // app/Http/Middleware/IpapiGeo.php: adds the visitor's lookup to the request.
    
    namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Http\Client\ConnectionException;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Cache;
    use Illuminate\Support\Facades\Http;
    use Symfony\Component\HttpFoundation\Response;
    
    class IpapiGeo
    {
        private const LOOKUP = 'https://ipapi.gr/api/lookup/';
        private const AI_CLAIM = '/GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|Claude-User|PerplexityBot|Perplexity-User/i';
    
        public function handle(Request $request, Closure $next): Response
        {
            $ip = $request->ip(); // honours TrustProxies, see bootstrap/app.php
            $geo = null;
    
            // Private and reserved addresses have no public location: skip them.
            if ($ip && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                $geo = Cache::remember('ipapi:'.$ip, now()->addHour(), fn () => $this->lookup($ip));
            }
            $request->attributes->set('geo', $geo);
    
            // Paid plans return risk flags: refuse self-declared AI crawlers whose address is not a known AI crawler.
            $risk = $geo['risk'] ?? null;
            if ($risk && preg_match(self::AI_CLAIM, (string) $request->userAgent()) && ! $risk['is_ai']) {
                abort(403, 'Unverified crawler');
            }
    
            return $next($request);
        }
    
        private function lookup(string $ip): ?array
        {
            try {
                $response = Http::timeout(2)->get(self::LOOKUP.rawurlencode($ip), [
                    'key' => config('services.ipapi.key'),
                ]);
            } catch (ConnectionException) {
                return null; // timeout or network error: fail open
            }
            if (! $response->ok()) {
                return null;
            }
    
            // Missing values come back as the string "No Data".
            $known = fn ($value) => in_array($value, [null, '', 'No Data'], true) ? null : $value;
    
            return [
                'country' => $known($response->json('country')),
                'country_code' => $known($response->json('country_code')),
                'city' => $known($response->json('city')),
                'timezone' => $known($response->json('timezone')),
                'asn' => $known($response->json('asn')),
                'isp' => $known($response->json('isp')),
                'risk' => $response->json('risk'), // ['is_ai' => …, 'is_crawler' => …, 'is_scanner' => …] on paid plans only
            ];
        }
    }
  3. Register it

    Append it to the web group, and trust your load balancer so $request->ip() is the visitor.

    bootstrap/app.phpPHP
    <?php
    // bootstrap/app.php (Laravel 11 and later)
    use App\Http\Middleware\IpapiGeo;
    use Illuminate\Foundation\Application;
    use Illuminate\Foundation\Configuration\Middleware;
    
    return Application::configure(basePath: dirname(__DIR__))
        // ->withRouting(...)
        ->withMiddleware(function (Middleware $middleware) {
            // Behind a load balancer: trust it, so $request->ip() is the visitor, not the balancer.
            $middleware->trustProxies(at: ['10.0.0.0/8']);
            $middleware->web(append: IpapiGeo::class);
        })
        ->create();
  4. Use it

    The lookup is stored as a request attribute.

    ControllerPHP
    // In a controller or view
    $geo = $request->attributes->get('geo');      // null when unknown
    $country = $geo['country_code'] ?? null;      // e.g. "GR"
    $timezone = $geo['timezone'] ?? 'UTC';

02 · The response

What you get back.

Free keys return location and network data: country, country_code, region, city, zipcode, latitude, longitude, timezone, isp, organization and asn.

Keys on any paid plan add risk.is_ai, risk.is_crawler and risk.is_scanner, which tell you when an address belongs to a known AI crawler, search crawler or internet scanner. The examples use is_ai to refuse requests whose user agent claims an AI crawler while the address isn't one. false means "not a known bot", not "human".

03 · Pitfalls

What usually goes wrong.

Trust only your own proxies
trustProxies(at: '*') lets any client choose its IP through X-Forwarded-For. List your load balancer's addresses instead.
Failed lookups are retried
Cache::remember does not keep a null result, so a failed lookup is tried again on the next request rather than cached for an hour.
Localhost has no location
In development the client is 127.0.0.1 or ::1. The snippets skip private and loopback addresses instead of spending a request on them.
Website restrictions block server calls
A key restricted to "Allowed websites" needs an Origin or Referer header, which server-side code does not send, so the call is refused. For a server key, leave websites empty and restrict by your server's outgoing IP address instead.
"No Data" is a string, not null
When no value is known for a field, the field is still present and set to "No Data". Every snippet converts it to null before you use it.

04 · Questions

Good to know.

More in the docs and the FAQ.

Does it work with Laravel 10?

The middleware class does. Register it in app/Http/Kernel.php under the web group instead of bootstrap/app.php.

Which cache store is used?

Your default store. With the file or database driver it works; Redis or Memcached are faster for many visitors.

Do I need a paid plan?

No. Location and network fields work with a free key. The crawler check needs the risk flags, which come with every paid plan; on a free key it is skipped.

Ship it today.

Free: 1.000 requests a day for location and network data. Bot flags and higher limits on every paid plan, from 9,00 € a month (VAT included).