ritechoice23 / laravel-saveable
A flexible Laravel package for adding save/bookmark functionality to any model with collections, metadata, and ordering support.
Fund package maintenance!
ritechoice23
Installs: 5
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/ritechoice23/laravel-saveable
Requires
- php: ^8.2
- illuminate/contracts: ^11.0||^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2025-11-08 16:17:08 UTC
README
A flexible Laravel package for adding save/bookmark functionality to any Eloquent model with collections, metadata, and custom ordering support. Perfect for building features like bookmarks, favorites, reading lists, and more!
Features
- Fully Polymorphic: Any model can save any other model (User → Post, User → Article, etc.)
- MorphMap Compatible: Full support for both
Relation::morphMap()and base class path configurations - Collections/Folders: Organize saved items into nested collections
- Metadata Support: Add notes, priorities, or custom data to saves
- Custom Ordering: Control the order of saved items within collections
- Simple API: Intuitive methods like
saveItem(),unsaveItem(),toggleSaveItem(),hasSavedItem() - Rich Queries: Chainable query scopes and eager loading support
- Zero Configuration: Works out of the box with sensible defaults
- Full Test Coverage: Comprehensive Pest PHP test suite included
Table of Contents
- Installation
- Configuration
- Model Setup
- Basic Usage
- Collection Management
- Retrieving Saved Items
- Advanced Features
- API Reference
- Testing
Installation
Install the package via composer:
composer require ritechoice23/laravel-saveable
Publish and run the migrations:
php artisan vendor:publish --tag="laravel-saveable-migrations"
php artisan migrate
Optionally, publish the config file:
php artisan vendor:publish --tag="laravel-saveable-config"
Configuration
The published config file (config/saveable.php) includes:
return [ 'saves_table' => 'saves', 'collections_table' => 'collections', 'auto_ordering' => true, ];
Model Setup
Add the traits to your models to enable the "saver" and "saveable" functionality.
1. The "Saver" Model (e.g., User)
Add the HasSaves trait to any model that should be able to save things.
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Ritechoice23\Saveable\Traits\HasSaves; class User extends Authenticatable { use HasSaves; // ... }
2. The "Saveable" Model (e.g., Post)
Add the IsSaveable trait to any model that you want to be saveable.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Ritechoice23\Saveable\Traits\IsSaveable; class Post extends Model { use IsSaveable; // ... }
Basic Usage
Saving & Unsaving
All methods are called from the "saver" model (e.g., your User instance).
Save an item:
$user->saveItem($post);
Unsave an item:
$user->unsaveItem($post);
Toggle a save:
$user->toggleSaveItem($post);
Check if an item is saved:
if ($user->hasSavedItem($post)) { // User has saved this post }
You can also check from the "saveable" model:
if ($post->isSavedBy($user)) { // This post is saved by the user }
Count how many times an item has been saved:
$saveCount = $post->timesSaved();
Collection Management
Creating Collections
Collections are owned by a "saver" (typically a User).
Create a top-level collection:
$collection = $user->collections()->create([ 'name' => 'Reading List' ]);
Create a nested collection:
$parentCollection = $user->collections()->first(); $subCollection = $user->collections()->create([ 'name' => 'Tech Articles', 'parent_id' => $parentCollection->id ]);
Saving to Collections
Pass the collection as the second argument to the saveItem method.
$collection = $user->collections()->where('name', 'Reading List')->first(); // Save the post directly into the 'Reading List' collection $user->saveItem($post, $collection);
Retrieving Collections & Items
Get all collections for a user:
$collections = $user->collections;
Get only top-level (root) collections:
$rootCollections = $user->rootCollections;
Get a collection's children (sub-folders):
$subCollections = $collection->children;
Get a collection's parent:
$parent = $subCollection->parent;
Get all saved items within a collection:
$items = $collection->items(); // Returns a collection of Post, Product models, etc.
Move a saved item to a different collection:
// Move to another collection $user->moveSavedItem($post, $otherCollection); // Move to root (unsorted) $user->moveSavedItem($post, null);
Retrieving Saved Items
Get all "save" records for a user:
This returns the Save model instances, which include metadata.
$saves = $user->savedRecords;
Get all saved items as a query (single type):
This returns a Builder so you can chain additional queries.
// Get all saved posts $savedPosts = $user->savedItems(Post::class)->where('published', true)->get(); // Or use savedItemsOfType for clarity $savedPosts = $user->savedItemsOfType(Post::class)->latest()->get();
Get saved items grouped by type:
Perfect for when users save multiple model types.
$savedItemsGrouped = $user->savedItemsGrouped(); // Returns: Collection<string, Collection> // e.g., ['App\Models\Post' => Collection, 'App\Models\Video' => Collection] foreach ($savedItemsGrouped as $type => $items) { echo "Type: {$type}, Count: {$items->count()}"; }
Get all unsorted items:
This gets items that were saved without a collection.
// Get Save records $unsortedSaves = $user->unsortedSavedRecords(); // Get actual models (single type) $unsortedPosts = $user->unsortedSavedItems(Post::class)->get(); // Get all unsorted (mixed types) $allUnsorted = $user->unsortedSavedItems()->get();
Get all users who saved a specific item:
// Get as a query (can chain additional filters) $users = $post->savers(User::class)->where('verified', true)->get(); // Get grouped by type $saversGrouped = $post->saversGrouped();
Count saved items:
// Count all saved items $totalCount = $user->savedItemsCount(); // Count by type $postCount = $user->savedItemsCount(Post::class);
Advanced Features
Metadata
You can add extra data (like notes) to a save. This is stored in a JSON column on the saves table.
Save with metadata:
$user->saveItem($post, $collection, [ 'note' => 'Read this by Friday.', 'priority' => 'high' ]);
Update metadata on an existing save:
$user->updateSavedItemMetadata($post, [ 'note' => 'Finished reading.', 'priority' => 'low' ]);
Retrieve metadata:
$saveRecord = $user->getSavedRecord($post); if ($saveRecord) { $note = $saveRecord->metadata['note']; // 'Finished reading.' $priority = $saveRecord->metadata['priority']; // 'low' }
Ordering
The saves table has an order_column (integer). When a user saves an item, it's automatically given the next available number for its scope (user + collection).
You can retrieve items in this order:
$saves = $user->savedRecords()->where('collection_id', $collection->id) ->orderBy('order_column', 'asc') ->get();
Query Scopes
Eager load save counts:
$posts = Post::withSavesCount()->get(); foreach ($posts as $post) { echo $post->saves_count; }
Get most saved posts:
$topPosts = Post::mostSaved(10)->get();
Check save status for current user:
$posts = Post::withSaveStatus($currentUser)->get(); foreach ($posts as $post) { if ($post->is_saved) { echo "Saved with metadata: " . json_encode($post->save_metadata); } }
Filter posts saved by a user:
$savedPosts = Post::whereSavedBy($user)->get();
Filter users who saved a specific post:
$users = User::whereSavedItem($post)->get();
API Reference
HasSaves Trait Methods
| Method | Parameters | Return | Description |
|---|---|---|---|
saveItem() |
Model $model, ?Collection $collection, array $metadata |
bool |
Save an item with optional collection/metadata |
unsaveItem() |
Model $model |
bool |
Unsave an item |
toggleSaveItem() |
Model $model, ?Collection $collection, array $metadata |
bool |
Toggle save status |
hasSavedItem() |
Model $model |
bool |
Check if has saved a model |
getSavedRecord() |
Model $model |
?Save |
Get the save record for a model |
moveSavedItem() |
Model $model, ?Collection $collection |
bool |
Move a save to a different collection |
updateSavedItemMetadata() |
Model $model, array $metadata |
bool |
Update metadata on a save |
savedItems() |
?string $type |
Builder |
Get saved items query (chainable) |
savedItemsOfType() |
string $type |
Builder |
Get saved items of specific type |
savedItemsGrouped() |
- | Collection |
Get saved items grouped by type |
unsortedSavedRecords() |
- | Collection |
Get unsorted Save records |
unsortedSavedItems() |
?string $type |
Builder |
Get unsorted items query |
savedItemsCount() |
?string $type |
int |
Count saved items |
savedRecords() |
- | MorphMany |
Relationship: all Save records |
collections() |
- | HasMany |
Relationship: all collections owned |
rootCollections() |
- | HasMany |
Relationship: root collections |
HasSaves Trait Scopes
| Scope | Parameters | Description |
|---|---|---|
whereSavedItem() |
Model $model |
Filter by models that saved an item |
IsSaveable Trait Methods
| Method | Parameters | Return | Description |
|---|---|---|---|
timesSaved() |
- | int |
Total number of saves |
isSavedBy() |
Model $saver |
bool |
Check if saved by a specific model |
savedRecordBy() |
Model $saver |
?Save |
Get save record by a specific model |
savers() |
?string $type |
Builder |
Get savers query (chainable) |
saversGrouped() |
- | Collection |
Get savers grouped by type |
removeSavedBy() |
Model $saver |
bool |
Remove a specific model's save |
saversCount() |
?string $type |
int |
Count savers |
saveRecords() |
- | MorphMany |
Relationship: all Save records |
IsSaveable Trait Scopes
| Scope | Parameters | Description |
|---|---|---|
withSavesCount() |
- | Eager load save count |
mostSaved() |
int $limit = 10 |
Order by most saved |
withSaveStatus() |
Model $saver |
Add save status for a specific saver |
whereSavedBy() |
Model $saver |
Filter by saved by a saver |
Practical Examples
Building a Bookmark Feature
// Controller class BookmarkController extends Controller { public function store(Post $post, Request $request) { $collection = null; if ($request->collection_id) { $collection = auth()->user()->collections()->find($request->collection_id); } auth()->user()->saveItem($post, $collection, [ 'note' => $request->note ]); return back()->with('success', 'Post bookmarked!'); } public function destroy(Post $post) { auth()->user()->unsaveItem($post); return back()->with('success', 'Bookmark removed!'); } }
User's Saved Items Dashboard
public function dashboard() { $user = auth()->user(); // Get all collections with item counts $collections = $user->collections() ->withCount('saves') ->get(); // Get unsorted saved posts $unsortedPosts = $user->unsortedSavedItems(Post::class) ->where('published', true) ->latest() ->get(); // Get all saved items grouped by type $savedItemsGrouped = $user->savedItemsGrouped(); return view('dashboard', compact('collections', 'unsortedPosts', 'savedItemsGrouped')); }
Popular Posts
public function popular() { $popularPosts = Post::mostSaved(20) ->with('saveRecords') ->get(); return view('popular', compact('popularPosts')); }
Get Saved Posts with Filtering
public function mySavedPosts() { // Using the Builder pattern - can chain any query methods $savedPosts = auth()->user() ->savedItems(Post::class) ->where('published', true) ->where('category_id', 5) ->latest() ->paginate(20); return view('my-saves', compact('savedPosts')); }
Testing
Run the test suite:
composer test
Run tests with coverage:
composer test-coverage
Run static analysis:
composer analyse
Run code formatting:
composer format
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.