---
title: "Managing tags — Tags for Laravel | Roundly"
description: "Create, seed idempotently, find, rename, recolour, delete and restore tags through the catalog — plus the Tag model’s columns, relations and scopes."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/managing-tags
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tags for Laravel](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)

# Managing tags

Tags::type($type) is the catalog of one type. Every write goes through it — the Tag model carries reads only — so slugs, paths, usage counts and events stay right under concurrent writers.

## Creating and seeding

```php
use RoundlyConsulting\Tags\DataTransferObjects\TagData;

$genres = Tags::type('genre');

// The builder…
$music = $genres->make(['en' => 'Music', 'sk' => 'Hudba'])->color('#f24e29')->create();
$jazz = $genres->make('Jazz')->under($music)->position(0)->create();

// …or the DTO — the same code path
$rock = $genres->create(new TagData(name: 'Rock', parent: $music, icon: 'guitar'));

// Idempotent seeding: an existing tag is returned unchanged (never moved, recoloured or renamed),
// a missing one is created with every attribute — so the seeder may run again
$bebop = $genres->make('Bebop')->under($jazz)->color('#a7c4d6')->findOrCreate();
$live = Tags::type('label')->findOrCreate('Live');            // a name, a locale map or a TagData
```

create() refuses a name a live tag of the type and scope holds in any locale (DuplicateTagException) and restores a deleted tag that holds it with the new attributes, where it was unless you give a parent (TagRestored instead of TagCreated). findOrCreate() looks the name up in the scope, else globally, and returns a found tag unchanged — a deleted one comes back as it was, moved only by a given parent (see Names, slugs and translations); a locale map is matched on the current locale’s entry (else its first), its other entries only fill a newly created tag. It is allowed on curated types — it is an explicit catalog call — and two racing calls end with one tag.

## Finding

```php
$genres = Tags::type('genre');

$genres->find('jazz');              // a live tag by slug or name (along the locale chain), a key or a model
$genres->find(12);                  // by primary key (int)
$genres->findOrFail('jazz');        // TagNotFoundException when missing
$genres->findMany($ids);            // by keys, in the order given — malformed, foreign or missing keys skipped
$genres->findTrashed('jazz');       // a deleted tag of exactly this handle's scope, to restore it

$genres->all();                     // every live tag of the type, by position
$genres->roots();                   // the roots (every tag of a flat type)
$genres->query()->where('usage_count', '>', 10)->get();   // Builder<Tag> of the readable scopes

Tags::find($key);                   // a live tag of any type by primary key
Tags::findOrFail($key);
```

## Editing

```php
use RoundlyConsulting\Tags\DataTransferObjects\UpdateTagData;

// Fluently — only what you call changes
Tags::type('genre')->edit($jazz)->name('Jazz music')->color(null)->save();

// A rename of the current locale, or of several locales at once
Tags::type('genre')->rename($jazz, 'Jazz music');
Tags::type('genre')->rename($jazz, ['en' => 'Jazz music', 'sk' => 'Jazzová hudba']);

// The DTO — fields left out stay; null clears a description, colour or icon
Tags::type('genre')->update($jazz, new UpdateTagData(description: null, icon: 'sax'));

// A null (or blank) description entry clears that locale only
Tags::type('genre')->edit($jazz)->description(['sk' => null])->save();
```

A name or description map merges into the stored one per locale; a blank or null description entry clears that locale only. A rename onto a slug another tag holds is refused — and points to restore() when a deleted tag holds it. TagUpdated fires with the attributes that changed (name, description, slug, color, icon); a call that changes nothing fires nothing.

## Deleting and restoring

```php
use RoundlyConsulting\Tags\Enums\TagChildren;

$genres = Tags::type('genre');

$genres->delete($rock);                                   // soft-deletes it and detaches it everywhere
$genres->delete($music);                                  // TagHasChildrenException while it has live children
$genres->delete($music, TagChildren::Reparent);           // children move up to its parent
$genres->delete($music, TagChildren::Delete);             // the whole subtree goes

$genres->restore($rock);                                  // back, with no old taggings
$genres->restore($genres->findTrashed('rock'));           // find it by name first
```

delete() soft-deletes the tag and detaches it from every model in the same transaction, so a re-created tag of the same name never relabels old records. No per-model Untagged fires — TagDeleted carries the number of taggings removed. Deleting an already deleted tag, or restoring a live one, returns it unchanged. restore() refuses a child whose parent is still deleted, recomputes its path from the parent’s current place — where it must fit the type’s depth cap, its live subtree included — and keeps its position under an unchanged parent; restoring a parent restores only that tag.

## A plain $tag->delete()

```php
// An admin panel calling Eloquent directly — safe, but it detaches nothing
$tag->delete();

// Reads ignore the leftover taggings at once; a restore through the package starts clean,
// and tags:doctor reports them as an error until you run:
// php artisan tags:repair --orphans
```

## The Tag model

RoundlyConsulting\\Tags\\Models\\Tag is swappable through tags.models.tag (a subclass). It uses sluggable’s HasSlug, translatable’s HasTranslations, SoftDeletes and HasFactory:

```php
$jazz->type;                   // 'genre'
$jazz->name;                   // in the current locale
$jazz->slug;                   // in the current locale
$jazz->slugFor('sk');          // one locale's slug, or null
$jazz->description;
$jazz->color;                  // '#f24e29' or null
$jazz->icon;
$jazz->parent_id;
$jazz->depth;                  // 1 for a root
$jazz->position;               // among its siblings
$jazz->usage_count;            // taggings, kept in the same transaction as each write
$jazz->definition();           // its TagType

Tag::query()->ofType('genre')->within($team)->roots()->ordered()->get();
```

| Attribute | Meaning |
| --- | --- |
| `type` | The type name; never changes. |
| `scope_type, scope_id, scope()` | The owner of a scoped tag (null for a global one); never changes. scope\_type is hidden from serialisation. |
| `scope_key` | Internal and hidden: '' for a global tag, the owner’s morph class and key otherwise, so the unique slug index binds global tags too. Derived on every save. |
| `parent_id, path, depth` | The tree: path is the materialised chain of ancestor keys (/ for a root), depth 1 for a root. |
| `position` | The order among siblings (0 first). |
| `name, description, slug` | Translatable: read in the current locale; getTranslations('name') gives the locale map, slugFor($locale) one locale’s slug. |
| `color, icon` | #rrggbb or null; an identifier or an emoji, or null. |
| `usage_count` | Taggings of the tag, adjusted in the same transaction as every write — every taggable class, soft-deleted taggables included. |
| `wasRecentlyRestored` | A plain property, the counterpart of wasRecentlyCreated: the instance was just brought back from deleted. |

| Method | Does |
| --- | --- |
| `definition()` | The tag’s resolved TagType. |
| `parent() / children()` | BelongsTo and HasMany; children by position. |
| `ancestors()` | Root first, in one query — or none, when loadAncestors() loaded them. A new collection either way. |
| `descendants() / siblings()` | Builders of the live tags below it, and of the live tags with the same parent, type and scope. |
| `isRoot()` | No parent. |
| `taggings()` | HasMany of the pivot rows (Models\\Tagging). |
| `taggedModels($class)` | The models of one class carrying the tag — read-only, like tags(). |
| `ofType(), within($scope, $includeGlobal = true), global(), roots(), descendantsOf($tag, self: false), ordered()` | Query scopes on Tag::query(). |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)
