Skip to content
Package Toolkit
GitHub repository

Quickstart

Build a complete Laravel package — config, routes, migrations, views, an install command — from an empty directory.

On this page 10

We are going to build acme/blog: a package with a config file, an API route, a migration, a Blade view, a console command and an installer. Every step below is real code from a working package — nothing is elided.

1. Scaffold the package

mkdir -p packages/blog && cd packages/blog
composer init --name=acme/blog --type=library --no-interaction
composer require nyoncode/laravel-package-toolkit

Set up autoloading and Laravel's package discovery:

packages/blog/composer.jsonjson
{
"name": "acme/blog",
"autoload": {
"psr-4": { "Acme\\Blog\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Blog\\BlogServiceProvider"]
}
}
}

2. The provider

Start with the smallest thing that registers:

src/BlogServiceProvider.phpphp
namespace Acme\Blog;
 
use NyonCode\LaravelPackageToolkit\Contracts\Packable;
use NyonCode\LaravelPackageToolkit\PackageServiceProvider;
use NyonCode\LaravelPackageToolkit\Packager;
 
class BlogServiceProvider extends PackageServiceProvider implements Packable
{
public function configure(Packager $packager): void
{
$packager->name('Blog');
}
}

name('Blog') sets the human name — it is what php artisan about prints and what the install command says it is installing. From it the toolkit derives the short name blog (Str::kebab()), and that short name is what every tag and namespace in the rest of this page is built from.

3. Config

Create the file:

config/blog.phpphp
return [
'per_page' => 15,
 
'cache' => [
'enabled' => true,
'ttl' => 3600,
],
];

And declare it:

src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig();

That single call does three things. It merges config/blog.php into the application's config under the key blog, so config('blog.per_page') works with no publishing at all. It registers the file for publishing under --tag=blog::config. And it validates, at registration time, that the file actually returns an array — a config file that forgets its return throws InvalidReturnTypeException naming the file, instead of silently merging nothing.

config('blog.per_page'); // 15
config('blog.cache.enabled'); // true

4. Routes

routes/api.phpphp
use Illuminate\Support\Facades\Route;
 
Route::middleware('api')->prefix('api/blog')->group(function () {
Route::get('/posts', PostController::class);
});
src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig()
->hasRoutes();

With no arguments, hasRoutes() discovers every file in the package's routes directory. The toolkit deliberately does not wrap your routes in a group — prefixes, middleware and name patterns stay yours to declare inside the file, where they are visible.

5. Migrations

database/migrations/create_blog_posts_table.phpphp
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('blog_posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
 
public function down(): void
{
Schema::dropIfExists('blog_posts');
}
};

Note the filename: no timestamp. The toolkit calls these timeless migrations, and prepends a timestamp when the file is published, so the published copy sorts correctly against everything else in the application. Shipping a fixed timestamp instead would date your migration to whenever you wrote it, which is almost never where it belongs in a consumer's timeline.

src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasMigrations();

6. Views

resources/views/post.blade.phpblade
<article class="post">
<h1>{{ $post->title }}</h1>
{!! $post->body !!}
</article>
src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasMigrations()
->hasViews();

Views register under the short name, so from anywhere in the application:

return view('blog::post', ['post' => $post]);

A consumer who wants to change the markup runs vendor:publish --tag=blog::views, which copies the directory to resources/views/vendor/blog — Laravel's own override location, checked before yours.

7. A console command

src/Commands/PruneCommand.phpphp
namespace Acme\Blog\Commands;
 
use Illuminate\Console\Command;
 
class PruneCommand extends Command
{
protected $signature = 'blog:prune {--days=30}';
 
protected $description = 'Delete blog posts in the trash';
 
public function handle(): int
{
// …
 
return self::SUCCESS;
}
}
src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasMigrations()
->hasViews()
->hasCommands();

With no arguments, hasCommands() scans src/Commands and resolves each file to a fully qualified class name through Composer's PSR-4 map. Commands are only registered when the application is running in the console, so a web request never pays for them.

8. The install command

src/BlogServiceProvider.phpphp
use NyonCode\LaravelPackageToolkit\Commands\InstallCommand;
 
$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasMigrations()
->hasViews()
->hasCommands()
->hasInstallCommand(function (InstallCommand $command) {
$command
->publishConfig()
->publishMigrations()
->publishViews()
->askToStarRepoOnGitHub('https://github.com/acme/blog');
});

Your users now get:

php artisan blog:install
🚀 Installing Blog
 
(1/3) Publishing configuration...
✅ Published config
(2/3) Publishing migrations...
✅ Published migrations
(3/3) Publishing views...
✅ Published views
 
✨ Blog installed successfully!
 
📋 Next steps:
• Review configuration in config/blog.php
• Run: php artisan migrate

The finished provider

src/BlogServiceProvider.phpphp
namespace Acme\Blog;
 
use NyonCode\LaravelPackageToolkit\Commands\InstallCommand;
use NyonCode\LaravelPackageToolkit\Contracts\Packable;
use NyonCode\LaravelPackageToolkit\PackageServiceProvider;
use NyonCode\LaravelPackageToolkit\Packager;
 
class BlogServiceProvider extends PackageServiceProvider implements Packable
{
public function configure(Packager $packager): void
{
$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasMigrations()
->hasViews()
->hasCommands()
->hasAbout()
->hasInstallCommand(function (InstallCommand $command) {
$command
->publishConfig()
->publishMigrations()
->publishViews()
->askToStarRepoOnGitHub('https://github.com/acme/blog');
});
}
}

Twenty lines, and the package is complete: merged config, loaded routes, publishable migrations, namespaced views, console commands, an about entry and an installer.

Growing from here

The same chain extends to everything else the toolkit knows about. A more complete package might look like this — the highlighted lines are what changed:

$packager
->name('Blog')
->hasConfig()
->hasRoutes()
->hasBroadcastChannels(['channels.php'])
->hasMigrations()
->hasSeeders()
->hasFactories()
->hasTranslations()
->hasViews()
->hasComponents('blog', [
'card' => PostCard::class,
])
->hasAssets()
->hasMiddlewareAliases(['blog.auth' => Authenticate::class])
->hasEvent(PostPublished::class, NotifySubscribers::class)
->hasCommands()
->hasFullInstall();

Each of those has its own page: