laravel-merit maintained by ram64
Laravel Merit
Achievements, XP and levelling for Laravel. Each achievement is a PHP class in your application; the database stores only each achiever's progress. Adding an achievement needs no migration, and deleting one leaves its stored rows orphaned rather than broken.
Requires PHP 8.3+ and Laravel 12.25+ or 13.
Installation
composer require ram64/laravel-merit
Publish and run the migrations. They create three tables:
achiever_achievements, experiencers and experience_logs.
php artisan vendor:publish --tag="merit-migrations"
php artisan migrate
Optionally publish the config file to config/merit.php:
php artisan vendor:publish --tag="merit-config"
Configuration
Every option can be set from the environment.
| Key | Env | Default | Purpose |
|---|---|---|---|
achievements_namespace |
MERIT_ACHIEVEMENTS_NAMESPACE |
App\Definitions\Achievements\ |
Namespace of your definitions |
achievements_path |
MERIT_ACHIEVEMENTS_PATH |
app/Definitions/Achievements |
Directory scanned for definitions (relative to app/) |
achievements_table |
MERIT_ACHIEVEMENTS_TABLE |
achiever_achievements |
Progress table |
cache_ttl |
MERIT_CACHE_TTL |
10 |
Minutes to cache discovery, records and display text |
queue_achievements |
MERIT_QUEUE_EVENTS |
false |
Run check() on a queued job instead of inline |
queue_connection |
MERIT_QUEUE_CONNECTION |
null |
Connection for that job |
queue_name |
MERIT_QUEUE_NAME |
null |
Queue for that job |
cleanup_on_delete |
MERIT_CLEANUP_ON_DELETE |
true |
Delete an achiever's rows when it is hard deleted |
debug.enabled |
MERIT_DEBUG |
false |
Log every step of a check (see debug.log_channel) |
experience.* |
Tables, logging, and the level ladder; see Experience and levels |
The migrations read the table names from the config, so set any custom names before running them.
Achievements
Add the trait
use Ram64\LaravelMerit\Concerns\CanAchieve;
class User extends Authenticatable
{
use CanAchieve;
}
Define an achievement
php artisan make:merit-achievement FirstPostAchievement
The command asks for the goal, any prerequisites, a description and whether
the achievement is secret, then writes
app/Definitions/Achievements/FirstPostAchievement.php with the key
first_post. Fill in how progress is measured:
namespace App\Definitions\Achievements;
use Illuminate\Database\Eloquent\Model;
use Ram64\LaravelMerit\Definitions\Achievements\AchievementDefinition;
class FirstPostAchievement extends AchievementDefinition
{
public static function key(): string
{
return 'first_post';
}
public static function name(?Model $achiever = null): string
{
return 'First Post';
}
public static function description(?Model $achiever = null): string
{
return 'Create your first post.';
}
public static function goal(): int
{
return 1;
}
public static function category(): string
{
return 'posts';
}
public static function qualifiesFor(Model $achiever): bool
{
return static::getCount($achiever, 'posts') >= static::goal();
}
public static function calculateCurrentProgress(Model $achiever): int
{
return min(static::getCount($achiever, 'posts'), static::goal());
}
}
Extend AchievementDefinition, not BaseAchievementDefinition: the tracker
relies on the prerequisite support the former adds.
Track progress
use Ram64\LaravelMerit\Services\AchievementTracker;
// Re-evaluate every achievement for this user. Runs inline, or on a queued
// job when merit.queue_achievements is true.
AchievementTracker::check($user);
// Always inline.
AchievementTracker::processCheck($user);
// Award outright, or move progress by hand.
AchievementTracker::award($user, 'first_post');
AchievementTracker::increment($user, 'prolific_poster', 1);
A check asks each definition for calculateCurrentProgress() and stores the
result against the definition's current goal(). A progress row is created
only once an achievement has some progress, so an achievement nobody has
started has no row at all. Reaching the goal completes and unlocks the
achievement in the same pass, and an unlocked achievement stays unlocked
whatever its progress does afterwards; a check does not even recompute it.
A check reads all of the achiever's rows in one query. The unlock itself is a conditional update, so two checks racing over the same achiever unlock each achievement once and announce it once.
Prerequisites
public static function prerequisite(): string|array|null
{
return 'first_post'; // a key
return FirstPostAchievement::class; // or a class
return ['first_post', 'first_comment']; // ALL of these
}
An array means every prerequisite must be unlocked; there is no "any of". Achievements are evaluated in dependency order, so a chain can complete in a single check. Until its prerequisites are unlocked an achievement is not evaluated at all, even if the achiever already qualifies for it.
Secret achievements
Return true from isSecret() and, until it is unlocked,
getDisplayName($user) and getDisplayDescription($user) return placeholder
text instead of the real thing. Override hiddenName() and
hiddenDescription() to change the placeholders. The achievement itself is
still listed; only its text is hidden.
Reading state
FirstPostAchievement::getProgress($user); // int
FirstPostAchievement::getProgressPercentage($user); // float
FirstPostAchievement::isUnlocked($user);
FirstPostAchievement::isVisibleTo($user);
AchievementTracker::getAchievementDisplay($user, 'first_post'); // everything above, cached
AchievementTracker::getAchievementDisplayMany($user, $keys); // the same for a page of them
AchievementTracker::getAllAchievements(); // definition classes, in dependency order
AchievementTracker::getVisibleAchievements($user); // without locked secrets
Events
AchievementStarted, AchievementProgressed (carries the new total),
AchievementCompleted and AchievementUnlocked, all in
Ram64\LaravelMerit\Events, each with the stored record and the achiever.
They fire after the row is saved, so a listener reading the achiever's state
sees the change. That makes them at most once: a listener that throws leaves
the unlock stored, and it is not announced again. Listeners run
synchronously unless the listener implements ShouldQueue.
Experience and levels
use Ram64\LaravelMerit\Concerns\CanExperience;
class User extends Authenticatable
{
use CanExperience;
}
$user->gainExperience(100, 'Completed a quest');
$user->loseExperience(25, 'Penalty');
$user->setExperience(500);
$user->setLevel(5);
$user->getExperience();
$user->getLevel();
$user->getExperienceProgress(); // ['current' => ..., 'required' => ..., 'percentage' => ...]
Levels come from merit.experience.level_calculation: static reads the
level_thresholds ladder, and dynamic evaluates level_formula for each
level up to max_level. The formula is arithmetic on $level (numbers,
+ - * / % **, parentheses and common math functions, never eval()), or
the name of an invokable class.
Gaining experience can raise the level; losing it never lowers the level.
Only setLevel() can lower one, and only when allow_level_decrease is
true, in which case experience comes down to the new level's threshold too.
Each change locks the experiencer row, so concurrent awards do not overwrite
each other.
Each change is written to experience_logs unless logging_enabled is false,
and fires ExperienceGained, ExperienceLost or LevelledUp.
Caching
Each stored record, and each achievement's name and description, is cached
per achiever for cache_ttl minutes and cleared when the tracker changes the
row it describes. The list of discovered definitions is cached for the same
time, so after a deploy that adds a definition, clear it (or the whole cache)
for the new achievement to appear straight away:
AchievementTracker::clearDiscoveryCache();
To render a list, read the text of every achievement in one cache round trip rather than two per achievement:
$text = AchievementTracker::getDisplayTextMany($user, ['first_post', 'night_owl']);
// [
// 'first_post' => ['name' => 'First Post', 'description' => '...'],
// 'night_owl' => ['name' => 'Hidden Achievement', 'description' => '...'],
// ]
The output matches getDisplayName() and getDisplayDescription() for every
key, secret placeholders included. Unknown keys are left out.
getAchievementDisplayMany() does the same for the full payload, building
anything not cached from one query.
Deleting an achiever
Deleting a model also deletes its achievement progress, experience and
experience log, on a hard delete only: a soft-deleted model keeps them, so a
restore brings them back. Set cleanup_on_delete to false to keep them
always. A mass delete through the query builder fires no model events and is
not affected.
Documentation
- Prerequisites
- Experience and levels
- Secret achievements
- Caching
- Debug mode
- make:merit-achievement
- Publishing assets
Testing
composer test
composer analyse
Changelog
Please see CHANGELOG for what has changed recently.
Security Vulnerabilities
Please report security issues privately by email to contact@ram64.com rather than in a public issue.
Credits
License
The MIT License (MIT). Please see License File for more information.