+1 (415) 599-8902

Building and Publishing a Laravel Package: Service Providers, Testbench, Pest and Semantic Versioning

Most Laravel teams reach the same point eventually: the same audit-log trait, the same tenant-aware cache helper, or the same client API wrapper has now been copy-pasted into three applications and the copies have drifted. The fix is a package. This tutorial takes one from composer init to a tagged release — with a service provider, config, migrations, Blade components, a test suite that runs against several Laravel versions, and a release process that will not break your clients' applications.

Everything here assumes PHP 8.3+ and a package targeting Laravel 12 and 13.

Scaffold the package

Keep the package in its own repository. The common skeleton:

src/
  AuditLogServiceProvider.php
  AuditLogManager.php
  Facades/AuditLog.php
config/audit-log.php
database/migrations/create_audit_logs_table.php.stub
resources/views/components/audit-trail.blade.php
tests/
composer.json

The composer.json is where most of the real decisions live:

{
    "name": "polishandpixel/audit-log",
    "description": "Append-only audit logging for Laravel models.",
    "license": "MIT",
    "require": {
        "php": "^8.3",
        "illuminate/contracts": "^12.0|^13.0"
    },
    "require-dev": {
        "orchestra/testbench": "^10.0|^11.0",
        "pestphp/pest": "^4.0",
        "larastan/larastan": "^3.0",
        "laravel/pint": "^1.0"
    },
    "autoload": {
        "psr-4": { "PolishAndPixel\\AuditLog\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "PolishAndPixel\\AuditLog\\Tests\\": "tests/" }
    },
    "extra": {
        "laravel": {
            "providers": ["PolishAndPixel\\AuditLog\\AuditLogServiceProvider"],
            "aliases": { "AuditLog": "PolishAndPixel\\AuditLog\\Facades\\AuditLog" }
        }
    },
    "config": { "sort-packages": true },
    "minimum-stability": "dev",
    "prefer-stable": true
}

Two details worth arguing about:

  • Depend on illuminate/contracts, not laravel/framework. A package that requires the whole framework forces a full framework install on anyone who uses it and makes version constraints harder to satisfy. Pull in individual illuminate/* components only when you genuinely need them.
  • Use an | range for the framework version, not ^13.0. Supporting the current release and the previous one is what lets your users upgrade their application before they upgrade every package.

The extra.laravel block is package auto-discovery: Composer writes it into the application's cached manifest, so nobody has to register your provider by hand.

The service provider

A service provider has two halves, and mixing them up is the classic package bug.

<?php

namespace PolishAndPixel\AuditLog;

use Illuminate\Support\ServiceProvider;

class AuditLogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bindings only. No config() reads, no routes, no facades.
        $this->mergeConfigFrom(__DIR__.'/../config/audit-log.php', 'audit-log');

        $this->app->singleton(AuditLogManager::class, function ($app) {
            return new AuditLogManager($app['config']['audit-log']);
        });
    }

    public function boot(): void
    {
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'audit-log');
        $this->loadTranslationsFrom(__DIR__.'/../lang', 'audit-log');

        if ($this->app->runningInConsole()) {
            $this->publishes([
                __DIR__.'/../config/audit-log.php' => config_path('audit-log.php'),
            ], 'audit-log-config');

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

            $this->commands([Console\PruneAuditLogs::class]);
        }
    }
}

register() runs for every provider before any boot() runs. Resolving a service out of the container in register() — or reading config that another provider has not merged yet — produces failures that only appear in some applications, in some orders. Keep register() to bindings.

mergeConfigFrom means a user who publishes your config and then upgrades still gets sensible defaults for new keys. It only merges the top level, so keep config arrays shallow or merge nested defaults yourself.

Migrations: publish or load?

Two valid strategies.

  • Publish (publishesMigrations) when the table belongs to the user's domain and they may want to edit it. Ship the files with .php.stub extensions in database/migrations; Laravel timestamps them on publish.
  • Load (loadMigrationsFrom) when the table is purely your package's business and you will maintain its schema across versions.

Pick one and document it. Doing both causes duplicate-table errors.

Make it configurable, not rigid

The quickest way to make a package unusable is to hard-code a model, a table name, or a guard. Resolve them through config:

return [
    'table' => 'audit_logs',
    'model' => \PolishAndPixel\AuditLog\Models\AuditLog::class,
    'queue' => env('AUDIT_LOG_QUEUE', 'default'),
    'retention_days' => 365,
    'redact' => ['password', 'password_confirmation', 'remember_token'],
];

And never call env() outside config files. Once the consuming application runs php artisan config:cache, every env() call in your package code returns null — in production, where you will find out about it from a customer.

Test against real Laravel versions with Testbench

Orchestra Testbench boots a minimal Laravel application around your package, so your tests exercise the real container, router, and database.

<?php

namespace PolishAndPixel\AuditLog\Tests;

use Orchestra\Testbench\TestCase as Orchestra;
use PolishAndPixel\AuditLog\AuditLogServiceProvider;

abstract class TestCase extends Orchestra
{
    protected function getPackageProviders($app): array
    {
        return [AuditLogServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testing');
        $app['config']->set('audit-log.retention_days', 30);
    }

    protected function defineDatabaseMigrations(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
    }
}

With Pest, point tests/Pest.php at that base class and write ordinary feature tests:

uses(PolishAndPixel\AuditLog\Tests\TestCase::class)->in(__DIR__);

it('records an entry when a model changes', function () {
    $post = Post::factory()->create();

    $post->update(['title' => 'Renamed']);

    expect(AuditLog::for($post)->latest()->first())
        ->event->toBe('updated')
        ->changes->toHaveKey('title');
});

it('redacts configured attributes', function () {
    $user = User::factory()->create();

    $user->update(['password' => 'hunter2']);

    expect(AuditLog::for($user)->latest()->first()->changes)
        ->not->toHaveKey('password');
});

Testbench can also generate a real skeleton application for manual poking:

vendor/bin/testbench workbench:build
vendor/bin/testbench serve

Workbench is the right place to put demo routes, views, and seeders that you want for development but not in the shipped package.

Run the matrix in CI

The matrix is the point. A package that is only ever tested on the maintainer's PHP version and the newest Laravel is a package that quietly breaks for half its users.

name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.3', '8.4']
        laravel: ['12.*', '13.*']
        stability: [prefer-lowest, prefer-stable]

    name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} / ${{ matrix.stability }}

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: none
      - run: composer require "illuminate/contracts:${{ matrix.laravel }}" --no-interaction --no-update
      - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
      - run: vendor/bin/pest

prefer-lowest is the unglamorous hero here: it installs the oldest dependency versions your constraints allow and catches the case where you used a method that only exists in a newer patch release than you declared.

Add Pint and Larastan as separate jobs so a formatting nit does not mask a real failure.

Release like someone depends on you

Publish on Packagist, enable the GitHub webhook so new tags appear automatically, and then take semantic versioning seriously:

  • Patch (1.2.0 → 1.2.1): bug fixes, no signature changes.
  • Minor (1.2.1 → 1.3.0): new features, new optional config keys, new methods. Existing code must keep working untouched.
  • Major (1.3.0 → 2.0.0): anything else — renamed methods, changed return types, a new required config key, dropping a Laravel version, raising the minimum PHP.

Things that look harmless but are breaking changes: adding a required parameter to a public method, changing a published migration (users already ran the old one), tightening a validation rule, and renaming a config key without a fallback.

Keep a CHANGELOG.md in Keep a Changelog format and an UPGRADING.md for every major. Mark internal classes @internal so you are free to change them. If you must remove a public method, deprecate it for one minor release first:

/**
 * @deprecated 1.4.0 Use AuditLog::for($model) instead. Removed in 2.0.
 */
public function forModel(Model $model): Builder
{
    trigger_deprecation('polishandpixel/audit-log', '1.4.0', 'Use for() instead.');

    return $this->for($model);
}

A short pre-release checklist

  • README with install, config publish, and a copy-pasteable usage example in the first screen.
  • Licence file present and matching composer.json.
  • No dd(), no dump(), no commented-out debugging.
  • register() free of container resolution and config reads.
  • Everything publishable has a tag name, and tag names are prefixed with the package name.
  • Tests green on every cell of the matrix, including prefer-lowest.
  • Version constraints allow the next Laravel release only after you have actually tested it.

Extracting shared code into a well-tested internal package is usually the cheapest reliability win available to a team running several Laravel applications. If you want help carving a package out of an existing codebase, or an audit of one you already maintain, see our Laravel module and package development service or get in touch.