ipop/plans
ipop/plans is a lightweight Laravel package for defining and managing plans within your application. Installed via Composer, it offers a simple way to organize plan data and integrate plan handling with your existing Eloquent models and workflows, making it a practical fit for projects that need str...
Treat this package as source-first: with one star and zero dependents, the fastest path to a working mental model is reading src/ and the migrations before wiring it into anything real.
Install and inspect
composer require ipop/plans
Immediately open vendor/ipop/plans and read:
config/plans.php (or equivalent) — you’ll need every key name herePublish and migrate
php artisan vendor:publish --provider="AdrianPop\Plans\PlansServiceProvider"
php artisan migrate
If the provider name doesn’t resolve, run php artisan vendor:publish and pick the tag interactively — the exact namespace is the first thing to confirm from the source.
Attach the trait
On whatever model owns a subscription (usually User or a Team):
use AdrianPop\Plans\Traits\HasPlans;
class User extends Authenticatable
{
use HasPlans;
}
First use case: create a plan with a couple of features, then subscribe
$pro = Plan::create(['name' => 'Pro', 'price' => 29]);
$pro->features()->create([
'slug' => 'projects',
'type' => 'limited', // boolean | limited | countable
'limit' => 10,
]);
$pro->features()->create([
'slug' => 'api_access',
'type' => 'boolean',
'limit' => null,
]);
$user->subscribeTo($pro); // method name depends on the trait; confirm from source
First gate Guard a route or controller action with the feature check (middleware or trait method — pick whichever the source actually exposes):
if (! $user->hasFeature('projects')) {
abort(403);
}
Seed plans and features as data, not code. Plans get edited by product people. Put plans and features in a seeder or a small admin CRUD, and reference features only by slug string constants in code. That way renaming a plan never breaks a hasFeature() call.
Model features by behavior, not by name. The three buckets in this package map cleanly to real product gates:
| Type | Use for | Check looks like |
|---|---|---|
boolean |
On/off entitlement (API access, SSO) | hasFeature('api_access') |
limited |
Ceiling on a quantity (seats, projects) | featureLimit('projects') vs current count |
countable |
Metered consumption (API calls, exports) | increment on use, reset via event/schedule |
Keep the counting logic in your own domain service, not scattered through controllers — the package stores the limit, your app stores the counter.
Wrap mutations in a subscription service class. Don’t call subscribeTo/unsubscribe/changePlan directly from controllers. A SubscriptionManager with upgrade(), downgrade(), cancel() methods gives you one place to add proration, trial logic, and audit events later.
Use the plan events as the integration seam. The description mentions “events for plans” — this is your hook for side effects:
Event::listen(PlanSubscribed::class, function ($event) {
// provision resources, send receipt, fire analytics
});
Because the package is small, these events are likely thin. Treat them as an interface you can broaden by dispatching your own events alongside.
Gate routes with middleware, gate UI with a helper. Add the feature middleware to route groups for hard enforcement, and expose a Blade directive or view composer for showing/hiding nav items. Never rely on UI hiding alone — always back it with middleware.
Integrate with billing at the edge. Keep your cashier/stripe code outside the package. On successful webhook, call $user->subscribeTo($plan). On cancellation webhook, call the unsubscribe path. The plans package should never know Stripe exists.
Write a feature-check test helper.
protected function actingAsPlan(string $planSlug)
{
$this->user->subscribeTo(Plan::where('slug', $planSlug)->first());
return $this;
}
Then feature-gated tests read as $this->actingAsPlan('pro')->get('/projects')->assertOk(). This pays for itself fast because gating bugs are otherwise painful to reproduce.
The API surface is undocumented and unstable. With a single star, expect method names to drift between commits and README examples to lag reality. Before relying on any trait method, grep the trait file for public function. Add a small integration test per method you call so upgrades fail loudly.
Foreign keys and cascade behavior are your job to verify. Open the migration and confirm onDelete semantics for subscriptions and feature records. If deleting a plan leaves orphaned subscriptions, add cleanup in your admin layer — don’t assume the package handles it.
Config keys are not self-evident. Read the config file end to end once and note which table names and model classes are configurable. If you need multi-tenancy (plans per team, per-tenant pricing tables), you may need to override the models via config rather than fork.
Countable features need an explicit reset. Nothing in a small package will reset usage on billing anniversary. Wire a scheduled command:
$schedule->call(fn () => $this->resetCountables())->monthlyOn(1, '00:00');
Test timezone behavior at month boundaries — off-by-a-day resets are a classic support ticket.
Race conditions on counters. If countable features increment concurrently, use an atomic DB::raw('count = count + 1') or a cache lock. read-then-write in PHP will lose increments under load.
Feature name checks fail silently. A typo in hasFeature('api_acess') returns false and looks like a bug in your entitlement logic. Centralise feature slugs in a Features class of constants, and add a test that asserts every constant exists on the seeded Pro plan.
Plan changes and grandfathered limits. If a user is on Pro-10 and you edit Pro to Pro-20, decide whether existing subscribers move. The package likely stores features per plan, not per subscription — meaning edits are retroactive. Snapshot limits onto the subscription if you need grandfathered pricing.
Debugging path when a check fails:
$user->subscription — is there one at all?$user->plan->features — is the feature attached?->refresh() in tests and listeners).Extension points, in order of safety: your own service layer → package events/listeners → config overrides → publishing and editing migrations → forking. Given the low adoption, prefer wrapping over forking so you can still pull fixes.
Pin the version. In composer.json, use "ipop/plans": "^1.0" (adjust to the actual tags) rather than dev-main. With no downstream users, breaking changes can land quietly. A committed composer.lock plus a CI matrix is your safety net.
How can I help you explore Laravel packages today?