Laravel Bookmarks
Laravel Bookmarks lets any Eloquent model bookmark any other, and keep a JSON object of metadata on each bookmark: a note, a priority, a reminder flag, or whatever your application needs. Add CanBookmark to the model that bookmarks, such as a user, and HasBookmarks to the model that is bookmarked, such as a post. Each bookmark is one row in a bookmarks table, and a unique index keeps every pair to one row, even when two requests write it at the same moment. bookmark() creates the bookmark, replaces its metadata, or leaves it untouched, and always returns the stored row; the metadata reads back as an immutable BookmarkMeta object.
JSON-Object Metadata
Each bookmark holds a JSON object, read as properties at every depth.
Replace or Keep
New metadata replaces the whole object, no metadata keeps it, an empty array empties it.
No Empty Writes
Equal metadata in any key order runs no UPDATE and fires no event.
Unique by the Database
A unique index keeps each pair to one row under concurrent requests.
Native Relations
Count, filter, flag and eager load bookmarks with plain Eloquent queries.
Mixed Key Types
Integer, UUID and ULID models share one table, with morph map aliases respected.
Metadata is replaced, never merged. To change one key, read the current object, change it, and pass the whole object back, as Metadata shows.
#Installation
Install the package with Composer and create its table.
#Requirements
- PHP 8.0 or 8.1
- Laravel 8
- MySQL, MariaDB, PostgreSQL or SQLite
#Composer Installation
composer require pharaonic/laravel-bookmarksThe service provider is registered automatically.
#Run Migrations
The package loads its migration automatically, so the bookmarks table is created with your other migrations:
php artisan migrateTo change the migration before it runs, publish it into database/migrations:
php artisan vendor:publish --tag=bookmarks-migrationsThe published file keeps the package's file name, 2026_11_13_000001_create_pharaonic_bookmarks_table.php, so Laravel runs your copy instead of the package's, never both.
The migration stops with an error when the table already exists. Set pharaonic.bookmarks.table to another name before migrating, or move that table out of the way first.
#Publish Configuration
Publish the configuration only when you need another table name, your own Bookmark model, or want to turn off cleanup on delete:
php artisan vendor:publish --tag=bookmarks-configThe file is published to config/pharaonic/bookmarks.php.
You're all set! Add CanBookmark and HasBookmarks to your models, as Basic Usage shows.
#Configuration
Every setting is optional. Publish the file with php artisan vendor:publish --tag=bookmarks-config to change them.
1return [2 'table' => 'bookmarks',3 'model' => \Pharaonic\Laravel\Bookmarks\Models\Bookmark::class,4 'cleanup_on_delete' => true,5];| Key | Default | Description |
|---|---|---|
pharaonic.bookmarks.table | bookmarks | The table the migration creates and the Bookmark model uses. |
pharaonic.bookmarks.model | Pharaonic\Laravel\Bookmarks\Models\Bookmark | The model the package reads, writes and returns: the package model or a subclass. See Custom Bookmark Model. |
pharaonic.bookmarks.cleanup_on_delete | true | Delete a model's bookmarks when the model is deleted. See Deletes and Soft Deletes. |
#Changing the table name
The migration reads the table name when it runs, so set it before the first php artisan migrate:
1'table' => 'saved_posts',Index names follow the table name (saved_posts_unique_pair, saved_posts_bookmarker_index).
Changing the setting after the migration ran does not rename the table: the package then reads and writes a table that doesn't exist. Rename the table in a migration of your own (Schema::rename('bookmarks', 'saved_posts')) at the same time as you change the setting. Rolling back the package migration drops the table the setting names at that moment.
#Basic Usage
#Add the traits
Add CanBookmark to the model that bookmarks. It does not have to be a user: a team, a bot or any other model works.
1namespace App\Models;2 3use Illuminate\Foundation\Auth\User as Authenticatable;4use Pharaonic\Laravel\Bookmarks\Concerns\CanBookmark;5 6class User extends Authenticatable7{8 use CanBookmark;9}Add HasBookmarks to the model that is bookmarked. It gives the model its bookmarks() relation and query scopes; a model without it can still be bookmarked.
1namespace App\Models;2 3use Illuminate\Database\Eloquent\Model;4use Pharaonic\Laravel\Bookmarks\Concerns\HasBookmarks;5 6class Post extends Model7{8 use HasBookmarks;9}A model can use both traits, for example users who bookmark each other.
#Bookmark
bookmark() returns the stored Bookmark, whether this call created it or it already existed:
1$bookmark = $user->bookmark($post);2 3$bookmark->exists; // true4$bookmark->meta->isEmpty(); // true: a new bookmark without metadata stores {}Pass metadata as the second argument. It replaces the whole metadata of an existing bookmark:
1$user->bookmark($post, ['note' => 'Read later', 'priority' => 5]);2$user->bookmark($post, ['priority' => 2]);3 4$user->bookmarkOf($post)->meta->toArray(); // ['priority' => 2]: note is goneLeave the metadata out (or pass null) to keep what is stored, and pass [] to empty it:
1$user->bookmark($post); // keeps ['priority' => 2]2$user->bookmark($post, []); // stores {}#Read and remove
1$user->hasBookmarked($post); // true2$user->bookmarkOf($post); // the Bookmark, or null3$user->unbookmark($post); // true: this call removed it4$user->unbookmark($post); // false: there was none#Toggle
toggleBookmark() creates the bookmark, with the metadata you pass, and returns it; when the bookmark exists, it removes it and returns null. The metadata is only used when it creates one.
1$user->toggleBookmark($post, ['source' => 'feed']); // the new Bookmark2$user->toggleBookmark($post); // null: removedFor a bookmark button, send the state the user wants and call bookmark() or unbookmark(). A double click then can't undo itself, which toggleBookmark() can.
#From the bookmarked side
HasBookmarks offers the same operations from the other side, with the same results:
1$post->bookmarkBy($user, ['note' => 'Read later']); // same as $user->bookmark($post, [...])2$post->bookmarkedBy($user); // same as $user->hasBookmarked($post)3$post->unbookmarkBy($user); // same as $user->unbookmark($post)#Unsaved models
The writes (bookmark(), unbookmark(), toggleBookmark(), bookmarkBy(), unbookmarkBy()) throw an InvalidArgumentException when either model is not saved yet, and write nothing. The reads return false or null instead.
#A returned bookmark is a snapshot
A Bookmark you hold does not change when a later call writes the same pair. Use the model the later call returns, or refresh() the one you have:
1$first = $user->bookmark($post, ['a' => 1]);2$user->bookmark($post, ['a' => 2]);3 4$first->meta->a; // 15$first->refresh()->meta->a; // 2#Metadata
Every bookmark has a meta attribute that holds one JSON object. It is never NULL and never a list or a scalar: a bookmark without metadata holds the empty object {}.
#What you can pass
bookmark(), bookmarkBy(), toggleBookmark() and the meta attribute accept:
- an array with string keys, such as
['note' => 'Read later']; the empty array[]is the empty object; - a
stdClass, such asjson_decode($json), which keeps{}and[]apart at every depth; - a
BookmarkMeta, such as another bookmark'smeta.
Inside the object, values can be strings, integers, floats, booleans, null, lists and nested objects (arrays with string keys, stdClass or BookmarkMeta).
Anything else throws an InvalidArgumentException before the database is touched: a list at the top (['a', 'b']), a string (including a JSON string), a number, a boolean, an object such as a Collection, a Carbon date or a model, NAN or INF, invalid UTF-8, a key that starts with a NUL byte, a structure that contains itself, or nesting deeper than 512 levels. The message says where the problem is, for example (at meta.preferences.channels[2]).
Below the top level, an empty PHP array is the empty list [], as json_encode() writes it. Use new stdClass for an empty object there. A PHP array whose keys are not 0, 1, 2… is an object, with its keys as strings: [1 => 'a'] is {"1":"a"}.
#Reading metadata
$bookmark->meta is a Pharaonic\Laravel\Bookmarks\ValueObjects\BookmarkMeta. Read keys as properties; a nested object is a BookmarkMeta too, and a list is a PHP array.
1$bookmark = $user->bookmark($post, [ 2 'note' => 'Read later', 3 'priority' => 5, 4 'reminder' => null, 5 'preferences' => ['notify' => true, 'channels' => ['mail', 'push']], 6]); 7 8$bookmark->meta->note; // "Read later" 9$bookmark->meta->preferences->notify; // true10$bookmark->meta->preferences->channels; // ["mail", "push"]11$bookmark->meta->missing; // nullget() takes a default for a key that is absent, and has() tells an absent key from one that holds null:
1$bookmark->meta->get('priority', 0); // 52$bookmark->meta->get('missing', 0); // 03$bookmark->meta->get('reminder', 0); // null: the key is present4$bookmark->meta->has('reminder'); // true5$bookmark->meta->has('missing'); // false6isset($bookmark->meta->reminder); // false, as isset() meansRead a key that is not a valid PHP name with get('my-key') or $meta->{'my-key'}.
#Converting
| Method | Returns | Notes |
|---|---|---|
toArray() | array | Plain nested arrays. {} and [] both become [], and numeric keys become integers. |
toObject() | stdClass | A detached copy that keeps {} and [] apart. Pass it back to bookmark() without losing anything. |
json_encode($meta) | string | Always a JSON object. |
isEmpty() | bool | Whether the metadata is {}. |
$bookmark->toJson() and json_encode($bookmark) write meta as a JSON object. $bookmark->toArray()['meta'] is the BookmarkMeta itself, so the JSON shape survives until it is encoded.
#Metadata is read-only
Writing to a BookmarkMeta throws a LogicException, at every depth, and values it returns are copies, so nothing you do with them changes the stored metadata:
1$bookmark->meta->priority = 10; // LogicException2$bookmark->meta->preferences->notify = false; // LogicExceptionTo change one key, build the new object and pass it whole:
1$meta = $user->bookmarkOf($post)->meta->toObject();2$meta->priority = 10;3 4$user->bookmark($post, $meta); // every other key is kept#Replace, keep, or empty
| Pair already bookmarked? | Metadata argument | Result |
|---|---|---|
| No | omitted or null | Inserts the bookmark with {}. |
| No | an object | Inserts the bookmark with that object. |
| Yes | omitted or null | Returns the bookmark. Nothing is written. |
| Yes | equal to what is stored | Returns the bookmark. Nothing is written, updated_at is kept, no event. |
| Yes | a different object | Replaces the whole object: one UPDATE, updated_at changes, created_at does not. |
| Yes | [] | Stores {}, unless it is already empty. |
| Either | not a JSON object | Throws InvalidArgumentException. Nothing is written. |
#When metadata is equal
Two objects are equal when they hold the same JSON, whatever the order of their keys, at every depth. List order and value types count:
| Stored | New | Equal? |
|---|---|---|
{"a":1,"b":2} | {"b":2,"a":1} | Yes |
{"a":{"x":1,"y":2}} | {"a":{"y":2,"x":1}} | Yes |
{"a":1} | {"a":"1"} | No |
{"a":1} | {"a":1.0} | No |
{"a":true} | {"a":1} | No |
{"a":null} | {} | No |
{"a":[1,2]} | {"a":[2,1]} | No |
{"a":[]} | {"a":{}} | No |
Metadata is stored as canonical JSON, with its keys sorted, so it reads back in key order rather than the order you wrote it. Floats keep their type: 1.0 reads back as a float, not the integer 1.
#Assigning on the model
You can also assign the attribute and save the model yourself:
1$bookmark->meta = ['priority' => 5];2$bookmark->save();The attribute takes the same values as bookmark(). Assigning null throws, because it would store SQL NULL; assign [] for the empty object. Assigning metadata equal to what is stored leaves the attribute clean (isDirty('meta') is false), so save() runs no UPDATE. A direct save() fires the Bookmark model's own Eloquent events, but not the package's BookmarkUpdated event.
#Querying metadata
Use Laravel's JSON where clauses on the meta column:
1$user->givenBookmarks()->where('meta->priority', 5)->get();2$user->givenBookmarks()->where('meta->preferences->notify', true)->count();#Partial selects and damaged rows
When a query does not select the meta column, $bookmark->meta is null, as for any column you didn't select.
When a row holds something other than a JSON object in meta (SQL NULL, a JSON string or list, broken JSON written outside the package), reading $bookmark->meta throws an UnexpectedValueException that names the table and the bookmark id. Calling bookmark() with new metadata replaces the damaged value.
#Relationships
All relations are native Eloquent relations, so everything Eloquent offers works on them.
| Relation | On | Type | Holds |
|---|---|---|---|
givenBookmarks() | CanBookmark models | MorphMany of Bookmark | The bookmarks the model kept. |
bookmarks() | HasBookmarks models | MorphMany of Bookmark | The bookmarks the model received. |
bookmarker() | Bookmark | MorphTo | The model that kept the bookmark. |
bookmarkable() | Bookmark | MorphTo | The model that was bookmarked. |
#Why givenBookmarks() and not bookmarks()
The bookmarker side's relation is called givenBookmarks() so it never clashes with bookmarks() on the bookmarked side. A User can use both traits without a trait conflict, and $user->bookmarks always means the bookmarks other models kept of that user.
#Reading through the relations
1use Pharaonic\Laravel\Bookmarks\Models\Bookmark; 2 3$bookmark = $post->bookmarks()->latest()->first(); 4 5$bookmark->bookmarker; // App\Models\User 6$bookmark->bookmarkable; // App\Models\Post 7$bookmark->meta->note; // "Read later" 8 9$user->givenBookmarks()->with('bookmarkable')->latest()->get()10 ->map(fn (Bookmark $bookmark) => $bookmark->bookmarkable); // what the user saved, of every type#The Bookmark model
Pharaonic\Laravel\Bookmarks\Models\Bookmark reads the table from pharaonic.bookmarks.table and has standard timestamps. It has two scopes for filtering bookmarks by one end:
1Bookmark::whereBookmarker($user)->count(); // bookmarks the user kept2Bookmark::whereBookmarkable($post)->count(); // bookmarks of the post3 4User::whereHas('givenBookmarks', fn ($query) => $query->whereBookmarkable($post))->get(); // who saved the postBoth scopes match nothing for an unsaved model. To add your own relations or scopes to the model, extend it, as Custom Bookmark Model shows.
#Querying and Counts
Counting, filtering and flagging run in SQL. None of them loads the bookmarks into PHP.
#Counting
1$post->bookmarks()->count(); // select count(*) ...2 3$posts = Post::withCount('bookmarks')->get();4$posts->first()->bookmarks_count; // 125 6$post->loadCount('bookmarks');$post->bookmarks->count() (property, not method) loads every bookmark of the post first. Use bookmarks()->count() or withCount('bookmarks').
#Filtering by bookmarker
whereBookmarkedBy() keeps the models the bookmarker bookmarked, and whereNotBookmarkedBy() keeps the others:
1Post::whereBookmarkedBy($user)->latest()->paginate();2Post::whereNotBookmarkedBy($user)->latest()->paginate();For an unsaved bookmarker, whereBookmarkedBy() matches nothing and whereNotBookmarkedBy() keeps every row.
#"Did I save this?" in a list
Calling $user->hasBookmarked($post) for every post of a page runs one query per post. withBookmarkedBy() adds an is_bookmarked column from one EXISTS subquery instead:
1$posts = Post::withBookmarkedBy($request->user())2 ->withCount('bookmarks')3 ->latest()4 ->paginate();5 6(bool) $post->is_bookmarked; // true when the viewer saved it7$post->bookmarks_count; // 12is_bookmarked is 1/0 on MySQL, MariaDB and SQLite and true/false on PostgreSQL, so cast it with (bool) or a model cast. It is false for every row when the viewer is unsaved (a guest model).
withBookmarkedBy() keeps the columns you already selected, and selects posts.* when you selected none. Call it after select(), because a later select() replaces the column list.
#Filtering by metadata
The meta column is JSON, so Laravel's JSON clauses work on the relations:
1$user->givenBookmarks()->where('meta->priority', '>=', 3)->get();2 3Post::whereHas('bookmarks', fn ($query) => $query4 ->whereBookmarker($user)5 ->where('meta->remind', true)6)->get();#Eager loading
Load bookmarks and both ends with a fixed number of queries:
1Post::with('bookmarks.bookmarker')->get(); // posts, bookmarks, then one query per bookmarker type2$user->givenBookmarks()->with('bookmarkable')->latest()->get();#Indexes
The migration creates:
| Index | Columns | Serves |
|---|---|---|
bookmarks_unique_pair (unique) | bookmarkable_type, bookmarkable_id, bookmarker_type, bookmarker_id | Uniqueness, bookmarks(), withCount('bookmarks'), bookmarkedBy(), withBookmarkedBy() |
bookmarks_bookmarker_index | bookmarker_type, bookmarker_id | givenBookmarks(), whereBookmarkedBy() |
The ids are strings, so the relations compare your model's key as a string too. That keeps both indexes usable, and lets PostgreSQL compare a bigint or uuid key with the varchar column at all. The meta column has no index; add a generated column and an index of your own if you filter large tables by a metadata key.
#Morph Maps and Ids
#Stored types
The bookmarker_type and bookmarkable_type columns store $model->getMorphClass(): the class name, or its alias when you register a morph map.
1use Illuminate\Database\Eloquent\Relations\Relation;2 3public function boot()4{5 Relation::morphMap([6 'user' => \App\Models\User::class,7 'post' => \App\Models\Post::class,8 ]);9}With this map a bookmark stores user and post, and $bookmark->bookmarker resolves back to App\Models\User. Register the map before you store bookmarks: rows stored with class names are not rewritten when you add a map later, and lookups for post won't find rows stored as App\Models\Post.
#Enforced morph maps
With Relation::enforceMorphMap(), every model that bookmarks or is bookmarked needs an alias. Bookmarking with an unmapped model throws Laravel's ClassMorphViolationException before anything is written:
1Relation::enforceMorphMap([2 'user' => \App\Models\User::class,3 'post' => \App\Models\Post::class,4]);5 6$user->bookmark($product); // ClassMorphViolationException: No morph map defined for model [App\Models\Product].#Type length
Types are limited to 191 characters. A longer class name or alias throws an InvalidArgumentException before anything is written; register a shorter alias.
#Integer, UUID and ULID keys
bookmarker_id and bookmarkable_id are varchar(64), so models with different key types share the table:
1$user->bookmark($document); // integer bookmarker, UUID bookmarkable2$document->bookmark($comment); // UUID bookmarker, integer bookmarkableKeys are stored as strings (42 becomes '42'). A key longer than 64 characters throws an InvalidArgumentException instead of being cut.
#No foreign keys
The table has no FOREIGN KEY constraints. A polymorphic column points at a different table on each row (users, teams, posts), and a foreign key can only reference one table. The package deletes a model's bookmarks when the model is deleted instead (see Deletes and Soft Deletes).
#Custom Bookmark Model
To add relations, scopes, accessors or casts to bookmarks, extend the package model and name your class in the configuration. Do it before you migrate when your model changes the table or the connection.
1namespace App\Models; 2 3use Pharaonic\Laravel\Bookmarks\Models\Bookmark as BaseBookmark; 4 5class Bookmark extends BaseBookmark 6{ 7 public function scopePinned($query) 8 { 9 return $query->where('meta->pinned', true);10 }11 12 public function isPinned(): bool13 {14 return $this->meta->get('pinned') === true;15 }16}1'model' => \App\Models\Bookmark::class,The package then uses your class everywhere: bookmark(), bookmarkOf() and toggleBookmark() return it, givenBookmarks() and bookmarks() load it, the scopes and the cleanup query its table, and the events carry it.
1$user->bookmark($post, ['pinned' => true])->isPinned(); // true2$user->givenBookmarks()->pinned()->get();A class that is not Pharaonic\Laravel\Bookmarks\Models\Bookmark or a subclass of it makes every call throw an UnexpectedValueException.
#Table and connection
| Your model sets | The package |
|---|---|
| nothing | uses pharaonic.bookmarks.table on the default connection. |
protected $table = 'saved_items'; | uses saved_items, and the migration creates it, ignoring pharaonic.bookmarks.table. |
protected $connection = 'secondary'; | writes on that connection, in its own transactions, and the migration creates the table there. |
whereBookmarkedBy(), whereNotBookmarkedBy(), withBookmarkedBy(), withCount('bookmarks') and whereHas() put the bookmarks table in a subquery of your models' query, so they need both in the same database. Eager loading and the trait methods run separate queries and work across connections.
#What stays the package's
Keep the columns and the meta accessor and mutator as they are: the package writes bookmarker_type, bookmarker_id, bookmarkable_type, bookmarkable_id and meta itself, through forceFill(), and reads the row through a query without your global scopes, so a scope cannot hide the row that holds a pair. Your own extra columns need defaults, because the package never fills them.
#Events
The package dispatches three events, only when a call actually changed the table:
| Event | Dispatched by | Not dispatched |
|---|---|---|
Pharaonic\Laravel\Bookmarks\Events\BookmarkCreated | bookmark(), bookmarkBy(), toggleBookmark() when they insert the bookmark | When the bookmark already existed |
Pharaonic\Laravel\Bookmarks\Events\BookmarkUpdated | bookmark(), bookmarkBy() when they replace the metadata with different metadata | When the metadata was omitted, null or equal; for a direct $bookmark->save() |
Pharaonic\Laravel\Bookmarks\Events\BookmarkRemoved | unbookmark(), unbookmarkBy(), toggleBookmark() when they delete the bookmark | When there was none, and for the cleanup on delete |
Each has three public properties: $bookmark (the Bookmark row, or your configured subclass), $bookmarker, and $bookmarkable. In BookmarkUpdated, $bookmark->meta is the new metadata; in BookmarkRemoved, $bookmark is the deleted row, with exists false.
1namespace App\Listeners; 2 3use App\Jobs\SendReminder; 4use Pharaonic\Laravel\Bookmarks\Events\BookmarkCreated; 5 6class ScheduleReminder 7{ 8 public function handle(BookmarkCreated $event) 9 {10 if ($event->bookmark->meta->get('remind') === true) {11 SendReminder::dispatch($event->bookmark)->delay(now()->addDay());12 }13 }14}1protected $listen = [2 \Pharaonic\Laravel\Bookmarks\Events\BookmarkCreated::class => [3 \App\Listeners\ScheduleReminder::class,4 ],5];#When listeners run
Each write runs in a short transaction of its own, and the event is dispatched right after it. When you bookmark inside your own database transaction, the package's transaction is a savepoint in yours, so listeners run before your transaction commits, and a rollback afterwards does not take the event back. Make a listener that must only see committed bookmarks a queued listener dispatched after your transaction, or call the package outside it.
#Model events
Inserts and updates go through Eloquent's save(), so the Bookmark model's own creating, created, saving, saved, updating and updated events fire.
- A listener that returns
falsefromcreating,updatingorsavingcancels the write.bookmark()andtoggleBookmark()then throw aRuntimeException("A model event cancelled saving the bookmark…") instead of returning a bookmark that isn't stored, and no package event is dispatched. - A listener that changes the metadata changes what is stored, and the returned bookmark and the event carry the stored metadata.
The removal deletes the row with a query, so the Bookmark model's deleting and deleted events do not fire. Listen to BookmarkRemoved instead.
#Deletes and Soft Deletes
When a model that uses CanBookmark or HasBookmarks is deleted through Eloquent, its bookmarks are deleted with it:
| Action | Bookmarks it kept (CanBookmark) | Bookmarks it received (HasBookmarks) |
|---|---|---|
$model->delete() | deleted | deleted |
$model->delete() on a SoftDeletes model | kept | kept |
$model->restore() | still there | still there |
$model->forceDelete() | deleted | deleted |
A model with both traits loses both directions. Keeping the bookmarks on a soft delete means a restored model has exactly the bookmarks, and the metadata, it had.
1$post->delete(); // SoftDeletes: the bookmarks stay2$post->restore(); // $post->bookmarkedBy($user) is still true3$post->forceDelete(); // the bookmarks are deletedThe cleanup deletes the rows with one query and dispatches no BookmarkRemoved events.
#Turning cleanup off
1'cleanup_on_delete' => false,Deleting a model then leaves its bookmarks in the table. whereBookmarkedBy() and withCount() ignore them for models that no longer exist, but givenBookmarks() of a bookmarker still lists bookmarks of deleted posts.
#Deletes that skip the cleanup
The cleanup runs in the model's deleted event, so it only happens when a model instance is deleted. These skip it:
Post::where(...)->delete()and other query deletes;DB::table('posts')->delete()and raw SQL;truncate().
After such a delete, remove the bookmarks yourself, for example in the same job:
1use Pharaonic\Laravel\Bookmarks\Models\Bookmark;2 3$ids = Post::where('created_at', '<', now()->subYear())->pluck('id');4 5Post::whereIn('id', $ids)->delete();6Bookmark::where('bookmarkable_type', (new Post)->getMorphClass())7 ->whereIn('bookmarkable_id', $ids->map(fn ($id) => (string) $id))8 ->delete();To clean up after the fact, delete the bookmarks whose model is gone:
1Bookmark::where('bookmarkable_type', (new Post)->getMorphClass())2 ->whereNotIn('bookmarkable_id', Post::withTrashed()->pluck('id')->map(fn ($id) => (string) $id))3 ->delete();Leave out withTrashed() for models without SoftDeletes. Run it per type, and for bookmarker_type the same way. Use your configured model class instead of Bookmark when you have one.
#Integrity and Concurrency
#The unique index
The bookmarks_unique_pair index on (bookmarkable_type, bookmarkable_id, bookmarker_type, bookmarker_id) is what keeps a pair to one row. When bookmark() tries to insert a pair that already has a row, the index refuses the insert, and the call reads that row and continues with it: it replaces the metadata you passed, or returns the row unchanged.
Only a refusal by that index counts as "already bookmarked". Every other database error is thrown: a missing table, a lost connection, or a unique index of your own that refused the row.
#Transactions and retries
Each write runs in a transaction on the bookmark model's connection. Inside it, the insert runs in a savepoint, so a refused insert doesn't abort the transaction on PostgreSQL, and a row is locked (FOR UPDATE) before its metadata is compared and replaced, so a concurrent delete cannot slip in between. On MySQL, whose transactions read from a snapshot, that locking read also finds a row another request committed after your transaction started.
When the database rolls a statement back to break a deadlock, or SQLite reports that the database is locked, Laravel runs the package's transaction again, up to three times, so the call still returns its normal result. Inside your own transaction the database has already rolled your whole transaction back, so the error reaches you; run your transaction with retries:
1DB::transaction(function () use ($user, $post) {2 $user->bookmark($post, ['note' => 'Read later']);3 // ...4}, 3);#Concurrent writes of the same pair
| Situation | Result |
|---|---|
| Two requests bookmark a new pair at once | One row. One call creates it (BookmarkCreated), the other replaces its metadata (BookmarkUpdated) when it differs. Both return the row. |
| Two requests replace the metadata at once, with different objects | The last write wins, whole: the row holds one call's object, never a merge of both. |
| A bookmark and an unbookmark at once | Either the row is gone, or it exists with the metadata of the bookmark call. When the row keeps disappearing, bookmark() gives up after three tries with a RuntimeException rather than report a bookmark that isn't stored. |
| Two unbookmarks at once | One call deletes the row and returns true; the other returns false. |
#Toggling under concurrency
toggleBookmark() inserts first: when the insert works, the pair is now bookmarked and the call returns the bookmark. When the pair already has a bookmark, the call deletes it and returns null. When another call deleted it first, the pair has none either way, and the call returns null without an event.
What is guaranteed:
- the table never holds two rows for a pair;
- every call returns the state it left the pair in;
- events are dispatched only for changes the call made.
What is not: two toggles of the same pair at the same moment don't simply cancel out. When a user's intent matters, as in a bookmark button, send the state the user wants and call bookmark() or unbookmark().
#Tested databases
The test suite runs on SQLite, MySQL 8.4 and PostgreSQL 16, including tests that start several PHP processes, each with its own connection, bookmarking with different metadata, unbookmarking or toggling the same pair at the same moment. On SQLite the database file serializes those writes; MySQL and PostgreSQL run them in parallel.
#API Reference
#CanBookmark
Pharaonic\Laravel\Bookmarks\Concerns\CanBookmark, for the model that bookmarks. $meta is an array with string keys, a stdClass, a BookmarkMeta, or null.
| Method | Returns | Description |
|---|---|---|
bookmark(Model $bookmarkable, $meta = null) | Bookmark | Creates the bookmark, replaces its whole metadata, or keeps it (null). Returns the stored row. |
bookmarkOf(Model $bookmarkable) | ?Bookmark | The bookmark of the model, or null. Null when either is unsaved. |
hasBookmarked(Model $bookmarkable) | bool | Whether this model bookmarked the model. False when either is unsaved. |
unbookmark(Model $bookmarkable) | bool | Removes the bookmark. True when this call removed it, false when there was none. |
toggleBookmark(Model $bookmarkable, $meta = null) | ?Bookmark | Creates the bookmark with the metadata and returns it, or removes it and returns null. |
givenBookmarks() | MorphMany | The bookmarks this model kept. |
#HasBookmarks
Pharaonic\Laravel\Bookmarks\Concerns\HasBookmarks, for the model that is bookmarked.
| Method | Returns | Description |
|---|---|---|
bookmarks() | MorphMany | The bookmarks this model received. |
bookmarkBy(Model $bookmarker, $meta = null) | Bookmark | Same as $bookmarker->bookmark($this, $meta), for any bookmarker model. |
unbookmarkBy(Model $bookmarker) | bool | Same as $bookmarker->unbookmark($this). |
bookmarkedBy(Model $bookmarker) | bool | Same as $bookmarker->hasBookmarked($this). |
whereBookmarkedBy(Model $bookmarker) | scope | Keeps the models the bookmarker bookmarked: Post::whereBookmarkedBy($user). |
whereNotBookmarkedBy(Model $bookmarker) | scope | Keeps the models the bookmarker did not bookmark. |
withBookmarkedBy(Model $bookmarker) | scope | Adds an is_bookmarked column from one EXISTS subquery. |
#Bookmark
Pharaonic\Laravel\Bookmarks\Models\Bookmark, or the class in pharaonic.bookmarks.model.
| Member | Type | Description |
|---|---|---|
bookmarker() | MorphTo | The model that kept the bookmark. |
bookmarkable() | MorphTo | The model that was bookmarked. |
whereBookmarker(Model $bookmarker) | scope | Bookmarks kept by the model. Matches nothing for an unsaved model. |
whereBookmarkable(Model $bookmarkable) | scope | Bookmarks of the model. Matches nothing for an unsaved model. |
meta | BookmarkMeta | The metadata. Assign an array, stdClass or BookmarkMeta to replace it; null when the column was not selected. |
id | int | Primary key. |
bookmarker_type, bookmarkable_type | string | getMorphClass() of each end, up to 191 characters. |
bookmarker_id, bookmarkable_id | string | The key of each end as a string, up to 64 characters. |
created_at, updated_at | Carbon | Timestamps. updated_at changes only when the metadata does. |
#BookmarkMeta
Pharaonic\Laravel\Bookmarks\ValueObjects\BookmarkMeta, immutable.
| Member | Returns | Description |
|---|---|---|
new BookmarkMeta(array|stdClass $data = []) | BookmarkMeta | Builds metadata, for example to pass around before bookmarking. |
$meta->key | mixed | The value, or null when absent. Objects are BookmarkMeta, lists are arrays. |
get(string $key, $default = null) | mixed | The value, or the default only when the key is absent. |
has(string $key) | bool | Whether the key is present, even with a null value. |
isEmpty() | bool | Whether the metadata is {}. |
toArray() | array | Plain nested arrays; {} and [] both become []. |
toObject() | stdClass | A detached copy that keeps {} and [] apart. |
jsonSerialize() | stdClass | Used by json_encode(): always a JSON object. |
#Events
| Event | Properties | Dispatched |
|---|---|---|
Pharaonic\Laravel\Bookmarks\Events\BookmarkCreated | Bookmark $bookmark, Model $bookmarker, Model $bookmarkable | After a call inserted a bookmark. |
Pharaonic\Laravel\Bookmarks\Events\BookmarkUpdated | Bookmark $bookmark, Model $bookmarker, Model $bookmarkable | After a call replaced the metadata with different metadata. |
Pharaonic\Laravel\Bookmarks\Events\BookmarkRemoved | Bookmark $bookmark, Model $bookmarker, Model $bookmarkable | After a call deleted a bookmark (not on cleanup). |
#Exceptions
| Exception | Thrown by | When |
|---|---|---|
InvalidArgumentException | the writes, new BookmarkMeta(), assigning meta | A model is unsaved or has no key, a key is longer than 64 characters, a morph type is longer than 191, or the metadata is not a JSON object. Nothing is written. |
UnexpectedValueException | reading meta; every call | A stored value is not a JSON object; pharaonic.bookmarks.model is not a Bookmark class. |
LogicException | writing to a BookmarkMeta | Always: metadata is read-only. |
RuntimeException | bookmark(), bookmarkBy(), toggleBookmark() | A model event cancelled the save, or the row kept being deleted while it was written. |
Illuminate\Database\ClassMorphViolationException | every method | Relation::enforceMorphMap() is on and a model has no alias. Nothing is written. |
Illuminate\Database\QueryException | every method | Any database error other than the pair's own unique index refusing a duplicate. |
#Configuration and publish tags
| Key | Default |
|---|---|
pharaonic.bookmarks.table | bookmarks |
pharaonic.bookmarks.model | Pharaonic\Laravel\Bookmarks\Models\Bookmark |
pharaonic.bookmarks.cleanup_on_delete | true |
| Tag | Publishes |
|---|---|
bookmarks-config, pharaonic-config | config/pharaonic/bookmarks.php |
bookmarks-migrations, pharaonic-migrations | database/migrations/2026_11_13_000001_create_pharaonic_bookmarks_table.php |
laravel-bookmarks, pharaonic | both |
#Examples
#1. A save button with a note
Send the state the user wants, so a double click can't undo itself.
1use App\Http\Controllers\PostBookmarkController;2 3Route::middleware('auth')->group(function () {4 Route::put('posts/{post}/bookmark', [PostBookmarkController::class, 'update']);5 Route::delete('posts/{post}/bookmark', [PostBookmarkController::class, 'destroy']);6}); 1namespace App\Http\Controllers; 2 3use App\Models\Post; 4use Illuminate\Http\Request; 5 6class PostBookmarkController extends Controller 7{ 8 public function update(Request $request, Post $post) 9 {10 $data = $request->validate([11 'note' => 'nullable|string|max:500',12 'remind' => 'boolean',13 ]);14 15 $bookmark = $request->user()->bookmark($post, $data);16 17 return ['saved' => true, 'meta' => $bookmark->meta];18 }19 20 public function destroy(Request $request, Post $post)21 {22 $request->user()->unbookmark($post);23 24 return ['saved' => false];25 }26}$request->validate() returns an array with string keys, so it is valid metadata as it is. Every save replaces the whole object, so a note the user cleared is gone, not merged back.
#2. A feed that shows what the viewer saved
The posts, their bookmark counts, and whether the viewer saved each one come from one query, plus the paginator's count. The route needs auth: withBookmarkedBy() takes a model, and $request->user() is null for a guest.
1use App\Http\Controllers\FeedController;2 3Route::get('feed', [FeedController::class, 'index'])->middleware('auth');1public function index(Request $request)2{3 $posts = Post::withBookmarkedBy($request->user())4 ->withCount('bookmarks')5 ->latest()6 ->paginate(20);7 8 return view('feed', compact('posts'));9}Each button sends the state the user wants to the routes of the first example:
1@foreach ($posts as $post) 2 <form method="POST" action="{{ url('posts/'.$post->id.'/bookmark') }}"> 3 @csrf 4 @method($post->is_bookmarked ? 'DELETE' : 'PUT') 5 6 <button type="submit"> 7 {{ $post->is_bookmarked ? 'Saved' : 'Save' }} ({{ $post->bookmarks_count }}) 8 </button> 9 </form>10@endforeach11 12{{ $posts->links() }}The first example's controller answers with JSON, for buttons that send the request from JavaScript. For plain forms like these, return back() from it instead.
#3. A "saved for later" page
Newest bookmark first, with every type the user saved and their notes:
1$bookmarks = $request->user()->givenBookmarks()->with('bookmarkable')->latest()->paginate(20);2 3foreach ($bookmarks as $bookmark) {4 $bookmark->bookmarkable; // App\Models\Post, App\Models\Product, ...5 $bookmark->meta->get('note', '');6}Only the posts, or only those with a reminder:
1Post::whereBookmarkedBy($request->user())->latest()->paginate(20);2 3$request->user()->givenBookmarks()->where('meta->remind', true)->get();#4. Changing one key
Metadata is replaced as a whole, so read it, change the key, and write it back:
1$meta = $user->bookmarkOf($post)->meta->toObject();2$meta->priority = 1;3unset($meta->remind);4 5$user->bookmark($post, $meta);toObject() keeps nested empty objects as objects, so nothing else changes shape on the way back. When the stored object already equals the new one, nothing is written.
#5. Reminders from the event
1public function handle(BookmarkCreated $event)2{3 if ($event->bookmark->meta->get('remind') === true) {4 SendReminder::dispatch($event->bookmarker, $event->bookmarkable)->delay(now()->addDay());5 }6}Register it for Pharaonic\Laravel\Bookmarks\Events\BookmarkCreated in your EventServiceProvider. Listen to BookmarkUpdated too if a reminder can be turned on later.
#Troubleshooting
#The [bookmarks] table already exists when migrating
Your application already has a bookmarks table. Set pharaonic.bookmarks.table to another name before migrating, or move that table out of the way first.
#Bookmark metadata must be a JSON object, not a list
You passed a list at the top, such as ['a', 'b'] or a JSON array. Wrap it in an object: ['items' => ['a', 'b']].
#Bookmark metadata must be a JSON object … not string
You passed a JSON string, for example a request field. Decode it first: json_decode($json) keeps {} and [] apart.
#Bookmark metadata can only hold JSON values … not Illuminate\Support\Carbon (at meta.remind_at)
Objects other than stdClass and BookmarkMeta are refused, wherever they are. Store a plain value: $date->toIso8601String() for a date, $collection->all() for a Collection.
#Bookmark metadata cannot be null: assign [] to store the empty object {}
You assigned null to $bookmark->meta. Assign [] to empty it. With bookmark(), null keeps the metadata instead.
#A key disappeared after bookmark()
New metadata replaces the whole object; nothing is merged. Pass the full object, as Examples shows for changing one key.
#Bookmark metadata is read-only
You wrote to $bookmark->meta. Build a new object ($bookmark->meta->toObject(), change it) and pass it to bookmark(), or assign it to $bookmark->meta.
#The meta column of bookmark [12] in the [bookmarks] table holds NULL, not a JSON object
The row was written outside the package with a value that isn't a JSON object. Repair it (UPDATE bookmarks SET meta = '{}' WHERE meta IS NULL), or call bookmark() with new metadata, which replaces it.
#A model event cancelled saving the bookmark
A creating, updating or saving listener on the Bookmark model returned false. The package throws instead of returning a bookmark that isn't stored.
#The bookmarker [App\Models\User] is not saved
A write was called with a model that isn't saved. Save it first, or check $model->exists.
#Register a shorter alias with Relation::morphMap()
The class name is longer than the 191 characters the type columns hold. Add a morph map alias for the model.
#is_bookmarked is 1, not true
MySQL, MariaDB and SQLite return EXISTS as an integer. Cast it: (bool) $post->is_bookmarked.
#Illegal mix of collations on MySQL
Your model's table and the bookmarks table use different collations, and the model has a string key (UUID, ULID). Create both with the same collation, the collation of your database connection.
#operator does not exist: bigint = character varying on PostgreSQL
A query compares a model key with the string bookmarkable_id column directly, for example a hand-written join. Go through the package relations and scopes, which compare the key as a string, or cast it yourself: cast(posts.id as varchar) = bookmarks.bookmarkable_id.
#Bookmarks stay after deleting models
The models were deleted with a query (Post::where(...)->delete()), which skips model events, or cleanup_on_delete is false. See Deletes and Soft Deletes for the manual cleanup.
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Bookmarks!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.