---
title: "Taggable models — Tags for Laravel | Roundly"
description: "The Taggable contract and HasTags trait — tagScope() and tagTypes(), the read-only tags() relation, factories and what deleting a model does."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/taggable-models
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)

# Taggable models

Any Eloquent model becomes taggable with the Taggable contract and the HasTags trait. Two methods are yours to override — the owner of the model’s free-form tags, and the types it may carry:

```php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RoundlyConsulting\Tags\Concerns\HasTags;
use RoundlyConsulting\Tags\Contracts\Taggable;

final class Task extends Model implements Taggable
{
    use HasTags;

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    // The owner of this task's free-form tags: it sees the team's tags plus the global ones,
    // and a new label typed on a task belongs to its team. null = global tags only.
    public function tagScope(): ?Model
    {
        return $this->team;
    }

    // The types a task may carry; null (the default) = any defined type.
    public function tagTypes(): ?array
    {
        return ['label'];
    }
}
```

- tagScope() — null (the default) means global tags only. A model with a scope sees its scope’s tags plus the global ones, and an unknown free-form name typed on it is created in that scope (see Tenant-scoped tags).
- tagTypes() — null (the default) means any defined type. The type’s own on() list is checked too: a type limited to Concert never lands on a Task, whatever tagTypes() says.
- A model must be saved before it is tagged (UnsavedTaggableException) and keyed by tags.key\_type.

## What the trait adds

| Method | Returns | Does |
| --- | --- | --- |
| `attachTags($tags, $type = null)` | `static` | Tags::for($this)->attach() — chainable. |
| `detachTags($tags, $type = null)` | `static` | Tags::for($this)->detach(). |
| `syncTags($tags, $type = null)` | `static` | Tags::for($this)->sync(); syncTags(null, $type) clears the type. |
| `clearTags($type = null)` | `static` | Tags::for($this)->clear(). |
| `tagsOf($type) / tagOf($type)` | `Collection / ?Tag` | The tags of one type, or its one tag — the loaded relation filtered in memory, else one query. |
| `hasTag() / hasAnyTags() / hasAllTags()` | `bool` | The handle’s has(), hasAny() and hasAll(). |
| `tags()` | `TagsRelation` | The read-only relation, ordered by position. |
| `tagScope() / tagTypes()` | `?Model / ?array` | Override points: the owner of free-form tags, and the types allowed; both null by default. |

Reserved names — don’t define these on a taggable model: tags, tagsOf, tagOf, hasTag, hasAnyTags, hasAllTags, attachTags, detachTags, syncTags, clearTags, tagScope, tagTypes, bootHasTags, scopeWithAnyTags, scopeWithAllTags, scopeWithoutTags and scopeWithTagsOf.

## The read-only tags() relation

tags() is a plain morph-to-many for every read — with('tags'), whereHas, withCount, wherePivot — ordered by position. Every write through it throws ReadOnlyTagsRelationException before touching a row and names the verb to use, because a raw pivot write would skip the type rules, scopes, usage counts, events and the fake:

```php
$concert->tags;                                         // every tag, by position — a plain morph-to-many read
Concert::with('tags')->get();                           // eager load: two queries for the whole page
Concert::withCount('tags')->get();
Concert::whereHas('tags', fn ($tags) => $tags->where('color', '#f24e29'))->get();

$jazz->taggedModels(Concert::class)->get();             // the inverse — read-only too

$concert->tags()->attach($jazz);                        // ReadOnlyTagsRelationException: use attachTags()
```

attach, detach, sync, syncWithoutDetaching, syncWithPivotValues, toggle, updateExistingPivot, save, saveMany, create, createMany, firstOrCreate, createOrFirst, updateOrCreate and incrementOrCreate (and their Quietly variants) all throw. The pivot carries the tag’s type and created\_at.

## In model factories

hasAttached() writes through the relation, so tag in afterCreating instead:

```php
// database/factories/ConcertFactory.php — hasAttached() would hit the read-only relation
public function configure(): static
{
    return $this->afterCreating(fn (Concert $concert) => $concert->attachTags(['Live'], 'label'));
}
```

## Deleting a taggable

```php
$concert->forceDelete();               // detaches every tag: counts drop, Untagged fires
$concert->delete();                    // a soft delete keeps the taggings, so a restore is lossless

Concert::query()->where('archived', true)->delete();   // a mass delete fires no model event…
// …so the taggings stay behind until: php artisan tags:repair --orphans
```

A force delete — or a delete of a model without soft deletes — clears every tag through the manager: usage counts drop, Untagged fires and Tags::fake() records it as a clear. A soft delete keeps the taggings. A mass delete fires no model event, so its taggings stay until tags:repair --orphans, which also recounts the tags they pointed at; tags:doctor counts them.

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