Development
Jun 6, 2025
12 min read
9 views

Laravel Package Development: From Idea to Packagist

Learn how to build, test, and publish Laravel packages that developers actually want to use. From project structure to distribution, here's everything I've learned about creating packages that solve real problems.

Laravel Package Development: From Idea to Packagist

Share this post

Creating Laravel packages has taught me more about good software design than almost any other activity. When you build a package, you're forced to think about clean APIs, clear documentation, and maintainable code because other developers will judge your work (sometimes harshly). Here's what I've learned about building packages that people actually want to use and contribute to.

The best packages solve specific problems elegantly. They have clear APIs, excellent documentation, and work reliably without surprising developers.

Finding Problems Worth Solving

Not every piece of code deserves to become a package. The most successful packages I've seen (and built) solve real problems that multiple developers face repeatedly.

Good Package Ideas

Worth Building

  • • Code you've copy-pasted across projects
  • • Solutions to framework limitations
  • • Integration with external services
  • • Developer experience improvements
  • • Performance optimizations

Think Twice

  • • Features Laravel already provides
  • • Highly specific business logic
  • • One-off utility functions
  • • Complex domain-specific tools
  • • Packages that already exist and work well

Package Structure That Scales

A well-organized package is easier to maintain, test, and contribute to. Here's the structure I use for most Laravel packages:

my-laravel-package/
├── .github/
│   ├── workflows/
│   │   └── tests.yml
│   └── ISSUE_TEMPLATE.md
├── config/
│   └── my-package.php
├── database/
│   └── migrations/
│       └── create_package_tables.php
├── resources/
│   ├── views/
│   └── lang/
├── src/
│   ├── Commands/
│   ├── Http/
│   │   ├── Controllers/
│   │   ├── Middleware/
│   │   └── Requests/
│   ├── Models/
│   ├── Services/
│   ├── Facades/
│   ├── MyPackageServiceProvider.php
│   └── MyPackage.php
├── tests/
│   ├── Feature/
│   ├── Unit/
│   └── TestCase.php
├── composer.json
├── phpunit.xml
├── README.md
├── CHANGELOG.md
└── LICENSE

Service Provider Foundation

The service provider is the heart of your package. Here's how I structure them:

class MyPackageServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__.'/../config/my-package.php',
            'my-package'
        );

        $this->app->singleton(MyPackage::class, function ($app) {
            return new MyPackage($app['config']['my-package']);
        });

        $this->app->alias(MyPackage::class, 'my-package');
    }

    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $this->publishes([
                __DIR__.'/../config/my-package.php' => config_path('my-package.php'),
            ], 'config');

            $this->publishes([
                __DIR__.'/../database/migrations' => database_path('migrations'),
            ], 'migrations');

            $this->commands([
                MyPackageCommand::class,
            ]);
        }

        $this->loadRoutesFrom(__DIR__.'/../routes/web.php');
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'my-package');
        $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'my-package');

        $this->publishes([
            __DIR__.'/../resources/views' => resource_path('views/vendor/my-package'),
        ], 'views');
    }
}

API Design That Developers Love

The public API of your package is the most important part. Once people start using it, changes become breaking changes that affect real applications.

Fluent Interface Example

class QueryBuilder
{
    protected array $wheres = [];
    protected array $orders = [];
    protected ?int $limit = null;

    public function where(string $column, mixed $value): self
    {
        $this->wheres[] = ['column' => $column, 'value' => $value];
        return $this;
    }

    public function orderBy(string $column, string $direction = 'asc'): self
    {
        $this->orders[] = ['column' => $column, 'direction' => $direction];
        return $this;
    }

    public function limit(int $limit): self
    {
        $this->limit = $limit;
        return $this;
    }

    public function get(): Collection
    {
        // Build and execute query
        return $this->buildQuery()->get();
    }
}

// Usage feels natural
$results = MyPackage::query()
    ->where('status', 'active')
    ->where('type', 'premium')
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->get();

Configuration Design

// config/my-package.php
return [
    // Essential settings with sensible defaults
    'enabled' => env('MY_PACKAGE_ENABLED', true),
    
    'api_key' => env('MY_PACKAGE_API_KEY'),
    
    'cache' => [
        'enabled' => env('MY_PACKAGE_CACHE_ENABLED', true),
        'ttl' => env('MY_PACKAGE_CACHE_TTL', 3600),
        'prefix' => env('MY_PACKAGE_CACHE_PREFIX', 'my_package'),
    ],
    
    'defaults' => [
        'timeout' => 30,
        'retry_attempts' => 3,
        'batch_size' => 100,
    ],
    
    // Advanced configuration for power users
    'advanced' => [
        'custom_handlers' => [],
        'middleware' => [],
        'transformers' => [],
    ],
];

Testing Strategy for Packages

Package testing is different from application testing. You need to test against multiple Laravel versions and ensure your package doesn't break existing functionality.

Test Environment Setup

// tests/TestCase.php
abstract class TestCase extends Orchestra\Testbench\TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        
        $this->loadLaravelMigrations();
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
    }

    protected function getPackageProviders($app): array
    {
        return [MyPackageServiceProvider::class];
    }

    protected function getPackageAliases($app): array
    {
        return [
            'MyPackage' => MyPackageFacade::class,
        ];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testbench');
        $app['config']->set('database.connections.testbench', [
            'driver' => 'sqlite',
            'database' => ':memory:',
            'prefix' => '',
        ]);
        
        $app['config']->set('my-package.api_key', 'test-key');
    }
}

Feature Testing

class PackageIntegrationTest extends TestCase
{
    /** @test */
    public function it_can_be_configured_via_config_file()
    {
        config(['my-package.api_key' => 'custom-key']);
        
        $package = app(MyPackage::class);
        
        $this->assertEquals('custom-key', $package->getApiKey());
    }

    /** @test */
    public function it_registers_commands()
    {
        $commands = Artisan::all();
        
        $this->assertArrayHasKey('my-package:install', $commands);
        $this->assertArrayHasKey('my-package:sync', $commands);
    }

    /** @test */
    public function it_can_publish_config()
    {
        $this->artisan('vendor:publish', [
            '--provider' => MyPackageServiceProvider::class,
            '--tag' => 'config'
        ]);

        $this->assertFileExists(config_path('my-package.php'));
    }

    /** @test */
    public function it_provides_facade_access()
    {
        $result = MyPackage::query()->where('test', 'value')->get();
        
        $this->assertInstanceOf(Collection::class, $result);
    }
}

Documentation That Actually Helps

Good documentation can make or break a package. I spend almost as much time on docs as I do on code.

README Structure

Essential README Sections

  • One-line description: What does this package do?
  • Installation: Composer install + setup steps
  • Quick start: 5-minute example that works
  • Configuration: Available options explained
  • Usage examples: Common use cases with code
  • API reference: All public methods documented
  • Contributing: How others can help
  • License: Clear licensing terms

Effective Code Examples

## Quick Start

1. Install the package:
```bash
composer require yourname/my-package
```

2. Publish the config file:
```bash
php artisan vendor:publish --provider="YourName\MyPackage\MyPackageServiceProvider"
```

3. Add your API key to `.env`:
```
MY_PACKAGE_API_KEY=your-api-key-here
```

4. Use it in your code:
```php
use YourName\MyPackage\Facades\MyPackage;

// Simple usage
$results = MyPackage::query()
    ->where('status', 'active')
    ->get();

// With options
$results = MyPackage::query()
    ->where('type', 'premium')
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->get();
```

That's it! You're ready to start using the package.

Continuous Integration Setup

Automated testing across PHP and Laravel versions helps ensure your package works reliably for all users.

GitHub Actions Configuration

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      fail-fast: false
      matrix:
        php: [8.1, 8.2, 8.3]
        laravel: [9.*, 10.*, 11.*]
        stability: [prefer-lowest, prefer-stable]
        include:
          - laravel: 9.*
            testbench: 7.*
          - laravel: 10.*
            testbench: 8.*
          - laravel: 11.*
            testbench: 9.*

    steps:
    - uses: actions/checkout@v3

    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php }}
        extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
        coverage: xdebug

    - name: Install dependencies
      run: |
        composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
        composer update --${{ matrix.stability }} --prefer-dist --no-interaction

    - name: Run tests
      run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.clover

Publishing to Packagist

Composer.json Essentials

{
    "name": "yourname/my-package",
    "description": "A brief, clear description of what your package does",
    "keywords": ["laravel", "package", "relevant", "tags"],
    "type": "library",
    "license": "MIT",
    "authors": [
        {
            "name": "Your Name",
            "email": "your.email@example.com"
        }
    ],
    "require": {
        "php": "^8.1",
        "illuminate/support": "^9.0|^10.0|^11.0"
    },
    "require-dev": {
        "orchestra/testbench": "^7.0|^8.0|^9.0",
        "phpunit/phpunit": "^9.0|^10.0"
    },
    "autoload": {
        "psr-4": {
            "YourName\\MyPackage\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "YourName\\MyPackage\\Tests\\": "tests/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "YourName\\MyPackage\\MyPackageServiceProvider"
            ],
            "aliases": {
                "MyPackage": "YourName\\MyPackage\\Facades\\MyPackage"
            }
        }
    },
    "scripts": {
        "test": "vendor/bin/phpunit",
        "test-coverage": "vendor/bin/phpunit --coverage-html coverage"
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}

Version Tagging Strategy

Semantic Versioning

  • 1.0.0: Initial stable release
  • 1.1.0: New features, backward compatible
  • 1.0.1: Bug fixes, backward compatible
  • 2.0.0: Breaking changes

Maintenance and Community

Issue Management

Good Practices

Use issue templates: Guide users to provide useful information
Label consistently: bug, enhancement, question, help wanted
Respond quickly: Even if just to acknowledge the issue
Close promptly: Don't let old issues pile up

Pull Request Guidelines

Require tests: No new code without test coverage
Check CI status: All tests must pass
Review thoroughly: Code quality matters
Update docs: Keep documentation current

Common Pitfalls to Avoid

Breaking Changes Without Notice

Always follow semantic versioning and clearly document breaking changes in your changelog.

Poor Error Messages

Provide clear, actionable error messages that help developers understand what went wrong and how to fix it.

Overcomplicating the API

Start simple and add complexity only when needed. The best packages have clean, intuitive APIs.

Insufficient Testing

Test against multiple Laravel versions and edge cases. Your users depend on reliability.

Real Package Example

Here's a simplified example of a package that adds structured logging capabilities to Laravel:

// src/StructuredLogger.php
class StructuredLogger
{
    public function __construct(
        private LoggerInterface $logger,
        private array $config
    ) {}

    public function logUserAction(User $user, string $action, array $context = []): void
    {
        $this->logger->info('User action performed', [
            'user_id' => $user->id,
            'user_email' => $user->email,
            'action' => $action,
            'timestamp' => now()->toISOString(),
            'context' => $context,
            'session_id' => session()->getId(),
            'ip_address' => request()->ip(),
        ]);
    }

    public function logApiRequest(Request $request, Response $response): void
    {
        $this->logger->info('API request processed', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'status_code' => $response->getStatusCode(),
            'response_time' => $this->getResponseTime(),
            'user_id' => $request->user()?->id,
            'ip_address' => $request->ip(),
        ]);
    }
}

// Usage
StructuredLogger::logUserAction($user, 'post_created', ['post_id' => $post->id]);
StructuredLogger::logApiRequest($request, $response);

The Package Lifecycle

Building a successful package is just the beginning. Maintaining it, supporting users, and evolving it over time is where the real work happens. The most rewarding part is seeing other developers solve problems with something you built.

Remember that every popular Laravel package started as someone's side project. Focus on solving real problems well, write clear documentation, and be responsive to your community. The ecosystem will reward quality work.

🎯 Package Success Factors

Solve a real problem, provide a clean API, write excellent documentation, test thoroughly, and support your users. These fundamentals never go out of style.

The Laravel community is incredibly supportive of quality packages. If you build something useful and maintain it well, you'll find contributors and users who help it grow.

Working on a Laravel package or thinking about creating one? I'd love to hear about what you're building and any challenges you're facing. Package development is one of the best ways to level up your Laravel skills and contribute to the community.

Chris Page

Chris Page

Fractional CTO and Software Engineer with 25+ years of experience. I help startups scale from 0 to 7 figures using AI-assisted development and proven frameworks.

Related Posts

Continue reading

Ready to Scale Your Startup?

Let's discuss how I can help you build your MVP or optimize your existing technology stack.