Laravel Packagev8.0.0MIT License

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.

Quick Tip

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

Terminal
composer require pharaonic/laravel-bookmarks

The service provider is registered automatically.

#Run Migrations

The package loads its migration automatically, so the bookmarks table is created with your other migrations:

Terminal
php artisan migrate

To change the migration before it runs, publish it into database/migrations:

Terminal
php artisan vendor:publish --tag=bookmarks-migrations

The 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.

Existing bookmarks table

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:

Terminal
php artisan vendor:publish --tag=bookmarks-config

The file is published to config/pharaonic/bookmarks.php.

Installation Complete

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.

config/pharaonic/bookmarks.php
1return [
2 'table' => 'bookmarks',
3 'model' => \Pharaonic\Laravel\Bookmarks\Models\Bookmark::class,
4 'cleanup_on_delete' => true,
5];
KeyDefaultDescription
pharaonic.bookmarks.tablebookmarksThe table the migration creates and the Bookmark model uses.
pharaonic.bookmarks.modelPharaonic\Laravel\Bookmarks\Models\BookmarkThe model the package reads, writes and returns: the package model or a subclass. See Custom Bookmark Model.
pharaonic.bookmarks.cleanup_on_deletetrueDelete 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:

config/pharaonic/bookmarks.php
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.

app/Models/User.php
1namespace App\Models;
2 
3use Illuminate\Foundation\Auth\User as Authenticatable;
4use Pharaonic\Laravel\Bookmarks\Concerns\CanBookmark;
5 
6class User extends Authenticatable
7{
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.

app/Models/Post.php
1namespace App\Models;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Bookmarks\Concerns\HasBookmarks;
5 
6class Post extends Model
7{
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; // true
4$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 gone

Leave 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); // true
2$user->bookmarkOf($post); // the Bookmark, or null
3$user->unbookmark($post); // true: this call removed it
4$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 Bookmark
2$user->toggleBookmark($post); // null: removed

For 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; // 1
5$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 as json_decode($json), which keeps {} and [] apart at every depth;
  • a BookmarkMeta, such as another bookmark's meta.

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]).

Arrays below the top

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; // true
10$bookmark->meta->preferences->channels; // ["mail", "push"]
11$bookmark->meta->missing; // null

get() 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); // 5
2$bookmark->meta->get('missing', 0); // 0
3$bookmark->meta->get('reminder', 0); // null: the key is present
4$bookmark->meta->has('reminder'); // true
5$bookmark->meta->has('missing'); // false
6isset($bookmark->meta->reminder); // false, as isset() means

Read a key that is not a valid PHP name with get('my-key') or $meta->{'my-key'}.

#Converting

MethodReturnsNotes
toArray()arrayPlain nested arrays. {} and [] both become [], and numeric keys become integers.
toObject()stdClassA detached copy that keeps {} and [] apart. Pass it back to bookmark() without losing anything.
json_encode($meta)stringAlways a JSON object.
isEmpty()boolWhether 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; // LogicException
2$bookmark->meta->preferences->notify = false; // LogicException

To 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 argumentResult
Noomitted or nullInserts the bookmark with {}.
Noan objectInserts the bookmark with that object.
Yesomitted or nullReturns the bookmark. Nothing is written.
Yesequal to what is storedReturns the bookmark. Nothing is written, updated_at is kept, no event.
Yesa different objectReplaces the whole object: one UPDATE, updated_at changes, created_at does not.
Yes[]Stores {}, unless it is already empty.
Eithernot a JSON objectThrows 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:

StoredNewEqual?
{"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.

RelationOnTypeHolds
givenBookmarks()CanBookmark modelsMorphMany of BookmarkThe bookmarks the model kept.
bookmarks()HasBookmarks modelsMorphMany of BookmarkThe bookmarks the model received.
bookmarker()BookmarkMorphToThe model that kept the bookmark.
bookmarkable()BookmarkMorphToThe 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 kept
2Bookmark::whereBookmarkable($post)->count(); // bookmarks of the post
3 
4User::whereHas('givenBookmarks', fn ($query) => $query->whereBookmarkable($post))->get(); // who saved the post

Both 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; // 12
5 
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 it
7$post->bookmarks_count; // 12

is_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) => $query
4 ->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 type
2$user->givenBookmarks()->with('bookmarkable')->latest()->get();

#Indexes

The migration creates:

IndexColumnsServes
bookmarks_unique_pair (unique)bookmarkable_type, bookmarkable_id, bookmarker_type, bookmarker_idUniqueness, bookmarks(), withCount('bookmarks'), bookmarkedBy(), withBookmarkedBy()
bookmarks_bookmarker_indexbookmarker_type, bookmarker_idgivenBookmarks(), 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.

app/Providers/AppServiceProvider.php
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 bookmarkable
2$document->bookmark($comment); // UUID bookmarker, integer bookmarkable

Keys 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.

app/Models/Bookmark.php
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(): bool
13 {
14 return $this->meta->get('pinned') === true;
15 }
16}
config/pharaonic/bookmarks.php
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(); // true
2$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 setsThe package
nothinguses 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.
Another connection

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:

EventDispatched byNot dispatched
Pharaonic\Laravel\Bookmarks\Events\BookmarkCreatedbookmark(), bookmarkBy(), toggleBookmark() when they insert the bookmarkWhen the bookmark already existed
Pharaonic\Laravel\Bookmarks\Events\BookmarkUpdatedbookmark(), bookmarkBy() when they replace the metadata with different metadataWhen the metadata was omitted, null or equal; for a direct $bookmark->save()
Pharaonic\Laravel\Bookmarks\Events\BookmarkRemovedunbookmark(), unbookmarkBy(), toggleBookmark() when they delete the bookmarkWhen 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.

app/Listeners/ScheduleReminder.php
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}
app/Providers/EventServiceProvider.php
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 false from creating, updating or saving cancels the write. bookmark() and toggleBookmark() then throw a RuntimeException ("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:

ActionBookmarks it kept (CanBookmark)Bookmarks it received (HasBookmarks)
$model->delete()deleteddeleted
$model->delete() on a SoftDeletes modelkeptkept
$model->restore()still therestill there
$model->forceDelete()deleteddeleted

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 stay
2$post->restore(); // $post->bookmarkedBy($user) is still true
3$post->forceDelete(); // the bookmarks are deleted

The cleanup deletes the rows with one query and dispatches no BookmarkRemoved events.

#Turning cleanup off

config/pharaonic/bookmarks.php
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

SituationResult
Two requests bookmark a new pair at onceOne 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 objectsThe last write wins, whole: the row holds one call's object, never a merge of both.
A bookmark and an unbookmark at onceEither 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 onceOne 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.

MethodReturnsDescription
bookmark(Model $bookmarkable, $meta = null)BookmarkCreates the bookmark, replaces its whole metadata, or keeps it (null). Returns the stored row.
bookmarkOf(Model $bookmarkable)?BookmarkThe bookmark of the model, or null. Null when either is unsaved.
hasBookmarked(Model $bookmarkable)boolWhether this model bookmarked the model. False when either is unsaved.
unbookmark(Model $bookmarkable)boolRemoves the bookmark. True when this call removed it, false when there was none.
toggleBookmark(Model $bookmarkable, $meta = null)?BookmarkCreates the bookmark with the metadata and returns it, or removes it and returns null.
givenBookmarks()MorphManyThe bookmarks this model kept.

#HasBookmarks

Pharaonic\Laravel\Bookmarks\Concerns\HasBookmarks, for the model that is bookmarked.

MethodReturnsDescription
bookmarks()MorphManyThe bookmarks this model received.
bookmarkBy(Model $bookmarker, $meta = null)BookmarkSame as $bookmarker->bookmark($this, $meta), for any bookmarker model.
unbookmarkBy(Model $bookmarker)boolSame as $bookmarker->unbookmark($this).
bookmarkedBy(Model $bookmarker)boolSame as $bookmarker->hasBookmarked($this).
whereBookmarkedBy(Model $bookmarker)scopeKeeps the models the bookmarker bookmarked: Post::whereBookmarkedBy($user).
whereNotBookmarkedBy(Model $bookmarker)scopeKeeps the models the bookmarker did not bookmark.
withBookmarkedBy(Model $bookmarker)scopeAdds an is_bookmarked column from one EXISTS subquery.

#Bookmark

Pharaonic\Laravel\Bookmarks\Models\Bookmark, or the class in pharaonic.bookmarks.model.

MemberTypeDescription
bookmarker()MorphToThe model that kept the bookmark.
bookmarkable()MorphToThe model that was bookmarked.
whereBookmarker(Model $bookmarker)scopeBookmarks kept by the model. Matches nothing for an unsaved model.
whereBookmarkable(Model $bookmarkable)scopeBookmarks of the model. Matches nothing for an unsaved model.
metaBookmarkMetaThe metadata. Assign an array, stdClass or BookmarkMeta to replace it; null when the column was not selected.
idintPrimary key.
bookmarker_type, bookmarkable_typestringgetMorphClass() of each end, up to 191 characters.
bookmarker_id, bookmarkable_idstringThe key of each end as a string, up to 64 characters.
created_at, updated_atCarbonTimestamps. updated_at changes only when the metadata does.

#BookmarkMeta

Pharaonic\Laravel\Bookmarks\ValueObjects\BookmarkMeta, immutable.

MemberReturnsDescription
new BookmarkMeta(array|stdClass $data = [])BookmarkMetaBuilds metadata, for example to pass around before bookmarking.
$meta->keymixedThe value, or null when absent. Objects are BookmarkMeta, lists are arrays.
get(string $key, $default = null)mixedThe value, or the default only when the key is absent.
has(string $key)boolWhether the key is present, even with a null value.
isEmpty()boolWhether the metadata is {}.
toArray()arrayPlain nested arrays; {} and [] both become [].
toObject()stdClassA detached copy that keeps {} and [] apart.
jsonSerialize()stdClassUsed by json_encode(): always a JSON object.

#Events

EventPropertiesDispatched
Pharaonic\Laravel\Bookmarks\Events\BookmarkCreatedBookmark $bookmark, Model $bookmarker, Model $bookmarkableAfter a call inserted a bookmark.
Pharaonic\Laravel\Bookmarks\Events\BookmarkUpdatedBookmark $bookmark, Model $bookmarker, Model $bookmarkableAfter a call replaced the metadata with different metadata.
Pharaonic\Laravel\Bookmarks\Events\BookmarkRemovedBookmark $bookmark, Model $bookmarker, Model $bookmarkableAfter a call deleted a bookmark (not on cleanup).

#Exceptions

ExceptionThrown byWhen
InvalidArgumentExceptionthe writes, new BookmarkMeta(), assigning metaA 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.
UnexpectedValueExceptionreading meta; every callA stored value is not a JSON object; pharaonic.bookmarks.model is not a Bookmark class.
LogicExceptionwriting to a BookmarkMetaAlways: metadata is read-only.
RuntimeExceptionbookmark(), bookmarkBy(), toggleBookmark()A model event cancelled the save, or the row kept being deleted while it was written.
Illuminate\Database\ClassMorphViolationExceptionevery methodRelation::enforceMorphMap() is on and a model has no alias. Nothing is written.
Illuminate\Database\QueryExceptionevery methodAny database error other than the pair's own unique index refusing a duplicate.

#Configuration and publish tags

KeyDefault
pharaonic.bookmarks.tablebookmarks
pharaonic.bookmarks.modelPharaonic\Laravel\Bookmarks\Models\Bookmark
pharaonic.bookmarks.cleanup_on_deletetrue
TagPublishes
bookmarks-config, pharaonic-configconfig/pharaonic/bookmarks.php
bookmarks-migrations, pharaonic-migrationsdatabase/migrations/2026_11_13_000001_create_pharaonic_bookmarks_table.php
laravel-bookmarks, pharaonicboth

#Examples

#1. A save button with a note

Send the state the user wants, so a double click can't undo itself.

routes/web.php
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});
app/Http/Controllers/PostBookmarkController.php
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.

routes/web.php
1use App\Http\Controllers\FeedController;
2 
3Route::get('feed', [FeedController::class, 'index'])->middleware('auth');
app/Http/Controllers/FeedController.php
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:

resources/views/feed.blade.php
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@endforeach
11 
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

app/Listeners/ScheduleReminder.php
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!

Moamen Eltouny

@MoamenEltouny

50 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.