krystal-sf/plugin-manager
A plugin manager for Laravel and PHP applications that handles plugin discovery, registration, activation, and lifecycle management. It provides a clean API for installing, enabling, and disabling plugins so you can extend your application with modular, self-contained packages.
Before you write a line of integration code, resolve one thing: this is a Symfony bundle wearing a Laravel description. The vendor namespace (krystal-sf), the repo name (plugins-manager on GitLab), and the tagline ("Symfony Advanced Plugins Manager") all point at a Symfony Kernel/DI citizen. The "Laravel package" framing in the description is aspirational at best. That changes day-one work: on a pure Laravel app you are not installing a package, you are either bridging a Symfony container or reimplementing a manifest loader on Laravel primitives. Decide which before composer require.
Minimal path, assuming you proceed:
composer require krystal-sf/plugin-manager:<exact-version> — no caret, no tilde. Confirm the Packagist name matches the GitLab repo (note the singular/plural mismatch: plugin-manager vs plugins-manager). Mirror the repo internally the same day; zero stars and zero dependents means a deleted GitLab project is a real failure mode, not a hypothetical.App\Plugins\PluginManager (your class, not theirs) in AppServiceProvider::register(). Inside it, construct/adapt the package's manager, point it at base_path('plugins'), and expose exactly three methods to your app: enabled(), all(), resolve(string $name). Every other file in your codebase talks to your class. This is the seam that makes the inevitable fork or replacement survivable.plugins/HelloWorld/plugin.json (name, version, provider class, host version constraint) plus plugins/HelloWorld/HelloWorldServiceProvider.php. Keep it internal and trivial — one route, one view composer, one config merge.plugins/hello-world: true in config/plugins.php, register only enabled providers in your boot routine, and verify the route exists in local and 404s in production. That single loop — discovery → manifest parse → enabled check → provider register — is the entire value proposition. If it doesn't feel clean after step 5, stop; you've learned the package isn't worth the boot-path liability.Where to look first in the repo, in order: the service provider that registers the manager itself, the manifest schema/DTO, the discovery glob, and the Artisan/console commands (they document the intended workflow better than anything else).
Manifest as the single source of truth. Every behavioural fact about a plugin — name, version, entry provider, required host version, required sibling plugins, capabilities — belongs in the manifest, not in a class constant and not in your config file. Your config file should contain only enablement, never definition. That separation is what lets you ship a plugin directory and turn it on later without a deploy.
Enablement resolution order. Resolve in this order, first hit wins: database/tenant override → environment config → manifest default (enabled: false). Cache the resolved set keyed on a hash of all manifest contents, so any manifest edit invalidates it automatically. Do not cache the manifests separately from the resolution — you will get skew.
Registration split: bindings in register(), side effects in boot(). Bindings, singletons, and config merges for enabled plugins go in register(). Routes, migrations, view composers, event listeners, and command registration go in boot(). This matters more than usual here because a broken plugin in boot() can take down the whole request; isolating side effects keeps the blast radius of a bad plugin to its own features.
Adapter boundary, everywhere. Wrap the package facade behind your own interface:
interface PluginRegistry {
public function all(): Collection; // manifest DTOs
public function enabled(): Collection; // manifest DTOs
public function isEnabled(string $name): bool;
public function resolvedOrder(): array; // topological order
}
Application code, admin controllers, and commands depend on PluginRegistry. Only one class in your codebase imports the package namespace. When you fork it (you will), the diff is one file.
Topological registration order. Never register in filesystem order. Build a graph from each manifest's requires list, topologically sort, and fail loudly on a cycle with the cycle path in the exception message. If the package doesn't do this for you, do it in your adapter before handing providers to the framework — partial registration from a half-resolved graph is the worst possible failure mode because it looks like it worked.
Trust tiers, not just on/off. Two booleans per plugin, not one: enabled and trusted. trusted comes from config only and can never be set by a manifest. Untrusted plugins still need a separate, later decision — treat that as a security review gate, not a config toggle.
Ship the admin surface as Artisan first. plugins:list (name, version, enabled, resolved order), plugins:validate (schema + host version + dependency graph, exit non-zero on failure), plugins:enable/plugins:disable (write to the same store your resolver reads). A web UI is a later problem; commands give you a runbook immediately, and plugins:validate in CI catches manifest breakage before it reaches a boot path.
Testing pattern. Create a tests/Fixtures/plugins/ tree with three plugins: one valid, one with a bad manifest, one that requires a sibling. Assert: discovery finds all three, validation rejects the bad one with a useful message, the dependent resolves after its requirement, and a disabled plugin's routes are absent from the route collection. The last assertion is the one that catches the Octane/cache bugs.
Know when the plugin system is the wrong tool. If the toggle is internal, per-user rollout of your own code, reach for Pennant or a config flag. Plugins earn their complexity only when the code is independently packaged and independently released — third parties, a solutions team, or per-SKU white-label. Adopting a plugin manager to solve staged rollout is the most common and most expensive misdiagnosis here.
The name mismatch is a real risk, not a typo. krystal-sf/plugin-manager vs the GitLab repo plugins-manager. Resolve which is canonical and whether one is a rename-in-progress before you pin a constraint. If the Packagist package is a stale alias, you can end up with a version that has no corresponding tag in the source you're reading.
Zero stars, zero dependents means you own it. Set the expectation explicitly with whoever signs off: no upstream patch flow, no community pressure-testing of upgrades, no signal when it breaks on a PHP minor. Vendor the code or mirror the repo, pin exactly, and write down the fork trigger ("no release compatible with PHP X" / "upstream silent for N months").
A malformed manifest takes down the app, not the plugin. This is the headline operational risk. Three mitigations, do all of them: strict schema validation at discovery (reject unknown keys — silent acceptance of a typo'd require key is how dependency bugs get born), try/catch around each plugin's individual registration with the failure logged and the plugin marked degraded rather than rethrown, and a single PLUGINS_DISABLE_ALL_THIRD_PARTY=true env escape hatch that reverts to core-only boot without a deploy.
config:cache will bake whatever you read in register(). If your resolver reads the DB or the filesystem inside a service provider, php artisan config:cache freezes that answer until the next config:clear. Resolve the enabled set in a cached repository keyed on the manifest hash, and refresh it from a deploy hook or a queued command — not from the provider.
Octane and queue workers will leak plugin state. Registration happens once per worker, so a plugin enabled in a request will not un-register in the next one. That's fine if enablement only changes at deploy; it is a correctness bug if you're toggling per-tenant at runtime. Either accept the "deploy to change" constraint and enforce it (no runtime toggling), or do the tenant check at call time inside the plugin's entry points rather than at registration.
classmap-authoritative and --optimize-autoloader break dynamic discovery. Plugins dropped into plugins/ after composer dump-autoload won't autoload. Either give each plugin its own PSR-4 entry in the root composer.json (fine for a handful, painful past ten) or register a per-plugin autoloader at discovery time. Document the dump-autoload step in the plugin authoring guide or you will field this ticket forever.
Symfony DI in a Laravel container is a seam you will maintain forever. If you bridge, the adapter must construct plugin providers using Laravel's container, not Symfony's compiler-pass machinery — otherwise plugins expecting ContainerInterface-style Symfony injection silently get null. Test with a plugin that has constructor dependencies, not just one that's no-op.
Version skew fails silently by default. A plugin written against an older host API will load and misbehave rather than error. Enforce a requires_host constraint in the manifest at load time and throw a RuntimeException naming both versions. Loud failures at boot are a feature here.
Nothing sandboxes the plugin. Plugins run in-process with full application privileges — no process boundary, no capability restriction. Your controls are allowlisting (directory contents are trusted by review, not by runtime), least-privilege filesystem permissions, and making the plugin directory non-web-writable. If the requirement is ever "run untrusted third-party code," this package is not the answer and no plugin manager is; that's an isolation problem.
Debugging boot order. When something registers twice or a dependency is missing, bind a tiny PluginBootMarker singleton and have each plugin's provider append its name to it. Dump that array to see actual versus intended order. Cheaper than reading stack traces through framework boot noise.
Feature flag vs plugin, revisited at review time. When someone asks "can we make this a plugin?" the answer is yes only if the code will ship on a different cadence than core. Otherwise you're adding a boot-path dependency and a manifest file to avoid writing an if.
How can I help you explore Laravel packages today?