---
title: "Tagging models — Tags for Laravel | Roundly"
description: "Attach, detach, sync and clear by model, key, slug or name — limits, curated lists, on-the-fly creation, TagChanges and reads without N+1."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/tagging
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)

# Tagging models

Four verbs write a model’s tags, on the trait, the for() handle and the facade alike. The type is always the last, optional argument:

```php
$live = Tags::type('label')->make('Live')->create();

// A model, a primary key (int), a slug or a name — mixed freely, the type last
$concert->attachTags([$live, 42, 'outdoor', 'Late night'], 'label');

// Unknown names of a free-form type are created on the fly; a curated type never creates
$concert->attachTags('Polka', 'genre');                  // TagNotFoundException

// A single type carries one tag: sync replaces it, attach refuses a second one
$concert->syncTags('jazz', 'genre');                      // sets the category
$concert->attachTags('rock', 'genre');                    // TagLimitExceededException — nothing written
$concert->syncTags(null, 'genre');                        // clears it

// Detach is lenient: names that name no tag are ignored
$concert->detachTags(['outdoor', 'no-such-label'], 'label');
$concert->clearTags('label');                             // every label
$concert->clearTags();                                    // every tag of every type
```

- Inputs are a Tag model, a primary key (an int — a uuid or ulid key goes through findMany(), because a string always means a name), a slug or a name — one, or any iterable of them.
- attach adds. Unknown names of a free-form type are created in the model’s scope; a curated type never creates (TagNotFoundException). Going over a type’s max throws TagLimitExceededException and writes nothing — single types included.
- sync makes the model’s tags of one type exactly the given set: it detaches the rest and attaches what is missing; tags of other types are untouched. sync(null) or sync(\[\]) clears the type. Without a type it syncs the default type.
- detach is lenient — names that name no tag, deleted tags and tags of another scope are skipped — so it is idempotent.
- clear removes every tag of one type, or of every type.
- Every tagging verb — on the facade, the handle or the trait — drops the model’s loaded tags relation afterwards, so tagsOf() and your resources see the write.
- The max is checked only for the types a call adds to, so a model already over a lowered max may still shrink.

## Chaining

```php
$concert
    ->syncTags($jazz, 'genre')                            // each write returns the model…
    ->attachTags(['Live', 'Outdoor'], 'label');

Tags::for($concert)
    ->detach('outdoor', 'label')                          // …or the handle, which drops the loaded
    ->attach('Rooftop', 'label')                          //    tags relation after every write
    ->of('label');                                        // Live, Rooftop
```

## What really changed

The flat verbs on the facade return a TagChanges — the same object every action returns:

```php
$changes = Tags::sync($concert, ['live', 'Outdoor', 'Rooftop'], 'label');

$changes->attached;       // list<Tag> — put on the model by this call
$changes->detached;       // list<Tag> — taken off by this call
$changes->created;        // the attached tags this call created for new free-form names
$changes->restored;       // the attached tags this call brought back from deleted
$changes->isEmpty();      // true when nothing was attached or detached
$changes->ofType('label');
```

Tagged and Untagged fire only when something changed, after the transaction commits; a TagCreated or TagRestored fires for every tag a free-form name created or brought back.

## Strings and the default type

```php
// No type: a string names a tag of tags.default_type ('tag' — flat, free-form, unlimited)
$article->attachTags(['laravel', 'php']);
$article->tagsOf('tag');

// Models and keys carry their own type, so a list may mix types when no type is given
$article->attachTags([$jazz, $live]);

// 'default_type' => null (or blank): every string must name its type
$article->attachTags('laravel');           // UnknownTagTypeException (type_required)
```

## The check order

Every attach and sync resolves its input in a fixed order, so the same call always fails the same way — and nothing is written until every check passed:

| # | Check | Exception |
| --- | --- | --- |
| 1 | A key that names no tag, then input visibility: a tag model or key of a scope the model cannot see answers exactly like a missing one, before anything else can confirm that it exists. | `TagNotFoundException / TagScopeMismatchException` |
| 2 | A deleted tag. | `TagNotFoundException` |
| 3 | A tag of another type than the call names. | `TagTypeMismatchException` |
| 4 | The types the model may carry, from both sides: the type’s on() list and the model’s tagTypes(). | `TagTypeNotAllowedException` |
| 5 | Strings: a blank or too long name is invalid; an unknown name of a curated type is not found. | `InvalidTagException / TagNotFoundException` |
| 6 | The max of every type the call adds to — the call throws and writes nothing. | `TagLimitExceededException` |

## Reading without N+1

```php
foreach (Concert::with('tags')->get() as $concert) {
    echo $concert->tagOf('genre')?->name;                         // Jazz — filtered in memory
    echo $concert->tagsOf('label')->pluck('name')->join(', ');    // Live, Outdoor
}

$concert->hasTag('live', 'label');                  // a model, a key, a name or a slug
$concert->hasAnyTags(['live', 'free'], 'label');
$concert->hasAllTags(['live', 'outdoor'], 'label');

Tags::for($concert)->all();                         // every tag, by position
Tags::for($concert)->one('genre');                  // the same as tagOf()
```

tagsOf() and tagOf() filter the loaded tags relation in memory when it is loaded, else run one query — so a page of 25 models with their tags is two queries, however many tags they carry. Reads ignore the taggings of a deleted tag.

## 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)
