atannex / atannex-provider
A base Laravel service provider for packages: declare config files, routes, migrations, translations, views, commands, middleware aliases, listeners, bindings, assets, requirements and optional integrations once, and they are registered at the right point of Laravel's lifecycle.
Requires
- php: ^8.3
- composer/semver: ^3.4
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
- orchestra/testbench: To test packages built on the provider (^10.0|^11.0).
- phpunit/phpunit: Needed by Atannex\Provider\Testing\AssertsPackages (^11.5|^12.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 23:53:33 UTC
README
A base service provider for Laravel packages. Describe your package once (or let conventions describe it) and every resource is registered at the right point of Laravel's lifecycle: config, routes, migrations, translations, views, commands, middleware aliases, listeners, container bindings, assets, policies, Blade components, schedules, requirements and optional integrations. It also generates an install command, validates config, adds a php artisan about section and caches itself for production through php artisan optimize.
Requires PHP 8.3+ and Laravel 12 or 13. Has no dependency on other Atannex packages. It connects packages to Laravel and contains no domain logic: forms, tables, panels and the like stay in their own packages.
composer require atannex/atannex-provider
The whole provider
use Atannex\Provider\AtannexServiceProvider; final class KitServiceProvider extends AtannexServiceProvider {}
List it in your composer.json (extra.laravel.providers) as usual. The package name comes from composer.json (acme/kit → kit; Acme/Invoice_Tools → invoice-tools) and everything in its conventional place is registered:
| Exists in your package | Result | Publish tag |
|---|---|---|
config/*.php |
merged under the file name: config('kit.x') |
kit-config |
routes/*.php (not console.php / channels.php) |
loaded (skipped when routes are cached) | |
database/migrations/ |
run by php artisan migrate |
kit-migrations |
lang/ |
__('kit::file.key') and JSON strings |
kit-translations |
resources/views/ |
view('kit::name'), <x-kit::component> |
kit-views |
resources/dist/ |
published to public/vendor/kit |
kit-assets |
Commands in Commands/ or Console/ under your PSR-4 roots |
registered with Artisan (abstract classes skipped) |
When you need more
Override configurePackage(). Every method returns the definition, so calls chain:
protected function configurePackage(): void { $this->package() // or ->package('acme-kit') ->discoversResources() // drop this to list everything yourself ->hasMiddlewareAliases(['kit' => EnsureKitIsEnabled::class]) ->hasListeners([OrderPlaced::class => SendReceipt::class]) ->hasSingletons([KitManager::class, Gateway::class => StripeGateway::class]) ->hasConfigRules('kit', ['greeting' => ['required', 'string']]) ->hasInstallCommand(fn (InstallCommand $c) => $c->publishConfigFile()->publishMigrations()->askToRunMigrations()) ->requiresLaravel('^12.0|^13.0'); }
Explicit declarations always win over discovery: hasViews('other') keeps its namespace, hasMigrations(load: false) keeps load: false, and anything already declared is never added twice. Leave things out with ->discoversResources(except: ['routes', 'config/local.php', DevCommand::class]): whole kinds (config, routes, migrations, translations, views, assets, commands), single files, or command classes. Only autoload PSR-4 roots are searched for commands, never autoload-dev, so test and dev-only commands are not picked up. Abstract classes are skipped.
Method reference
| Method | Effect |
|---|---|
package(?string $name = null) |
Start the definition. Lowercase letters, digits and dashes. The short name drops a leading atannex- and is the default for config, route, namespace and asset names. |
basePath(string $path) |
Package root. Default: nearest composer.json up to 6 levels above the provider. |
discoversResources(array $except = []) |
Register what exists by convention (table above). |
hasConfigFile(string|array|null $names = null, bool $required = true) |
config/{name}.php, shallow-merged (the app's top-level keys win). |
hasRoute(string $name), hasRoutes(string|array|null $names = null) |
routes/{name}.php. The file sets its own middleware; the provider adds none. |
hasMigrations(bool $load = true) |
database/migrations; load: false makes them publish-only. Never runs migrations. |
hasTranslations(?string $namespace = null) |
lang/, PHP and JSON. |
hasViews(?string $namespace = null, string $path = 'resources/views') |
Overridable in resources/views/vendor/{namespace}. |
hasCommands(array $classes, bool $consoleOnly = true), hasCommand(...) |
Built lazily by Artisan. |
hasMiddlewareAliases(array $aliases) |
Aliases only. Overriding an existing alias throws. |
hasListeners(array $events) |
Event => Listener or a list; Class@method allowed; never registered twice. |
hasBindings(), hasSingletons(), hasScoped() |
bindIf / singletonIf / scopedIf, so app bindings win. Use hasScoped for request state (Octane, queues). |
hasAssets(string $source = 'resources/dist'), hasPublishable(string $source, string $target, string $tag) |
Publish groups {name}-assets, {name}-{tag}. |
hasPolicies(array $modelToPolicy) |
Registered when the Gate is resolved. |
hasViewComponents(string $prefix, array $classes), hasAnonymousComponents(string $path = 'resources/views/components', ?string $prefix = null) |
Blade components. |
hasSchedule(Closure $fn) |
Added when the scheduler is resolved. |
hasAboutSection(?Closure $details = null) |
A php artisan about section with the installed version. Keep secrets out of it. |
hasConfigRules(string $key, array $rules) |
Laravel validation rules, checked by diagnose and optimize. |
hasInstallCommand(Closure $fn, ?string $name = null) |
{short name}:install; see below. |
hasOptionalIntegration(string $composerPackage, string $integrationClass, array $requires = [], string $version = '*') |
Runs an OptionalIntegration only when that package is installed, its version satisfies $version, and the $requires classes exist. Otherwise it stays off and diagnose says why (a warning when installed but incompatible). |
providesCapabilities(string|array), requiresCapabilities(string|array) |
Plain capability names; see below. |
requiresPackage(), requiresExtension(), requiresLaravel(), requiresClass() |
Checked on register, with a clear message when unmet. |
Every file and folder method takes required: true by default. A missing required resource throws MissingPackageResourceException, naming the path. With required: false it is skipped, and diagnose warns about it. Names and paths are validated: no .. and no absolute paths.
Hooks
register() and boot() are final, so the order can't drift. Put custom code in the hooks instead: registeringPackage(), packageRegistered(), bootingPackage() and packageBooted(). Inside them you have the normal provider API ($this->app, $this->publishes(), $this->callAfterResolving()...). $this->whenPackageInstalled('vendor/pkg', fn () => ..., version: '^2.0') runs code only when an optional package is present and compatible.
Order: register = manifest lookup → configurePackage() → discovery → requirements and required files → registeringPackage() → config → bindings → optional integrations' register() → packageRegistered(). boot = required capabilities → bootingPackage() → publish groups → migrations, translations, views → routes → commands → middleware aliases, listeners → install command, about, schedule, policies, components (each waits for the service it needs) → optional integrations' boot() → packageBooted().
Capabilities
Packages can depend on a feature rather than on a sibling package:
// atannex/pages $this->package()->providesCapabilities('pages.registration'); // atannex/panel $this->package() ->providesCapabilities(['panel.registration', 'panel.context']) ->requiresCapabilities(['pages.registration', 'navigation.registration']);
Required capabilities are checked when the provider boots, after every provider has registered, so the registration order doesn't matter. A missing one stops that package with a PackageRequirementException naming it. Several providers of one capability are allowed (diagnose lists them); pick the implementation through the container, for example by binding an interface. A capability is only a name: the packages agree on what it means, and Composer still installs everything. Cycles are harmless, because nothing is reordered.
Install command
->hasInstallCommand(fn (InstallCommand $c) => $c ->publishConfigFile()->publishMigrations()->publish('stubs') ->askToRunMigrations() ->endWith(fn (InstallCommand $c) => $c->info('Done!')))
Everything goes through vendor:publish and migrate. Existing files are kept unless --force is given, so running it again is safe. --migrate migrates without asking, and with --no-interaction the command never migrates on its own. Saying yes, or passing --migrate, counts as the confirmation migrate asks for in production, so the command works in CI and deploy scripts. A failing step stops the install with a non-zero exit code instead of reporting success.
Production
Run php artisan optimize on deploy, which you already do. It verifies every package (requirements, required files, config rules) and caches the result, including what discovery found, in bootstrap/cache/atannex-providers.php. Cached apps skip path lookups, folder scans, class checks and file stats. A package that fails verification is not cached and the command exits 1.
- Editing a provider file invalidates its entry automatically.
optimize:clearremoves the cache. - Any
composer install,update,requireorremovethat changes packages invalidates the whole cache, so requirements are verified again rather than trusted from before the change. - A corrupted or unreadable cache file is ignored, as if not cached; it never breaks a request. Writes are atomic and invalidate opcache.
- Resources added after
optimizeare picked up by the nextoptimize, which is the normal deploy order. - The cache holds only plain arrays: class names, paths, mtimes, the package name and the discovery result. No closures, objects, config values or secrets.
Behaviour with config:cache, route:cache, event:cache, view:cache and optimize was checked to be identical to uncached behaviour in a fresh Laravel 13 app, with a symlinked monorepo package.
Diagnostics
php artisan atannex:provider:list [--json] php artisan atannex:provider:diagnose [--package=kit] [--json] [--fail-on-warnings] # exit 0 ok, 1 errors, 2 warnings
diagnose reports unmet requirements and capabilities, invalid config, optional integrations and why they are off, missing optional files, discovered resources, duplicate listeners, cache state, and conflicts between packages:
| Conflict | Severity | Why |
|---|---|---|
| Same command name, Blade component alias or route name | error | Laravel keeps only one; a duplicate route name also breaks route:cache. |
| Same config, view or translation namespace | warning | The packages' files are mixed under one key or namespace. |
| Same binding with different concretes, or same model with different policies | warning | First binding (bindIf) or last policy wins. |
| Same publish target | warning | vendor:publish writes both to one place. |
Conflicting middleware aliases fail at boot. Your application's own overrides (published config or views, your own bindings) are never reported. JSON output ({"exit_code": n, "findings": [{"severity", "package", "message"}]}) is stable for CI. It never prints config values.
Troubleshooting
| Symptom | Fix |
|---|---|
no composer.json with a "name" was found |
Pass the name: $this->package('my-package'). |
Cannot find the root of package |
Add ->basePath(__DIR__.'/..'). |
declares a ... but it does not exist |
Create the file, fix the name, or pass required: false. |
wants middleware alias [x] ... already points to |
Choose another alias; existing aliases are never overridden. |
can only be configured inside configurePackage() |
A has*() method was called after registration. |
| Config change has no effect | Config is cached (config:clear), or the app's published file already defines that top-level key. |
| New file not picked up in production | Run php artisan optimize again. |
vendor:publish shows no tags |
Publish groups exist only in the console; check atannex:provider:list. |
Octane, queue workers and tenancy
Providers register and boot once per worker. Package metadata (ProviderRegistry, the manifest) is application-wide and holds no request or tenant data, and nothing re-registers per request (covered by a repeated-request test). Use hasScoped() for anything that holds request, job or tenant state: Laravel resets those between requests and jobs. hasSingletons() is for stateless or application-wide services only. The provider implements no tenancy; that belongs to your application.
Security
Package definitions are trusted developer code, not user input. Within that:
- Paths stay inside the package. Resource names and paths reject
.., slashes in names and absolute paths. Discovery only looks in fixed folders under the package root. The only code it loads is candidate command classes, autoloaded from the package's ownautoloadPSR-4 roots (neverautoload-dev), and only while uncached. - Nothing is overridden silently. Existing middleware aliases are never replaced, bindings use
bindIf, and publishing never overwrites without--force. Cross-package collisions are reported bydiagnose. - Nothing runs on boot. No migrations, publishing, network or database calls. Routes keep their own middleware; none is added.
- Caches can't be poisoned into trust. The manifest is only written after full verification and is dropped when the provider file or the Composer install changes, or when it can't be read.
- Diagnostics never print config values. Keep secrets out of
hasAboutSection()details.
Residual risks: a symlink inside a package can point outside it (the package author controls this). A trusted manifest doesn't stat required files, so a file deleted after optimize fails when it is used (config and route files are still checked). Whatever your own hooks and integrations do is your responsibility. Report vulnerabilities privately: see SECURITY.md.
Upgrading from 1.0
No code changes are needed. The cache format changed, so the first request after upgrading runs uncached until php artisan optimize runs again, which deploys do anyway. Optional integrations that previously activated with any version now accept an optional version: constraint; the default '*' keeps the old behaviour. diagnose may report conflicts that already existed but were silent; they are real collisions to fix.
Testing your package
use Atannex\Provider\Testing\AssertsPackages; final class ProviderTest extends \Orchestra\Testbench\TestCase { use AssertsPackages; protected function getPackageProviders($app): array { return [KitServiceProvider::class]; } public function test_wiring(): void { $this->assertPackageBooted('kit'); $this->assertConfigMerged('kit'); $this->assertRouteRegistered('kit.ping', 'GET', 'kit/ping'); $this->assertCommandRegistered('kit:status'); $this->assertPublishes('kit-config', config_path('kit.php')); $this->assertMiddlewareAlias('kit', EnsureKitIsEnabled::class); $this->assertListening(OrderPlaced::class, SendReceipt::class); $this->assertBound(KitManager::class, 'singleton'); // 'transient' | 'singleton' | 'scoped' } }
Web routes in tests need an app.key; set it in defineEnvironment().
Tested on PHP 8.5 with Laravel 13.39 and 12.70. The CI matrix also runs PHP 8.3 and 8.4 against both Laravel versions.
Contributing
composer install
composer quality # composer validate, pint --test, phpstan (max level), phpunit
Stay Laravel-native (every resource maps to a Laravel API), cover every feature with a Testbench test against real behaviour, and record user-facing changes in CHANGELOG.md. Report security issues privately as described in SECURITY.md.
License
MIT. See LICENSE.md.