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.
Architecture fit. The package is a thin Eloquent trait layer over a single polymorphic subscriptions pivot table: a Subscriber trait on the actor side, a Subscribable trait on the target side, and subscribe() / unsubscribe() / toggleSubscribe() / isSubscribedTo() plus query scopes on top. That is a good structural match for a Laravel monolith or a modular monolith that already treats Eloquent as its domain model. It is a poor match for a service-oriented architecture where subscription state is owned by a separate service, or where fan-out is the dominant concern. Crucially, the package stores state only — it does not dispatch, batch, dedupe, or retry. Delivery orchestration remains our work either way, so the "buy" here is narrow and should be scoped as such in any business case.
Integration feasibility. Integration is a one-evening job in a greenfield app: composer require, publish config and migrations, migrate, add traits to User and to each subscribable model, ship endpoints. The published migration and config are the two files that determine nearly all downstream behavior — column names, index and unique constraints, UUID vs. bigint keys, model/table mappings — and they must be reviewed line by line before production migration, not after. The real effort is not installation but the surrounding work the package deliberately omits: authorization policies, idempotent controllers, the deletion/cleanup story for orphaned rows, and the read path for feeds. Budget days, not hours, for a production-grade slice.
Technical risk. The dominant risk is supply-chain, not technical. Signals are weak across the board: 8 stars, 0 dependents, a last release of 2022-02-16, and a 0.34 score. The 1.4.0 release shipped with an auto-generated compare link and no changelog body, so there is no documented statement that 1.3.0 → 1.4.0 is non-breaking — only the semver convention, which is not evidence. Treat 1.4.0 as unverified. Secondary technical risks are concrete and enumerable: (a) duplicate subscription rows under concurrent toggles unless the migration carries a unique index across all four morph columns — this must be verified, and added in our own migration if absent; (b) no foreign keys on morph relations, so deleting a Channel silently leaves dangling subscriptions rows that break counts and resurrect-orphan queries; (c) isSubscribedTo() is a query, so it is an N+1 inside any list rendering; (d) Relation::enforceMorphMap() or any model rename after launch silently orphans every historically stored *_type value; (e) overriding the configured model or the traits is a contract, not a hook — a plain Model swap degrades to empty collections rather than an exception, which is expensive to debug. All are mitigable, but each is a small piece of work we must own rather than inherit.
Key questions to resolve before committing.
users → channels? If the latter, a plain pivot table is simpler and has no dependency risk.git diff 1.3.0..1.4.0 and diffing published stubs on every upgrade?Stack fit. Assume a standard Laravel app (8/9/10-era), PHP 8.x, MySQL or Postgres, Eloquent as the persistence layer. The package is trait- and config-driven, so it introduces no container bindings we depend on, no queue contract, no facade surface we must wrap deeply — which is precisely what makes it cheap to adopt and cheap to remove. Confirm the Laravel/PHP constraint matrix against our app version and pin an exact tag before any code lands; the absence of a maintained compatibility declaration is itself a reason to pin.
Migration path. Publish config and migrations separately (--tag=subscribe-config, --tag=subscribe-migrations) rather than with the combined provider publish, so the subscriptions table is reviewed in a PR before it can reach production. Review the stub for key types (UUID vs. bigint), column nullability, and index coverage; add the four-column unique index in our migration if the stub lacks it, and add the orphan-cleanup observer in the same PR. If the domain already has a bespoke pivot table, do not migrate it destructively — write a one-off backfill into subscriptions, dual-read behind a flag for a release, then retire the old table. If the domain is greenfield, sequence is: config and migration → traits on User and one subscribable model → single toggle endpoint → events wired → backfill not required.
Compatibility. Two hard compatibility checks. First, morph maps: if we call Relation::enforceMorphMap(), every stored subscriber_type / subscriptionable_type must be the alias, and model renames require alias → backfill → FQCN removal, in that order. Second, UUID keys must match on both sides — if config('subscribe.uuids') or the migration key type is UUID, subscribers need UUID PKs too; a mismatch can insert zero-value keys without erroring under some MySQL modes, which is a silent data-integrity failure. Both checks belong in the adoption checklist, not in tribal knowledge.
Sequencing. Ship one vertical slice before generalizing: a single subscribable model (e.g. Channel), a single toggle endpoint, one policy, one event listener, and tests covering subscribe, unsubscribe, and — critically — the double-subscribe race. That slice validates the migration, the index, the trait wiring, and the event surface at low blast radius. Only then add a second subscribable model, then the feed read path (withCount('subscribers'), resolve polymorphic rows to IDs and query our own domain models rather than joining polymorphically), then denormalized counters if and only if profiling demands them. Wrap the traits behind a thin internal repository/service from day one — roughly an hour of work — so that swapping the package, forking it, or absorbing it into our codebase stays a contained refactor rather than a cross-cutting one. That is the single highest-leverage decision in the whole adoption.
Maintenance. Low code-surface maintenance, high dependency-watch overhead. The package itself is unlikely to churn; the problem is that when it does, it may not tell us. The 1.4.0 precedent — a release with no changelog body — means changelog-driven upgrade planning is not available. Operational posture: pin the exact commit or tag, gate upgrades behind a diff review of the config and migration stubs plus a migrate:fresh in CI (existing databases will not pick up stub edits), and assign a named owner for that review. If organizational policy requires documented change records for every dependency, this package either fails that policy or costs us a bespoke diff-reading ritual each bump.
Support. Single-maintainer community package with a permissive MIT license and no SLA. There is no vendor to escalate to, no security review process to inherit, and no guarantee of a fix turnaround. The mitigations are architectural, not contractual: internal wrapper interface, pinned version, vendored fork on standby, and an explicit statement that we — not upstream — own the bug tail. If any stakeholder expects "the vendor will fix it," that expectation needs correcting before adoption, not after the first incident.
Scaling. The subscription table scales as a pivot table: reads are cheap and well-indexed if the unique index exists; writes are one row. withCount('subscribers') is fine at moderate volume; denormalized subscribers_count columns drift the moment any mutation bypasses the package (forceDelete, bulk deletes, seeded data) and should be avoided until profiling justifies them. The genuine scaling wall is not the package — it is fan-out. A channel with very large subscriber counts writing to a feed on every publish needs a fan-out-on-write design outside this package. State the threshold explicitly in the design doc so the eventual rewrite is planned rather than discovered. Also plan the read-side discipline up front: eager-load the user's subscription set once and check in memory rather than calling isSubscribedTo() per row in a @foreach.
Failure modes to design against. (1) Duplicate subscriptions from concurrent toggles — verify the unique index, wrap check-then-act in a transaction, and let toggleSubscribe() be the only state-flipping path. (2) Dangling rows after entity deletion — model observer or deleting hook per morph pair, plus a scheduled sweep for unresolvable subscriptionable rows. (3) Morph-map and model-rename drift silently orphaning history. (4) UUID/key-type mismatch inserting zero-value keys without error. (5) Config model swap breaking trait relations and surfacing as empty collections rather than exceptions — a debugging tax, not a crash, which is worse. (6) Toggle events firing for both directions, so a "you're subscribed" mail must branch on resulting state or it will send on unsubscribe. Each has a cheap, known mitigation; the operational risk is only in failing to assign them.
Ramp-up. A mid-level Laravel engineer reaches productive use in under a day — the API is small and the trait pattern familiar. The non-obvious knowledge is entirely in the gotchas list, not the README: morph maps, the missing foreign keys, the N+1 disguised as a boolean, the index verification, the toggle-event bidirectionality. That knowledge should live in a short internal ADR and a test suite that asserts on the subscriptions table (including the double-subscribe case) rather than on internal calls, so the guardrails survive staff turnover. Two onboarding artifacts make the difference: a written adoption checklist covering the five pre-production verifications, and a wrapper interface that keeps the package name out of controllers and Blade templates. Total ramp cost is small; total institutional-knowledge cost is small if those two artifacts are written, and quietly compounding if they are not.
How can I help you explore Laravel packages today?