Add the behavior to the model you want to tag:
$this->addBehavior('Tags.Tag', ['taggedCounter' => false]);If you want a tag counter in your tagged table, add a migration that adds a tag_count field into this table.
For now, we skip this.
And in the add/edit forms you can use a basic text input:
echo $this->Form->control('tag_list'); // input e.g.: Foo, Bar, BazThis will be transformed into the array form automatically on save.
You can even just use the helper:
$this->loadHelper('Tags.Tag');
echo $this->Tag->control();Your edit action needs to contain the Tags relation to display existing tags into the form:
// Inside get() call in the action
contain: ['Tags'],This is also important for the patching part to avoid the ORM trying to re-add existing ones.
So a controller "edit" action usually still looks like always:
$article = $this->Article->get($id,
contain: ['Tags'],
);
if ($this->request->is(['patch', 'post', 'put'])) {
$article = $this->Articles->patchEntity($article, $this->request->getData());
if ($this->Articles->save($article)) {
$this->Flash->success(__('Post and its tags has been saved.'));
return $this->redirect(['action' => 'view', $id]);
}
$this->Flash->error(__('The post could not be saved. Please, try again.'));
}
$tags = $this->Articles->Tags->find('list', ...['keyField' => 'slug']);
$this->set(compact('article', 'tags'));Enjoy tagging!
If the default "string" strategy and comma separated text input list does not suffice, you can for example use "array" strategy.
This can be useful when working with select2 and dropdowns (<select>).
echo $this->Tag->control();in this case is equivalent with the manual version of
echo $this->Form->control('tag_list', ['type' => 'select', 'multiple' => true, 'options' => ..., 'val' => ...]);If you need more customization, use the tags property directly.
When saving the tags, they need to be in the normalized form then on patching.
They can also be combined/stacked with other custom finders, of course.
$taggedRecords = $this->Records->find('tagged', value: 'tag-slug');It also accepts an array here to match any of these tags given.
Note: If you want to use label lookup instead of slug lookup, you need to configure the finderField.
$untaggedRecords = $this->Records->find('untagged');You can easily find and display all tags as cloud.
In your controller:
$tags = $this->MyTaggedTable->Tagged->find('cloud')->toArray();
$this->set(compact('tags'));In your template:
<?php
$this->loadHelper('Tags.TagCloud');
echo $this->TagCloud->display($tags, ['shuffle' => false], ['class' => 'tag-cloud']);
?>With a bit of custom CSS you can make each tag a floating element.
By default the shuffle is enabled, you can disable using 'shuffle' => false config as shown above.
By default the tags are counted (globally).
You can add the column counter to the taggable table to also cache this counter for the specific types.
Tags support an optional color field (hex format like #FF5733) for visual labeling, similar to GitHub labels.
After running the plugin migrations, the color field is available. You can use it in forms:
// In tag management forms
echo $this->Form->control('color', ['type' => 'color']);
// Or use the TagHelper with automatic contrast color handling
echo $this->Tag->colorControl();The TagHelper provides several convenient methods for displaying colored tags:
Single Tag
// Basic colored tag
echo $this->Tag->tag($tag);
// With custom options
echo $this->Tag->tag($tag, [
'url' => ['action' => 'view', $tag->slug],
'class' => 'custom-tag',
'defaultColor' => '#cccccc',
]);Multiple Tags
// Display all tags in a container
echo $this->Tag->tags($entity->tags);
// As Bootstrap badges
echo $this->Tag->badges($entity->tags);
// As Bootstrap pills (rounded badges)
echo $this->Tag->pills($entity->tags);
// As a list with links
echo $this->Tag->list($entity->tags, [
'url' => '/tags/view',
'separator' => ', ',
]);Color Utilities
// Get contrast text color (black or white) for readability
$textColor = $this->Tag->getContrastColor('#FF5733'); // Returns '#ffffff'
// Adjust brightness
$lighter = $this->Tag->adjustBrightness('#808080', 20); // Lighten by 20%
$darker = $this->Tag->adjustBrightness('#808080', -20); // Darken by 20%You can also use the ColorUtility class directly in your code:
use Tags\Utility\ColorUtility;
// Validate hex colors
$isValid = ColorUtility::isValidHex('#FF5733'); // true
// Generate random colors
$color = ColorUtility::random(); // e.g., '#A3C5F7'
// Normalize colors
$normalized = ColorUtility::normalize('ff5733'); // '#FF5733'
// Convert between hex and RGB
$rgb = ColorUtility::hexToRgb('#FF5733'); // ['r' => 255, 'g' => 87, 'b' => 51]
$hex = ColorUtility::rgbToHex(255, 87, 51); // '#FF5733'The color field accepts standard hex color values (e.g., #FF5733) and the helper automatically calculates appropriate text colors for optimal readability.
You can extend tags with icon support by adding a custom field to the tags table.
Migration
Create a migration to add the icon field:
<?php
use Migrations\BaseMigration;
class AddIconToTags extends BaseMigration
{
public function change(): void
{
$table = $this->table('tags_tags');
$table->addColumn('icon', 'string', [
'default' => null,
'limit' => 100,
'null' => true,
'after' => 'color',
]);
$table->update();
}
}Admin Form
Add the icon field to your tag management forms:
echo $this->Form->control('icon', [
'placeholder' => 'e.g., fa-solid fa-star',
]);Displaying Tags with Icons
Display tags with icons in your templates:
foreach ($tags as $tag) {
$icon = $tag->icon ? $this->Html->tag('i', '', ['class' => $tag->icon]) . ' ' : '';
echo $this->Html->link(
$icon . h($tag->label),
['controller' => 'Tags', 'action' => 'view', $tag->slug],
['escape' => false, 'class' => 'badge bg-secondary']
);
}Combining Color and Icon
For a complete solution with both color and icon:
$style = $tag->color ? 'background-color: ' . h($tag->color) : '';
$icon = $tag->icon ? $this->Html->tag('i', '', ['class' => $tag->icon]) . ' ' : '';
echo $this->Html->link(
$icon . h($tag->label),
['controller' => 'Tags', 'action' => 'view', $tag->slug],
['escape' => false, 'class' => 'badge', 'style' => $style]
);Tip: Store the full icon class string (e.g., fa-solid fa-music for FontAwesome, bi-music-note for Bootstrap Icons) to support different icon libraries.
Don't forget to set up some basic validation on your tagged model. You can re-use the same validation if you store it in a more central place.
If you happen to set tags in a non-form context, you can just patch the entity manually:
// $tags could be "Foo, Bar"
$this->Posts->patchEntity($post, ['tag_list' => $tags]);
$this->Posts->saveOrFail($post);Make sure, that - when updating instead of creating tags - you contained the existing ones in the entity. It should look somewhat like this before patching:
object(App\Model\Entity\Post) {
...
'tags' => [
object(Tags\Model\Entity\Tag) {
'id' => 1,
...
'_joinData' => object(Tags\Model\Entity\Tagged) {
...
}
},
...
]
}
After patching, it should contain the full list of existing/modified ('new' => false) and to be added entities ('new' => true) - and should not contain any to be deleted ones.
You can easily combine the tagged custom finder with e.g. Search plugin.
This way you can add a filter to your paginated index action.
Just pass a list of tags ([slug => name] pairs) down to the view layer where you populate the search form field as dropdown, for example:
echo $this->Form->control('tag', ['options' => $tags, 'empty' => true]);In your table's searchManager() configuration you will need a small callback config:
use Cake\ORM\Query\SelectQuery;
$searchManager
...
->callback('tag', [
'callback' => function (SelectQuery $query, array $args, $manager): bool {
$query->find('tagged', value: $args['tag']);
return true;
},
]);Using orSeparator/andSeparator config, one can also filter by multiple tags at once:
// Records that have either "one" or "two" tag
$query->find('tagged', value: 'one,two');
// Records that have both "one" and "two" tag
$query->find('tagged', value: 'one+two');For this to be used inside Search plugin, you would usually generate URLs like this:
$this->Html->link('Title', ['?' => ['tag'=> 'one,two']]);
$this->Html->link('Title', ['?' => ['tag'=> 'one+two']]);Inside the URLs the special chars will automatically be URLencoded.
You can use the untagged finder here inside the search callback.
For this the tag_count field (and check for 0) is the quickest and easiest. It will otherwise
automatically fallback to a live lookup in the pivot table (tagged).
Your search form might now have an additional value for this in the $tags array:
$tags['-1'] = '- All without any tags -';
echo $this->Form->control('tag', ['options' => $tags, 'empty' => true]);Then you just have to switch the query inside the callback in the case of -1:
'callback' => function (SelectQuery $query, array $args, $manager): bool {
if ($args['tag'] === '-1') {
$query->find('untagged');
} else {
$query->find('tagged', value: $args['tag']);
}
return true;
},To have a behavior attached with different "tags" multiple times, a few config keys have to be overwritten or set.
Let's imagine MultiTagsRecords table and one, two tag collections.
$this->addBehavior('TagsOne', [
'className' => 'Tags.Tag',
'fkModelAlias' => 'MultiTagsRecordsOne',
'field' => 'one_list',
'tagsAlias' => 'TagsOne',
'taggedAlias' => 'TaggedOne',
'taggedCounter' => false,
'tagsAssoc' => [
'propertyName' => 'one',
],
'implementedFinders' => [
...
],
'implementedMethods' => [
...
],
]);
$this->addBehavior('TagsTwo', [
'className' => 'Tags.Tag',
'fkModelAlias' => 'MultiTagsRecordsTwo',
'field' => 'two_list',
'tagsAlias' => 'TagsTwo',
'taggedAlias' => 'TaggedTwo',
'taggedCounter' => false,
'tagsAssoc' => [
'propertyName' => 'two',
],
'implementedFinders' => [
...
],
'implementedMethods' => [
...
],
]);They important config key here is fkModelAlias which has to be unique per tag collection and therefore per loaded behavior instance.
Also make sure the finders and methods have been aliases to unique ones.
As for your helper call, that one now needs also to be loaded twice. Or you use the inline config:
echo $this->Tag->control(['field' => 'other_list']);You can set the configuration globally in your app.php using the "Tags" key.
Or you can dynamically set it on each addBehavior() method call as well as when loading the helper.
The most important ones are:
'taggedCounter': Set to false if you don't need a counter cache field in your tagged table.'strategy':'string'/'array''delimiter'- Separating the tags in input elements or as string list, e.g.:',''namespace'-'string'to use for internal namespace column. Do not use together with separator if you want to keep it internal. Otherwise it will become the default namespace and visible.'separator': For namespace prefix, e.g.:':'. With this set the namespace will be parsed from the tag.'andSeparator'- Allows AND filtering, e.g.:'+'or'&''orSeparator'- Allows OR filtering, e.g.:','or'|'
You can set them globally using Configure and the Tags config key.
If you need also to pass options to the slug behavior, use an array config for it:
'slugBehavior' => [
'Tools.Slugged' => [
'mode' => [Text::class, 'slug'],
...
],If you want to use custom slugging, use the 'slug' callable you can provide to the behavior:
'slug' => function($tag) {
return (new CustomSlugger())->slug($tag);
}The tags_tagged.fk_id column holds the primary key of the host record and is
therefore polymorphic — it must match the type of whichever model is being tagged.
You can control its column type globally via the Polymorphic.type config key:
// config/app.php (merged into Configure at bootstrap, including the migrations CLI)
'Polymorphic' => [
'type' => 'uuid', // integer (default) | biginteger | uuid | binaryuuid
],This setting only affects fresh installs. Existing installs need an app-side migration to change the column type.
integer(default) — standard signed/unsigned integer; signedness followsMigrations.unsigned_primary_keys.biginteger— 64-bit integer; signedness followsMigrations.unsigned_primary_keys.uuid— UUID string (36 chars). Nosignedoption is set.binaryuuid— binary UUID. Nosignedoption is set.
Note: tags_tagged.tag_id is not governed by Polymorphic.type — it is a plain
integer that references tags_tags.id, which is the plugin's own primary key and
is not polymorphic. If you switch tags_tags.id to UUID (see the UUIDs section
below), tag_id must be updated to match via an app-side migration.
By default, the plugin works with AIIDs (auto-incremental IDs). This usually suffices, as the tags are usually not exposes via ID, but via slug.
As such the internal ID is usually not leaking to the outside.
If you, for some reason, still need to use UUIDs for the plugin's own tags_tags table primary key, please copy over the schema to your project's /config/Migrations/ folder and adjust the primary key in the migration files to 'type' => 'uuid', 'length' => 36, 'null' => false.
Make sure you didn't add any validation like "numeric" here, only "scalar" ideally. See the test cases (and fixtures for UUIDs) for details.
If you would like to tag models that use a UUID as a primary key, set Polymorphic.type to uuid
(see section above) instead of manually patching the migration.
If you create your own APP Tags controller, you can easily have EntityRouting set up for it:
$routes->connect('/tag/:slug', ['controller' => 'Tags', 'action' => 'view'], ['routeClass' => 'EntityRoute']);In your templates you can then build URLs with the entities passed along directly:
echo $this->Html->link($tag->label,
[
'controller' => 'Tags',
'action' => 'view',
'_entity' => $tag,
]
);Defining a route name you could even just use the short form '_name' => 'my-tag-alias', '_entity' => $tag for the links:
$routes->get('/tag/:slug', ['controller' => 'Tags', 'action' => 'view'], 'my-tag-alias');For details see Core docs.
The plugin ships a Bootstrap 5 admin UI under /admin/tags.
By default the admin UI inherits the host application's AppController auth.
If your host gates /admin/* for admins, the Tags admin is gated by the
same mechanism. Nothing extra is required.
For defense-in-depth — or if you flipped Tags.standalone = true and
detached from the host AppController — set Tags.accessCheck to a Closure
that receives the current request and returns literal true. Anything else
(returns false, returns a truthy non-bool, throws) yields a 403.
use Cake\Core\Configure;
use Cake\Http\ServerRequest;
// Example — gate by role on the authenticated identity:
Configure::write('Tags.accessCheck', function (ServerRequest $request): bool {
$identity = $request->getAttribute('identity');
return $identity !== null && in_array('admin', (array)$identity->roles, true);
});Unset = no-op (host auth alone applies). The gate calls
Authorization::skipAuthorization() when the cakephp/authorization
component is loaded so the policy layer doesn't double-reject.
For higher productivity use the IdeHelper plugin to auto-add the annotations for your new relations.
This will most likely add the following annotations to your table class:
* @property \Tags\Model\Table\TaggedTable|\Cake\ORM\Association\HasMany $Tagged
* @property \Tags\Model\Table\TagsTable|\Cake\ORM\Association\BelongsToMany $Tags
* @mixin \Tags\Model\Behavior\TagBehavior
And also some in your entity:
* @property \Tags\Model\Entity\Tagged[] $tagged
* @property \Tags\Model\Entity\Tag[] $tags
For helper usage in the templates this will be added to AppView:
* @property \Tags\View\Helper\TagHelper $Tag
These will help you, your IDE and tooling like PHPStan to understand the relations and how to use them. The IdeHelper will also give you autocomplete on those for all loadModel() calls as well as autocomplete on the custom finders.
The only manual annotation you will have to add, is the tag_list for the entity:
* @property string $tag_list !
You do not necessarily need to have:
protected $_accessible = [
'*' => true,
'id' => false,
];The TagsBehavior will usually automatically make the needed tags field accessible for patching.
If in doubt or if patching doesn't work as expected, double check if those fields have been properly made accessible.
Only if you need to store more data than the default fields, you might have to additionally whitelist those, as well.