alibayat/laravel-categorizable
Install and publish
composer require alibayat/laravel-categorizable
php artisan vendor:publish --provider="Alibayat\Categorizable\CategorizableServiceProvider"
php artisan migrate
The publish step gives you the categories table and the polymorphic pivot table (typically categorizables). If the provider registers tags, publishing with --tag=migrations is safer so you don't overwrite unrelated config.
Where to look first
Before wiring anything into your app, read these four things in vendor/alibayat/laravel-categorizable/src:
Category model is swappable via config.Categorizable trait — this is the actual public API. Confirm the exact method names (categories(), attachCategory(), syncCategories(), scopes like whereCategory()) rather than assuming; this package has a small, thin surface and the trait is the whole contract.Category model — check for parent_id/nested support, fillable attributes, and whether it is a plain Eloquent model or uses a nested-set library.whereHas clauses.First use case
Categorize a Post, render its categories, and filter an index page:
use Alibayat\Categorizable\Traits\Categorizable;
class Post extends Model
{
use Categorizable;
}
$post->categories()->sync($request->validated('category_ids'));
$posts = Post::with('categories')
->whereHas('categories', fn ($q) => $q->where('slug', $slug))
->paginate();
Even if the trait exposes sugar methods, the underlying categories() relation is what you should lean on for anything non-trivial — it behaves like a normal morphToMany, so sync, toggle, whereHas, withCount, and has() all work.
Treat it as a taxonomy, not free-form tags. Seed categories once and reference them by ID. A seeder that upserts by slug keeps your local, staging, and production taxonomies aligned:
Category::updateOrCreate(
['slug' => 'laravel'],
['name' => 'Laravel']
);
This also makes sync() safe in form handling, because IDs are stable across environments.
Sync from a form request, never attach() blindly. A multi-select that posts category_ids should always sync() so deselections actually remove rows. Wrap it in a transaction when you're also writing the parent model:
DB::transaction(function () use ($request, $post) {
$post->update($request->validated());
$post->categories()->sync($request->input('category_ids', []));
});
Use syncWithoutDetaching() only for additive flows (e.g. importing, or an API that adds a category to an existing resource).
Centralize filtering in a query scope. The biggest day-to-day win is not the trait — it's that categorization gives you a clean filter axis. Put it on the model or in a dedicated query builder, so controllers stay thin:
public function scopeInCategory(Builder $query, Category|string $category): Builder
{
return $query->whereHas('categories', function ($q) use ($category) {
is_string($category)
? $q->where('slug', $category)
: $q->whereKey($category->getKey());
});
}
Add whereHas plus with('categories') together, or you will pay for it twice — one query per row to render the category chips.
Always eager-load in list endpoints. Serialize through an API resource so the pivot noise (pivot with timestamps and morph type) does not leak:
$post->categories->map(fn ($c) => [
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
]);
Do not expose $post->categories directly — the polymorphic pivot contains categorizable_type and can confuse clients.
Nested categories need recursion, not magic. If the Category model has a parent_id, build a parent/child tree yourself or use kalnoy/nestedset alongside. Two practical options:
Category::with('children.children') for a two-level menu.whereHas over a self-referencing tree in SQL is painful and slow, so resolve descendants to an ID array first and use whereIn.For a filter UI, resolve the descendant set in PHP (cacheable) and then:
Post::whereHas('categories', fn ($q) => $q->whereIn('categories.id', $ids));
Reuse the trait across models, with a consistent pivot contract. Post, Product, Video all get the same trait and the same pivot table. That means one admin UI, one CategoryController, one filter scope. Keep the trait usage and the morphMap in sync so the pivot rows stay readable:
Relation::enforceMorphMap([
'post' => \App\Models\Post::class,
'product' => \App\Models\Product::class,
]);
Cache the taxonomy, not the assignments. Categories change rarely; assignments change constantly. Cache Category::orderBy('name')->get() (and any tree structure) for an hour, and always query the pivot live.
Check maintenance before you commit. Last release is January 2023 and the repo is small (60 stars, low overall score). Before adopting, run composer require against your Laravel version and confirm the constraints resolve. If you're on Laravel 11/12, expect to either --ignore-platform-reqs (bad) or fork and bump the illuminate/* constraints (fine — the package is thin).
The dependency may be more than the package is worth. What this package gives you is essentially a categories table plus a polymorphic pivot plus a trait wrapper. If the version constraints fight you, the honest fallback is ~40 lines of your own:
// categories: id, name, slug, parent_id, timestamps
// categorizables: category_id, categorizable_id, categorizable_type, timestamps, unique(...)
public function categories(): MorphToMany
{
return $this->morphToMany(Category::class, 'categorizable');
}
Copy the migration stubs out of vendor/ and drop the dependency. You keep 100% of the behavior and remove the upgrade risk. The only thing you lose is the sugar methods, which you probably weren't using in hot paths anyway.
Watch for table-name collisions. categories is a very common table name. If you already have one (or a category_id column on a model), the published migration will fail or, worse, silently conflict. Rename the published table in the migration before running it, and update the Category model's $table.
Add a unique index on the pivot — and handle the duplicate error. Without unique(['category_id', 'categorizable_id', 'categorizable_type']), repeated form submits will happily create duplicate rows, and $post->categories will return dupes. With the index, attach() on an existing pair throws a QueryException. sync() avoids this because it diffs; that's another reason to prefer it.
Deleting a category leaves orphaned pivots unless you cascade. The published migration may not include onDelete('cascade') on the pivot foreign keys. Check, and add it. Also decide what happens on model delete — morphToMany does not clean up pivot rows automatically unless the database FK handles it or you detach in a model event / observer.
Morph map changes break existing rows. If you switch from class names to aliases (or change an alias), every existing categorizable_type value in the pivot becomes stale. Backfill the pivot in a data migration, in the same deploy, or you will silently lose associations.
whereHas on a polymorphic relation across multiple models is a query-plan trap. Filtering Post::inCategory($slug) works fine, but a global search page that unions Posts, Products, and Videos by category will run one query per model. Consider a denormalized category_slugs cache column on each searchable model if category filtering is a core feature rather than an admin convenience.
Debugging is easiest at the SQL level. When $post->categories comes back empty, dump $post->categories()->toSql() and $post->categories()->getBindings(), then check the pivot table directly. Ninety percent of the time it's a morph type mismatch (alias vs FQCN), a missing sync after a detach, or the relation resolving to the wrong pivot table name.
Testing. Use RefreshDatabase and a factory for Category. Because the trait is just a relation, standard assertions work:
$post->categories()->attach($category);
$this->assertTrue($post->fresh()->categories->contains($category));
Assert on ->categories()->count() rather than the loaded property when you're checking that a detach actually persisted.
Extension points are limited — plan for a fork. If the package ships a config with a custom model binding, use it to point at App\Models\Category so you can add scopes, accessors, and media without touching vendor/. If it doesn't, subclass and swap the relation return type, or migrate to the in-house morphToMany described above. With a package this size and this release cadence, assume you may own the code eventually, and keep your category logic in your own app layer so the swap is a one-file change.
How can I help you explore Laravel packages today?