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.
Full Changelog: https://github.com/NyonCode/laravel-package-toolkit/compare/2.5.2...2.5.3
Full Changelog: https://github.com/NyonCode/laravel-package-toolkit/compare/2.4.2...2.5.0
A layout stops naming its packages, and a tag stops vanishing where public/ is closed.
composer update nyoncode/laravel-package-toolkit
Nothing to change. ^2.4 already covers it, and [@packageAssets](https://github.com/packageAssets)('blog') renders byte for byte what it did.
<head>
[@packageStyles](https://github.com/packageStyles)
</head>
<body>
[@packageScripts](https://github.com/packageScripts)
</body>
Every package that declared entries renders, in the order their providers handed them over. A layout that names its packages is one that has to be edited every time a package is installed or removed, in every file carrying the line — and package:discover does not close that gap, because it discovers providers while the template still names packages by hand.
Stylesheets lead across the whole set rather than within each package, though not across the seam between the two halves: what the application's Vite build covers is emitted as one block so its preloads stay a single set, and that block leads. Everything else stays per entry — each package's classic(), its attributes, its own Vite resolution. [@packageAssetUrl](https://github.com/packageAssetUrl) still takes both arguments; it answers with one URL, and there is no URL of every package.
→ Naming no package renders every one
hasAssetFallback() — where to serve from when nothing is publishedCheck this if your package supports deployments where public/ cannot be written.
An entry that resolved to nothing rendered no tag at all. That is right for an entry the application chose not to build, and wrong for the entry that is your package's only copy: on a read-only container, on Vapor, on shared hosting, a page lost its stylesheet or its behaviour with nothing in the markup, the log or the console to say why — on exactly the deployments least likely to go looking.
$packager
->hasAssets(entries: ['js/blog.js'])
->hasAssetFallback(fn (string $file): string => route('blog.asset', ['file' => $file]));
The resolver is reached only after both the mirror and public/vendor/{short-name} came back empty, so a normal deployment never calls it, and it owns the whole URL including any cache-busting query string. Returning null drops the tag as before.
What declaring it buys is the tag itself — type="module" or the defer that classic() implies, your declared attributes, data-navigate-track="reload" and the application's CSP nonce. That is the whole reason to declare a fallback rather than hand-write a <script> beside the directive.
→ Keeping the tag: hasAssetFallback()
resolution() called an unwritable public/ healthyCheck this if you have ever trusted the Assets line in php artisan about.
A mirrored package — the default — claimed shipped for every entry outright, ahead of every arm that checks anything. So an unwritable public/ reported both halves of a page as served from public/ while the page rendered nothing at all, and it left the new fallback state unreachable for any package that had not turned mirroring off. The report was wrong in precisely the production condition it exists to expose.
A copy already published now earns shipped first. A mirrored entry without one earns it only where the mirror could still create it — asked of the nearest existing ancestor of public/vendor/{short-name}, so a read-only public/vendor under a writable public/ is not taken for a writable one. Otherwise the answer is fallback, or not published where there is no fallback either.
The lazy mirror is unaffected: a fresh install, where about runs before any request has published anything, still reports shipped, and nothing is published to find out.
Full changelog: https://github.com/NyonCode/laravel-package-toolkit/compare/2.4.1...2.4.2
Assets stop asking to be listed twice, and a shipped bundle stops quietly turning into a module.
composer update nyoncode/laravel-package-toolkit
Nothing to change. ^2.4 already covers it.
hasAssets() discovers its entriesName nothing and the asset directory answers for itself:
$packager->hasAssets(); // → [@packageStyles](https://github.com/packageStyles)('blog') / [@packageScripts](https://github.com/packageScripts)('blog')
It reads the directory's root and its css/ and js/ subdirectories, alphabetically, following whatever directory hasAssets() was given. Only stylesheet and script extensions are registered, so maps, fonts, images and manifest.json are passed over.
It is not recursive, on purpose: a code-split build's chunks live in a subdirectory of their own and are imported by an entry point, not loaded beside it. Such a build still names its entry points — as does an IIFE or UMD bundle, since a discovered script is emitted as a module. Naming any entry replaces discovery outright.
→ Naming nothing discovers them
hasViteAssets() discarded classic()Check this if your package ships an IIFE or UMD bundle and declares a Vite source for it.
$packager
->hasAssets(entries: [Asset::make('js/blog.js')->classic()])
->hasViteAssets(['resources/js/blog.js' => 'js/blog.js']); // classic() used to be lost here
The second declaration replaced the first with a blank entry, dropping classic() and every declared attribute. It only showed on the fallback path: an application that did not build the entry served the shipped IIFE as type="module", whose top-level declarations never reach window — no exception, no 404, bundle dead. The replacement now inherits the earlier entry's presentation.
It renders, which is what keeps it around, but url() is nullable — an unwritable public/ gives src="" — and the tag carries no type="module", no data-navigate-track, no CSP nonce and no Vite resolution. Use [@packageScripts](https://github.com/packageScripts) / [@packageAssetUrl](https://github.com/packageAssetUrl) instead.
PublishedAssets is not deprecated: it stays the layer under PackageAssets, for an asset no directive renders and for isStale().
→ What the hand-written tag misses
Full changelog: https://github.com/NyonCode/laravel-package-toolkit/compare/2.4.0...2.4.1
hasAssets(entries: [...]) and the asset directives — name the files a template renders and the toolkit
registers [@packageAssets](https://github.com/packageAssets), [@packageStyles](https://github.com/packageStyles), [@packageScripts](https://github.com/packageScripts) and [@packageAssetUrl](https://github.com/packageAssetUrl), each taking the package
short name. This replaces the boilerplate the toolkit used to leave to the package author: a helper class holding the
paths, a method assembling and escaping the markup, and a Blade::directive() registered from a lifecycle hook — all
of it derived from facts the packager already had. Paths are validated at declaration, so a typo throws where it was
written rather than 404ing in a browser. .js renders as type="module" with data-navigate-track="reload";
Support\Asset::make('js/x.js')->classic() opts a shipped IIFE or UMD bundle out, since a module is deferred and its
top-level declarations never reach window. Directives are registered per container, guarded on the compiler's
existing directives rather than a static, so a rebuilt application re-registers and a second package does not.hasViteAssets() — declare sources the consuming application's Vite build can compile. The toolkit still
ships no Vite config, no build step and no manifest of its own: a package building its own would produce a second
manifest, a second dev server and a second set of hashed filenames, and would still have to hand plain files to a
template — which the mirror already does. What was missing is the other direction. An application on Tailwind must
run its own config over the package's Blade markup or half the package's classes are purged, and an application
bundling its own JavaScript would rather not ship a second copy of a shared dependency; both need the package's
sources in the application's build, and then the package's own layout has to emit a hashed filename it can only
learn from the application's manifest. Declaring 'resources/js/blog.js' => 'js/blog.js' covers both: the entry
resolves through the dev server while npm run dev runs, through the application's manifest once built, and through
the shipped file otherwise — per entry, so an application can build the CSS and leave the JavaScript alone. A miss
falls back instead of throwing, because a package's layout cannot fix the application's Vite config and a 500 on
every page is a poor way to report a missed optimisation. The manifest key prefix is derived from the package's
location under the application; base: states it for a symlinked path repository, where nothing can be derived.PackageAssets::resolution() — names how each entry resolves right now (dev server, application build,
shipped, not published, unresolved), the counterpart to PublishedAssets::isStale() and there for the same
reason. Falling back is silent by design, which means the most common mistake on the application's side is silent
too: an input listed under a path one segment off from the manifest key leaves every page working, served from the
shipped file, with nothing saying the configured build is unused. A package that called hasAbout() and declared
Vite sources gets the same summary as an Assets line in php artisan about. Nothing is written to report —
an entry the mirror would publish on demand reports shipped on the strength of the file existing.Vite::useCspNonce() nonces every tag Laravel generates, so without this
the two halves of one declaration behave differently under a strict policy: the entry the application built loads
and the one falling back to the shipped file is blocked. An entry's own nonce attribute wins.hasBroadcastChannels() — registers channel authorization files (Broadcast::channel()) with the broadcaster.
Until now a channel file had to be smuggled through hasRoutes(), which loads it into the router inside a route
group — the wrong destination for an authorization callback. Discovers the package's routes directory by default,
or takes explicit files and a directory. Channels are load-only and deliberately not publishable: an application does
not 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. Skipped silently when illuminate/broadcasting is absent.hasSeeders() — publishes database seeders under <package-short-name>::seeders, flat into the application's
database/seeders directory rather than a vendor/<package> subdirectory, because that is where the application's
own Database\Seeders namespace resolves — a published seeder is immediately runnable with
db:seed --class=Database\Seeders\<Name>. A seeder shipped as .stub is published as .php.hasFactories() — publishes model factories under <package-short-name>::factories, flat into
database/factories for the same reason. Publishing is the only mechanism offered on purpose: Laravel removed
loadFactoriesFrom() in version 8, so a package that wants unpublished factories used must point at them from its
model's newFactory().hasStubs() — publishes generator stubs under <package-short-name>::stubs to stubs/<package-short-name>/,
keeping the original extension. The subdirectory is not decoration: stubs/ is one flat directory shared with
php artisan stub:publish and with every other installed package.publishSeeders(), publishFactories() and publishStubs(), all three included in
publishEverything(), each with its own progress step. Note that getInstallationSteps() only runs tags it knows
about, so a new publishable resource has to be listed there or the install command silently skips it.PublishedAssets::flush() — forgets the resolved URLs and the per-request sync marks, for a long-lived worker
where the singleton outlives the request it was scoped to. Without it the mirror is attempted at most once per
worker boot, so a published copy deleted underneath a running worker is never put back, and every URL keeps
emitting the ?id=<mtime> of the release the worker started on — the query string Livewire's
data-navigate-track watches to notice a deploy. Call it from the framework's request-terminated hook. The
directories declared by hasAssets() are kept: providers register those once per worker boot, not per request.ai/AGENTS.md is the complete public API in one file, and it now
travels inside the package rather than living only in this repository. That is the whole point: an agent adding a
resource to someone's package otherwise reconstructs this API from whatever release was in its training data, and
a copy pasted into their project goes stale at the next release, silently, in the one file nobody re-reads. A
reference into vendor/ cannot. vendor/bin/package-toolkit-ai install wires it into a consuming project three
ways — a delimited block in the project's AGENTS.md, a Claude Code skill under .claude/skills/, and an MCP
server registered in .mcp.json — each independent, each idempotent, and all three reversible with remove,
which is what the markers and the namespaced JSON key are for.ai/mcp/server.mjs) — search_docs, list_docs, get_doc, list_api and describe_api.
The last two parse signatures and docblocks out of the installed src/, so a method's arguments are read rather
than recalled; the first three serve the pages the documentation site publishes, so a page the site does not build
is not offered as fact. Node 18+ and no dependencies — the MCP stdio transport is newline-delimited JSON-RPC,
which is less code to implement than an SDK inside vendor/ would be to install. --self-test parses everything
and prints what it found.llms.txt on the documentation site — plus llms-full.txt and a .md twin of every page, generated from the
same Markdown as the HTML so the two cannot disagree. Links inside them are absolute and point at other .md
files, so an agent that follows one stays in Markdown; each HTML page advertises its twin as
<link rel="alternate" type="text/markdown">.Support\PublishedAssets, a container singleton shared by every package, keeps
public/vendor/<package-short-name> in step with the directory named by hasAssets() without anyone running a
command. Ask it for a URL with app(PublishedAssets::class)->url($shortName, $absolutePath): the first asset of a
package to resolve one in a request compares each shipped file against its published counterpart and copies only
what is missing or older, so in steady state it is a handful of stat calls and no writes. Copies land through a
temporary file and rename(), so a concurrent request never sees a half-written file. The returned URL is
cache-busted by the published copy's mtime, which is what keeps Livewire's data-navigate-track meaningful across
an upgrade. Where public/ cannot be written — a read-only container, Vapor — nothing throws: url() returns
null and isStale() reports a copy left behind, so the caller can fall back and warn.hasAssets(string $directory = 'dist', bool $mirror = true) — the new second argument opts a package out of the
mirror while keeping its publish tags.laravel-assets — in addition to <package-short-name>::assets. That tag is what the
Laravel application skeleton already runs from composer's post-update-cmd
(vendor:publish --tag=laravel-assets --ansi --force), the same hook Horizon, Telescope and Nova rely on, so one
command covers every installed package. Laravel accumulates groups per path, so both tags publish the same files and
an untagged vendor:publish is unaffected.Minor release — additive only, no breaking changes.
Event registration — register event listeners and subscribers directly from configure(), applied at boot via the Event facade:
hasEvents([Event::class => [ListenerA::class, ListenerB::class]]) — a map of event to listener(s); each value may be a class-string, a closure, or an array.hasEvent(Event::class, Listener::class) — a single event.hasSubscriber(Subscriber::class) / hasSubscribers([...]) — event subscriber classes.Optimize command registration — hasOptimizeCommands(optimize: 'pkg:cache', clear: 'pkg:clear') registers artisan commands that run with php artisan optimize and php artisan optimize:clear, forwarding to Laravel's ServiceProvider::optimizes(). The cache key defaults to the package short name.
Configurable publish tag separator — hasPublishTagSeparator() controls the format of publish tags:
{short-name}::{group} (e.g. my-package::config).'-' for the classic flat form (my-package-config).['::', '-']) to register every group under multiple tag forms at once, so consumers can publish with either; the first separator is primary and is used by the install command.The separator applies consistently to both vendor:publish tags and the package install command.
laravel/pint is now a runtime dependency.composer lint runs PHPStan with --memory-limit=512M.Full test suite: 252 passing, PHPStan level 5 clean.
hasAssets() stored a wrong path — the directory was validated as ../<dir> but stored without the ../
prefix, so vendor:publish --tag=<name>::assets silently published nothing.hasTranslations() rejected valid languages — the language-directory check used Collection::search(),
whose 0 index for the first supported language was treated as a failure. Region locales
(e.g. pt_BR, en-US) are now accepted as well.hasViews() broke with a custom path — a relative path was passed unresolved to loadViewsFrom() and an
absolute path (as documented) failed validation. Both are now resolved correctly.routes, view-components and view-component-namespaces publish tags did nothing — the corresponding
publish registrations were missing or never called. Route files are published to
routes/vendor/<package-short-name>/.publishMigrations() ignored an explicit file selection — publishing the whole directory of the first
migration file instead of the configured files.1; hasComponent() also registered the component twice.packageCommands() was never called — commands returned from the documented override are now registered.exit(0) — cancelling the production confirmation returns a proper exit
code instead of terminating the process.getVersion() no longer throws for packages without a composer name or not installed via Composer.PackageConfigWithFileNames was missing the Test suffix and never ran.bootVewComposers() was renamed to bootViewComposers(); the misspelled method is kept as a deprecated alias.phpstan.neon, level 5) so composer lint works.$tagSeparator — the tag separator is passed to the install
command instead of being hardcoded to ::.Packable is now part of the contract hierarchy — ProvidesPackageServices extends Packable, so
providers following the documented implements Packable pattern are covered by the toolkit itself.HasAbout contract — unused by the toolkit; Packable already declares aboutData(). Will be removed
in 3.0.illuminate/support constraint is now
^12.61.1|^13.12.0, pinning to the first releases patched against the June 2026 advisories. If you still depend on
Laravel 10 or 11, stay on the ~2.0.0 release.aboutData()
on a service provider was never included in the php artisan about output; only the
package Version was displayed. The provider's aboutData() is now passed through to
the packager and merged with the version data as documented.Fixed a bug when using the install command Added new tests
// Timeless files get auto-timestamped on publish: // create_posts_table.php → 2026_03_20_143022_create_posts_table.php
| Method | Description |
|---|---|
| hasDatePrefix(string $filename): bool | Check if a filename has a standard Laravel migration date prefix |
| shouldPrependTimestamp(): bool | Whether the current migration set contains timeless files |
| getMigrationPublishMapping(): array | Generate source → destination mapping with auto-timestamps |
Added support conditional loading Added support for Windows file system paths Added install command Added lifecycle hooks
Update docs
Small improvements
Full Changelog: https://github.com/NyonCode/laravel-package-toolkit/compare/1.1.1...1.2.1
Fix small bugs
Update concern Packable.php
Update README.md
Initial release
📦 Alfa Laravel Package Builder is here 🎉🎉
Full Changelog: https://github.com/NyonCode/laravel-package-builder/commits/v0.1.0-alfa
How can I help you explore Laravel packages today?