| 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
- Location tables store identity only. No
total_* counters, no manual_verify, no aggregates. All stats come from Redis or the metrics table.
- 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
metrics table.
- HyperLogLog for contributor counts.
PFCOUNT gives ~0.81% error, O(1) space, append-only (cannot decrement).
- Country uses
shortcode as route key, not id. Routes: /countries/{shortcode}.
Patterns
ResolveLocationAction
public function run(float $lat, float $lon): LocationResult
{
$address = $this->reverseGeocode->run($lat, $lon);
$country = $this->resolveCountry($address);
$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;
case Country = 1;
case State = 2;
case City = 3;
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)
$country->total_litter_redis
$country->total_photos_redis
$country->total_contributors_redis
$country->total_xp
$country->litter_data
$country->objects_data
$country->materials_data
$country->brands_data
$country->ppm
$country->recent_activity
Location hierarchy rankings
RedisKeys::globalCountryLitterRanking()
RedisKeys::globalCountryPhotosRanking()
RedisKeys::countryStateRanking($countryId, $metric)
RedisKeys::stateCityRanking($stateId, $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.