Cookbook

Glueful Extensions

Learn how to create, configure, and ship Glueful extensions using the ServiceProvider pattern, Composer discovery, declared migrations, and production caching.

Glueful extensions let you add modular domain features (routes, services, migrations, commands, permissions, frontends) through a ServiceProvider. This guide covers Composer discovery, local scaffolding, the migration manifest, configuration, enabling and disabling, the in-admin installer, and production caching.

Overview

An extension is a Composer package of type glueful-extension whose extra.glueful block names a provider class. Composer discovers it; config/extensions.php decides whether it loads; enabling it runs its declared migrations first. For the official packages, see Extensions.

Quick Start

1. Check System Status

# Every discovered extension with its state (enabled ✓ / available ○ / enabled-but-missing ⚠)
php glueful extensions:list

# Details by package name, provider FQCN, or slug
php glueful extensions:info blog

# Discovery + resolver errors (and, in production, whether the cache is present)
php glueful extensions:diagnose

# Startup summary
php glueful extensions:summary

2. Create Your First Extension

php glueful create:extension my-extension

This scaffolds extensions/my-extension/ and adds a Composer path repository for it to your app's composer.json. It does not run Composer. The package contains:

  • composer.json: type glueful-extension, package name glueful/my-extension, with extra.glueful.provider and a migrations declaration (one on_enable lane at the flat migrations/ folder)
  • src/MyExtensionServiceProvider.php: loads routes/routes.php in boot(), registers display metadata, and has a commented mergeConfig() line pointing at the config file below
  • routes/routes.php and config/my-extension.php
  • migrations/.gitkeep, so the declared path exists in a fresh checkout

The provider doesn't call loadMigrationsFrom(): the composer.json declaration registers the migrations and extensions:enable runs them. Next:

composer require glueful/my-extension:@dev
php glueful extensions:enable my-extension

If the extension has no schema, you can replace the declaration with "migrations": "none".

Extensions scaffolded before 1.91 have no migrations declaration and can't be enabled (they fail with UndeclaredSchemaException). Add extra.glueful.migrations (or "none") to their composer.json, move migrations to a flat migrations/ folder, remove any loadMigrationsFrom() call, then run composer update glueful/<slug>. See Migrations.

3. Install a Composer Extension

# 1. Install via Composer
composer require vendor/my-extension

# 2. Enable it: runs its declared migrations, then adds the provider to
#    config/extensions.php `enabled` and recompiles the cache
php glueful extensions:enable my-extension

Installed ≠ enabled. A glueful-extension package is discovered by Composer but does nothing until its provider FQCN is in config/extensions.php → enabled. Use extensions:enable rather than editing the list by hand: it migrates first and records the operation.

Architecture

Core Principles

  1. Composer-native discovery: installed packages of type glueful-extension
  2. ServiceProvider pattern: services() for container compilation, register() / boot() at runtime
  3. Declared schema: migrations are declared in composer.json and applied before enable
  4. PHP config files: one enabled allow-list
  5. Standard interfaces: PSR-11 container, Symfony Console

Package layout

extensions/
└── my-extension/
    ├── composer.json
    ├── src/
    │   ├── MyExtensionServiceProvider.php
    │   └── Console/
    ├── routes/
    │   └── routes.php
    ├── config/
    │   └── my-extension.php
    └── migrations/            # flat, at the package root
        └── 001_CreateWidgetsTable.php

composer.json

{
    "name": "vendor/my-extension",
    "type": "glueful-extension",
    "require": {
        "glueful/framework": "^1.90"
    },
    "autoload": {
        "psr-4": {
            "Vendor\\MyExtension\\": "src/"
        }
    },
    "extra": {
        "glueful": {
            "provider": "Vendor\\MyExtension\\MyExtensionServiceProvider",
            "requires": {
                "glueful": ">=1.90.0",
                "extensions": []
            },
            "migrations": [
                {
                    "id": "default",
                    "path": "migrations",
                    "priority": "default",
                    "mode": "on_enable"
                }
            ]
        }
    }
}

ServiceProvider Pattern

Each extension has a provider that extends Glueful\Extensions\ServiceProvider. register() and boot() both receive the ApplicationContext:

<?php

declare(strict_types=1);

namespace Vendor\MyExtension;

use Glueful\Bootstrap\ApplicationContext;
use Glueful\Extensions\ExtensionManager;
use Glueful\Extensions\ServiceProvider;
use Vendor\MyExtension\Services\MyService;

final class MyExtensionServiceProvider extends ServiceProvider
{
    /**
     * Service definitions compiled into the container.
     */
    public static function services(): array
    {
        return [
            MyService::class => [
                'class' => MyService::class,
                'shared' => true,
                'arguments' => ['@database', '@cache.store'],
            ],
        ];
    }

    /**
     * Runtime configuration (config defaults, etc.).
     */
    public function register(ApplicationContext $context): void
    {
        $this->mergeConfig('my-extension', require __DIR__ . '/../config/my-extension.php');
    }

    /**
     * Runs after every provider is registered.
     */
    public function boot(ApplicationContext $context): void
    {
        $this->loadRoutesFrom(__DIR__ . '/../routes/routes.php');

        // CLI commands (no-op outside the console)
        $this->discoverCommands('Vendor\\MyExtension\\Console', __DIR__ . '/Console');

        // Optional display metadata for extensions:info / list
        $this->app->get(ExtensionManager::class)->registerMeta(self::class, [
            'slug' => 'my-extension',
            'name' => 'My Extension',
            'description' => 'My awesome extension',
        ]);
    }
}

Packaged extensions don't call loadMigrationsFrom(): the extra.glueful.migrations manifest registers their migrations.

Event listeners can be registered from boot() with the event service. Since 1.91, EventService::subscribe(MyListener::class) reads #[AsListener] attributes on public methods or on the class (handler named by method, default __invoke), with priority; see Events. The listener class doesn't need to be in services(): subscribe() autowires an unregistered class, and a listener whose dependencies can't be resolved throws at subscribe() (during boot) rather than being skipped silently at dispatch.

An extension event that should go out as a webhook only needs #[Webhookable] (or the DispatchesWebhooks trait): the framework registers WebhookEventListener at boot (when events.enabled and api.webhooks.enabled are on), so don't subscribe it from your provider or each event is delivered twice. See Webhooks.

Service definitions

services() returns id => spec arrays:

  • 'class' => Concrete::class with optional 'arguments'. A string starting with @ is a service reference: @database (the Connection), @cache.store (the cache), or any class/service id.
  • 'autowire' => true to resolve constructor arguments by type.
  • 'factory' => [self::class, 'makeThing'] for a static factory method (closures aren't allowed in compiled production containers).
  • 'shared' => true|false (default true).
  • 'alias' => ['some-alias', SomeInterface::class] registers other ids that point to this one. That's how an extension contributes a middleware alias: the router resolves string middleware names through the container, so 'alias' => ['my_mw'] makes ->middleware('my_mw') work.
MyMiddleware::class => [
    'class' => MyMiddleware::class,
    'autowire' => true,
    'alias' => ['my_mw'],
],

Available Helper Methods

  • loadRoutesFrom(string $path) - Load a route file (idempotent). The file gets $router only; use $context = $router->getContext(); inside it if you need the context.
  • loadMigrationsFrom(string $dir, int $priority = MigrationPriority::DEFAULT, ?string $source = null, array $previousSources = []) - For app-local providers only. Inside a Composer package, a call must match the package's manifest exactly, and a package with no manifest throws UndeclaredSchemaException.
  • mergeConfig(string $key, array $defaults) - Merge default configuration (app config wins)
  • loadMessageCatalogs(string $dir, string $domain = 'messages') - Load translation catalogs (messages.<locale>.php)
  • serveFrontend(string $path, string $dir, array $options = []) - Serve a prebuilt SPA or static bundle at a literal path (see Frontends)
  • commands(array $classes) - Register console command classes
  • discoverCommands(string $namespace, string $dir) - Register every #[AsCommand] class in a directory
  • registerNotificationChannel(NotificationChannel $channel) / registerNotificationExtension(NotificationExtension $ext) - Plug into notifications (call from boot())
  • runningInConsole() - Whether the process is the CLI

The $this->app property is the PSR-11 container.

CLI Metadata and Slugs

extensions:info|enable|disable resolve an extension by its Composer package name (vendor/my-extension), its provider FQCN, or its slug: the last segment of the package name (my-extension), matched case-insensitively. This needs no registration.

The metadata registry (ExtensionManager::registerMeta()) is display-only: a friendly name and description for extensions:info / list. The version column comes from the installed Composer package.

Load Order: DeclaresLoadOrder

Implement Glueful\Extensions\DeclaresLoadOrder when your provider must load after another. The methods are static so the order can be read during container compilation and cache generation, and the same order is used for service compilation, register(), boot(), diagnostics, and the extensions cache:

use Glueful\Extensions\DeclaresLoadOrder;
use Glueful\Extensions\ServiceProvider;

final class MyServiceProvider extends ServiceProvider implements DeclaresLoadOrder
{
    public static function loadAfter(): array
    {
        return [
            \Glueful\Extensions\Users\UsersServiceProvider::class,
        ];
    }

    public static function loadPriority(): int
    {
        return 0; // tie-break within a level; lower loads first
    }
}

Providers named in loadAfter() that aren't installed are ignored. A cycle among installed providers is an error: it fails cache generation and production boot.

OrderedProvider (instance bootAfter() / priority()) still exists for backward compatibility but only orders the development boot sorter. Don't combine it with DeclaresLoadOrder.

DeferrableProvider (provides(): array) is a declaration for future lazy loading; it has no effect today.

Migrations

Since 1.80 every Glueful package must declare its schema in extra.glueful.migrations. The manifest is the only inventory of extension schema: extensions:enable uses it to migrate before enabling, and migrate:run uses it to decide what to run.

Each row has:

KeyValues
idstable descriptor id, e.g. "default"
pathmigrations directory relative to the package root, e.g. "migrations"
priorityfoundation (-200), identity (-100), platform (-50), default (0), dependent (100)
modeon_enable (applied when the extension is enabled; skipped by global runs while it's disabled) or core (always applied)
verifieroptional FQCN of a StructuralVerifierInterface, used by migrate:verify --adopt
previous_sourcesoptional list of source names the files were recorded under before

A package with no schema declares the string "none" (an empty list is rejected):

"extra": { "glueful": { "provider": "Vendor\\Cdn\\CdnServiceProvider", "migrations": "none" } }

A package without any migrations key still boots, but it can't be enabled or disabled through the schema-aware flow, and if its provider calls loadMigrationsFrom() it throws UndeclaredSchemaException. Malformed declarations throw DescriptorValidationException.

Use dependent for tables that reference identity tables (for example users), and keep migrations in a flat migrations/ directory at the package root.

Adopting an existing schema

When the tables already exist (an install that predates the manifest), classify and adopt instead of re-running:

php glueful migrate:verify                          # ready / adoptable / divergent per source
php glueful migrate:verify --adopt=vendor/my-extension

The source name is the package name for the default descriptor (vendor/package:<id> for others). Adoption requires the descriptor's verifier to confirm each missing migration's effects are present; it then writes the receipts. Nothing is dropped.

Authorization Hooks

Providers can declare permissions, roles, Gate voters, and policies. They're collected for every loaded provider at boot:

use Glueful\Permissions\Catalog\Permission;
use Glueful\Permissions\Catalog\Role;

public function permissions(): array
{
    return [
        Permission::define('widgets.manage')
            ->label('Manage widgets')
            ->category('Widgets')
            ->resource('widgets')
            ->managedBy('vendor/my-extension'),
    ];
}

public function roles(): array
{
    return [Role::define('widget_admin')->label('Widget admin')->grants(['widgets.manage'])];
}

public function voters(): array
{
    return [new WidgetVoter()];                 // list<VoterInterface>
}

public function policies(): array
{
    return ['widgets' => WidgetPolicy::class];  // resource => PolicyInterface class
}

Check the result with php glueful permissions:list / permissions:diff. See Permissions and Authorization.

Frontends

serveFrontend() serves a prebuilt bundle (an admin SPA, docs, …) at a literal path, with safe asset serving and an index.html fallback for client-side routing. Call it from boot():

public function boot(ApplicationContext $context): void
{
    $this->serveFrontend('/admin/widgets', __DIR__ . '/../dist', [
        'spaFallback' => true,     // default true; requires dist/index.html
        'name' => 'Widgets admin', // used in log messages
    ]);
}

The path must start with / and use lowercase [a-z0-9-] segments with no trailing slash. If the directory or (with spaFallback) its index.html is missing, the mount is skipped with a warning. Mounts register controller routes, so route caching keeps working. serveFrontend() replaces the old mountStatic(), which no longer exists.

Configuration

Discovery is Composer-only. config/extensions.php has three keys:

<?php

return [
    // Providers whose enable/disable is owned by another flow; generic surfaces refuse them
    'protected' => [
        // 'Vendor\\Tenancy\\TenancyServiceProvider' => ['reason' => '...', 'managed_by' => '...'],
    ],

    // The activation allow-list: plain string FQCNs, edited by extensions:enable|disable
    'enabled' => [
        'Glueful\\Extensions\\Users\\UsersServiceProvider',
        'Vendor\\MyExtension\\MyExtensionServiceProvider',
    ],

    // The in-admin installer (see below)
    'install' => [
        'enabled'    => env('EXTENSIONS_INSTALL_ENABLED', env('APP_ENV') !== 'production'),
        'timeout'    => (int) env('EXTENSIONS_INSTALL_TIMEOUT', 600),
        'vendor'     => 'glueful/',
        'php_binary' => env('EXTENSIONS_INSTALL_PHP_BINARY') ?: null,
    ],
];

Notes:

  • String FQCNs, not ::class, in enabled. The CLI edits this list as text. A Foo::class literal won't match and shows as enabled-but-missing. Keep env() calls out of enabled.
  • Dev-only extensions: require them as require-dev and list them in enabled. There's no dev_only key.
  • Disabling: run extensions:disable (or remove the entry). There's no blacklist.
  • App providers (e.g. AppServiceProvider) live in config/serviceproviders.php; they always load and aren't gated by extensions.enabled.

Declaring requirements (extension authors)

  • requires.glueful is a Composer version constraint; a mismatch is a resolver error and the extension won't load.
  • requires.extensions lists provider FQCNs that must also be enabled. They aren't auto-enabled; enabling an extension whose dependency isn't enabled is refused.

Command Reference

Discovery & Information

php glueful extensions:list              # every discovered extension + state
php glueful extensions:info <name>       # detail by package name, provider FQCN, or slug
php glueful extensions:diagnose          # resolver errors, load order, (prod) cache presence
php glueful extensions:summary           # startup summary and diagnostics

Enable / Disable

php glueful extensions:enable <name>     # migrate declared schema, then enable + recompile
php glueful extensions:disable <name>    # disable + recompile; schema and data are kept

Both validate before writing (a missing dependency, version mismatch, or cycle is refused and the config is untouched), take the migration lock, persist an operation record, and support --dry-run and --backup. They're allowed in production. Providers listed under protected are refused with the recorded reason. After upgrading to 1.80+, run php glueful migrate:run once before the first enable: the operation ledger is itself a core migration.

Cache Management

php glueful extensions:cache             # compile the manifest (fails on any resolver error)
php glueful extensions:clear             # clear it (--all, --reset)

Example CLI Output

$ php glueful extensions:list
St  Package                Provider                                 Requires     Version
----------------------------------------------------------------------------------------------------
✓ glueful/blog            Glueful\Blog\BlogServiceProvider          >=1.90.0     v1.2.0
○ vendor/analytics        Vendor\Analytics\AnalyticsServiceProvider *            v0.4.1
⚠ (not installed)         Glueful\Shop\ShopServiceProvider          -            n/a

Summary:
Enabled:             1
Available (off):     1
Enabled-but-missing: 1
Cache used:          no

Admin Install API

Core mounts an extensions manager under the API prefix (for example /api/v1/extensions). Every route requires auth:

MethodPathPermissionBody / result
GET/extensionssystem.config.viewinstalled extensions and state
GET/extensions/catalogsystem.config.viewinstallable packages from Packagist (cached)
POST/extensions/installsystem.config.edit{"package": "glueful/flags"}
POST/extensions/enablesystem.config.edit{"package": "glueful/flags"}
POST/extensions/disablesystem.config.edit{"package": "glueful/flags"}

install runs composer require synchronously; the extension is installed but disabled until you call enable, which goes through the same migrate-first executor as the CLI. Install only accepts packages under install.vendor (glueful/ by default) that appear in the catalog, and it's off in production unless EXTENSIONS_INSTALL_ENABLED=true. EXTENSIONS_INSTALL_TIMEOUT (seconds, default 600) and EXTENSIONS_INSTALL_PHP_BINARY (a CLI php for when the web SAPI's binary can't run Composer) tune it. A host where the code or config isn't writable answers 409 with a reason.

Real-World Example

Blog Extension

composer.json

{
    "name": "glueful/blog",
    "description": "Blog functionality for Glueful",
    "type": "glueful-extension",
    "require": {
        "php": "^8.3",
        "glueful/framework": "^1.90"
    },
    "autoload": {
        "psr-4": {
            "Glueful\\Blog\\": "src/"
        }
    },
    "extra": {
        "glueful": {
            "provider": "Glueful\\Blog\\BlogServiceProvider",
            "requires": {
                "glueful": ">=1.90.0",
                "extensions": []
            },
            "migrations": [
                { "id": "default", "path": "migrations", "priority": "dependent", "mode": "on_enable" }
            ]
        }
    }
}

BlogServiceProvider.php

<?php

declare(strict_types=1);

namespace Glueful\Blog;

use Glueful\Bootstrap\ApplicationContext;
use Glueful\Blog\Middleware\BlogAuthMiddleware;
use Glueful\Blog\Services\BlogService;
use Glueful\Extensions\ServiceProvider;
use Glueful\Permissions\Catalog\Permission;

final class BlogServiceProvider extends ServiceProvider
{
    public static function services(): array
    {
        return [
            BlogService::class => [
                'class' => BlogService::class,
                'shared' => true,
                'arguments' => ['@database', '@cache.store'],
            ],
            // Contributes the middleware alias 'blog.auth'
            BlogAuthMiddleware::class => [
                'class' => BlogAuthMiddleware::class,
                'autowire' => true,
                'alias' => ['blog.auth'],
            ],
        ];
    }

    public function register(ApplicationContext $context): void
    {
        $this->mergeConfig('blog', require __DIR__ . '/../config/blog.php');
    }

    public function boot(ApplicationContext $context): void
    {
        $this->loadRoutesFrom(__DIR__ . '/../routes/blog.php');
        $this->discoverCommands('Glueful\\Blog\\Console', __DIR__ . '/Console');
    }

    public function permissions(): array
    {
        return [
            Permission::define('blog.posts.manage')
                ->label('Manage blog posts')
                ->category('Blog')
                ->managedBy('glueful/blog'),
        ];
    }
}

routes/blog.php

<?php

use Glueful\Blog\Controllers\BlogController;
use Glueful\Routing\Router;

// $router is provided by loadRoutesFrom(); get the context from it if needed
$context = $router->getContext();

$router->group(['prefix' => '/blog'], function (Router $router) {
    // Public routes
    $router->get('/', [BlogController::class, 'index'])->name('blog.index');
    $router->get('/posts/{slug}', [BlogController::class, 'show'])
           ->where('slug', '[a-z0-9\-]+')
           ->name('blog.show');

    // Authenticated routes; the controller methods carry #[RequiresPermission('blog.posts.manage')]
    $router->group(['middleware' => ['auth']], function (Router $router) {
        $router->post('/posts', [BlogController::class, 'store'])->name('blog.store');
        $router->put('/posts/{id}', [BlogController::class, 'update'])->name('blog.update');
        $router->delete('/posts/{id}', [BlogController::class, 'destroy'])->name('blog.destroy');
    });
});

Migration Guide

From the Old Extension System

Old SystemNew System
manifest.jsoncomposer.json with extra.glueful.provider
BaseExtension classServiceProvider class
Routes in manifestloadRoutesFrom() in boot()
Migrations in manifest / loadMigrationsFrom() in a packageextra.glueful.migrations descriptors
Config publishingmergeConfig() in register()
Runtime service registrationStatic services()
mountStatic()serveFrontend()
register() / boot() with no argumentsregister(ApplicationContext $context) / boot(ApplicationContext $context)
@db / @cache references@database / @cache.store

Migration Steps

  1. Create a composer.json with type glueful-extension
  2. Convert your main extension class to extend ServiceProvider
  3. Add a static services() method for container compilation
  4. Move initialization to register(ApplicationContext) and boot(ApplicationContext)
  5. Declare extra.glueful.migrations (or "none") and move migrations to a flat migrations/ directory
  6. On installs where the tables already exist, run migrate:verify --adopt=<package>
  7. Remove the old manifest.json

Production

Deployment

# Rebuild the extensions manifest for this release
php glueful extensions:cache

# Apply pending migrations (core + enabled extensions)
php glueful migrate:run

# Compile the DI container with extension services
php glueful di:container:compile

In production the framework boots only from the compiled manifest (bootstrap/cache/extensions.php) and fails loudly if it's missing on an installed app, so run extensions:cache in every deploy. The cache doesn't expire in production (EXTENSIONS_CACHE_TTL_PROD, default forever).

extensions:enable / disable are allowed in production and recompile the cache themselves. Commit the resulting config/extensions.php change, or the next deploy will revert it.

Development Mode

  • Resolves live from Composer + the enabled list (no cache required).
  • A compiled cache, if present, expires after 5 seconds (EXTENSIONS_CACHE_TTL_DEV).
  • A provider that throws in register() or boot() rethrows outside production; in production it's logged and the remaining providers continue.

Troubleshooting

Common Issues

Extension Not Found

[Extensions] Extension provider not found ['provider' => 'MyExtension\\Provider']

Ensure the provider class exists and is autoloaded (composer dump-autoload).

Provider Boot Failure

[Extensions] Provider failed during boot() ['provider' => 'MyExtension\\Provider', 'error' => '...']

Check the provider's boot(). A route file that uses $context must define it from $router->getContext().

UndeclaredSchemaException

The package has no extra.glueful.migrations. Add descriptors or "migrations": "none", then run composer update vendor/package so vendor/composer/installed.json (which the framework reads) picks up the change.

Load-order cycle

Providers blocked by a load-order cycle: ...

Review the loadAfter() declarations of the listed providers.

Debug Commands

php glueful extensions:list
php glueful extensions:info my-extension
php glueful extensions:diagnose
php glueful migrate:verify
php glueful extensions:clear

API-First Philosophy

  • No runtime views: extensions don't use view templating
  • No asset publishing: no file copying into the app
  • Prebuilt frontends: SPAs are compiled and served with serveFrontend() (or from a CDN)
  • Configuration via files

Security

  • Explicit allow-list: nothing loads unless its provider FQCN is in enabled; installing does not activate.
  • Migrate-first, enable-last: enable runs declared migrations under a lock and writes the enabled state last, with a persisted operation record.
  • Validate-before-write: enable/disable refuse to leave the config broken (missing dependency, version mismatch, cycle).
  • Protected providers: lifecycle-owned extensions can't be toggled from generic surfaces.
  • Installer scope: the HTTP installer is permission-gated, vendor- and catalog-restricted, and off in production unless explicitly enabled.
  • Safe static serving: serveFrontend() serves only files inside the bundle directory.
  • Strict production manifest: production boots only from the compiled cache.

Summary

The Glueful Extensions System provides:

  • Composer packages with a declared provider and schema
  • ServiceProvider lifecycle: services(), register(ApplicationContext), boot(ApplicationContext)
  • Declared migrations applied before enable, with verified adoption for existing installs
  • Authorization hooks for permissions, roles, voters, and policies
  • Production-safe operations: enable/disable in production, compiled manifest, admin installer

For the official packages, see Extensions; for the commands, see the CLI Reference.