The tags table is simple until the fourth requirement arrives

This is me

This is me

Tagging is the most deceptive small feature in web development.

You add a tags table, a pivot, a slug column and a belongsToMany. Twenty minutes, and it is genuinely correct. The trouble is that tagging never stays one requirement. They arrive one at a time, each small enough to justify patching what you have instead of rethinking it:

  • Categories should be hierarchical, so add a parent_id.
  • A tag belongs to one workspace and must not leak, so add an organization_id, and now every query needs it.
  • Labels need Spanish, so add a name_es. Then French.
  • Search should match a tag whatever language the visitor is browsing in, so the search query unions across the language columns.

No single step is wrong. The end state is a table nobody wants to touch, and you build it again in the next project because it was tangled up with that project's models.

The insight is not that tagging is hard. It is that the shape is identical every time, and those four requirements are not exotic: they are what happens when an app succeeds.

Taxonomies in config, not classes

A tag and a category are the same data structure with different rules: one is flat and unlimited, the other is a tree with one per model. Modelling them as separate tables, or separate classes, duplicates everything to express a difference that is three booleans.

So a taxonomy is a config entry:

'categories' => [
    'hierarchical'        => true,
    'max_terms_per_model' => 1,
    'scope'               => 'tenant',
],

Adding "departments" or "skills" or "practice areas" is a config block, not a migration and a model. Two tables serve all of them, which is also what makes cross-taxonomy questions expressible at all.

One thing I made noisy rather than lenient: max_terms_per_model throws TooManyTermsException when exceeded. The alternative (silently dropping or replacing the extra) turns a bug in your code into missing data in production, discovered by a user.

Tenancy is a callback, not a column

The tenant requirement is the one that most infects a hand-rolled implementation, because it touches every query. Miss the organization_id in one place and you leak one customer's vocabulary into another's autocomplete, which is a support ticket you very much do not want.

The package cannot know what a tenant is in your app (an organization, a team, a workspace, a firm) so it asks once:

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

Everything after that respects it: a term created in one organization is invisible to another, and the same label in two organizations is genuinely two terms. Not a filter you must remember at each call site, which is the property that matters. Isolation you have to remember is isolation you will eventually forget.

Taxonomies that are genuinely global (languages, countries) opt out explicitly with 'scope' => 'global', so sharing is a decision someone made rather than a default someone forgot.

A search column instead of a query over locales

Translations are stored as a canonical column plus a JSON map, {"en": "...", "es": "..."}, with the name accessor resolving locale, then fallback, then canonical. That part is conventional.

The part worth explaining is search. A visitor browsing in Spanish may well type an English tag name: brand names, technical terms, anything they saw elsewhere in the app. If search only queries the active locale, that fails. If it queries every locale, you are running a LIKE across a JSON column per language, which cannot use an index and gets slower with every language you add.

So there is a denormalized search_text column holding every locale's label, kept current on write. Search matches one indexable column regardless of the visitor's language, and adding a language does not add a clause.

That is the same trade as any denormalization, with the same risk of going stale if something writes around it. It is the right one here, because the alternative degrades with exactly the thing you want to grow.

Where it pays off

In a legal platform I run, every firm is a tenant and member tags are a tenant-scoped taxonomy: each firm builds its own vocabulary and never sees another's. The same two tables hold case-level tags. None of that logic carries an organization id by hand.

Config format, the trait, query scopes and the tree API are on the Laraterms page. Source on GitHub, package on Packagist.