qcod/laravel-gamify
Quickly add reputation points and badges to your Laravel app. Create point classes, award them to users, and unlock badges with an easy-to-use trait and migrations.
qcod/laravel-gamify is a small, conventional Laravel package: one service provider, publishable config/migrations, a Gamify trait on your payee model, and generated PointType / BadgeType classes. Day-to-day, treat it as your reputation domain layer rather than scattering reputation++ calls through controllers. Current releases track modern Laravel versions, so it is not a legacy-only dependency.
Minimum install
composer require qcod/laravel-gamify
# Needed by the published migration that alters the users table on many Laravel versions
composer require doctrine/dbal
php artisan vendor:publish --provider="QCod\Gamify\GamifyServiceProvider" --tag="migrations"
php artisan vendor:publish --provider="QCod\Gamify\GamifyServiceProvider" --tag="config"
php artisan migrate
On Laravel 5.4 or lower, register QCod\Gamify\GamifyServiceProvider manually. On 5.5+ auto-discovery handles it.
Add the trait to your user/payee model:
use QCod\Gamify\Gamify;
class User extends Authenticatable
{
use Gamify;
}
If you do not need badges, use QCod\Gamify\HasReputation instead of Gamify to avoid badge evaluation overhead.
First point type
php artisan gamify:point PostCreated
Edit app/Gamify/Points/PostCreated.php:
namespace App\Gamify\Points;
use QCod\Gamify\PointType;
class PostCreated extends PointType
{
public $points = 20;
protected $payee = 'user';
public function __construct($subject)
{
$this->subject = $subject;
}
}
First award
$post = $request->user()->posts()->create($validated);
$request->user()->givePoint(new PostCreated($post));
First undo
$request->user()->undoPoint(new PostCreated($post));
$post->delete();
Look first at:
config/gamify.php — payee model, badge levels, broadcast channel.app/Gamify/Points and app/Gamify/Badges — your domain definitions.reputation column on users.vendor/qcod/laravel-gamify/src/PointType.php and BadgeType.php — base-class contracts.givePoint, undoPoint, getPoints, addPoint, reducePoint, resetPoint.A good first use case is deliberately small: award points when a post is created, show $user->getPoints() on the profile, and add badges only after the point flow is stable.
Award from one boundary. For simple apps, awarding in the controller is fine. For anything with multiple entry points, use a model observer, domain event listener, or service so points are never missed.
namespace App\Observers;
use App\Gamify\Points\PostCreated;
use App\Models\Post;
class PostObserver
{
public function created(Post $post): void
{
$post->user->givePoint(new PostCreated($post));
}
public function deleting(Post $post): void
{
$post->user->undoPoint(new PostCreated($post));
}
}
Use deleting, not deleted, so the subject relationship is still available. Wrap award/undo logic in a transaction when the point and the domain write must stay consistent.
Configure the point type, not the call site. Most variation belongs in the PointType class:
public $points = 20;getPoints(): intprotected $payee = 'user';payee()qualifier() and return a booleanpublic $allowDuplicates = false;class PostCreated extends PointType
{
protected $payee = 'user';
public $allowDuplicates = false;
public function getPoints(): int
{
return $this->getSubject()->is_featured ? 50 : 20;
}
public function qualifier($user): bool
{
return $user !== null;
}
}
Use the reputation log as an audit trail. $user->getPoints() returns the integer total; $user->getPoints(true) returns the formatted UI value such as 1K+. Do not use the formatted value for sorting or business logic.
History is available through $user->reputations. Each reputation exposes name, payee, point, and subject. To query points by subject, add a morph relation on the subject model:
public function reputations()
{
return $this->morphMany(\QCod\Gamify\Reputation::class, 'subject');
}
Then $post->reputations gives you the points awarded on that post. For dashboards, query the reputation model directly, eager-load payee and subject, paginate, and group by point name. This table grows forever, so plan indexes and pagination early.
Badges are reactive. Generate with php artisan gamify:badge FirstContribution, then define the qualifier:
namespace App\Gamify\Badges;
use QCod\Gamify\BadgeType;
class FirstContribution extends BadgeType
{
protected $description = 'Awarded for the first accepted contribution';
protected $name = 'First Contribution';
protected $icon = 'images/badges/first-contribution.svg';
protected $level = 1;
public function qualifier($user)
{
return $user->getPoints() >= 1000;
}
}
The package evaluates badges when reputation changes and assigns those the user qualifies for. Read them via $user->badges. Badge names derive from the class name unless you set $name; icons and levels can also be set by property or getName() / getIcon() / getLevel() overrides. Clear the badge cache after adding, removing, or renaming badges:
php artisan cache:forget gamify.badges.all
If that command is unavailable on your Laravel version, use Cache::forget('gamify.badges.all') in Tinker or a deployment script.
Backfill badges explicitly. Because badges are evaluated on reputation change, users who already qualify before a badge is introduced will not receive it until their next point event. Write a one-off command that loops users, runs qualifier(), and attaches missing badges through your badge relation. The package does not ship a general backfill command.
Live updates. The package fires QCod\Gamify\Events\ReputationChanged with $user, $point, and $increment. It broadcasts on the configured channel prefix, by default user.reputation.{userId}. Listen on the frontend with Echo on that private channel, and ensure your queue worker is running if the event is queued. Private channels require broadcasting auth.
Simple counters without history. If you only need a reputation number and do not want the audit log:
$user->addPoint(5);
$user->reducePoint(2);
$user->resetPoint();
This bypasses PointType generation. It is useful for imports, migrations, or high-volume counters where an audit trail is not required. Verify whether your badge workflow still triggers on these methods before relying on them for badge awarding.
Test the types directly. Use RefreshDatabase, create a user and subject, call givePoint, then assert the aggregate and history:
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$user->givePoint(new PostCreated($post));
$this->assertSame(20, $user->fresh()->getPoints());
$this->assertCount(1, $user->fresh()->reputations);
Also test duplicate prevention, undo behavior, and badge qualification. Disable model observers in tests if they would double-award points.
Custom models are possible. You can swap payee_model, reputation_model, and badge_model in config. Custom reputation and badge models should extend the package models and support the same relations/columns. Update the config before writing custom queries against the package tables.
payee_model for modern Laravel. The default config may point at \App\User, while Laravel 8+ commonly uses App\Models\User. If the payee model is wrong, point awarding and relations can fail or resolve the wrong class.composer require doctrine/dbal before migrating. The published migration adds a reputation column to users. On many Laravel versions, the schema change needs Doctrine DBAL. If migration fails, install it and rerun, or hand-write the column migration.gamify.badges.all. Adding, removing, renaming, or changing a badge class without clearing this cache leads to confusing “badge not awarded” behavior.givePoint(new PostCreated($post)) uses auth()->user() as the payee. In jobs, commands, webhooks, impersonation, or admin actions, pass the payee explicitly or call $post->user->givePoint(...).deleting, not deleted, for undo. Once the subject is gone, its relations and morph target may be unavailable. Undo the reputation first, then delete. Pass the original payee explicitly if an admin is performing the delete.allow_reputation_duplicate config and the per-point $allowDuplicates = false property both matter. Set them intentionally. Duplicate checks are generally tied to the point type and subject, so define one point class per logical award.getPoints() must return an integer. Dynamic point calculations should not return floats or formatted strings. Keep formatting at the presentation layer.protected $payee = 'user' assumes the subject has a user relation. For anonymous content, guest posts, or soft-deleted owners, use qualifier() to reject the award or override payee() with safe fallback logic.$icon property needs the full path with extension; config’s badge_icon_folder and badge_icon_extension are ignored in that case. Otherwise the config folder/extension are used.user_badges rows may still point at the old badge record. Plan a data migration if badge identity matters.HasReputation when badges are unnecessary. The full Gamify trait evaluates badges on reputation changes. If you only need points and history, the smaller trait avoids unnecessary queries and cache dependency.App\Gamify\Points and App\Gamify\Badges. If your application namespace is not App, move the generated files and update namespaces manually.How can I help you explore Laravel packages today?