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 Markable Laravel Package

maize-tech/laravel-markable

Laravel Markable adds markable features like likes, bookmarks, favorites, and reactions to any Eloquent model. Install via composer, publish the migrations you need, and start tracking user interactions with simple traits.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and run its installer — that's the only setup step that isn't optional:

composer require maize-tech/laravel-markable
php artisan markable:install

markable:install publishes config/markable.php. Read that file first: it holds the user_model, the table_prefix (default markable_), and the allowed_values map. Every quirk you'll hit later traces back to one of those three keys.

Next, publish only the migrations for the marks you actually need. Each mark type gets its own table, so don't publish all four "just in case":

php artisan vendor:publish --tag="markable-migration-like"
php artisan migrate

Available tags: markable-migration-bookmark, -favorite, -like, -reaction.

Your first use case is almost always a like button. Add the trait to the model being marked, declare which marks it accepts, and you're done:

use Maize\Markable\Markable;
use Maize\Markable\Models\Like;

class Course extends Model
{
    use Markable;

    protected static $marks = [Like::class];
}

Then wire it up statically — no dependency injection, no service class:

Like::toggle($course, auth()->user());
Like::has($course, auth()->user());   // bool
Like::count($course);                 // int

If you only read one thing beyond the README, read the abstract Maize\Markable\Mark class. It is where add/remove/toggle/has/count, the value validation, and the relation-name resolution live — everything is built on it.

Implementation Patterns

Controller + toggle endpoint. The static API maps cleanly onto a single POST route. Because toggle both creates and removes, you don't need separate like/unlike routes, and the response can return the fresh Like::count() for optimistic UI updates:

public function toggle(Course $course)
{
    abort_unless(auth()->check(), 401);

    Like::toggle($course, auth()->user());

    return response()->json([
        'liked' => Like::has($course, auth()->user()),
        'count' => Like::count($course),
    ]);
}

Use has() to render state, not ->likes. For a single record on a detail page, Like::has($course, $user) is one cheap query against one small table. Reserve the Eloquent relations ($course->likes, $course->likers) for admin views and exports where you actually need the full collection.

Filtering lists is where the package earns its keep. The dynamic scope name is whereHas + the mark class name, so Like becomes whereHasLike. This is the pattern for a "My liked courses" page and it composes with normal query builder calls:

Course::whereHasLike(auth()->user())
    ->withCount('likes')
    ->latest()
    ->paginate(20);

Post::whereHasReaction(auth()->user(), ReactionType::Heart)->get();

Values vs. separate mark classes. Decide this early. If two interactions are the same verb with different flavours, use one mark plus allowed_values (reactions are the canonical example). If they're semantically different actions — a bookmark is not "a like with a different value" — define two marks and two tables. Retrofitting a value column into a mark that already has data is annoying; splitting a value mark into two classes is worse.

Custom marks follow a two-step recipe (migration + class), and the migration must reproduce the shipped schema: user_id foreign key with cascade, morphs('markable'), nullable value, nullable metadata JSON, timestamps. The only thing you're required to implement is markableRelationName():

class Bookmark extends Mark
{
    public static function markableRelationName(): string { return 'bookmarkers'; }
}

Now $course->bookmarkers yields users with the mark as pivot. markRelationName() defaults to the plural of the class name and only needs overriding if that collides with an existing relationship on your model — worth doing proactively on models that also have a real bookmarks() relation of their own.

Metadata is for context, not state. Like::add($course, $user, ['topic' => $course->topic->slug]) is a good use: it snapshots what the user was looking at. Don't store mutable facts there (e.g. the target's current title) — you'll be writing cleanup jobs to reconcile them.

Enums (v3+). If you're on 3.x, define a BackedEnum and point allowed_values at the class itself rather than an array of strings. Every method accepts null|string|BackedEnum, so your controller signatures become type-safe and IDE-completable instead of relying on string literals that can silently drift.

Gotchas and Tips

The allowed_values key must be the mark class's short name, lowercased. Reaction::class → 'reaction'. A custom App\Models\Bookmark → 'bookmark', not the FQCN. Get this wrong and your values are silently rejected rather than erroring loudly; if add() with a value seems to no-op, check this key first.

Calling add() with a value on a mark that has no allowed_values entry will fail validation. The config docblock is precise: a mark that accepts no values simply shouldn't appear in the array at all. Use '*' as the value when you genuinely want free-form input, and prefer the enum approach over '*' for anything user-facing.

table_prefix is load-bearing. It's used both to name tables and by the package to resolve them. If you change it after migrating, existing data becomes invisible. Change it before php artisan migrate, never after — or write a rename migration.

Each mark is a separate table, and that's the point. Performance is why the design exists, and it means Like::count($course) never scans bookmark rows. The cost is that "total engagement across all mark types" requires N queries or a manual union. If you need that number on every page, cache it rather than summing on the fly.

The shipped migrations don't declare a unique constraint across (user_id, markable_type, markable_id, value). Publish the migration, then add the unique index yourself before migrating. toggle is a read-then-write and two concurrent requests can otherwise leave you with duplicate rows and an inflated count. Same reasoning applies to any custom mark table you write.

Use whereHas… scopes over whereIn on ->likers. Course::whereHasLike($user) runs as a subquery against the mark table; pulling $user->likedCourses->pluck('id') into PHP and using whereIn loads the whole set into memory and breaks down past a few thousand rows.

Eager-load the mark relations in list views. $course->likes in a @foreach is a classic N+1. Use withCount('likes') for counts and with('likes') only when you truly need the mark rows; combine with the whereHas… scope so you're not loading marks for rows the user never interacted with.

Deleting a marked model cascades marks away (handled since 1.0.1), and the user_id foreign key cascades on user deletion — but only if you kept the constrained()->cascadeOnDelete() from the published stub. If you hand-wrote a custom mark migration, verify that FK exists; otherwise deleting a user leaves orphaned marks that will skew your counts forever.

Debugging a scope that returns nothing: the scope name is built from the mark's class basename, so a custom mark named Bookmark gives whereHasBookmark, not whereHasBookmarks. Check singular vs. plural before you start doubting your data. Likewise, the trait reads $marks — if you typo the property name or forget static, the model has no marks registered and every static call bypasses the model-level guard entirely.

Extension point: Mark is abstract and small. If you need behaviour changes — say, firing a domain event on every add, or enforcing "one value per user per entity" for reactions — subclass it, override the relevant method, and register your subclass in $marks and allowed_values. That's cleaner than wrapping every call site in an observer.

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.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic