Search by

goldnead / statamic-entitlements

goldnead

Brand-scoped entitlement grants for Statamic: one state machine, database-level idempotency, real revocation and four domain events. Decides who may access what; sends nothing.

Package info

github.com/goldnead/statamic-entitlements

Type:statamic-addon

pkg:composer/goldnead/statamic-entitlements

Statistics

Installs: 3 122

Dependents: 8

Suggesters: 8

Stars: 0

Open Issues: 1

v1.6.0 2026-10-03 23:04 UTC

This package is auto-updated.

Last update: 2026-10-03 23:05:42 UTC


README

Who may access what, and why — as one state machine, one table, and four events.

An entitlement is a grant: this subject may use this product, from this source, for this window. This package writes them, resolves their state, answers access questions and announces the four transitions worth reacting to. It does not create accounts, send mail, issue magic links or know what a product is.

Requires goldnead/statamic-brand-context and goldnead/statamic-identity-contracts. Nothing else.

Why it exists

It was extracted from a live Statamic application that had been selling courses for months. Four things were wrong there, and each one is a design decision here:

In the source system Here
State resolved in two places, which disagreed about pending — so an unconfirmed opt-in got access through one of them One resolver. Support\StateResolver and nothing else.
A grant starting tomorrow reported expired on the customer's own account screen Scheduled, a state of its own, with no access and its own event when the clock arrives
revoked_at was displayed, entered no decision, and no write path ever set it — 48 grants, 0 revocations revoke() with a mandatory reason, read by the resolver, fired as an event
No unique index for five months; firstOrCreate() in PHP loses every race Unique over the grant tuple, in the table's first migration, with the write path built around the violation

Requirements

  • PHP 8.2+
  • Laravel 12.40+ or 13
  • Statamic 6
  • goldnead/statamic-brand-context and goldnead/statamic-identity-contracts

Nothing else is required. Every sibling integration is optional and attached by class_exists.

Installation

composer require goldnead/statamic-entitlements
php artisan migrate

Add the announcement pass to your scheduler. Two of the four events happen because the clock moved and nothing wrote, so without this they never fire:

// routes/console.php or App\Console\Kernel
Schedule::command('entitlements:announce')->everyFifteenMinutes();

It is safe to run as often as you like — see Events below.

Usage

Granting

use Goldnead\Entitlements\Facades\Entitlements;

Entitlements::grant(
    subject: $user,                 // any Eloquent model, or a SubjectReference
    productSlug: 'chorleiter-kurs', // a free string; there is no catalogue
    source: 'mollie',               // a free string; see Sources
    sourceRef: $payment->id,        // the external id this grant came from
    expiresAt: now()->addYear(),
);

Two grants that agree on (subject, product, source, sourceRef, brand) are the same grant. Calling grant() again returns the existing row and fires nothing. That tuple is deliberately not narrower: a repeat purchase brings a new provider id, and the same product won by opt-in and later bought has a different source — collapsing either would destroy real customer history.

Three things grant() will not do:

  • widen an existing window (a retry is not a renewal),
  • resurrect a revoked grant — use restore(), which is a decision somebody makes,
  • fire an event for a grant that starts in the future.
Entitlements::grantPending($contact, 'warmup-guide', 'newsletter_optin', 'warmup-guide');
Entitlements::claimPending($grant);            // true exactly once, under any concurrency
Entitlements::revoke($grant, 'Chargeback #1234', $actor);
Entitlements::restore($grant, $actor);
Entitlements::enterGracePeriod($grant, now()->addWeek());

Asking

Entitlements::allows($user, 'chorleiter-kurs');            // bool
Entitlements::decide($user, 'chorleiter-kurs');            // AccessDecision
Entitlements::activeProductSlugsFor($user);                // list<string>
Entitlements::forSubject($user);                           // Eloquent builder

decide() returns allowed, a machine-readable reason (ENTITLED / NOT_ENTITLED) and, on a refusal, the state of the closest grant — so support can say "your access ran out on the 3rd" rather than "no".

Access over several grants is an OR: a subject gets in as soon as any grant is Active or in a grace period. A refunded purchase next to a valid one, or an expired trial next to a paid licence, locks nobody out. This is load-bearing; de-duplicating grants is only ever lossless because of it.

Superusers are not special-cased. That is a host authorisation concern, and baking $user->super into a domain answer would both bind this package to one user model and hide an override inside a decision. Check it before you ask.

Limits

A grant says yes or no. A limit says how many: 50 analyses per year, 10 arrangements at a time. A permission is a product grant; an amount is a limit. There is no feature-flag catalogue.

Limits hang off a product slug, like grants. Set them in the Control Panel (Entitlements → Limits), in code, or in config:

Entitlements::setLimits('choir', [
    'analyses' => ['value' => 50, 'period' => 'year'],   // usage, counted here
    'arrangements' => 25,                                 // stock, counted by the app
    'exports' => null,                                    // unlimited
]);

// config/entitlements.php
'limits' => [
    'keys' => ['analyses' => ['label' => 'Analyses', 'period' => 'year']],
    'products' => ['free' => ['analyses' => 10, 'arrangements' => 3]],
    'fallback_product' => 'free',   // applies to everybody without a grant carrying the key
    'period_anchor' => 'grant',     // or 'calendar'
],

null is unlimited, 0 is none. A stored row wins over config per key.

Which number applies. Every grant that currently gives access counts, the subject's own and those of the subjects it acts for (see extendSubjects() below). The highest value wins, unlimited above any number; at equal height the subject's own grant wins. When that grant expires or is revoked, the limit falls to the next grant's, then to the fallback product, then to 0. Nothing is recomputed: it is derived on every read.

Two kinds.

// Usage per period: counted here, reset with the term.
$receipt = Entitlements::consume($team, 'analyses');   // UsageReceipt, or null when refused
if (! $receipt) {
    return response()->json(['message' => 'Analysis quota exceeded.'], 403);
}
$job->quota_receipt = $receipt->toArray();              // keep it with the job

// The job failed, maybe in the next period: give it back where it was booked.
Entitlements::release($team, 'analyses', receipt: $job->quota_receipt);

// Stock: the app counts, the addon decides.
if (! Entitlements::withinLimit($team, 'arrangements', $team->projects()->count())) {
    return response()->json(['message' => 'Project limit reached.'], 403);
}

// A stock read without a count uses the last count withinLimit() was given.
Entitlements::limit($user, 'analyses');              // int|null (null = unlimited)
Entitlements::remaining($user, 'analyses');          // int|null
Entitlements::quota($user, 'analyses')->toArray();   // limit, used, remaining, period, holder, product…
Entitlements::quotasFor($user);                      // every limit, keyed by key
Entitlements::resetUsage($user, 'analyses', $actor);

Where usage is counted. At the holder: the subject whose grant sets the limit. A choir member using the choir's plan books against the choir's counter, so "50 per year for the choir" means 50.

In a team context, pass the team as the subject, not the user. This is binding, not a style choice. A person can be in several teams; asked about the person, the limits resolve over all of them, and among equally high teams the one with the smallest subject key wins (team:10 before team:9, whatever order the expander returns). That is deterministic, but it is not "the team the person is working in right now" — only the caller knows that. withinLimit() records a stock count only when the subject passed is the holder; a member's own count never overwrites a team's.

Releasing. consume() returns a UsageReceipt, or null when refused — check with ! consume(...) or === null, never === false. The receipt is scalars (toArray() for a queue or a column). release(..., receipt: $receipt) gives the booking back into exactly that counter, up to what was booked (partial releases add up; $amount omitted means the rest).

The server keeps its own copy of every receipt, and only the id is read from what the caller presents: holder, period, amount and brand come from the stored copy. A release is refused (false, logged) when the receipt is unknown in the current brand, was issued for another key, or is held by a subject that is neither the one passed nor one it acts for. The deduction is one UPDATE that cannot go below zero, in the same transaction as the claim. Two consequences, both intended: a member who has left a team can no longer release that team's bookings (pass the team, or a current member), and resetUsage() closes every open receipt of the counter it resets, so a receipt from before a reset never takes anything off the count after it.

Without a receipt a release only reaches the current period of the current holder; when that holds less than asked (a March booking released in April), nothing changes and a warning is logged.

Without a grant. limits.fallback_products per subject type (['personal_team' => 'free']), then limits.fallback_product for everybody, or decide in code with Entitlements::fallbackUsing(fn (SubjectReference $s) => $s->type === 'personal_team' ? 'free' : null) (a null from the callback means none). Otherwise 0.

-1. In limits.products (config) -1 is read as unlimited, so plans copied from ChoirLive keep their meaning. In the database and through setLimits()/the Control Panel unlimited is null, and -1 is refused.

Periods. month or year, anchored on the start of the grant that sets the limit (a yearly plan bought on 14 March resets on 14 March), or on calendar months and years with the anchor calendar, per key (limits.keys.analyses.anchor) or for all (limits.period_anchor). With grant, a plan change starts a new period: an upgrade in June brings a fresh counter, as a term-based plan is sold. With calendar the counter belongs to the holder and the year, not to the plan: an upgrade keeps what was used and only raises the limit (ChoirLive's analyses). The fallback product always uses calendar periods. entitlements:announce announces a period that ended with something used (UsageReset, reason period).

Concurrency. A booking is one conditional UPDATE (used = used + n WHERE used <= limit - n); of two bookings on the last slot exactly one affects the row. The counter row is created with insertOrIgnore against a unique index. No lockForUpdate(), which SQLite compiles to nothing, and no caught INSERT, which would abort a caller's transaction on Postgres. tests/Feature/Limits/ConcurrentConsumeTest.php races two real processes on MySQL and Postgres in CI.

Further subjects (teams)

Entitlements::extendSubjects(new TeamSubjects);   // a Contracts\SubjectExpander
Entitlements::extendSubjects($teams);              // any object with relatedSubjects(SubjectReference)
Entitlements::extendSubjects(fn ($subject, SubjectReference $ref) => [new SubjectReference('team', '7')]);
// or tag a class: app()->tag([TeamSubjects::class], 'entitlements.subject-expanders');

Applies to decide(), allows(), activeProductSlugsFor() and every limit. Not to forSubject(), renew() or any write: a refund against a person must never revoke the team's grant. One level deep, never recursive; an expander that throws is logged and skipped.

Siblings that query grants themselves do not see expanded subjects: statamic-courses' EntitlementsCourseAccess::allowsExcept() builds on forSubject() directly, so a team's course grant does not open the course for a member there until courses moves to forSubjects(Entitlements::subjectsOf($user)).

States

State Access Becomes active by itself Stored in status
Pending no no — waits for a confirmation yes
Scheduled no yes, when starts_at arrives no
Active yes — yes
GracePeriod yes — yes
Expired no no no
Revoked no no yes

Resolution order:

  1. revoked_at set or status = revoked → Revoked
  2. status = pending → Pending
  3. status = grace_period → GracePeriod while grace_until is ahead, else Expired
  4. starts_at in the future → Scheduled
  5. expires_at reached → Expired
  6. otherwise → Active

Three states are never written down, because they are relationships between the row's dates and the current time. status holds only what the clock cannot tell you. Support\StateResolver also exposes the same six branches as query constraints; a test builds every combination of status and timestamps and asserts the SQL and the PHP select exactly the same rows, so the projection cannot drift into a second opinion.

Events

Eight. The package sends one mail, "limit reached", and only when an operator switches it on.

Event Handle When Payload
EntitlementGranted entitlements.granted a grant becomes Active, including out of Pending and when a scheduled grant starts grant, previous state, actor
EntitlementPending entitlements.pending a grant is parked without access grant, actor
EntitlementRenewed entitlements.renewed the window moved later grant, previous expiry, actor
EntitlementRevoked entitlements.revoked an explicit revocation grant, reason, previous state, actor
EntitlementExpired entitlements.expired the window closed grant, the instant access actually ended
LimitReached entitlements.limit_reached a limit is full; once per period (usage) or per filling (stock) subject, holder, key, limit, used, kind, product, period
UsageConsumed entitlements.usage_consumed a booking went through subject, holder, key, amount, used, limit, period
UsageReset entitlements.usage_reset a counter starts from zero (period ended, or by hand) holder, key, previous, reason, actor

The three limit events carry references and scalars only, plus brandId.

Webhook Manager. All eight are triggers under the handles above. Body: event, event_id (sha1 of the moment's own parts, for de-duplication), occurred_at, brand ({id, handle}), subject_type, subject_id, then entitlement (id, product, source, source_ref, state, subject, starts/expires/grace/revoked dates, revoked_reason) or holder + limit (key, label, kind, limit, unlimited, used, remaining, product, period, period_start, period_end) or holder + reset. Never meta, never an actor's address. Dispatched after the commit, in the event's brand.

Automations. The three limit events are triggers registered by this addon (filter: limit key, product). The five grant triggers ship with statamic-automations itself.

Mail. "Limit reached" goes to the person who reached it (Entitlements::mailRecipientsUsing() to change that, for a team's owner), switched on per brand in the settings. The text is the email-templates template entitlements-limit-reached, registered with its registry (trigger, event, placeholders with examples, defaults; on an email-templates without a registry through the email-templates.sources tag). php artisan email-templates:import --source=Entitlements makes it an editable entry; until then the default goes out. Without email-templates the bundled text is sent.

Each fires once per transition, not once per call: the write paths use conditional UPDATEs and check the affected-row count, so a retried job or a double-clicked button produces one event.

EntitlementGranted at start time and EntitlementExpired are the two that no write causes. They come from entitlements:announce, which claims a row's announced_state before firing — that is why the pass is safe to run every minute, and why running it once a day means a customer learns their access ended up to a day late.

Listeners registered synchronously reproduce the source system's ordering exactly: the welcome mail still goes out inside the same request, after the grant is written.

Extension points

Four, all optional, all null objects by default. The fourth, Contracts\SubjectExpander, is described under Further subjects.

Contracts\SubjectResolver — how your idea of "somebody" becomes a (type, id) pair. The default handles Eloquent models through the morph map and explicit SubjectReferences. Bind your own for subjects that are not Eloquent models, and to give the Control Panel readable labels.

Contracts\PackageResolver — expanding a bundle into the products it contains. This package has no catalogue by design; given the product being asked about, return the bundle slugs that would also grant it. Unbound, bundles do not exist.

config('entitlements.sources') — display names for sources. A registry, never a whitelist: an unregistered source writes, resolves and grants access exactly like a registered one.

Entitlements::registerProducts(): names for product slugs, so the Control Panel offers a picker instead of a text field. Same rule as sources: names only, never a whitelist.

Entitlements::registerProducts(fn () => [
    'choiraccelerator' => 'Choir Accelerator',
    'masterclass' => ['label' => 'Masterclass', 'group' => 'Adrian Goldner'],
]);

A closure (read when a form asks, never at boot), an array, an object with grantableProducts(), or a class tagged entitlements.product-catalog. A source that throws is logged and skipped. While no source answers with a product, every product field stays free text. goldnead/statamic-products registers its accesses this way.

Optional siblings

Attached by class_exists, never by Composer. With none installed the package still writes grants, resolves states, decides access, fires events and serves the Control Panel — asserted in tests/Feature/WithoutAnyBridgeTest.php.

  • goldnead/statamic-activity — records the grant events into the ledger.
  • goldnead/statamic-leadhub — lets a CRM contact be the subject.
  • goldnead/statamic-webhook-manager — all eight events as outbound webhook triggers.
  • goldnead/statamic-automations — the three limit events as automation triggers.
  • goldnead/statamic-email-templates — the "limit reached" mail as an editable template.

tests/Unit/BootWithoutSiblingsTest.php boots the addon in its own process with the last three hidden from the autoloader.

Availability is checked with class_exists on a concrete class. Never method_exists on a facade: a facade forwards through __callStatic and declares none of the methods it appears to have, so such a guard answers false forever and disables itself silently. When the question really is about a method, go through Facade::getFacadeRoot().

Control Panel

Users → Entitlements. A filterable listing (state, source, product), a detail screen with the resolved state and a timeline, a manual grant form and a revocation form whose reason is mandatory.

Entitlements → Limits lists every product with limits or grants and edits a product's limits; the grant detail screen shows the subject's limits as they apply now (used, left, period end, where it is counted) with a reset. Entitlements → Wiring lists the eight events, the mail that goes with one, and how many automations and outbound webhooks listen, with links to the Webhook Manager's trigger catalogue.

Four permissions, because there are four different jobs:

  • view entitlements — a support task
  • grant entitlements — a commercial decision (restoring a revoked grant needs this one, not the next)
  • revoke entitlements — the one that generates a refund request
  • manage entitlements limits — changing what a plan allows, and resetting a counter

Manual grants are always written with source manual; the form does not let an admin type thrivecart and fabricate a purchase in the audit trail.

The grant form picks a person through core's user search (name or email); type and ID remain for any other subject. With a product catalogue the product is picked by name in the grant form and in "new limits", from the catalogue's entries only. Grants on a slug no source knows stay valid and are flagged "not in the catalogue" on the grant and on the user page.

A user subject reads as the person: name, email and a link to her user page in the listing, on the grant and on the revocation form. The listing search matches names, emails and catalogue names as well as slugs, and ?subject=type:id filters it to one subject.

On the user page

Add the section to your user blueprint:

-
  handle: zugaenge
  field:
    type: user_entitlements
    display: Zugänge

It lists the user's own grants (name, state, source, from, until, link to the grant), grants an access from a stack (picker, optional start and end) and revokes with a mandatory reason. Granting posts to the manual grant and revoking to the revocation, so the result is the same row, source manual, with the acting admin, and the same four permissions apply: without view entitlements the section shows nothing, the grant button needs grant entitlements, revoking needs revoke entitlements. It stores nothing on the user. On the create screen it asks to save first.

Set entitlements.cp.enabled to false to remove the screens. The switch bites on the routes as well as on the nav entry.

Multi-brand

Every grant carries a brand_id and is scoped by goldnead/statamic-brand-context. Single-brand installs are unaffected; the scope is a no-op there.

brand_id is part of the unique key. Without it, a grant in one brand would block the identical grant in another — one tenant deciding what another may write. The cost is that the same purchase booked against two brands produces two rows without complaint, which is a reporting question rather than a leak.

Notes on the schema

source_ref is NOT NULL, default ''. NULLs never collide in a unique index on any engine, so a nullable column here would switch idempotency off for every grant without an external reference — manual grants, opt-ins, everything a human creates by hand, which are exactly the rows that get double-submitted. Absence is the empty string; hasSourceRef() asks the question.

The unique index carries MySQL prefix lengths, keeping the key at 2448 of InnoDB's 3072 bytes. tests/Unit/IndexKeyLengthTest.php compiles the migration through Laravel's MySQL grammar without a server and fails if that ever stops being true.

Testing

composer test          # SQLite
composer test:mysql    # the identical suite against real MySQL — not optional here
composer lint
composer analyse
npm test               # the two Control Panel pages

The MySQL leg is required rather than nice to have: this package's central guarantee is enforced by a unique index, and everything that can make such an index quietly useless — InnoDB's key limit, utf8mb4 byte arithmetic, prefix comparison under a case-insensitive collation — exists only on the real engine.

Licence

MIT. See LICENSE.md.