| name | achievements-system |
| description | AchievementEngine, AchievementRepository, milestone checkers, AchievementsSeeder, user_achievements pivot, AchievementsController API, and achievement evaluation flow. |
Achievements System
Milestone-based achievements unlocked when users hit thresholds for uploads, streaks, and per-tag counts. Definitions are seeded from config/achievements.php milestones crossed with every tag in the system.
Key Files
config/achievements.php — Milestones array, cache TTL, level thresholds
app/Services/Achievements/AchievementEngine.php — Core evaluator (checkers + fallback pass)
app/Services/Achievements/AchievementRepository.php — DB reads/writes + caching
app/Services/Achievements/Checkers/AchievementChecker.php — Abstract base checker
app/Services/Achievements/Checkers/UploadsChecker.php — Uploads dimension
app/Services/Achievements/Checkers/ObjectsChecker.php — Objects dimension + per-object
app/Services/Achievements/Checkers/CategoriesChecker.php — Categories dimension + per-category
app/Services/Achievements/Checkers/MaterialsChecker.php — Materials (extends OptimizedTagBasedChecker)
app/Services/Achievements/Checkers/BrandsChecker.php — Brands (extends OptimizedTagBasedChecker)
app/Services/Achievements/Checkers/CustomTagChecker.php — Custom tags (extends OptimizedTagBasedChecker)
app/Services/Achievements/Checkers/OptimizedTagBasedChecker.php — Abstract base for tag-based checkers
app/Services/Achievements/Tags/TagKeyCache.php — Tag key-to-ID resolution cache
app/Providers/AchievementServiceProvider.php — DI wiring (engine, repository, checkers)
app/Models/Achievements/Achievement.php — Eloquent model
app/Events/AchievementsUnlocked.php — Event fired when achievements unlock
app/Http/Controllers/Achievements/AchievementsController.php — API endpoint
database/seeds/AchievementSeeder.php — Seeds achievement definitions
database/factories/AchievementFactory.php — Test factory
tests/Feature/Achievements/AchievementEngineTest.php — Engine integration tests
tests/Feature/Achievements/LongTermAchievementsTest.php — Long-term achievement tests
tests/Unit/Achievements/CheckerUnitTest.php — Checker unit tests
Invariants
- Achievements are evaluated after Redis metrics update.
AchievementEngine::evaluate() reads from RedisMetricsCollector::getUserMetrics(). Redis must be updated first.
- Definitions are seeded, not runtime-created.
AchievementsSeeder creates all definition rows. Adding a new tag type or milestone requires rerunning the seeder.
user_achievements pivot has composite PK (user_id, achievement_id). insertOrIgnore prevents duplicates.
- Checkers use early-exit optimization. Achievements sorted by threshold ascending — once a threshold isn't met, remaining are skipped.
- Fallback pass in engine catches checker gaps.
meetsThreshold() runs after all checkers, catching anything they missed (safety net for off-by-one or unregistered checkers).
- Caching: Definitions cached 24h (
achievements.all), per-user unlocked cached 5m (user.achievements.$userId). Cache cleared on unlock.
Architecture
Photo tagged + verified
→ TagsVerifiedByAdmin event
→ ProcessPhotoMetrics listener
→ MetricsService::processPhoto() (MySQL + Redis)
→ EvaluateUserAchievements job (queued)
→ AchievementEngine::evaluate($userId)
→ RedisMetricsCollector::getUserMetrics($userId)
→ Run all registered checkers
→ Fallback meetsThreshold() pass
→ AchievementRepository::unlockAchievements()
→ Cache invalidation
Achievement Types
| Type | Scope | Example |
|---|
uploads | Dimension-wide (no tag_id) | "Upload 42 photos" |
streak | Dimension-wide (no tag_id) | "7-day upload streak" |
objects | Dimension-wide (no tag_id) | "Tag 1000 total objects" |
object | Per-tag (tag_id = litter_object.id) | "Tag 69 cigarette butts" |
categories | Dimension-wide (no tag_id) | "Tag items in 10 categories" |
category | Per-tag (tag_id = category.id) | "Tag 256 smoking items" |
materials | Dimension-wide (no tag_id) | "Tag 500 total materials" |
material | Per-tag (tag_id = material.id) | "Tag 42 plastic items" |
brands | Dimension-wide (no tag_id) | "Tag 100 total brands" |
brand | Per-tag (tag_id = brand.id) | "Tag 69 Coca-Cola items" |
customTag | Per-tag (tag_id = custom_tag.id) | Per custom tag milestones |
Milestones
From config/achievements.php:
[1, 42, 69, 256, 360, 404, 420, 451, 512, 666, 777, 1337, 2048, 3333, 3600, 9001, 13337, 42069, 69420, 133337, 420420, 666666, 696969, 4206969]
Each milestone is crossed with each type/tag combination, producing ~222k achievement definitions.
API
GET /api/achievements (auth required) — Returns hierarchical JSON:
overview: progress for uploads, streak, total_categories, total_objects
categories: per-category with nested per-object progress
summary: total/unlocked/percentage
Common Mistakes
- Evaluating before Redis is updated.
getUserMetrics() reads from Redis. If called before RedisMetricsCollector::processPhoto(), counts are stale.
- Forgetting to reseed after adding tags. New LitterObjects/Materials/Brands need new achievement definition rows.
- Assuming checkers cover all types. The fallback
meetsThreshold() is the safety net. If a checker is missing, achievements still unlock (just slower).