Loading...

This is taking longer than expected.

Back to the help centre

Detect the visitor's country

Where the country of a visitor comes from, where it is kept, and what reads it.

The kit works out the country of every visitor from their IP, remembers it, and hands it to the features that price, format or locate something by country. This guide covers the service that does it, the two environment variables it reads, where the result is stored for a visitor and for an account, what uses it, and what happens on your machine, where there is no public IP.

The service

App\Services\CountryDetectionService is a class of static methods and the only entry point. Call it from anywhere:

use App\Services\CountryDetectionService;

$country = CountryDetectionService::detect(); // 'MX', or null

detect()

Returns a two-letter ISO code, or null when nothing answered. It tries, in order:

  1. The country_code of the signed-in account, when it has one.
  2. A code already found for this visitor, kept in the session under current_country_code.
  3. The IP of the request, through detectFromIp(). A code found this way is written to the session, so the lookup happens at most once per visit.

detectFromIp(?string $ip)

The low-level lookup, also usable on its own:

  • No IP returns null.
  • A private or reserved IP (127.0.0.1, ::1, 10.x, 192.168.x and the like) returns config('app.country_code_fallback'), or null when that is empty. No request is made.
  • Any other IP is sent to https://ipapi.co/{ip}/country/, with no key and a two-second timeout. A two-letter answer is returned upper-cased; a failure, a timeout or anything else returns null.

The application trusts every proxy, so behind a load balancer the IP is the one in X-Forwarded-For and not the balancer's.

getWorldId()

Resolves the detected code to the numeric id of the country in Weblabor World, through GET https://world.weblabor.mx/api/country/{code} with the token as a Bearer header and a two-second timeout. Without WEBLABOR_WORLD_TOKEN it returns nothing and makes no request; on any error it fails silently.

detectTimezone() and worldTimezone()

The same class finds the visitor's timezone, from the session, from https://ipapi.co/{ip}/timezone/, and finally from the country's timezone in Weblabor World when the token is set. That flow is in Timezones and dates.

The two environment variables

Variable Config key Default What it does
COUNTRY_CODE_FALLBACK app.country_code_fallback US The country assumed when the IP is private. Set it to your market for local development, or to an empty value to get null and see what a visitor with no country sees.
WEBLABOR_WORLD_TOKEN services.weblabor.world.token empty The API token of Weblabor World. With it, an account also stores its country as a World division, the country, state and city selects of the profile work, and the timezone fallback by country is available. Without it, detection still works from the IP alone.

.env.example lists WEBLABOR_WORLD_TOKEN= and not COUNTRY_CODE_FALLBACK; add the latter when US is not the country you develop for.

The World UI package that draws the selects reads the same token, plus WEBLABOR_WORLD_ENDPOINT, which defaults to https://world.weblabor.mx/api and only changes when Weblabor tells you to. WEBLABOR_WORLD_TIMEOUT, read into worldui.timeout, is how many seconds each call to that service may wait before it gives up — 3 by default — so a service that does not answer holds the profile for seconds rather than half a minute.

Where the country is stored

For a visitor, in the session, under current_country_code. It is written the first time detect() reaches the IP lookup and read on every later call, so a visitor who moves between pages is looked up once.

For an account, in users.country_code, a nullable two-character column. It is filled when the account is created: App\Observers\UserObserver calls detect() after the insert, which at that moment reads the session the person registered with. An account created with a country already keeps it, and an account an administrator creates while signed in is not detected at all, so it never takes the administrator's country: it gets its own on its first navigation. It is not re-detected on later requests, with one exception: an account that ended up without a country has one detected again the next time it opens a page, as described under When the country is required below.

With WEBLABOR_WORLD_TOKEN set, the observer also stores the World division id of the account's own code in extra_data.country. That attribute is cast with DivisionCast, so reading it returns a Division object whose ->country is the ISO code. The profile's "Select Country" is bound to that attribute; when the person picks another country there, the observer copies the new ISO code back into country_code, so the two never disagree. Without the token, extra_data.country is left alone and only country_code is written.

The country cannot be left blank in the profile, and it cannot be cleared: the select has no clear button and the field is required. Picking another country empties the state and the city, which belonged to the old one, so they are chosen again. Saving never fails because of the country service: if it cannot resolve the country that was picked, the profile is still saved and the internal code keeps the value it had until a country resolves again. The country select itself draws with an empty list rather than breaking the page when that service is unreachable, so the person can simply try again.

When the country is required

In a project that sells subscriptions the country decides the currency and the price a person is offered, so an account cannot use the site without one. A signed-in user who has none has one detected on the next page they open, saved to their account, and walks on to that page without seeing anything at all. Being sent to the profile is what happens when that detection answers with nothing — a country service that is down, or an IP nothing can be read from.

A failed detection is remembered for the rest of the session and not tried again, since the attempt costs up to two seconds; a new session tries once more. While it keeps failing, the user is sent to /account and kept there: every other page sends them back, and only signing out is left. A banner at the top says "Select your country before using your account" with a Select country button. The select it points at is the same one anybody uses to change their country by hand.

What is never blocked:

  • The admin panel, which is never conditioned by the country of whoever is signed in.
  • Anything that is not a page the person is walking to. Livewire updates, uploads and push subscriptions keep working, otherwise the profile could not be filled in.
  • Signing out, stopping impersonation, verifying an email or a phone, and confirming a password.

A project without billing, or with plans switched off, never sees this at all — the guard is only registered when the user billing adapter is available and only acts when there is an active plan catalogue. When both the country and a subscription are missing, the country is asked first, because the price depends on it.

What reads the country

Feature How
Prices per country The billing package asks CountryDetectionService through the country_resolver in config/billing-core.php. The pricing block on the landing, the plans page and the free-plan auto-subscription at registration show the price row for the visitor's country and fall back to the default row — and to nothing at all when there is no default row, because a price row carries its own currency and quoting another country's row would charge in another country's currency. Codes are compared without regard to case. What the screens say when there is no applicable price is in Create and price a plan.
Phone numbers normalize_phone_number() turns a number typed without + into E.164 by assuming the account's country_code, the detected country, or COUNTRY_CODE_FALLBACK, in that order. User::phoneExists() uses it to find a number stored in another shape.
The admin dashboard The "Users by Country" metric groups accounts by country_code, with unknown for accounts that have none.
Connected devices The device list at /account calls detectFromIp() on the IP of each session and names the country through the World countries list.
The account's timezone When the IP gives no timezone, the country's timezone from Weblabor World is used, as described above.

The phone input itself does not pre-select a prefix from the country: the person types the number with its country code, and the normalisation above is what interprets a number typed without one.

Local development

On your machine every request arrives from 127.0.0.1 or ::1, which is a reserved IP, so detectFromIp() never calls ipapi.co and returns COUNTRY_CODE_FALLBACK instead. With the default you are a visitor from the United States; set COUNTRY_CODE_FALLBACK=MX to see Mexican prices, or leave it empty to get null and the default price row. The value is read on the first detect() of a visit and kept in the session, so after changing it, sign out or open a private window.

Everything that asks Weblabor World, the division id, the country, state and city selects of the profile, the country names in the device list and the timezone by country, needs WEBLABOR_WORLD_TOKEN on your machine as well. The service methods fail silently without it; the selects have no API to load their options from. The IP detection itself does not need the token.