lunarphp/licensing
Lunar PHP Licensing adds license key management to Lunar e-commerce stores: generate, assign, activate and validate add-on licenses tied to orders, products and customers, with migrations, models and a simple Laravel API for selling and enforcing licensed software.
Install and pin. Require the package exactly — the last tag is 0.1 from January 2023 and there are no release notes, so a floating constraint buys you nothing but surprise:
composer require lunarphp/licensing:0.1.*
Confirm resolution against your Lunar and Laravel majors before anything else. The split is a snapshot of a component inside lunarphp/lunar, so the version that matters is the one that matches the Lunar release you are actually running. If the split lags your Lunar version, install from the monorepo instead.
Publish, then read what you published. Assuming the conventional Lunar shape:
php artisan vendor:publish --provider="Lunar\Licensing\LicensingServiceProvider"
php artisan migrate
Do not run the migration blind. Open the published migration files first and answer: does it create its own tables or alter existing ones? Is down() real? A licensing table that foreign-keys into orders or order_lines is a schema commitment you will carry through every Lunar upgrade.
Read these four things, in this order:
License and Activation models — relations, scopes, casts, and whether keys are stored hashed or plaintext.First use case: issue a license when an order is paid. The shortest path to value is a listener on Lunar's order payment event that creates one license per license-bearing order line, then surfaces the key on the order confirmation. Build that end-to-end before touching validation — it forces you through the data model, the customer relation, and the admin lookup, which is where the real decisions are.
Second use case: a validation endpoint. A single POST /licenses/activate and POST /licenses/validate pair, backed by your own service class (see below), is enough to prove the package's validation hooks do what you need — or to discover you are writing them yourself.
Wrap the package behind one application-level service. The package is small and pre-1.0; the cheapest insurance you can buy is not letting its models and methods leak into controllers, jobs, and admin resources. One class, one contract:
interface LicenseService
{
public function issueForOrderLine(OrderLine $line): License;
public function activate(string $key, string $fingerprint): ActivationResult;
public function validate(string $key): ValidationResult;
public function revoke(string $key, string $reason): void;
}
If the package is abandoned, or its validation semantics do not fit, you replace one class instead of grepping forty call sites. This is also where you put policy the package does not own — seat counts, grace periods, product-to-license mapping.
Issue on events, not controllers. Hook Lunar's order lifecycle (payment received / status transition) rather than the checkout controller. Controllers get bypassed by admin edits, imports, and retries; events do not. Keep issuance idempotent:
(order_line_id, product_id) if a line can mint several).firstOrCreate keyed on that constraint.DB::afterCommit() or ShouldDispatchAfterCommit — never send the key from inside the transaction, or a rollback leaves a customer holding a license that does not exist.Tie licenses to order lines, not just orders. The order is the commercial record; the line is the entitlement. An order can mix a physical product and a downloadable add-on, and only one of those should mint a key. Model this as an explicit map (product/SKU → licenseable) in your config rather than a column the package may not have.
Model activations as fingerprints, not devices. An activation record should carry an opaque fingerprint — domain, install ID, machine hash — plus activated_at, last_seen_at, and deactivated_at. Seat limits then become a withCount query, not a loaded collection:
$active = $license->activations()->whereNull('deactivated_at')->count();
Support a deactivate/reactivate flow from day one. Without it, every "I reinstalled my server" ticket becomes a manual database edit.
Return reason codes, not booleans. Validation has at least five outcomes — not found, expired, revoked, seat limit reached, valid — and support tickets are unanswerable with false. Define an enum in your layer and map the package's result into it, so a package upgrade cannot silently collapse two distinct failures into one.
Derive time-bound licenses from the order, don't duplicate the clock. For subscription-style licenses, store the source of truth (order/subscription reference) and compute expiry, or run a scheduled licenses:expire command that flips state. Storing an expires_at and trusting a subscription's ends_at guarantees they drift.
Give the back office a lookup. A Lunar admin page or Nova/Filament resource that searches by key, customer email, and order reference. Licenses generate the same support load as refunds; if the only way to look one up is tinker, the feature is not finished.
Test the edges, not the happy path. Model factories for licenses and activations, travelTo() for expiry boundaries, and HTTP tests for activation that assert each reason code. The package's own coverage is unknown — assume yours is the only suite that will ever run against your integration.
This repository is read-only. It is a subtree split; issues and pull requests belong in lunarphp/lunar. That also means the tagged 0.1 may lag the component in the monorepo — check the code in the Lunar release you run, not the split, before concluding a bug is yours.
Treat every bump as a manual review. No changelog, no release notes, 0.x semver. Exclude the package from Dependabot/Renovate and read the diff on each version. The upgrade cost is real and should be named in the decision to adopt, not discovered later.
Keys are identifiers, not secrets. A license key is closer to an order number than a password — it will be pasted into support tickets and screenshots. Check whether the package stores it hashed; if it does not, at minimum do not log full keys, mask them in admin, and never put one in a URL query string.
config:cache ordering. If your deployment caches config, publish the package config before the cache step, and remember that any env() call inside the package config returns null once cached. Read env from your own config file and let the package read from that.
Watch the service provider's boot cost. Whatever it registers is charged to every request in the application, including routes that will never touch a license. If your storefront is high-traffic and licensing only matters on a handful of endpoints, consider splitting the licensed surface into its own service — or verify the provider is deferred before you assume it is free.
N+1 on activation counts. Listing licenses with their activations is the obvious query to write and the obvious one to regret. withCount(['activations as active_count' => fn ($q) => $q->whereNull('deactivated_at')]) on the index page, always.
Do not let the package own your validation policy. Use its validation/activation hooks to extend — trial windows, product tiers, IP allowlists — but keep the rules in your service class. If a rule only exists inside a package hook, a minor bump can change it and your tests will not be looking.
Transaction and event ordering. firstOrCreate gives you idempotency against duplicates, but not against a race between two app servers processing the same webhook concurrently — that needs the unique index, and a catch-and-re-read on the resulting QueryException. Test this with parallel workers, not with two sequential calls.
Timezones. Store and compare in UTC; the package's expiry comparison and your scheduled expiry command must agree on the boundary, or you will get licenses that validate in tests and expire a day early in production. Freeze time in the tests and assert the exact boundary second.
Log the validation decision. One line per call with key prefix, fingerprint, outcome, and reason — no full key. When a customer says "it says invalid", that log is the difference between a two-minute answer and a two-hour investigation.
Budget for writing most of this yourself. Realistically the package provides the tables, two models, key generation, and a hook. The entitlement mapping, seat policy, expiry lifecycle, admin surface, and API are yours. That is a reasonable trade at this size — but price it honestly, because the package name suggests more is included than likely is.
How can I help you explore Laravel packages today?