hulkapps/appmanager
HulkApps AppManager is a Laravel/PHP package for application management, offering scaffolding for app settings and administrative workflows. It has minimal community traction, so review the repository and documentation before using it in production.
hulkapps/appmanager is a HulkApps-published SDK (app-manager-sdk) for the "many Shopify-style apps, one Laravel codebase" problem: registering app definitions, resolving which app the current request belongs to, and — as of 3.7.0, unambiguously — owning the billing surface: /plans, per-plan discount resolution, and a ChargeController::process() that actually creates the charge. Treat the README as a hypothesis and src/ as the truth; there are still zero dependents and no public discussion, but 3.7.0 ships a real changelog and a GitHub compare link, so the package is maintained rather than abandoned.
Minimal path in:
composer require hulkapps/appmanager:^3.7 and read the require block in the package's composer.json. Confirm the supported Laravel/PHP matrix before committing — the SDK is now at 3.x on a 3.6.2 → 3.7.0 cadence, so minors are moving.extra.laravel.providers / extra.laravel.aliases in the package composer.json. If present, the provider auto-registers; if not, add it to bootstrap/providers.php (Laravel 11+) or config/app.php.php artisan vendor:publish --provider="HulkApps\AppManager\AppManagerServiceProvider" and read the published config end to end. Config keys are the de-facto API surface.vendor/hulkapps/appmanager/src and identify five things: the manager class, the contract it resolves from the container, any middleware, ChargeController, and the client methods getPlans() / getAutomaticDiscount().First use case to validate is no longer just "resolve two apps" — it's display/charge parity. Call getPlans() with a discount_code and a real shop_installed_at, confirm the injected per-plan discount, then drive ChargeController::process() for that same plan and assert the amount charged matches the amount displayed. That is the promise 3.7.0 makes ("the billing-page display and the actual charge share one verdict") and the only test that tells you whether the abstraction holds. Second, repeat both calls from an artisan command — if resolution only works from HTTP context, you've found the constraint that will bite you in queued jobs and scheduled billing.
Two prerequisites before you trust a green test suite: the backend must have promotional-discount targeting (MGR-97) deployed, and the tag must actually exist on Packagist. Pin the version and commit composer.lock.
Resolve, don't instantiate. Wrap the manager behind a single injectable boundary in your own code — an AppContext service bound in your own provider that delegates to the package. Day-to-day you depend on your interface, so swapping the SDK out (still plausible at zero dependents) is a one-file change.
// app/Providers/AppServiceProvider.php
$this->app->singleton(AppContext::class, fn ($app) => new SdkAppContext(
$app->make(\HulkApps\AppManager\AppManager::class)
));
Middleware for request-scoped resolution. If the package ships middleware, register it on the webhook/API group, not globally. If it doesn't, write a thin middleware that reads the tenant identifier (header, host, or signed payload), calls the resolve method, and stores the result in a scoped() binding. Keep the resolution rule in one class — you will change it.
Config-driven app registry. App definitions (key, slug, credential source, scopes, webhook topics, default plan catalog) belong in config/appmanager.php; secrets come from env() or a secrets manager. Use the DB only for per-install state: tokens, current plan, installed_at.
Thread the context into jobs explicitly. Never let a queued job re-resolve "the current app" from ambient state.
final class SyncOrders implements ShouldQueue
{
public function __construct(private string $appKey, private int $shopId) {}
public function handle(AppContext $ctx): void
{
$ctx->forApp($this->appKey, $this->shopId)->run(fn () => /* work */);
}
}
Discount resolution: one verdict, two call sites. 3.7.0 makes discounts a two-phase operation — display (via getPlans()) and charge (via ChargeController::process() / getAutomaticDiscount()). Your job is to guarantee the two agree, because the SDK only guarantees it while the backend is reachable.
discount_code and shop_installed_at into getPlans(). The release is explicit that this is what lets /plans resolve and inject the winning per-plan discount with no extra round trip. Omitting shop_installed_at silently degrades targeting (new-merchant rules, cohort rules) rather than erroring.shop_installed_at from a single column that is written once at install and never recomputed from a webhook timestamp. Skew between your value and App Manager's is the most likely cause of "the discount showed but didn't apply."/plans returns a discounted price, store {plan, discount_id, value, type, cycles, computed_at} on the install record. At charge time, compare what the SDK is about to charge against that row and alert on mismatch. This is your only defence against the fail-safe divergence described below.Automatic vs coded discounts. A discount flagged is_automatic applies with no code. App Manager picks the winner among automatic-plus-any-code by priority, with automatic breaking ties. Practical consequences:
discount_code being present — automatic discounts render for merchants with no code at all. Test the no-code path explicitly./plans and charge paths.Honour "upgrades only" as a data condition, not a UI condition. ChargeController::process() applies the per-plan value/type/cycles for the chosen plan and only applies the discount when the new plan costs more than the merchant's current plan. So a downgrade, or a lateral change at equal price, is deliberately undiscounted. Build the pricing page to reflect that: if you render a struck-through price on a downgrade, you're promising a discount the charge path will refuse.
Treat cycles as a subscription-lifecycle concern. A discount scoped to N cycles expires. Your renewal, upgrade, and dunning flows all need to know cycle counts, and the SDK is not going to reconcile that for you. Store the discount's cycle budget alongside the plan and decrement it on your own renewal events.
Wrap side effects in idempotency. Any webhook handler or charge the SDK dispatches into should be idempotent — dedupe on the provider's event ID in your own table before acting. Do not assume the package does this. This applies doubly to process(): a retried charge job must not double-charge a merchant.
Test against a fake, not the container. Bind a fake AppContext in tests and assert branching per app, per plan, per discount state. Keep one or two integration tests that hit the real SDK — and make one of them the display/charge parity test — so an upstream change surfaces in a narrow place.
Extension points, in priority order: a contract you can rebind, events on install/uninstall/charge/discount-applied, a Macroable manager, publishable migrations/stubs. ChargeController is a shipped controller — if you need to change charge behaviour, subclass or rebind rather than editing vendor/. Anything published into your app is yours; anything in vendor/ is not.
/automatic-discount correctly in staging.process() won't apply. Compare like the SDK compares.getPlans() is a per-merchant, per-moment call. The response embeds shop_installed_at, code, and resolved discount. Do not put it behind a long TTL or a shared cache key that lacks shop + code. Cache per merchant with a short TTL if you must.config/appmanager.php, run php artisan config:clear. A stale cached config in a container is the classic "works locally" failure.artisan and in workers. Always pass the app key explicitly; ambient resolution is an HTTP-only convenience.scoped(), or require every public method to take the app key as an argument.AppManager is a coin flip against your own classes. Prefer constructor injection; it also makes faking trivial.Log::debug() around every SDK boundary for the first two weeks. For discount issues, log the full discount verdict — discount_id, value, type, cycles, is_automatic, and whether it came from live path or fail-safe. When something misbehaves, the question is always "which app, which discount, which path."illuminate/support, composer require can silently downgrade a transitive dependency. Run composer why hulkapps/appmanager and diff composer.lock after install.^3.7, commit the lock, and read the changelog on every bump. Zero dependents still means zero community bug reports; budget for a fork (MIT, small src/).How can I help you explore Laravel packages today?