laravel-interaction/bookmark
Laravel Interaction Bookmark adds bookmarking to any Eloquent model via a simple trait. Users can bookmark posts, comments, or other records, with toggle, count, and query helpers. Polymorphic, configurable, and easy to drop into existing Laravel apps.
Install and publish
composer require laravel-interaction/interactions
php artisan vendor:publish --tag="interactions-config"
php artisan vendor:publish --tag="interactions-migrations"
php artisan migrate
If the tagged publishes don't resolve, fall back to --provider="LaravelInteraction\Interactions\InteractionsServiceProvider" and pick the config/migration entries interactively.
Wire up the two sides of the relationship
// The actor (usually User)
use LaravelInteraction\Interactions\Concerns\HasInteractions;
class User extends Authenticatable
{
use HasInteractions;
}
// The target (Post, Comment, Video…)
use LaravelInteraction\Interactions\Concerns\CanBeInteracted;
class Post extends Model
{
use CanBeInteracted;
}
First use case — likes on a Post
$user->like($post); // create
$user->toggleLike($post); // create or remove (what your endpoint should call)
$user->unlike($post); // remove
$user->hasLiked($post); // bool, for the button state
$post->likes()->count(); // read side
$post->isLikedBy($user); // inverse convenience check
Where to look first
config/interactions.php — the types map is the whole public surface. Every feature (like, dislike, love, bookmark, subscribe, follow) is just a type name; methods like like()/hasLiked()/likes() are generated from those names.src/Concerns — the definitive list of available methods for this version (don't trust blog snippets for 1.x).README quick-start for the exact publish tags and supported Laravel/PHP matrix.One generic endpoint, many features. Because types are config-driven, avoid writing LikeController, BookmarkController, SubscribeController. Use a single controller action with a whitelisted type and toggleInteraction/attachInteraction/detachInteraction:
public function __invoke(Request $request, Post $post)
{
$type = $request->enum('type', InteractionType::class); // or validated string
$post->isInteractedBy($request->user(), $type) // or hasInteraction($type)
? $request->user()->detachInteraction($post, $type)
: $request->user()->attachInteraction($post, $type);
return response()->json([
'active' => $request->user()->hasInteraction($post, $type),
'count' => $post->interactions()->where('type', $type->value)->count(),
]);
}
Cast the type. Keep a App\Enums\InteractionType: string (or the package's enum if it ships one) and pass ->value into the DB but ->name to the UI. Raw strings scattered across Blade, controllers, and tests are the #1 source of "why is my count zero" bugs — a single typo in 'bookmarked' vs 'bookmark' silently creates a second type.
Read-side: counts without N+1. On index pages, eager-load per type:
$posts = Post::query()
->withCount(['likes', 'bookmarks'])
->paginate();
// then, for the current user's state, one query for the whole page:
$interacted = Interaction::query()
->where('user_id', $request->user()->id)
->whereIn('interactable_id', $posts->pluck('id'))
->get()
->groupBy('interactable_id');
Rendering $post->isLikedBy(auth()->user()) inside a loop is a query per row — the classic performance trap with this package.
Blade component for the button. Extract <x-interaction-button :model="$post" type="like" :active="$post->is_liked" :count="$post->likes_count" /> so the JS/optimistic-update logic lives in one place. Alpine + fetch to the single endpoint, swap active and count from the JSON response.
Multiple types on one model. The single interactions table is polymorphic on both sides, so a Post can carry likes and bookmarks and a User can like a Comment — no extra tables. Group UI by type and always scope reads by type explicitly (->where('type', 'like')) rather than relying on $post->interactions() which returns all types mixed.
Testing. Factories are trivial because the API is fluent; assert against the table, not the count:
$this->actingAs($user)->post("/posts/{$post->id}/like")->assertOk();
$this->assertDatabaseHas('interactions', [
'user_id' => $user->id,
'interactable_id' => $post->id,
'interactable_type' => $post->getMorphClass(),
'type' => 'like',
]);
Notifications / side effects. Keep them out of the package hooks and in a small InteractionService you own — you'll want "don't notify on unlike", "debounce spam", and "only notify on the first like", none of which belong in a controller.
bigint interactable_id with UUID-keyed models produces silently empty relations, not errors.attachInteraction on a duplicate will throw a QueryException rather than no-op. Use toggle semantics in the controller, or firstOrCreate-style checks, and wrap in a transaction if the UI can double-submit.count() counts every type. $post->interactions()->count() is not the like count. Always ->where('type', 'like') or the generated likes() relation. This bites hardest right after you add a second type to a page that already "worked".withCount('likes') only exists if like is in types. Adding a type later requires a config change and (in cached config environments) php artisan config:clear, otherwise the new relation method doesn't appear in production but does locally.utf8mb4_bin exposes it. Enums + a single ->value conversion avoids it entirely.User deleted outside Eloquent (raw delete, or a Post hard-deleted by a cleanup job). Add FK cascades in a follow-up migration or a scheduled prune on interactions where the morph target no longer exists.hasLiked() is a query. Accept a loaded $user and pre-fetch state for lists; if you're rendering 50 rows, you want one query, not 50. Consider withExists for boolean columns in API resources.config/interactions.php (add types, adjust table/column names, model bindings); publish the migration to add extra columns (e.g. created_at indexes, a metadata JSON) — do this in a new migration rather than editing the published one if you already deployed; override the model class via config if you need Interaction with casts or scopes; and finally, extend the traits in your own Concerns namespace if you need a domain wrapper.^1.0. This is the first stable tag, so the public API (trait names, generated method names, table name) should now hold — but a 1.0 from a single-maintainer package still warrants a lockfile-pinned deploy and a quick composer update --dry-run review before jumping minors, especially around config key renames.How can I help you explore Laravel packages today?