---
title: "Categories and labels — Events for Laravel | Roundly"
description: "One curated category tree and free-form labels through tags-for-laravel — seeding, tagging, filtering, private labels, organizer scopes and overrides."
url: https://roundly-consulting.com/open-source/docs/events-for-laravel/categories-labels
language: en
---

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

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

# Categories and labels

Events uses [Tags for Laravel](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md) for one curated category tree and free labels. It calls tags only through its manager — so Tags::fake() records too — never writes through the raw tags() relation, and declares two tag types at boot, without I/O, so package:discover works before migrating:

| Type | Rules | Meaning |
| --- | --- | --- |
| `events:category` | hierarchical(maxDepth: 3), single(), curated(), stableSlugs(), globalOnly() | One curated, public category tree. |
| `events:label` | max(10), freeForm(), private() | Free-form labels, kept out of tags’ JSON API. |

The rules are tags’ own — [Tag types](https://roundly-consulting.com/open-source/docs/tags-for-laravel/tag-types.md) explains what each one does.

## Seeding the category tree

```php
$music = Events::categories()->make(['en' => 'Music', 'sk' => 'Hudba'])->findOrCreate();
Events::categories()->make('Jazz')->under($music)->findOrCreate();   // safe to re-run
```

Events::categories() and Events::labels() return tags’ TypeTags handle unchanged — make(), findOrCreate(), find(), tree(), roots(), suggest(), popular() and the rest of [the Tags facade](https://roundly-consulting.com/open-source/docs/tags-for-laravel/facade.md):

```php
Events::categories()->tree();                        // tags' TypeTags handle, unchanged
Events::categories()->roots();
Events::labels()->suggest('li');
Events::labels()->within($organizer)->popular(10);   // an organizer's own labels (events.tags.scope = organizer)
```

An unscoped handle reads global tags; read an organizer’s labels with Events::labels()->within($organizer). Catalogue writes are tags’ own operations — recorded by Tags::fake(), not Events::fake().

## Tagging an event

```php
Events::make('Jam')->organizedBy($user)->category('jazz')->labels(['Live', 'Late night'])->create();

Events::for($event)->by($user)->categorize('jazz');      // a slug or name, a tag key, a Tag; null clears
Events::for($event)->labels()->attach(['Outdoor']);       // names are created when new
Events::for($event)->labels()->detach('Live');
Events::for($event)->labels()->sync(['Live']);            // exactly these
Events::for($event)->labels()->all();
```

- A category is always synced, one per event. An unknown category throws tags’ TagNotFoundException, because the tree is curated.
- More than ten labels throws TagLimitExceededException, and nothing changes.
- Writes check update with an actor, lock the event row first and then let tags take its own locks in the same transaction — one consistent order.
- EventUpdated lists category or labels only when something changed. Tags are not a time, place or link change, so no notice goes out.
- Events::fake() records assertCategorized($event, 'jazz') and assertLabelled($event, \['Live'\]); Tags::fake() sees the same writes, as assertSynced($event, EventTagType::Category, \['jazz'\]).

## Reading and filtering

```php
use RoundlyConsulting\Events\Models\Event;

Event::query()->inCategory('music')->get();                       // Music and its sub-categories
Event::query()->inCategory('music', descendants: false)->get();
Event::query()->labelled(['Live', 'Outdoor'])->get();             // any of them
Event::query()->labelled(['Live', 'Outdoor'], all: true)->get();  // every one
Event::query()->uncategorized()->get();

$event->category;   // ?Tag
$event->labels;     // Collection<Tag>
```

$event->category and $event->labels are read in memory when tags is loaded — Events::browse() loads it. The API filters with filter\[category\] (sub-categories included) and filter\[labels\] (every label), and factories tag after creating: Event::factory()->categorized('jazz')->labelled(\['Live'\]). The matching underneath is tags’ own, as in [Querying models](https://roundly-consulting.com/open-source/docs/tags-for-laravel/querying.md).

## Labels stay private

Labels are one global, free-form catalogue, and a label typed on a private event must not surface elsewhere. So events:label is a private tag type: hidden from the tags JSON API, whose suggest, popular and index endpoints answer 404 for it. Events renders a minimal tag shape without usage\_count or scopes, because a usage count adds up taggings on private events too. Categories are a curated, public tree.

## Labels per organizer

events.tags.scope = organizer gives every organizer its own label catalogue — $event->tagScope() returns the organizer — while categories stay one global tree. events.primary\_key\_type, events.key\_type and tags.key\_type must then be equal, and events:doctor checks it. An organizer hand-over moves the event’s labels into the new organizer’s catalogue in the same transaction. Scopes work as described in [Tenant-scoped tags](https://roundly-consulting.com/open-source/docs/tags-for-laravel/tenant-scopes.md).

## Changing the rules

Use tags’ own mechanisms — events never calls extend() itself:

```php
use App\Models\Venue;   // your venue subclass (events.models.venue) with tags' HasTags
use RoundlyConsulting\Events\Enums\EventTagType;
use RoundlyConsulting\Tags\Facades\Tags;
use RoundlyConsulting\Tags\Types\TagTypeBuilder;

// in a service provider's boot()
Tags::extend(EventTagType::Label, fn (TagTypeBuilder $type) => $type->on(Venue::class)->max(5));
// or config/tags.php: 'overrides' => ['events:label' => ['max' => 5]]
```

on() adds a class, which must be a Taggable model. events:doctor reports an override that lets an event carry more than one category as an error, and warns when an override makes the category not curated, global or hierarchical, makes labels not private, or makes either type both hierarchical and free-form — tags then takes its tree lock before the event row while events locks the event row first, so concurrent writes from both paths can deadlock. Events’ own transactions retry; a host transaction must retry itself.

## 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/events-for-laravel.md)
