Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Package Development from Scratch

By Kokil Thapa | Last reviewed: September 2026

Laravel package development from scratch is how you turn repeated application logic into a reusable Composer module that any Laravel 13 project can install with one command. You copy the same validation helpers, payment adapters, or SEO utilities across client projects until maintenance becomes painful. Packaging that code behind a service provider, config file, and test suite keeps your main application lean and gives you a versioned artefact you can share internally or publish on Packagist. This guide walks through the full workflow—from folder structure to CI and release tags—using patterns I rely on across production Laravel development in Nepal and client work worldwide.

What is Laravel package development and when should you build one?

A Laravel package is a standalone PHP library that integrates with the framework through a service provider. Unlike copying a folder into app/, a package lives in vendor/, follows semantic versioning, and declares its Laravel compatibility in composer.json. You reach for package extraction when the same code appears in two or more projects, when you want unit tests isolated from application noise, or when you plan to open-source a integration—think a Khalti webhook normaliser or a reusable slug helper tied to SEO setup for Laravel sites.

Do not package everything on day one. I have seen teams spend a week extracting a 40-line helper that never leaves one repo. A practical threshold: the module has a clear public API, at least one config surface, and you expect a second consumer within six months. For one-off client logic on a legal-tech portal, keep it in the app until the pattern repeats—I've maintained sister sites on a shared Deployer pipeline where only the third duplicate justified a package.

Laravel Package ArchitectureHost AppLaravel 13.xcomposer requireService Providerregister() + boot()Auto-discoveredPackage CoreConfig, RoutesViews, CommandsPackage Surface AreaFacadesArtisanMigrationsBlade TagsEach opt-in via provider methods
Laravel package development from scratch: host app, service provider, and modular package surface

Compare your options before committing:

ApproachBest forTrade-off
Inline app/ codeSingle-project logic, fast iterationNo reuse, harder to test in isolation
Local path repositoryActive package developmentRequires symlink or path mapping in Composer
Private Composer repoAgency-internal shared librariesNeeds Satis or GitLab package registry
Public Packagist packageOpen-source or community toolsAPI stability and semver discipline required

For background on extracting logic you already wrote inside an app, see the companion walkthrough on how to create custom Laravel packages. The steps below assume you are starting clean with Laravel 13.x on PHP 8.3 or higher and Composer 2.10.

How do you scaffold a Laravel package from scratch?

Start outside any existing Laravel project. Create a directory, initialise Composer, and define PSR-4 autoloading. A minimal layout looks like this:

acme/invoice-sdk/
├── composer.json
├── config/
│   └── invoice-sdk.php
├── database/
│   └── migrations/
├── resources/
│   └── views/
├── routes/
│   └── web.php
├── src/
│   ├── InvoiceSdkServiceProvider.php
│   ├── Facades/
│   │   └── InvoiceSdk.php
│   └── InvoiceCalculator.php
└── tests/
    ├── TestCase.php
    └── InvoiceCalculatorTest.php

composer.json essentials

Your composer.json is the contract. Include Laravel package discovery metadata so consuming apps auto-register your provider:

{
    "name": "acme/invoice-sdk",
    "description": "Invoice calculation helpers for Laravel",
    "type": "library",
    "license": "MIT",
    "require": {
        "php": "^8.3",
        "illuminate/support": "^13.0"
    },
    "require-dev": {
        "orchestra/testbench": "^10.0",
        "phpunit/phpunit": "^11.0"
    },
    "autoload": {
        "psr-4": {
            "Acme\\InvoiceSdk\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Acme\\InvoiceSdk\\Tests\\": "tests/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "Acme\\InvoiceSdk\\InvoiceSdkServiceProvider"
            ],
            "aliases": {
                "InvoiceSdk": "Acme\\InvoiceSdk\\Facades\\InvoiceSdk"
            }
        }
    },
    "minimum-stability": "stable"
}

Use illuminate/support rather than laravel/framework when your package does not need the full stack—this keeps dependency trees lighter. Require the specific Illuminate components you actually touch: illuminate/database for migrations, illuminate/routing for route files, and so on. The official Laravel package documentation lists each integration point.

Local development with path repositories

While building, point a sandbox Laravel app at your package folder:

{
    "repositories": [
        {
            "type": "path",
            "url": "../invoice-sdk",
            "options": { "symlink": true }
        }
    ],
    "require": {
        "acme/invoice-sdk": "@dev"
    }
}

Run composer update acme/invoice-sdk after each change to refresh the symlink. For day-to-day work, modern Laravel architecture often keeps the sandbox app in the same mono-repo or a sibling directory on your dev machine.

Package Dev WorkflowScaffoldcomposer initProviderregister bootTestbenchPHPUnitPublishPackagist tagIterate LoopEdit package → composer update → run tests → fixUse path repo symlink during active developmentTag semver release when API is stable
End-to-end Laravel package development workflow from Composer scaffold to Packagist release

How do you register config, routes, and assets in a Laravel package?

The service provider is the integration hub. Split responsibilities correctly: bind services in register(), touch the framework in boot().

<?php

namespace Acme\InvoiceSdk;

use Illuminate\Support\ServiceProvider;

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

        $this->app->singleton(InvoiceCalculator::class, function ($app) {
            return new InvoiceCalculator(
                taxRate: $app['config']['invoice-sdk.tax_rate']
            );
        });
    }

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

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

        $this->loadRoutesFrom(__DIR__.'/../routes/web.php');
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'invoice-sdk');
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
    }
}

Config merging vs publishing

mergeConfigFrom() lets your package ship defaults while the host app overrides keys in its own config file—ideal for API endpoints or feature flags. Use publishes() only when the consumer must edit the full file, such as a Blade layout they will customise. On projects like Nepal Gift Card, I publish config for payment gateway keys but merge defaults for formatting rules.

Routes and middleware

Load routes from a dedicated file and apply middleware groups explicitly:

<?php

use Illuminate\Support\Facades\Route;

Route::middleware(['web', 'auth'])
    ->prefix('invoice-sdk')
    ->group(function () {
        Route::get('/preview', function () {
            return view('invoice-sdk::preview');
        });
    });

If your package exposes an API, follow the same versioning and response conventions you would use in application code—see Laravel API best practices and building RESTful APIs with Laravel for patterns that translate directly into package route groups.

Artisan commands and scheduled tasks

Register commands conditionally so web requests never load CLI code unnecessarily:

if ($this->app->runningInConsole()) {
    $this->commands([
        Commands\SyncInvoicesCommand::class,
    ]);
}

Consumers schedule your command in their routes/console.php or bootstrap/app.php scheduler—your package documents the signature; it does not hijack their cron table.

register() vs boot()register()Bind interfacesmergeConfigFrom()No facades yetNo route loadingRuns for every requestboot()loadRoutesFrom()loadViewsFrom()publishes() tagsEvent listenersAll providers registeredorderCalling Route or View in register() causes boot-order bugs
Correct service provider split is critical in Laravel package development from scratch

How do you test a Laravel package before publishing?

Package tests should not require a full application checkout. Orchestra Testbench boots a minimal Laravel kernel around your provider. Create a base test case:

<?php

namespace Acme\InvoiceSdk\Tests;

use Acme\InvoiceSdk\InvoiceSdkServiceProvider;
use Orchestra\Testbench\TestCase as BaseTestCase;

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

    protected function defineEnvironment($app): void
    {
        $app['config']->set('invoice-sdk.tax_rate', 0.13);
    }
}

Write focused unit tests against your public API:

<?php

namespace Acme\InvoiceSdk\Tests;

use Acme\InvoiceSdk\InvoiceCalculator;

class InvoiceCalculatorTest extends TestCase
{
    public function test_it_calculates_tax(): void
    {
        $calc = new InvoiceCalculator(taxRate: 0.13);
        $this->assertSame(113.0, $calc->total(100.0));
    }
}

Run vendor/bin/phpunit locally and in CI. A GitLab pipeline might lint with Laravel Pint, run tests on PHP 8.3 and 8.5, and only then tag a release—the same pattern I use on shared EC2 deploy pipelines for production Laravel applications. Validate JSON config files during development with a JSON formatter and validator before they ship in your package tree.

Testing package integrations

When your package registers routes or views, use Testbench HTTP tests:

public function test_preview_route_returns_view(): void
{
    $user = \Orchestra\Testbench\Factories\UserFactory::new()->create();
    $response = $this->actingAs($user)->get('/invoice-sdk/preview');
    $response->assertOk();
    $response->assertViewIs('invoice-sdk::preview');
}

Study how mature packages structure tests—event sourcing with the Spatie package is a useful reference for service-provider-heavy libraries even if your domain is unrelated.

What are the steps to publish a Laravel package on Packagist?

Publishing makes your package installable worldwide. The sequence:

  1. Push the repository to a public Git host (GitHub, GitLab, etc.).
  2. Ensure composer.json has correct name, license, and autoload blocks.
  3. Create a Packagist account and submit your repository URL.
  4. Enable the Packagist webhook so new tags trigger index updates.
  5. Tag releases with semantic versioning: git tag v1.0.0 && git push origin v1.0.0.
  6. Document install steps in a README with PHP and Laravel version constraints.

Declare compatibility honestly. If you support Laravel 12 and 13, use "illuminate/support": "^12.0|^13.0" and run CI against both. Laravel 11 reached end-of-life in March 2026; new packages should target 12.x or 13.x unless you maintain legacy clients deliberately.

Publish PipelineGit TagWebhookPackagistcomposer requireSemver RulesMAJOR — breaking public API changesMINOR — backward-compatible featuresPATCH — bug fixes onlyDocument upgrade notes in CHANGELOG.md
Publishing Laravel package development from scratch: Git tags trigger Packagist and Composer installs

For agency-internal packages, skip public Packagist. GitLab's Composer registry or a private Satis mirror works well when you build multiple client apps and want shared payment or directory logic without exposing code—relevant for teams doing custom software development across several repos.

What mistakes break Laravel packages in production?

These issues appear repeatedly when packages move from sandbox to real apps:

  • Heavy work in register() — loading routes or hitting the database during registration breaks provider order. Keep bindings lean.
  • Missing mergeConfigFrom() — consumers cannot override defaults; they fork your vendor files instead.
  • Unconstrained Illuminate versions — a package locked to ^13.0 fails on Laravel 12 apps; test a sensible range or split major branches.
  • Global state and facades in core logic — inject dependencies; facades make unit testing outside Laravel harder.
  • Unnamespaced migrations — prefix table names or publish migrations so they do not collide with host apps.
  • No deferred provider when appropriate — packages that only register bindings can implement DeferrableProvider to speed boot.

Authorization-heavy packages should document how host apps wire gates and policies—your package should not assume Spatie Permission is installed unless you declare it as a dependency. See Laravel policies and gates for integration patterns consumers expect.

On eCommerce projects, payment packages must treat webhooks as idempotent and document callback URLs clearly—patterns covered in Laravel payment integrations apply whether the code lives in app/ or vendor/. For large modular apps, consider how your package boundary fits a modular monolith before extracting ten interdependent packages at once.

If you maintain packages alongside application work, treat semver as a contract. Breaking constructor signatures without a major bump destroys trust faster than a bug—I've rolled back package updates on production deployments when a minor release renamed a config key without notice.

Key Takeaways

  • Start Laravel package development from scratch with PSR-4 autoloading, a dedicated service provider, and Testbench—not by copying files into app/.
  • Bind services in register(); load routes, views, migrations, and publish tags in boot().
  • Use path repositories and symlinks for local iteration; tag semver releases only after PHPUnit passes on supported PHP and Laravel versions.
  • Prefer illuminate/* component dependencies over requiring the full framework when possible.
  • Document install, config keys, and upgrade paths in README and CHANGELOG before Packagist submission.
  • Extract packages when reuse is real— not because folder structure looks cleaner in a diagram.

People Also Ask

Do I need a service provider for every Laravel package?

Yes, for anything that integrates with Laravel beyond plain PHP classes. The service provider registers bindings, config, routes, views, commands, and migrations. Pure utility classes with no framework coupling can ship without a provider, but most Laravel packages need one for auto-discovery via the extra.laravel block in composer.json.

Can I develop a Laravel package inside an existing project?

You can use a path repository pointing at a subdirectory, but a sibling directory with its own git history is cleaner. It forces proper Composer boundaries and makes Packagist publication a copy-paste instead of a refactor. Many teams keep packages/ in a mono-repo for internal tools and split to a separate repo when going public.

What PHP version should a new Laravel package require in 2026?

Target PHP 8.3 as the minimum if you support Laravel 13, which itself requires PHP 8.3+. Testing on PHP 8.5 catches forward compatibility early. Laravel 12 projects need only PHP 8.2, so dual-support packages often declare "php": "^8.2" with Illuminate constraints covering both framework lines.

How is a Laravel package different from a Composer library?

Every Laravel package is a Composer library, but not every Composer library is Laravel-aware. A Laravel package adds a service provider, optional facade alias, config merging, and sometimes migrations or Blade namespaces. A generic PHP library lacks those hooks and cannot auto-register with the framework.

Ship reusable code the right way

Laravel package development from scratch pays off when you treat the package as a product: stable public API, tested provider boot, honest semver, and documentation a stranger can follow. Whether you are extracting shared logic from a booking platform like Adventure Third Pole Trek or preparing an open-source integration for the community, the scaffold-and-provider pattern stays the same on Laravel 13 and PHP 8.3+. Need help architecting reusable modules for an enterprise app or publishing a private agency library? Get in touch or explore enterprise application development services. For front-end integration patterns in consuming apps, see Vue with Laravel setup and essential Laravel plugins worth studying before you reinvent them.

Frequently Asked Questions

Building a standalone PSR-4 Composer library with a service provider, config, tests, and Laravel auto-discovery metadata so any Laravel 13 app installs it via one Composer command.

Yes, for anything beyond plain PHP utilities. The provider registers bindings, config, routes, views, commands, and migrations. Pure framework-agnostic classes can ship without one, but most Laravel packages need a provider listed in composer.json extra.laravel for auto-discovery.

PHP 8.3 or higher when targeting Laravel 13.x, matching the article’s scaffold and illuminate/support ^13.0 constraint.

Extract when the same logic appears in two or more projects, you want PHPUnit tests isolated from application noise, or you plan to open-source an integration like a Khalti webhook normaliser. The article’s practical threshold is a clear public API, at least one config surface, and a realistic second consumer within six months. Do not package a 40-line helper used once—I keep one-off client logic in app/ until the third duplicate on sister sites justifies extraction.

Start outside any existing Laravel project. Create a directory, run composer init, and define PSR-4 autoloading. A minimal layout includes composer.json, config/, database/migrations/, resources/views/, routes/web.php, src/ with your service provider and domain classes, Facades/ if needed, and tests/ with TestCase.php. Require php ^8.3, illuminate/support ^13.0, orchestra/testbench ^10.0, and phpunit/phpunit ^11.0. Add the extra.laravel block so consuming apps auto-register your provider and optional facade alias.

Your composer.json is the contract: name, description, type library, license, PSR-4 autoload for src/ and tests/, require php ^8.3 and illuminate/support ^13.0 (or specific illuminate/ components you touch), require-dev orchestra/testbench ^10.0 and phpunit/phpunit ^11.0, and extra.laravel listing providers and aliases. Use illuminate/support rather than laravel/framework when you do not need the full stack—add illuminate/database, illuminate/routing, and similar only for integrations you actually register in the service provider.

While building, add a path repository in your sandbox app’s composer.json pointing at ../your-package with symlink true, then require the package at @dev. Run composer update your/package after changes to refresh the symlink. This lets you iterate against a real Laravel 13 host without publishing. Many teams keep the sandbox in a sibling directory or mono-repo packages/ folder. Path repos suit active development; Packagist or a private registry comes after the API stabilises.

Split responsibilities correctly: bind services in register(), touch the framework in boot(). In register(), call mergeConfigFrom() for defaults and register singletons or bindings that read merged config. In boot(), load routes, views, and migrations; register publish tags when runningInConsole(); and register Artisan commands conditionally. Heavy work in register()—loading routes or hitting the database—breaks provider order and is a common production failure the article warns against repeatedly.

mergeConfigFrom() ships defaults the host app overrides in its own config file—ideal for API endpoints, tax rates, or feature flags without copying your whole file. publishes() is for when consumers must edit the full file, such as a Blade layout they customise or payment gateway keys they own. On projects like Nepal Gift Card, I publish config for payment keys but merge defaults for formatting rules. Missing mergeConfigFrom() forces consumers to fork vendor files instead of overriding keys cleanly.

Package tests should not require a full application checkout. Orchestra Testbench boots a minimal Laravel kernel around your provider. Extend Orchestra\Testbench\TestCase, return your service provider from getPackageProviders(), and set config in defineEnvironment(). Write focused unit tests against your public API, plus HTTP tests for routes and views using actingAs() and assertViewIs(). Run vendor/bin/phpunit locally and in CI—a GitLab pipeline might lint with Laravel Pint and test PHP 8.3 and 8.5 before tagging a release.

Push the repository to a public Git host. Ensure composer.json has correct name, license, and autoload blocks. Create a Packagist account and submit the repository URL. Enable the Packagist webhook so new tags trigger index updates. Tag releases with semantic versioning, for example git tag v1.0.0 and git push origin v1.0.0. Document install steps in README with honest PHP and Laravel constraints. If you support Laravel 12 and 13, use illuminate/support ^12.0|^13.0 and run CI against both. Laravel 11 reached end-of-life in March 2026; new packages should target 12.x or 13.x.

Skip public Packagist when code is proprietary. GitLab’s Composer registry or a private Satis mirror works well for agencies building multiple client apps that share payment adapters, directory logic, or validation helpers without exposing source. A local path repository suits active development with symlink true. Public Packagist fits open-source or community tools but demands API stability and semver discipline. Choose based on reuse scope: inline app/ for single projects, path repos while building, private registry for internal shared libraries.

Repeated failures include heavy work in register(), missing mergeConfigFrom() so consumers cannot override defaults, Illuminate versions locked too narrowly so Laravel 12 apps fail installs, global state and facades in core logic that resist unit testing, unnamespaced migrations that collide with host tables, and skipping DeferrableProvider when the package only registers bindings. Authorization-heavy packages should not assume Spatie Permission unless declared as a dependency. Payment packages must treat webhooks as idempotent. Breaking constructor signatures or renaming config keys without a major semver bump destroys trust—I have rolled back minor releases over that.

Prefer illuminate/ component dependencies over requiring the full framework when possible. Require illuminate/support ^13.0 for a minimal package, and add illuminate/database for migrations, illuminate/routing for route files, and other components only where your service provider integrates. This keeps consumer dependency trees lighter than pulling laravel/framework. Declare a sensible version range—^12.0|^13.0 if you test both—and run CI against supported combinations. Unconstrained or overly narrow Illuminate constraints are a frequent cause of Composer conflicts on real client projects.

You can point a path repository at a subdirectory within the project, but a sibling directory with its own git history is cleaner. Separate repos force proper Composer boundaries and make Packagist publication a copy-paste instead of a refactor. Many teams keep packages/ in a mono-repo for internal tools and split to a separate repository when going public. The article assumes you scaffold outside any existing app with Composer 2.10, then wire a sandbox Laravel 13 app via path mapping for day-to-day iteration before semver tagging.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: