aliziodev/laravel-taxonomy
Manage categories, tags, and any hierarchical structure in Laravel. Terms live in one table, attach to any model via a polymorphic pivot, and nested-set hierarchies keep ancestor and descendant lookups to a single query.
Build vs. buy for the taxonomy layer. Categorization looks trivial in a spec and isn't: you need one term store shared by many models, slugs unique per type, a parent/child tree, ancestor and descendant lookups that stay fast as the tree deepens, cache invalidation on every write, and repair tooling for when someone writes to the table directly. That is weeks of engineering plus the debugging that follows. This package is a single taxonomies table, one polymorphic pivot, a HasTaxonomy trait, and a nested set maintained in model hooks — adoptable in days. The buy decision is clear; the real work is the integration surface below.
Roadmap unlock — browse and filter surfaces. The scopes are the feature backlog: withTaxonomySlug and filterByTaxonomies (keyed type => slug, perfect for request-driven filters) give you faceted catalog search; withTaxonomyHierarchy gives "show this category and everything under it" in one scope; orderByTaxonomyType gives merchandised ordering; path and full_slug give you SEO-clean URLs like electronics/smartphones without a slug-tree table. If faceted browse, tag pages, or related-content are on the roadmap, this collapses several epics into wiring.
A one-way door the PM should own early: morph_type. The pivot stores taxonomable_id as numeric, UUID, or ULID, and it can't be changed after the first migration without rewriting the table. This is a cross-team architecture decision (ID strategy, service boundaries), not a sprint-level detail. Lock it in the design review before anyone runs migrate, not after.
Import and backfill feasibility — a real budget line. create() costs 4–7 queries per row; bulkCreate() measured 10,000 flat rows at 0.95s / 32 queries against 14.8s / 40,000 queries for a loop, and 1.0s / 37 queries nested against 22.0s / 59,980. That converts "we need a maintenance window and a feature flag for the catalog import" into "a seeder runs in a second." It accepts generators, so memory stays flat on large CSVs. The trade is explicit: model events don't fire, so anything on observers must stay on create() — a scope decision worth writing down, not discovering.
A multi-tenancy decision this package forces, not makes for you. No tenant_id column ships, cache keys are global unless you register resolveCacheScopeUsing(), and the unique(['slug','type','deleted_at']) index blocks two tenants using the same slug in the same type. If we're multi-tenant, "tenant-scoped taxonomy cache + index rewrite" is committed scope in the same epic. If we're not, say so out loud and revisit at the next tenancy conversation — a cross-tenant cache leak is a security incident, not a bug.
Scope discipline: types, not parallel relations. Custom type strings need no registration, and slugs are unique within a type, so featured can be both a category and a tag. The $name relationship parameter is deprecated for removal in 3.0 and selects pivot columns the migration doesn't create. Decision: every taxonomy concept is a type, never a second relationship. This is cheap now and an upgrade tax later.
What we own, and should staff for. The admin UI (the tree editor, drag-and-drop, bulk edit), any term-name localization (the meta JSON column is the hook — decide the schema before content teams fill it), per-term usage counts (there's no models relation; count from the pivot side and treat it as a reporting job), and any approval workflow or per-term permissions. None of that is in the box.
Adopt when:
Look elsewhere when:
belongsToMany, a tags table, and an afternoon beats a dependency.Adoption gates before committing: pin the version explicitly; read the 3.0 deprecation and put the upgrade in the roadmap; run a two-week spike on one secondary surface (a help-center or internal catalog, not the primary storefront); and contract-test the documented examples yourself — the package pins its README examples in its test suite, which tells you the docs are trustworthy, but your integration is still yours to prove.
For executives: This is a buy, not a build. MIT-licensed, no per-seat or per-call cost, and it replaces what is realistically three to five weeks of engineering plus the tree-corruption bugs that always follow. It directly enables features already on the roadmap — faceted browse, category landing pages with clean URLs, tag-based merchandising — at a fraction of the cost of building the hierarchy layer ourselves. Two risks, both containable and both known: the project is a single well-run maintainer with modest adoption, which we mitigate by pinning the version and keeping our integration behind a thin wrapper so we can swap; and any multi-tenant deployment needs explicit cache scoping, which we would scope and fund as part of the same epic rather than discover in production. Recommendation: approve a two-week spike on a secondary surface with a go/no-go at the end.
For engineering: The maintenance hygiene is the strongest signal here — 100% coverage enforced, PHPStan and Pint gating CI, semantic-release on conventional commits, a stated backward-compatibility policy, and documentation examples pinned by tests so the docs can't silently rot. Surface area is small: one table, one pivot, one trait, one manager. The traps are documented and specific, and should go in our onboarding notes on day one: the facade proxies a manager and is not an Eloquent builder (where() lives on the model); attach/sync/detach take IDs, never slugs, and fail silently otherwise; getDescendants()/getAncestors() read lft/rgt off the instance, so call refresh() first or use the id-keyed descendants(); never wrap tree()/flatTree()/getNestedTree() in a second Cache::remember(); never write to taxonomies with DB::table() or delete via a query, or the nested set breaks; and in multi-tenant installs, register the cache scope resolver or one tenant gets another's tree. bulkCreate() for all seeders and imports — 10k rows in ~1s — but not where model events matter. Everything else the package does in its hooks, it does for us.
How can I help you explore Laravel packages today?