lendable/tag-bundle
Lendable TagBundle provides Doctrine-backed tagging for Symfony apps. Add a Taggable interface to any entity, then use the included Tag and Tagging entities and manager services to attach, detach, and query tags without building custom tag storage or relationships.
Reality check before you install anything. lendable/tag-bundle is a Symfony bundle (Lendable\TagBundle\LendableTagBundle::class), not a Laravel package. As a Laravel developer, composer require lendable/tag-bundle will pull code into vendor/, but nothing will auto-register — there is no TagBundleServiceProvider, no config/tag.php to publish, and Laravel's container has no idea what a Symfony kernel bundle is. Running it in a Laravel app means standing up Doctrine, the Symfony DI container bridge, and event subscribers — a multi-day integration to get a tags table. That is the wrong trade. Treat this package as a reference implementation of polymorphic tagging, not a dependency.
Where to look first (5 minutes well spent). Open vendor/lendable/tag-bundle/src and read three things:
Tag entity — its columns (name? slug? type? normalized form?) and the unique constraint. This is the schema you want to mirror.Taggable interface/trait — what methods it forces (addTag, removeTag, getTags), and whether it manages the inverse side of the ManyToMany.ManyToMany mapping on the taggable side — whether the pivot is (taggable_id, tag_id) only, or includes a discriminator for polymorphism.Copy those three decisions into Eloquent and you have the package's entire value in an afternoon.
First use case — the Laravel-native equivalent. Do not implement the Symfony interface. Build the same shape with Eloquent:
php artisan make:model Tag -m
// tags table
$table->id();
$table->string('name')->unique(); // store the normalized form
$table->string('slug')->unique(); // if you want URL-friendly tag filters
$table->timestamps();
// taggables pivot (polymorphic)
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable'); // taggable_type + taggable_id
$table->unique(['tag_id', 'taggable_type', 'taggable_id']);
class Tag extends Model
{
public function taggables(): MorphToMany
{
return $this->morphedByMany(Article::class, 'taggable');
}
}
trait Taggable
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function tag(string|array $names): void
{
$ids = collect($names)->map(fn ($n) => Tag::firstOrCreate(
['name' => $this->normalize($n)],
['slug' => Str::slug($n)],
)->id);
$this->tags()->syncWithoutDetaching($ids);
}
}
That is the day-one outcome you actually wanted from this package, with no vendor coupling.
When (rarely) the bundle itself is the right call. Only if the host application is Symfony + Doctrine ORM + Flex, where the bundle genuinely saves boilerplate: it auto-wires the Taggable event subscriber and registers the tag entity mapping. In that world the pattern is: implement Taggable, use TaggableTrait, call the registered tag manager service from a controller, and let the subscribers persist the pivot on flush. You are not writing that pattern in Laravel — you are reading it to confirm the schema and lifecycle, then reimplementing.
Normalize at the boundary, not in the database. The package (per its description) stores free-form tags — it does not advertise normalization, deduplication, or hierarchy. That means whether "PHP", "php", and " php " become one tag is your decision, made before the insert. Pick one normalization rule (trim + lowercase + collapse whitespace + optional Str::slug) and apply it in a single TagResolver service:
class TagResolver
{
public function firstOrCreate(string $raw): Tag
{
$name = Str::of($raw)->lower()->squish()->toString();
return Tag::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]);
}
}
Every write path (controller, command, job, seeder) goes through this service. Nothing else constructs Tag directly. This is the single most important pattern — it is what the bundle leaves as an exercise.
Resolve-then-sync, never cascade. The tempting shortcut is to attach unsaved Tag models and rely on cascade persist. That is how you get duplicate tags from two concurrent requests. With Eloquent, use firstOrCreate inside a transaction-backed resolver, then syncWithoutDetaching on the relation:
DB::transaction(function () use ($article, $names) {
$article->tag($names);
});
syncWithoutDetaching is the right primitive here — it is idempotent, so re-tagging the same entity with the same tag is a no-op rather than a unique-constraint violation.
Two read paths, both indexed. You will need (a) tags for an entity and (b) entities for a tag. (a) is a whereMorphExists/eager-load — fine. (b) is the one that bites at scale:
// "all articles tagged 'php', newest first"
$tag = Tag::where('slug', 'php')->firstOrFail();
$articles = $tag->taggables()
->where('taggable_type', Article::class) // constrains the morph
->latest()
->cursorPaginate(50);
Add an index on (tag_id, taggable_type, taggable_id) and be prepared to paginate with cursors — an offset paginate() over a hot tag turns into an unindexed sort as the table grows.
Wire it into the feature that needs it, not the model layer. The generic primitive is only worth its weight once a second entity type needs tags. First consumer: a TagFilter query scope + a <select> facet on one admin index. Second consumer: same trait on Document. Third: you now have a reusable segment substrate for saved views. Do not generalize past the first consumer.
The framework mismatch is the gotcha. Everything else in this list is downstream of it. composer require on a Laravel app will "succeed" and then do nothing — no provider, no config, no migrations. The Bundle suffix and Lendable\TagBundle namespace are Symfony conventions. If your composer.json has no doctrine/orm and no symfony/framework-bundle, you have installed dead weight. Remove it and use the Eloquent pattern above.
Polymorphic pivots cannot have foreign keys — enforce integrity yourself. taggable_type + taggable_id is not a foreign key, so deleting an Article leaves orphaned pivot rows unless you clean up. Two options: a deleted observer that detaches tags, or a scheduled php artisan tags:prune-orphans command. Laravel's morphToMany does not cascade pivot deletes for you. Also watch the taggable_type column — Eloquent stores the FQCN by default, so a class rename orphans every row; add a Relation::enforceMorphMap(['article' => Article::class, ...]) in a service provider before you ship.
Uniqueness index length on MySQL. A unique(['tag_id', 'taggable_type', 'taggable_id']) is fine, but a unique index that includes the tag name string alongside a taggable_type varchar can blow MySQL's 3072-byte index limit on utf8mb4. Store a short slug or a tag_hash and index on that instead of the raw name. This is the kind of thing you discover in production under load, not in a test with ten rows.
Concurrency duplicates. firstOrCreate is a read-then-write under the hood; two parallel requests can both miss and both insert. Rely on the DB unique('name') constraint and catch the QueryException — retry the lookup in the catch. Do not assume firstOrCreate is race-free because it looks atomic.
Case-sensitivity varies by driver. MySQL's default collation is case-insensitive (PHP == php in a unique index), Postgres's is not. If you normalize in PHP (lowercase) and rely on the DB unique constraint, behavior is consistent across drivers. If you normalize only by collation, your tests on SQLite will pass and Postgres will duplicate. Always normalize in code.
The bundle gives you no tag metadata, hierarchy, or permissions. As soon as a stakeholder asks for "tag categories," "tag colors," or "only admins can create tags," you are extending the schema — but that extension sits cleanly on top of a tags table you own, and is a migration you can write in an hour. This is precisely why the abstraction (the table + trait) is worth keeping and the library is worth not keeping: the thing you would need to fork is the thing you should own.
If you must adopt the bundle, pin and vendor it. A zero-star, zero-dependent, single-org bundle has no release cadence, no issue triage, and no security-response pipeline. composer require lendable/tag-bundle:^x.y in production grants its transitive tree execution rights. Audit with composer why and composer licenses, pin the exact version, commit a composer.lock your CI enforces, and treat the source as first-party code (fork into app/Support/Tagging if that is simpler). Document why it is in the tree so the next maintainer doesn't rip it out blindly.
Debugging tips. When tags don't persist on Symfony, the usual culprits in order: (1) the bundle's event subscriber isn't registered — check config/bundles.php and that the Doctrine event subscriber is wired; (2) the Taggable mapping isn't loaded — check doctrine.orm.mappings; (3) the pivot table wasn't migrated because doctrine:migrations:diff didn't see the bundle's mapping. In Laravel, the equivalent first checks are: pivot table exists, morphMap is registered before you query, and the relation is on the owning side (the Article, not the Tag).
The extension point is the resolver, and you already own it. If you later need tag hierarchies, tag metadata, or per-tag permissions, the TagResolver service is where the semantics live — add parent_id, color, or a Tenant-scoped unique index there without touching a single consumer. That is the design the package should have shipped; since it didn't, ship it yourself and move on.
How can I help you explore Laravel packages today?