| name | photo-pipeline |
| description | Photo upload, tagging, verification status, summary generation, XP calculation, AddTagsToPhotoAction, UploadPhotoController, and the VerificationStatus enum. |
Photo Pipeline
Photos flow through three phases: Upload (observation only) -> Tag (summary + XP) -> Verify (metrics). Each phase is independent and idempotent.
Key Files
app/Http/Controllers/Uploads/UploadPhotoController.php — Web upload entry point
app/Http/Requests/UploadPhotoRequest.php — Web upload validation (EXIF datetime, GPS). Dedup is NOT here — it's an idempotent lookup in the controller. HEIC files skip the image/dimensions rules AND the raw exif_read_data() GPS/datetime checks (both detected via MakeImageAction::isHeic()) — PHP can't read HEIC EXIF pre-conversion, so that validation is deferred to the controller, which reads the converted JPEG's EXIF.
app/Http/Controllers/API/Tags/PhotoTagsController.php — V5 tagging endpoint (POST /api/v3/tags add, PUT /api/v3/tags replace)
app/Actions/Tags/AddTagsToPhotoAction.php — Core tagging logic (v5)
app/Actions/Photos/MakeImageAction.php — Image processing + EXIF extraction. Converts HEIC→JPEG by shelling out to heif-convert (from libheif; NOT ImageMagick — IM's HEIC delegate is unreliable across variants). The binary path comes from config('services.heif_convert.path') (env HEIF_CONVERT_PATH, default heif-convert resolved via PATH). Set an absolute path when the web server's PATH excludes it (e.g. local Valet php-fpm under launchd lacks /opt/homebrew/bin).
app/Actions/Photos/UploadPhotoAction.php — S3 storage (requires non-null Carbon datetime)
app/Services/Tags/GeneratePhotoSummaryService.php — Builds summary JSON + calculates XP
app/Services/Tags/XpCalculator.php — XP scoring rules
app/Enums/VerificationStatus.php — Photo verification state machine
app/Enums/XpScore.php — XP values per tag type
app/Http/Requests/Api/PhotoTagsRequest.php — V5 tag request validation (POST — blocks already-verified photos)
app/Http/Requests/Api/ReplacePhotoTagsRequest.php — V5 replace tag request validation (PUT — ownership only, no verification gate)
app/Http/Controllers/API/GetUntaggedUploadController.php — Mobile untagged photos (supports ?platform=web|mobile filter)
app/Observers/PhotoObserver.php — Sets is_public = false for school team photos
app/Helpers/helpers.php — getDateTimeForPhoto(), getCoordinatesFromPhoto(), dmsToDec()
tests/Feature/UploadValidationTest.php — 11 tests (EXIF datetime, GPS DMS conversion, edge cases)
tests/Feature/Tags/ReplacePhotoTagsTest.php — 5 tests (replace tags, ownership, auth, extra tags cleanup)
Invariants
- Upload creates observation only. No tags, no XP, no summary, no metrics. Just the photo record with location FKs.
- EXIF datetime is required for web uploads.
UploadPhotoRequest rejects images without EXIF datetime. Controller has ?? Carbon::now() safety fallback. UploadPhotoAction::run() type-hints Carbon $datetime — null will crash. Mobile uploads send explicit lat, lon, date — EXIF validation is skipped.
- GPS DMS conversion guards against division by zero.
dmsToDec() validates all 6 denominator values before dividing. Returns null on malformed data.
- (0,0) coordinates rejected for explicit mode. Mobile uploads with
lat=0, lon=0 get 422 (Null Island guard). Web uploads accept 0,0 from EXIF.
- Summary generation is unconditional.
GeneratePhotoSummaryService::run() MUST run regardless of trust level. School photos need a summary at tag time so it exists when the teacher approves later. Gating summary behind a trust check causes null summary at approval = zero metrics.
- XP calculation is unconditional. Runs for all users, before verification.
TagsVerifiedByAdmin fires for ALL non-school users. This ensures all users get immediate leaderboard credit. Trusted users also get ADMIN_APPROVED (visible on map). Non-trusted users stay at verified=0 (not on map). School students' photos stop at VERIFIED(1) and wait for teacher approval — event does NOT fire for them.
- VerificationStatus is an enum cast.
$photo->verified returns the enum, not an int. Use ->value for >=/< comparisons, === for equality checks. Never compare enum to raw int.
remaining is deprecated and NO LONGER read for picked-up. DB column is photos.remaining (tinyint(1) NOT NULL DEFAULT 1). The Photo::getPickedUpAttribute() accessor (appended via $appends) now derives picked-up from the first tag — data_get($this->summary, 'tags.0.picked_up') — returning ?bool (null for untagged), NOT !$this->remaining. So $photo->picked_up reflects per-tag edits everywhere (map popups, profile, uploads, team views). Requires summary to be loaded (all readers select it). photos.remaining is now only written at upload (a user default) and hidden from API serialization ($hidden = ['geom', 'remaining']) — it is no longer returned in any response. Clients use picked_up. Per-tag photo_tags.picked_up is the source of truth (nullable true/false/null). users.picked_up defaults to true for new users; column stays nullable tri-state.
- Loose PhotoTags (nullable CLO).
photo_tags.category_litter_object_id, category_id, and litter_object_id are now NULLABLE. Extra-tag-only tags (brands, materials, custom tags) can exist as standalone PhotoTags without a litter object. AddTagsToPhotoAction::createExtraTagOnly() creates these with null CLO fields. GeneratePhotoSummaryService only counts objects when objectId > 0 (variable renamed $totalLitter → $totalObjects). XpCalculator only awards object XP when object_id > 0.
- Replace tags accepts empty tags array.
PUT /api/v3/tags with tags: [] clears all tags on a photo (resets summary, XP, verified). ReplacePhotoTagsRequest validates tags as present|array (not required|array|min:1).
- Photo visibility is user-controlled (except school teams). Non-school users can set
is_public = false per photo or globally via users.public_photos default. Private-by-choice photos still receive immediate upload XP — the metrics gate in recordUploadMetrics() uses a school team check ($photo->team_id && $team->isSchool()), NOT an is_public check. Never change the gate to $photo->is_public === false — that would incorrectly defer metrics for private-by-choice photos.
VerificationStatus Enum
enum VerificationStatus: int
{
case UNVERIFIED = 0;
case VERIFIED = 1;
case ADMIN_APPROVED = 2;
case BBOX_APPLIED = 3;
case BBOX_VERIFIED = 4;
case AI_READY = 5;
public function isPublicReady(): bool // >= ADMIN_APPROVED
public function isVerified(): bool // >= VERIFIED
}
Patterns
Phase 1: Upload
UploadPhotoRequest::after() validates before controller runs:
- EXIF must exist and be non-empty
- DateTime must exist (DateTimeOriginal → DateTime → FileDateTime fallback)
- GPS fields must exist and
dmsToDec() must succeed (guards zero denominators)
Duplicate handling is NOT validation. Dedup (user_id + datetime) lives in UploadPhotoController::__invoke(), before any S3 write / Photo::create / XP. A duplicate is idempotent success, not a 422: it returns { success: true, photo_id: <existing>, already_uploaded: true, tagged: <bool>, xp_awarded: 0 } (pure lookup, zero side effects). Skipped for participant uploads (students share the facilitator's user_id and may share a datetime). tagged = existing photo has a non-null summary.
Error contract on UploadPhotoRequest: failedValidation() returns a structured response:
{ "success": false, "error": "<code>", "message": "<human string>", "errors": {} }
resolveErrorCode() maps failures to typed string codes:
no_exif — image has no readable EXIF data
no_gps — image has no GPS coordinates
no_datetime — image has no datetime in EXIF
invalid_coordinates — GPS coordinates failed parsing (zero denominators, etc.)
validation_error — generic Laravel validation failure (wrong file type, size, etc.)
Mobile clients should read the error field for programmatic handling. Note: Handler::unauthenticated() returns { message: "Unauthenticated." } without an error code field (inconsistency — not yet fixed).
UploadPhotoController::__invoke() flow:
MakeImageAction::run($file) — extract EXIF
getDateTimeForPhoto($exif) ?? Carbon::now() — EXIF datetime with safety fallback
UploadPhotoAction::run() x2 — S3 full image + bbox thumbnail
getCoordinatesFromPhoto($exif) → ResolveLocationAction::run($lat, $lon) — Country/State/City FKs
Photo::create() — observation record with FKs only. For participant uploads: team_id from participant's team, participant_id from participant slot
event(new ImageUploaded(...)) — real-time broadcast
Phase 2: Tagging
POST is append-only + has an idempotent guard. store() first checks the target photo's summary: if non-null (already tagged), it returns an idempotent no-op { success: true, already_tagged: true, photoTags: [...] } WITHOUT re-adding — because POST appends, a retried POST would otherwise double-tag/double-count (the verified >= 1 authorize gate does NOT catch ordinary users, who stay verified = 0). To re-tag/edit an already-tagged photo, use PUT /api/v3/tags (replace).
PhotoTagsController::store() -> AddTagsToPhotoAction::run():
public function run(int $userId, int $photoId, array $tags): array
{
$photoTags = $this->addTagsToPhoto($userId, $photoId, $tags);
$photo->generateSummary();
$photo->xp = $this->calculateXp($photoTags);
$this->updateVerification($userId, $photo);
}
Frontend tag types handled by AddTagsToPhotoAction
The web frontend sends 4 distinct tag types. resolveTag() handles each:
- Object tag —
{ object: { id, key }, quantity, materials?, brands? }. Category auto-resolved from object->categories()->first().
- Custom-only —
{ custom: true, key: "dirty-bench", quantity }. Uses $tag['key'] (not $tag['custom']).
- Brand-only —
{ brand_only: true, brand: { id, key }, quantity }. PhotoTag with null category/object.
- Material-only —
{ material_only: true, material: { id, key }, quantity }. Same as brand-only pattern.
Verification routing
protected function updateVerification(int $userId, Photo $photo): void
{
$user = User::find($userId);
$isSchoolStudent = false;
if ($user->verification_required) {
$photo->verification = 0.1;
if ($photo->team_id) {
$team = Team::find($photo->team_id);
if ($team && $team->isSchool()) {
$photo->verified = VerificationStatus::VERIFIED->value;
$isSchoolStudent = true;
}
}
} else {
$photo->verification = 1;
$photo->verified = VerificationStatus::ADMIN_APPROVED->value;
}
$photo->save();
if (! $isSchoolStudent) {
event(new TagsVerifiedByAdmin(...));
}
}
Key distinction: TagsVerifiedByAdmin fires for ALL non-school users. Trusted users also get verified = ADMIN_APPROVED (photo visible on map). Non-trusted users stay at verified = 0 (photo NOT on map, but user IS on leaderboard).
XP calculation
Upload => 5
Object => 1
Material => 2
Brand => 3
CustomTag => 1
PickedUp => 5
Small => 10
Medium => 25
Large => 50
BagsLitter => 10
Phase 2b: Replace Tags (edit mode)
PhotoTagsController::update() handles PUT /api/v3/tags for replacing all tags on an already-tagged photo. The entire operation is wrapped in DB::transaction():
- Delete all existing PhotoTags + PhotoTagExtraTags
- Reset photo:
summary=null, xp=0, verified=0
- Call
AddTagsToPhotoAction::run() — regenerates summary, XP, fires TagsVerifiedByAdmin
MetricsService::processPhoto() detects prior processing (has processed_at), calls doUpdate() which calculates deltas between old processed_tags and new summary, applies adjustments to all metrics
- Marks
onboarding_completed_at on first tag submission (parity with store()), guarded to non-empty tags. PUT-first-time == POST-first-time: on a never-tagged photo the reset is a no-op and the same AddTagsToPhotoAction::run(..., skipVerification=false) runs → identical verified/XP/metrics for trusted/school/ordinary users. The mobile auto-upload flow tags exclusively via PUT (idempotent).
Frontend edit mode: /tag?photo=<id> loads a specific photo. If it has existing tags, isEditMode=true → uses PUT. If untagged, uses POST. convertExistingTags() transforms API new_tags format back to frontend format (including litter_object_type_id for the type dimension).
Frontend guards: Double-submit prevention via isSubmitting ref. After success, REFRESH_USER() updates the nav XP bar (non-blocking). Stats and photos refresh in parallel via Promise.all().
Security: ReplacePhotoTagsRequest checks $photo->user_id === $this->user()->id. GET_SINGLE_PHOTO calls /api/v3/user/photos which filters by authenticated user.
result_string and total_litter (v4 compatibility — write-only)
GeneratePhotoSummaryService::run() still populates result_string from the summary keys for backward compatibility. Format: category.object qty,category.object qty,... (e.g., smoking.butts 3,food.wrapper 2,). However, no public-facing endpoint reads result_string anymore — all map endpoints (GlobalMapController, DisplayTagsOnMapController, TeamsClusterController, PointsController, FilterPhotosByGeoHashTrait) were updated to select and return summary instead. Both result_string and total_litter columns are now write-only and scheduled for eventual removal.
total_litter → total_tags: All active endpoints now read total_tags instead of total_litter. Fixed: CommunityController, ContributorAggregator, TimeSeriesAggregator, ProfileController (global litter fallback), JoinTeamAction (team pivot). Safe (location-level Redis, not photo column): GlobalStatsController, WorldCupController. Safe (correct fallback): CreateCSVExport. Console commands that read these columns (CompileResultsString, ResetResultString) have been deleted. Dead jobs deleted: Api/AddTags, Photos/AddTagsToPhoto (both wrote total_litter + verification float).
Summary JSON structure
{
"tags": {
"2": {
"65": {
"quantity": 5,
"materials": {"16": 3, "15": 2},
"brands": {"12": 3}
}
}
},
"totals": {
"total_tags": 15, "total_objects": 5,
"by_category": {"2": 10},
"materials": 8, "brands": 3, "custom_tags": 0
},
"keys": {
"categories": {"2": "smoking"},
"objects": {"65": "wrapper"},
"materials": {"16": "plastic"},
"brands": {"12": "marlboro"}
}
}
Photo model hidden attribute
protected $hidden = ['geom'];
Always ensure geom stays in $hidden. If you need coordinates, use lat/lon columns.
Common Mistakes
- Gating summary generation behind trust check. Summary MUST be unconditional. This is the #1 cause of broken metrics for school photos.
- Comparing VerificationStatus enum to int.
$photo->verified >= 2 fails. Use $photo->verified->value >= VerificationStatus::ADMIN_APPROVED->value.
- Dispatching
TagsVerifiedByAdmin for school students. School photos must wait for teacher approval. Only trusted users get immediate dispatch.
- Including
geom in API responses. Binary spatial data. Keep it in $hidden.
- Using
$photo->toArray() for queue responses. The Location model's updatedAtDiffForHumans accessor crashes on null updated_at. Build response arrays manually when including country relation. See AdminQueueController for pattern.
- Passing null datetime to
UploadPhotoAction::run(). The method type-hints Carbon $datetime. If EXIF has no datetime, getDateTimeForPhoto() returns null. Validation must reject first; controller has ?? Carbon::now() safety fallback.
- Not guarding
dmsToDec() against zero denominators. EXIF GPS values are "numerator/denominator" format. If denominator is 0 in any of the 6 components (degrees/minutes/seconds for lat and lon), division crashes. The function now returns null instead.
- Rejecting 0,0 coordinates. Photos at latitude 0, longitude 0 are valid (Gulf of Guinea). Do not reject
0,0 — only reject null.
- Forgetting
city_id in factory. PhotoFactory doesn't include city_id by default. Add 'city_id' => City::factory() when testing location-dependent features.
- Confusing
category_litter_object_id with category_id. Phase 1 adds category_litter_object_id (FK to category_litter_object pivot) and litter_object_type_id (FK to litter_object_types) to photo_tags. Both are nullable in Phase 1. The existing category_id and litter_object_id columns remain and are still the authoritative source until Phase 3.
- Returning
'tags' instead of 'new_tags' in upload controller. UsersUploadsController must return tags under the key 'new_tags' — the Uploads.vue frontend reads photo.new_tags for tag counts and objects list.
- Using
where('verified', 0) or doesntHave('photoTags') for untagged filter. Use whereNull('summary') — summary is set by GeneratePhotoSummaryService when tags are added, regardless of verification status. After "leaderboard immediate credit," untrusted users' verified stays at 0 after tagging, so where('verified', 0) includes tagged photos.
- Not including
litter_object_type_id in photo response. UsersUploadsController::getNewTags() must include litter_object_type_id so the frontend can preserve the type dimension on edit round-trips.
- Replace tags without
DB::transaction(). If AddTagsToPhotoAction::run() fails after old tags are deleted, the photo loses all tag data. The entire delete-reset-add sequence must be atomic. AddTagsToPhotoAction::run() itself is also wrapped in a transaction — both operations are independently protected.
getNewTags() conditionally includes category/object. For extra-tag-only PhotoTags (brand/material/custom-only), category and object fields are null. The serializer only includes them when both category_id and litter_object_id are non-null. Frontend must handle null category/object gracefully.
- Expecting
UploadPhotoRequest errors to match Laravel's default shape. UploadPhotoRequest overrides failedValidation() to return a custom { success, error, message, errors } shape with typed error codes. Do not assert the standard Laravel { errors: { field: [...] } } shape for upload failures.
- Adding
image/dimensions back unconditionally, running raw exif_read_data() on HEIC, or swapping the converter to ImageMagick. Laravel's image rule excludes HEIC and dimensions (getimagesize()) returns false for HEIC — UploadPhotoRequest::rules() drops both for HEIC (detected via MakeImageAction::isHeic()), keeping mimes+max. after() also skips the raw exif_read_data() GPS/datetime checks for HEIC (PHP can't read HEIC EXIF pre-conversion → false no_exif); the controller validates GPS from the converted JPEG instead and returns no_gps if genuinely missing. And MakeImageAction converts via heif-convert (libheif; path from config('services.heif_convert.path')), NOT ImageMagick convert/magick — IM's HEIC delegate is unreliable across variants. Reintroducing any of these breaks HEIC upload (silent client/server rejection, or conversion failure).