flashmandu / app-sdk
Public SDK and contracts for Flashmandu app/plugin developers.
Requires
- php: ^8.3
Requires (Dev)
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The public SDK for building apps/plugins that extend a Flashmandu storefront platform. The SDK exposes contracts; the platform keeps its implementation.
This package is intentionally dependency-light (PHP 8.2+, contracts only) so it is fully standalone and independently versionable.
Full guide: https://business.autohisab.com/custom-app/development-docs — sections 3 → 3e cover this track end to end (package anatomy, sidebar nav, admin screens, hooks, ports, lifecycle), and the hook/scope/extension tables there are rendered live from the platform so they cannot drift.
Install
composer require flashmandu/app-sdk
That gives you contracts and nothing else — the SDK boots nothing. What follows is everything between installed the SDK and the app shows up in a merchant's admin.
Two tracks — pick the right one first
A PHP-native app runs inside the platform process with the host's full privileges (database, filesystem, secrets), so a platform only runs PHP apps whose code its operator has deliberately placed on the server: a first-party package, or a partner package the operator vendored and reviewed.
If you are a third-party developer shipping to merchants on someone else's
platform, build a remote app instead (OAuth install, signed webhooks, App
Bridge iframe, GraphQL API — same hooks, same scopes, same sidebar, your code on
your own server). See section 2 of the guide, and the autohisab-business CLI.
1. Package layout
packages/acme/loyalty/
├── composer.json # extra.laravel.providers → your provider
├── src/
│ ├── Manifest.php # implements AppProvider (+ Installable)
│ ├── LoyaltyServiceProvider.php # routes, views, migrations, Livewire
│ ├── Hooks/LoyaltyHooks.php # implements AppHooks
│ ├── Livewire/… # your admin screens
│ └── Models/… # your OWN tables, profile-scoped
├── routes/web.php
├── database/migrations/
└── resources/views/
{
"name": "acme/loyalty",
"require": { "php": "^8.2", "flashmandu/app-sdk": "^1.0" },
"autoload": { "psr-4": { "Acme\\Loyalty\\": "src/" } },
"extra": { "laravel": { "providers": ["Acme\\Loyalty\\LoyaltyServiceProvider"] } }
}
2. The manifest
namespace Acme\Loyalty; use Flashmandu\AppSdk\Admin\AdminUI; use Flashmandu\AppSdk\Admin\MenuItem; use Flashmandu\AppSdk\AppManifest; use Flashmandu\AppSdk\AppProvider; use Flashmandu\AppSdk\Scope; use Flashmandu\AppSdk\Settings\SettingField; use Flashmandu\AppSdk\Settings\SettingsSchema; class Manifest implements AppProvider { public function manifest(): AppManifest { return new AppManifest( id: 'acme/loyalty', name: 'Loyalty Points', version: '1.0.0', scopes: [Scope::ReadOrders, Scope::ReadCustomers], hooks: new Hooks\LoyaltyHooks, admin: new AdminUI(menu: [ new MenuItem( label: 'Loyalty', route: 'loyalty.dashboard', // a REGISTERED named route icon: 'star', requiredScopes: [Scope::ReadCustomers], // hidden unless granted ), ]), settingsSchema: new SettingsSchema(fields: [ new SettingField(name: 'points_per_currency', label: 'Points per unit', type: 'number', default: 1), ]), ); } }
All fields are optional except id / name / version:
scopes—Scope[]requested at install (ReadOrders,WriteCustomers,ManageStorefront,ReadContent,ReadEmployees,ReadMedia, …).ManagePaymentsis elevated and defaults to denied.ReadEmployeesis sensitive — employee records carry contact details for people who are not the merchant, and marketplace review treats it as elevated. Port scopes (read:catalog,write:ledger,send:notifications, …) are granted platform-side, not from the manifest — see §5. Catalog reads (item.*,inventory.*, theitemslist query) rideread:catalog; there is deliberately no separateread:items.hooks— anAppHooksimplementation (§4).admin— anAdminUIofMenuItems: your entries in the merchant's admin sidebar (§3).storefront— aStorefrontofSectionDefinitions: composable section templates built from existing page-builder elements, so parity across the platform's renderers is inherited by construction. Never introduce a new element type.extensions—Extensions that contribute a block to a host surface (admin.order.detail.block,pos.cart.action, …).settingsSchema— a declarative form the host renders for you at/apps/{appId}/settings, persisted on the install row. Read it back with the platform'sAppManager::settingsFor($profile, $appId).- Implement
InstallableforonInstall()/onUninstall()lifecycle work (seed defaults, register notification types, clean up). Prefer leaving business records intact on uninstall.
MCP tools
Declare MCP tool verbs the host's MCP proxy exposes to agents on the merchant's behalf:
use Flashmandu\AppSdk\Mcp\McpSurface; use Flashmandu\AppSdk\Mcp\McpTool; mcp: new McpSurface(tools: [ new McpTool( name: 'list_loyalty_balances', description: 'List a customer\'s loyalty point balance.', inputSchema: ['type' => 'object', 'properties' => ['customer_id' => ['type' => 'string']]], endpoint: '/mcp/list-loyalty-balances', scopes: [Scope::ReadCustomers], readOnly: true, ), ]),
- A tool's
scopesmust be a subset of the app's ownscopesabove — the manifest constructor callsMcpSurface::assertWithin()and refuses to build otherwise, so an app can never declare a verb needing access it did not itself request. - The host proxies each call as an HTTP POST to
endpointon the app's own base URL; tool code never runs inside the platform. - The host treats a tool's response as untrusted third-party content — the same as any other data an app returns.
endpointis allow-listed, not blacklisted: it must be one or more/segmentgroups made of[A-Za-z0-9._~-]characters (no bare.or..segment), with an optional trailing slash — anything else (a scheme,//,?,#, whitespace, backslashes, percent-encoding) is refused.inputSchema['properties']must not declareprofile_id— the host injects it itself, so a tool cannot accept it as caller-supplied input.
The call your endpoint receives
A signed JSON POST, verified exactly like a webhook with
Flashmandu\AppSdk\Webhooks\Verifier and your install's webhook secret:
{"tool": "list_loyalty_balances", "arguments": {"customer_id": "c_1"}, "profile_id": 42, "request_id": "…"}
Headers: X-App-Event: mcp.tool_call, X-App-Signature, X-App-Timestamp,
X-App-Delivery (the request_id). Answer with a JSON body, within the host's
timeout (5 s by default) and under its size cap (64 KB by default); anything
else reaches the agent as "unavailable". Your answer is shown to the agent under
data.result, marked untrusted.
The review probe — do not act on it
When your app is submitted for review, the host calls every tool once to
check it conforms. The probe body carries "probe": true and "profile_id": 0,
the request carries X-App-Probe: 1, and it is signed with a one-off secret
your app cannot verify. A write tool must not act on a probe: answer it with
any JSON body (a 4xx is fine) and do nothing. The probe passes on a JSON
answer, in time, under the cap, with a status below 500.
Host limits per app: at most 50 tools, descriptions up to 1,024 characters, and
an inputSchema up to 16 KB.
3. Service provider, routes, sidebar
class LoyaltyServiceProvider extends ServiceProvider { public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadViewsFrom(__DIR__.'/../resources/views', 'loyalty'); $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); Livewire::component('loyalty.dashboard', Livewire\Dashboard::class); } }
// routes/web.php — the installed-app middleware enters your app's identity, // which is what makes port scope checks (§5) real for your own screens. Route::middleware(['web', 'auth', 'installed-app:acme/loyalty']) ->prefix('apps/loyalty') ->name('loyalty.') ->group(function (): void { Route::get('/', Livewire\Dashboard::class)->name('dashboard'); });
A sidebar item renders only when (a) the merchant has the app installed and
enabled, (b) Route::has($item->route) is true, and (c) the item's
requiredScopes are granted. A missing route is skipped silently — that is the
usual cause of "my app has no sidebar entry". Check with
php artisan route:list --name=loyalty.
Screens are ordinary full-page Livewire components rendered in the host layout
(->layout('components.layouts.app', ['title' => …])) and must match host
styling — header block, filter bar, plain-table card, tinted icon-button row
actions. Section 3b of the guide is the pattern sheet; copy it rather than
inventing a package-local design system.
4. Hooks
Declare listeners and filters once; the platform decides at fire time which run, so a listener only ever executes for a profile that installed and enabled your app.
class LoyaltyHooks implements AppHooks { public function register(HookRegistrar $r): void { $r->on('order.status.changed', function (array $payload, AppContext $ctx): void { // $ctx->profileId() — the merchant (may be null: return early) // $ctx->hasScope(Scope::ReadOrders) — what they actually granted try { AwardPoints::for($ctx->profileId(), (int) $payload['order_id']); } catch (Throwable $e) { report($e); // listeners run inline in the host request } }); // Filters receive a value and MUST return one. $r->filter('cart.totals', fn (array $totals, AppContext $ctx): array => $totals); } }
- Your exceptions are not caught by the host: an uncaught throw fails the
merchant's checkout, not just your feature. Catch,
report(), return — and push anything slow onto a queued job. - Ordering across apps is unspecified; never assume you run first or last.
- Prefer the typed
Flashmandu\AppSdk\Hooks\HookNameenum over raw strings. - Common events:
order.created,order.updated,order.status.changed,customer.created/updated/deleted,item.created/updated/deleted,price.changed,inventory.level.changed,cms.entry.published/updated/unpublished/deleted,media.uploaded,employee.created/updated/archived,schedule.tick;cart.totalsis a filter. The live catalog is section 9 of the guide (andautohisab-business hooks). - Payloads are thin.
{ id, occurred_at, sequence, changed: [...] }plus a small summary — hydrate details through the ports with your scoped token. Deliveries are at-least-once: dedupe onX-App-Deliveryand drop updates whose per-resourcesequenceis below what you already applied. - Subscribing is scope-gated, at manifest sync AND at delivery time — a
scope revoked later silences the stream.
customer.*needsScope::ReadCustomers,item.*/inventory.*need the host'sread:catalogport scope,cms.entry.*needsScope::ReadContent,employee.*needsScope::ReadEmployees, media reads needScope::ReadMedia. - Three lifecycle events are mandatory-delivery — every remote app gets
them whether or not it subscribed, and they retry through the full backoff
schedule:
app.uninstalled,app.scopes.updated,profile.data.erased. They are the purge signals;HookName::isMandatory()tells you which. schedule.tickis declaration-driven: it arrives because the app declared a[[schedules]]block inflashmandu.app.toml, not because it subscribed, and no read scope gates it. Payload is{ schedule, profile_id, scheduled_at }—scheduleis the block'sname, which is why those names must be unique per app. Adding it tosubscribed_eventsdoes nothing; deleting the block is what stops it.HookName::isDeclarationDriven()distinguishes it from the mandatory set.
Verifying inbound webhooks
Remote (non-PHP-native) apps receive signed HTTP deliveries. Stop hand-rolling the HMAC:
use Flashmandu\AppSdk\Webhooks\Verifier; $result = (new Verifier($webhookSecret))->verify( file_get_contents('php://input'), // RAW body — never a re-encode getallheaders(), // or $request->headers->all() ); if (! $result->ok) { // $result->reason, $result->event, $result->deliveryId — log all three. http_response_code(401); return; } match ($result->hook()) { HookName::OrderCreated => $this->onOrderCreated($payload), default => null, };
verify() checks hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret)
against X-App-Signature in constant time and rejects a timestamp more than
300s (configurable) from now, in either direction. verifyAndDecode() does the
same and hands back the decoded array, or null.
Retry ladder: 6 attempts — 1m, 5m, 30m, 2h, 8h — then dead-letter. Respond within 10s; accept with 202 and finish asynchronously if the work is slow.
Page chrome (PHP-native apps)
An app renders no desktop heading of its own. Breadcrumbs, page actions and
the dirty ⇄ Save/Discard swap live in the host command bar. Iframe apps send
them over the bridge as SET_PAGE; PHP-native apps declare the same thing:
use Flashmandu\AppSdk\Admin\{Breadcrumb, PageAction, PageChrome}; new PageChrome( crumbs: [ new Breadcrumb('Loyalty', 'loyalty.index'), new Breadcrumb('Tiers'), // current page — no route ], actions: [ new PageAction(id: 'new-tier', label: 'New tier', variant: 'primary'), ], dirty: $form->isDirty(), );
Caps mirror the protocol — 5 crumbs, 4 actions — because the host drops an
over-cap payload whole rather than trusting a partial chrome. While dirty is
true the host replaces your actions with its own native Save / Discard.
5. Reading and writing host data — ports
Do not query the host's Eloquent models. The platform exposes ports —
CatalogPort, PartyPort, LocationPort, OrdersPort, LedgerPort,
DiscountPort, SettingsPort, NotificationPort, BroadcastPort — resolved
from the container and scope-checked on every call:
app(LocationPort::class)->forIds($ids); // requires read:locations app(NotificationPort::class)->notifyStaff(…); // requires send:notifications
Four rules: reads are batch-first (forIds(array), keyed by the id you
asked for — there is no find($id), so an N+1 across the boundary is not
writable by accident); no Eloquent crosses the boundary (readonly DTOs, and
you store platform ids as plain scalars so a deleted host row cannot cascade
into your data); isAvailable() lets you degrade instead of crash; and a
call without the scope throws ScopeDeniedException and is audited.
Port scopes are granted platform-side (config/apps.php →
default_port_scopes) because they are not yet part of this package's Scope
enum. Tenancy comes from the platform's ProfileScope, never from an argument
you pass — scope your own tables by it too.
6. Registering the app with a platform
Either add the provider class to config/apps.php → registered (first-party,
trusted code only), or have a super-admin register it from Apps → Add custom
app by its FQCN, which requires only that the class is autoloadable and
implements AppProvider. Merchants then install it from their Apps page — that
install record is what gates hooks, menu items and scopes.
On Octane, manifest edits need a worker reload (php artisan octane:reload);
install/enable changes take effect immediately.
7. Test your app locally (no platform needed)
Flashmandu\AppSdk\Testing\AppTester is an in-memory harness that collects your
app's declarations and lets you install, emit and pipe filters in your own
Pest/PHPUnit suite:
it('awards points when an order is paid', function (): void { $tester = AppTester::for(Acme\Loyalty\Manifest::class); $tester->install()->emit('order.status.changed', ['status' => 'paid']); expect($tester->wasFired('order.status.changed'))->toBeTrue(); }); it('applies a redemption to cart totals', function (): void { $tester = AppTester::for(Acme\Loyalty\Manifest::class)->install(); expect($tester->runFilter('cart.totals', 100))->toBe(90); });
The harness mirrors the platform's gating: nothing fires before install(), so
tests reflect how the real engine only runs apps a merchant has installed.
Versioning
This package is the stable contract between apps and the platform. Breaking
changes follow Semver (vMAJOR.MINOR.PATCH); apps pin a compatible range.