Search by

atannex / foundation

Atanga1920

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.

Package info

github.com/atannex/foundation

pkg:composer/atannex/foundation

Statistics

Installs: 35

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-11-03 00:08 UTC

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:

  1. Register descriptors. Capabilities, definition types and extension points become available.
  2. Verify every package: required packages, conflicts, required capabilities, version compatibility, exclusive capabilities, cycles. This also computes the load order (from requires and requiresCapabilities, ties broken by name).
  3. registerFoundation() hooks, in load order.
  4. bootFoundation() hooks, in load order.
  5. 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.exclusive to 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 follow definitions.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, highest priority first. Foundation never calls them itself.
  • When they're checked. Classes are validated (existence, contract, duplicates) at registration time.
  • Single-extension points. multiple: false allows 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 TenantResolver you configure. Client headers are ignored, except a configured correlation header.
  • Isolation. ContextManager is 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.