baks-dev/products-favorite
A Laravel/PHP module that adds a product favorites (wishlist) feature to your application. Install via Composer with baks-dev/products-favorite. Requires PHP 8.4+ and is released under the MIT license. Tests run with phpunit using the products-favorite group.
This package is a single-purpose module: it stores which products a user has favourited. The README (Russian only) gives you the install line and a test group — everything else you learn from the source. Budget 30–60 minutes for a discovery pass before writing feature code.
1. Install and pin the version
composer require baks-dev/products-favorite:7.4.8
Pin it exactly. With zero stars, zero dependents, and patch releases that carry no changelog beyond a version string, you do not want ^7.4 silently dragging in a behavioural change. Note the hard requirement: PHP 8.4+. On 8.2/8.3 the solver will fail or, worse, Composer will resolve an older tag.
2. Discover what you actually got
composer show baks-dev/products-favorite # namespace, autoload, requires
php artisan vendor:publish --list # which tags exist (config, migrations, views, translations)
php artisan about # is the provider registered?
ls vendor/baks-dev/products-favorite/src
ls vendor/baks-dev/products-favorite/src/*/Resources # migrations, config, translations
Read, in this order:
composer.json — the PSR-4 root namespace and any extra.laravel.providers / dont-discover entries.loadMigrationsFrom, whether the provider is auto-discovered.src/*/Resources/config/*.php — table name, user model/guard, cascade rules.(user_id, product_id) (or its absence tells you a lot).tests/ — the executable spec. This is your real documentation.3. Run the shipped tests first
php bin/phpunit --group=products-favorite
If your phpunit.xml does not declare that group, or the package's test bootstrap expects its own harness, you will get "No tests executed" — treat that as a red flag, not a success, and inspect what the tests cover manually.
4. First use case — a toggle endpoint
The fastest proof of life is an authenticated toggle route. Wire it, hit it with php artisan tinker or a test, and confirm a row lands in the favourites table:
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/products/{product}/favorite', [FavoriteController::class, 'toggle'])
->name('products.favorite.toggle');
});
Do this before designing any UI. If the toggle does not round-trip, the rest of the assessment is moot.
Treat the package as the persistence + relation layer and keep your own HTTP/UI layer thin on top. That is the pattern that survives a package swap.
Do not let controllers talk to the model directly if the package exposes a service or facade — but if it does not, wrap it once so you have a single seam to change later.
final class FavoriteController
{
public function toggle(Product $product, FavoriteService $favorites): JsonResponse
{
$isFavorite = $favorites->toggle(auth()->id(), $product->id);
return response()->json([
'favorited' => $isFavorite,
'count' => $favorites->countFor($product->id),
]);
}
}
Make the operation idempotent by intent: toggle for click handlers, and separate add/remove for API consumers who retry. If the package only offers one of these, implement the other as firstOrCreate / delete on the relation rather than relying on a toggle in a retried job.
Favourites are per-user; enforce ownership in a policy so both the web controller and any JSON API share one gate:
public function delete(User $user, Favorite $favorite): bool
{
return $favorite->user_id === $user->id;
}
Never accept user_id from the request body. Bind to auth()->id() — or, if the package's config exposes the user model/guard, respect that config rather than hard-coding App\Models\User.
The common day-to-day need is a product listing where each card shows a heart. Avoid an N+1 by loading the set once and mapping in PHP:
$favoriteIds = Favorite::query()
->where('user_id', auth()->id())
->whereIn('product_id', $products->pluck('id'))
->pluck('product_id')
->all();
$products->each(fn ($p) => $p->is_favorited = in_array($p->id, $favoriteIds, true));
Do this instead of Product::withExists('favorite') unless you have verified the inverse relation name — the relation name is the number-one source of confusion in undocumented packages. Grep src/ for belongsTo, hasMany, and belongsToMany before guessing.
The package is backend-only, so the UI is yours. Keep the interaction optimistic and degrade gracefully:
<button
type="button"
data-favorite-toggle
data-url="{{ route('products.favorite.toggle', $product) }}"
data-favorited="{{ $product->is_favorited ? 'true' : 'false' }}"
aria-pressed="{{ $product->is_favorited ? 'true' : 'false' }}"
>
<span class="sr-only">{{ __('products-favorite::messages.toggle') }}</span>
</button>
Add a @csrf meta tag and a fetch handler; flip aria-pressed immediately, roll back on non-2xx. For Livewire, put the toggle in a single action method and call $this->dispatch('$refresh') — but be aware the package's state lives in the DB, so you must re-read it, not cache it in the component.
If the package fires domain events (grep for dispatch(, Event::, or a src/*/Event directory), hook them for notifications, analytics, or cache busting. If it does not, that is your single best extension point: emit your own event from the service wrapper.
// App\Listeners\ForgetFavoriteCounts
public function handle(ProductFavorited $event): void
{
Cache::forget("products:{$event->productId}:favorites");
}
Write your own feature tests rather than relying on the shipped ones — they prove the package's internals, not your integration:
public function test_toggling_creates_and_removes_a_favorite(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$this->actingAs($user)->post(route('products.favorite.toggle', $product))->assertOk();
$this->assertDatabaseHas('product_favorites', ['user_id' => $user->id, 'product_id' => $product->id]);
$this->actingAs($user)->post(route('products.favorite.toggle', $product))->assertOk();
$this->assertDatabaseMissing('product_favorites', ['product_id' => $product->id]);
}
Run these in CI alongside --group=products-favorite so an upstream patch that breaks you fails your build, not production.
PHP 8.4 is a hard floor, not a suggestion. If your app or CI image is on 8.3, you cannot install this at all. Check platform.php in composer.json and the CI matrix before promising the feature.
Zero stars and zero dependents means you are the QA team. There is no issue tracker history, no Stack Overflow corpus, and patch releases 7.4.4 → 7.4.8 whose entire changelog is "v7.4.8". Consequences:
vendor/.src/ between tags (git diff v7.4.4..v7.4.8 -- src) — the release notes will never tell you what changed.The migration may not be published. Many modular packages load migrations from the provider; others require vendor:publish. Always run php artisan migrate --pretend before migrate in a shared environment, and check whether the table name collides with an existing favorites / wishlists table you already have. If you need a custom table name, publish the config and verify the model actually reads it.
Unique constraint vs. race conditions. Two rapid clicks (or a double-submitted form) can produce a duplicate row or a QueryException on a unique index. Mitigate with firstOrCreate semantics, a unique DB index on (user_id, product_id), and a disabled/aria-busy button while the request is in flight. Confirm the shipped migration has that index — if it does not, add it in your own migration.
Auth guard mismatch. If your app uses a multi-guard setup (admin, api via Sanctum), check whether the package resolves the user from auth()->user() or from a configurable guard. The silent failure mode is favourites saved against guard A and listed against guard B — the user sees an empty wishlist. Add a test with actingAs($user, 'sanctum') if you serve an API.
Orphaned favourites. Unless the migration declares onDelete('cascade') on product_id, deleting a product leaves rows behind, and listing favourites then hits missing models. Check the FK, and if it is missing, either add the cascade yourself or filter with whereHas('product') on read.
Caching counts. A "12 people saved this" badge will hammer the table. Cache per product and invalidate from the add/remove path — but remember invalidation must fire from every write path, including seeders and admin tooling. Centralise writes in one service method for exactly this reason.
Translations are Russian. If the package ships Resources/lang/ru, publish and add en (and your locales) under the same key structure — otherwise you will get raw keys or Russian strings in an otherwise English UI. Run php artisan vendor:publish --tag=...translations if the tag exists; otherwise copy the directory into lang/vendor/.
Lumen / non-standard bootstraps. Service provider auto-discovery depends on extra.laravel.providers in the package's composer.json. If it is absent, register it manually in bootstrap/providers.php (Laravel 11+) or config/app.php, and register the migrations path yourself.
Debugging checklist when a favourite "doesn't stick":
Route::has(...) and CSRF token present — 419 is the most common cause, not the package.Log::info(auth()->id()) in the controller — null user means the favourite was never attributable.php artisan tinker → create a row directly via the package model; if that works, the bug is your controller; if not, it's config/migration.DB::listen(...) or Telescope to see the actual insert/delete SQL and the table it targets.Extension points to look for before writing your own: a publishable config (table, user model, guard), a bound interface in the service provider you can bind() over in your own provider, and any events. If none of these exist, the pragmatic extension point is your own FavoriteService wrapper — one class, one seam, and the package becomes replaceable in an afternoon.
How can I help you explore Laravel packages today?