Manage categories, tags and any hierarchical structure in Laravel. Terms live in one table, attach to any model through a polymorphic pivot, and hierarchies are maintained as a nested set so ancestor and descendant lookups stay a single query.
🇮🇩 Dokumentasi Bahasa Indonesia
- Requirements · Installation · Configuration
- Quick start · Working with taxonomies · Bulk imports · Attaching to models
- Query scopes · Hierarchies · Types · Metadata
- Caching · Multi-tenancy · Slugs and exceptions
- Console commands · Examples · Troubleshooting
| Requirement | Version |
|---|---|
| PHP | 8.2 or newer |
| Laravel | 11, 12 or 13 |
composer require aliziodev/laravel-taxonomy
php artisan taxonomy:install
php artisan migratetaxonomy:install publishes the config and the migration. Pass --force to overwrite files that already exist — without it, existing files are left untouched and the command tells you so.
To publish individually:
php artisan vendor:publish --tag=taxonomy-config
php artisan vendor:publish --tag=taxonomy-migrationsconfig/taxonomy.php, with the shipped defaults:
return [
'table_names' => [
'taxonomies' => 'taxonomies',
'taxonomables' => 'taxonomables',
],
// Morph column type on the pivot: 'numeric', 'uuid' or 'ulid'.
// Must match how YOUR models are keyed. Set before the first migrate.
'morph_type' => 'uuid',
'types' => collect(TaxonomyType::cases())->pluck('value')->toArray(),
// Swap in your own model; it must extend the package's Taxonomy.
'model' => Taxonomy::class,
'slugs' => [
'generate' => true, // auto-generate from name when omitted
'regenerate_on_update' => true, // rewrite the slug when the name changes
'consider_trashed' => false, // count soft-deleted rows when checking uniqueness
'regenerate_on_restore' => true, // resolve a conflict on restore instead of throwing
],
'cache' => [
'ttl' => 86400, // seconds
'scope' => null, // see Multi-tenancy
],
'migrations' => [
'autoload' => env('TAXONOMY_AUTOLOAD_MIGRATIONS', true),
'paths' => [],
],
];morph_type is the one setting to get right up front. It decides whether the pivot stores taxonomable_id as an integer, UUID or ULID, and it cannot be changed after you migrate without rewriting the table. Use numeric for the usual auto-incrementing keys.
migrations.autoload controls whether the package registers its migration path with php artisan migrate. Disable it when you run migrations per tenant connection:
'migrations' => ['autoload' => false],php artisan migrate --path=database/migrations/tenants --database=tenantuse Aliziodev\LaravelTaxonomy\Facades\Taxonomy;
use Aliziodev\LaravelTaxonomy\Enums\TaxonomyType;
$electronics = Taxonomy::create([
'name' => 'Electronics',
'type' => TaxonomyType::Category,
'meta' => ['icon' => 'devices'],
]);
$phones = Taxonomy::create([
'name' => 'Smartphones',
'type' => TaxonomyType::Category,
'parent_id' => $electronics->id,
]);Add the trait to any model that should carry taxonomies:
use Aliziodev\LaravelTaxonomy\Traits\HasTaxonomy;
class Product extends Model
{
use HasTaxonomy;
}$product->attachTaxonomies([$phones->id]);
$product->taxonomies; // all attached terms
Product::withTaxonomySlug('smartphones')->get(); // filter by slugThe Taxonomy facade proxies TaxonomyManager and exposes exactly these methods:
Taxonomy::create($attributes); // create
Taxonomy::createOrUpdate($attributes); // create, or update a matching slug+type
Taxonomy::bulkCreate($rows, $chunkSize = 1000); // insert many at once, no model events
Taxonomy::find($id);
Taxonomy::findMany($ids, $perPage = null, $page = 1);
Taxonomy::findBySlug('smartphones', TaxonomyType::Category);
Taxonomy::findByType(TaxonomyType::Category, $perPage = null, $page = 1);
Taxonomy::findByParent($parentId, $perPage = null, $page = 1);
Taxonomy::search('phone', TaxonomyType::Category, $perPage = null, $page = 1);
Taxonomy::exists('smartphones', TaxonomyType::Category);
Taxonomy::getTypes(); // Support\Collection<string>
Taxonomy::tree($type = null, $parentId = null); // nested, one level of children
Taxonomy::flatTree($type = null, $parentId = null); // flat, each node carries `depth`
Taxonomy::getNestedTree($type = null); // fully nested, via the nested set
Taxonomy::getDescendants($taxonomyId);
Taxonomy::getAncestors($taxonomyId);
Taxonomy::moveToParent($taxonomyId, $parentId);
Taxonomy::rebuildNestedSet($type);
Taxonomy::clearCacheForType($type);The facade is not an Eloquent builder.
Taxonomy::where(...)and friends do not exist on it. For query building, import the model instead:use Aliziodev\LaravelTaxonomy\Models\Taxonomy as TaxonomyModel; TaxonomyModel::type(TaxonomyType::Category)->ordered()->get();
Model scopes: type(), root(), ordered(), roots(), atDepth(), nestedSetOrder().
create() costs four to seven queries per row — a slug check, a parent or
max(rgt) lookup, the range updates that widen the nested set, the insert, and
a cache bump. Fine for a handful of rows, painful for a seeder.
bulkCreate() resolves slugs in memory, inserts in chunks, and renumbers the
nested set once at the end:
Taxonomy::bulkCreate([
['name' => 'Electronics', 'type' => TaxonomyType::Category],
['name' => 'Books', 'type' => TaxonomyType::Category, 'sort_order' => 2],
['name' => 'Fiction', 'type' => TaxonomyType::Category, 'parent_id' => $booksId],
]);Measured on 10,000 rows:
| Time | Queries | |
|---|---|---|
create() in a loop, flat |
14.8s | 40,000 |
bulkCreate(), flat |
0.95s | 32 |
create() in a loop, nested |
22.0s | 59,980 |
bulkCreate(), nested |
1.0s | 37 |
Both paths produce the same tree; only the number of round trips differs.
It accepts any iterable, so a generator keeps memory flat on large imports:
Taxonomy::bulkCreate((function () {
foreach (LazyCollection::make($csvRows) as $row) {
yield ['name' => $row['name'], 'type' => 'category'];
}
})(), chunkSize: 500);Rows take name and type (required), plus optional slug, description,
parent_id, sort_order, meta, created_at and updated_at. Slugs are
generated and de-duplicated against both the batch and the rows already in the
table; an explicit slug that is already taken raises DuplicateSlugException,
exactly as create() would.
Model events are not fired. That is the trade for the speed. If you rely on observers, or on anything hooked into
creating/created, keep usingcreate(). Everything the package itself does in those hooks — slug generation, nested set values, cache invalidation —bulkCreate()does for you.
$product->attachTaxonomies([$a->id, $b->id]); // add, keep existing
$product->syncTaxonomies([$a->id, $b->id]); // replace all
$product->detachTaxonomies([$a->id]); // remove some
$product->detachTaxonomies(); // remove all
$product->toggleTaxonomies([$a->id]); // flipEach accepts an id, a Taxonomy, an array, or any collection:
$product->attachTaxonomies($taxonomy);
$product->attachTaxonomies(collect([$a, $b]));
$product->attachTaxonomies(TaxonomyModel::type('category')->pluck('id'));These take taxonomy ids or models, not slugs. Passing a slug string attaches nothing. Resolve it first:
Taxonomy::findBySlug('featured', TaxonomyType::Tag).
Reading and checking:
$product->taxonomies; // relation
$product->taxonomiesOfType(TaxonomyType::Category); // Collection
$product->getFirstTaxonomyOfType(TaxonomyType::Category);
$product->getTaxonomyCountByType(TaxonomyType::Tag);
$product->hasTaxonomies([$a->id]); // any of
$product->hasAllTaxonomies([$a->id, $b->id]); // all of
$product->hasTaxonomyType(TaxonomyType::Category); // any of this typeEvery attach/detach/sync/toggle has an *OfType twin that only touches terms of one type, leaving the rest of the model's taxonomies alone:
$product->syncTaxonomiesOfType(TaxonomyType::Category, [$catA->id]); // tags untouched
$product->attachTaxonomiesOfType(TaxonomyType::Tag, [$tagA->id]);
$product->detachTaxonomiesOfType(TaxonomyType::Tag); // all tags
$product->toggleTaxonomiesOfType(TaxonomyType::Tag, [$tagA->id]);
$product->hasTaxonomiesOfType(TaxonomyType::Tag, [$tagA->id]);
$product->hasAllTaxonomiesOfType(TaxonomyType::Tag, [$tagA->id]);Ids that are not of the given type are skipped — that filtering is the point of these methods.
Product::withTaxonomy($ids)->get(); // has any of
Product::withAnyTaxonomies($ids)->get(); // has any of
Product::withAllTaxonomies($ids)->get(); // has every one
Product::withoutTaxonomies($ids)->get(); // has none of
Product::withTaxonomyType(TaxonomyType::Category)->get();
Product::withTaxonomySlug('smartphones', TaxonomyType::Category)->get();
Product::withAnyTaxonomiesOfType(TaxonomyType::Tag, $ids)->get();
Product::withAllTaxonomiesOfType(TaxonomyType::Tag, $ids)->get();
Product::withoutTaxonomiesOfType(TaxonomyType::Tag, $ids)->get();
Product::withTaxonomyHierarchy($categoryId)->get(); // term + its descendants
Product::withTaxonomyAtDepth(1, TaxonomyType::Category)->get();
Product::orderByTaxonomyType(TaxonomyType::Category, 'asc', 'name')->get();Scopes chain, so combining them is an AND:
Product::withTaxonomySlug('smartphones', TaxonomyType::Category)
->withAnyTaxonomiesOfType(TaxonomyType::Tag, $featuredIds)
->get();filterByTaxonomies() takes a keyed array, handy for request filters:
Product::filterByTaxonomies([
'category' => 'smartphones', // type => slug
'color' => ['red', 'blue'], // OR within the type
'exclude' => $discontinuedIds,
])->get();Hierarchy is stored twice: as parent_id, and as nested-set lft/rgt/depth columns kept in sync automatically on create, update, delete and restore.
$node->parent; // relation
$node->children; // relation, ordered by sort_order
$node->ancestors(); // walks parent_id — correct even if lft/rgt drifted
$node->descendants(); // depth-first, one query per level
$node->getAncestors(); // nested set, single query
$node->getDescendants(); // nested set, single query
$node->getSiblings();
$node->getChildren();
$node->isAncestorOf($other);
$node->isDescendantOf($other);
$node->getLevel(); // depth, 0 for roots
$node->path; // "Electronics > Smartphones"
$node->full_slug; // "electronics/smartphones"
$node->moveToParent($newParentId); // throws on a circular movegetAncestors()/getDescendants() read lft/rgt and are the fastest option. ancestors()/descendants() follow parent_id and stay correct even if the nested set has drifted — use them if you write to the table outside the model.
Refresh before using the nested-set variants on a model you already held. Adding a child widens its parent's
rgtin the database, and an instance loaded before that still carries the old bounds —getDescendants()then returns an empty collection with no error:$parent = Taxonomy::create(['name' => 'Electronics', 'type' => 'category']); Taxonomy::create(['name' => 'Phones', 'type' => 'category', 'parent_id' => $parent->id]); $parent->getDescendants(); // empty — $parent->rgt is stale $parent->refresh()->getDescendants(); // 1
descendants()is keyed on the id, so it is immune to this.
Trees:
Taxonomy::tree(TaxonomyType::Category); // roots with one level eager-loaded
Taxonomy::flatTree(TaxonomyType::Category); // flat list, `depth` set on each node
Taxonomy::getNestedTree(TaxonomyType::Category); // full depth, `children_nested` + `tree_depth`TaxonomyType ships Category, Tag, Color, Size, Unit, Type, Brand, Model, Variant. Everywhere a type is accepted you may pass the enum or a plain string, so custom types need no registration:
Taxonomy::create(['name' => 'Winter', 'type' => 'season']);
Product::withTaxonomyType('season')->get();List them in config so tooling and the rebuild command know about them:
'types' => ['category', 'tag', 'season', 'department'],Enum helpers:
TaxonomyType::values(); // ['category', 'tag', ...]
TaxonomyType::options(); // [['value' => ..., 'label' => ...], ...]
TaxonomyType::Category->label(); // 'Category'
TaxonomyType::Category->getLabel(); // same, named for Filament's HasLabel contractmeta is a JSON column, cast to array:
Taxonomy::create([
'name' => 'Electronics',
'type' => TaxonomyType::Category,
'meta' => ['icon' => 'devices', 'color' => '#3498db', 'featured' => true],
]);
$taxonomy->meta['icon'];
TaxonomyModel::where('meta->featured', true)->get();
TaxonomyModel::whereJsonContains('meta->tags', 'sale')->get();There is no translation layer; meta is a reasonable home for translations:
'meta' => ['translations' => ['id' => ['name' => 'Elektronik']]],tree(), flatTree() and getNestedTree() are cached for cache.ttl (24 hours by default) and invalidated automatically whenever a taxonomy is created, updated, deleted, restored, moved, or rebuilt.
Taxonomy::clearCacheForType(TaxonomyType::Category); // manual, rarely neededInvalidation works by bumping a version key, so entries expire logically rather than being enumerated and deleted — that keeps it correct on cache stores without tag support.
Do not wrap these calls in another
Cache::remember(). A second, unversioned layer will not see the package's invalidation and will serve stale trees.
Two things need attention.
1. Isolate the cache. Cache keys are global unless you say otherwise, so without a scope one tenant can be served another tenant's tree. Register a resolver:
use Aliziodev\LaravelTaxonomy\TaxonomyManager;
// AppServiceProvider::boot()
TaxonomyManager::resolveCacheScopeUsing(fn () => tenant()?->getKey());Or point taxonomy.cache.scope at an invokable class — a class name rather than a closure, so the config survives php artisan config:cache:
class TenantCacheScope
{
public function __invoke(): ?string
{
return tenant()?->getKey();
}
}With no scope registered the keys are unchanged from earlier releases, so single-tenant apps need no action.
2. Scope the data yourself. The package ships no tenant_id column. Add one, and replace the unique index — the shipped unique(['slug', 'type', 'deleted_at']) otherwise stops two tenants using the same slug within a type:
Schema::table('taxonomies', function (Blueprint $table) {
$table->dropUnique(['slug', 'type', 'deleted_at']);
$table->foreignId('tenant_id')->nullable()->index();
$table->unique(['tenant_id', 'slug', 'type', 'deleted_at']);
});Then point the package at a model carrying your scope:
namespace App\Models;
use Aliziodev\LaravelTaxonomy\Models\Taxonomy as BaseTaxonomy;
class Taxonomy extends BaseTaxonomy
{
protected static function boot(): void
{
parent::boot();
static::addGlobalScope('tenant', function ($query) {
if ($tenantId = tenant()?->getKey()) {
$query->where('tenant_id', $tenantId);
}
});
}
}'model' => \App\Models\Taxonomy::class,It must extend the package's Taxonomy — that is where slug generation and nested-set maintenance live.
The
*OfTypemethods validate the ids you pass without applying global scopes, so a taxonomy shared across tenants is not silently discarded. The flip side: an id from another tenant will attach if your application passes it through. Validate user input, e.g.Rule::exists()scoped to the tenant.
Slugs are generated from the name and are unique within a type, so a featured category and a featured tag can coexist.
Taxonomy::create(['name' => 'Electronics', 'type' => TaxonomyType::Category]); // 'electronics'
Taxonomy::create(['name' => 'Electronics', 'type' => TaxonomyType::Category]); // 'electronics-1'
Taxonomy::create(['name' => 'Electronics', 'slug' => 'custom', 'type' => 'category']); // 'custom'Two exceptions, both extending TaxonomyException:
use Aliziodev\LaravelTaxonomy\Exceptions\DuplicateSlugException;
use Aliziodev\LaravelTaxonomy\Exceptions\MissingSlugException;
try {
Taxonomy::create(['name' => 'Electronics', 'slug' => 'taken', 'type' => 'category']);
} catch (DuplicateSlugException $e) {
$e->getSlug(); // 'taken'
$e->getType(); // 'category'
}MissingSlugException is thrown when slugs.generate is false and no slug was supplied.
Soft deletes interact with uniqueness through two settings: consider_trashed decides whether trashed rows block a slug, and regenerate_on_restore decides whether restoring a row with a now-taken slug renames it or throws.
php artisan taxonomy:install [--force]
php artisan taxonomy:rebuild-nested-set [type] [--force]taxonomy:rebuild-nested-set recomputes lft, rgt and depth. You need it only if rows were written outside the model — direct SQL, a raw seeder, a bulk import. It rebuilds every type when no type is given, uses one transaction per type, and clears the caches afterwards. --force is required when running non-interactively.
Attaching does nothing, no error. You are probably passing slugs. These methods take ids or models; resolve slugs with Taxonomy::findBySlug() first.
Call to undefined method ... where() on the facade. The facade proxies TaxonomyManager, not Eloquent. Import Models\Taxonomy for query building.
Wrong column type on the pivot. morph_type must match your models' keys and is fixed at migration time. Check it before your first migrate.
Tree looks stale. It should invalidate itself; if you write rows with raw SQL, call Taxonomy::clearCacheForType() and, if lft/rgt are involved, php artisan taxonomy:rebuild-nested-set.
Ancestors or descendants look wrong. The nested set has drifted, usually from direct SQL writes. Run the rebuild command, or use ancestors()/descendants(), which follow parent_id.
See UPGRADE.md. Notable in 2.11: cache isolation for multi-tenant apps, and custom relationship names are deprecated for removal in 3.0.
See CONTRIBUTING.md. Commits follow Conventional Commits; releases and the changelog are generated from them.
composer test # Pest
composer analyse # PHPStan
composer format # PintReport vulnerabilities to aliziodev@gmail.com rather than the public tracker.
MIT. See LICENSE.