Data model — Location
A single physical place (store, office, venue) that can appear as a pin on a Finder map and as a row in the results list.
Table: locations
| Column | Type | Nullable | Description |
|---|---|---|---|
| id | bigint (auto-increment) | no | Primary key |
| title | varchar | no | Location name shown to visitors |
| street_address_1 | varchar | yes | Primary address line |
| street_address_2 | varchar | yes | Suite, floor, unit, etc. |
| street_address_3 | varchar | yes | Additional address line |
| city | varchar | yes | City |
| state | varchar | yes | State or province |
| postal_code | varchar | yes | Postcode / ZIP |
| country | varchar | yes | Country |
| latitude | decimal(10,7) | yes | WGS-84 latitude; populated by the geocoding pipeline |
| longitude | decimal(10,7) | yes | WGS-84 longitude; populated by the geocoding pipeline |
| phone | varchar | yes | Contact phone number |
| website | varchar | yes | URL of the location’s website (added in migration 2026_04_17) |
| image_url | varchar | yes | Public CDN URL of the location hero image |
| image_key | varchar | yes | R2 storage object key used for delete-on-replace; not exposed to visitors |
| custom_fields | json | yes | Key-value map of custom field values keyed by CustomFieldDefinition.name. See JSON columns |
| created_at | timestamp | no | Laravel auto-managed |
| updated_at | timestamp | no | Laravel auto-managed |
Relations
- belongsToMany
Set— viaset_locationpivot. Sets (called “Categories” in the UI) group locations for filtering. The pivot exposesset_idandlocation_id. When serialized viatoArray(), sets are returned as a flat array of IDs only. - belongsToMany
Tag— vialocation_tagpivot. Free-form workspace-scoped labels. - belongsToMany
Workspace— vialocation_workspacepivot. A location belongs to one or more workspaces. - belongsToMany
User— vialocation_userpivot (owners). Returns onlyusers.idfor performance.
Maps access locations through the map_location pivot (on the Map model), not directly from Location.
Geocoding
latitude and longitude are populated from the address fields via the autocomplete provider selected for the finder. The frontend submits coordinates captured from the place-detail lookup at edit time; the backend stores them directly. There is no background geocoding job — coordinates are set at save time if the user selects a place from the autocomplete dropdown.
🔴 [NEEDS CLARIFICATION: If a location is created/imported without coordinates (e.g. via AI import or CSV), what populates lat/lng — is there a batch geocoding step?]
Image storage
image_url is the public CDN URL constructed via publicCdnUrl() helper using services.cdn.base_url. The R2 object key format is:
workspaces/{workspace_id}/locations/{location_id}/{uuid}.{ext}image_key stores this key so the old object can be deleted when a new image is uploaded. The upload endpoint is POST /locations/{id}/image. See codebases/backend/storage.
JSON columns
custom_fields
A flat key-value map where each key is the name field of a CustomFieldDefinition belonging to the workspace. Values are typed according to the field’s type (text, textarea, number, select, url, email, or image):
{
"hours": "Mon–Fri 9am–5pm",
"rating": 4.5,
"featured": true,
"region": "Northeast"
}The shape is defined by CustomFieldDefinition records on the workspace. Fields not present in the map are treated as empty/null on the frontend.
Computed / virtual attributes
toArray() is overridden to return sets as a flat array of integer IDs rather than the full pivot relation objects:
$array['sets'] = $this->sets->pluck('id')->toArray();This is the serialization contract used by the API — consumers should expect sets: [1, 2, 3], not an array of Set objects.
Cross-links
- Set data model — Categories that group locations
- Tag data model — Workspace-scoped labels on locations
- Custom Field Definition data model
- API reference — Locations
codebases/backend/storage— image upload pipeline