laravel-interaction/subscribe
Laravel Interaction Subscribe adds reusable subscribe and unsubscribe behavior to Eloquent models, letting users follow posts, topics, or any model through polymorphic relationships with simple helper methods and query scopes.
Install and scaffold
composer require laravel-interaction/subscribe
php artisan vendor:publish --provider="LaravelInteraction\Subscribe\SubscribeServiceProvider"
php artisan migrate
Publish the config and migrations separately (--tag=subscribe-config, --tag=subscribe-migrations) so the subscriptions table can be reviewed before it reaches production. At the time of writing the package is at 1.4.0 (Feb 2022) and the release notes are only an auto-generated compare link — pin the exact version in composer.json ("laravel-interaction/subscribe": "1.4.0") rather than floating.
Wire up both sides of the relationship
HasSubscriptions / Subscriber) to App\Models\User.HasSubscribers / Subscribable) to every model that can be followed: Channel, Newsletter, Topic, Author, another User.subscriptions table serves every pair — no per-pair pivot tables.Check the source before you trust the README. This is an 8-star, single-maintainer package with no rendered API documentation. Open three things in this order:
src/ traits — confirm the actual public method names (subscribe, unsubscribe, toggleSubscribe, and whether the check is isSubscribed() or isSubscribedTo(); secondary write-ups disagree, and guessing costs you a confusing BadMethodCallException).config/subscribe.php — model and table mappings you may want to override.database/migrations/*_create_subscriptions_table.php — column names, key type (bigint vs UUID), and, critically, whether a unique index exists over the four morph columns.First use case — the "Follow channel" button
$user->subscribe($channel);
$user->isSubscribed($channel); // bool — verify the exact name in the trait
$user->unsubscribe($channel);
$user->toggleSubscribe($channel); // single-button UI
That's the 90% case. Everything else is queries against the two relations.
One toggle endpoint per button. Keep the client dumb; let the server decide direction and return the resulting state.
Route::post('/channels/{channel}/subscription', [SubscriptionController::class, 'toggle']);
public function toggle(Request $request, Channel $channel)
{
$this->authorize('subscribe', $channel); // your policy, not the package's
$subscribed = $request->user()->toggleSubscribe($channel);
return response()->json([
'subscribed' => (bool) $subscribed,
'subscribers_count' => $channel->subscribers()->count(),
]);
}
Return the boolean plus a count so the UI updates without a refetch. Wrap the mutation in a transaction if you also write side effects (activity log, notification preferences) in the same request.
Idempotent write endpoints. If you expose separate POST/DELETE routes instead of a toggle, make double-submits harmless: catch the unique-constraint violation and return 200, or use firstOrCreate semantics on the relation. Do not rely on the UI disabling the button — two tabs defeat that.
Read the graph from the subscribable side for audiences. Use withCount for display and eager-load to avoid per-row queries:
Channel::withCount('subscribers')->paginate();
$channel->subscribers; // User models
$channel->subscriptions; // Subscription models, with pivot/morph attributes
Build a feed by resolving IDs, not by polymorphic joins. Subscriptions are polymorphic, so SQL joins to your domain tables are awkward. Hydrate the morph targets and query your own models:
$subscriptions = $user->subscriptions()->with('subscriptionable')->get();
$posts = Post::whereIn('channel_id', $subscriptions->pluck('subscriptionable.id'))
->latest()
->paginate();
This keeps the package out of your query layer and keeps the feed query indexable.
Preload the subscription set once per page. Never call the isSubscribed-style check inside a Blade loop over many subscribables. Load the user's set once and check in memory:
$subscribedIds = $user->subscriptions()
->where('subscriptionable_type', $channel->getMorphClass())
->pluck('subscriptionable_id')
->flip(); // isset() lookups
// Blade: $subscribedIds->has($channel->id)
Store context on the subscription row, not in a second table. source, subscribed_at, digest preferences, and notification flags all belong on the subscription. If the installed version's subscribe() accepts attributes:
$user->subscribe($channel, ['source' => 'onboarding']);
Otherwise create through the relation directly — this works regardless of the package's method signature:
$user->subscriptions()->create([
'subscriptionable_id' => $channel->id,
'subscriptionable_type' => $channel->getMorphClass(),
'source' => 'onboarding',
]);
If you go this route, add the columns to $fillable on your extended subscription model (see the Gotchas section on swapping models).
React to state changes with listeners, not controller sprawl. Subscribe to the package's model events or its dedicated events (SubscriptionCreated, deleted/toggled — check src/Events) and fan out side effects from there:
Event::listen(SubscriptionCreated::class, SendChannelWelcome::class);
For a toggle event, branch on the resulting state — a single listener registered for both directions will otherwise send a "you're subscribed" mail on unsubscribe.
Wrap it behind a thin internal service. One small class (SubscriptionService) with follow, unfollow, follows, and followers methods that delegate to the traits. It costs an hour and it means every call site is insulated from a method-name mismatch, a version bump, or a decision to replace the package. Given the release hygiene (see below), this is the highest-value pattern in this document.
Test against the table, not the internals.
$user->subscribe($channel);
$this->assertDatabaseHas('subscriptions', [
'subscriber_id' => $user->id,
'subscriber_type' => $user->getMorphClass(),
'subscriptionable_id' => $channel->id,
'subscriptionable_type' => $channel->getMorphClass(),
]);
Always pair it with a double-subscribe test and an unsubscribe test — the double-subscribe case is the one that breaks under concurrency and the one the package's unique index is supposed to prevent.
Morph maps silently break stored rows. If you call Relation::enforceMorphMap(), every existing subscriber_type / subscriptionable_type value must match an alias. Renaming a model class after launch orphans rows without an error — queries just return nothing. Order of operations: add the alias, backfill the table, then remove the FQCN usage.
The state check is a query. isSubscribed() (or its equivalent) is convenient in a controller and disastrous in a loop. See the preload pattern above. Grep your Blade templates for it before shipping.
Duplicates are a race condition, not a UI bug. Two fast clicks or two tabs produce two rows unless the migration has a unique index covering all four columns: (subscriber_type, subscriber_id, subscriptionable_type, subscriptionable_id). Open the published migration and verify. If it's missing, add it in your own follow-up migration before launch — deduplicating a live table is a much worse afternoon.
Nothing cascades. Morph relations have no foreign key, so Channel::delete() leaves dangling subscription rows. Handle it explicitly:
static::deleting(function (Channel $channel) {
$channel->subscriptions()->delete();
});
Or a model observer. Add a scheduled job to purge rows whose morph target no longer resolves, especially if you soft-delete.
Denormalized counts drift. A cached subscribers_count column only stays correct if every mutation goes through the package — including forceDelete, bulk UPDATEs, seeders, and admin tools. Prefer withCount('subscribers') until profiling says otherwise, and if you do cache, rebuild on a schedule as a backstop.
Replacing the configured model is a contract. Pointing config('subscribe.models.subscription') at your own class means extending the package model and preserving its table name, $fillable, casts, and morph relation methods. A plain Model won't throw — it'll produce empty collections and quietly broken relations, which is worse. Copy the package model's body, don't reimplement it.
UUID keys must match on both sides. If the config or migration enables UUID keys, the subscriber models need UUID primaries too. Under some MySQL modes, a mismatch inserts zero-value keys without raising an error.
Trait method collisions are silent. If your User model or another trait defines subscriptions() or subscribe(), PHP resolves the conflict by declaration order without complaint. You'll get inexplicable behaviour rather than a fatal error. Alias the trait method (use HasSubscriptions { subscribe as packageSubscribe; }) if you're unsure, and prefer the wrapper-service pattern over overriding trait methods inside models.
Verify before you upgrade. For 1.4.0 the release notes are a compare link with no changelog body, so there is no documented statement about API, config, or migration changes. Treat it as unverified rather than safe: diff 1.3.0..1.4.0 locally, then re-diff the config and migration stubs in your repo against the upstream versions after composer update. Stub edits (a widened column, a new index) never show up in an auto-generated compare link, and existing databases won't pick them up — run migrate:fresh in CI to catch the difference.
Know the three extension points and stay in them: the package events for state changes, the config for table/model mapping, and the relations on the traits for custom queries. Anything else — overriding trait methods, monkey-patching the subscription model's behaviour instead of extending it, or joining the subscriptions table from unrelated query builders — trades a five-minute win for a debugging session with no documentation to fall back on.
Budget for the maintenance risk. Zero dependents, one maintainer, a last release in early 2022, and no changelog. None of that is disqualifying for a polymorphic pivot table — the schema is simple and easy to absorb — but it means the exit path should be cheap. That's the wrapper service again: if the package goes stale, you vendor the migration and ~100 lines of trait code into your app and change one class.
How can I help you explore Laravel packages today?