maize-tech/laravel-badges
Laravel Badges lets you attach badges and achievements to any Eloquent model via a simple trait. Define badges, assign or revoke them, check ownership, and build gamification features like user rewards with a clean, fluent API.
This release (1.2.0) is a maintenance drop: PHPStan cleanups, a CI action bump, and — most importantly — Laravel 12 support. There are no breaking changes, so existing installs on 1.1.0 can upgrade freely.
Minimal setup:
composer require maize-tech/laravel-badges
php artisan vendor:publish --provider="Maize\Badges\BadgesServiceProvider" --tag="badges-migrations"
php artisan migrate
Then add the package trait to any model that should be able to receive badges:
use Maize\Badges\HasBadges;
class User extends Authenticatable
{
use HasBadges;
}
The first productive use case is awarding a badge to a user when they hit a milestone:
$badge = Badge::create([
'name' => 'Early Adopter',
'description' => 'Joined in the first week',
]);
$user->assignBadge($badge);
Before writing feature code, check two things in the repo:
Badge model contract — whether name is unique and what fields are fillable, so seeders don't fail silently.If you're on Laravel 12, 1.2.0 is the first version to add it; pin ^1.2 in composer.json rather than ^1.1.
Seeding a badge catalogue. Treat badges as reference data, not user-generated content. Define them in a seeder and look them up by a stable key (slug or name) rather than hardcoding IDs:
$badge = Badge::firstOrCreate(
['name' => 'profile-complete'],
['description' => 'Filled in every profile field']
);
Awarding from domain events. The cleanest integration is a listener on your own events, keeping badge logic out of controllers:
class AwardProfileBadge
{
public function handle(ProfileCompleted $event): void
{
$event->user->assignBadge(
Badge::where('name', 'profile-complete')->firstOrFail()
);
}
}
Making assignment idempotent. Wrap assignment in a check so repeated events don't duplicate rows or throw on a unique constraint:
if (! $user->hasBadge($badge)) {
$user->assignBadge($badge);
}
Displaying in Blade / API. Eager-load the relationship to avoid N+1 in listings:
$users = User::with('badges')->paginate();
Then map to a resource so the frontend isn't coupled to the package's column names:
'badges' => $user->badges->map(fn ($b) => [
'name' => $b->name,
'description' => $b->description,
]);
Testing. Use the trait on your User factory model and assert against the relationship rather than the package internals:
$user->assignBadge($badge);
$this->assertTrue($user->fresh()->hasBadge($badge));
Because assignment goes through a polymorphic relation, tests that swap models or use RefreshDatabase with multiple badgeable types should assert on badgeable_type explicitly if you're using a morph map.
Upgrade on 1.2.0 is safe, but verify Laravel 12 constraints. The release adds Laravel 12 support alongside existing versions. If you're pinned to Laravel 11, nothing regresses — but if you're upgrading to Laravel 12, this is the minimum version to use. Run composer why-not maize-tech/laravel-badges if the install won't resolve.
PHPStan fixes are not runtime behaviour. The two PHPStan PRs may change type hints or docblocks. If you extend the package's classes or override methods, re-run your own static analysis after upgrading rather than assuming a patch-only bump is inert.
Polymorphic duplicates. Many badge packages allow the same badge to be attached multiple times to the same model. If you need "award once" semantics, enforce it at the database level with a unique index on (badge_id, badgeable_type, badgeable_id) — relying on application-layer checks alone will race under concurrency.
Morph map collisions. If you use Relation::enforceMorphMap(), the badgeable_type values already stored will not match new morph aliases. Decide on the morph map before awarding any badges in production, or write a migration to rewrite existing rows.
Revoking and re-awarding. Confirm how the package detaches badges (revokeBadge / detach). If revocation is a hard delete, you lose the audit trail; if that matters, add your own awarded_at column via a migration on the published pivot/model table rather than fighting the package schema.
Cache invalidation. Badge counts are a common dashboard widget. If you cache counts, invalidate on the badges() relation changes — the package won't emit events for you unless the release notes say so, so hook into your own assignment service rather than calling the trait directly from many call sites.
Debugging missing badges. When $user->badges is empty despite a successful assignBadge, check in this order: (1) the migration actually ran, (2) the trait is on the exact model class being saved, (3) the morph type stored matches the class or alias, (4) you're reading a stale model instance — $user->refresh()->badges resolves most confusion.
Extension point. The package's service provider is the place to swap implementations. If you need custom badge metadata beyond name/description, prefer adding columns to the badges table via your own migration over forking the package — that survives upgrades to 1.3+ cleanly.
How can I help you explore Laravel packages today?