This document outlines the implementation of the tag system in Lychee, focusing on the data models, relationships, custom queries, and operations like renaming and merging.
The Tag model (app/Models/Tag.php) represents a simple tagging entity with minimal properties:
class Tag extends Model
{
// No timestamps
public $timestamps = false;
// Properties
protected $fillable = [
'name', // The tag text
'description', // Optional description
];
// Relationships
public function photos(): BelongsToMany
{
return $this->belongsToMany(
Photo::class,
'photos_tags', // pivot table
'tag_id', // foreign key in pivot
'photo_id' // related key in pivot
);
}
}The model includes a helper method Tag::from(array $tags) that:
- Sanitizes tag names (trims whitespace, removes empty values)
- Finds existing tags in the database
- Creates any missing tags in bulk
- Returns a collection of all tag objects
This method is critical for maintaining tag consistency across the application.
Photos maintain a many-to-many relationship with tags through the photos_tags pivot table:
// In Photo model
public function tags(): BelongsToMany
{
return $this->belongsToMany(
Tag::class,
'photos_tags', // pivot table
'photo_id', // this model key
'tag_id', // related model key
);
}Tag Albums (app/Models/TagAlbum.php) are virtual collections of photos sharing specific tags. A TagAlbum:
- Extends
BaseAlbum - Contains a
BelongsToManyrelationship toTagviatag_albums_tagspivot table - Implements a custom
photos()relation that fetches photos containing all assigned tags
The HasManyPhotosByTag relation implements a sophisticated filtering mechanism to return only photos that contain all tags specified in a tag album:
$tag_count = count($tags_ids);
$query->whereExists(fn (BaseBuilder $q) => $q->select(['photo_id', DB::raw('COUNT(tag_id) AS num')])
->from('photos_tags')
->whereIn('photos_tags.tag_id', $tags_ids)
->whereColumn('photos_tags.photo_id', 'photos.id')
->groupBy('photos_tags.photo_id')
->havingRaw('COUNT(DISTINCT photos_tags.tag_id) = ?', [$tag_count])
);This query:
- Counts how many tags are assigned to the album (
$tag_count) - Creates a subquery that selects photos from the pivot table
- Groups these by photo ID and counts matched tags
- Uses
HAVINGto ensure the photo has exactly the number of required tags - The outer
whereExistsconnects this to the main photos query
This approach implements a logical AND between tags rather than OR, meaning photos must have all specified tags to appear in the album.
Tag operations are handled by action classes:
- ListTags: Returns all tags accessible to the current user
- GetTagWithPhotos: Retrieves a tag with its associated photos
- EditTag: Handles tag renaming using a merge strategy
- DeleteTag: Removes tag associations and cleans up orphaned tags
- MergeTag: Combines two tags while preserving user contexts
Renaming a tag might seem as simple as updating the name column on the existing record, but since tags are shared across all users and contexts, a direct rename would unintentionally change the tag for everyone. Instead, Lychee performs a merge-based rename scoped to the current user:
- Create or find a new tag with the target name (so as not to overwrite the original).
- Transfer only your own photo and tag-album associations from the old tag to the new one.
- Remove the old associations and, if the old tag has no more links, delete it via
TagCleanupTrait.
This approach ensures:
- Multi-user safety: Each user's tag context is preserved
- Collision avoidance: avoids merging meanings when different users use the same tag name for different contexts.
- Database efficiency: Unused tags are automatically pruned.
The TagCleanupTrait provides automatic cleanup of unused tags:
// Simplified from TagCleanupTrait
public function cleanupUnusedTags(): int
{
return Tag::whereNotExists(fn ($q) => $q->select(DB::raw(1))
->from('photos_tags')
->whereColumn('photos_tags.tag_id', 'tags.id'))
->whereNotExists(fn ($q) => $q->select(DB::raw(1))
->from('tag_albums_tags')
->whereColumn('tag_albums_tags.tag_id', 'tags.id'))
->delete();
}This trait is used by operations like merge and delete to maintain database efficiency.
Last updated: December 22, 2025