location-system

star 130

Countries, states, cities, ResolveLocationAction, Location base model, LocationType enum, geocoding, and location-level Redis data.

OpenLitterMap By OpenLitterMap schedule Updated 3/1/2026

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 attributes
  • app/Models/Location/Country.php — Route key: shortcode (ISO 3166-1 alpha-2)
  • app/Models/Location/State.php — Belongs to Country
  • app/Models/Location/City.php — Belongs to Country + State
  • app/Actions/Locations/ResolveLocationAction.php — Lat/lon -> Country/State/City via geocoding
  • app/Actions/Locations/ReverseGeocodeLocationAction.php — LocationIQ API wrapper
  • app/Actions/Locations/LocationResult.php — DTO returned by ResolveLocationAction
  • app/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

  1. Location tables store identity only. No total_* counters, no manual_verify, no aggregates. All stats come from Redis or the metrics table.
  2. Photo table uses FK columns only: country_id, state_id, city_id. Deprecated string columns (country, county, city, display_name, location, road) are dropped.
  3. Redis is a derived cache. All Redis location data is rebuildable from the metrics table.
  4. HyperLogLog for contributor counts. PFCOUNT gives ~0.81% error, O(1) space, append-only (cannot decrement).
  5. Country uses shortcode as route key, not id. 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 stats
  • GET /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 metrics table and Redis. Location tables are identity only.
  • Using deprecated photo string columns. country, county, city, display_name, location, road are dropped. Use country_id, state_id, city_id FKs.
  • Routing countries by ID instead of shortcode. Country model has getRouteKeyName(): 'shortcode'.
  • Treating Redis location stats as authoritative. They're derived caches. The metrics table is source of truth.
  • Decrementing HyperLogLog. PFCOUNT is append-only. You cannot remove a contributor from HLL.
  • Forgetting GeocodingException. ResolveLocationAction::run() throws GeocodingException when geocoding fails. Always handle this.
  • Using children or children_type in API responses. The correct keys are locations and location_type.
  • Filtering locations by manual_verify. This deprecated column is no longer used. Don't scope queries with it.
Install via CLI
npx skills add https://github.com/OpenLitterMap/openlittermap-web --skill location-system
Repository Details
star Stars 130
call_split Forks 41
navigation Branch main
article Path SKILL.md
More from Creator
OpenLitterMap
OpenLitterMap Explore all skills →