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.
Architecture fit. The package occupies a well-defined seam: one taxonomies table plus a polymorphic taxonomables pivot, with hierarchy stored redundantly as both parent_id and nested-set lft/rgt/depth. That is the correct shape for a catalog/classification domain, and the deliberate redundancy is a genuine design decision rather than an accident — nested set gives single-query ancestor/descendant reads, parent_id gives a correctness fallback (ancestors()/descendants()) when raw writes have drifted the bounds. It is a self-contained domain library, not a framework extension, so it does not couple us to application internals beyond Eloquent.
Integration feasibility. Low engineering cost to adopt: composer require, one install command, one migration, one trait on each classifying model. No service provider gymnastics, no queue workers, no external services. The HasTaxonomy trait delivers relation, attach/sync/detach/toggle, type-scoped variants, and a broad scope vocabulary (withTaxonomySlug, withAllTaxonomies, withTaxonomyHierarchy, filterByTaxonomies) which covers ~80% of typical filtering requirements without us writing query code.
Technical risk. The risks are concentrated, not diffuse:
AGENTS.md that pre-documents its own footguns. This is an engineering-credible package with a supply-chain-fragile footprint. Treat any dependency here as "we may inherit maintenance."morph_type (numeric/uuid/ulid) must be correct before first migrate; changing it later means rewriting the pivot. This is a one-way door and must be a gating review item, especially in a UUID/ULID-keyed codebase (the shipped default is uuid, which will silently mismatch integer-keyed models).TaxonomyManager and is not an Eloquent builder — no where(), no delete(). Query building requires importing the model under an alias. Two classes named Taxonomy with different capabilities is a predictable source of developer error and a code-review checklist item.getDescendants()/getAncestors() read lft/rgt off the instance and return an empty collection for a stale parent. bulkCreate() skips model events entirely. These are all "wrong answer, no exception" behaviors — the most expensive class of bug to detect in production.$name relationship parameter is deprecated for removal in 3.0, selecting pivot morph columns the shipped migration does not create. We must forbid its use from day one or we inherit a 3.0 migration.Key questions to resolve before approval.
morph_type; blocks migration.)models relation — counting requires aggregating from the pivot or the inverse side, which is a real product-feature constraint for admin UIs.resolveCacheScopeUsing() is mandatory and the unique(['slug','type','deleted_at']) index must be replaced (no tenant_id column ships).Stack fit. Requires PHP 8.2+ and Laravel 11/12/13. Confirm our fleet is on Laravel 11+ before scoping; this package will not serve an L10 LTS tail without a backport we would own.
Migration path. The package ships its own migration and does not retrofit an existing taxonomy schema. Two viable paths:
| Path | When | Cost |
|---|---|---|
| Greenfield | No existing category/tag tables | Low — install, migrate, add trait |
| Backfill | Existing categories/tags/pivot tables |
Medium — ETL into taxonomies + taxonomables, slug collision handling, rebuildNestedSet() per type, dual-write window |
For backfill, Taxonomy::bulkCreate() is the correct tool and materially changes the cost estimate: documented measurements show 10,000 rows at 0.95s / 32 queries versus 14.8s / 40,000 queries for a create() loop (nested: 1.0s / 37 vs 22.0s / 59,980). Accepts any iterable, so a generator keeps memory flat on large datasets — but it does not fire model events, so any observer-based logic in the backfill must be done explicitly.
Compatibility. Coexistence with an existing taxonomy implementation is possible only if slugs/types are namespaced, because slug uniqueness is scoped to (slug, type) within a single table. Plan an explicit cutover rather than a long parallel run.
Sequencing.
morph_type against real models, measure nested-set read latency under our data volume, verify bulkCreate on a production-shaped dataset.morph_type, cache.ttl, cache.scope, migrations.autoload (must be false for per-tenant migration runs). Treat as an ADR.bulkCreate, rebuildNestedSet() per type, reconcile counts against source.filterByTaxonomies() behind our existing repository/service boundary — do not let controllers bind directly to the facade.DB::table('taxonomies')->insert() and TaxonomyModel::where(...)->delete() in review (both bypass model hooks and corrupt lft/rgt/depth).restore() does not move them back.Maintenance. Ongoing cost is low in steady state and spiky at Laravel majors. The package tracks L11/12/13, which implies an active maintainer today — but with one author and zero dependents, there is no ecosystem redundancy. Budget a named internal owner and a quarterly review of upstream activity; if the repo goes quiet for two consecutive LTS cycles, we are the maintainers. Mitigate by consuming only documented public API (which their ReadmeExamplesTest/DocsExamplesTest pin, so it should not rot silently) and keeping our integration behind a thin internal interface so a fork-and-vendor swap is a contained change.
Support. Upstream support is best-effort, single-maintainer, GitHub-issue-driven — no SLA, no commercial backing, no security disclosure policy documented. Our team absorbs first-line support. The saving grace: the codebase is small and fully covered, so internal engineers can read it end-to-end; the AGENTS.md/skills docs are an unusually good onboarding artifact and materially reduce the "bus factor" cost of debugging.
Scaling. Read scaling is a strength: nested-set ancestor/descendant lookups are single-query, and tree()/flatTree()/getNestedTree() are cached with version-key-based invalidation (works on stores without tag support — relevant if we are on Redis without clustering or on a store lacking Cache::tags). Write scaling is the constraint: create() costs 4–7 queries per row because each insert widens the nested set; wide trees and frequent re-parenting mean range updates. Mitigations: bulkCreate() for imports, moveToParent() over manual parent_id writes, and avoid high-frequency reordering of large subtrees in request paths.
Failure modes and detection. These are the operational runbook items:
| Failure | Symptom | Detection / Remedy |
|---|---|---|
| Stale instance nested-set read | Empty descendants, no error | refresh() before read, or use descendants() (id-keyed, drift-immune) |
| Raw insert / query-delete | lft/rgt/depth null or wrong, tree unusable |
php artisan taxonomy:rebuild-nested-set --force; forbid in code review |
morph_type mismatch |
Attach silently no-ops or type errors at query time | Gating config review pre-migrate |
| Slug passed to attach | Nothing attached, no exception | Contract test; resolve via findBySlug() first |
| Multi-tenant cache bleed | Tenant A served Tenant B's tree | resolveCacheScopeUsing(); assert in tenant isolation tests |
| Double-layer caching | Permanently stale tree | Never wrap tree()/flatTree()/getNestedTree() in Cache::remember() — second layer misses invalidation |
bulkCreate skips events |
Observers/audit hooks don't fire | Route imports deliberately; replicate side effects explicitly |
Ramp-up. For a Laravel-fluent team: roughly 1–2 days to producer-level, dominated by three conceptual hurdles — facade-vs-model duality, nested-set staleness, and type-scoped attach semantics. All three are documented with worked examples (and in Bahasa Indonesia as well), which shortens onboarding. Budget an additional half-day for whoever owns the morph_type and multi-tenancy configuration decisions, since those are the irreversible ones.
Recommendation. Approve for internal, non-revenue-critical classification (content tagging, internal catalogs, taxonomies behind an admin surface) with a named owner and the code-review guardrails above. Defer for revenue-critical catalog navigation or anything requiring per-term counts, multi-tenant isolation, or a multi-year support commitment until we either accept fork ownership explicitly or see adoption and release cadence materially improve. The engineering quality justifies the trial; the bus factor justifies the cap.
How can I help you explore Laravel packages today?