Extract a Package from Your Laravel App: The Complete Guide

Pull a folder of classes out of a live Laravel app and turn it into a tested, versioned Composer package using path repositories, Testbench and a safe cutover.

Steven Richardson
Steven Richardson
· 19 min read

Every agency ends up with the same artefact: an app/Support/ directory copy-pasted into four applications, drifting slightly in each one. Everyone agrees it should be a package. Nobody does it, because the first attempt hits a wall — the namespace breaks, the tests fail because there's no app() to call, the config vanishes, and you're running composer update after every single edit to find out whether it worked.

Every guide I've read starts from an empty skeleton. That isn't the problem anyone actually has. This one starts from a live application and walks the whole extraction: deciding what can leave, finding the hidden ties, building the package in place with a path repository, testing it with the application deleted, shipping it privately, and migrating the app onto it without a big-bang cutover.

The running example is an internal audit-logging helper — App\Support\Audit — becoming acme/audit. Substitute your own folder as you go.

Decide what to extract#

Start with a boundary check, not a file move. The test that matters is coupling, not size: a chunk of code that references your application's models, reads your application's config keys, or fires your application's events is not a package yet. It is application code that happens to live in a tidy folder. Run this check before you move a single file, because every coupling you miss becomes a surprise two steps later when the tests won't boot.

Three questions, in order:

  1. Could a different application use this, unchanged? If the answer needs a "well, if they also had a Tenant model…", it isn't ready.
  2. Does it know anything about your domain? Grep the folder for App\. Every hit is a decision you owe yourself.
  3. Would you version it independently? If the only reason to cut a release is "the app changed", the boundary is in the wrong place.

The cheapest way to make this permanent is to encode the boundary as a test. Pest's architecture testing does exactly that, and I'd rather fail CI than rediscover a stray import six months later:

// tests/Architecture/AuditBoundaryTest.php
arch('audit support has no application dependencies')
    ->expect('App\Support\Audit')
    ->not->toUse([
        'App\Models',
        'App\Events',
        'App\Http',
    ]);

Add that before you extract and fix the failures first. If you've not used arch tests before, the guide to Pest architecture testing in Laravel covers the ruleset in full. The extraction gets dramatically easier when the boundary is already green.

Run the dependency archaeology#

Now find the ties the arch test didn't catch. Static imports are the easy half; the hard half is the runtime coupling Laravel makes so convenient — helper functions, facades, container bindings and config reads that never appear in a use statement. Walk the folder with grep and turn every hit into one of three decisions: inject it, publish it as config, or declare it as a contract the host application must bind.

cd app/Support/Audit

grep -rn "config(" .          # config keys that must become package config
grep -rn "env(" .             # must die — env() returns null under config caching
grep -rn "auth()\|request()" . # request-scoped helpers: inject instead
grep -rn "^use App\\\\" .     # application classes: contract or delete
grep -rn "Storage::\|Log::\|Event::" . # facades: fine, but note the required bindings

Each category has one correct answer:

What you find What you do
config('audit.retention') Becomes config('audit.retention') in your package config, merged with a default
env('AUDIT_DRIVER') Replace with config('audit.driver'), default env() only inside the config file
App\Models\User typehint Replace with Illuminate\Contracts\Auth\Authenticatable or your own interface
auth()->id() Inject Illuminate\Contracts\Auth\Guard through the constructor
Storage::disk('s3') Keep the facade, but make the disk name a config value

The env() row is not negotiable. A package that calls env() at runtime returns null the moment the consuming application runs php artisan config:cache, which every production deployment does. env() belongs in config files and nowhere else.

While you're in there, check what the package will actually need from Composer. Running a dependency audit across the application tells you which third-party libraries the extracted code genuinely touches, and an unused-dependency analysis stops you copying the entire app's require block into the package out of caution.

Scaffold the package inside the application#

Build the package where you can see it — inside the application repository, at packages/acme/audit. You are not creating a second repository yet. Keeping it in place means the application's own test suite keeps running against the real code while you move it, and you can delete the folder and start over if the boundary turns out to be wrong.

mkdir -p packages/acme/audit/{src,config,database/migrations,tests}
git mv app/Support/Audit/* packages/acme/audit/src/

The composer.json is where most extractions go wrong, so here it is in full:

{
    "name": "acme/audit",
    "description": "Append-only audit logging for Laravel applications",
    "type": "library",
    "license": "proprietary",
    "require": {
        "php": "^8.4",
        "illuminate/support": "^12.0|^13.0",
        "illuminate/database": "^12.0|^13.0"
    },
    "require-dev": {
        "orchestra/testbench": "^10.0|^11.0",
        "pestphp/pest": "^4.0",
        "larastan/larastan": "^3.0"
    },
    "autoload": {
        "psr-4": {
            "Acme\\Audit\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Acme\\Audit\\Tests\\": "tests/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "Acme\\Audit\\AuditServiceProvider"
            ]
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}

Two details carry real weight. First, require the individual illuminate/* components you use, never laravel/framework — requiring the framework from a package drags the entire monolith into every consumer's dependency graph and makes version conflicts almost guaranteed. Second, the constraint is ^12.0|^13.0, not a pinned ^13.0. A package that only supports the newest Laravel is a package nobody can adopt, because the applications that most need it are a major version behind.

The extra.laravel.providers block is what powers package auto-discovery: consumers never edit a providers array, Laravel reads this key during composer install and registers the provider for them.

Then rename the namespace across the moved files:

grep -rl 'App\\Support\\Audit' packages/acme/audit/src app tests \
  | xargs sed -i '' 's/App\\Support\\Audit/Acme\\Audit/g'

Wire up the path repository loop#

This is the mechanic the whole guide turns on. A Composer path repository points at a directory on disk and symlinks it into vendor/, so the application loads the package's real source files. Edit the package, refresh the page, see the change. No tagging, no pushing, no composer update between edits. Without this, extraction is miserable; with it, the package behaves exactly like the app/ folder it used to be.

Add this to the application's composer.json:

{
    "repositories": [
        {
            "type": "path",
            "url": "packages/acme/audit",
            "options": {
                "symlink": true
            }
        }
    ],
    "require": {
        "acme/audit": "@dev"
    }
}
composer update acme/audit --no-scripts
ls -l vendor/acme/audit   # → symlink to ../../packages/acme/audit

Four things worth knowing before this bites you, all documented in the Composer repositories reference:

  • Use @dev, not a stable constraint. Composer only finds branch versions in a path repository. Ask for ^1.0 and it may skip the local copy and go looking on Packagist — or fail to resolve at all.
  • Order matters. Repositories are searched top to bottom and Composer stops at the first match. If the package name already exists on a private Packagist, the path entry must come first.
  • The lock file records the path. composer.lock will contain a dist reference pointing at your local directory. That's fine while the package lives in the same repository; it becomes a problem the moment the package moves out and a colleague clones without the folder. Which brings us to the real rule:
  • The path repository is a development convenience, not a deployment strategy. Production installs the package from a VCS or Packagist entry. Keep the path block in the application's composer.json only while the package lives inside the application repository, and drop it the moment you split it out.

On Windows, symlink: true quietly degrades to a junction or a full copy depending on the host. If a colleague on Windows reports that their edits aren't taking effect, that's why — they need composer update acme/audit after each change.

Write the service provider#

The service provider is the package's entire contract with Laravel, and the order of operations inside it is not stylistic. register() runs while the container is still assembling; no other provider is guaranteed to have booted yet, so touching config, routes, events or anything resolved from another package there will work on your machine and explode in somebody else's application. Bindings and mergeConfigFrom() in register(). Everything else in boot().

<?php

namespace Acme\Audit;

use Acme\Audit\Contracts\AuditRecorder;
use Acme\Audit\Recorders\DatabaseRecorder;
use Illuminate\Support\ServiceProvider;

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

        $this->app->singleton(AuditRecorder::class, function ($app) {
            return new DatabaseRecorder(
                connection: $app['db']->connection(config('audit.connection')),
                table: config('audit.table'),
            );
        });
    }

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

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

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

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

mergeConfigFrom() performs a shallow merge. If your config has a nested drivers array and the consumer publishes a copy with only one driver defined, the top-level drivers key is replaced wholesale, not merged key by key. Keep package config flat, or document the nesting loudly.

Note the binding is against an interface, AuditRecorder, not a concrete class. That one decision is what lets a consuming application swap in its own implementation without forking you, and it's what makes the package testable in their suite as well as yours.

Publish config, migrations and views#

Publishing is how consumers take ownership of parts of your package, and each resource type has a different correct default. Config should be publishable but optional — mergeConfigFrom() already supplies defaults, so a consumer who publishes nothing still gets a working package. Migrations are the opposite: loadMigrationsFrom() runs them straight from the package, which is right for most packages and wrong for any package whose schema consumers will want to customise.

php artisan vendor:publish --tag=audit-config
php artisan vendor:publish --tag=audit-migrations

Tag everything. A consumer who runs vendor:publish --provider="Acme\Audit\AuditServiceProvider" and gets eight files they didn't want will not thank you.

The migration itself must assume nothing about the host schema:

// database/migrations/2026_10_02_000000_create_audit_log_table.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create(config('audit.table', 'audit_log'), function (Blueprint $table) {
            $table->id();
            $table->string('actor_type')->nullable();
            $table->string('actor_id')->nullable();   // string, not foreignId
            $table->string('event');
            $table->json('payload')->nullable();
            $table->timestamp('occurred_at')->index();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists(config('audit.table', 'audit_log'));
    }
};

No foreignId('user_id')->constrained(). A package migration that assumes a users table with a bigint primary key will break on every application using UUIDs, a non-standard table name, or no users table at all. Store the actor polymorphically as strings and let the consumer resolve it.

Test the package without the application#

This is the step that proves the extraction worked. Orchestra Testbench boots a minimal, throwaway Laravel application inside your package's test suite — a real container, real config, a real database connection — so you can call app(), run migrations and hit the container exactly as you would in an application test. When the package's suite goes green with your application deleted from the machine, the extraction is genuinely finished. Until then, you have a folder with a composer.json.

Testbench 11 targets Laravel 13; Testbench 10 targets Laravel 12. Allow both, matching the framework constraint you set earlier.

<?php
// tests/TestCase.php

namespace Acme\Audit\Tests;

use Acme\Audit\AuditServiceProvider;
use Illuminate\Foundation\Application;
use Orchestra\Testbench\TestCase as Orchestra;

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

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testing');
        $app['config']->set('database.connections.testing', [
            'driver' => 'sqlite',
            'database' => ':memory:',
            'prefix' => '',
        ]);

        $app['config']->set('audit.table', 'audit_log');
    }

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

Wire Pest to it in tests/Pest.php:

<?php

uses(Acme\Audit\Tests\TestCase::class)->in(__DIR__);

Then two tests — one pure unit test with no framework involved, one that actually hits the migrated table:

<?php
// tests/Unit/PayloadRedactionTest.php

use Acme\Audit\Support\PayloadRedactor;

it('redacts configured sensitive keys', function () {
    $redacted = (new PayloadRedactor(['password', 'token']))
        ->redact(['email' => 'a@b.com', 'password' => 'hunter2']);

    expect($redacted)->toBe(['email' => 'a@b.com', 'password' => '[redacted]']);
});
<?php
// tests/Feature/DatabaseRecorderTest.php

use Acme\Audit\Contracts\AuditRecorder;
use Illuminate\Support\Facades\DB;

it('writes an audit row through the container binding', function () {
    app(AuditRecorder::class)->record('user.updated', ['id' => 7]);

    expect(DB::table('audit_log')->count())->toBe(1);

    expect(DB::table('audit_log')->first())
        ->event->toBe('user.updated');
});
cd packages/acme/audit
composer install
vendor/bin/pest

If app(AuditRecorder::class) throws a binding resolution exception here, your register() method is doing something it shouldn't — nine times out of ten it's reading config that hasn't been merged yet, or depending on another provider's boot.

Run CI across PHP and Laravel versions#

A package that supports two Laravel majors and two PHP minors needs to be tested against all of them, and a package is exactly where this matters most: the application's CI only ever proves the one combination that application happens to run. Add a matrix with a prefer-lowest leg, because the single most common package bug is using a method that only exists in the newest patch of your lowest supported constraint.

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

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php: ['8.4', '8.5']
        laravel: ['12.*', '13.*']
        dependency-version: [prefer-stable]
        include:
          - php: '8.4'
            laravel: '12.*'
            dependency-version: prefer-lowest

    name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} / ${{ matrix.dependency-version }}

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: none

      - name: Install dependencies
        run: |
          composer require "illuminate/support:${{ matrix.laravel }}" --no-interaction --no-update
          composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction

      - name: Run tests
        run: vendor/bin/pest

The composer require --no-update then composer update pairing is the trick that makes the matrix work: it rewrites the constraint in memory before resolution, so each leg genuinely resolves a different Laravel. I won't re-teach matrix mechanics here — the full breakdown of GitHub Actions matrix testing for Laravel covers service containers, database legs and fail-fast in depth, and caching Composer and npm in Laravel CI will take a six-leg matrix from slow to tolerable.

Run static analysis inside the package#

Static analysis behaves differently inside a package, and the difference catches people out. In an application, Larastan reads your container bindings, your models and your config to infer types. A package has none of that — no booted application, no models, no config/ directory at analysis time — so the generous inference you got in the app disappears, and the level you were comfortable at in the application is not the level you'll pass at here.

composer require --dev larastan/larastan:^3.0
# phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    level: 8
    paths:
        - src
    treatPhpDocTypesAsCertain: false
vendor/bin/phpstan analyse

Start at level 8 for a freshly extracted package and climb. The errors you'll hit first are almost always the same handful — Illuminate\Support\Collection generics, facade return types, and container make() calls that PHPStan can't resolve — all of which are covered in PHPStan level 10 on Laravel and the errors you'll actually hit. If the extracted code arrives with hundreds of existing violations, don't block the extraction on fixing them; generate a baseline and work it down afterwards using the approach in burning down a PHPStan baseline on legacy Laravel.

Keep the formatting and analysis entrypoint identical to the application's so nobody has to learn two commands — running Pint, PHPStan and Rector behind one command works just as well in a package as it does in an app.

Publish the package privately or publicly#

Now split the directory into its own Git repository and pick a distribution route. There are three, and the right one depends entirely on how many applications and how many developers need to install it. Be honest about the trade-offs — I've used all three and the cheapest option is not always the cheapest option.

Private VCS repository. Zero infrastructure, zero cost. Add a vcs entry to each consuming application:

{
    "repositories": [
        {
            "type": "vcs",
            "url": "git@github.com:acme/audit.git"
        }
    ],
    "require": {
        "acme/audit": "^1.0"
    }
}

Composer resolves tags straight from the repository. The cost is authentication: every CI runner, every deploy target and every developer machine needs credentials. In CI, use a read-only deploy key or a token via COMPOSER_AUTH:

      - name: Authenticate Composer
        env:
          COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.COMPOSER_TOKEN }}"}}'
        run: composer install --no-interaction --prefer-dist

It also scales badly: resolution does a VCS fetch per repository, so ten private packages make composer update noticeably slow.

Private Packagist. Paid, and worth it past roughly three private packages or two consuming teams. It mirrors your repositories behind a single Composer registry, so each application gets one repositories entry and one credential, and resolution is as fast as public Packagist. Authentication is the same COMPOSER_AUTH mechanism with a http-basic entry instead.

Public Packagist. Free and frictionless, and the correct answer more often than teams assume. If the package contains no business logic — and if you did the boundary check properly, it shouldn't — publishing it publicly removes the entire authentication problem and costs you nothing. Submit the repository at packagist.org and add a GitHub webhook so tags sync automatically.

Whichever route you choose, remember that dev-main in a production composer.json is not a version. It's a promise that your next merge to main deploys itself to every application that runs composer update.

Version the package and cut the application over#

Tag 0.x while the API is still moving — Semantic Versioning explicitly allows anything to change before 1.0, and reaching for 1.0.0 on day one just means a 2.0.0 by Friday. Once you tag 1.0, be precise about what counts as breaking, because a Laravel package's public API is wider than its PHP signatures: a published config key is public API. A container binding is public API. A migration's column names are public API. A published view is public API. Rename any of those and you owe consumers a major version.

Then migrate the application in three separate pull requests. Never one.

PR 1 — install alongside. Add the VCS or Packagist entry, require ^1.0, delete the path repository block. Both the old App\Support\Audit namespace and the new Acme\Audit package are installed. Nothing uses the package yet. CI proves the install resolves on every environment.

PR 2 — alias and switch. Point the old namespace at the new one so nothing has to change at every call site at once:

// app/Support/Audit/AuditRecorder.php — temporary shim
namespace App\Support\Audit;

class_alias(\Acme\Audit\Contracts\AuditRecorder::class, AuditRecorder::class);

Better still, skip the alias and change imports mechanically — it's a find-and-replace and it's reviewable:

grep -rl 'App\\Support\\Audit' app tests \
  | xargs sed -i '' 's/App\\Support\\Audit/Acme\\Audit/g'

Run the application's full suite. This PR should contain nothing but import changes.

PR 3 — delete. Remove app/Support/Audit/ entirely. This PR is pure deletion and should be a diff of -400 / +0. If it isn't, something in PR 2 was missed and you now know exactly what.

Splitting it this way means each PR has one failure mode and any one of them reverts cleanly. A combined PR that installs, migrates and deletes has three failure modes tangled together, and a revert takes the whole thing out including the install that was fine.

Avoid the mistakes that break extracted packages#

These are the ones I see repeatedly, in roughly the order they bite. Check each before your first tag — all five are cheap to fix now and expensive to fix after consumers have installed.

Requiring laravel/framework. Require the illuminate/* components you actually use. Requiring the framework pulls the monolith into every consumer's graph and all but guarantees a version conflict.

A migration that assumes users. foreignId('user_id')->constrained() breaks on UUID primary keys, renamed tables and applications with no users table. Store identifiers as strings and let the consumer resolve them.

env() at runtime. Returns null under config:cache, which every production deploy runs. The symptom is maddening: works locally, works in CI, silently returns defaults in production. env() only ever belongs inside a config file.

vendor:publish --force in a deploy script. It overwrites the consumer's published config on every deploy, silently reverting their overrides. Publishing is a one-time, human action.

Pinning the Laravel constraint. "illuminate/support": "^13.0" locks out every application that hasn't upgraded yet — which is most of them. Support two majors and drop the old one on a major release of your own. If dependency upgrades across your estate feel unmanageable, automating them with Renovate makes a two-major support window much less painful to hold.

And one that isn't a mistake so much as a trap: a package Facade. A facade is a genuine convenience when it fronts a stateless service with a stable API. It's a smell when it exists because the underlying class is awkward to inject — in that case the facade is hiding a testability problem rather than solving one. Ship the interface binding first; add the facade only if consumers ask for it.

Wrap up and keep the package healthy#

You've gone from a copy-pasted app/Support/ folder to a versioned Composer package with its own test suite, a CI matrix across two PHP and two Laravel majors, static analysis, a distribution route and a three-PR cutover that left the application working at every step. The path repository carried you through development; Testbench proved the package stands alone; the three-PR migration meant nothing was ever half-moved.

Two things to do next. Put the package on the same upgrade cadence as everything else — a package nobody updates rots faster than application code, and automated dependency updates with Renovate applied to the package repository keeps the matrix honest. And when the next Laravel major lands, the package is the thing you upgrade first, since every consuming application is blocked behind it; the Laravel 12 to 13 upgrade guide is the order I work through. If you're standardising this across a team, the 2026 Laravel developer toolchain covers the surrounding tooling the package repository should inherit from day one.

Do the next extraction straight after this one. The second is roughly a third of the work, because you now have a skeleton, a Testbench base class and a CI workflow to copy.

FAQ#

How do I extract a package from an existing Laravel application?

Run a boundary check first — the code must not reference your models, config keys or events — then move the folder to packages/vendor/name inside the same repository, add a composer.json with PSR-4 autoloading and an extra.laravel.providers entry, and wire it into the application with a Composer path repository so you can keep editing it in place. Once its Testbench suite passes without the application, split it into its own Git repository and migrate the application onto it across three separate pull requests.

What is a Composer path repository and how do I use it for local package development?

A path repository tells Composer to resolve a package from a directory on disk rather than a registry. Add {"type": "path", "url": "packages/acme/audit", "options": {"symlink": true}} to your repositories array and require the package as "@dev"; Composer symlinks the folder into vendor/, so edits to the package take effect immediately with no composer update. It is a development convenience only — production should install the package from a VCS entry or Packagist.

How do I test a Laravel package without a Laravel application?

Use Orchestra Testbench, which boots a minimal throwaway Laravel application inside your package's test suite. Extend Orchestra\Testbench\TestCase, return your provider from getPackageProviders(), set config in defineEnvironment() and load your migrations in defineDatabaseMigrations(). You then get a real container, config and database to test against — and if the suite passes with the original application deleted from your machine, the extraction is genuinely complete.

How do I publish config and migrations from a Laravel package?

Call mergeConfigFrom() in register() so the package works with no publishing at all, then add publishes() calls inside boot() guarded by runningInConsole(), each with its own tag. Migrations can either be loaded directly with loadMigrationsFrom() — right for most packages — or published so consumers can edit them. Always tag publishable groups separately so a consumer can take the config without also taking eight files they didn't want.

How does Laravel package auto-discovery work?

Laravel reads the extra.laravel key in your package's composer.json during composer install and composer update, and registers any listed service providers and facade aliases automatically. Consumers never edit a providers array. A consumer can opt out of a specific package with the extra.laravel.dont-discover key in their own composer.json, which is why your provider should still be registerable manually.

Can I use a private Composer package without publishing to Packagist?

Yes, by two routes. The free one adds a {"type": "vcs", "url": "git@github.com:acme/audit.git"} entry to each consuming application and authenticates every machine and CI runner with a deploy key or token via COMPOSER_AUTH. The paid one, Private Packagist, mirrors your repositories behind a single registry so each application needs one entry and one credential — worth the cost past roughly three private packages or two consuming teams.

How should I version a Laravel package that multiple apps depend on?

Stay on 0.x while the API is still moving, since Semantic Versioning allows anything to change before 1.0. After 1.0, treat the package's public API as wider than its PHP signatures: published config keys, container bindings, migration column names and published views are all public, and renaming any of them needs a major version. Support two Laravel majors in your constraint so applications a version behind can still adopt you.

Should the package live in a monorepo or its own repository?

Start it inside the application repository under packages/ with a path repository, because that keeps the feedback loop tight while the boundary is still uncertain and lets you abandon the extraction cheaply. Split it out to its own repository once the Testbench suite is green and a second application wants it. A permanent monorepo with read-only subtree splits is a reasonable end state for a team shipping many packages, but it's infrastructure you should earn rather than start with.

What's the difference between a package contract and a package facade?

A contract is an interface the package binds in the container, which consumers can typehint, mock in tests, and replace with their own implementation without forking you. A facade is a static proxy to a container binding — convenient for stateless services with a stable API, but a warning sign when it exists because the underlying class is awkward to inject. Ship the interface binding first and add a facade only if consumers ask for one.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.