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.
Install and publish in one pass, then migrate:
composer require aliziodev/laravel-taxonomy
php artisan taxonomy:install # publishes config + migration
php artisan migrate
The first place to look is config/taxonomy.php, specifically morph_type. It defaults to uuid, which is wrong for the vast majority of Laravel apps — if your products table uses auto-incrementing keys, set 'morph_type' => 'numeric' before the first migrate. This column type is baked into taxonomables.taxonomable_id and cannot be changed later without rewriting the table. Everything else in the config (table names, slug behaviour, cache TTL) is safe to adjust later.
The first use case is almost always categories on a product-like model. Add the trait, create a couple of terms through the facade, attach, and query:
use Aliziodev\LaravelTaxonomy\Traits\HasTaxonomy;
class Product extends Model { use HasTaxonomy; }
use Aliziodev\LaravelTaxonomy\Facades\Taxonomy;
use Aliziodev\LaravelTaxonomy\Enums\TaxonomyType;
use Aliziodev\LaravelTaxonomy\Models\Taxonomy as TaxonomyModel;
$electronics = Taxonomy::create(['name' => 'Electronics', 'type' => TaxonomyType::Category]);
$phones = Taxonomy::create([
'name' => 'Smartphones', 'type' => TaxonomyType::Category, 'parent_id' => $electronics->id,
]);
$product->attachTaxonomies([$phones->id]);
Product::withTaxonomySlug('smartphones', TaxonomyType::Category)->get();
That is enough to evaluate fit. If your requirement is "arbitrary hierarchy on one or more models, filterable, with a single query for descendants," this package is a good match and you can stop reading the alternatives.
Two entry points, used for two different jobs. The facade (Facades\Taxonomy) is a manager with a fixed, small method list: create, find, tree, moveToParent, rebuildNestedSet, and so on. The model (Models\Taxonomy) is your query builder. In a real app you will use both in the same controller — the facade for writes, the model for scopes like ->type(...)->ordered()->get(). Getting comfortable with that split early saves a lot of confusion.
Types are free-form and do the work that separate tables would otherwise do. Categories, tags, colors, seasons — all live in taxonomies, distinguished by type. Custom types need no registration; a plain string works as well as the enum. Slugs are unique per type, so a featured tag and a featured category coexist happily. This is the single most useful design decision in the package for day-to-day modelling.
Use the *OfType twins whenever a model carries more than one type. The plain syncTaxonomies() replaces everything; the typed variant only touches one slice:
$product->syncTaxonomiesOfType(TaxonomyType::Category, [$catA->id]); // tags untouched
$product->attachTaxonomiesOfType(TaxonomyType::Tag, [$tagA->id]);
This maps cleanly onto admin screens where categories and tags are separate form fields but the same pivot table. Ids of the wrong type are filtered out silently, which is usually what you want in a form handler.
Model-side filtering is where the package earns its keep. Scopes chain with AND, and the hierarchy-aware one is the standout:
Product::withTaxonomyHierarchy($categoryId) // term + all descendants, one query
->withAnyTaxonomiesOfType(TaxonomyType::Tag, $featuredIds)
->get();
For request-driven catalogue filters, filterByTaxonomies() accepts a keyed array (type => slug, or an array of slugs for OR-within-type) and is the cleanest place to hand off $request->validated() data.
Bulk import is not optional at scale. create() in a loop costs 4–7 queries per row. Use bulkCreate() for seeders, CSV imports, and any sync job — it resolves slugs in memory, chunks the insert, and renumbers the nested set once. It accepts any iterable, so wrap large sources in a generator to keep memory flat. The trade is documented and real: model events do not fire, so anything hooked into creating/created will not run. The package's own hooks (slug generation, nested set, cache invalidation) are reproduced internally.
Choose your hierarchy API deliberately. getAncestors() / getDescendants() read lft/rgt and are single-query fast. ancestors() / descendants() walk parent_id and survive drift. In practice: use nested-set variants on read-heavy paths (breadcrumbs, category pages), use parent_id variants in admin tooling or anywhere you might write outside the model. Taxonomy::getNestedTree() / flatTree() are cached and invalidated automatically — ideal for building a nav menu once per request.
Multi-tenant installs need one extra line. Register a cache scope resolver in a service provider:
TaxonomyManager::resolveCacheScopeUsing(fn () => tenant()?->getKey());
Without it, cache keys are global and one tenant can be served another's tree. You will also need your own tenant_id column plus a replacement for the unique(['slug', 'type', 'deleted_at']) index if tenants should be able to reuse slugs.
The facade has no where(). Taxonomy::where(...) does not exist — it proxies the manager, not the model. This is the mistake the package's own AGENTS.md calls out as the most common one, and older versions of the README taught it incorrectly. Import Models\Taxonomy for anything query-shaped.
Attach methods take ids or model instances — never slugs. $product->attachTaxonomies('featured') attaches nothing and raises no error. Resolve first: Taxonomy::findBySlug('featured', TaxonomyType::Tag)->id. In production code, wrap attachment in a small helper that accepts slugs and resolves them, so this failure mode cannot reach a controller.
Refresh before nested-set reads on a stale instance. Creating a child widens the parent's rgt in the database; a parent object loaded beforehand still carries the old bounds and getDescendants() returns an empty collection with no error. Call $parent->refresh() first, or use descendants(), which keys off the id and is immune.
Never write the table raw. DB::table('taxonomies')->insert(...) and TaxonomyModel::where(...)->delete() both bypass the model hooks, leaving lft/rgt/depth null or inconsistent. If it has already happened, repair with php artisan taxonomy:rebuild-nested-set --force. Same rule for bulkCreate() — it's the sanctioned fast path; raw insert is not.
Do not double-wrap the tree cache. Invalidation is version-key based (which is why it works on stores without tag support). Wrapping tree()/flatTree()/getNestedTree() in your own Cache::remember() creates a second layer that never sees the version bump and serves stale trees indefinitely.
Deletion moves children, it does not cascade. Deleting a term soft-deletes it and promotes its children — including trashed children — to its parent. restore() does not move them back. Design your admin UI around this: either prevent deleting non-leaf nodes, or accept the reparent and show a warning.
Prune with eachById(), not each(). each() pages by offset and skips rows deleted mid-loop. Orphan-pruning that removes leaves then leaves their parents orphaned, so loop until a pass deletes nothing.
morph_type is the one irreversible decision. Everything else in the config is editable after the fact. Set it, migrate, and only then start modelling.
The $name relationship parameter is deprecated (removal in 3.0). It selects pivot morph columns the shipped migration does not create, and query scopes can't honour a runtime argument anyway. Use types to distinguish categories from tags and ignore the parameter entirely.
Version pinning. Laravel 11/12/13 and PHP 8.2+ only, and the release line is recent (releases in 2026). With 245 stars and no dependents, pin the minor version in composer.json and read the changelog before upgrading — the public surface is small enough that a pin costs you nothing.
How can I help you explore Laravel packages today?