| name | tagging-system |
| description | PhotoTag, PhotoTagExtraTags, categories, litter objects, materials, brands, ClassifyTagsService, GeneratePhotoSummaryService, tag migration, and the v4-to-v5 conversion. |
Tagging System
V5 uses a normalized hierarchy: Photo -> PhotoTag (category + object + quantity) -> PhotoTagExtraTags (materials, brands, custom tags). All tag data lives in photo_tags and photo_tag_extra_tags tables — not the old per-category tables.
V5.1 Architecture (Phase 1 complete — schema + seed only, no behavior changes): Added LitterObjectType dimension ("what was in the container" — beer, water, soda, etc.), category_object_types pivot controlling which types are valid per category+object combo, and category_litter_object_id/litter_object_type_id nullable FK columns on photo_tags. Full spec: readme/TaggingArchitectureSpec.md.
Key Files
app/Models/Litter/Tags/PhotoTag.php — Primary tag record (category + object)
app/Models/Litter/Tags/PhotoTagExtraTags.php — Materials, brands, custom tags per tag
app/Models/Litter/Tags/Category.php — Tag categories (smoking, food, etc.)
app/Models/Litter/Tags/LitterObject.php — Taggable objects (butts, wrapper, etc.)
app/Models/Litter/Tags/BrandList.php — Brand records (brandslist table)
app/Models/Litter/Tags/Materials.php — Material records (materials table)
app/Models/Litter/Tags/CustomTagNew.php — Custom tags (custom_tags_new table)
app/Models/Litter/Tags/CategoryObject.php — Pivot: category_litter_object + types() BelongsToMany
app/Models/Litter/Tags/LitterObjectType.php — Type lookup: "what was in the container" (beer, water, etc.)
database/seeds/Tags/GenerateTagsSeeder.php — Seeds all categories, objects, CLO pivots, materials, and types from TagsConfig. Also ensures unclassified system category exists.
app/Services/Tags/ClassifyTagsService.php — Tag classification + deprecated key mapping
app/Services/Tags/UpdateTagsService.php — V4->V5 migration per photo
app/Services/Tags/GeneratePhotoSummaryService.php — Summary JSON + XP from PhotoTags
app/Services/Tags/XpCalculator.php — XP scoring rules
app/Enums/Dimension.php — Tag type enum (object, category, material, brand, custom_tag)
Invariants
photo_tags uses FK columns: category_id and litter_object_id (not string columns). Tests must create Category/LitterObject records and use their IDs. These columns are now NULLABLE — extra-tag-only tags (brands, materials, custom tags) can exist without a litter object.
photo_tag_extra_tags is polymorphic: tag_type is 'material'|'brand'|'custom_tag', tag_type_id is the FK to the respective table.
- Namespace is
App\Models\Litter\Tags\PhotoTag, not App\Models\PhotoTag.
- Summary generation MUST follow any tag change. Call
$photo->generateSummary() after creating/updating/deleting PhotoTags.
- Unknown tags are auto-created:
LitterObject::firstOrCreate(['key' => $key], ['crowdsourced' => true]).
- Loose PhotoTags (nullable CLO).
category_litter_object_id, category_id, litter_object_id are all nullable. AddTagsToPhotoAction::createExtraTagOnly() creates standalone extra-tag PhotoTags with null CLO fields. GeneratePhotoSummaryService counts objects only when objectId > 0 (variable renamed $totalLitter → $totalObjects). XpCalculator awards object XP only when object_id > 0 — extra-tag-only tags don't get phantom object XP. Frontend useXpCalculator.js mirrors this logic.
- No unique constraint on
photo_tags for (CLO, type) pairs. There is no DB-level unique constraint on (photo_id, category_litter_object_id, litter_object_type_id). Duplicate CLO+type pairs are possible (each is a separate PhotoTag row). Do NOT assume uniqueness. Extra-tag deduplication (materials/brands within a single tag) is handled via upsert inside a single PhotoTag's extra tags, not across multiple PhotoTag rows.
getNewTags() serializer contract. UsersUploadsController::getNewTags() conditionally includes category and object only when both category_id and litter_object_id resolve. For extra-tag-only PhotoTags (brand/material/custom-only), category and object are returned as null. Always includes litter_object_type_id (may be null), quantity, picked_up (cast to bool with photo-level fallback), and extra_tags array.
Patterns
Creating a tag with extras
$photoTag = PhotoTag::create([
'photo_id' => $photo->id,
'category_id' => $category->id,
'litter_object_id' => $object->id,
'quantity' => 5,
'picked_up' => true,
]);
$photoTag->attachExtraTags([
['id' => $plasticId, 'quantity' => 5],
['id' => $paperId, 'quantity' => 3],
], 'material', 0);
$photoTag->attachExtraTags([
['id' => $marlboroId, 'quantity' => 3],
], 'brand', 0);
Custom-tag-only tags (no category/object)
$photoTag = PhotoTag::create([
'photo_id' => $photo->id,
'custom_tag_primary_id' => $customTag->id,
'quantity' => $quantity,
'picked_up' => $pickedUp,
]);
Brand-only tags (no specific object)
$photoTag = PhotoTag::create([
'photo_id' => $photo->id,
'category_id' => Category::where('key', 'brands')->value('id'),
'quantity' => array_sum($brandQuantities),
]);
$photoTag->attachExtraTags($brands, Dimension::BRAND->value, 0);
Deprecated key normalization (v4 -> v5)
130+ mappings from old camelCase keys to normalized keys with inferred materials.
Category aliases (CATEGORY_ALIASES)
ClassifyTagsService::CATEGORY_ALIASES resolves deprecated v4 category keys: coastal→marine, trashdog→pets, dogshit→pets, automobile→vehicles, pathway→unclassified, drugs→unclassified, political→unclassified, stationery→unclassified. The public getCategory(string $rawKey) method checks aliases before DB lookup.
TagsConfig defines 16 active categories (ordered alphabetically): alcohol, art, civic, coffee, dumping, electronics, food, industrial, marine, medical, other, pets, sanitary, smoking, softdrinks, vehicles. The unclassified system category is NOT in TagsConfig but is created by GenerateTagsSeeder for v4 alias resolution.
Dimension enum
enum Dimension: string
{
case LITTER_OBJECT = 'object';
case CATEGORY = 'category';
case MATERIAL = 'material';
case BRAND = 'brand';
case CUSTOM_TAG = 'custom_tag';
public function table(): string
public static function fromTable(string $table): ?self
}
Database schema
photo_tags (
id, photo_id, category_id, litter_object_id,
category_litter_object_id,
litter_object_type_id,
custom_tag_primary_id,
quantity, picked_up,
created_at, updated_at
)
photo_tag_extra_tags (
id, photo_tag_id,
tag_type,
tag_type_id,
quantity, index,
created_at, updated_at
)
categories (id, key, parent_id)
litter_objects (id, key, crowdsourced)
litter_object_types (id, key, name)
materials (id, key)
brandslist (id, key, crowdsourced)
custom_tags_new (id, key)
category_litter_object (id, category_id, litter_object_id)
category_object_types (
category_litter_object_id,
litter_object_type_id,
UNIQUE(category_litter_object_id, litter_object_type_id)
)
TagKeyCache for performance
use App\Services\Achievements\Tags\TagKeyCache;
$id = TagKeyCache::idFor('material', 'glass');
$id = TagKeyCache::getOrCreateId('material', 'glass');
$key = TagKeyCache::keyFor('material', $id);
TagKeyCache::preloadAll();
Three-layer cache: in-memory array -> Redis hash (24h TTL) -> database fallback.
Web Frontend Tag Types (POST /api/v3/tags)
The Vue frontend sends 4 distinct tag types to AddTagsToPhotoAction:
1. Object tag (with optional materials/brands/custom_tags)
{ "object": { "id": 5, "key": "butts" }, "quantity": 3, "picked_up": true,
"materials": [{ "id": 2, "key": "plastic" }], "brands": [], "custom_tags": [] }
Backend auto-resolves category from object->categories()->first(). Category need NOT be sent.
Materials and brands accept flexible formats:
- Materials:
[50, 51] (plain IDs) or [{"id": 50}] (objects). Quantity inherits from parent tag.
- Brands:
[10] (plain IDs, quantity=1) or [{"id": 10, "quantity": 3}] (objects with per-brand quantity).
attachMaterials() and attachBrands() both check is_array($item) ? $item['id'] : $item.
2. Custom-only tag
{ "custom": true, "key": "dirty-bench", "quantity": 1, "picked_up": null }
$tag['custom'] is boolean true (flag), $tag['key'] is the actual tag name. Creates CustomTagNew via $tag['key'].
Custom tag sanitization (AddTagsToPhotoAction::attachCustomTags). Custom tag keys are free text — there is no allowlist regex. The key is sanitized with mb_substr(trim(strip_tags($key)), 0, 255) (caps to the custom_tags_new.key varchar(255)) and accepted, including punctuation like & . ' / (real brand/product names, e.g. "Black & Mild"). An empty-after-sanitize key is skipped (continue) — never thrown. Do NOT reintroduce a throwing allowlist: the throw lived inside run()'s DB::transaction, so one bad custom tag would 500 the request and roll back the user's valid object tags. Same path for standalone (createExtraTagOnly) and object-attached (createTagFromClo) custom tags. (bn:→brand resolution is a separate deferred ticket; bn: currently stores as a literal custom string.)
3. Brand-only tag
{ "brand_only": true, "brand": { "id": 1, "key": "coca-cola" }, "quantity": 1 }
Creates PhotoTag with null category/object, attaches brand as extra tag.
4. Material-only tag
{ "material_only": true, "material": { "id": 2, "key": "plastic" }, "quantity": 1 }
Same pattern as brand-only — PhotoTag with null FKs, material as extra tag.
GET /api/tags/all response (v5.1)
{
"categories": [{"id": 1, "key": "alcohol"}],
"objects": [{"id": 5, "key": "bottle", "categories": [{"id": 1, "key": "alcohol"}]}],
"materials": [{"id": 1, "key": "glass"}],
"brands": [{"id": 7, "key": "heineken"}],
"types": [{"id": 3, "key": "beer", "name": "Beer"}],
"category_objects": [{"id": 42, "category_id": 1, "litter_object_id": 5}],
"category_object_types": [{"category_litter_object_id": 42, "litter_object_type_id": 3}]
}
unclassified category is excluded from the response. category_object_types maps which types are valid per CLO.
Frontend files
| File | Purpose |
|---|
resources/js/views/General/Tagging/v2/AddTags.vue | Main tagging page — dark glass UI, 55/45 split layout, search index with per-(object,category) entries, progress bar, auto-advance, success flash, keyboard shortcuts (/, Escape, J/K/←/→, Enter, Ctrl+Enter, ?), empty state |
resources/js/views/General/Tagging/v2/components/UnifiedTagSearch.vue | Debounced (100ms) search combobox, grouped results (object/type/material/brand/customTag), i18n translated labels, category breadcrumbs, emerald accent |
resources/js/views/General/Tagging/v2/components/TagCard.vue | Tag card with "Object · Category" display, type pills, picked-up pills, dark glass styling, red border on unresolved CLO |
resources/js/views/General/Tagging/v2/components/TaggingHeader.vue | XP bar (emerald), level titles, unresolved tags warning, submit disabled when unresolved, edit mode badge |
resources/js/views/General/Tagging/v2/components/ActiveTagsList.vue | Container for active tags, keyboard hint in empty state |
resources/js/stores/photos/requests.js | UPLOAD_TAGS() → POST, REPLACE_TAGS() → PUT, GET_SINGLE_PHOTO() |
resources/js/stores/user/requests.js | REFRESH_USER() — refreshes user XP/level after tag submission |
resources/js/stores/tags/requests.js | GET_ALL_TAGS() → GET /api/tags/all |
Frontend category disambiguation
The search index generates one entry per (object, category) pair with pre-resolved cloId, categoryId, categoryKey. Each entry has:
label — i18n translated display name via translateTag(key, prefix) (e.g. coke → "Coca-Cola" from litter.brands.coke). Falls back to formatKey() if no translation exists.
categoryLabel — translated category name (e.g. litter.categories.alcohol → "Alcohol")
lowerKey — includes both raw key AND translated label for search matching (e.g. "coke coca-cola")
Translation prefixes: objects use litter.{categoryKey}.{objectKey}, brands use litter.brands.{key}, materials use litter.material.{key}, categories use litter.categories.{key}.
formatKey(key) converts snake_case → Title Case (e.g., six_pack_rings → "Six Pack Rings"). Used as fallback when no i18n translation exists.
hasUnresolvedTags computed blocks submit when any object tag lacks a cloId. Keyboard shortcuts guard against firing inside form inputs (INPUT/SELECT/TEXTAREA).
Dark glass design system
All tagging components use a dark glass UI with emerald accent:
- Background:
bg-gradient-to-br from-slate-900 via-blue-900 to-emerald-900
- Glass panels:
bg-white/5 border border-white/10 rounded-xl
- Accent: Emerald (
text-emerald-400, bg-emerald-500, focus:border-emerald-500/50)
- Text:
text-white / text-white/60 / text-white/40 / text-white/30
Auto-advance flow: Submit → success flash (green border pulse, 400ms) → clear tags → advance to next photo.
Keyboard shortcuts: / focus search, Escape blur/close, J/← prev, K/→ next, Enter confirm (bare), Ctrl+Enter confirm (in input), ? toggle hints.
Common Mistakes
- Using string keys in
photo_tags. The table uses category_id and litter_object_id (integer FKs), not string columns like 'smoking' or 'butts'.
- Forgetting to regenerate summary after tag changes. Always call
$photo->generateSummary() after modifying PhotoTags.
- Looking for PhotoTag in
App\Models\. The namespace is App\Models\Litter\Tags\PhotoTag.
- Confusing
brandslist table name. Not brands — the table is literally brandslist.
- Attaching brands directly to objects. Brand matching is deferred. Brands go through
attachExtraTags() or as brand-only PhotoTags.
- Not handling
custom_tag_primary_id. Custom-only tags have no category_id or litter_object_id — they use custom_tag_primary_id instead.
- Expecting category from frontend. The web frontend sends
object.id but NOT category. Backend auto-resolves category from object->categories()->first().
- Reading
$tag['custom'] as the tag name. It's a boolean flag. The actual name is $tag['key'].
- Checking
$tag['brands'] for brand-only tags. Brand-only tags use $tag['brand'] (singular) + $tag['brand_only'] flag.
- Using
cot.id for type entries. The category_object_types API only returns category_litter_object_id and litter_object_type_id — no id column. Use composite key type-${cot.category_litter_object_id}-${cot.litter_object_type_id}.
- Relying on old localStorage recentTags. Entries from before category disambiguation lack
cloId. Filter them out on mount: parsed.filter((t) => t.type !== 'object' || t.cloId).
- Losing
litter_object_type_id on edit round-trip. UsersUploadsController::getNewTags() must include litter_object_type_id in the response, and convertExistingTags() must read it into typeId. Without this, the type dimension (e.g., "beer" on a "bottle") is lost when editing tags.
- Replace tags without DB::transaction.
PhotoTagsController::update() must wrap delete + reset + add in DB::transaction(). If AddTagsToPhotoAction::run() throws after tags are deleted, the photo loses all data.
- Using
|| instead of ?? for counts that can be zero. photosStore.untaggedStats.leftToTag || fallback treats 0 as falsy. Use ?? (nullish coalescing) to only fall through on null/undefined.
- Assuming one PhotoTag row per (photo, CLO, type). There is no unique constraint. Multiple rows with the same
category_litter_object_id and litter_object_type_id can exist on the same photo. Don't add a UNIQUE index or query logic that assumes uniqueness across rows.
- Expecting
category/object to always be present in getNewTags() output. For brand-only, material-only, or custom-only PhotoTags, category and object are null in the serializer output. The frontend must handle null gracefully.