Introduction
After 70 days of building Laravel applications, you've likely accumulated reusable logic that appears across multiple projects. Rather than copying code between repositories or maintaining duplicate business logic, Laravel package development in PHP & Laravel Development lets you extract, version, and share that logic as a proper package. Whether you're building utilities, API clients, or domain-specific features, packaging makes your code testable, maintainable, and shareable across your organization or the open-source community.
In this article, we'll walk through the practical process of creating a Laravel package from scratch, publishing it, and ensuring it works reliably in downstream projects.
Why package your Laravel code
Building packages forces you to think about contracts and interfaces. When code lives inside a single application, it's easy to make assumptions about configuration, dependencies, and environment. A package must be explicit: it declares its dependencies, provides clear configuration points, and integrates cleanly into any Laravel application.
We've seen teams move from copy-paste maintenance across five projects to a single source of truth in a package. Updates happen once. Testing is comprehensive because the package is isolated. Versioning becomes clear: if you need the old behavior, you pin an earlier version.
Packages also encourage better API design. When you know external developers (or your future self) will use your code, you tend to write clearer interfaces and better documentation.
Use spatie/package-skeleton-laravel
Rather than creating the scaffolding from scratch, the Spatie team maintains spatie/package-skeleton-laravel, a starter template that includes all the boilerplate you need: directory structure, a service provider, testing configuration, and GitHub Actions workflows.
To get started:
composer create-project spatie/package-skeleton-laravel your-package-nameThis generates a working package structure with composer.json already configured, a src/ directory for your code, a tests/ directory with PHPUnit set up, and a README template.
The key files you'll modify:
composer.json– your package name, dependencies, and autoloading rulessrc/ServiceProvider.php– the entry point where Laravel discovers your packageconfig/– publishable configuration filessrc/– your actual code: classes, facades, commands, migrationstests/– comprehensive test suite
After running the composer command, check your composer.json. The skeleton includes reasonable defaults for minimum PHP version and Laravel version constraints. Update these to match your requirements.
How the service provider registers features
Laravel discovers your package through its service provider. When a user installs your package, Laravel looks for the service provider registered in your composer.json and automatically calls it.
Let's say you're building a package that provides a reporting engine. Your service provider might look like this:
namespace YourVendor\YourPackage;
use Illuminate\Support\ServiceProvider;
use YourVendor\YourPackage\Commands\GenerateReportCommand;
use YourVendor\YourPackage\Services\ReportingService;
class YourPackageServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/reporting.php',
'reporting'
);
$this->app->singleton(ReportingService::class, function ($app) {
return new ReportingService($app['config']['reporting']);
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
GenerateReportCommand::class,
]);
}
$this->publishes([
__DIR__.'/../config/reporting.php' => config_path('reporting.php'),
], 'config');
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'migrations');
}
}Breaking this down:
- register() runs when the service provider is instantiated. This is where you bind services into the container. Use
mergeConfigFromto provide default configuration. - boot() runs after all service providers are registered. This is where you publish assets, register commands, or define routes.
$this->publishes()makes files available for the user to override or customize.
The service provider is your package's contract with Laravel. It's the only class the framework needs to know about directly.
Publishable config and migrations
Most packages need configuration. Rather than hard-coding settings, use Laravel's config system. Create a config file in your package:
// config/reporting.php
return [
'timezone' => 'UTC',
'cache_ttl' => 3600,
'database_connection' => 'default',
'templates_path' => storage_path('reporting/templates'),
];
In your service provider, call mergeConfigFrom() during registration to provide these defaults. During boot, call publishes() so users can override them:
$this->publishes([
__DIR__.'/../config/reporting.php' => config_path('reporting.php'),
], 'config');After installation, the user runs:
php artisan vendor:publish --provider="YourVendor\YourPackage\YourPackageServiceProvider" --tag="config"This copies your config to their app's config/ directory, where they can edit it freely.
Migrations work similarly. If your package needs database tables, place migrations in database/migrations/:
// database/migrations/2024_01_15_000000_create_reports_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->json('parameters');
$table->timestamps();
});
}
};
In your service provider:
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'migrations');Users can then run php artisan vendor:publish --tag="migrations" and execute migrations normally. This approach keeps your package's database changes explicit and under user control.
Test with Orchestra Testbench
Testing packages is different from testing applications. Your package doesn't have a Laravel instance; it lives in isolation. You need a lightweight Laravel environment just for testing. That's where orchestra/testbench comes in.
The skeleton already includes it. Your tests might look like:
namespace YourVendor\YourPackage\Tests;
use Orchestra\Testbench\TestCase;
use YourVendor\YourPackage\Services\ReportingService;
class ReportingServiceTest extends TestCase
{
protected function getPackageProviders($app)
{
return [
\YourVendor\YourPackage\YourPackageServiceProvider::class,
];
}
public function test_service_can_generate_report()
{
$service = $this->app->make(ReportingService::class);
$report = $service->generate(['type' => 'daily']);
$this->assertIsArray($report);
$this->assertArrayHasKey('data', $report);
}
public function test_config_is_merged()
{
$this->assertEquals('UTC', config('reporting.timezone'));
}
}
Testbench creates a minimal Laravel container for each test. You define which service providers to load via getPackageProviders(). You get access to the service container and all Laravel testing helpers (assertions, mocking, factories, etc.).
A typical package test suite includes:
- Unit tests for core service logic, independent of Laravel
- Integration tests using Testbench to verify the service provider, configuration merging, and container bindings
- Feature tests
Run tests locally with vendor/bin/phpunit. Use GitHub Actions (included in the skeleton) to run tests on every push, across multiple PHP versions and Laravel versions.
Publishing and consuming your package
Once your package is solid, publish it. Most teams use Packagist (the Composer package registry). Push your code to GitHub, then register it at packagist.org. Anyone can then install it:
composer require your-vendor/your-packageFor private packages, you can host your own Composer repository or use Packagist Private.
In the consuming application, after installation, the service provider is auto-discovered and Laravel bootstraps it automatically (thanks to composer.json's extra.laravel.providers entry). The user can then:
- Publish configuration if they need to customize it
- Publish migrations and run them
- Use your classes and services from the container
Version your package semantically: 1.0.0, 1.1.0 (feature), 1.0.1 (bugfix), 2.0.0 (breaking change). Document breaking changes in a CHANGELOG. Users can then update safely with composer update or composer require your-vendor/your-package:^1.1.
Common mistakes
- Forgetting to register auto-discovery in composer.json. Your package's composer.json must have an
extra.laravel.providersentry, or users must manually register the provider. The skeleton includes this by default. - Publishing config without using mergeConfigFrom. If you only publish, users who don't publish won't get defaults. Always merge defaults in register().
- Assuming a specific directory structure. Use
storage_path(),config_path(), anddatabase_path()rather than hardcoding paths. This respects the consuming app's structure. - Tight coupling to specific dependencies. Declare loose version constraints in composer.json. Allow users to upgrade dependencies without being blocked by your package.
- Insufficient testing across Laravel versions. Test against the Laravel versions your package claims to support. Use the skeleton's GitHub Actions workflow to automate this.
- Poor documentation of configuration options. Publish a config file with comments explaining every key. It's your user's first port of call when customizing behavior.
Key takeaways
- Use
spatie/package-skeleton-laravelas your starting point. It includes testing setup, directory structure, and GitHub Actions workflows. - Your service provider is the contract between your package and Laravel. Register services in
register(), publish assets inboot(). - Provide default configuration with
mergeConfigFrom()and let users override it withpublishes(). Do the same for migrations. - Test thoroughly using Orchestra Testbench. Run tests across multiple PHP and Laravel versions in CI.
- Version semantically and maintain a CHANGELOG. Users depend on clear versioning to upgrade safely.
- Document configuration options inline in the published config file. Make it easy for users to understand what they can customize.
- Avoid hard-coded paths. Respect the consuming app's directory structure using Laravel's path helpers.
- Keep the package focused. A package that does one thing well is easier to maintain and test than a kitchen-sink package.






Comments
Loading comments…