When I added a country breakdown to ICanUp Analytics, I had an important design question: where should the visitor country come from if I deliberately do not want to store raw IP addresses in analytics history?
I also did not want Laravel to send the visitor IP to an external GeoIP API for every page view.
I moved country resolution to the infrastructure boundary: Nginx and MaxMind resolve the country, while the application receives only a normalized two-letter code.

I Needed the Country, Not the IP Address
For Analytics, I want to understand which countries generate traffic.
That does not require storing the address of an individual visitor.
A country code gives me the aggregation level I need without creating a history of raw IP addresses.
What I need | What I do not need |
|---|---|
Country code | Raw IP address |
Aggregated statistics | Precise visitor location |
Country breakdown | City, coordinates, or visitor profile |
Stable infrastructure source | External HTTP request for every view |
The Main Decision Was to Move GeoIP Outside Laravel
Laravel already has enough work to do during a public request: routing, rendering, analytics tracking, and other application logic.
I did not want every eligible view to add another HTTP request to an external GeoIP service.
Country resolution fits more naturally where the network request already crosses the infrastructure boundary.
Visitor request | vNginx | | visitor IP is used for local GeoIP lookup vMaxMind GeoLite2 Country | | result: UA vtrusted internal country header | vLaravel | vcountry_code = UA | vAnalytics aggregate IP is not persisted in Analytics historyMaxMind Runs Locally Next to Nginx
A local MaxMind GeoLite2 Country database combined with GeoIP2 support in Nginx fits this model well.
The lookup does not require sending the visitor address to a third-party service on every request.
The lookup happens inside my infrastructure, and the application receives only the country code.
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb { auto_reload 1h; $geoip2_country_code country iso_code;}Laravel Must Not Trust a Header Sent by the Browser
Passing the country code through an HTTP header is convenient, but it can easily create a security bug.
A client can send a header such as X-Icanup-Country-Code: US itself.
The application therefore must not trust a value merely because it arrived under the expected header name.
location ~ \.php$ { include fastcgi_params; fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE $geoip2_country_code; # Existing PHP-FPM configuration...}The origin infrastructure supplies the value derived from the GeoIP lookup.
A client-provided value must not pass into the application as the source of truth.
The Header Name Remains Configurable
I did not want Analytics to be permanently coupled to one specific header name.
The application environment therefore defines which trusted header contains the country code.
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-CodeThis keeps the boundary clear: infrastructure decides how the country is resolved, while Analytics only knows the agreed application contract.
The Resolver Receives an Already Resolved Country Code
On the application side, PublicAnalyticsCountryDimensionResolver receives the country value.
It does not need to know the MaxMind database format, the .mmdb path, or Nginx internals.
The resolver has a small contract: trusted header -> normalized country_code or null.
$headerName = config( 'analytics.country_header'); $value = $request->header( $headerName); if (! is_string($value)) { return null;} $countryCode = strtoupper( trim($value)); return preg_match( '/^[A-Z]{2}$/', $countryCode,) === 1 ? $countryCode : null;Only a Normalized Value Reaches Storage
A two-letter country code is enough for the Analytics use case.
The value is normalized to a stable format such as UA, US, or DE.
An arbitrary string should not automatically become an analytics dimension.
- Accept only the expected short format.
- Normalize the value before persistence.
- Treat missing or unknown values as
nullwhen appropriate. - Do not fail the public request when country resolution is unavailable.
- Do not add the IP address to analytics dimensions.
Why I Did Not Use an External GeoIP API for Every View
Laravel could technically take the IP address, call an external GeoIP endpoint, and receive a country.
That would add a network dependency to every eligible page view.
The visitor IP would also leave my infrastructure during the lookup.
For a simple country breakdown, a local GeoIP database creates a much cleaner boundary.
Local GeoIP lookup | External API per view |
|---|---|
No additional HTTP request | Additional network operation |
IP stays at the infrastructure boundary | IP is sent to an external provider |
Predictable latency | External availability dependency |
Local database must be updated | API limits and availability must be managed |
The GeoLite2 Database Is Not Part of the Application Repository
The local GeoIP database is an operational dependency, not application source code.
I do not want a large binary .mmdb file committed to Git alongside Laravel code.
- Keep the
.mmdbfile outside the application repository. - Treat the database path as server configuration.
- Give the Nginx process the required read permissions.
- Update GeoLite2 through a separate operational process.
- Keep the same trusted-header contract in development and production.
- Replacing the database should not require application-code changes.
A Missing Country Must Not Break Analytics
GeoIP does not guarantee that every address can always be mapped to a valid country.
The value may also be unavailable during local development.
The country dimension is therefore additional analytics context, not a requirement that can break the entire request.
No country code means no country dimension, while the public request continues normally.
The Complete Path Needs End-to-End Verification
Testing MaxMind alone or testing the Laravel resolver alone is not enough.
I need to know that the complete path works from a real public request to aggregated analytics.
1. Public request reaches Nginx 2. Nginx resolves country with MaxMind 3. Infrastructure creates: X-Icanup-Country-Code: UA 4. Laravel reads the configured trusted header 5. Resolver normalizes: UA 6. Analytics stores: country_code = UA 7. Admin Analytics: Countries -> Ukraine- A new eligible public view receives a country code through the trusted infrastructure path.
PublicAnalyticsCountryDimensionResolveraccepts the value.- A new analytics bucket contains
country_code. - Admin Analytics starts showing the country.
- A missing country does not cause an exception.
- Analytics history does not contain a raw IP.
Header Spoofing Needed Its Own Security Check
The most important security property is that a browser cannot simply choose its own country.
If a client supplies X-Icanup-Country-Code, the origin should replace or discard it and generate its own value.
A trusted header is not trusted because of its name. It is trusted because of the controlled infrastructure path that creates it.
Client sends: X-Icanup-Country-Code: XX Do not trust it directly. Infrastructure resolves: MaxMind lookup -> UA Laravel receives trusted value: X-Icanup-Country-Code: UAWhat Analytics Actually Stores
After this pipeline finishes, Analytics no longer needs the IP address.
The new aggregate contains only a normalized country_code among its additional dimensions.
That is the outcome I wanted: country statistics without a history of individual visitor addresses.
SELECT JSON_UNQUOTE( JSON_EXTRACT( additional_dimensions, '$.country_code' ) ) AS country_code, COUNT(*) AS bucketsFROM analytics_daily_aggregatesWHERE JSON_EXTRACT( additional_dimensions, '$.country_code') IS NOT NULLGROUP BY country_codeORDER BY buckets DESC;The Country Becomes Useful After Data Accumulates
Seeing UA in the database was not the final goal.
The country code exists to power the aggregated Countries breakdown in Admin Analytics.
As new eligible views accumulate, the block starts showing real country values.
I Deliberately Stopped at Country-Level GeoIP
Once GeoIP exists, it is technically easy to ask for more: region, city, coordinates, and other location data.
None of those values were required for this use case.
My rule is to collect the smallest amount of data that answers the actual analytics question.
| In scope | Out of scope |
|---|---|
| Country code | City |
| Country aggregate | Coordinates |
| Normalized two-letter value | Raw IP history |
| Anonymous traffic dimension | Visitor profiling |
What Could Go Wrong
- Store the IP together with the country code "just in case".
- Call an external GeoIP API for every page view.
- Trust
X-Icanup-Country-Codesent directly by a client. - Commit the GeoLite2
.mmdbfile to the application repository. - Never update the MaxMind database.
- Persist an arbitrary unvalidated string as
country_code. - Throw an exception when country resolution has no result.
- Expand to city-level tracking without a real requirement.
What I Kept for Next Time
- Resolve GeoIP at the infrastructure boundary.
- Do not make Laravel call an external GeoIP API for every view.
- A two-letter country code is enough for a Countries breakdown.
- Raw IP does not need to enter analytics history.
- Create or forcibly overwrite the trusted header at the origin.
- Keep the header name configurable.
- Store the GeoLite2 database outside the Git repository.
- Treat a missing country as a normal state.
- Verify the complete path from Nginx to Admin Analytics.
- Do not collect more precise geolocation without a concrete need.
For country analytics, I do not need a history of visitor IP addresses. I need a reliable way to reduce a network request to the smallest useful country code before the data reaches Analytics.
Conclusion
The most important part of this solution was not the GeoIP database itself but the responsibility boundary around it.
Nginx sees the network request, MaxMind resolves the country locally, the infrastructure creates a trusted internal header, and Laravel receives only the two-letter country_code.
In ICanUp, Analytics does not persist raw IP addresses and does not call an external GeoIP API for every view.
I get the Countries breakdown I need without turning simple country statistics into storage for more precise visitor network data.



