Advanced Laravel

Building Laravel Packages

18 min Lesson 39 of 40

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