# Laravel Package Toolkit > Build Laravel packages without the boilerplate. Describe config, routes, migrations, views, assets and commands through one fluent API. Version 2.4.1. Requires PHP ^8.2 and Laravel 12.x (>= 12.61.1) or 13.x (>= 13.12.0). A package built on the toolkit describes itself once, in `configure(Packager $packager)`, and the base provider does the register, boot and publish work. Every `hasX()` on the description has a matching `bootX()` or `publishX()` on the provider, and any of them can be overridden. - Index of every page, with descriptions: https://package-toolkit.nyoncode.cz/llms.txt - Every page in one file: https://package-toolkit.nyoncode.cz/llms-full.txt - Raw Markdown of any page: append `.md` to its URL - Shipped with the package, and therefore always matching the installed release: `vendor/nyoncode/laravel-package-toolkit/ai/AGENTS.md`, a Claude Code skill, and an MCP server that answers from the installed source. Install all three with `vendor/bin/package-toolkit-ai install`. --- # Overview > Prologue · https://package-toolkit.nyoncode.cz/index.md There are exactly two objects to keep in your head, and nothing is hidden behind either of them: every `hasX()` on the description has a matching `bootX()` or `publishX()` on the provider, and you can call, override or skip any of them. --- # Installation > Prologue · https://package-toolkit.nyoncode.cz/installation.md The toolkit is a dependency of *your package*, not of the application that installs your package. Add it to the package you are building. ```bash composer require nyoncode/laravel-package-toolkit ``` In your package's `composer.json` it belongs in `require`, because your service provider extends a class from it at runtime: ```json title="composer.json" { "name": "acme/blog", "require": { "php": "^8.2", "nyoncode/laravel-package-toolkit": "^2.4" // [tl! ++] }, "autoload": { "psr-4": { "Acme\\Blog\\": "src/" } }, "extra": { // [tl! focus:start] "laravel": { "providers": [ "Acme\\Blog\\BlogServiceProvider" ] } } // [tl! focus:end] } ``` The `extra.laravel.providers` entry is Laravel's own package discovery — the toolkit does not replace it. Without it, an application would have to register your provider by hand. ## Directory layout Every `hasX()` method takes an explicit path, so no layout is mandatory. But the defaults are worth matching, because matching them means calling `hasConfig()` instead of `hasConfig(directory: '../src/resources/configuration')`. ```text acme/blog/ ├── composer.json ├── config/ │ └── blog.php hasConfig() ├── database/ │ ├── factories/ hasFactories() │ ├── migrations/ hasMigrations() │ └── seeders/ hasSeeders() ├── dist/ hasAssets() │ ├── css/blog.css │ └── js/blog.js ├── lang/ hasTranslations() │ ├── en/messages.php │ └── en.json ├── resources/ │ └── views/ hasViews() ├── routes/ │ ├── web.php hasRoutes() │ └── channels.php hasBroadcastChannels() ├── stubs/ hasStubs() └── src/ ├── BlogServiceProvider.php ├── Commands/ hasCommands() └── View/Components/ ``` Two details in that tree are easy to miss and are covered in full on [The Packager](https://package-toolkit.nyoncode.cz/packager.md): - Paths are resolved from **the directory your service provider lives in** — normally `src/` — which is why nearly every default starts with `../`. - `hasCommands()` is the exception: its default directory is `Commands`, *without* `../`, because commands are PHP classes that live inside `src/` alongside the provider. :::tip Provider in a subdirectory If you keep your provider at `src/Providers/BlogServiceProvider.php`, the toolkit detects the `src/Providers` suffix and trims it back to `src`, so every default path keeps working unchanged. ::: ## Your first provider ```php title="src/BlogServiceProvider.php" 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'); } } ``` `configure()` is the only abstract method: a provider that does not implement it will not compile. Implementing `Packable` is optional — `PackageServiceProvider` already implements `ProvidesPackageServices`, which extends `Packable` — but stating it makes the contract visible at the top of the file, and every example in these docs does. `name()` is the only required call. Leave it out and registration throws `MissingNameException`, because the short name derived from it is what every publish tag, view namespace and translation namespace is built from. ## Developing against a local application While building a package it is convenient to test it inside a real application without publishing to Packagist. Add a path repository to the *application's* `composer.json`: ```json title="my-app/composer.json" { "repositories": [ // [tl! ++:start] { "type": "path", "url": "../packages/blog" } ], // [tl! ++:end] "require": { "acme/blog": "@dev" // [tl! ++] } } ``` Composer symlinks the directory, so edits in the package are live in the application immediately. For testing the package on its own — no host application at all — see [Testing](https://package-toolkit.nyoncode.cz/testing.md), which covers the Orchestra Testbench setup this toolkit itself uses. ## Verifying the install ```bash php artisan about ``` The toolkit registers its own section, so a correct install shows: ```text Laravel Package Toolkit ...................................... Version ................................................ 2.4.0 ``` Your package gets its own section too, once you opt in with `hasAbout()` — see [The about command](https://package-toolkit.nyoncode.cz/about-command.md). --- # Upgrade guide > Prologue · https://package-toolkit.nyoncode.cz/upgrade.md The toolkit follows [Semantic Versioning](https://semver.org/), and the promise worth holding it to is the one below the top number: nothing but a major release breaks working code. Minor releases add functionality. A patch release occasionally does too, where the addition closes a gap rather than opening a new surface — 2.4.1's asset discovery gave `hasAssets()` the file discovery every other `hasX()` already had, and a package that named its entries never notices. Major releases may break. | Constraint | Gets you | |---|---| | `^2.4` | the current line, including seeders, factories, stubs and broadcast channels | | `^2.0` | Laravel 12/13, PHP 8.2+ | | `~2.0.0` | the last line supporting Laravel 10 and 11 | | `^1.0` | Laravel 9 | :::warning 2.3.0 has been withdrawn It is no longer available to install, and `^2.3` now resolves to 2.4 or later. Everything 2.3.0 introduced — the asset mirror and the `laravel-assets` publish tag — is in 2.4 unchanged, so **the minimum supported version is `^2.4`**. If a lock file still pins 2.3.0, `composer update nyoncode/laravel-package-toolkit` is the whole migration. ::: ## To 2.4.1 Nothing to do. One thing starts working that previously did nothing: [`hasAssets()` with no entries named](https://package-toolkit.nyoncode.cz/assets.md#naming-nothing-discovers-them) now discovers them, instead of leaving `@packageAssets` with nothing to render. A package that already names its entries is untouched — naming any entry replaces discovery outright. A package that called `hasAssets()` bare gets the stylesheets and scripts from the asset directory's root and its `css/` and `js/` subdirectories, which is what a template asking for `@packageAssets` wanted in the first place. The two cases discovery cannot read off a directory listing still need naming: a **code-split build**, whose chunk directory is deliberately skipped, and an **IIFE or UMD bundle**, since a discovered script is emitted as a module. ## To 2.4 Purely additive. Update the constraint and run `composer update`: ```json title="composer.json" { "require": { "nyoncode/laravel-package-toolkit": "^2.4" // [tl! ++] } } ``` ### What is new **[`hasBroadcastChannels()`](https://package-toolkit.nyoncode.cz/broadcast-channels.md)** registers channel authorization files with the broadcaster. If you were passing a channel file to `hasRoutes()` — the only option before — move it: ```php $packager ->hasRoutes() // [tl! --] ->hasRoutes(['web.php', 'api.php']) // [tl! ++] ->hasBroadcastChannels(['channels.php']); // [tl! ++] ``` The old arrangement kept working, but it loaded the authorization callbacks inside a route group and put your channel names into the router. Both defaults point at `../routes`, so name the files explicitly or move channels to their own directory. **[`hasSeeders()`](https://package-toolkit.nyoncode.cz/seeders.md)**, **[`hasFactories()`](https://package-toolkit.nyoncode.cz/factories.md)** and **[`hasStubs()`](https://package-toolkit.nyoncode.cz/stubs.md)** add three publish-only resources, with the tags `blog::seeders`, `blog::factories` and `blog::stubs`. **The install command** gained `publishSeeders()`, `publishFactories()` and `publishStubs()`, all three included in `publishEverything()`. **[`PublishedAssets::flush()`](https://package-toolkit.nyoncode.cz/assets.md#flush-for-long-lived-workers)** clears the resolved URLs and per-request sync marks, for applications running the toolkit under a long-lived worker. ## To 2.3 :::note Withdrawn 2.3.0 is no longer available to install. This section is kept because both features below shipped unchanged in 2.4 — read it as "what 2.4 brought along", and target `^2.4`. ::: Additive, with one behaviour change worth knowing about. ### Assets now also publish under `laravel-assets` In addition to `{short-name}::assets`. That tag is what the Laravel application skeleton runs from Composer's `post-update-cmd`, so your assets are republished after every `composer update` in an application that keeps the default script — the same mechanism Horizon and Telescope rely on. Laravel accumulates publish groups per path, so both tags publish the same files and an untagged `vendor:publish` is unaffected. **No action needed**, but be aware that a consumer's `composer update` may now overwrite hand-edited files under `public/vendor/{short-name}` — that hook runs with `--force`. ### The asset mirror [`Support\PublishedAssets`](https://package-toolkit.nyoncode.cz/assets.md#the-asset-mirror) keeps `public/vendor/{short-name}` in step with your `dist/` directory without anyone running a command. It is registered automatically by `hasAssets()`; opt out with: ```php $packager->hasAssets(mirror: false); ``` ## To 2.2 Additive: [events](https://package-toolkit.nyoncode.cz/events.md), [optimize commands](https://package-toolkit.nyoncode.cz/optimize.md) and the [configurable publish tag separator](https://package-toolkit.nyoncode.cz/publishing.md#changing-the-tag-separator). ## To 2.1.1 A bug-fix release, but several of the fixes changed behaviour that was previously broken — so something in your package may start working, and a workaround you wrote may become redundant. | Was broken | Now | |---|---| | `hasAssets()` stored a path without `../`, so the assets tag published nothing | Publishes correctly. **Remove any manual `publishes()` workaround.** | | `hasTranslations()` rejected the first supported language, and all region locales | `pt_BR`, `en-US` and the rest are accepted | | `hasViews()` with a custom path failed either relative or absolute | Both resolve | | `routes`, `view-components`, `view-component-namespaces` tags did nothing | All three publish. Routes go to `routes/vendor/{short-name}/` | | `publishMigrations()` ignored an explicit file list | Publishes exactly the files you named | | List-style component arrays got numeric aliases (`1`, `2`) | Only string keys become aliases | | `packageCommands()` was never called | The override is honoured | | The install command called `exit(0)` on a declined production prompt | Returns an exit code | | `getVersion()` threw for a package with no composer `name` | Returns `null` | ### Renamed `bootVewComposers()` → `bootViewComposers()`. The misspelling is kept as a deprecated alias; if you override it, move to the correct spelling. ### Deprecated `Contracts\HasAbout` will be removed in 3.0. `Packable` already declares `aboutData()`, and `ProvidesPackageServices` extends `Packable`, so `PackageServiceProvider` covers it: ```php class BlogServiceProvider extends PackageServiceProvider implements HasAbout // [tl! --] class BlogServiceProvider extends PackageServiceProvider implements Packable // [tl! ++] ``` ## To 2.1 **Laravel 10 and 11 support was removed.** The requirement is now Laravel 12 (>= 12.61.1) or 13 (>= 13.12.0). Both branches had passed security-support end of life, and the June 2026 advisories — including the High-severity CRLF injection CVE-2026-48019 — were patched only in Laravel 12.60+ and 13.9+, never backported. There is no secure release on those branches, so the minimums are pinned to the first patched versions. ```json title="composer.json" { "require": { "nyoncode/laravel-package-toolkit": "^2.1" } } ``` No code changes are required. If your package must still support Laravel 10 or 11, stay on `~2.0.0` — and consider what that implies for the applications installing it. ## To 2.0 from 1.x | Change | Action | |---|---| | Laravel 9 support dropped | Stay on `^1.0` if you need it | | Minimum PHP raised to 8.2 | Update your `require.php` constraint | | Laravel 13 support added | — | | [Timeless migrations](https://package-toolkit.nyoncode.cz/migrations.md#timeless-migrations) added | Non-breaking; timestamped migrations are unaffected | ```json title="composer.json" { "require": { "php": "^8.2", "nyoncode/laravel-package-toolkit": "^2.0" } } ``` Then `composer update`. No code changes are required unless your package explicitly depends on Laravel 9 or PHP 8.1. ### Timeless migrations, in detail The addition is non-breaking, but it changes what happens when a migration file has no date prefix. Before 2.0 such a file was published verbatim, which produced a migration Laravel could not order. From 2.0, the toolkit detects the missing prefix and generates one at publish time. If your package ships migrations with a date prefix, nothing changes. If it shipped prefix-less files and you worked around the ordering by hand, you can drop the workaround. ## Compatibility promise - **Major** versions may contain breaking changes. - **Minor** versions stay backward compatible within a major. - **Patch** versions contain bug fixes and security updates only. Recommended constraint: ```json { "require": { "nyoncode/laravel-package-toolkit": "^2.4" } } ``` ## Looking ahead to 3.0 Known removals: - `Contracts\HasAbout` — use `Packable`. - `bootVewComposers()` — use `bootViewComposers()`. One rule holds the whole plan for 3.0 together: nothing gets taken away without a replacement that is already in place. --- # Quickstart > Getting started · https://package-toolkit.nyoncode.cz/quickstart.md 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 ```bash 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: ```json title="packages/blog/composer.json" { "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: ```php title="src/BlogServiceProvider.php" 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: ```php title="config/blog.php" return [ 'per_page' => 15, 'cache' => [ 'enabled' => true, 'ttl' => 3600, ], ]; ``` And declare it: ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig(); // [tl! focus] ``` 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. ```php config('blog.per_page'); // 15 config('blog.cache.enabled'); // true ``` ## 4. Routes ```php title="routes/api.php" use Illuminate\Support\Facades\Route; Route::middleware('api')->prefix('api/blog')->group(function () { Route::get('/posts', PostController::class); }); ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->hasRoutes(); // [tl! focus] ``` 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 ```php title="database/migrations/create_blog_posts_table.php" 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. ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasMigrations(); // [tl! focus] ``` ## 6. Views ```blade title="resources/views/post.blade.php"

{{ $post->title }}

{!! $post->body !!}
``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasMigrations() ->hasViews(); // [tl! focus] ``` Views register under the short name, so from anywhere in the application: ```php 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 ```php title="src/Commands/PruneCommand.php" 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; } } ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasMigrations() ->hasViews() ->hasCommands(); // [tl! focus] ``` 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 ```php title="src/BlogServiceProvider.php" use NyonCode\LaravelPackageToolkit\Commands\InstallCommand; // [tl! focus] $packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasMigrations() ->hasViews() ->hasCommands() ->hasInstallCommand(function (InstallCommand $command) { // [tl! focus:start] $command ->publishConfig() ->publishMigrations() ->publishViews() ->askToStarRepoOnGitHub('https://github.com/acme/blog'); }); // [tl! focus:end] ``` Your users now get: ```bash php artisan blog:install ``` ```text 🚀 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 ```php title="src/BlogServiceProvider.php" 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: ```php $packager ->name('Blog') ->hasConfig() ->hasRoutes() ->hasBroadcastChannels(['channels.php']) // [tl! ++] ->hasMigrations() ->hasSeeders() // [tl! ++] ->hasFactories() // [tl! ++] ->hasTranslations() // [tl! ++] ->hasViews() ->hasComponents('blog', [ // [tl! ++:start] 'card' => PostCard::class, ]) ->hasAssets() // [tl! ++:end] ->hasMiddlewareAliases(['blog.auth' => Authenticate::class]) // [tl! ++] ->hasEvent(PostPublished::class, NotifySubscribers::class) // [tl! ++] ->hasCommands() ->hasFullInstall(); // [tl! --] ``` Each of those has its own page: - [Config](https://package-toolkit.nyoncode.cz/config.md) · [Routes](https://package-toolkit.nyoncode.cz/routes.md) · [Broadcast channels](https://package-toolkit.nyoncode.cz/broadcast-channels.md) - [Migrations](https://package-toolkit.nyoncode.cz/migrations.md) · [Seeders](https://package-toolkit.nyoncode.cz/seeders.md) · [Factories](https://package-toolkit.nyoncode.cz/factories.md) - [Translations](https://package-toolkit.nyoncode.cz/translations.md) · [Views](https://package-toolkit.nyoncode.cz/views.md) · [View components](https://package-toolkit.nyoncode.cz/view-components.md) - [Assets](https://package-toolkit.nyoncode.cz/assets.md) · [Middleware](https://package-toolkit.nyoncode.cz/middleware.md) · [Events](https://package-toolkit.nyoncode.cz/events.md) · [Commands](https://package-toolkit.nyoncode.cz/commands.md) - [Publishing](https://package-toolkit.nyoncode.cz/publishing.md) · [The install command](https://package-toolkit.nyoncode.cz/install-command.md) --- # The service provider > Getting started · https://package-toolkit.nyoncode.cz/service-provider.md `PackageServiceProvider` is an ordinary Laravel `ServiceProvider` with one abstract method and a fixed sequence of work built on top of it. Understanding that sequence is most of what you need to know to extend it, because every step is a `protected` or `public` method you can override. ```php abstract class PackageServiceProvider extends ServiceProvider implements ProvidesPackageServices { abstract public function configure(Packager $packager): void; } ``` ## What runs during `register()` ```php public function register(): void { $this->packager = $this->bootPackager(); // 1 $this->validatePackager(); // 2 $this->packager->hasBasePath($this->getPackageBaseDir()); // 3 $this->configure($this->packager); // 4 $this->packager->executeConditionalCallbacks(); // 5 $this->validatePackageConfiguration(); // 6 $this->registeringPackage(); // 7 $this->registerConfig(); // 8 $this->registerAssetMirror(); // 9 $this->registerInstallCommand(); // 10 $this->performAutoInstall(); // 11 $this->registeredPackage(); // 12 } ``` 1. **`bootPackager()`** returns a fresh `Packager`. Override it to return a subclass if you want to add your own `hasX()` builders. 2. **`validatePackager()`** throws `PackageConfigurationException` if that returned `null`. 3. **`getPackageBaseDir()`** reflects on your provider class to find its own file, and takes the directory. That directory becomes the root every relative path is resolved against — see [The Packager](https://package-toolkit.nyoncode.cz/packager.md#paths). 4. **`configure()`** — your method. This is where the whole package is described. 5. **Conditional callbacks** registered with `when()`, `whenEnvironment()` and friends run here — *after* `configure()` returns, so they see the fully built chain. See [Conditional configuration](https://package-toolkit.nyoncode.cz/conditional-configuration.md). 6. **`validatePackageConfiguration()`** throws `MissingNameException` if `name()` was never called. 7. **`registeringPackage()`** fires the `registering` [lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md). 8. **`registerConfig()`** requires each config file, checks it returns an array, and calls `mergeConfigFrom()`. A file that does not return an array throws `InvalidReturnTypeException`. 9. **`registerAssetMirror()`** declares the package's asset directory with the shared [`PublishedAssets`](https://package-toolkit.nyoncode.cz/assets.md#the-asset-mirror) singleton. Bookkeeping only — nothing is copied here. 10. **`registerPackageAssets()`** declares the entries a template renders with the shared [`PackageAssets`](https://package-toolkit.nyoncode.cz/assets.md#rendering-them-in-a-template) singleton. Also bookkeeping — no manifest is read and no file is touched until a tag is actually rendered. 11. **`registerInstallCommand()`** registers the install command, but only when the package is installable *and* the application is running in the console. 12. **`performAutoInstall()`** schedules a silent installation on `app.booted` when `installOnRun()` was set. 13. **`registeredPackage()`** fires the `registered` lifecycle hook. :::note Why config is merged in `register()` Laravel expects `mergeConfigFrom()` in `register()` so that other providers booting after yours already see your defaults. Publishing, by contrast, belongs in `boot()`, and that is where the toolkit does it. ::: ## What runs during `boot()` ```php public function boot(): void { $this->bootingPackage(); // booting lifecycle hook $this->registerPublishing(); // every publishX() below $this->registerPackageCommands(); // console only $this->registerAboutCommand(); // the toolkit's own `about` section $this->bootPackageResources(); // every bootX() below $this->bootedPackage(); // booted lifecycle hook } ``` ### `registerPublishing()` Guarded on `runningInConsole()` — a web request never builds a publish map. It calls, in order: `publishAssets()` · `publishConfig()` · `publishFactories()` · `publishMigrations()` · `publishProvider()` · `publishRoutes()` · `publishSeeders()` · `publishStubs()` · `publishTranslations()` · `publishViewComponentNamespaces()` · `publishViewComponents()` · `publishViews()` Each one returns early if the matching resource was never declared, so the cost of a resource you do not use is one boolean check. Full destinations and tag names are on [Publishing](https://package-toolkit.nyoncode.cz/publishing.md). ### `bootPackageResources()` ```php $this->bootAboutCommand() ->bootAssets() ->bootMigrations() ->bootRoutes() ->bootBroadcastChannels() ->bootMiddleware() ->bootEvents() ->bootOptimizes() ->bootSharedViewData() ->bootTranslations() ->bootViewComposers() ->bootViewComponentNamespaces() ->bootViewComponents() ->bootViews(); ``` The chain is fluent, so overriding one link and calling `parent::` keeps the rest intact. ## Hooks you can override ### `packageCommands()` Returns commands to register *in addition* to whatever `hasCommands()` discovered. Useful for a command that needs constructor arguments, since `hasCommands()` deals in class strings. ```php use Illuminate\Console\Command; public function packageCommands(): array { return [ new ImportCommand($this->app->make(Importer::class)), PruneCommand::class, ]; } ``` ### `aboutData()` Extra rows for your package's section in `php artisan about`. Values may be strings or closures; closures are evaluated lazily, which matters for anything that touches the database or config. ```php public function aboutData(): array { return [ 'Driver' => fn () => config('blog.driver'), 'Posts' => fn () => (string) Post::count(), ]; } ``` This only appears if you also called `hasAbout()` on the packager — see [The about command](https://package-toolkit.nyoncode.cz/about-command.md). ### `bootPackager()` Return your own `Packager` subclass to add package-specific builders: ```php class BlogPackager extends Packager { public function hasSearchIndex(string $driver): static { $this->searchDriver = $driver; return $this; } } class BlogServiceProvider extends PackageServiceProvider { public function bootPackager(): Packager { return new BlogPackager(); } public function configure(Packager $packager): void { // The parameter is typed as Packager, so narrow it if your // static analysis needs to see the subclass: /** @var BlogPackager $packager */ $packager->name('Blog')->hasSearchIndex('meilisearch'); } } ``` ### `getPackageBaseDir()` Override it if your provider does not live where your resources are. The default reflects on `static::class`, so it follows subclassing correctly. ## Registering your own bindings `configure()` describes resources; it is not where you bind services. Do that by overriding `register()` or `boot()` and calling `parent::` — the toolkit's own work is all inside those two methods, so it composes normally. ```php public function register(): void { parent::register(); // [tl! focus] $this->app->singleton(PostRepository::class, function ($app) { return new EloquentPostRepository($app['db']); }); } public function boot(): void { parent::boot(); // [tl! focus] Gate::policy(Post::class, PostPolicy::class); } ``` Forgetting `parent::` is the one way to break the toolkit: nothing at all gets wired up, and the failure is silent. If a package suddenly stops registering its views, check for a `register()` or `boot()` override missing its `parent::` call. :::tip Prefer lifecycle hooks for small additions For a handful of statements, the [lifecycle hooks](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md) keep everything inside `configure()` and read better than an override. ::: ## Contracts | Interface | Declares | |---|---| | `Contracts\Packable` | `configure()`, the four lifecycle methods, `aboutData()` | | `Contracts\ProvidesPackageServices` | extends `Packable`, adds `register()`, `boot()`, `packageCommands()` | | `Contracts\HasAbout` | **Deprecated.** Removed in 3.0 — `Packable` already declares `aboutData()` | `PackageServiceProvider` implements `ProvidesPackageServices`, so implementing `Packable` on your own provider is documentation rather than a requirement. ## Exceptions | Exception | Thrown when | |---|---| | `MissingNameException` | `name()` was never called | | `PackageConfigurationException` | the packager is `null`, or the provider's own file cannot be reflected | | `InvalidReturnTypeException` | a config file does not return an array | | `InvalidLanguageDirectoryException` | a translation subdirectory is not a known language code | | `Illuminate\Contracts\Filesystem\FileNotFoundException` | a named resource file does not exist | | `Symfony\…\DirectoryNotFoundException` | a resource directory does not exist or is unreadable | All of them fire during `register()` or `boot()`, which is to say: on the first request or artisan call after installation, not months later. --- # The Packager > Getting started · https://package-toolkit.nyoncode.cz/packager.md `Packager` is the object your `configure()` method receives. It carries the whole description of your package: what it has, where those things live, and how they should be named once they reach a consumer's application. Every builder on it returns `static`, so configuration is one chain, and every builder validates what it is given the moment it is given it. ## Name ```php $packager->name('My Awesome Package'); ``` Required. An empty or whitespace-only name throws `InvalidArgumentException` immediately; a name that is never set throws `MissingNameException` when the provider validates its configuration. The name is the human-facing label. It appears in `php artisan about`, in the install command's description (`Install My Awesome Package package`) and in its welcome banner. ## Short name The short name is the machine-facing identifier, and it is doing far more work than the name is. It is the prefix of every publish tag, the namespace of your views and translations, the directory your assets are mirrored into, and the prefix of your install command. ```php $packager->name('My Awesome Package'); $packager->shortName(); // 'my-awesome-package' ``` It is derived with `Str::kebab()` the first time it is asked for, and cached. To set it yourself: ```php $packager->name('My Awesome Package')->hasShortName('awesome'); ``` `hasShortName()` validates twice over. The value must already equal `Str::kebab()` of itself, and must match `/^[a-z0-9-]+$/` — lowercase letters, digits and hyphens only. Anything else throws `InvalidArgumentException` with the offending value in the message. ```php $packager->hasShortName('Awesome'); // not kebab-case [tl! --] $packager->hasShortName('my_awesome'); // underscore [tl! --] $packager->hasShortName('awesome-2'); // [tl! ++] ``` Here is what a short name of `blog` buys you, in full: | | | |---|---| | Publish tags | `blog::config`, `blog::views`, `blog::migrations`, … | | Views | `view('blog::post')` | | Translations | `trans('blog::messages.title')` | | Install command | `php artisan blog:install` | | Published views | `resources/views/vendor/blog/` | | Published translations | `lang/vendor/blog/` | | Published routes | `routes/vendor/blog/` | | Published stubs | `stubs/blog/` | | Published assets | `public/vendor/blog/` | | Optimize cache key | `blog` | :::warning Changing the short name is a breaking change Every one of the paths above moves with it, and a consumer who has published views or translations will find their overrides silently ignored afterwards. Treat it the way you would treat a public API rename. ::: ## Paths Every relative path a `hasX()` method takes is resolved against the **base path**, which the provider sets from its own location: ```php $this->packager->hasBasePath($this->getPackageBaseDir()); ``` `getPackageBaseDir()` reflects on your provider class, takes its filename, and returns the directory. For the conventional layout — provider at `src/BlogServiceProvider.php` — the base path is `src/`, which is why the defaults reach outwards with `../`: ```text acme/blog/ ├── config/ ← '../config' ├── database/ │ └── migrations/ ← '../database/migrations' ├── resources/views/ ← '../resources/views' ├── routes/ ← '../routes' └── src/ ← the base path ├── BlogServiceProvider.php └── Commands/ ← 'Commands' (no ../ — inside src) ``` ### The `src/Providers` special case If the base path ends in `src/Providers`, everything from `/Providers` onwards is trimmed off, so the base path becomes `src` again: ```php // src/Providers/BlogServiceProvider.php // base path resolves to src/, not src/Providers/ ``` Every default keeps working. This is the only path rewriting the toolkit does. ### Cross-platform normalisation Paths are normalised before use: separators are converted to the host's `DIRECTORY_SEPARATOR`, duplicate separators collapse, and a trailing separator is stripped. Both `'../config'` and `'..\\config'` work on either platform, and the toolkit's own test suite runs on Windows and Linux for exactly this reason. ## How files are resolved Every file-based builder — `hasConfig()`, `hasRoutes()`, `hasMigrations()`, `hasSeeders()`, `hasFactories()`, `hasStubs()`, `hasBroadcastChannels()`, `hasCommands()`, `hasProviders()` — funnels into one resolver with the same two modes. ### Discovery mode Pass nothing, and the directory is scanned: ```php $packager->hasConfig(); // every file in ../config $packager->hasRoutes(); // every file in ../routes $packager->hasMigrations(); // every file in ../database/migrations ``` Discovery has three properties worth knowing: - **It is not recursive.** Only files directly inside the directory are found. A `database/migrations/tenant/` subdirectory is invisible. - **A missing directory is an error.** `DirectoryNotFoundException`, naming the resolved absolute path — so a typo in a custom `directory:` argument surfaces immediately. - **Unreadable files are skipped**, not fatal. A file that cannot be read is left out of the set rather than taking the whole package down. ### Explicit mode Pass a filename, or a list of them, and only those are used — in the order you list them: ```php $packager->hasRoutes('api.php'); $packager->hasRoutes(['api.php', 'web.php']); $packager->hasConfig(['blog.php', 'blog-cache.php']); ``` A name that does not resolve to a file throws `FileNotFoundException` with the resource type in the message: ```text Route file [admin.php] does not exist in directory [../routes]. ``` Order matters more than it looks. Routes are loaded in the order given; migrations are published in the order given, and for [timeless migrations](https://package-toolkit.nyoncode.cz/migrations.md#timeless-migrations) that order decides the generated timestamps, and therefore the order they run in. ### Relative and absolute paths A name starting with `..`, `/` or a drive letter is treated as a path from the base path rather than a filename inside the directory. This is how `hasProviders()` reaches into `../stubs`: ```php $packager->hasProviders([ '../stubs/BlogServiceProvider.stub', '../stubs/BlogEventServiceProvider.stub', ]); ``` ### Custom directories Every builder takes the directory as its second argument, so nothing forces you into the conventional layout: ```php $packager ->hasConfig(directory: '../resources/configuration') ->hasRoutes(directory: '../resources/routes') ->hasMigrations(directory: '../database/schema'); ``` ## Introspection Each `hasX()` sets a flag, and each flag has a reader. The provider uses them to skip work; you can use them in tests, or in your own `boot()` override. | Reader | True after | |---|---| | `isConfigurable()` | `hasConfig()` found at least one file | | `isRoutable()` | `hasRoutes()` found at least one file | | `isBroadcastable()` | `hasBroadcastChannels()` found at least one file | | `isMigratable()` | `hasMigrations()` found at least one file | | `isSeedable()` | `hasSeeders()` found at least one file | | `isFactorable()` | `hasFactories()` found at least one file | | `isStubbable()` | `hasStubs()` found at least one file | | `isTranslatable()` | `hasTranslations()` found a non-empty directory | | `isViewable()` | `hasViews()` | | `isViewComponentized()` | `hasComponents()` / `hasComponent()` | | `isViewComponentNamespaceConfigured()` | `hasComponentNamespaces()` / `hasComponentNamespace()` | | `isViewComposable()` | `hasViewComposer()` | | `isSharedWithViews()` | `hasSharedDataForAllViews()` | | `isAssetable()` | `hasAssets()` | | `isCommandable()` | `hasCommands()` / `hasCommand()` | | `isEventable()` | `hasEvents()` / `hasSubscribers()` | | `isOptimizable()` | `hasOptimizeCommands()` | | `isProvidable()` | `hasProviders()` / `hasProvider()` | | `isInstallable()` | `hasInstallCommand()` or a preset | | `isAboutable()` | `hasAbout()` | | `isSetMiddlewareAliases()` | `hasMiddlewareAliases()` | | `isSetMiddlewareGroups()` | `hasMiddlewareGroups()` | | `isSetMiddlewareGlobals()` | `hasMiddlewareGlobals()` | The matching getters return the resolved values: `configFiles()`, `routeFiles()`, `migrationFiles()`, `seederFiles()`, `factoryFiles()`, `stubFiles()`, `broadcastChannelFiles()` (all arrays of `Support\SplFileInfo`), plus `views()`, `translationPath()`, `assetDirectory()`, `viewComponents()`, `viewComponentPaths()`, `viewComponentNamespaces()`, `viewComposers()`, `viewSharedData()`, `events()`, `subscribers()`, `optimizeCommands()`, `providers()` and `commands`. ## `Support\SplFileInfo` File sets are returned as a thin subclass of PHP's `SplFileInfo` with one addition that the toolkit leans on constantly: ```php $file->getBasename(); // 'blog.php' — as SplFileInfo $file->getBaseFileName(); // 'blog' — added: name without extension $file->getPathname(); // absolute path $file->getFileSize(); // alias of getSize() ``` `getBaseFileName()` is what makes `config/blog.php` merge under the config key `blog`, and what lets a seeder shipped as `TestSeeder.stub` publish as `TestSeeder.php`. --- # Lifecycle hooks > Getting started · https://package-toolkit.nyoncode.cz/lifecycle-hooks.md Four closures, registered on the packager, run at the four points where the provider does its work. They exist so that small pieces of custom wiring can stay inside `configure()` rather than forcing an override of `register()` or `boot()`. ```php $packager ->name('Blog') ->registeringPackage(function (Packager $packager) { // before anything is registered }) ->registeredPackage(function (Packager $packager) { // after config, asset mirror and install command are registered }) ->bootingPackage(function (Packager $packager) { // before publishing, commands and resources are booted }) ->bootedPackage(function (Packager $packager) { // after everything is booted }); ``` Each callback receives the `Packager`, so it can read anything the chain declared. ## When each one fires ```php // register() $this->configure($packager); $packager->executeConditionalCallbacks(); // ── registering ────────────────────────────── $this->registerConfig(); $this->registerAssetMirror(); $this->registerInstallCommand(); $this->performAutoInstall(); // ── registered ─────────────────────────────── // boot() // ── booting ────────────────────────────────── $this->registerPublishing(); $this->registerPackageCommands(); $this->registerAboutCommand(); $this->bootPackageResources(); // ── booted ─────────────────────────────────── ``` Two consequences follow from that ordering, and they are the whole reason to pick one hook over another: - **`registering` runs after `configure()` returns.** The chain is complete by then, including any [conditional callbacks](https://package-toolkit.nyoncode.cz/conditional-configuration.md). There is no hook that fires mid-chain. - **`booted` is the only hook where every resource is live.** Views are loaded, routes are in the router, translations resolve, middleware is registered. Anything that reads a resource belongs here. | Hook | Safe to use | Not yet available | |---|---|---| | `registering` | container bindings, `$packager` state | config values from your package | | `registered` | your merged config, `config()` | views, routes, translations | | `booting` | everything from `registered` | views, routes, translations, middleware | | `booted` | everything | — | ## Choosing a hook ### `registeringPackage` Bind services before anything else touches the container. ```php $packager->registeringPackage(function () { app()->singleton(BlogRepository::class, EloquentBlogRepository::class); }); ``` ### `registeredPackage` Your config has been merged, so this is the first point where `config('blog.…')` is meaningful. ```php $packager->registeredPackage(function () { app()->bind(SearchEngine::class, function () { return match (config('blog.search.driver')) { 'meilisearch' => new MeilisearchEngine(config('blog.search.host')), default => new DatabaseEngine(), }; }); }); ``` ### `bootingPackage` Anything that must be in place *before* your resources register — a macro your Blade components depend on, a custom validation rule your routes use. ```php $packager->bootingPackage(function () { Str::macro('excerpt', fn (string $value, int $words = 30) => Str::words($value, $words)); }); ``` ### `bootedPackage` The general-purpose hook. Policies, gates, scheduled tasks, morph maps, macros on things that only exist once the framework has booted. ```php use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Gate; $packager->bootedPackage(function () { Gate::policy(Post::class, PostPolicy::class); Relation::enforceMorphMap([ 'post' => Post::class, 'comment' => Comment::class, ]); app()->booted(function () { app(Schedule::class)->command('blog:prune')->daily(); }); }); ``` :::note Scheduling needs one more layer `Schedule` is resolved after all providers boot, so registering a scheduled command from `bootedPackage` still needs the `app()->booted()` wrapper above. That is Laravel's ordering, not the toolkit's. ::: ## Hooks and provider methods have the same names `PackageServiceProvider` has four methods called `registeringPackage()`, `registeredPackage()`, `bootingPackage()` and `bootedPackage()` — with no arguments. Those are what *fire* the hooks. The identically named methods on `Packager` take a `Closure` and *register* them. Both are usable, and they are not alternatives — overriding the provider method without calling `parent::` stops the closure from ever running: ```php // ✗ the closure registered in configure() never fires public function bootedPackage(): void { Gate::policy(Post::class, PostPolicy::class); } // ✓ public function bootedPackage(): void { parent::bootedPackage(); // [tl! focus] Gate::policy(Post::class, PostPolicy::class); } ``` Prefer the closure form. It keeps the whole description of the package in one place, and it cannot be broken by a forgotten `parent::`. ## One closure per hook Registering a hook twice replaces the first: ```php $packager ->bootedPackage(fn () => Gate::policy(Post::class, PostPolicy::class)) ->bootedPackage(fn () => Gate::policy(Comment::class, CommentPolicy::class)); // Only the comment policy is registered. ``` Combine them into one closure instead — or, if the two pieces belong to genuinely different concerns, use [conditional configuration](https://package-toolkit.nyoncode.cz/conditional-configuration.md), which accumulates. ## Exceptions inside a hook Lifecycle hook closures are not wrapped in a `try`. An exception thrown inside one propagates out of `register()` or `boot()` and takes the request with it. That is intentional: a package that cannot wire itself up should fail loudly. This is the opposite of the [conditional callbacks](https://package-toolkit.nyoncode.cz/conditional-configuration.md#error-handling), which report and continue. If you want that behaviour in a hook, do it yourself: ```php $packager->bootedPackage(function () { try { BlogSearchIndex::warm(); } catch (Throwable $e) { report($e); } }); ``` --- # Conditional configuration > Getting started · https://package-toolkit.nyoncode.cz/conditional-configuration.md Not every package should register everything, everywhere. Debug routes belong in local development. An integration with another package should only wire itself up when that package is installed. A maintenance command has no business existing during a web request. The packager's conditional builders express that inside the same chain as everything else. ```php $packager ->name('Blog') ->hasConfig() ->hasRoutes(['api.php']) ->whenLocal(function (Packager $packager) { $packager->hasRoutes(['api.php', 'debug.php']); }) ->whenClassExists(Livewire\Livewire::class, function (Packager $packager) { $packager->hasComponents('blog', LivewirePostList::class); }); ``` ## When callbacks run Each conditional builder evaluates its condition **immediately**, and queues the callback only if it passed. The queue is drained by the provider straight after `configure()` returns: ```php $this->configure($this->packager); // conditions evaluated, callbacks queued $this->packager->executeConditionalCallbacks(); // callbacks run here $this->validatePackageConfiguration(); ``` Two things follow from this. First, callbacks run in the order they were registered, and a later one sees whatever an earlier one did. Second, they run *after* the whole chain — so a callback can override something declared further down: ```php $packager ->name('Blog') ->whenLocal(fn (Packager $p) => $p->hasRoutes(['api.php', 'debug.php'])) ->hasRoutes(['api.php']); // Locally: api.php + debug.php. The callback runs last and replaces the set. ``` Because `hasRoutes()` *replaces* the file set while `hasCommands()` *appends* to it, the effect of running last differs per resource. Check the page for the resource you are toggling if the distinction matters. ## The builders ### `when()` and `unless()` ```php $packager ->when(config('blog.api.enabled'), fn (Packager $p) => $p->hasRoutes(['api.php'])) ->unless(app()->runningUnitTests(), fn (Packager $p) => $p->hasAssets()); ``` `unless()` is `when(! $condition, …)` — nothing more. :::warning `config()` inside a condition Your package's own config has not been merged yet when `configure()` runs — that happens in `registerConfig()`, later in `register()`. `config('blog.api.enabled')` therefore reads only what the application published, and is `null` if the consumer never published the file. Use an environment variable, a `whenClassExists()` check, or move the decision into the [`registeredPackage` hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#registeredpackage) where config is available. ::: ### Environments ```php $packager ->whenEnvironment('staging', fn (Packager $p) => $p->hasRoutes(['debug.php'])) ->whenEnvironment(['local', 'testing'], fn (Packager $p) => $p->hasSeeders()) ->whenProduction(fn (Packager $p) => $p->hasOptimizeCommands('blog:cache', 'blog:clear')) ->whenLocal(fn (Packager $p) => $p->hasStubs()); ``` `whenLocal()` matches **both** `local` and `development`. `whenProduction()` matches `production`. The current environment is resolved defensively, in this order: `app()->environment()`, then `config('app.env')`, then the `APP_ENV` / `ENVIRONMENT` variables — and if none of those answer, it falls back to `'production'`. That default is deliberately the restrictive one: a package whose environment cannot be determined should behave as though it is live. ### Class, function and extension checks ```php $packager ->whenClassExists(Laravel\Horizon\Horizon::class, function (Packager $p) { $p->hasConfig(['blog-horizon.php']); }) ->whenFunctionExists('imagecreatetruecolor', function (Packager $p) { $p->hasCommands([GenerateThumbnails::class]); }) ->whenExtensionLoaded('redis', function (Packager $p) { $p->hasOptimizeCommands('blog:cache-warm'); }); ``` `whenClassExists()` is the idiomatic way to build optional integrations. It works because Composer's autoloader answers `class_exists()` without the class having to be loaded already. ### Console ```php $packager->whenConsole(function (Packager $packager) { $packager->hasCommands()->hasStubs(); }); ``` The check is `php_sapi_name() === 'cli' || app()->runningInConsole()`, so it also covers artisan running through a non-CLI SAPI. :::note Commands are already console-guarded `registerPackageCommands()` returns early unless `runningInConsole()`. Wrapping `hasCommands()` in `whenConsole()` saves the file discovery, not the registration — worth it for a package with many commands, pointless for a package with two. ::: ### `whenMultiple()` For a table of conditions built elsewhere — a compatibility matrix, say: ```php $packager->whenMultiple([ [ 'condition' => class_exists(Livewire\Livewire::class), 'callback' => fn (Packager $p) => $p->hasViews(directory: '../resources/views/livewire'), ], [ 'condition' => version_compare(app()->version(), '13.0', '>='), 'callback' => fn (Packager $p) => $p->hasConfig(['blog-13.php']), ], ]); ``` Entries missing either key are skipped silently. ## Error handling A callback that throws does **not** take the package down. The exception is passed to `report()` — or to `error_log()` when the application has not booted far enough for `report()` to exist — and the remaining callbacks still run. ```php $packager ->whenLocal(function () { throw new RuntimeException('boom'); // reported, then execution continues }) ->whenLocal(fn (Packager $p) => $p->hasSeeders()); // still runs ``` This is the right trade for optional configuration: a broken integration with a third-party package should degrade that integration, not break the application. It does mean a silently missing resource is worth checking your log for. If you want a failure to be fatal, use a [lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#exceptions-inside-a-hook) instead — those are not wrapped. ## Introspection ```php $packager->conditionalCallbacksExecuted(); // bool $packager->getPendingConditionalCallbacksCount(); // int — 0 once drained $packager->resetConditionalCallbacks(); // clear the queue and the flag ``` `executeConditionalCallbacks()` is idempotent: the second call returns immediately. `reset` exists for tests that reuse a packager across cases. ## Recipes ### Optional integration with another package ```php $packager ->name('Blog') ->hasConfig() ->whenClassExists(Spatie\MediaLibrary\MediaCollections\Models\Media::class, function (Packager $p) { $p->hasMigrations(['create_blog_media_table.php']) ->hasConfig(['blog.php', 'blog-media.php']); }); ``` ### Development-only tooling ```php $packager ->name('Blog') ->hasConfig() ->hasRoutes(['api.php']) ->whenLocal(function (Packager $p) { $p->hasRoutes(['api.php', 'debug.php']) // [tl! ++] ->hasSeeders() // [tl! ++] ->hasFactories() // [tl! ++] ->hasStubs(); // [tl! ++] }); ``` ### Feature flag from the environment ```php $packager->when( filter_var(env('BLOG_API_ENABLED', true), FILTER_VALIDATE_BOOL), fn (Packager $p) => $p->hasRoutes(['api.php']), ); ``` :::warning `env()` and cached config The usual Laravel caveat applies in full here: once the consumer runs `php artisan config:cache`, Laravel stops loading the `.env` file, and `env()` returns only its default. A flag read this way will quietly revert to `true` in exactly the environment where it matters most. If the flag has to survive `config:cache`, publish a config file for it and make the decision in the [`registeredPackage` hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#registeredpackage), which runs after your config is merged: ```php $packager ->name('Blog') ->hasConfig() ->registeredPackage(function () { if (! config('blog.api.enabled')) { return; } // …bind or register the API-only pieces here }); ``` ::: --- # Config > Config & routing · https://package-toolkit.nyoncode.cz/config.md ```php public function hasConfig( string|array|null $configFiles = null, string $directory = '../config', ): static ``` `hasConfig()` does two jobs at once. It merges each file into the application's config at `register()` time, so your defaults are readable without anyone publishing anything, and it registers the same files for publishing under `{short-name}::config`. ## Discovering every config file ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig(); ``` ```text acme/blog/ └── config/ ├── blog.php └── blog-cache.php ``` Both files are merged. **The config key is the filename without its extension**, so: ```php config('blog.per_page'); config('blog-cache.ttl'); ``` ## Naming files explicitly ```php $packager->hasConfig('blog.php'); $packager->hasConfig(['blog.php', 'blog-cache.php']); ``` A file that does not exist throws `FileNotFoundException`: ```text Config file [blog-cache.php] does not exist in directory [../config]. ``` ## A different directory ```php $packager->hasConfig(directory: '../resources/configuration'); $packager->hasConfig(['blog.php'], '../src/config'); ``` Relative paths resolve from the directory your provider lives in — see [The Packager](https://package-toolkit.nyoncode.cz/packager.md#paths). ## Writing the config file A config file must `return` an array. The toolkit checks this during registration: ```php title="config/blog.php" return [ /* |-------------------------------------------------------------------------- | Posts per page |-------------------------------------------------------------------------- */ 'per_page' => env('BLOG_PER_PAGE', 15), 'cache' => [ 'enabled' => env('BLOG_CACHE', true), 'store' => env('BLOG_CACHE_STORE'), 'ttl' => 3600, ], 'models' => [ 'post' => \Acme\Blog\Models\Post::class, ], ]; ``` Forget the `return` and registration fails with a message that names the file: ```text Configuration file [blog] must return an array. ``` That check runs once, at `register()`, which means a broken config file fails on the first request after installation rather than at the first `config()` call somewhere deep in a controller. ## How merging behaves `mergeConfigFrom()` is a **shallow** merge, and that is Laravel's behaviour, not the toolkit's. If a consumer publishes your config and your next release adds a key inside an existing nested array, their published file wins for that whole array and your new key is invisible. ```php // Your package ships: 'cache' => ['enabled' => true, 'ttl' => 3600, 'tags' => ['blog']], // A consumer published an older copy containing: 'cache' => ['enabled' => false], // config('blog.cache') is: [tl! highlight:1] 'cache' => ['enabled' => false], // no ttl, no tags — the published array won whole ``` Two ways to live with it: ```php $ttl = config('blog.cache.ttl', 3600); // read defensively for anything added after 1.0 [tl! focus] ``` ```php 'cache' => ['enabled' => true, 'ttl' => 3600], // [tl! --] 'cache_enabled' => true, // [tl! ++] 'cache_ttl' => 3600, // [tl! ++] ``` The second keeps additions at the top level, where the shallow merge does reach them. ## Publishing ```bash php artisan vendor:publish --tag=blog::config ``` Each file lands in `config/` under its own filename — `config/blog.php`, `config/blog-cache.php`. Note that this is the *basename*, so two config files with the same name in different package directories would collide; give them a package-specific prefix. Add it to your [install command](https://package-toolkit.nyoncode.cz/install-command.md): ```php use NyonCode\LaravelPackageToolkit\Commands\InstallCommand; $packager->hasInstallCommand(function (InstallCommand $command) { $command->publishConfig(); }); ``` ## Reading config from your package Because the file is merged, your package can always read its own defaults — published or not: ```php namespace Acme\Blog; class PostRepository { public function paginate(): LengthAwarePaginator { return Post::query()->paginate(config('blog.per_page')); } } ``` For a value used across the package, a small accessor beats scattering string keys: ```php namespace Acme\Blog; class Blog { public static function model(string $name): string { return config("blog.models.{$name}"); } public static function cacheTtl(): int { return (int) config('blog.cache.ttl', 3600); } } ``` ## Multiple config files A package large enough to warrant several files should still keep them recognisably one package: ```php $packager->hasConfig(['blog.php', 'blog-cache.php', 'blog-search.php']); ``` ```php config('blog.per_page'); config('blog-cache.ttl'); config('blog-search.driver'); ``` They all publish under the one `blog::config` tag — there is no per-file tag. If a consumer should be able to publish them separately, ship one file and document its sections instead. ## Introspection ```php $packager->isConfigurable(); // bool $packager->configFiles(); // Support\SplFileInfo[] foreach ($packager->configFiles() as $file) { $file->getBaseFileName(); // 'blog' — the config key $file->getBasename(); // 'blog.php' — the published filename $file->getPathname(); // absolute source path } ``` --- # Routes > Config & routing · https://package-toolkit.nyoncode.cz/routes.md ```php public function hasRoutes( array|string|null $routeFiles = null, string $directory = '../routes', ): static ``` Route files are loaded at boot with Laravel's own `loadRoutesFrom()`, which means they participate in `route:cache` exactly like an application's routes do. ## Loading every route file ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasRoutes(); ``` ```text acme/blog/ └── routes/ ├── web.php └── api.php ``` ## Naming files explicitly ```php $packager->hasRoutes('api.php'); $packager->hasRoutes(['api.php', 'web.php']); ``` The order is the load order. It rarely matters, but when two files register the same URI the first one wins, so an explicit list is the way to make that deterministic. :::warning Discovery picks up *every* file `hasRoutes()` with no arguments loads everything in `routes/` — including a `channels.php` that is meant for [broadcast channels](https://package-toolkit.nyoncode.cz/broadcast-channels.md). A channel authorization callback loaded as a route file is registered inside a route group, which is the wrong destination for it. Either name your route files explicitly, or keep channels in their own directory. ::: ## Writing the route file The toolkit loads the file and stops there. It does **not** apply a prefix, a middleware group or a name prefix on your behalf — those belong inside the file, where a reader of your package can see them: ```php title="routes/api.php" use Acme\Blog\Http\Controllers\PostController; use Illuminate\Support\Facades\Route; Route::middleware(['api', 'throttle:60,1']) ->prefix('api/blog') ->name('blog.api.') ->group(function () { Route::get('/posts', [PostController::class, 'index'])->name('posts.index'); Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show'); }); ``` That gives `/api/blog/posts` and the route name `blog.api.posts.index`. Prefixing route names with your short name is worth doing: route names are a flat global namespace, and `posts.index` is a collision waiting to happen. ### Making the prefix configurable ```php title="config/blog.php" return [ 'route' => [ 'prefix' => 'api/blog', 'middleware' => ['api'], ], ]; ``` ```php title="routes/api.php" Route::middleware(config('blog.route.middleware')) ->prefix(config('blog.route.prefix')) ->name('blog.') ->group(function () { Route::get('/posts', [PostController::class, 'index'])->name('posts.index'); }); ``` This works because config is merged during `register()` and routes are loaded during `boot()` — your defaults are in place by the time the file runs, whether or not the consumer published it. ## Publishing ```bash php artisan vendor:publish --tag=blog::routes ``` Files land in `routes/vendor/{short-name}/`: ```text routes/vendor/blog/api.php routes/vendor/blog/web.php ``` Publishing a route file is a **copy, not a takeover.** Your package keeps loading its own file from `vendor/`; the published copy is inert until the consumer loads it themselves: ```php title="bootstrap/app.php" ->withRouting( web: __DIR__.'/../routes/web.php', then: function () { require base_path('routes/vendor/blog/api.php'); // [tl! focus] }, ) ``` Which means a consumer who publishes and edits, without doing that, ends up with two copies of every route — yours from the package, theirs from the published file, both registered. Say so in your readme, or offer a config flag: ```php title="config/blog.php" 'load_routes' => true, ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->registeredPackage(function (Packager $packager) { if (config('blog.load_routes', true)) { $packager->hasRoutes(); } }); ``` ## Route model binding Register bindings from the [`booted` lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#bootedpackage), which runs after routes are loaded: ```php use Illuminate\Support\Facades\Route; $packager->bootedPackage(function () { Route::bind('post', function (string $value) { return Post::where('slug', $value)->firstOrFail(); }); }); ``` ## Controllers and middleware Controllers are just classes in your package — nothing to declare. Middleware referenced by alias in a route file must be registered first; see [Middleware](https://package-toolkit.nyoncode.cz/middleware.md): ```php $packager ->name('Blog') ->hasMiddlewareAliases(['blog.author' => EnsureUserIsAuthor::class]) ->hasRoutes(); ``` ```php title="routes/api.php" Route::middleware(['api', 'blog.author'])->group(/* … */); ``` `bootRoutes()` actually runs *before* `bootMiddleware()` in the toolkit's boot sequence, which sounds like a problem and is not: Laravel resolves a middleware alias when a request is dispatched, not when the route is declared. The alias only has to exist by the time a request arrives. ## Route caching Because loading goes through `loadRoutesFrom()`, `php artisan route:cache` covers your package's routes. The usual constraint applies to your files as much as an application's: **no closures**. ```php // ✗ breaks route:cache Route::get('/health', fn () => response()->json(['ok' => true])); // ✓ Route::get('/health', HealthController::class); ``` An invokable single-action controller is the cheap fix. ## Introspection ```php $packager->isRoutable(); // bool $packager->routeFiles(); // Support\SplFileInfo[] ``` --- # Broadcast channels > Config & routing · https://package-toolkit.nyoncode.cz/broadcast-channels.md ```php public function hasBroadcastChannels( array|string|null $channelFiles = null, string $directory = '../routes', ): static ``` Added in **2.4.0**. A channel file is the one that calls `Broadcast::channel()` to authorize private and presence channels. Before this existed, the only way to ship one from a package was to pass it to `hasRoutes()` — which loads it through `loadRoutesFrom()`, inside a route group. That is the wrong destination for an authorization callback, and it put your channel names into the router. ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasBroadcastChannels(); ``` Each declared file is `require`d at boot, so its `Broadcast::channel()` calls run against the application's broadcaster. ## Writing the channel file ```php title="routes/channels.php" use Acme\Blog\Models\Post; use Illuminate\Support\Facades\Broadcast; Broadcast::channel('blog.post.{postId}', function ($user, int $postId) { return Post::find($postId)?->isVisibleTo($user) ?? false; }); Broadcast::channel('blog.presence.{postId}', function ($user, int $postId) { return ['id' => $user->id, 'name' => $user->name]; }); ``` Prefix your channel names with the package short name. Channel names are a flat global namespace shared with the application and every other package — `post.{id}` will collide sooner or later. ## Keep channels out of the routes directory The default directory is `../routes`, matching Laravel's own convention. That creates a trap: ```php $packager ->name('Blog') ->hasRoutes() // ← discovers routes/channels.php too [tl! ~~] ->hasBroadcastChannels(); // ← and so does this [tl! ~~] ``` Both builders discover the same directory, so `channels.php` is loaded twice — once correctly, once as a route file. Two ways out, both fine. Name the files explicitly: ```php $packager ->hasRoutes() // [tl! --] ->hasBroadcastChannels(); // [tl! --] ->hasRoutes(['web.php', 'api.php']) // [tl! ++] ->hasBroadcastChannels(['channels.php']); // [tl! ++] ``` Or give channels their own directory, and let discovery keep working: ```php $packager ->hasRoutes() ->hasBroadcastChannels(directory: '../broadcasting'); // [tl! focus] ``` ## Several channel files ```php $packager->hasBroadcastChannels( ['channels.php', 'presence-channels.php'], '../broadcasting', ); ``` All of them are required, in order. ## Applications without broadcasting The toolkit requires `illuminate/support`, not `illuminate/broadcasting`. If the broadcasting component is not installed, `bootBroadcastChannels()` checks for `BroadcastManager` and returns early — no exception, no fatal error. Your package stays installable in an application that does not broadcast. ```php if (! class_exists(BroadcastManager::class)) { return $this; } ``` ## Channels are deliberately not publishable There is no `blog::channels` tag, and running `vendor:publish --tag=blog::channels` publishes nothing: ```bash php artisan vendor:publish --tag=blog::channels # Exits 0, writes nothing. ``` That is a design decision, not an omission. An application does not load `routes/channels.php` unless its own bootstrap asks for it, so a published copy would sit there looking authoritative while the package quietly kept using its own — the worst possible outcome for a file whose entire job is authorization. **Consumers override a channel by re-registering it from their own application.** The last registration for a channel name wins, and application providers boot after package providers: ```php title="app/Providers/AppServiceProvider.php" public function boot(): void { Broadcast::channel('blog.post.{postId}', function ($user, int $postId) { return $user->isAdmin() || Post::find($postId)?->isVisibleTo($user); }); } ``` Document the channel names your package registers, the way you would document a public API — that list *is* the extension point. ## Using the channels From your package's events: ```php title="src/Events/PostPublished.php" namespace Acme\Blog\Events; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; class PostPublished implements ShouldBroadcast { use Dispatchable, InteractsWithSockets; public function __construct(public Post $post) {} public function broadcastOn(): array { return [new PrivateChannel("blog.post.{$this->post->id}")]; } public function broadcastAs(): string { return 'blog.post.published'; } } ``` And from the consumer's JavaScript: ```js Echo.private(`blog.post.${postId}`) .listen('.blog.post.published', (event) => { console.log(event.post) }) ``` ## Testing ```php use Illuminate\Support\Facades\Broadcast; test('the package registers its channels', function () { $channels = Broadcast::getChannels()->keys()->all(); expect($channels)->toContain('blog.post.{postId}') ->and($channels)->toContain('blog.presence.{postId}'); }); test('channel files are not registered as routes', function () { expect(collect(app('router')->getRoutes()->getRoutes()) ->contains(fn ($route) => str_contains($route->uri(), 'blog.post'))) ->toBeFalse(); }); ``` ## Introspection ```php $packager->isBroadcastable(); // bool $packager->broadcastChannelFiles(); // Support\SplFileInfo[] ``` --- # Middleware > Config & routing · https://package-toolkit.nyoncode.cz/middleware.md Three registration styles, matching the three things Laravel's router and kernel can do with middleware. ```php public function hasMiddlewareAliases(array $aliases): static public function hasMiddlewareGroups(array $groups): static public function hasMiddlewareGlobals(array $middlewares): static ``` ## Aliases An alias is a short name a route can refer to. It costs nothing until a route uses it, which makes it the right default for a package. ```php title="src/BlogServiceProvider.php" use Acme\Blog\Http\Middleware\{EnsureUserIsAuthor, VerifyBlogToken}; $packager ->name('Blog') ->hasMiddlewareAliases([ 'blog.author' => EnsureUserIsAuthor::class, 'blog.token' => VerifyBlogToken::class, ]); ``` ```php title="routes/api.php" Route::middleware(['api', 'blog.token'])->group(function () { Route::post('/posts', [PostController::class, 'store'])->middleware('blog.author'); }); ``` Consumers can use the alias too, on their own routes — which is the point. **Prefix your aliases.** `auth` is taken; `blog.auth` is not. A package that registers a bare alias will overwrite whatever the application had under that name, silently, and the failure surfaces somewhere completely unrelated. ## Groups Pushes middleware onto an existing group, or creates a new one: ```php $packager->hasMiddlewareGroups([ 'web' => [TrackBlogVisit::class], 'blog' => [ VerifyBlogToken::class, EnsureUserIsAuthor::class, ], ]); ``` ```php title="routes/web.php" Route::middleware('blog')->group(function () { // … }); ``` The middleware is appended with `pushMiddlewareToGroup()`, so it runs **after** whatever the group already contained. Repeated calls accumulate per group rather than replacing it: ```php $packager ->hasMiddlewareGroups(['blog' => [VerifyBlogToken::class]]) ->hasMiddlewareGroups(['blog' => [EnsureUserIsAuthor::class]]); // blog => [VerifyBlogToken, EnsureUserIsAuthor] ``` :::warning Pushing into `web` affects the whole application Every request through the `web` group now runs your middleware — including pages that have nothing to do with your package. Reach for it only when the behaviour genuinely is application-wide (request tracking, a locale switch your package owns), and make it configurable when you can: ```php $packager->when( config('blog.track_visits', false), fn (Packager $p) => $p->hasMiddlewareGroups(['web' => [TrackBlogVisit::class]]), ); ``` ::: ## Global middleware Pushed onto the HTTP kernel, so it runs on **every request**, before routing: ```php $packager->hasMiddlewareGlobals([ ForceJsonResponse::class, ]); ``` :::danger Almost never the right choice for a package Global middleware runs on every request in the host application, including routes owned by other packages and by the application itself. It cannot be excluded per route and cannot be turned off without editing your package. Very few packages have a legitimate need for it — a security package enforcing a header, perhaps. If your middleware needs to run for your package's routes, put it in your route file. If it needs to run for the application's routes, push it into a group and let the consumer decide. ::: ## Writing middleware ```php title="src/Http/Middleware/EnsureUserIsAuthor.php" namespace Acme\Blog\Http\Middleware; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class EnsureUserIsAuthor { public function handle(Request $request, Closure $next): Response { abort_unless($request->user()?->isBlogAuthor(), 403, __('blog::messages.not_an_author')); return $next($request); } } ``` ### With parameters ```php title="src/Http/Middleware/VerifyBlogToken.php" public function handle(Request $request, Closure $next, string $ability = 'read'): Response { abort_unless($request->user()?->tokenCan("blog:{$ability}"), 403); return $next($request); } ``` ```php Route::middleware('blog.token:write')->post('/posts', …); ``` ## Making registration configurable A consumer who wants to swap your middleware for their own should not have to fork the package: ```php title="config/blog.php" return [ 'middleware' => [ 'author' => \Acme\Blog\Http\Middleware\EnsureUserIsAuthor::class, ], ]; ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasConfig() ->registeredPackage(function (Packager $packager) { $packager->hasMiddlewareAliases([ 'blog.author' => config('blog.middleware.author'), ]); }); ``` The `registeredPackage` hook runs after your config is merged, so the value is available — but note that it also runs *after* `register()` has already done its work, and middleware is booted later, so the alias still lands in time. ## Ordering The toolkit registers aliases, then groups, then globals — in that order, all inside `bootMiddleware()`. It does not attempt to control Laravel's own middleware priority. If your middleware must run before or after a framework one, the consumer sets that in their `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->priority([ \Illuminate\Session\Middleware\StartSession::class, \Acme\Blog\Http\Middleware\TrackBlogVisit::class, // [tl! ++] \Illuminate\Routing\Middleware\SubstituteBindings::class, ]); }) ``` Document the requirement rather than trying to enforce it. ## Introspection ```php $packager->isSetMiddlewareAliases(); // bool $packager->getMiddlewareAliases(); // ['alias' => Middleware::class] $packager->isSetMiddlewareGroups(); // bool $packager->getMiddlewareGroups(); // ['group' => [Middleware::class, …]] $packager->isSetMiddlewareGlobals(); // bool $packager->getMiddlewareGlobals(); // [Middleware::class, …] ``` ## Testing ```php use Illuminate\Routing\Router; test('the package registers its middleware alias', function () { expect(app(Router::class)->getMiddleware()) ->toHaveKey('blog.author'); }); test('the package pushes middleware into its group', function () { expect(app(Router::class)->getMiddlewareGroups()['blog']) ->toContain(VerifyBlogToken::class); }); ``` --- # Migrations > Database · https://package-toolkit.nyoncode.cz/migrations.md ```php public function hasMigrations( ?array $migrationFiles = null, string $directory = '../database/migrations', ): static public function canLoadMigrations(bool $value = true): static ``` Migrations are the one resource where *when* a file runs matters as much as what it contains, and the toolkit spends most of its migration logic on getting that right. :::note `hasMigrations()` takes an array, not a string Unlike `hasConfig()` and `hasRoutes()`, the parameter is typed `?array`. Pass `hasMigrations(['create_blog_posts_table.php'])` even for a single file. ::: ## Timestamped migrations The familiar Laravel form — the file already carries its date: ```text database/migrations/ ├── 2025_01_15_120000_create_blog_posts_table.php └── 2025_02_01_093000_add_slug_to_blog_posts_table.php ``` ```php $packager->name('Blog')->hasMigrations(); ``` Published verbatim, filename unchanged: ```bash php artisan vendor:publish --tag=blog::migrations # → database/migrations/2025_01_15_120000_create_blog_posts_table.php ``` The catch is that those dates are *yours*. A consumer installing in 2027 gets migrations dated 2025, which sort before every migration they have ever written — including the one that created their `users` table, which your foreign key needs. It works when your tables stand alone, and breaks quietly when they do not. ## Timeless migrations Ship the file with no date prefix at all: ```text database/migrations/ ├── create_blog_posts_table.php └── add_slug_to_blog_posts_table.php ``` ```php $packager->name('Blog')->hasMigrations(); ``` The toolkit detects the missing prefix and generates one **at publish time**, from the current moment, incrementing by one second per file in declaration order: ```bash php artisan vendor:publish --tag=blog::migrations # → database/migrations/2026_08_08_142530_create_blog_posts_table.php # → database/migrations/2026_08_08_142531_add_slug_to_blog_posts_table.php ``` The consumer's own migrations all pre-date these, so a foreign key to their `users` table resolves. And your ordering is preserved: `create_` before `add_slug_`, exactly as declared. A prefix is recognised by this pattern, which is Laravel's own convention: ```php public function hasDatePrefix(string $filename): bool { return (bool) preg_match('/^\d{4}_\d{2}_\d{2}_\d{6}_/', $filename); } ``` :::warning Order is declaration order With discovery, the order is whatever the filesystem returns. If one timeless migration depends on another — a foreign key, an added column — name the files explicitly so the generated timestamps cannot come out reversed: ```php $packager->hasMigrations([ 'create_blog_posts_table.php', 'create_blog_comments_table.php', // FK → blog_posts [tl! ~~] 'add_slug_to_blog_posts_table.php', ]); ``` ::: ### Writing a timeless migration Nothing about the file changes — only the name: ```php title="database/migrations/create_blog_posts_table.php" 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->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('body'); $table->timestamp('published_at')->nullable(); $table->timestamps(); $table->index(['published_at', 'id']); }); } public function down(): void { Schema::dropIfExists('blog_posts'); } }; ``` Anonymous-class migrations are the right choice for a package: no class name means no collision with a consumer's migration of the same name. ## Mixing both styles A package can ship both. Each file is judged on its own name — timestamped ones publish verbatim, timeless ones get a generated prefix: ```text database/migrations/ ├── 2025_06_15_100000_create_blog_categories_table.php → published as-is └── create_blog_tags_table.php → gets today's timestamp ``` Useful during a transition, but a package that has to explain which of its migrations sort where is harder to reason about than one that picks a style. Prefer timeless for new packages. ## Running migrations without publishing Sometimes a package's schema is not the consumer's business — an internal queue table, a cache table, a package that manages its own upgrades. `canLoadMigrations()` registers the migration paths with the framework so `php artisan migrate` runs them straight from `vendor/`: ```php $packager ->name('Blog') ->hasMigrations() ->canLoadMigrations(); ``` ```bash php artisan migrate # Runs the package's migrations from vendor/acme/blog/database/migrations ``` They stay publishable as well — the two are independent. If you want load-only, with no publishing at all, there is no flag for that; skip `hasMigrations()` and call Laravel's `loadMigrationsFrom()` yourself from a [lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md). | | Publishable | Runs from `vendor/` | Consumer can edit | |---|---|---|---| | `hasMigrations()` | yes | no | yes, after publishing | | `hasMigrations()->canLoadMigrations()` | yes | yes | yes, after publishing | :::warning Publishing *and* loading timeless migrations runs them twice Laravel's migrator identifies a migration by its filename without the extension, and collapses duplicates across paths. For **timestamped** migrations that is harmless: the published copy has the same name as the source, so the consumer sees one migration either way. For **timeless** migrations it is not. Publishing generates a new prefix, so `create_blog_posts_table` and `2026_08_08_142530_create_blog_posts_table` are two different migrations as far as the migrator is concerned, and `migrate` will run both — the second failing on a table that already exists. If you enable `canLoadMigrations()` on timeless migrations, tell consumers not to publish them, or ship timestamped files instead. ::: ## Naming tables Prefix your tables with the package name. It is the only defence against a collision with an application table, and it makes a package's footprint obvious in a schema dump. ```php Schema::create('posts', …); // whose posts? [tl! --] Schema::create('blog_posts', …); // [tl! ++] ``` For a table name the consumer can change, read it from config: ```php title="config/blog.php" 'table_prefix' => 'blog_', ``` ```php title="database/migrations/create_blog_posts_table.php" Schema::create(config('blog.table_prefix').'posts', function (Blueprint $table) { // … }); ``` ## Publishing ```bash php artisan vendor:publish --tag=blog::migrations ``` ```php use NyonCode\LaravelPackageToolkit\Commands\InstallCommand; $packager->hasInstallCommand(function (InstallCommand $command) { $command->publishConfig()->publishMigrations(); }); ``` The install command adds `Run: php artisan migrate` to its closing "next steps" when migrations were part of the run. ## Introspection ```php $packager->isMigratable(); // bool $packager->hasMigrationsOnRun; // bool — public property, set by canLoadMigrations() $packager->migrationFiles(); // Support\SplFileInfo[] $packager->shouldPrependTimestamp(); // bool — true if any file lacks a date prefix $packager->hasDatePrefix('create_x.php'); // false $packager->getMigrationPublishMapping(); // [source => destination] ``` `getMigrationPublishMapping()` is what the publisher uses, and it is worth calling in a test to assert your migrations land where you expect. --- # Seeders > Database · https://package-toolkit.nyoncode.cz/seeders.md ```php public function hasSeeders( array|string|null $seederFiles = null, string $directory = '../database/seeders', ): static ``` Added in **2.4.0**. Seeders are a **publish-only** resource: the toolkit copies them into the application, and nothing else. There is no boot step, because a seeder is only ever run explicitly. ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasSeeders(); ``` ```bash php artisan vendor:publish --tag=blog::seeders ``` ## Where they land, and why it matters Published **flat** into `database/seeders/` — not into a `vendor/blog/` subdirectory: ```text database/seeders/BlogPostSeeder.php database/seeders/BlogCategorySeeder.php ``` That is the directory the application's own `Database\Seeders` namespace maps to, so a published seeder is immediately runnable: ```bash php artisan db:seed --class="Database\Seeders\BlogPostSeeder" ``` A `vendor/blog/` subdirectory would have needed the consumer to rewrite the namespace before the class could be autoloaded, which is exactly the kind of chore that makes a "just run this" step stop being one. The flat layout has a cost, and it is yours to manage: **prefix your seeder class names**. Shipping a `PostSeeder` into a directory the application already owns is asking for a collision. ## Writing the seeder ```php title="database/seeders/BlogPostSeeder.php" namespace Database\Seeders; use Acme\Blog\Models\Post; use Illuminate\Database\Seeder; class BlogPostSeeder extends Seeder { public function run(): void { Post::factory() ->count(25) ->hasComments(3) ->create(); } } ``` The namespace must be `Database\Seeders` — that is where it will live once published. :::warning Namespaces and the unpublished file Because the file declares `Database\Seeders`, it is not autoloadable from inside your package (your PSR-4 prefix maps `Acme\Blog\` to `src/`). That is fine and expected: a seeder only exists to be published. If you want it usable before publishing, ship it under your own namespace and accept that consumers must run it with the full class name. ::: ## Naming specific files ```php $packager->hasSeeders('BlogPostSeeder.php'); $packager->hasSeeders(['BlogPostSeeder.php', 'BlogCategorySeeder.php']); $packager->hasSeeders(directory: '../database/seed'); ``` Discovery is not recursive, so a `database/seeders/demo/` subdirectory needs its own call. ## Shipping seeders as stubs A `.stub` source publishes as `.php`: ```text database/seeders/BlogDemoSeeder.stub → database/seeders/BlogDemoSeeder.php ``` The destination is built from `getBaseFileName()` — the name without its extension — plus `.php`, which is the same convention [publishable providers](https://package-toolkit.nyoncode.cz/providers.md) follow. This is useful when the seeder contains placeholder content you do not want a static analyser or a test runner in your own repository to pick up, or when it references classes that only exist after installation. ## Registering with `DatabaseSeeder` Publishing puts the file in place; it does not add it to the consumer's `DatabaseSeeder`. Tell them what to add: ```php title="database/seeders/DatabaseSeeder.php" public function run(): void { $this->call([ BlogCategorySeeder::class, // [tl! ++] BlogPostSeeder::class, // [tl! ++] ]); } ``` Or do it from an [install hook](https://package-toolkit.nyoncode.cz/install-command.md#hooks), if your package is opinionated enough to edit a consumer's file: ```php use NyonCode\LaravelPackageToolkit\Commands\InstallCommand; $packager->hasInstallCommand(function (InstallCommand $command) { $command ->publishMigrations() ->publishSeeders() ->afterInstallation(function (InstallCommand $command) { if ($command->confirm('Seed demo blog content now?', false)) { $command->call('db:seed', ['--class' => 'Database\Seeders\BlogPostSeeder']); } }); }); ``` Prompting beats rewriting: `DatabaseSeeder` is a file consumers edit constantly, and a package that patches it will eventually patch it wrongly. ## In the install command ```php $packager->hasInstallCommand(function (InstallCommand $command) { $command->publishConfig() ->publishMigrations() ->publishSeeders(); }); ``` `publishSeeders()` is its own step in the installer's progress output, and it adds `Run: php artisan db:seed --class=` to the closing "next steps". It is also included in `publishEverything()`. ## Introspection ```php $packager->isSeedable(); // bool $packager->seederFiles(); // Support\SplFileInfo[] ``` ## Testing ```php test('the package publishes its seeders', function () { $this->artisan('vendor:publish --tag=blog::seeders')->assertExitCode(0); expect(database_path('seeders/BlogPostSeeder.php'))->toBeFile() ->and(file_get_contents(database_path('seeders/BlogPostSeeder.php'))) ->toContain('namespace Database\Seeders;') ->and(database_path('seeders/vendor/blog'))->not->toBeDirectory(); }); ``` --- # Factories > Database · https://package-toolkit.nyoncode.cz/factories.md ```php public function hasFactories( array|string|null $factoryFiles = null, string $directory = '../database/factories', ): static ``` Added in **2.4.0**. Like [seeders](https://package-toolkit.nyoncode.cz/seeders.md), factories are **publish-only**. ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasFactories(); ``` ```bash php artisan vendor:publish --tag=blog::factories ``` Files land flat in `database/factories/`, which is where the application's `Database\Factories` namespace resolves: ```text database/factories/BlogPostFactory.php ``` ## Why there is no "load factories" option Laravel removed `loadFactoriesFrom()` in version 8, when factories became classes resolved by convention rather than files scanned from a directory. There is no framework hook left for a package to say "my factories live here" — so the toolkit does not pretend to offer one. If you want your factories usable **without** the consumer publishing them, point at them from the model, which is the mechanism Laravel does still provide: ```php title="src/Models/Post.php" namespace Acme\Blog\Models; use Acme\Blog\Database\Factories\PostFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasFactory; protected static function newFactory(): PostFactory // [tl! focus] { // [tl! focus] return PostFactory::new(); // [tl! focus] } // [tl! focus] } ``` ```php title="src/Database/Factories/PostFactory.php" namespace Acme\Blog\Database\Factories; use Acme\Blog\Models\Post; use Illuminate\Database\Eloquent\Factories\Factory; class PostFactory extends Factory { protected $model = Post::class; public function definition(): array { return [ 'title' => $this->faker->sentence(), 'slug' => $this->faker->unique()->slug(), 'body' => $this->faker->paragraphs(3, true), 'published_at' => null, ]; } public function published(): static { return $this->state(fn () => ['published_at' => now()]); } } ``` Now `Post::factory()->published()->create()` works in the consumer's tests with nothing published, because the factory lives under *your* PSR-4 prefix and the model resolves it directly. The two approaches answer different questions, and a package can do both: | | `hasFactories()` | `newFactory()` | |---|---|---| | Consumer must publish | yes | no | | Consumer can edit the definition | yes | only by extending | | Namespace | `Database\Factories` | your own | | Useful for | seeding demo data, a starting point to customise | tests against your models | ## Writing a publishable factory A factory intended for publishing declares the application's namespace: ```php title="database/factories/BlogPostFactory.php" namespace Database\Factories; use Acme\Blog\Models\Post; use Illuminate\Database\Eloquent\Factories\Factory; class BlogPostFactory extends Factory { protected $model = Post::class; public function definition(): array { return [ 'title' => fake()->sentence(), 'slug' => fake()->unique()->slug(), 'body' => fake()->paragraphs(4, true), 'published_at' => fake()->boolean(70) ? fake()->dateTimeThisYear() : null, ]; } } ``` Prefix the class name. `database/factories/` belongs to the application, and `PostFactory` is a name somebody else will want. ## Naming specific files ```php $packager->hasFactories('BlogPostFactory.php'); $packager->hasFactories(['BlogPostFactory.php', 'BlogCommentFactory.php']); $packager->hasFactories(directory: '../database/factory-stubs'); ``` ## In the install command ```php use NyonCode\LaravelPackageToolkit\Commands\InstallCommand; $packager->hasInstallCommand(function (InstallCommand $command) { $command ->publishMigrations() ->publishFactories() ->publishSeeders(); }); ``` Factories are usually only wanted in development, which the installer can express: ```php $packager->hasInstallCommand(function (InstallCommand $command) { $command ->publishConfig() ->publishMigrations() ->publishForLocal('factories', 'seeders'); // [tl! focus] }); ``` ## Introspection ```php $packager->isFactorable(); // bool $packager->factoryFiles(); // Support\SplFileInfo[] ``` --- # Views > Views & assets · https://package-toolkit.nyoncode.cz/views.md ```php public function hasViews( ?string $viewsPath = null, string $directory = '../resources/views', ?string $namespace = null, ): static ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasViews(); ``` ```text acme/blog/ └── resources/views/ ├── index.blade.php ├── post.blade.php └── partials/ └── author.blade.php ``` Views register under the package short name, and — unlike the file-based resources — the whole directory is registered, subdirectories included: ```php view('blog::index'); view('blog::post', ['post' => $post]); view('blog::partials.author', ['author' => $author]); ``` ```blade @include('blog::partials.author', ['author' => $post->author]) @extends('blog::layouts.app') ``` ## The `$namespace` parameter ```php $packager->hasViews(namespace: 'acme-blog'); // has no effect ``` :::warning Not currently applied The third parameter is accepted and stored, but `bootViews()` registers the view namespace from `shortName()` regardless. As of **2.4.0** there is no way to register views under a namespace other than the package short name. If you need a different one, set it with [`hasShortName()`](https://package-toolkit.nyoncode.cz/packager.md#short-name) — which changes the publish tags and every other namespaced path too — or call `loadViewsFrom()` yourself from a [lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#bootingpackage). ::: ## A custom views directory The first parameter takes a path, absolute or relative to your provider's directory: ```php $packager->hasViews('../resources/blade'); // relative $packager->hasViews(__DIR__.'/../resources/blade'); // absolute ``` Or use the second parameter, which is only consulted when the first is empty: ```php $packager->hasViews(directory: '../ui/views'); ``` Either way, a directory that does not exist throws `DirectoryNotFoundException` at registration. The two parameters differ in one detail worth knowing: the first is checked with `is_dir()` and throws if missing, the second is used as-is. Prefer the first for anything unusual. ## Publishing and overriding ```bash php artisan vendor:publish --tag=blog::views ``` The directory is copied to `resources/views/vendor/{short-name}/`: ```text resources/views/vendor/blog/ ├── index.blade.php ├── post.blade.php └── partials/author.blade.php ``` Laravel's view finder checks that directory **before** the package's own, per view. Which means a consumer can publish everything and then delete all but the one file they wanted to change — `blog::post` resolves to their copy, `blog::index` still resolves to yours, and your future updates to `index.blade.php` keep arriving. Worth putting in your readme, because the instinct is to keep the whole published directory and then wonder why an upgrade changed nothing. ## Writing views a consumer will want to override The more a view assumes, the harder it is to override well. A few habits that pay off: **Keep layout out of content views.** A consumer with their own layout should not have to fork your content view to use it. ```blade title="resources/views/post.blade.php" @extends(config('blog.layout', 'blog::layouts.app')) @section('content')

{{ $post->title }}

{!! $post->body !!}
@endsection ``` **Leave named slots for insertion**, so small additions do not require a fork: ```blade title="resources/views/post.blade.php"
@stack('blog-post-before') {{-- [tl! ++] --}}

{{ $post->title }}

{!! $post->body !!} @stack('blog-post-after') {{-- [tl! ++] --}}
``` **Don't hard-code CSS classes** a consumer cannot change: ```blade
{{-- [tl! --] --}}
{{-- [tl! ++] --}} ``` ## Sharing data with your views Three mechanisms, in increasing order of scope: ```php // Passed per render — ordinary Blade. view('blog::post', ['post' => $post]); // Bound to specific views, resolved at render time. $packager->hasViewComposer('blog::post', PostComposer::class); // Available in every view in the application. $packager->hasSharedDataForAllViews(['blogVersion' => '2.4.0']); ``` See [View composers](https://package-toolkit.nyoncode.cz/view-composers.md) for both of the latter. ## Views and Blade components Class-based components have their own view resolution, and register separately — see [View components](https://package-toolkit.nyoncode.cz/view-components.md): ```php $packager ->name('Blog') ->hasViews() ->hasComponents('blog', PostCard::class); ``` A component's view is looked up in your namespace, so it belongs in the same directory: ```php title="src/View/Components/PostCard.php" public function render(): View { return view('blog::components.post-card'); } ``` ## Anonymous components Laravel finds anonymous components under a registered view namespace at `components/`, so they work with nothing extra declared: ```text resources/views/components/badge.blade.php ``` ```blade Draft ``` ## Introspection ```php $packager->isViewable(); // bool $packager->views(); // absolute path to the views directory ``` ## Testing ```php test('the package registers its views', function () { expect(view()->exists('blog::post'))->toBeTrue(); }); test('a published view overrides the package view', function () { $this->artisan('vendor:publish --tag=blog::views')->assertExitCode(0); file_put_contents(resource_path('views/vendor/blog/post.blade.php'), 'overridden'); expect(view('blog::post')->render())->toBe('overridden'); }); ``` --- # View components > Views & assets · https://package-toolkit.nyoncode.cz/view-components.md Two mechanisms, for two different situations. Register components **individually** when you want control over each tag name, or register a **namespace** when you would rather let Laravel resolve them by convention. ```php // Individually public function hasComponent(string $prefix, string $componentClass, string $alias = ''): static public function hasComponents(string $prefix, array|string $components): static // By namespace public function hasComponentNamespace(string $prefix, string $namespace): static public function hasComponentNamespaces(array $namespaces): static ``` ## Registering components individually ```php title="src/BlogServiceProvider.php" use Acme\Blog\View\Components\PostCard; $packager ->name('Blog') ->hasViews() ->hasComponent('blog', PostCard::class); ``` ```blade ``` The tag is `x-{prefix}-{kebab-case class name}`. The prefix is yours to choose and is not derived from the short name, so a package can group components under more than one. ### With an alias ```php $packager->hasComponent('blog', PostCard::class, 'card'); ``` Registers **both** names — the alias does not replace the derived one: ```blade {{-- alias --}} {{-- still works --}} ``` That is worth knowing when you rename: adding an alias is always additive, so it is a safe way to introduce a shorter tag without breaking anyone. ### Several at once ```php use Acme\Blog\View\Components\{PostCard, PostList, AuthorBadge}; $packager->hasComponents('blog', [ 'card' => PostCard::class, 'list' => PostList::class, AuthorBadge::class, // no alias — derived name only [tl! ~~] ]); ``` ```blade ``` String keys are aliases; numeric keys (a plain list) are not. Mixing both in one array is fine, as above. ## Writing a component ```php title="src/View/Components/PostCard.php" namespace Acme\Blog\View\Components; use Acme\Blog\Models\Post; use Illuminate\Contracts\View\View; use Illuminate\View\Component; class PostCard extends Component { public function __construct( public Post $post, public bool $compact = false, ) {} public function excerpt(): string { return str($this->post->body)->stripTags()->words(30); } public function render(): View // [tl! focus:start] { return view('blog::components.post-card'); } // [tl! focus:end] } ``` ```blade title="resources/views/components/post-card.blade.php"
merge(['class' => 'blog-post-card']) }}>

{{ $post->title }}

@unless ($compact)

{{ $this->excerpt() }}

@endunless {{ __('Read more') }}
``` The component's view lives in your package's view namespace, so `hasViews()` has to be declared as well. ## Registering a namespace For a package with many components, register the namespace once and let Laravel resolve classes on demand: ```php $packager ->name('Blog') ->hasViews() ->hasComponentNamespace('blog', 'Acme\\Blog\\View\\Components'); ``` ```blade ``` Note the syntax difference, which is Laravel's and not the toolkit's: | Registration | Tag | |---|---| | `hasComponent('blog', PostCard::class)` | `` | | `hasComponentNamespace('blog', '…\Components')` | `` | The namespace form nests: a class at `View\Components\Admin\Dashboard` becomes ``. ### Several namespaces ```php $packager->hasComponentNamespaces([ 'blog' => 'Acme\\Blog\\View\\Components', 'blog-admin' => 'Acme\\Blog\\View\\Components\\Admin', ]); ``` ## Choosing between them | | Individual | Namespace | |---|---|---| | Declaration cost | one line per component | one line total | | Tag names | fully controlled, aliasable | convention only | | A new component | needs registering | just works | | Publishable | yes, per component directory | yes, whole namespace | A package with five components that consumers use directly is better off registering them individually — the tags are shorter and the aliases can be curated. A package with thirty is better off with the namespace. ## Publishing Both forms publish, under different tags: ```bash php artisan vendor:publish --tag=blog::view-components php artisan vendor:publish --tag=blog::view-component-namespaces ``` Destinations are built from the short name and the source directory's own name: ```text app/View/Components/blog/Components/PostCard.php ``` For individual components, the source directory is the one holding the class file — derived by reflection, so all components in the same directory publish together. For namespaces, the directory is resolved through Composer's PSR-4 map. :::warning A published component is a fork, not an override Unlike views and translations, Laravel has no lookup order for component *classes*. A published copy lives under the application's namespace and is not registered by anything — the consumer has to register it themselves and, realistically, rename it to avoid confusion. Publishing components is a "start from my code" feature, not a customisation hook. If you want a genuine override point, put it in the component's **view** and let the consumer publish that instead. ::: :::warning Not covered by the install command `publishComponents()` and `publishComponentNamespaces()` add their tags to the install command, and `publishEverything()` includes them — but the installer only runs steps it has a definition for, and these two have none. They are silently skipped. Publish them with `vendor:publish` directly, or from an [install hook](https://package-toolkit.nyoncode.cz/install-command.md#hooks): ```php $command->afterInstallation(function (InstallCommand $command) { $command->call('vendor:publish', ['--tag' => 'blog::view-components']); }); ``` ::: ## Introspection ```php $packager->isViewComponentized(); // bool $packager->viewComponents(); // [['component' => …, 'alias' => …, 'prefix' => …], …] $packager->viewComponentPaths(); // unique directories holding component classes $packager->isViewComponentNamespaceConfigured(); // bool $packager->viewComponentNamespaces(); // ['prefix' => 'Namespace'] ``` ## Testing ```php use Illuminate\Support\Facades\Blade; test('the package registers its components', function () { expect(Blade::render('', ['post' => $post])) ->toContain($post->title); }); test('both the alias and the derived name resolve', function () { expect(Blade::render('', ['post' => $post])) ->toContain($post->title); }); ``` --- # View composers & shared data > Views & assets · https://package-toolkit.nyoncode.cz/view-composers.md Two ways to get data into a view without threading it through every controller that renders one. A **composer** runs when a specific view is rendered; **shared data** is available to every view in the application, always. ```php public function hasViewComposer(string|array $views, string|Closure $composer): static public function hasSharedDataForAllViews(array $viewSharedData): static ``` ## View composers ```php title="src/BlogServiceProvider.php" use Acme\Blog\View\Composers\SidebarComposer; $packager ->name('Blog') ->hasViews() ->hasViewComposer('blog::sidebar', SidebarComposer::class); ``` ```php title="src/View/Composers/SidebarComposer.php" namespace Acme\Blog\View\Composers; use Acme\Blog\Repositories\PostRepository; use Acme\Blog\Models\Category; use Illuminate\View\View; class SidebarComposer { public function __construct(private PostRepository $posts) {} public function compose(View $view): void { $view->with('categories', Category::withCount('posts')->get()); $view->with('recent', $this->posts->latest(limit: 5)); } } ``` ```blade title="resources/views/sidebar.blade.php" ``` The composer class is resolved from the container, so constructor injection works. ### Closures For something small, skip the class: ```php $packager->hasViewComposer('blog::sidebar', function (View $view) { $view->with('categories', Category::all()); }); ``` :::warning Closures and `config:cache` A closure composer is registered at boot and lives in memory, so it is unaffected by config caching. But a closure captured in a property of a serialisable object is not — keep composer closures inside `configure()` and they are fine. ::: ### Several views at once ```php $packager->hasViewComposer( ['blog::sidebar', 'blog::footer', 'blog::partials.nav'], SidebarComposer::class, ); ``` ### Wildcards Laravel's own wildcard syntax works, since the value is passed straight to `View::composer()`: ```php $packager->hasViewComposer('blog::*', BlogComposer::class); // every package view $packager->hasViewComposer('blog::admin.*', AdminComposer::class); // one subdirectory $packager->hasViewComposer('*', GlobalComposer::class); // every view — see below ``` :::danger `'*'` runs for every view in the application Including views that have nothing to do with your package. A package that registers a global composer imposes its cost on every render in the host application. Scope it to your own namespace unless you have a specific reason not to. ::: ### Registering more than one Composers accumulate per view, keyed by the view name — so registering two composers for the same view keeps only the last: ```php $packager ->hasViewComposer('blog::sidebar', CategoryComposer::class) ->hasViewComposer('blog::sidebar', RecentPostsComposer::class); // [tl! ~~] // Only RecentPostsComposer is registered for blog::sidebar. ``` Different views accumulate normally: ```php $packager ->hasViewComposer('blog::sidebar', SidebarComposer::class) // ✓ ->hasViewComposer('blog::footer', FooterComposer::class); // ✓ ``` If one view genuinely needs two composers, combine them into one class, or call `View::composer()` directly from a [lifecycle hook](https://package-toolkit.nyoncode.cz/lifecycle-hooks.md#bootedpackage). ## Shared data ```php $packager ->name('Blog') ->hasSharedDataForAllViews([ 'blogName' => 'Acme Blog', 'blogVersion' => '2.4.0', 'blogSettings' => ['theme' => 'default', 'rtl' => false], ]); ``` ```blade {{-- available in every view in the application --}}
{{ $blogName }} v{{ $blogVersion }}
``` ### Allowed values Keys must be strings. Values must be scalar, array, `null`, or an instance of `Illuminate\Contracts\Support\Arrayable`. Anything else throws `InvalidArgumentException` during registration: ```php $packager->hasSharedDataForAllViews([ 'blogName' => 'Acme Blog', // ✓ string 'blogPerPage' => 15, // ✓ int 'blogFlags' => ['beta' => true], // ✓ array 'blogTheme' => null, // ✓ null 'blogCategories' => Category::all(), // ✓ Arrayable 'blogFormatter' => fn () => …, // Closure [tl! --] 'blogRepository' => new PostRepository(), // object [tl! --] ]); ``` The restriction exists to stop packages sharing service objects into the view layer — a habit that turns templates into an untestable second application. :::warning Eloquent queries run at boot `Category::all()` in the array above executes during `boot()`, on **every request**, whether or not any view uses it. That is a database query added to your artisan commands, your queue workers and your health check. Use a [view composer](#view-composers) instead — it runs only when the view that needs the data is actually rendered. ::: ### Merging Calls accumulate, and a repeated key wins on the later call: ```php $packager ->hasSharedDataForAllViews(['blogName' => 'Blog']) ->hasSharedDataForAllViews(['blogVersion' => '2.4.0', 'blogName' => 'Acme Blog']); // ['blogName' => 'Acme Blog', 'blogVersion' => '2.4.0'] ``` ### Prefix your keys Shared data is a flat global namespace shared with the application and every other package. A package that shares `$settings` will one day silently overwrite something. ```php 'settings' => … // one day, somebody else's [tl! --] 'blogSettings' => … // [tl! ++] ``` ## Choosing between them | | Composer | Shared data | |---|---|---| | Runs | when a matching view renders | at boot, always | | Scope | the views you name | every view | | Suitable for | queries, request-dependent values | constants, static config | | Cost when unused | none | paid on every request | The rule of thumb: if computing the value costs anything at all, it belongs in a composer. ## Introspection ```php $packager->isViewComposable(); // bool $packager->viewComposers(); // ['view name' => composer] $packager->isSharedWithViews(); // bool $packager->viewSharedData(); // ['key' => value] ``` ## Testing ```php test('the composer binds categories to the sidebar', function () { Category::factory()->count(3)->create(); expect(view('blog::sidebar')->render())->toContain('Announcements'); }); test('shared data reaches an application view', function () { expect(Blade::render('{{ $blogName }}'))->toBe('Acme Blog'); }); ``` --- # Translations > Views & assets · https://package-toolkit.nyoncode.cz/translations.md ```php public function hasTranslations(string $translationPath = 'lang'): static ``` One call registers both styles of Laravel translation file — the keyed PHP arrays and the flat JSON strings — and makes the directory publishable. ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasTranslations(); ``` :::note The argument is a directory name, not a path `hasTranslations('lang')` resolves to `../lang` relative to your provider's directory. Pass `'resources/lang'` to get `../resources/lang`. Unlike the other builders, there is no separate `directory:` parameter — the one argument *is* the directory. ::: ## Directory layout ```text acme/blog/ └── lang/ ├── en/ │ ├── messages.php │ └── validation.php ├── cs/ │ └── messages.php ├── pt_BR/ # [tl! ~~:1] │ └── messages.php ├── en.json └── cs.json ``` ```php title="lang/en/messages.php" return [ 'title' => 'Blog', 'post' => [ 'published' => 'Published on :date', 'by' => 'Written by :author', ], 'posts_count' => '{0} No posts|{1} One post|[2,*] :count posts', ]; ``` ```json title="lang/en.json" { "Read more": "Read more", "Leave a comment": "Leave a comment" } ``` ## Using them PHP translations are namespaced under the package short name: ```php trans('blog::messages.title'); // 'Blog' trans('blog::messages.post.published', ['date' => $date]); trans_choice('blog::messages.posts_count', $posts->count()); ``` ```blade

{{ __('blog::messages.title') }}

{{ __('blog::messages.post.by', ['author' => $post->author->name]) }}

``` JSON translations are **not** namespaced — that is how Laravel's JSON loader works, for packages and applications alike: ```php __('Read more'); ``` Which means your JSON keys share one global space with the application's. Keep them few, and keep them specific enough not to collide; anything ambiguous belongs in a namespaced PHP file. ## Language directory validation Every subdirectory is checked against the toolkit's `Support\Enums\Language` enum — the ISO 639-1 set, 180-odd codes. An unrecognised one throws during registration: ```text Invalid language directory [/…/lang/english]. Directory name must be one of the supported languages. ``` The check is there because a mistyped locale directory does not fail loudly on its own — Laravel simply never finds the translations, and the fallback locale is served instead. Failing at registration turns a silent content bug into an immediate one. **Region locales are supported.** Only the language part before a `-` or `_` is validated, so `pt_BR`, `en-US` and `zh_CN` all pass. ```php use NyonCode\LaravelPackageToolkit\Support\Enums\Language; Language::codes(); // Collection ['ab', 'aa', 'af', …] Language::names(); // Collection ['Abkhazian', 'Afar', …] Language::collection(); // Collection Language::CS->value; // 'Czech' Language::CS->name; // 'CS' ``` ## Missing and empty directories | Situation | Result | |---|---| | Directory does not exist | `DirectoryNotFoundException` | | Directory exists but is empty | returns silently, package is **not** translatable | | Subdirectory is not a language code | `InvalidLanguageDirectoryException` | The empty-directory case is deliberate: a package can commit an empty `lang/` placeholder without declaring itself translatable, which keeps `isTranslatable()` honest. ## Publishing ```bash php artisan vendor:publish --tag=blog::translations ``` The whole directory is copied to `lang/vendor/{short-name}/`: ```text lang/vendor/blog/ ├── en/messages.php ├── cs/messages.php ├── en.json └── cs.json ``` On an application still using the pre-Laravel-9 layout — one where the `lang_path()` helper does not exist — the destination falls back to `resources/lang/vendor/blog/`. The toolkit checks for the helper rather than the framework version. Laravel checks the vendor override directory **before** the package's own, so a consumer who publishes and edits `lang/vendor/blog/en/messages.php` gets their version. Keys they did not override still fall through to yours. ## Adding a language as a consumer Only the file needs to exist — nothing to register: ```text lang/vendor/blog/de/messages.php ``` ```php app()->setLocale('de'); trans('blog::messages.title'); ``` ## Translating your own package's output Everything your package renders should go through the translator, including exception messages and command output: ```php title="src/Commands/PruneCommand.php" public function handle(): int { $count = Post::onlyTrashed()->forceDelete(); $this->info(trans('blog::messages.pruned', ['count' => $count])); return self::SUCCESS; } ``` ## Introspection ```php $packager->isTranslatable(); // bool $packager->translationPath(); // absolute path to the lang directory $packager->loadJsonTranslate(); // bool — true if any .json file was found ``` `loadJsonTranslate()` is set by scanning the directory recursively for a `.json` extension, so a JSON file nested inside a locale directory is detected too. ## Testing ```php test('the package registers its translations', function () { expect(trans('blog::messages.title'))->toBe('Blog'); }); test('the package registers its json translations', function () { expect(trans('Read more'))->toBe('Read more'); }); test('translations are publishable', function () { $this->artisan('vendor:publish --tag=blog::translations')->assertExitCode(0); expect(lang_path('vendor/blog/en/messages.php'))->toBeFile(); }); ``` --- # Assets > Views & assets · https://package-toolkit.nyoncode.cz/assets.md ```php public function hasAssets(string $directory = 'dist', bool $mirror = true, array $entries = []): static public function hasViteAssets(array $entries, ?string $base = null): static ``` ```php title="src/BlogServiceProvider.php" $packager ->name('Blog') ->hasAssets(entries: [ 'css/blog.css', 'js/blog.js', ]); ``` ```text acme/blog/ └── dist/ ├── css/blog.css └── js/blog.js ``` ```blade title="any layout — the package's own, or the application's" @packageStyles('blog') @packageScripts('blog') ``` One call gives you four things: two publish tags, a self-maintaining mirror that keeps `public/vendor/blog` in step with `dist/` without anyone running a command, and tags a template can ask for by name. [`hasViteAssets()`](#vite-in-the-application) adds the fifth — the same declaration resolving through the consuming application's own Vite build when that application wants the package inside it. ## Publishing ```bash php artisan vendor:publish --tag=blog::assets php artisan vendor:publish --tag=laravel-assets --force ``` Both tags publish the same files to `public/vendor/{short-name}/`, preserving the directory structure: ```text public/vendor/blog/ ├── css/blog.css └── js/blog.js ``` The second tag is the interesting one. `laravel-assets` is what the Laravel application skeleton already runs from Composer's `post-update-cmd`: ```json title="a Laravel application's composer.json" "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ] ``` It is the same hook Horizon, Telescope and Nova rely on, and it means **one command republishes every installed package's assets after every `composer update`** — something a per-package tag cannot express. Laravel accumulates publish groups per path, so registering both tags publishes the same files and leaves an untagged `vendor:publish` unaffected. ## The asset mirror Added in 2.3.0, which has since been withdrawn — in practice, available from **2.4**. Publishing solves the deploy case. The mirror solves the case where nobody published — which, for a package whose CSS is not optional, is the difference between working and not. `Support\PublishedAssets` is a container singleton shared by every package in the application. Ask it for a URL and it returns one, mirroring the package's directory first if anything is missing or out of date: ```php use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; // An og:image the layout composes itself — not a stylesheet or a script, so not // something a directive renders, and the mirror is how it reaches `public/`. $url = app(PublishedAssets::class)->url( 'blog', __DIR__.'/../dist/img/og.png', ); // https://example.test/vendor/blog/img/og.png?id=1754640000 ``` :::note That example is deliberately not a stylesheet This is the layer, not the way to reach it. A file a template renders as a `` or a ` ``` ### When `public/` is not writable A read-only container, Vapor, a hardened deployment. Nothing throws: ```php $assets = app(PublishedAssets::class); $url = $assets->url('blog', $path); // null if nothing is published if ($url === null) { // Fall back to however you served it before — a CDN, an inline