Advanced Laravel
Building Laravel Packages
Building Laravel Packages
Laravel packages allow you to create reusable functionality that can be shared across multiple projects or distributed to the community. In this lesson, you'll learn how to structure, develop, test, and publish Laravel packages.
Package Structure
A typical Laravel package follows this directory structure:
my-package/
├── config/
│ └── my-package.php # Package configuration
├── database/
│ ├── migrations/ # Package migrations
│ └── seeders/ # Package seeders
├── resources/
│ ├── views/ # Package views
│ └── lang/ # Package translations
├── routes/
│ ├── web.php # Web routes
│ └── api.php # API routes
├── src/
│ ├── Commands/ # Artisan commands
│ ├── Controllers/ # Controllers
│ ├── Models/ # Eloquent models
│ ├── Middleware/ # Middleware
│ ├── Facades/ # Facades
│ ├── MyPackageServiceProvider.php
│ └── MyPackage.php # Main package class
├── tests/
│ ├── Unit/ # Unit tests
│ └── Feature/ # Feature tests
├── .gitignore
├── composer.json # Package metadata
├── LICENSE
└── README.md
Creating a Package
Let's create a simple package for managing API keys:
# Create package directory structure
mkdir -p packages/vendor-name/api-keys/src
cd packages/vendor-name/api-keys
# Initialize composer
composer init
# Follow prompts:
# Package name: vendor-name/api-keys
# Description: API key management for Laravel
# Author: Your Name <your@email.com>
# Minimum Stability: stable
# License: MIT
# Add Laravel dependencies
composer require illuminate/support
Service Provider
The service provider is the entry point for your package:
<?php
// src/ApiKeysServiceProvider.php
namespace VendorName\ApiKeys;
use Illuminate\Support\ServiceProvider;
use VendorName\ApiKeys\Commands\GenerateApiKeyCommand;
use VendorName\ApiKeys\Commands\RevokeApiKeyCommand;
class ApiKeysServiceProvider extends ServiceProvider
{
/**
* Register services
*/
public function register(): void
{
// Merge package config with app config
$this->mergeConfigFrom(
__DIR__ . '/../config/api-keys.php',
'api-keys'
);
// Register singleton
$this->app->singleton('api-keys', function ($app) {
return new ApiKeyManager($app['config']['api-keys']);
});
// Register facade
$this->app->bind('api-keys-facade', function () {
return new ApiKeyManager();
});
}
/**
* Bootstrap services
*/
public function boot(): void
{
// Publish configuration
$this->publishes([
__DIR__ . '/../config/api-keys.php' => config_path('api-keys.php'),
], 'api-keys-config');
// Publish migrations
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'api-keys-migrations');
// Load migrations
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Publish views
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/api-keys'),
], 'api-keys-views');
// Load views
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'api-keys');
// Load translations
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'api-keys');
// Load routes
$this->loadRoutesFrom(__DIR__ . '/../routes/api.php');
// Register commands
if ($this->app->runningInConsole()) {
$this->commands([
GenerateApiKeyCommand::class,
RevokeApiKeyCommand::class,
]);
}
// Register middleware
$router = $this->app['router'];
$router->aliasMiddleware('api.key', \VendorName\ApiKeys\Middleware\ValidateApiKey::class);
}
}
Note: The
register() method is used for binding services into the container, while boot() is used for actions that depend on other services being registered.
Package Configuration
Create a configuration file for your package:
<?php
// config/api-keys.php
return [
/*
|--------------------------------------------------------------------------
| API Key Settings
|--------------------------------------------------------------------------
*/
'table' => env('API_KEYS_TABLE', 'api_keys'),
'key_length' => env('API_KEY_LENGTH', 32),
'prefix' => env('API_KEY_PREFIX', 'sk_'),
'hash_algo' => env('API_KEY_HASH_ALGO', 'sha256'),
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
*/
'rate_limiting' => [
'enabled' => env('API_KEY_RATE_LIMIT_ENABLED', true),
'requests_per_minute' => env('API_KEY_RATE_LIMIT_REQUESTS', 60),
],
/*
|--------------------------------------------------------------------------
| Expiration
|--------------------------------------------------------------------------
*/
'expiration' => [
'enabled' => env('API_KEY_EXPIRATION_ENABLED', false),
'days' => env('API_KEY_EXPIRATION_DAYS', 365),
],
/*
|--------------------------------------------------------------------------
| IP Whitelisting
|--------------------------------------------------------------------------
*/
'ip_whitelist' => [
'enabled' => env('API_KEY_IP_WHITELIST_ENABLED', false),
],
];
Package Main Class
Create the main functionality class:
<?php
// src/ApiKeyManager.php
namespace VendorName\ApiKeys;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ApiKeyManager
{
protected array $config;
public function __construct(array $config = [])
{
$this->config = $config ?: config('api-keys');
}
/**
* Generate a new API key
*/
public function generate(
int $userId,
string $name,
?array $permissions = null,
?array $ipWhitelist = null,
?Carbon $expiresAt = null
): array {
$key = $this->generateRandomKey();
$hashedKey = $this->hashKey($key);
$apiKeyId = DB::table($this->config['table'])->insertGetId([
'user_id' => $userId,
'name' => $name,
'key_hash' => $hashedKey,
'permissions' => $permissions ? json_encode($permissions) : null,
'ip_whitelist' => $ipWhitelist ? json_encode($ipWhitelist) : null,
'expires_at' => $expiresAt,
'created_at' => now(),
'updated_at' => now(),
]);
return [
'id' => $apiKeyId,
'key' => $this->config['prefix'] . $key, // Return with prefix
'name' => $name,
'created_at' => now(),
];
}
/**
* Validate an API key
*/
public function validate(string $key, ?string $ipAddress = null): ?array
{
// Remove prefix if present
$key = Str::startsWith($key, $this->config['prefix'])
? Str::after($key, $this->config['prefix'])
: $key;
$hashedKey = $this->hashKey($key);
$apiKey = DB::table($this->config['table'])
->where('key_hash', $hashedKey)
->where('is_active', true)
->first();
if (!$apiKey) {
return null;
}
// Check expiration
if ($apiKey->expires_at && Carbon::parse($apiKey->expires_at)->isPast()) {
return null;
}
// Check IP whitelist
if ($this->config['ip_whitelist']['enabled'] && $apiKey->ip_whitelist) {
$whitelist = json_decode($apiKey->ip_whitelist, true);
if ($ipAddress && !in_array($ipAddress, $whitelist)) {
return null;
}
}
// Update last used timestamp
DB::table($this->config['table'])
->where('id', $apiKey->id)
->update([
'last_used_at' => now(),
'usage_count' => DB::raw('usage_count + 1'),
]);
return (array) $apiKey;
}
/**
* Revoke an API key
*/
public function revoke(int $apiKeyId): bool
{
return DB::table($this->config['table'])
->where('id', $apiKeyId)
->update([
'is_active' => false,
'revoked_at' => now(),
]) > 0;
}
/**
* Check if API key has permission
*/
public function hasPermission(array $apiKey, string $permission): bool
{
if (!$apiKey['permissions']) {
return true; // No permissions = all permissions
}
$permissions = json_decode($apiKey['permissions'], true);
return in_array($permission, $permissions) || in_array('*', $permissions);
}
/**
* Generate a random key
*/
protected function generateRandomKey(): string
{
return Str::random($this->config['key_length']);
}
/**
* Hash a key for storage
*/
protected function hashKey(string $key): string
{
return hash($this->config['hash_algo'], $key);
}
}
Tip: Always hash API keys before storing them in the database, just like passwords. Store only the hash and return the plain key to the user once during generation.
Middleware
Create middleware to validate API keys:
<?php
// src/Middleware/ValidateApiKey.php
namespace VendorName\ApiKeys\Middleware;
use Closure;
use Illuminate\Http\Request;
use VendorName\ApiKeys\Facades\ApiKeys;
class ValidateApiKey
{
/**
* Handle an incoming request
*/
public function handle(Request $request, Closure $next, ?string $permission = null)
{
$apiKey = $request->bearerToken() ?: $request->header('X-API-Key');
if (!$apiKey) {
return response()->json([
'error' => 'API key is required',
], 401);
}
$validatedKey = ApiKeys::validate($apiKey, $request->ip());
if (!$validatedKey) {
return response()->json([
'error' => 'Invalid or expired API key',
], 401);
}
// Check permission if specified
if ($permission && !ApiKeys::hasPermission($validatedKey, $permission)) {
return response()->json([
'error' => 'Insufficient permissions',
], 403);
}
// Attach API key info to request
$request->attributes->set('api_key', $validatedKey);
return $next($request);
}
}
Facade
Create a facade for easier access to your package:
<?php
// src/Facades/ApiKeys.php
namespace VendorName\ApiKeys\Facades;
use Illuminate\Support\Facades\Facade;
class ApiKeys extends Facade
{
/**
* Get the registered name of the component
*/
protected static function getFacadeAccessor(): string
{
return 'api-keys-facade';
}
}
Artisan Commands
Create Artisan commands for your package:
<?php
// src/Commands/GenerateApiKeyCommand.php
namespace VendorName\ApiKeys\Commands;
use Illuminate\Console\Command;
use VendorName\ApiKeys\Facades\ApiKeys;
use App\Models\User;
class GenerateApiKeyCommand extends Command
{
protected $signature = 'api-keys:generate
{user : The user ID or email}
{name : The API key name}
{--permissions=* : Permissions for this key}
{--expires-in= : Expiration in days}
{--ip=* : Whitelisted IP addresses}';
protected $description = 'Generate a new API key for a user';
public function handle(): int
{
$userIdentifier = $this->argument('user');
$user = is_numeric($userIdentifier)
? User::find($userIdentifier)
: User::where('email', $userIdentifier)->first();
if (!$user) {
$this->error('User not found');
return self::FAILURE;
}
$name = $this->argument('name');
$permissions = $this->option('permissions') ?: null;
$ipWhitelist = $this->option('ip') ?: null;
$expiresAt = $this->option('expires-in')
? now()->addDays((int) $this->option('expires-in'))
: null;
$result = ApiKeys::generate(
$user->id,
$name,
$permissions,
$ipWhitelist,
$expiresAt
);
$this->info("API key generated successfully!");
$this->line("");
$this->line("Key: {$result['key']}");
$this->line("Name: {$result['name']}");
if ($expiresAt) {
$this->line("Expires: {$expiresAt->toDateTimeString()}");
}
$this->warn("\nStore this key securely. It won't be shown again!");
return self::SUCCESS;
}
}
Testing Your Package
Set up PHPUnit for testing:
<?php
// tests/TestCase.php
namespace VendorName\ApiKeys\Tests;
use Orchestra\Testbench\TestCase as Orchestra;
use VendorName\ApiKeys\ApiKeysServiceProvider;
abstract class TestCase extends Orchestra
{
protected function setUp(): void
{
parent::setUp();
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
protected function getPackageProviders($app): array
{
return [
ApiKeysServiceProvider::class,
];
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
]);
}
}
// tests/Feature/ApiKeyGenerationTest.php
namespace VendorName\ApiKeys\Tests\Feature;
use VendorName\ApiKeys\Tests\TestCase;
use VendorName\ApiKeys\Facades\ApiKeys;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ApiKeyGenerationTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_can_generate_an_api_key()
{
$result = ApiKeys::generate(1, 'Test Key');
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('key', $result);
$this->assertStringStartsWith('sk_', $result['key']);
}
/** @test */
public function it_can_validate_a_generated_key()
{
$result = ApiKeys::generate(1, 'Test Key');
$validated = ApiKeys::validate($result['key']);
$this->assertNotNull($validated);
$this->assertEquals(1, $validated['user_id']);
}
/** @test */
public function it_rejects_invalid_keys()
{
$validated = ApiKeys::validate('invalid_key');
$this->assertNull($validated);
}
}
Publishing to Packagist
Prepare your package for distribution:
# Update composer.json
{
"name": "vendor-name/api-keys",
"description": "API key management for Laravel",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"VendorName\\ApiKeys\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"VendorName\\ApiKeys\\Tests\\": "tests/"
}
},
"require": {
"php": "^8.1",
"illuminate/support": "^10.0|^11.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0",
"phpunit/phpunit": "^10.0"
},
"extra": {
"laravel": {
"providers": [
"VendorName\\ApiKeys\\ApiKeysServiceProvider"
],
"aliases": {
"ApiKeys": "VendorName\\ApiKeys\\Facades\\ApiKeys"
}
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
# Steps to publish:
# 1. Create account on packagist.org
# 2. Push code to GitHub/GitLab
# 3. Submit package URL to Packagist
# 4. Add webhook for auto-updates
# 5. Tag releases with semantic versioning (v1.0.0, v1.1.0, etc.)
# Create a git tag
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
Warning: Always test your package thoroughly before publishing. Once published, follow semantic versioning strictly to avoid breaking changes for users.
Exercise 1: Create a Laravel package called "notification-channels" that adds support for Telegram and Discord notifications. Include service providers, configuration, notification channels, and tests. Publish it to a private GitHub repository.
Exercise 2: Build a package for managing user preferences (theme, language, timezone, email notifications). Include migrations, models, a facade, Artisan commands for importing/exporting preferences, and a middleware that applies user preferences to every request.
Exercise 3: Develop a "laravel-audit-log" package that automatically logs all model changes (create, update, delete) with user information and timestamps. Include a dashboard view to browse audit logs, search/filter capabilities, and automatic cleanup of old logs. Add comprehensive tests.
Summary
In this lesson, you learned:
- Laravel package structure and organization
- Creating service providers with configuration and asset publishing
- Building package functionality with facades and middleware
- Writing Artisan commands for package management
- Testing packages with Orchestra Testbench
- Publishing packages to Packagist