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

Bookmarkable Laravel Package

x-laravel/bookmarkable

x-laravel/bookmarkable adds bookmarking to Laravel Eloquent models with a simple trait. Let users save favorites or bookmarks on any model through polymorphic relations, with straightforward attach, detach, and toggle methods plus convenient per-user queries.

View on GitHub
Deep Wiki
Context7

Getting Started

Three commands and two traits get you running:

composer require <vendor>/<package>
php artisan vendor:publish --tag="<package>-config"   # and --tag="<package>-migrations"
php artisan migrate

Then wire the models. The split is deliberate and worth internalizing before writing a line of code:

  • The owner (usually User) implements Contracts\CanBookmark and uses Concerns\Bookmarker — this is the model that saves things.
  • The bookmarkable models (Post, Product, Recipe…) implement Contracts\HasBookmarks and use Concerns\Bookmarkable.

The contract/trait naming is inconsistent enough that it will trip you up once: the contract is CanBookmark, the trait is Bookmarker. Read the use statements in the README carefully rather than pattern-matching from memory.

Every CanBookmark owner gets a default private folder lazily created on the first bookmark() call. That folder is the "save for later" bucket most apps actually need, and it is immutable by design — no rename, no sharing, no deletion. Treat it as package-owned state, not user data.

First use case to build: a save/unsave toggle on a listing page. One endpoint calls bookmark()/unbookmark(), one query decorates the index with withBookmarkedBy($user), and isBookmarkedBy($user) renders the button state. That's a complete feature and exercises the whole happy path.

Look at the config file first — connection, table names, default folder name, and model swaps all live there. Then read the migrations, because the indexes on the pivot table tell you what the package expects to be fast.

Implementation Patterns

Toggle endpoints. Keep the controller thin and let events carry the side effects:

public function store(Post $post)
{
    auth()->user()->bookmark($post);          // default folder
    return back();
}

Return the resulting state (hasBookmarked()) so the frontend doesn't have to guess, and make the endpoint idempotent at the HTTP layer — double-clicks on a bookmark button are the norm, not the exception.

Index pages — kill the N+1 before it ships. This is the single highest-value thing in the release:

$posts = Post::query()
    ->withBookmarkedBy(auth()->user())   // adds the saved state in one query
    ->paginate(20);

$post->isBookmarkedBy(auth()->user());   // reads the already-loaded state

Use the scope on any list that renders bookmark icons. Use isBookmarkedBy() on a single model or a collection that already has the flag loaded; calling it in a loop without the scope is exactly the N+1 the scope exists to prevent.

Custom folders as a real feature. Folders are ULID-keyed (safe for URLs and route model binding), carry a private/shared flag, and expose ordered() sorted by position then last activity. The natural workflow — "save to default, then organize later" — maps cleanly onto a move action, and ordered() gives you a drag-and-drop list that doesn't need its own sort column.

Events as the integration seam. Bookmarked and Unbookmarked are dispatched after commit, so listeners see the persisted row. That makes them the right place for activity feeds, notification fan-out, search index updates, and cache invalidation. Queue the listeners — anything slow or failure-prone in a post-commit listener cannot roll back the bookmark.

Swapping models. Config-driven model resolution means you extend the shipped models, add your own behavior (soft deletes, extra columns, casts), and repoint the config keys. Do this before you have production data; retrofitting later means migrating rows.

Pruning. Model deletion cleans up folders and bookmarks via events, but rows orphaned by raw deletes (DB::table(...)->delete(), bulk imports, dropped foreign keys) need model:prune. The package schedules it, but confirm it's actually registered in your app's schedule and tune the chunk size for your table size. Also verify your queue worker and scheduler are both running — silent pruning failure is invisible until the table is large.

Gotchas and Tips

  • The default folder is untouchable. No rename, no share, no delete. Don't build UI affordances for those operations — a settings page with a delete button on the default folder is a support ticket factory. Enforce it in your policies, not just in the view.

  • hasBookmarked() has three meanings. Default folder, a specific folder, or every folder. Be explicit at every call site, and be aware that "any folder" and "default folder" diverge the moment a user creates their first custom folder. Your save button and your folder view can disagree about the same item.

  • After-commit events and transactional tests don't mix. Laravel wraps tests in a transaction, and post-commit events don't fire inside an open transaction. If you're asserting Event::assertDispatched(Bookmarked::class) in a test using RefreshDatabase or DatabaseTransactions and it fails, this is almost certainly why — not a broken listener. Reach for DatabaseTruncation for those specific tests.

  • Duplicate saves depend on a DB constraint. Toggling a bookmark twice in flight can race. Confirm whether the pivot carries a unique index on (owner, folder, bookmarkable) and decide whether you want a caught QueryException or a firstOrCreate-style guard. Don't assume bookmark() is idempotent — verify.

  • Separate connection is a trap for scopes. If you configure the package onto its own connection, withBookmarkedBy() may need to join across databases. Cross-connection joins fail or silently degrade. Keep bookmarks on the app's default connection unless you have a concrete reason not to.

  • Config cache will lie to you. After changing the default folder name, table names, or models, run php artisan config:clear. And note that renaming the default folder in config does not rename already-created folders — the name is materialized at creation time.

  • shared is a flag, not an authorization layer. The package has no idea what a team, tenant, or workspace is. You own the visibility checks: a policy on folder access, a scoped query for shared listings, and a decision about whether shared folders leak bookmark existence for items the viewer can't otherwise see.

  • ULIDs are the extension point you'll want. Folder keys are non-sequential, so they're safe in URLs, but if you swap in your own folder model, preserve the key type and incrementing = false or route model binding and ordered() will misbehave.

  • Two cleanup mechanisms means two failure modes. Event-driven cascade handles normal model deletes; prune handles everything else. Neither covers hard-deleted rows from DB::table() — that's the prune path. Add a dashboard check or a low-frequency alert on orphan count before it becomes a migration project.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource