greabock/tentacles
Laravel package for hierarchical data: add a tree trait to Eloquent models to manage nested sets, with helpers for ancestors, descendants, siblings, moving nodes, and rebuilding trees. A lightweight fit for categories, menus, and threaded comments.
composer.json in the repo for illuminate/* constraints and PHP requirement, then try composer require greabock/tentacles on a scratch branch. On Laravel 9+ you will almost certainly need a fork with loosened constraints and PHP 8 signature fixes.*_type / *_id for both sides of the relation). Publish it, read it, and add the composite indexes yourself before running it — see Gotchas.// AppServiceProvider::boot()
Relation::morphMap([
'post' => \App\Models\Post::class,
'video' => \App\Models\Video::class,
'comment' => \App\Models\Comment::class,
]);
src/ and read the trait before you write anything; don't trust blog posts from 2017.Post, Video, and Page, and want to attach the same Tag (or Attachment, or SeoMeta) to all three without three separate pivot tables. That "attach anything to anything, one table" scenario is what makes this package worth a look over hand-rolled morphToMany pairs. Do that end-to-end in tinker first: attach, reload, query back, detach.Trait on both sides, relation on the owning side. Typical usage: a shared model (tag/label/attachment) gets queried from any parent, and parents expose a tentacles()-style relation returning the collection. Treat the shared model as the "tentacle" and the parents as "hosts".
class Post extends Model
{
use \Greabock\Tentacles\...; // trait from src/
public function tags()
{
return $this->tentacles(); // confirm exact signature in the trait
}
}
Attach/detach/sync instead of raw pivot writes. The package's helper methods are the reason to use it — they handle the type/id pairing and dedupe. Wrap multi-model operations in a transaction, because a partial sync leaves a half-attached record that's annoying to detect later:
DB::transaction(function () use ($post, $tags) {
$post->syncTentacles($tags); // verify name: attach/detach/sync variants
});
Eager loading is non-negotiable. Polymorphic pivots are the classic N+1 trap. Always ->with('tags') when listing hosts, and use whereHas for filtering rather than loading and filtering in PHP:
Post::with('tags')->whereHas('tags', fn ($q) => $q->where('slug', 'laravel'))->paginate(20);
Extra pivot data via withPivot. If you attach metadata (position, weight, created_by), declare it explicitly and pass it as the second argument to attach. Otherwise it silently disappears on reload.
Service-layer wrapper. Because the package is thin and unmaintained, isolate it: put attach/detach/sync behind your own Tagger/AttachmentService class. If you later swap to native morphToMany or spatie/laravel-tags, you change one file, not 40 controllers.
Admin multi-select binding. For forms, hydrate a list of IDs from the relation, submit the array, and diff in the service layer. Avoid sync directly off $request->all() — validate IDs with exists rules per morph type first.
Reverse lookups. If the shared model needs "what is attached to me" (e.g. a tag showing its posts), you need a relation back through the same pivot. Confirm the package supports the inverse; if not, define a plain morphToMany on that side pointing at the same table.
morphToMany (with a shared taggables-style table plus morph map) covers 90% of the need with zero dependency risk. Often it does.(tentacle_type, tentacle_id) and the mirror pair — polymorphic pivots degrade badly without both directions indexed.Post leaves orphan pivot rows. Add a deleting observer that detaches, or a scheduled cleanup that removes rows whose morph type/id no longer resolve.morphTo eager-load with aliases. When filtering by type, use the morph alias, not the class name, in where('tentacle_type', ...). Mismatches here are the #1 silent-empty-result cause.->toSql() with bindings for the relation query, and in tinker inspect the pivot table directly — DB::table('tentacles')->where(...) — to confirm rows are being written with the values you expect. If a relation returns empty despite rows existing, it's nearly always a morph alias vs FQCN mismatch.Collection type hints, missing return types, and Illuminate\Support\Arr signature changes. Budget a few hours for a fork; keep the fork as a VCS repository in composer.json and pin the commit.Pivot, adds casts and accessors), a repository/service wrapper, and Laravel events in the service layer (TagAttached, TagDetached) — these survive a package swap, whereas anything touching Greabock\Tentacles\* directly does not.How can I help you explore Laravel packages today?