atannex / foundation
Headless foundation for Atannex Laravel packages: package descriptors, dependency and capability checks, a predictable package lifecycle, named definition registries, extension points, execution context and diagnostics.
Requires
- php: ^8.3
- atannex/atannex-provider: ^1.1
- composer/semver: ^3.4
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^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
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 00:10:10 UTC
README
The shared base for Atannex Laravel packages. Each package describes itself with a descriptor. Foundation checks dependencies and capabilities, boots packages in a predictable order, and gives them named definition registries, extension points, an execution context and diagnostics. It renders nothing and holds no domain logic: each package owns its behaviour, and the application owns its interface.
Laravel wiring (config, commands, publishing, bindings, config checks, the production cache) comes from atannex/atannex-provider, which Foundation is built on and requires.
Requires PHP 8.3+ and Laravel 12 or 13.
composer require atannex/foundation php artisan atannex:diagnose
Package discovery registers both providers and the Foundation facade. With discovery off, list Atannex\Provider\ProviderServiceProvider and Atannex\Foundation\FoundationServiceProvider in bootstrap/providers.php, in any order.
Building a package
1. A package class with a descriptor (plain data, in PHP):
use Atannex\Foundation\Contracts\{BootsWithFoundation, Package, RegistersWithFoundation}; use Atannex\Foundation\Definitions\DefinitionType; use Atannex\Foundation\Extensions\ExtensionPoint; use Atannex\Foundation\Foundation; use Atannex\Foundation\Package\PackageDescriptor; final class FormPackage implements Package, RegistersWithFoundation, BootsWithFoundation { public function __construct(private readonly FormManager $forms) {} public static function descriptor(): PackageDescriptor { return new PackageDescriptor( name: 'atannex/form', version: '1.0.0', // keep equal to the Composer release requires: ['atannex/foundation'], optional: ['atannex/action'], capabilities: ['forms', 'forms.validation'], optionalCapabilities: ['actions.execution'], config: 'form', definitionTypes: [new DefinitionType('form', FormDefinition::class)], extensionPoints: [new ExtensionPoint('forms.validators', FormValidator::class)], ); } public function registerFoundation(Foundation $foundation): void // after every package registered { $foundation->extensions()->register('forms.validators', RequiredFieldsValidator::class, priority: 100, package: 'atannex/form'); } public function bootFoundation(Foundation $foundation): void // required packages have booted { if ($foundation->hasCapability('actions.execution')) { $this->forms->enableActions(); } } }
2. A service provider built on atannex-provider:
use Atannex\Foundation\Support\InteractsWithFoundation; use Atannex\Provider\AtannexServiceProvider; final class FormServiceProvider extends AtannexServiceProvider { use InteractsWithFoundation; protected function configurePackage(): void { $this->package('atannex-form') ->discoversResources() // config/, routes/, views, commands... ->hasSingletons([FormManager::class]); } protected function packageRegistered(): void { $this->registerFoundationPackage(FormPackage::class); } }
Never call $this->app->make(Foundation::class) while providers register: Composer orders providers alphabetically, so Foundation's may not be bound yet. registerFoundationPackage() and withFoundation() wait for the kernel, which works in any provider order, even when providers are registered after the app booted. Put Laravel wiring in configurePackage() (see the provider's README); keep the descriptor for Foundation concerns.
3. The application registers definitions:
final class AppServiceProvider extends ServiceProvider { use InteractsWithFoundation; public function boot(): void { $this->withFoundation(static function (Foundation $foundation): void { if ($foundation->definitions()->defines('form')) { // guard optional packages $foundation->register('form', 'students.create', CreateStudentForm::class, ['title' => 'New student']); $foundation->extensions()->register('forms.validators', EmailDomainValidator::class); } }); } }
Lifecycle
Foundation boots from Laravel's booted callback, after every provider has registered and booted:
- Register descriptors. Capabilities, definition types and extension points become available.
- Verify every package: required packages, conflicts, required capabilities, version compatibility, exclusive capabilities, cycles. This also computes the load order (from
requiresandrequiresCapabilities, ties broken by name). registerFoundation()hooks, in load order.bootFoundation()hooks, in load order.- Ready:
Foundation::isReady().
Strict mode (default) stops the app on the first package failure, with a message naming the cause. With ATANNEX_FOUNDATION_STRICT=false, failed packages are logged at error level, skipped, and provide nothing. atannex:diagnose, optimize, config:* and a few other maintenance commands keep working even in strict mode.
Capabilities
Lowercase dotted names for installed features (forms, tables.exports, actions.execution):
-
Declaring. Packages provide them (
capabilities), need them (requiresCapabilities, which fails without a provider and orders the boot after it), or use them when present (optionalCapabilities, which neither orders nor fails). -
Querying:
Foundation::hasCapability('forms'); Foundation::providersFor('tables.exports'); // sorted package names Foundation::capabilities()->require('forms'); // throws CapabilityNotFoundException
-
Exclusive capabilities. Several providers are allowed. List a capability in
capabilities.exclusiveto allow only one. -
Capabilities are not permissions. A capability being installed never means anyone may use the feature. Authorization stays in gates, policies and the feature packages.
Foundation's own capabilities (foundation, foundation.packages, ...) are also declared on its provider. That lets any AtannexServiceProvider package use ->requiresCapabilities('foundation') as a provider-level check. Packages registered through descriptors don't need it.
Definitions and extensions
Definitions are named classes grouped by a type that one package owns:
Foundation::register('table', 'students.index', StudentTable::class); $table = Foundation::registry('table')->resolve('students.index'); // built through the container, contract re-checked
- What is stored. Registration only stores strings, so boot stays cheap.
- Allowed values. Names are lowercase dotted keys, and metadata is plain data.
- Overriding.
DefinitionType(..., replaceable: false)forbids overrides. Duplicates followdefinitions.duplicates(exception,ignore,replace).
Extension points are named places where others plug in classes:
- Who calls them. The owner calls
extensions()->resolve('forms.validators')and gets instances, highestpriorityfirst. Foundation never calls them itself. - When they're checked. Classes are validated (existence, contract, duplicates) at registration time.
- Single-extension points.
multiple: falseallows exactly one extension.
Execution context
ExecutionContext is an immutable value: actor, tenant, locale, correlation id, source (Http, Console, Queue, Schedule, Test, Custom) and plain metadata. Read the current one with Foundation::context()->current(), and run code inside another with Foundation::context()->run($context, fn ($context) => ...).
- Where the values come from. The actor comes from Laravel's guards, and the tenant only from the
TenantResolveryou configure. Client headers are ignored, except a configured correlation header. - Isolation.
ContextManageris scoped: it is reset per Octane request and per queue job, and jobs and scheduled tasks start with no actor or tenant. - No serialization. A context refuses to be serialized. Pass the ids a job needs, then rebuild the context in the job with
ExecutionContext::forQueue(actorId: ..., tenantId: ...).
Result::success($value) / Result::failure(new ExecutionFailure('form.invalid', 'The form has errors.', $details)) is an optional shared result type. toArray() never exports the wrapped exception.
Configuration
php artisan vendor:publish --tag=atannex-foundation-config. The file holds plain values (config:cache safe), and invalid critical values are rejected rather than guessed.
| Key | Default | Env |
|---|---|---|
strict |
true |
ATANNEX_FOUNDATION_STRICT |
definitions.duplicates |
exception |
ATANNEX_FOUNDATION_DUPLICATES |
capabilities.exclusive |
[] |
|
events (lifecycle events) |
true |
ATANNEX_FOUNDATION_EVENTS |
context.guards / tenant_resolver / correlation_header / locale |
[null] / null / null / app locale |
|
diagnostics.log_channel / log_lifecycle / log_actor / log_tenant |
null / false / false / false |
ATANNEX_FOUNDATION_LOG_CHANNEL, ATANNEX_FOUNDATION_LOG_LIFECYCLE |
interface.enabled / interface.path |
true / app/Atannex |
ATANNEX_INTERFACE, ATANNEX_INTERFACE_PATH |
The merge is shallow (Laravel's mergeConfigFrom): when you publish the file, keep whole nested arrays. app/Atannex (App\Atannex) is where feature packages look for the application's panels, resources, actions and widgets. Classes there implement DefinesDefinition to give their registered name.
Events: PackageRegistered, PackageBooted, PackageBootFailed, FoundationBooted, ExtensionRegistered. By default, logs carry only package names, versions, exception classes, the correlation id and the source.
Diagnostics
php artisan atannex:diagnose [-v] [--json] [--fail-on-warnings] # exit 0 ok, 1 errors, 2 warnings php artisan atannex:packages [--json] # load order, versions, states, capabilities php artisan atannex:provider:diagnose # Laravel wiring: config rules, conflicts, cache
atannex:diagnose covers platform, config, packages (with descriptor/Composer version mismatches), dependencies and load order, capabilities, definitions, extensions, route conflicts (duplicate names, shadowed routes) and lifecycle failures. In code: Foundation::diagnose().
Troubleshooting
| Message | Fix |
|---|---|
Cannot initialize atannex/x. Required package: |
Install it, or register its provider (discovery off?). |
Required capability: ... No registered package provides this capability. |
Install a package providing it. |
Circular package dependency detected |
Make one dependency optional. |
Capability [x] is exclusive |
Remove one provider, or drop it from capabilities.exclusive. |
Definition type [form] is not defined. |
The owning package isn't installed or registered. Guard with definitions()->defines('form'). |
make(Foundation::class) fails in a provider |
Use registerFoundationPackage() / withFoundation(). |
An ExecutionContext cannot be serialized. |
Pass ids to the job and rebuild the context there. |
| Config change ignored | php artisan config:clear; published files replace whole top-level keys. |
Testing
// Unit, without Laravel $foundation = Foundation::create(new FoundationConfig(strict: true), new Container); $foundation->registerPackage(FormPackage::class); $foundation->boot(); // Feature, with Testbench protected function getPackageProviders($app): array { return [ProviderServiceProvider::class, FoundationServiceProvider::class, FormServiceProvider::class]; }
Test every package alone and with or without each optional package, and assert that atannex:diagnose exits 0. The AssertsPackages trait from atannex-provider checks the Laravel wiring.
Security
Foundation enforces the following:
- Registry lookups only resolve names that code registered; a key is never treated as a class.
- Contracts are re-checked after the container builds an object.
- Metadata is plain data.
- Descriptors are code, never files read at runtime.
- Boot does no I/O.
The application remains responsible for authorization, tenant verification (never trust a raw header or subdomain), deciding which definitions a request may reach, and not registering anything during a request under Octane. Report vulnerabilities privately; see SECURITY.md.
Contributing
composer install, then composer quality (validate, Pint, PHPStan max, PHPUnit) and composer audit. Foundation stays headless and small, and must never depend on a feature package. Every behaviour change needs a test, and user-facing changes go in CHANGELOG.md.
License
MIT. See LICENSE.md.