Search by

atannex / atannex-provider

Atanga1920

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.

Package info

github.com/atannex/atannex-provider

pkg:composer/atannex/atannex-provider

Statistics

Installs: 125

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v1.1.0 2026-11-02 23:53 UTC

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:clear removes the cache.
  • Any composer install, update, require or remove that 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 optimize are picked up by the next optimize, 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 own autoload PSR-4 roots (never autoload-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 by diagnose.
  • 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.