devravik / laravel-licensing
A production-ready Laravel package for generating, managing, activating, and validating software licenses.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/hashing: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- larastan/larastan: ^2.0|^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
README
Laravel Licensing
A production-ready Laravel package for generating, managing, activating, and validating software licenses directly inside your application.
License keys are hashed before storage (never plaintext in the database), activations are seat-controlled, and the entire lifecycle creation, validation, activation, revocation, and expiry is covered by typed exceptions and dispatchable events.
Starter Kit
New to this package? Check out the Laravel Licensing Starter a complete working example that demonstrates how to use this package in a real Laravel application.
The starter kit includes:
- Full license management dashboard
- User management with license assignment
- License creation, activation, and validation workflows
- API endpoints for license validation and activation
- Complete UI built with Laravel Breeze
- Demo data and examples
Perfect for understanding how to integrate this package into your own application!
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.1 (PHP 8.2+ required for Laravel 11 / 12) |
| Laravel | ^10.0 | ^11.0 | ^12.0 |
Installation
1. Install via Composer
composer require devravik/laravel-licensing
2. Publish the config
php artisan vendor:publish --tag=license-config
3. Publish the migrations
php artisan vendor:publish --tag=license-migrations
4. Run the migrations
php artisan migrate
The package auto-registers its service provider via Laravel's package discovery. No manual registration is required.
Configuration
After publishing, the config file lives at config/license.php:
return [ 'license_model' => \DevRavik\LaravelLicensing\Models\License::class, 'activation_model' => \DevRavik\LaravelLicensing\Models\Activation::class, 'key_length' => env('LICENSE_KEY_LENGTH', 32), 'hash_keys' => env('LICENSE_HASH_KEYS', true), 'default_expiry_days' => env('LICENSE_DEFAULT_EXPIRY_DAYS', 365), 'grace_period_days' => env('LICENSE_GRACE_PERIOD_DAYS', 7), 'license_generation' => env('LICENSE_GENERATION', 'random'), // 'random' | 'signed' 'signature' => [ 'public_key' => env('LICENSE_PUBLIC_KEY'), 'private_key' => env('LICENSE_PRIVATE_KEY'), ], ];
| Option | Type | Default | Description |
|---|---|---|---|
license_model |
string |
License::class |
Eloquent model for licenses |
activation_model |
string |
Activation::class |
Eloquent model for activations |
key_length |
int |
32 |
Length of generated license keys (chars) |
hash_keys |
bool |
true |
Hash keys with bcrypt before storage |
default_expiry_days |
int|null |
365 |
Default license duration; null = no expiry |
grace_period_days |
int |
7 |
Days of temporary validity after expiry; 0 = disabled |
license_generation |
string |
'random' |
Generation strategy: 'random' or 'signed' |
signature.public_key |
string|null |
null |
Ed25519 public key (file path or base64 string) |
signature.private_key |
string|null |
null |
Ed25519 private key (file path or base64 string) |
Environment variables:
LICENSE_KEY_LENGTH=32 LICENSE_HASH_KEYS=true LICENSE_DEFAULT_EXPIRY_DAYS=365 LICENSE_GRACE_PERIOD_DAYS=7 LICENSE_GENERATION=random LICENSE_PUBLIC_KEY= LICENSE_PRIVATE_KEY=
Usage
Creating a License
Use the HasLicenses trait on any Eloquent model that should own licenses, then build with the fluent API:
// app/Models/User.php use DevRavik\LaravelLicensing\Support\HasLicenses; class User extends Authenticatable { use HasLicenses; }
use DevRavik\LaravelLicensing\Facades\License; $license = License::for($user) ->product('pro') ->seats(3) ->expiresInDays(365) ->create(); // The raw key is only available at creation time. Store or display it immediately. $rawKey = $license->key;
Builder reference:
| Method | Description | Required |
|---|---|---|
for(Model $owner) |
Bind the license to an Eloquent model | Yes |
product(string $product) |
Set the product name or tier | Yes |
seats(int $count) |
Maximum activations allowed (default: 1) | No |
expiresInDays(int $days) |
Expiry relative to now | No |
expiresAt(Carbon $date) |
Explicit expiry date | No |
create() |
Persist and return the license | Yes |
If neither
expiresInDays()norexpiresAt()is called, the value fromconfig('license.default_expiry_days')is used.
Validating a License
use DevRavik\LaravelLicensing\Facades\License; use DevRavik\LaravelLicensing\Exceptions\InvalidLicenseException; use DevRavik\LaravelLicensing\Exceptions\LicenseExpiredException; use DevRavik\LaravelLicensing\Exceptions\LicenseRevokedException; try { $license = License::validate($rawKey); // Valid. Check grace period if needed: if ($license->isInGracePeriod()) { // Warn: expires in $license->graceDaysRemaining() days } } catch (LicenseExpiredException $e) { // Expired beyond grace period } catch (LicenseRevokedException $e) { // Revoked } catch (InvalidLicenseException $e) { // Key not found }
Activating a License
Bind a license to a domain, IP address, machine ID, or any identifier. Each binding consumes one seat.
use DevRavik\LaravelLicensing\Facades\License; use DevRavik\LaravelLicensing\Exceptions\SeatLimitExceededException; try { $activation = License::activate($rawKey, 'app.example.com'); } catch (SeatLimitExceededException $e) { // All seats occupied }
Deactivating a License
Remove an activation to free up a seat:
License::deactivate($rawKey, 'app.example.com');
Revoking a License
Immediately and permanently invalidate a license:
License::revoke($rawKey);
Checking License Status
$license = License::find($rawKey); // no exception on failure returns null $license->isValid(); // not revoked and not fully expired $license->isExpired(); // past expiration date $license->isInGracePeriod(); // expired but within grace window $license->isRevoked(); // revoked_at is set $license->seatsRemaining(); // available activation slots $license->graceDaysRemaining(); // days left in grace period $license->activations; // Eloquent collection of current activations
Facade Reference
| Method | Signature | Returns | Description |
|---|---|---|---|
for |
for(Model $owner) |
LicenseBuilder |
Begin building a license |
validate |
validate(string $key) |
License |
Validate and return license; throws on failure |
activate |
activate(string $key, string $binding) |
Activation |
Activate a license against a binding |
deactivate |
deactivate(string $key, string $binding) |
bool |
Remove an activation binding |
revoke |
revoke(string $key) |
bool |
Permanently revoke a license |
find |
find(string $key) |
License|null |
Find a license without validation |
Artisan Commands
The package provides comprehensive Artisan commands for managing licenses:
Generate License Keys
Generate Ed25519 key pairs for signed license verification:
php artisan licensing:keys
Options:
--force- Overwrite existing keys if they already exist--show- Display the generated keys in the console--write- Automatically append keys to your.envfile
License Status
Check package configuration and status:
php artisan license:status
List Licenses
List all licenses with optional filtering:
php artisan licensing:list php artisan licensing:list --product=pro php artisan licensing:list --status=active php artisan licensing:list --expired php artisan licensing:list --revoked
Options:
--product- Filter by product name--owner-type- Filter by owner model class--owner-id- Filter by owner ID--status- Filter by status (active|expired|revoked)--expired- Show only expired licenses--revoked- Show only revoked licenses--per-page- Number of licenses per page (default: 15)
Show License
Display detailed information about a specific license:
php artisan licensing:show --key=abc123... php artisan licensing:show --id=1 php artisan licensing:show --id=1 --full
Options:
--key- License key--id- License ID--full- Show full license key (unmasked)
Create License
Create a new license interactively or via options:
# Interactive mode php artisan licensing:create # Non-interactive mode php artisan licensing:create \ --owner-type=App\\Models\\User \ --owner-id=1 \ --product=pro \ --seats=5 \ --expires-days=365 \ --non-interactive
Options:
--owner-type- Owner model class (e.g.,App\\Models\\User)--owner-id- Owner model ID--product- Product name or tier--seats- Number of seats (default: 1)--expires-days- Days until expiration (leave empty for never)--non-interactive- Skip interactive prompts
Revoke License
Revoke a license by key or ID:
php artisan licensing:revoke --key=abc123... php artisan licensing:revoke --id=1 --force
Options:
--key- License key--id- License ID--force- Skip confirmation prompt
Activate License
Activate a license for a binding:
php artisan licensing:activate --key=abc123... --binding=domain.com php artisan licensing:activate --id=1 --binding=192.168.1.1
Options:
--key- License key--id- License ID--binding- Activation binding (domain, IP, machine ID, etc.)
Deactivate License
Remove an activation binding:
php artisan licensing:deactivate --key=abc123... --binding=domain.com php artisan licensing:deactivate --id=1 --binding=192.168.1.1
Options:
--key- License key--id- License ID--binding- Binding to remove (if omitted, shows list to choose from)
License Statistics
Display license and activation statistics:
php artisan licensing:stats php artisan licensing:stats --product=pro
Options:
--product- Filter statistics by product
Output includes:
- Total, active, expired, and revoked license counts
- Total, used, and available seat counts
- Total activation count
- Seat usage percentage
Middleware
The package ships two middleware aliases that are registered automatically.
Protect routes requiring a specific product
// routes/api.php Route::middleware('license:pro')->group(function () { Route::get('/pro/dashboard', [ProDashboardController::class, 'index']); }); Route::middleware('license:enterprise')->group(function () { Route::get('/enterprise/analytics', [AnalyticsController::class, 'index']); });
Protect routes requiring any valid license
Route::middleware('license.valid')->group(function () { Route::get('/app', [AppController::class, 'index']); });
The middleware reads the license key from the X-License-Key request header (or license_key query/body parameter).
Middleware Responses
| Failure Reason | HTTP Status |
|---|---|
| No key provided | 401 Unauthorized |
| Key not found | 404 Not Found |
| Revoked or expired | 403 Forbidden |
| Wrong product tier | 403 Forbidden |
| Seat limit exceeded | 422 Unprocessable Entity |
For Accept: application/json requests the middleware returns:
{
"error": "license_check_failed",
"message": "The provided license key is invalid."
}
For web requests, abort() is called with the appropriate status code.
You can customise the response by extending AbstractLicenseMiddleware and overriding denyResponse().
Events
| Event | Fired When | Properties |
|---|---|---|
LicenseCreated |
create() is called |
$event->license |
LicenseActivated |
activate() is called |
$event->license, $event->activation |
LicenseDeactivated |
deactivate() is called |
$event->license, $event->binding |
LicenseRevoked |
revoke() is called |
$event->license |
LicenseExpired |
Dispatched manually from a scheduled command | $event->license |
LicenseExpired is intentionally not dispatched automatically it should be fired from a scheduled command so you control when and how expiration is processed:
// routes/console.php (Laravel 11+) use Illuminate\Support\Facades\Schedule; use DevRavik\LaravelLicensing\Models\License; use DevRavik\LaravelLicensing\Events\LicenseExpired; Schedule::call(function () { License::query() ->whereNull('revoked_at') ->whereNotNull('expires_at') ->where('expires_at', '<=', now()->subDays(config('license.grace_period_days'))) ->each(fn ($license) => event(new LicenseExpired($license))); })->daily();
Registering Listeners
// app/Providers/EventServiceProvider.php use DevRavik\LaravelLicensing\Events\LicenseCreated; use DevRavik\LaravelLicensing\Events\LicenseActivated; use DevRavik\LaravelLicensing\Events\LicenseRevoked; protected $listen = [ LicenseCreated::class => [SendLicenseKeyEmail::class], LicenseActivated::class => [LogActivation::class], LicenseRevoked::class => [NotifyUserOfRevocation::class], ];
Exceptions
All exceptions extend LicenseManagerException, so you can catch them collectively or individually.
| Exception | Thrown When | getStatusCode() |
|---|---|---|
InvalidLicenseException |
Key does not match any record | 404 |
LicenseExpiredException |
Expired beyond grace period | 403 |
LicenseRevokedException |
License has been revoked | 403 |
SeatLimitExceededException |
All activation seats occupied | 422 |
LicenseAlreadyActivatedException |
Binding already exists for this license | 409 |
LicenseManagerException |
Base exception (catch-all) | 500 |
Global Exception Handler
// bootstrap/app.php (Laravel 11+) use DevRavik\LaravelLicensing\Exceptions\LicenseManagerException; $exceptions->render(function (LicenseManagerException $e, $request) { if ($request->expectsJson()) { return response()->json([ 'error' => 'license_error', 'message' => $e->getMessage(), ], $e->getStatusCode()); } return redirect()->route('license.invalid'); });
Database Schema
licenses
| Column | Type | Notes |
|---|---|---|
id |
bigint PK |
Auto-increment |
key |
varchar(255) |
Bcrypt hash of the raw key (or plaintext if hash_keys=false) |
lookup_token |
varchar(64) nullable, indexed |
SHA-256 of raw key; used for O(log n) pre-filtering before bcrypt check |
product |
varchar(255) indexed |
Product name or tier |
owner_id |
bigint indexed |
Polymorphic FK |
owner_type |
varchar(255) |
Polymorphic type |
seats |
int default 1 |
Maximum activations allowed |
expires_at |
timestamp nullable |
Expiration timestamp |
revoked_at |
timestamp nullable |
Set when revoked; null = active |
created_at / updated_at |
timestamp |
Laravel timestamps |
license_activations
| Column | Type | Notes |
|---|---|---|
id |
bigint PK |
Auto-increment |
license_id |
bigint FK |
Cascades on delete |
binding |
varchar(255) |
Domain, IP, machine ID, or custom string |
activated_at |
timestamp |
When activation occurred |
created_at / updated_at |
timestamp |
Laravel timestamps |
A unique composite index on (license_id, binding) prevents duplicate activations.
Advanced Usage
Polymorphic Ownership
Licenses can belong to any model not just users. Add the HasLicenses trait:
// app/Models/Team.php use DevRavik\LaravelLicensing\Support\HasLicenses; class Team extends Model { use HasLicenses; } // Create a team license $license = License::for($team) ->product('team-plan') ->seats(25) ->expiresInDays(365) ->create();
Querying Licenses
// All licenses for a user $user->licenses; // Active licenses for a product $user->licenses() ->where('product', 'pro') ->whereNull('revoked_at') ->where(function ($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); }) ->get();
Custom License Model
// app/Models/License.php namespace App\Models; use DevRavik\LaravelLicensing\Models\License as BaseLicense; class License extends BaseLicense { public function getIsPremiumAttribute(): bool { return in_array($this->product, ['pro', 'enterprise']); } }
Update config/license.php:
'license_model' => \App\Models\License::class,
Custom Activation Model
namespace App\Models; use DevRavik\LaravelLicensing\Models\Activation as BaseActivation; class Activation extends BaseActivation { // Add custom attributes or relationships }
'activation_model' => \App\Models\Activation::class,
Using in Controllers
public function activate(Request $request) { $request->validate(['key' => 'required|string', 'binding' => 'required|string']); try { $activation = License::activate($request->key, $request->binding); return response()->json(['activated' => true, 'binding' => $activation->binding]); } catch (LicenseManagerException $e) { return response()->json(['error' => $e->getMessage()], $e->getStatusCode()); } }
Security
- Key generation uses
random_bytes()a cryptographically secure CSPRNG. Noopensslextension required. - Key storage keys are hashed with bcrypt via Laravel's
Hashfacade before being written to the database. The raw key is never persisted. A SHA-256lookup_tokenis stored alongside to enable fast lookups without a full-table bcrypt scan. - Key comparison uses
Hash::check(), which is resistant to timing attacks. - One-time retrieval the raw key is available only during the
create()call. Transmit it to the end user immediately over a secure channel (HTTPS). - Signature-based verification (v1.1+) optional Ed25519 signature-based license verification for offline and tamper-resistant validation scenarios. When enabled, license keys contain cryptographically signed payloads that can be verified without database access.
Production checklist:
- Keep
hash_keys = truenever disable in production. - Use HTTPS for all endpoints that transmit or receive license keys.
- Rate-limit validation and activation endpoints to prevent brute-force guessing.
- Use the provided events to audit all license operations.
- Never log raw license keys.
- For signed licenses, store private keys securely and never commit them to version control.
Testing
The package test suite runs against MySQL:
# MySQL
TEST_DB_DRIVER=mysql \
TEST_DB_HOST=127.0.0.1 \
TEST_DB_PORT=3306 \
TEST_DB_DATABASE=laravel_licensing_test \
TEST_DB_USERNAME=root \
TEST_DB_PASSWORD=secret \
vendor/bin/phpunit
Create the MySQL test database first:
mysql -u root -e "CREATE DATABASE IF NOT EXISTS laravel_licensing_test;"
Example Test
use DevRavik\LaravelLicensing\Facades\License; use DevRavik\LaravelLicensing\Exceptions\SeatLimitExceededException; public function test_seat_limit_is_enforced(): void { $license = License::for(User::factory()->create()) ->product('pro') ->seats(2) ->create(); $key = $license->key; License::activate($key, 'domain-1.com'); License::activate($key, 'domain-2.com'); $this->expectException(SeatLimitExceededException::class); License::activate($key, 'domain-3.com'); }
Signature-Based License Verification (v1.1+)
v1.1 adds optional Ed25519 signature-based license verification for offline and tamper-resistant validation scenarios.
Enabling Signed Licenses
Set LICENSE_GENERATION=signed in your .env file and provide Ed25519 key pair:
LICENSE_GENERATION=signed LICENSE_PUBLIC_KEY=base64_encoded_public_key LICENSE_PRIVATE_KEY=base64_encoded_private_key
Or use file paths:
LICENSE_GENERATION=signed LICENSE_PUBLIC_KEY=/path/to/public.key LICENSE_PRIVATE_KEY=/path/to/private.key
Generating Key Pairs
Generate an Ed25519 key pair using the Artisan command:
php artisan licensing:keys
This will generate a new key pair and display the values to add to your .env file.
Options:
--force- Overwrite existing keys if they already exist--show- Display the generated keys in the console--write- Automatically append keys to your.envfile
Example:
# Generate and display keys php artisan licensing:keys --show # Generate and auto-write to .env php artisan licensing:keys --write # Overwrite existing keys php artisan licensing:keys --force
Manual Generation (Alternative):
If you prefer to generate keys manually using PHP:
$keypair = sodium_crypto_sign_keypair(); $publicKey = base64_encode(sodium_crypto_sign_publickey($keypair)); $privateKey = base64_encode(sodium_crypto_sign_secretkey($keypair)); // Store these securely echo "Public Key: {$publicKey}\n"; echo "Private Key: {$privateKey}\n";
How It Works
When license_generation is set to 'signed':
- Generation: License keys are created by signing a JSON payload (product, seats, expiry, owner info) with the private key using Ed25519.
- Format: Keys are base64-encoded strings containing
base64(message . '.' . signature). - Verification: During validation, the signature is verified using the public key before database lookup.
- Storage: Signed licenses are still stored in the database (hashed) for seat management, activations, and revocation tracking.
Benefits
- Offline validation: Verify license authenticity without database access
- Tamper resistance: Any modification to the license key invalidates the signature
- Cryptographic proof: Ed25519 provides strong cryptographic guarantees
- Backward compatible: Default behavior remains random generation
Example
// Generate signed license $license = License::for($user) ->product('pro') ->seats(5) ->expiresInDays(365) ->create(); // Validate (signature verified automatically) $validated = License::validate($license->key);
Changelog
Please see CHANGELOG.md for a full release history.
Contributing
Contributions are welcome. Please:
- Fork the repository and create a feature branch from
main. - Write tests for all new functionality.
- Follow PSR-12 and run
composer formatbefore submitting. - Ensure
composer testandcomposer analysepass. - Open a pull request with a clear description of the change.
Pull requests without tests will not be merged.
When reporting a bug, please include your PHP and Laravel version, the package version, steps to reproduce, and any relevant stack traces.
Security Vulnerabilities
Please do not report security vulnerabilities via GitHub Issues. Email dev.ravikgupt@gmail.com with the subject line [SECURITY] devravik/laravel-licensing <brief description>. You will receive a response within 48 hours.
See SECURITY.md for details.
Maintainer
Ravi K Gupta
- Website: devravik.github.io
- Email:
dev.ravikgupt@gmail.com - LinkedIn: linkedin.com/in/ravi-k-dev
- GitHub: github.com/devravik
License
The MIT License (MIT). Please see LICENSE for more information.
