nyoncode/laravel-package-toolkit
Laravel Package Toolkit streamlines building Laravel packages with an expressive config API that auto-handles routes, migrations, views, translations, commands, and components, plus lifecycle hooks, middleware, events, and publishing options.
This is not a package you install into a normal application — it is a toolkit for authoring packages. You reach for it the day you decide to extract a chunk of an app into vendor/yourname/your-package, or when you start a fresh package and don't want to hand-write six loadViewsFrom/loadMigrationsFrom/mergeConfigFrom blocks.
composer require nyoncode/laravel-package-toolkit
The whole integration surface is one class. Your package's service provider extends PackageServiceProvider instead of Illuminate\Support\ServiceProvider, implements Packable, and describes itself in configure():
class MyPackageServiceProvider extends PackageServiceProvider implements Packable
{
public function configure(Packager $packager): void
{
$packager
->name('My Awesome Package')
->hasConfig()
->hasRoutes()
->hasMigrations()
->hasTranslations()
->hasViews();
}
}
That is the first use case and it should take five minutes: hasX() with no arguments assumes the conventional directory (config/, routes/, database/migrations/, lang/, resources/views/), so an already-convention-shaped skeleton just works. Register the provider in your package's composer.json extra.laravel.providers as usual.
Where to look first:
ai/AGENTS.md in the installed package (vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md) — the complete public fluent API extracted from source. This is the fastest reference, faster than the site.package-toolkit.nyoncode.cz), one page per resource type. /llms.txt and /llms-full.txt exist if you'd rather feed it to an agent.If you use a coding agent, run vendor/bin/package-toolkit-ai install once. It drops the API reference, a Claude Code skill, and a zero-dependency MCP server into the project. That is genuinely the differentiator here: the agent reads the installed source rather than hallucinating a stale API.
Fluent declaration, then hooks. Keep configure() purely declarative and put behaviour in the lifecycle hooks. You have two equivalent spellings and should pick one per package:
// override methods on the provider
public function registeringPackage(): void { /* ... */ }
public function bootingPackage(): void { /* ... */ }
public function registeredPackage(): void { /* ... */ }
public function bootedPackage(): void { /* ... */ }
// or chain them inside configure()
$packager->registeringPackage(fn ($p) => Log::info('registering'))
->bootedPackage(fn ($p) => Log::info('booted'));
The hook order is fixed: before register(), after register(), before boot(), after boot(). Singletons go in registeredPackage(); anything touching routes, events, or views goes in bootingPackage().
Conditional resources without if spaghetti. The when* family is the most day-to-day useful thing in the package. A package that ships dev-only commands and a local config shouldn't force every consumer to guard them:
$packager
->hasRoutes(['api.php'])
->whenLocal(fn ($p) => $p->hasConfig('local-config.php')->hasCommands())
->whenProduction(fn ($p) => $p->hasConfig('production-config.php'))
->whenConsole(fn ($p) => $p->hasCommands())
->whenExtensionLoaded('redis', fn ($p) => $p->hasConfig('redis-config.php'));
whenEnvironment([...]) takes an array; whenClassExists() and whenExtensionLoaded() are how you make optional integrations (Redis, a payment SDK) not blow up in consumers who don't have them.
Custom paths when the package isn't conventional. Every hasX() takes either a list of filenames or a directory: named argument, and paths may be relative to the package root ('../www/routes/web.php'). Use the named directory: form for a whole tree, and the array form for a subset of files inside the default tree.
Infrastructure registrations belong in the packager too. Rather than dropping into boot():
$packager
->hasMiddlewareAliases(['custom.alias' => CustomMiddleware::class])
->hasMiddlewareGroups(['web' => [WebMiddleware::class]])
->hasMiddlewareGlobals([SecurityMiddleware::class])
->hasEvents([OrderPlaced::class => [SendConfirmation::class, LogOrder::class]])
->hasSubscribers([UserEventSubscriber::class])
->hasOptimizeCommands(optimize: 'my-package:cache', clear: 'my-package:clear');
hasOptimizeCommands forwards to Laravel's ServiceProvider::optimizes(), so your cache-warmup command runs on php artisan optimize and clears on optimize:clear — easy to forget, and a nice win over hand-wiring.
Broadcast channels are a separate axis from routes. Channel authorization callbacks go in hasBroadcastChannels(['channels.php'], directory: '../broadcasting'), not hasRoutes(). Registration silently no-ops when illuminate/broadcasting isn't installed, which makes it safe in packages that only optionally broadcast.
Testing. The reference suite runs Pest against Orchestra Testbench, so no separate app/DB. composer test, composer lint (PHPStan/Larastan level 7 over src only), composer pint. composer build / composer serve bring up the workbench app for manual poking — that's the fastest loop when a resource isn't loading and you can't tell why.
The version floor is the first landmine. v2.1 dropped Laravel 10 and 11 entirely; you need 12.x >= 12.61.1 or 13.x >= 13.12.0. Those minimums aren't arbitrary — they're the first releases patched for the June 2026 advisories (including CVE-2026-48019, CRLF injection). If your consumers are still on 10/11, pin ~2.0.0; Laravel 9 means ^1.0. This is the single most likely reason a composer require fails in a real project, so check it before you build anything on top.
hasRoutes() is not a dumping ground. It loads files into the router inside a route group. Anything that is not a route definition — channel auth callbacks especially — will load without error and simply never fire correctly. Same instinct applies to console-only files.
Channel files are deliberately not publishable. An application doesn't load routes/channels.php unless its own bootstrap asks for it, so a published copy would look authoritative while the package kept using its own. Consumers override authorization by re-registering the same channel name — last registration wins. Don't fight this; document it.
Short names are validated eagerly. hasShortName() enforces kebab-case, lowercase letters/numbers/hyphens only. If you skip it, the short name is derived from name() — so ->name('My Awesome Package') gives you my-awesome-package, which is probably not the config/translation namespace you wanted. Set it explicitly on day one; renaming later means rewriting every __('my-awesome-package::...') and config('my-awesome-package.…') call.
Everything validates at declaration time. The builder throws on bad input rather than failing silently at boot, which is good — but it means a typo'd directory: surfaces during composer install/provider registration, not at request time. Read the stack trace top-down; the exception names the resource.
Config merging vs. publishing is two different mechanisms. hasConfig() gives you mergeConfigFrom (consumer overrides work out of the box). Publishing a config file is a separate, opt-in step via the install command / publishing options. Don't assume a consumer's published config is what the package is reading — it is, but only because merge wins in the right order, and it won't if they edit vendor/ directly.
Adoption risk is real. Zero stars, zero dependents, and it's a fresh package (releases dated into late 2026). It's MIT and the source is right there, but you are an early adopter: expect the API to move, and don't make it a hard dependency of a package you plan to keep supporting for years without a fallback. The escape hatch is friendly, at least — every hasX() maps to a handful of standard ServiceProvider calls, so unwinding later is mechanical.
Keep the AI artifacts in sync if you fork or vendor it. The ai/ directory ships with the package and is what agents in consuming projects read out of vendor/. If you patch the toolkit locally, run node ai/mcp/server.mjs --self-test — it parses src/ and docs/ and prints a summary, so a layout change that silently breaks the MCP answers shows up immediately.
How can I help you explore Laravel packages today?