Countries, states, cities, ResolveLocationAction, Location base model, LocationType enum, geocoding, and location-level Redis data.
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.
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)total_* counters, no manual_verify, no aggregates. All stats come from Redis or the metrics table.country_id, state_id, city_id. Deprecated string columns (country, county, city, display_name, location, road) are dropped.metrics table.PFCOUNT gives ~0.81% error, O(1) space, append-only (cannot decrement).shortcode as route key, not id. Routes: /countries/{shortcode}.// 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.
readonly class LocationResult
{
public function __construct(
public Country $country,
public State $state,
public City $city,
public array $addressArray,
public string $displayName,
) {}
}
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
}
// 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)
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
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))
app/Http/Controllers/Location/LocationController.php serves the locations browsing UI.
GET /api/v1/locations — Global view: list of countries with statsGET /api/v1/locations/{type}/{id} — Drill into country/state/city{
"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.
Supports ?period=today|yesterday|this_month|last_month|this_year and ?year=2024 query params. Mutually exclusive — year clears period and vice versa.
metrics table and Redis. Location tables are identity only.country, county, city, display_name, location, road are dropped. Use country_id, state_id, city_id FKs.getRouteKeyName(): 'shortcode'.metrics table is source of truth.GeocodingException. ResolveLocationAction::run() throws GeocodingException when geocoding fails. Always handle this.children or children_type in API responses. The correct keys are locations and location_type.manual_verify. This deprecated column is no longer used. Don't scope queries with it.