ritechoice23/laravel-saveable
A Laravel package that adds saveable behavior to Eloquent models, letting users bookmark, favorite, or save any model through simple polymorphic relationships and an easy-to-use trait.
ritechoice23/laravel-saveable is a small trait-based package for adding "save / bookmark / favourite" behaviour to Eloquent models via a polymorphic relationship. Version 1.0.1 is a patch release on top of 1.0.0 — no breaking changes are signalled by the release notes, so you can pin ^1.0.1 and treat the 1.0.0 → 1.0.1 diff as bug-fix only.
composer require ritechoice23/laravel-saveable
php artisan vendor:publish --tag="saveable-migrations"
php artisan migrate
Publish the config too if the tag exists — check vendor/ritechoice23/laravel-saveable/config for table name and model overrides before running migrate.README.md (trait names and method signatures), the published migration (column names and indexes), then the 1.0.0...1.0.1 diff on GitHub. Because the release note is only a changelog link, the diff is the authoritative record of what changed between the versions you might already have installed.User) and one on the "target" (any model that can be saved):
// app/Models/User.php
use Ritechoice23\Saveable\Traits\HasSaves;
The exact trait names are the one thing to confirm from the README before writing code — get them from src/Traits rather than guessing.Post. From the controller, $user->save($post) / $user->unsave($post) (or toggle) and $user->hasSaved($post) for the button state. That loop — toggle, check, list — is 90% of real usage; get it working end-to-end before touching counters or scopes.Trait placement is symmetric. Put the "saver" trait on User and the "saveable" trait on every model that can be bookmarked (Post, Product, Video). Users routinely add only the User side and then wonder why $post->savers is undefined.
Controller + route model binding is the cleanest integration:
Route::post('/posts/{post}/save', [SaveController::class, 'store'])->middleware('auth');
Route::delete('/posts/{post}/save', [SaveController::class, 'destroy'])->middleware('auth');
public function store(Post $post)
{
auth()->user()->save($post); // idempotent — confirm in README
return back();
}
Authorise before mutating (a policy on save), otherwise any authenticated user can save any record — including ones they can't view.
Lists and feeds. Build the "my saved items" page from the relationship with eager loading, not from the target model:
$saved = auth()->user()->saves()
->with('saveable')
->latest()
->paginate(24);
If the package exposes a savedPosts()-style typed accessor, prefer it — it avoids filtering a mixed polymorphic collection in PHP, which is where most N+1s appear.
Counters on cards. Use withCount on the index query rather than per-row queries:
Post::withCount('savers')->paginate();
and cache the result if the page is hot — aggregate counts on a polymorphic pivot are not cheap at scale.
API / Inertia responses. Expose two booleans computed once per collection, using a single query for the whole page (fetch the set of saved IDs for the current user, then in_array in the resource) instead of hasSaved() inside a loop.
Toggling in the UI. Prefer the toggle endpoint plus optimistic UI over separate save/unsave buttons; it halves the route surface and removes the "double-save" race when a user double-clicks.
Testing. Use factories for both sides and assert idempotency explicitly:
$user->save($post);
$user->save($post);
$this->assertDatabaseCount('saves', 1);
That single test catches the most common production bug with this class of package.
Relation::enforceMorphMap(), the saveable_type column stores the alias, not the FQCN. Renaming App\Models\Post is then safe, but changing an alias orphans rows. Decide before you ship, not after.(user_id, saveable_type, saveable_id); if the package's save() doesn't check first, wrap it in firstOrCreate yourself or catch the QueryException. Verify against the published migration — if there is no unique index, add one in your own migration before going live.Post with SoftDeletes leaves the save row intact; ->with('saveable') then returns null and your Blade/resource will throw on $save->saveable->title. Filter with ->whereHas('saveable') or add a whereNull('deleted_at') scope.user_id but a plain morph pair for the saveable side, deleting a Post leaves dangling rows. Add a model deleting observer to purge saves, or a scheduled cleanup.saves is generic; if you already have a saves table (game saves, drafts), publish the config and rename before migrating. Renaming after data exists is painful.composer update ritechoice23/laravel-saveable with no code changes. Because the release note is only a changelog link, actually open the compare diff and scan src/ and any migration changes — a migration edit in a patch release is the one thing that can bite you, since published migrations won't re-run.php artisan migrate:status), the row actually in the DB (DB::table('saves')->latest('id')->first()), then the relationship name — saveable_type must match what Eloquent resolves.vendor/.SavedItem event listener or an application service. Traits are hard to test in isolation and harder to replace if the package is abandoned; keeping your domain code outside it makes an eventual swap a one-day job.How can I help you explore Laravel packages today?