Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Categorizable Laravel Package

alibayat/laravel-categorizable

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The service provider — tells you what is bound, what is publishable, and whether the Category model is swappable via config.
  2. The 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.
  3. The Category model — check for parent_id/nested support, fillable attributes, and whether it is a plain Eloquent model or uses a nested-set library.
  4. The migration stubs — confirm the pivot table name and column names, because you'll reference them in raw queries and in 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.

Implementation Patterns

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:

  • Recursive eager load: Category::with('children.children') for a two-level menu.
  • Materialized path or nested set if you need "all descendants of X" filters — 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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast