Sitelet https://github.com/dereuromark/cakephp-tags/tree/master/docs
Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

README.md

Tags plugin documentation

Quick Start Guide

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, Baz

This 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!

Usage

Array strategy

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.

Custom finders

They can also be combined/stacked with other custom finders, of course.

Tagged

$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.

Untagged

$untaggedRecords = $this->Records->find('untagged');

Tag Cloud

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.

Advanced features

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.

Tag Colors

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();
Displaying Colored Tags

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.

Tag Icons

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.

Validation

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.

Patching

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.

Search/Filter

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;
        },
    ]);
Or/And

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.

Finding records without tags

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;
    },

Multiple tags per model

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']);

Configuration

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'],
        ...
],

Custom slugging

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);
}

Polymorphic foreign key type (Polymorphic.type)

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 follows Migrations.unsigned_primary_keys.
  • biginteger — 64-bit integer; signedness follows Migrations.unsigned_primary_keys.
  • uuid — UUID string (36 chars). No signed option is set.
  • binaryuuid — binary UUID. No signed option 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.

UUIDs

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.

Entity Routing

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.

Admin UI

The plugin ships a Bootstrap 5 admin UI under /admin/tags.

Access control (Tags.accessCheck)

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.

Tips

IDE support/help

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 !

Make sure modified fields are $_accessible

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.