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/blogcomposer init --name=acme/blog --type=library --no-interactioncomposer require nyoncode/laravel-package-toolkit
Set up autoloading and Laravel's package discovery:
{ "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:
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:
return [ 'per_page' => 15, 'cache' => [ 'enabled' => true, 'ttl' => 3600, ],];
And declare it:
$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'); // 15config('blog.cache.enabled'); // true
4. Routes
use Illuminate\Support\Facades\Route; Route::middleware('api')->prefix('api/blog')->group(function () { Route::get('/posts', PostController::class);});
$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
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.
$packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasMigrations();
6. Views
<article class="post"> <h1>{{ $post->title }}</h1> {!! $post->body !!}</article>
$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
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; }}
$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
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
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: