PHPackages                             edulazaro/laraterms - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. edulazaro/laraterms

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

edulazaro/laraterms
===================

Polymorphic taxonomies for Laravel. Define tags, categories or any custom classification in config. Hierarchical, slug-aware, attach to any model, counts cached, query scopes included.

0.2.0(2w ago)04MITPHPPHP &gt;=8.2

Since May 23Pushed 2w agoCompare

[ Source](https://github.com/edulazaro/laraterms)[ Packagist](https://packagist.org/packages/edulazaro/laraterms)[ RSS](/packages/edulazaro-laraterms/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (3)Versions (5)Used By (0)

Laraterms
=========

[](#laraterms)

**Polymorphic taxonomies for Laravel.** Define `tags`, `categories` or any custom classification in config. Multi-tenant. Multi-locale. Hierarchical or flat. Spatie-compatible. Cross-locale search via auto-maintained `search_text`. Zero schema opinion beyond two tables.

Installation
------------

[](#installation)

```
composer require edulazaro/laraterms
php artisan vendor:publish --tag=laraterms-config
php artisan vendor:publish --tag=laraterms-migrations
php artisan migrate
```

Define your taxonomies
----------------------

[](#define-your-taxonomies)

`config/laraterms.php` ships with `tags` and `categories` as examples. Edit or add what you need:

```
'taxonomies' => [
    'tags' => [
        'hierarchical'        => false,
        'max_terms_per_model' => null,
        'scope'               => 'tenant',  // or 'global'
    ],
    'categories' => [
        'hierarchical'        => true,
        'max_terms_per_model' => 1,
        'scope'               => 'tenant',
    ],
    'regions' => [
        'hierarchical' => true,
        'models'       => [\App\Models\Property::class],
    ],
],
```

Multi-tenant (scope-scoped)
---------------------------

[](#multi-tenant-scope-scoped)

By default taxonomies are **tenant-scoped**: each scope (Organization, Workspace, Team) has its own isolated terms. Define how to resolve the scope of any taxable model:

```
// AppServiceProvider::boot()
use EduLazaro\Laraterms\Facades\Laraterms;

Laraterms::resolveScopeUsing(fn ($model) => $model->organization ?? null);
```

Or per model:

```
class Post extends Model
{
    use HasTerms;

    public function termsScope(): ?\Illuminate\Database\Eloquent\Model
    {
        return $this->organization;
    }
}
```

The scope can be a Model, an array (`['type' => 'organization', 'id' => 5]`), a `Scope` value object, or `null` (global). It does not require a morph map: it works with FQCN as `scope_type`.

For taxonomies shared across all tenants (languages, countries), set `scope: 'global'`.

Make a model taxable
--------------------

[](#make-a-model-taxable)

```
use EduLazaro\Laraterms\Concerns\HasTerms;

class Post extends Model { use HasTerms; }
```

API
---

[](#api)

```
// Attach / sync / detach
$post->attachTerm('Laravel', 'tags');                  // find-or-create
$post->attachTerms(['Laravel', 'PHP'], 'tags');
$post->syncTerms(['Laravel', 'Vue'], 'tags');           // replace in the taxonomy
$post->detachTerm('Laravel', 'tags');
$post->detachAll('tags');

// Read
$post->terms;                                           // all attached
$post->termsIn('tags');                                 // by taxonomy
$post->hasTermsIn('tags');                              // bool

// Query
Post::whereHasTerm('laravel', 'tags')->get();
Post::whereHasAnyTerm(['laravel', 'vue'], 'tags')->get();
Post::whereHasAllTerms(['laravel', 'tutorial'], 'tags')->get();
Post::whereInTaxonomy('categories')->get();
```

Multi-locale (i18n)
-------------------

[](#multi-locale-i18n)

Each translatable field has **two columns**: the canonical one (`name`, `description`) and the translations one (`name_translations`, `description_translations`). Spatie-compatible (format: `{"en": "...", "es": "..."}`).

```
// Single-locale: use only `name`
Term::create(['name' => 'Laravel', 'taxonomy' => 'tags']);
$term->name;   // "Laravel"

// Multi-locale
Term::create([
    'name'              => 'Tag',                       // canonical fallback
    'name_translations' => ['en' => 'Tag', 'es' => 'Etiqueta'],
    'taxonomy'          => 'tags',
]);
$term->name;   // "Etiqueta" if locale=es, "Tag" if locale=en or fallback
```

The accessor on `name` and `description` resolves automatically: active locale, then fallback locale, then canonical column.

### Spatie integration (opt-in, on your side)

[](#spatie-integration-opt-in-on-your-side)

If you want the full Spatie API on the `*_translations` columns:

```
class Term extends \EduLazaro\Laraterms\Models\Term
{
    use \Spatie\Translatable\HasTranslations;
    protected $translatable = ['name_translations', 'description_translations'];
}
```

Our accessor on `name`/`description` keeps working because it reads the raw attributes.

Cross-locale search
-------------------

[](#cross-locale-search)

`search_text` is auto-maintained in `saving()` by concatenating all values across all locales. Language-agnostic LIKE search:

```
Term::search('impuesto')->get();                        // matches even if the user is on /en
Term::where('search_text', 'like', '%laravel%')->get();
Term::whereFullText('search_text', 'laravel')->get();   // if your engine supports FULLTEXT (default migration adds it)
```

Hierarchical
------------

[](#hierarchical)

```
use EduLazaro\Laraterms\Support\TermTree;

$tree = TermTree::for('categories');                    // Collection of roots with children populated (1 query)

foreach (TermTree::flatten($tree) as [$term, $depth]) {
    echo str_repeat('. ', $depth) . $term->name . "\n";
}

$term->ancestors();                                     // Collection root to parent
$term->breadcrumb(' > ');                               // "Tech > Web > Laravel"
$term->descendantIds();                                 // all descendant ids
```

Model
-----

[](#model)

```
$term = Term::findOrCreateByName('Laravel', 'tags', $organization);
Term::inTaxonomy('tags')->ordered()->get();
Term::byHandle('laravel', 'tags')->first();
Term::forScope($org)->inTaxonomy('tags')->get();
Term::forScopeOrGlobal($org)->inTaxonomy('tags')->get();
$term->refreshCount();
```

Activate / deactivate (soft hide)
---------------------------------

[](#activate--deactivate-soft-hide)

Each term has `is_active` (default `true`). Deactivating hides the term from pickers for new attachments, but models already attached keep showing the badge. Useful for "this tag is no longer used, but old posts that have it keep showing it".

```
$term->deactivate();        // hide from pickers
$term->activate();          // re-activate
Term::active()->inTaxonomy('tags')->forScope($org)->get();        // only active
Term::inactive()->inTaxonomy('tags')->forScope($org)->get();      // only inactive
```

**This is NOT soft-delete.** If you want proper SoftDeletes (with `withTrashed`, `restore`, etc.), extend the model in your app:

```
class Term extends \EduLazaro\Laraterms\Models\Term {
    use \Illuminate\Database\Eloquent\SoftDeletes;
}
// + migration with $table->softDeletes();
```

Merge terms (`mergeInto`)
-------------------------

[](#merge-terms-mergeinto)

For duplicate cleanup ("we had `laravel` and `Laravel Framework`, merge them into `laravel`"):

```
$dup = Term::byHandle('laravel-framework', 'tags')->first();
$canonical = Term::byHandle('laravel', 'tags')->first();

$dup->mergeInto($canonical, deactivateSource: true);
// 1. Moves all termables from $dup to $canonical (without duplicating)
// 2. Recalculates terms_count on the canonical
// 3. Deactivates $dup (kept in DB but hidden from pickers).
//    Pass deactivateSource: false for a real delete with cascade.
```

Guard: both must belong to the same taxonomy and the same scope. Throws `InvalidArgumentException` otherwise.

Facade
------

[](#facade)

```
use EduLazaro\Laraterms\Facades\Laraterms;

Laraterms::has('tags');
Laraterms::get('tags');                                   // TaxonomyDefinition
Laraterms::handles();                                     // ['tags', 'categories', ...]
Laraterms::register('moods', [...]);                      // runtime
Laraterms::resolveScopeUsing(fn ($m) => $m->organization);
Laraterms::scopeFor($model);                              // Scope VO
```

Schema
------

[](#schema)

**`terms`**: `id`, `taxonomy`, `scope_type`, `scope_id`, `parent_id`, `name`, `name_translations` (JSON), `handle`, `description`, `description_translations` (JSON), `search_text`, `color`, `sort_order`, `terms_count`, `meta` (JSON), timestamps. Unique on `(scope_type, scope_id, taxonomy, handle)`. FULLTEXT on `search_text` (best-effort, ignored if the engine does not support it).

**`termables`**: polymorphic pivot. `term_id`, `termable_type`, `termable_id`, `sort_order`, timestamps. Unique on `(term_id, termable_type, termable_id)`.

Table names are configurable.

Exceptions
----------

[](#exceptions)

- `UnknownTaxonomyException`: taxonomy handle not registered.
- `TooManyTermsException`: exceeds `max_terms_per_model`.
- `RequiresHierarchyException`: `parent_id` set on a flat taxonomy.

License
-------

[](#license)

MIT.

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance97

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

4

Last Release

15d ago

PHP version history (2 changes)0.1.0PHP ^8.2

0.2.0PHP &gt;=8.2

### Community

Maintainers

![](https://www.gravatar.com/avatar/6a3c47449dfb2ec121aa410da024f47586b87cc2799a825f0418e6c5e5904955?d=identicon)[edulazaro](/maintainers/edulazaro)

---

Top Contributors

[![edulazaro](https://avatars.githubusercontent.com/u/7797530?v=4)](https://github.com/edulazaro "edulazaro (3 commits)")

---

Tags

laraveltagsclassificationcategoriestaxonomyhierarchicalpolymorphic

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/edulazaro-laraterms/health.svg)

```
[![Health](https://phpackages.com/badges/edulazaro-laraterms/health.svg)](https://phpackages.com/packages/edulazaro-laraterms)
```

###  Alternatives

[aliziodev/laravel-taxonomy

Laravel Taxonomy is a flexible and powerful package for managing taxonomies, categories, tags, and hierarchical structures in Laravel applications. Features nested-set support for optimal query performance on hierarchical data structures.

24423.9k](/packages/aliziodev-laravel-taxonomy)[markwalet/nova-modal-response

A Laravel Nova asset for Modal responses on an action.

17818.7k](/packages/markwalet-nova-modal-response)[creasi/laravel-nusa

A Laravel package that aim to provide Indonesia' Administrative Data

987.7k2](/packages/creasi-laravel-nusa)[team-nifty-gmbh/tall-datatables

Server-side rendered datatables for Laravel and Livewire

1319.7k3](/packages/team-nifty-gmbh-tall-datatables)[tomshaw/electricgrid

A feature-rich Livewire package designed for projects that require dynamic, interactive data tables.

119.2k](/packages/tomshaw-electricgrid)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
