Glueful Extensions
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: typeglueful-extension, package nameglueful/my-extension, withextra.glueful.providerand a migrations declaration (oneon_enablelane at the flatmigrations/folder)src/MyExtensionServiceProvider.php: loadsroutes/routes.phpinboot(), registers display metadata, and has a commentedmergeConfig()line pointing at the config file belowroutes/routes.phpandconfig/my-extension.phpmigrations/.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-extensionpackage is discovered by Composer but does nothing until its provider FQCN is inconfig/extensions.php→enabled. Useextensions:enablerather than editing the list by hand: it migrates first and records the operation.
Architecture
Core Principles
- Composer-native discovery: installed packages of type
glueful-extension - ServiceProvider pattern:
services()for container compilation,register()/boot()at runtime - Declared schema: migrations are declared in
composer.jsonand applied before enable - PHP config files: one
enabledallow-list - 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::classwith optional'arguments'. A string starting with@is a service reference:@database(theConnection),@cache.store(the cache), or any class/service id.'autowire' => trueto 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(defaulttrue).'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$routeronly; 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 throwsUndeclaredSchemaException.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 classesdiscoverCommands(string $namespace, string $dir)- Register every#[AsCommand]class in a directoryregisterNotificationChannel(NotificationChannel $channel)/registerNotificationExtension(NotificationExtension $ext)- Plug into notifications (call fromboot())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:
| Key | Values |
|---|---|
id | stable descriptor id, e.g. "default" |
path | migrations directory relative to the package root, e.g. "migrations" |
priority | foundation (-200), identity (-100), platform (-50), default (0), dependent (100) |
mode | on_enable (applied when the extension is enabled; skipped by global runs while it's disabled) or core (always applied) |
verifier | optional FQCN of a StructuralVerifierInterface, used by migrate:verify --adopt |
previous_sources | optional 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, inenabled. The CLI edits this list as text. AFoo::classliteral won't match and shows asenabled-but-missing. Keepenv()calls out ofenabled. - Dev-only extensions: require them as
require-devand list them inenabled. There's nodev_onlykey. - Disabling: run
extensions:disable(or remove the entry). There's no blacklist. - App providers (e.g.
AppServiceProvider) live inconfig/serviceproviders.php; they always load and aren't gated byextensions.enabled.
Declaring requirements (extension authors)
requires.gluefulis a Composer version constraint; a mismatch is a resolver error and the extension won't load.requires.extensionslists 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:
| Method | Path | Permission | Body / result |
|---|---|---|---|
GET | /extensions | system.config.view | installed extensions and state |
GET | /extensions/catalog | system.config.view | installable packages from Packagist (cached) |
POST | /extensions/install | system.config.edit | {"package": "glueful/flags"} |
POST | /extensions/enable | system.config.edit | {"package": "glueful/flags"} |
POST | /extensions/disable | system.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 System | New System |
|---|---|
manifest.json | composer.json with extra.glueful.provider |
BaseExtension class | ServiceProvider class |
| Routes in manifest | loadRoutesFrom() in boot() |
Migrations in manifest / loadMigrationsFrom() in a package | extra.glueful.migrations descriptors |
| Config publishing | mergeConfig() in register() |
| Runtime service registration | Static services() |
mountStatic() | serveFrontend() |
register() / boot() with no arguments | register(ApplicationContext $context) / boot(ApplicationContext $context) |
@db / @cache references | @database / @cache.store |
Migration Steps
- Create a
composer.jsonwith typeglueful-extension - Convert your main extension class to extend
ServiceProvider - Add a static
services()method for container compilation - Move initialization to
register(ApplicationContext)andboot(ApplicationContext) - Declare
extra.glueful.migrations(or"none") and move migrations to a flatmigrations/directory - On installs where the tables already exist, run
migrate:verify --adopt=<package> - 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
enabledlist (no cache required). - A compiled cache, if present, expires after 5 seconds (
EXTENSIONS_CACHE_TTL_DEV). - A provider that throws in
register()orboot()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.
Writing Console Commands
Build your own Glueful CLI commands on Symfony Console — the BaseCommand base class, DI-enabled services, registration, and authoring best practices.
Error Handling
The JSON error envelope, the exceptions to throw, typed database failures, and how to customize the framework's exception Handler