name: location-system description: Countries, states, cities, ResolveLocationAction, Location base model, LocationType enum, geocoding, and location-level Redis data.
Location System
Location tables store identity only (name, shortcode, FKs). All aggregates live in the metrics table and Redis. The Location base model computes all stats from Redis on access via $appends.
Key Files
app/Models/Location/Location.php— Abstract base model with Redis-backed computed attributesapp/Models/Location/Country.php— Route key:shortcode(ISO 3166-1 alpha-2)app/Models/Location/State.php— Belongs to Countryapp/Models/Location/City.php— Belongs to Country + Stateapp/Actions/Locations/ResolveLocationAction.php— Lat/lon -> Country/State/City via geocodingapp/Actions/Locations/ReverseGeocodeLocationAction.php— LocationIQ API wrapperapp/Actions/Locations/LocationResult.php— DTO returned by ResolveLocationActionapp/Enums/LocationType.php— Global(0), Country(1), State(2), City(3)app/Enums/Timescale.php— AllTime(0), Daily(1), Weekly(2), Monthly(3), Yearly(4)
Invariants
- Location tables store identity only. No
total_*counters, nomanual_verify, no aggregates. All stats come from Redis or themetricstable. - Photo table uses FK columns only:
country_id,state_id,city_id. Deprecated string columns (country,county,city,display_name,location,road) are dropped. - Redis is a derived cache. All Redis location data is rebuildable from the
metricstable. - HyperLogLog for contributor counts.
PFCOUNTgives ~0.81% error, O(1) space, append-only (cannot decrement). - Country uses
shortcodeas route key, notid. Routes:/countries/{shortcode}.
Patterns
ResolveLocationAction
// app/Actions/Locations/ResolveLocationAction.php
public function run(float $lat, float $lon): LocationResult
{
$address = $this->reverseGeocode->run($lat, $lon);
$country = $this->resolveCountry($address); // firstOrCreate by country_code
$state = $this->resolveState($country, $address);
$city = $this->resolveCity($country, $state, $address);
return new LocationResult($country, $state, $city, $address, $displayName);
}
Lookup strategy for city: Searches keys in order: city, town, city_district, village, hamlet, locality, county.
LocationResult DTO
readonly class LocationResult
{
public function __construct(
public Country $country,
public State $state,
public City $city,
public array $addressArray,
public string $displayName,
) {}
}
LocationType enum
enum LocationType: int
{
case Global = 0; // dbColumn: null, scopePrefix: {g}
case Country = 1; // dbColumn: country_id, scopePrefix: {c:$id}
case State = 2; // dbColumn: state_id, scopePrefix: {s:$id}
case City = 3; // dbColumn: city_id, scopePrefix: {ci:$id}
public function dbColumn(): ?string
public function scopePrefix(int $id = 0): string
public function modelClass(): ?string
public function parentType(): ?self
}
Location model computed attributes (from Redis)
// All appended attributes on Country/State/City models:
$country->total_litter_redis // HGET {c:$id}:stats litter
$country->total_photos_redis // HGET {c:$id}:stats uploads
$country->total_contributors_redis // PFCOUNT {c:$id}:hll
$country->total_xp // HGET {c:$id}:stats xp
$country->litter_data // HGETALL {c:$id}:cat (resolved to names)
$country->objects_data // top 20 from {c:$id}:obj
$country->materials_data // HGETALL {c:$id}:mat
$country->brands_data // HGETALL {c:$id}:brands
$country->ppm // Cached time-series from metrics table (15min TTL)
$country->recent_activity // Last 7 days daily counts (5min TTL)
Location hierarchy rankings
RedisKeys::globalCountryLitterRanking() // {g}:rank:c:litter (ZSET)
RedisKeys::globalCountryPhotosRanking() // {g}:rank:c:photos
RedisKeys::countryStateRanking($countryId, $metric) // {c:$id}:rank:s:$metric
RedisKeys::stateCityRanking($stateId, $metric) // {s:$id}:rank:ci:$metric
Database schema (identity only)
countries (id, country, shortcode UNIQUE, created_by, timestamps)
states (id, state, country_id, created_by, timestamps, UNIQUE(country_id, state))
cities (id, city, country_id, state_id, created_by, timestamps, UNIQUE(country_id, state_id, city))
LocationController API (v1)
app/Http/Controllers/Location/LocationController.php serves the locations browsing UI.
Endpoints
GET /api/v1/locations— Global view: list of countries with statsGET /api/v1/locations/{type}/{id}— Drill into country/state/city
Response keys
{
"stats": { "countries": 120, "photos": 50000, "tags": 150000, ... },
"locations": [
{
"id": 1, "name": "Ireland", "shortcode": "IE",
"total_tags": 5000, "total_images": 1200, "total_members": 45,
"xp": 15000, "created_at": "...", "updated_at": "...",
"pct_tags": 3.3, "pct_photos": 2.4, "avg_tags_per_person": 111.1
}
],
"location_type": "country",
"breadcrumbs": [ ... ],
"activity": { "today": { ... }, "this_month": { ... } }
}
Key naming: Response uses locations (not children) and location_type (not children_type). Children use total_tags, total_images, total_members (not tags, photos, contributors). The Pinia store useLocationsStore reads these exact keys.
Time filtering
Supports ?period=today|yesterday|this_month|last_month|this_year and ?year=2024 query params. Mutually exclusive — year clears period and vice versa.
Common Mistakes
- Adding aggregate columns to location tables. Aggregates live in
metricstable and Redis. Location tables are identity only. - Using deprecated photo string columns.
country,county,city,display_name,location,roadare dropped. Usecountry_id,state_id,city_idFKs. - Routing countries by ID instead of shortcode. Country model has
getRouteKeyName(): 'shortcode'. - Treating Redis location stats as authoritative. They're derived caches. The
metricstable is source of truth. - Decrementing HyperLogLog. PFCOUNT is append-only. You cannot remove a contributor from HLL.
- Forgetting
GeocodingException.ResolveLocationAction::run()throwsGeocodingExceptionwhen geocoding fails. Always handle this. - Using
childrenorchildren_typein API responses. The correct keys arelocationsandlocation_type. - Filtering locations by
manual_verify. This deprecated column is no longer used. Don't scope queries with it.