neocode / aps-connect
A stateless Laravel client for Registra licence verification and App Station software distribution & auto-updates.
Fund package maintenance!
Requires
- php: ^8.3
- ext-zip: *
- illuminate/process: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- nikic/php-parser: ^5.3
Requires (Dev)
- brianium/paratest: <7.26
- larastan/larastan: ^3.9
- laravel/agent-detector: ^2.0
- laravel/chisel: ^0.1
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- laravel/prompts: ^0.3
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0.4||^5.0
- phpstan/extension-installer: ^1.4
- tomasvotruba/type-coverage: ^2.2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v1.1.0
- v1.0.0
- dev-feature/laravel-prompts
- dev-fix/doctor-development
- dev-feature/has-modules
- dev-feature/release-ships-api-key
- dev-feature/update-command
- dev-feature/sign-verify-commands
- dev-feature/status-command
- dev-feature/fetch-key-command
- dev-feature/login-logout-commands
- dev-feature/init-command
- dev-feature/release-uses-aps-login-session
- dev-chore/remove-update-changelog-workflow
- dev-release/1.1.0
- dev-feature/release-packaging-and-install
- dev-release/1.0.0
This package is auto-updated.
Last update: 2026-10-11 23:33:39 UTC
README
Aps Connect
English | Français
A stateless Laravel client for Registra licence
verification and App Station software distribution & auto-updates, exposed
through a single ApsConnect facade. Every runtime method (everything under
Licensing, Distribution & auto-update
and Marketplace catalogue below) never
persists anything itself — you store the API keys and results it returns in
your own application. aps-connect:release
is the one exception: it builds a release zip on disk and uploads it, on
purpose — see that section for what that involves.
Table of contents
- Requirements
- Installation
- How it works
- Configuration
- Licensing (Registra)
- Distribution & auto-update (App Station)
- Marketplace catalogue (App Station)
- Exception handling
- The
aps-connect:doctorcommand - Packaging & publishing a release (App Station)
- Testing your integration
- Data Transfer Objects reference
- Design notes & anti-patterns
- Changelog
- Contributing
- Security Vulnerabilities
- Credits
- License
Requirements
- PHP
^8.3 - Laravel
illuminate/support^12.0or^13.0 - An
appstation.conf.json(and, in development, anappstation.conf.local.json) file at your application's root, written by theapsCLI (aps init/aps promote) — Aps Connect reads these, it does not write them.
Installation
You can install the package via Composer:
composer require neocode/aps-connect
There is nothing to publish: no config file, no migrations, views,
translations, or public assets. Every runtime method is a pure HTTP call —
none of them touch your database or your frontend, and every credential
comes exclusively from appstation.conf.json / appstation.conf.local.json
(see Configuration below). aps-connect:release (see
Packaging & publishing a release)
is dev-time tooling, not part of that runtime surface — it does write a zip
to disk and shell out to composer/npm.
How it works
Aps Connect wraps two distinct external services behind one facade:
| Service | Purpose | Client class | Auth header value |
|---|---|---|---|
| Registra | Licence issuance & verification | RegistraClient |
Your software's product API key |
| App Station | Marketplace distribution & auto-update | AppStationClient |
The product key for registerSoftwareInstance(); a per-instance key (returned by registerSoftwareInstance()) for downloadPackage()/checkForUpdate() |
Both send the same X-Software-Api-Key header, and — this is a load-bearing
detail — the exact same product API key authenticates both Registra calls and
App Station's registerSoftwareInstance() call. App Station's own accepted
keys are synced from Registra, so there is nothing extra to generate or
configure for App Station on the product-key side.
The package is entirely stateless. It never writes to a database, cache, or disk. Every method call is a single outbound HTTP request, and every response is converted into a readonly, typed Data Transfer Object. Two consequences follow:
- Any state you need across requests — the current licence status, a customer's
App Station instance API key, a "last seen" timestamp — is yours to persist,
in whatever shape fits your application (a column on your
User/Tenantmodel, a dedicated table, a cache entry — Aps Connect has no opinion). registerSoftwareInstance()is not implicitly memoized. Call it once per tenant/device with a stable$clientReferenceand store what it returns; do not call it on every request or every boot (seeregisterSoftwareInstance()for why).
Configuration
appstation.conf.json
This file lives at your application's root (next to composer.json) and is
written by the aps CLI, not by hand. Aps Connect reads it through
Neocode\ApsConnect\Support\ProjectConfigReader:
{
"$schema": "https://appstation.dev/schemas/appstation.conf.v1.json",
"version": 1,
"type": "software",
"environment": "production",
"software": { "token": "53b5e626-4d7a-4a8b-8fbd-8db698b520e4", "name": "YourSoftware" },
"api": { "baseUrl": "https://registra.neocode.ci/api" },
"auth": { "apiKeySource": "env", "apiKeyEnvVar": "REGISTRA_API_KEY" },
"appstation": { "baseUrl": "https://app-station.neocode.ci", "softwareId": 2 }
}
Aps Connect only reads three of these keys — environment, api.baseUrl, and
appstation.baseUrl — everything else in the file exists for the aps CLI's own
bookkeeping and is ignored here.
| Key | Read by | Behaviour |
|---|---|---|
environment |
ProjectConfigReader::credentials() |
"development" or "production" (or absent). No config/env fallback at all — a missing file or key always means "production". This is deliberate: letting an env var flip the environment would let a deployer route real licence checks through Registra's permissive sandbox. |
api.baseUrl |
ProjectConfigReader::credentials() |
Registra's base URL, including the trailing /api (e.g. https://registra.neocode.ci/api). No config/env fallback at all — if absent, MissingCredentialsException is thrown. |
appstation.baseUrl |
ProjectConfigReader::appStationCredentials() |
App Station's bare domain, with no /api/v1 suffix (e.g. https://app-station.neocode.ci) — AppStationClient adds that prefix to every request path itself. No config/env fallback at all — if absent, MissingCredentialsException is thrown. |
Both base urls are trust anchors, not mere defaults: which server every licence check, registration, download, or update check is sent to determines whether those checks mean anything. That's why neither one is ever configurable through an environment variable, nor has a config default to fall back to — only this versioned, publisher-controlled file can set them.
Note
type is "software" (a standalone application) or "module" (an App
Station package tied to a parent application) — aps init --type module
writes a differently-shaped file (module/parentSoftware instead of
software, appstation.packageId/parentSoftwareId instead of
appstation.softwareId). Aps Connect doesn't care either way: environment,
api.baseUrl, and appstation.baseUrl exist under the same names in both
shapes, so credential resolution is identical. The one thing worth knowing if
you're integrating from a module's codebase: the resolved API key is always
the parent software's, not the module's own — the module has no API key of
its own. Use verifyModuleLicence()/verifyStandaloneModuleLicence() for the
module-scoped licence checks; they still authenticate with that same parent
key.
appstation.conf.local.json
A gitignored sibling file, also written by aps init, holding your local
development API key (and a signingSecret used by the aps CLI's own
sign/verify commands — Aps Connect never reads it):
{
"auth": { "apiKey": "your-dev-api-key" },
"signingSecret": "hmac-secret-for-aps-sign-and-aps-verify"
}
ProjectConfigReader::credentials() reads auth.apiKey from this file —
that's the only source for the API key. There is no Laravel config or env
var fallback: if auth.apiKey is missing or empty, MissingCredentialsException
is thrown. Run aps init to write this file.
Aps Connect ships no config file at all — nothing under aps-connect.* is
ever published or read from config(). environment, api.baseUrl, and
appstation.baseUrl come exclusively from appstation.conf.json; the API key
comes exclusively from appstation.conf.local.json's auth.apiKey. A missing
or empty value in either file throws MissingCredentialsException rather than
silently falling back to a value a deployer could edit — run aps init (or
aps promote) to write them.
Environment & sandbox routing
environment in appstation.conf.json controls two things:
- Which API key is expected:
appstation.conf.local.jsonholds whichever key is correct for the current environment — there is never more than one key present in that file at a time, soauth.apiKeyis read the same way regardless ofenvironment. - Sandbox routing for a subset of Registra endpoints.
RegistraClientmarks certain calls assandboxable: true(verifyLicence(),verifyModuleLicence(),subscribe()). Whenenvironmentis"development", those calls are transparently prefixed withsandbox/(e.g.POST sandbox/licences/verifyinstead ofPOST licences/verify). Registra's sandbox accepts reserved dev licence keys without checking real data. Every other method (issueTrial(),issueModuleTrial(),verifyStandaloneModuleLicence(),activateStandaloneModuleLicence(),attachStandaloneModuleLicence(),lookupByCustomerDevice(),me(), and all three App Station methods) always hits the real endpoint, in both environments — there is no sandbox equivalent for them.
Licensing (Registra)
Use the ApsConnect facade (Neocode\ApsConnect\Facades\ApsConnect) or inject
Neocode\ApsConnect\ApsConnect — both resolve the same singleton.
verifyLicence(string $licenceKey): LicenceStatus
Checks whether a licence key is active. Sandboxable.
use Neocode\ApsConnect\Facades\ApsConnect; use Neocode\ApsConnect\Exceptions\LicenceNotFoundException; use Neocode\ApsConnect\Exceptions\LicenceInactiveException; try { $status = ApsConnect::verifyLicence($licenceKey); } catch (LicenceNotFoundException) { abort(404, 'Unknown licence key.'); } catch (LicenceInactiveException $e) { abort(403, $e->getMessage()); } if (! $status->active) { // $status->reason explains why (e.g. "expired", "suspended") } report_usage_dashboard($status->remainingDays, $status->modules);
Returns a LicenceStatus: licenceKey, active,
statusActive, notExpired, reason, status, expiresAt, remainingDays,
modules (a list<ModuleEntitlement>), message.
verifyModuleLicence(string $licenceKey, string $moduleSlug): ModuleVerification
Checks whether a specific module is entitled under a mother licence. Sandboxable.
$verification = ApsConnect::verifyModuleLicence($licenceKey, 'reports'); if ($verification->module->active) { enable_reports_module(); }
Returns a ModuleVerification: licenceKey,
moduleSlug, mother (a full LicenceStatus for the mother licence),
module (a ModuleEntitlement), message.
subscribe(string $licenceKey, array $customer, array $device = []): SubscriptionResult
Claims a licence for a specific customer (and optionally a device), typically right after a purchase or at first activation. Sandboxable.
$result = ApsConnect::subscribe( $licenceKey, customer: [ 'email' => $user->email, 'firstname' => $user->first_name, 'lastname' => $user->last_name, ], device: [ 'uuid' => $deviceUuid, 'type' => 'desktop', 'os' => php_uname('s'), ], ); $user->update(['registra_customer_id' => $result->customer?->id]);
$customeracceptsfirstname,lastname,email(required),phone,address,software_key.$deviceacceptsuuid,type,name,os,browser; omit it entirely (default[]) for a customer-only claim — thedevicefield is then not sent at all, rather than sent asnull.
Returns a SubscriptionResult: licence (a full
LicenceStatus), usagePeriod, customer, device.
issueTrial(array $customer): TrialIssued
Issues a fresh trial licence for a customer. Always hits the real endpoint (no sandbox mirror), so a "trial" issued in development is a real Registra trial — don't call it from a test/CI environment against production Registra.
$trial = ApsConnect::issueTrial(['email' => $lead->email]); Mail::to($lead)->send(new TrialLicenceIssued($trial->licenceKey, $trial->mustActivateBeforeAt));
Returns a TrialIssued: licenceKey, trialPeriodDays,
mustActivateBeforeAt, usagePeriod, customerEmail, message.
issueModuleTrial(string $moduleSlug, array $customer): ModuleTrialIssued
Same idea, scoped to a single module rather than the whole software.
$trial = ApsConnect::issueModuleTrial('reports', ['email' => $lead->email]);
Returns a ModuleTrialIssued: moduleLicenceKey,
moduleSlug, trialPeriodDays, usagePeriod, customerEmail, message.
verifyStandaloneModuleLicence(string $moduleLicenceKey): StandaloneModuleLicence
Checks a module licence that was purchased independently of any mother licence (before it's necessarily attached to one).
$licence = ApsConnect::verifyStandaloneModuleLicence($moduleLicenceKey); if ($licence->status === 'pending_attachment') { prompt_user_to_attach($licence->moduleLicenceKey); }
Returns a StandaloneModuleLicence:
moduleLicenceKey, moduleSlug, status, active, attached,
motherLicenceKey, reason, expiresAt, message.
activateStandaloneModuleLicence(string $moduleLicenceKey, array $customer): StandaloneModuleActivation
Activates a standalone module licence for a customer.
$activation = ApsConnect::activateStandaloneModuleLicence($moduleLicenceKey, [ 'email' => $user->email, ]); if (! $activation->already) { notify_new_module_activation($activation); }
Returns a StandaloneModuleActivation:
moduleLicenceKey, status, expiresAt, usagePeriod, customer,
already (true if it was already active — this call is idempotent), message.
attachStandaloneModuleLicence(string $moduleLicenceKey, string $motherLicenceKey): StandaloneModuleAttachment
Attaches a standalone module licence to a mother licence.
$attachment = ApsConnect::attachStandaloneModuleLicence($moduleLicenceKey, $motherLicenceKey); foreach ($attachment->modules as $module) { // $module is a ModuleEntitlement — the mother licence's full module list post-attachment }
Returns a StandaloneModuleAttachment:
motherLicenceKey, modules (a list<ModuleEntitlement>), message.
lookupByCustomerDevice(string $email, string $deviceUuid): SubscriptionResult
Finds an existing subscription by customer email + device UUID — useful for "restore my licence on this device" flows.
try { $result = ApsConnect::lookupByCustomerDevice($user->email, $deviceUuid); } catch (\Neocode\ApsConnect\Exceptions\LicenceNotFoundException) { // No subscription for this email/device pair. }
Returns the same SubscriptionResult shape as
subscribe().
me(): SoftwareIdentity
Returns identity/metadata about the software the configured API key belongs to
— this is what aps-connect:doctor uses as its connectivity probe.
$identity = ApsConnect::me(); Log::info("Connected to Registra as {$identity->name} ({$identity->environment})");
Returns a SoftwareIdentity: token, key, name,
slug, environment, hasModules, hasApiKey, apiKeyFingerprint,
linkedProductionToken, hasPreviousApiKeyGracePeriod,
apiKeyPreviousExpiresAt. Check hasPreviousApiKeyGracePeriod after rotating
your API key: while true, the previous key is still accepted until
apiKeyPreviousExpiresAt.
Distribution & auto-update (App Station)
This is a separate system from licensing: it lets a specific deployment of your software at a customer's site (a "SoftwareInstance") download package releases and check for updates, gated by its own per-instance API key rather than the product key.
registerSoftwareInstance(string $licenceKey, string $clientReference, ?string $label = null): SoftwareInstanceRegistration
Registers (or resolves, if already registered) a deployment of your software
tied to a verified $licenceKey.
$registration = ApsConnect::registerSoftwareInstance( licenceKey: $tenant->registra_licence_key, clientReference: (string) $tenant->id, // <-- see below label: "{$tenant->name} — production", ); $tenant->update([ 'app_station_instance_id' => $registration->instance->id, 'app_station_instance_api_key' => encrypt($registration->apiKey), ]);
Important
$clientReference is App Station's idempotency key. Pass a stable
identifier — a tenant id, a device UUID, a machine fingerprint — that is the
same every time you call this method for the same deployment. Reusing it
returns the existing instance and its existing API key. Omitting a stable
value (or passing a fresh one every call, e.g. Str::uuid()) creates a
brand-new App Station instance and a brand-new API key on every
single call — orphaning instances server-side. This is why $clientReference
is a required parameter here, unlike App Station's own underlying API where
it's optional.
Returns a SoftwareInstanceRegistration:
instance (a SoftwareInstance) and apiKey (the instance's API key — the
package does not store this for you; persist it yourself, e.g. encrypted on
your tenant model, since it authenticates every subsequent
downloadPackage()/checkForUpdate() call for this deployment).
downloadPackage(string $instanceApiKey, int $packageReleaseId, ?string $moduleLicenceKey = null): PackageDownload
Requests a signed, time-limited download URL for a package release, using the
instance API key returned by registerSoftwareInstance().
$download = ApsConnect::downloadPackage( instanceApiKey: decrypt($tenant->app_station_instance_api_key), packageReleaseId: $release->id, moduleLicenceKey: $moduleLicenceKey, // required only for modules not included in the base licence ); return redirect($download->url); // expires at $download->expiresAt
Returns a PackageDownload: url, expiresAt, checksum
— verify the downloaded file against checksum before installing it.
checkForUpdate(string $instanceApiKey, string $currentVersion, ?string $platform = null, ?string $minStability = null, ?string $currentChannel = null): UpdateCheckResult
Checks whether a newer release exists for this instance's software. Unlike
registerSoftwareInstance()/downloadPackage(), this call has no licence
gate at all — any active instance can check for updates regardless of licence
state, so it's safe to call unconditionally on every app startup.
$update = ApsConnect::checkForUpdate( instanceApiKey: decrypt($tenant->app_station_instance_api_key), currentVersion: config('app.version'), platform: 'windows', // one of: windows, macos, linux, android, ios, universal minStability: 'stable', // least stable channel to consider: nightly, alpha, beta, rc, stable — defaults to stable server-side currentChannel: 'stable', // channel the caller is currently on — defaults to stable server-side ); if ($update->updateAvailable) { notify_user_update_available($update->latestVersion, $update->url, $update->checksum, $update->signature); }
minStability filters out any release published on a less stable channel,
regardless of version number — pass 'beta' to also receive beta releases as
updates, for instance. currentChannel only ever lets App Station offer a
same-version upgrade to a more stable channel (e.g. 1.0.0-rc ->
1.0.0-stable), never a regression to a less stable one.
Returns an UpdateCheckResult: updateAvailable,
latestVersion. When updateAvailable is true, also: release (a full
SoftwareRelease), url, expiresAt, checksum, signature (an optional
HMAC signature of the release, when App Station is configured to sign
releases — verify it if present before trusting the download).
A complete distribution flow
Putting the three App Station methods together, end to end, for a multi-tenant application:
use Neocode\ApsConnect\Facades\ApsConnect; use Neocode\ApsConnect\Exceptions\AppStationLicenceRejectedException; use Neocode\ApsConnect\Exceptions\InvalidAppStationApiKeyException; class SoftwareInstanceService { public function ensureRegistered(Tenant $tenant): string { if ($tenant->app_station_instance_api_key !== null) { return decrypt($tenant->app_station_instance_api_key); } try { $registration = ApsConnect::registerSoftwareInstance( licenceKey: $tenant->registra_licence_key, clientReference: (string) $tenant->id, label: $tenant->name, ); } catch (AppStationLicenceRejectedException $e) { throw new \RuntimeException("Tenant {$tenant->id} has no valid licence: {$e->getMessage()}"); } $tenant->update([ 'app_station_instance_id' => $registration->instance->id, 'app_station_instance_api_key' => encrypt($registration->apiKey), ]); return $registration->apiKey; } public function checkForUpdate(Tenant $tenant, string $currentVersion): \Neocode\ApsConnect\Data\UpdateCheckResult { try { return ApsConnect::checkForUpdate($this->ensureRegistered($tenant), $currentVersion); } catch (InvalidAppStationApiKeyException) { // The stored instance key was revoked server-side — re-register. $tenant->update(['app_station_instance_api_key' => null]); return ApsConnect::checkForUpdate($this->ensureRegistered($tenant), $currentVersion); } } }
Marketplace catalogue (App Station)
These methods read App Station's public marketplace catalogue — the same
data browsable on the App Station storefront itself, including softwares and
packages beyond your own. Unlike every method above, the underlying endpoints
require no authentication at all server-side; AppStationClient still
attaches your product X-Software-Api-Key header to these requests for
consistency, but it's not checked. There is nothing to persist here either:
call these directly wherever you render an in-app "browse marketplace" screen.
Every list method returns a Paginated wrapper, mirroring
Laravel's own paginator shape (items, currentPage, lastPage, perPage,
total) — pass page again yourself to fetch the next page.
listSoftwares(array $filters = []): Paginated<Software>
$softwares = ApsConnect::listSoftwares(['category_id' => $category->id, 'featured' => true, 'q' => $search, 'sort' => 'recent']);
$filters mirrors App Station's own listing query: category_id, featured,
q (free-text search), sort (popular (default), recent, rating, or
downloads), and page.
getSoftware(string $slug): Software
Throws AppStationResourceNotFoundException for an unknown software slug.
getSoftwareReleases(string $slug, int $page = 1): Paginated<SoftwareRelease>
The published release history of a software listing (reuses the same
SoftwareRelease DTO checkForUpdate() returns).
getSoftwarePackages(string $slug, int $page = 1): Paginated<Package>
The marketplace packages (add-ons/modules) published under a given software.
listPackages(array $filters = []): Paginated<Package>
$packages = ApsConnect::listPackages(['software_id' => $software->id, 'page' => 1]);
getPackage(string $softwareSlug, string $slug): Package
Throws AppStationResourceNotFoundException for an unknown software/package
slug pair.
getPackageReleases(string $softwareSlug, string $slug, int $page = 1): Paginated<PackageRelease>
The published release history of a single package.
listCategories(): Category[]
Returns the full category tree in one call (top-level categories with their
children nested) — App Station doesn't paginate this endpoint, so this
returns a plain array rather than a Paginated.
foreach (ApsConnect::listCategories() as $category) { foreach ($category->children as $child) { // ... } }
listTags(int $page = 1): Paginated<Tag>
search(string $query, string $type = 'software'): SearchResults
$results = ApsConnect::search('invoic', type: 'all'); // one of: software | package | all $results->softwares; // Software[] $results->packages; // Package[]
Unlike the list methods above, App Station caps search results at 10 per
type and does not paginate them — SearchResults holds plain arrays, not
Paginated wrappers.
Exception handling
Every exception extends Neocode\ApsConnect\Exceptions\ApsConnectException.
Registra and App Station each have their own hierarchy, keyed by HTTP status —
catching one never accidentally catches the other's failures, since they
authenticate against different servers for different reasons.
Registra exceptions
All extend RegistraRequestException (status: int, body: array).
| Exception | HTTP status | Extra properties | Thrown when |
|---|---|---|---|
InvalidApiKeyException |
401 | — | The configured API key was rejected. |
LicenceInactiveException |
403 | — | The licence exists but isn't in a usable state. |
LicenceNotFoundException |
404 | — | No licence matches the given key. |
LicenceConflictException |
409 | — | E.g. a licence already claimed by another customer/device. |
RegistraValidationException |
422 | errors: array<string, list<string>> |
The request payload failed Registra's validation. |
RegistraUnavailableException |
429 or 503 | retryAfter: ?int |
Rate-limited or Registra is down; retryAfter mirrors the Retry-After header, when present. |
RegistraRequestException |
any other status | — | Fallback for anything not mapped above. |
App Station exceptions
All extend AppStationRequestException (status: int, body: array) — the
same shape as RegistraRequestException, deliberately kept separate.
| Exception | HTTP status | Extra properties | Thrown when |
|---|---|---|---|
InvalidAppStationApiKeyException |
401 | — | The product key (registerSoftwareInstance()) or the instance key (downloadPackage()/checkForUpdate()) was rejected or revoked. |
AppStationLicenceRejectedException |
403 | — | The licence backing the instance/download doesn't allow it (inactive, wrong module entitlement, ...). |
PackageReleaseNotFoundException |
404 | — | downloadPackage() was called with an unknown $packageReleaseId. |
AppStationResourceNotFoundException |
404 | — | A marketplace catalogue lookup (getSoftware(), getPackage(), getSoftwareReleases(), getSoftwarePackages(), getPackageReleases()) was called with an unknown slug. |
AppStationValidationException |
422 | errors: array<string, list<string>> |
E.g. a required moduleLicenceKey was missing. |
AppStationUnavailableException |
429 or 503 | retryAfter: ?int |
Rate-limited or App Station is down. |
AppStationRequestException |
any other status | — | Fallback for anything not mapped above. |
Credential resolution errors
Neocode\ApsConnect\Exceptions\MissingCredentialsException is thrown by
ProjectConfigReader — before any HTTP request is even attempted — when it
cannot resolve an API key or a base url at all (see the resolution-order tables
in Configuration). Its message always says exactly which
value is missing and how to provide it.
The aps-connect:doctor command
php artisan aps-connect:doctor
A safe, side-effect-free diagnostic:
- Calls
me()to confirm the configured Registra API key is accepted, and prints the connected software's name and environment. - Warns if a previous API key is still valid during its rotation grace period.
- Calls
verifyLicence('APS-CONNECT-DOCTOR-PROBE')— a key guaranteed not to exist — to confirm the verification endpoint itself responds correctly (LicenceNotFoundExceptionis the expected, successful outcome here, not a failure). - Prints the resolved App Station base url.
It does not perform a live App Station check: registerSoftwareInstance()
(App Station's only endpoint authenticated with the product key) creates a real
SoftwareInstance on every call, so — unlike Registra's verifyLicence() —
there is no side-effect-free equivalent to probe with a throwaway value.
Exit code is 0 on success, 1 if the API key is rejected at either step.
Packaging & publishing a release (App Station)
One dev-time command, aps-connect:release, covers the part of publishing a
release that repeats on every version: turning the project into an
uploadable artifact, and uploading it. Everything else — linking the repo,
authenticating, signing a repo-linkage manifest, rotating keys — stays in
the aps CLI (aps login, aps init, aps sign, aps fetch-key, ...); this command picks up
where aps init leaves off, reading the same appstation.conf.json it
wrote.
Run with no flags, it asks what to do first (pack and publish, pack only,
or publish an existing file), then prompts for whatever it still needs —
version, channel, platform, which file, the token — one question at a
time, only for fields you didn't already pass as an option. Pass
--pack/--publish (either or both) to skip straight past that first
question; pass every option a field needs and it runs with no prompts at
all, which is what you want in CI (add --no-interaction there too, same
as any other Artisan command — every field falls back to its default or
fails with a clear message instead of prompting).
# fully interactive php artisan aps-connect:release # build the zip only php artisan aps-connect:release --pack [--out=] [--skip-npm] [--build-command=] [--no-obfuscate] # publish an existing file only php artisan aps-connect:release <file> --publish \ --release-version=1.4.0 [--channel=stable] [--platform=universal] \ [--notes=] [--min-software-version=] [--max-software-version=] \ [--upload-name=] [--token=] # build then publish in one shot (CI) php artisan aps-connect:release --pack --publish --release-version=1.4.0 --token=$APS_TOKEN --no-interaction
--pack
Builds a ready-to-deploy zip of the current project:
- Copies the project into a temporary staging directory — never in
place: running
composer install --no-devdirectly in your working copy would strip your own dev dependencies. Excludes.git,.github,.idea,.vscode,node_modules,tests,storage/logs,storage/framework/{cache,sessions,views},.phpunit.cache,*.log,.DS_Store, plus anything a project-root.apsignorefile adds (one glob pattern per line — a barenode_modules-style pattern matches that name anywhere in the tree; a pattern containing/is anchored to the project root)..env*andappstation.conf.local.jsonare always excluded, regardless of.apsignore— they hold secrets (your dev Registra API key, theaps signsigning secret) that must never end up in a published artifact. - Runs
composer install --no-dev --optimize-autoloader --no-interactionin the staged copy. - If a
package.jsonis present and--skip-npmwasn't passed: runsnpm ci && npm run build(override with--build-command=). - Obfuscates the staged PHP source (see below) unless
--no-obfuscateis passed. - Zips the result to
--out=(default:storage/app/aps-connect/releases/{slug}-{date}.zip).
This only automates a plain Laravel web source zip. It does not
orchestrate a NativePHP native build — if you're
shipping a NativePHP installer, build it with NativePHP's own tooling and
hand the resulting file straight to --publish below, exactly as aps release <file> already treats any file as opaque.
--publish
Uploads any file (typically the zip --pack just built, but a NativePHP
installer works just as well) to
POST /api/v1/publisher/{softwares,packages}/{id}/releases, mirroring aps release <file>'s own validation (semver, --channel
stable|beta|rc|nightly, --platform
windows|macos|linux|android|ios|web|cli|browser_extension|universal;
--min-software-version/--max-software-version only for a module). When
combined with --pack, the file --pack just produced is used — the
<file> argument is only read when --publish runs without --pack.
Authentication is a publisher session token, not the runtime Registra
API key. It is resolved in the same order as aps-cli: --token, then the
APS_TOKEN environment variable (sent to APPSTATION_BASE_URL when set),
then the session aps login (aps-cli) stored on this machine — sent to the
App Station instance you logged into — and finally, interactively, a hidden
secret() prompt. Tokens without a base url of their own target
appstation.baseUrl from appstation.conf.json. There is no browser login
flow here; that stays in aps-cli, since it's a one-off per machine, not
something worth reimplementing in PHP.
On success it prints the release's checksum and — once App Station has computed one — its HMAC signature; warns if the signature isn't available yet (App Station backfills it once a signing secret exists for the software).
Source obfuscation: what it does and doesn't protect
By default, --pack runs every staged .php file (except vendor/**,
which is left untouched) through a source obfuscator before zipping. This
exists because a licence check a customer can just delete from the source
— ApsConnect::verifyLicence(), checkForUpdate()'s signature check —
isn't much of a licence check. It is a deterrent against casual
browsing/editing, not real security:
- What it does: strips every comment and docblock, and renames local
variables wherever that's provably safe (a name is left alone if it's
passed to
compact(), declaredglobal, part of a closureuse()capture, or if the containing function usesextract()/$$xat all). - What it never touches: class, method, property, and namespace names.
Laravel resolves those constantly through reflection and magic strings —
the service container, Eloquent, route model binding, job/listener
handle()conventions, named arguments — and renaming any of them automatically risks silently breaking your app on a customer's server, a far worse outcome than merely-weak protection.
This intentionally stops short of ionCube/Zend Guard-grade protection,
which needs a loader extension installed on the target server — not
something you can assume on arbitrary customer-controlled hosting. If you
need real protection against a motivated attacker rather than a deterrent
against casual tampering, that's a different, separate investment (a
compiled component for just the licence check, or requiring ionCube on
hosts you control) — --no-obfuscate is there so you can still hand-roll
your own step in front of --publish if you go that route.
Installing (or updating) on a customer server
--pack deliberately excludes .env* from the zip (it's a secret), so a
customer extracting it can't boot the app yet. aps-connect:install
finishes the job:
php artisan aps-connect:install
It's also the update path: run it again after extracting a newer
release over an existing install (same .env) and it goes straight to
migrate --force/storage:link — no separate "update" command.
It takes two runs the first time, and that's by design, not a bug to
work around: Laravel resolves config('database.connections.*') from
.env at boot, before this command even runs, so writing new DB_*
values to .env partway through a process can't retroactively change the
DB connection migrate would use in that same process. Rather than
override config()/putenv() at runtime in ways that are easy to get
subtly wrong, the command just stops and asks for a second run once a
fresh boot has actually picked the new .env up:
- First run (
.envmissing) — copies.env.example(or writes a minimal template if there isn't one), prompts forAPP_URLand a DB connection (sqliteby default — nothing else to ask;mysql/pgsqlalso prompt for host/port/database/username/password), writes them into.env, and stops with a message to run the command again. - Second run (
.envnow exists) — generatesAPP_KEYif it's empty, runsmigrate --force, runsstorage:link, in that order.
Every field can be supplied as an option instead of prompted for
(--app-url=, --db-connection=, --db-host=, --db-port=,
--db-database=, --db-username=, --db-password=) — with
--no-interaction, a scripted first-time deploy is one line:
php artisan aps-connect:install --no-interaction \
--app-url=https://mon-logiciel.exemple.com \
--db-connection=mysql --db-host=127.0.0.1 --db-database=app \
--db-username=app --db-password="$DB_PASSWORD"
--no-migrate/--no-storage-link skip those two steps individually.
Two events are dispatched — Neocode\ApsConnect\Events\ApsConnectInstalling
at the start of every run (including a run that only writes .env), and
Neocode\ApsConnect\Events\ApsConnectInstalled once migrations/storage:link
have actually run. This is the extension point for a software's own
post-install steps (seeding an admin account, warming a cache, ...) — the
package ships no config file to declare those in, by design, so a listener
in your own EventServiceProvider is how you hook in:
use Neocode\ApsConnect\Events\ApsConnectInstalled; Event::listen(ApsConnectInstalled::class, function (): void { // e.g. Artisan::call('app:seed-admin-account'); });
Testing your integration
Aps Connect is built to be tested with Http::fake() — every example below
mirrors the package's own test suite.
use Illuminate\Support\Facades\Http; use Neocode\ApsConnect\Facades\ApsConnect; it('gates a feature behind an active licence', function () { Http::fake(['*/licences/verify' => Http::response([ 'success' => true, 'active' => true, 'status_active' => true, 'not_expired' => true, 'reason' => null, 'licence_key' => 'LIC-1', 'status' => 'active', 'expires_at' => null, 'remaining_days' => null, 'modules' => [], 'message' => null, ])]); $status = ApsConnect::verifyLicence('LIC-1'); expect($status->active)->toBeTrue(); }); it('handles a rejected instance api key by re-registering', function () { Http::fake([ '*/integrations/software/packages/*/download' => Http::response(['message' => 'Invalid.'], 401), '*/integrations/software/instances/register' => Http::response([ 'instance' => ['id' => 1, 'label' => null, 'licence_key_mask' => '****', 'client_reference' => 'tenant-1', 'status' => 'active', 'last_seen_at' => null, 'revoked_at' => null, 'registered_at' => null], 'api_key' => 'new-instance-key', ], 201), ]); // ... exercise your own service class here ... });
For unit tests that don't need the full container (e.g. testing
ProjectConfigReader in isolation), write a temporary appstation.conf.json to
an isolated directory rather than the shared Testbench skeleton — see
tests/Unit/Support/ProjectConfigReaderTest.php in this repository for the
exact pattern, including why it matters under parallel test execution.
Data Transfer Objects reference
All DTOs are final readonly classes under Neocode\ApsConnect\Data, each with
a static fromArray() constructor. Nested objects follow the same convention.
LicenceStatus
licenceKey: string, active: bool, statusActive: bool, notExpired: bool,
reason: ?string, status: string, expiresAt: ?CarbonImmutable,
remainingDays: ?int, modules: list<ModuleEntitlement>, message: ?string
ModuleEntitlement
slug: string, status: string, active: bool, expiresAt: ?CarbonImmutable,
remainingDays: ?int, reason: ?string
ModuleVerification
licenceKey: string, moduleSlug: string, mother: LicenceStatus,
module: ModuleEntitlement, message: ?string
SubscriptionResult
licence: LicenceStatus, usagePeriod: ?UsagePeriod, customer: ?Customer,
device: ?Device
UsagePeriod
value: int, unit: string, label: string
Customer
id: ?int, email: string, firstname: ?string, lastname: ?string,
status: ?string, phone: ?string, address: ?string, softwareKey: ?string
Device
id: int, uuid: string, type: string, name: ?string, os: ?string,
browser: ?string, lastActiveAt: ?CarbonImmutable
TrialIssued
licenceKey: string, trialPeriodDays: int,
mustActivateBeforeAt: ?CarbonImmutable, usagePeriod: UsagePeriod,
customerEmail: string, message: ?string
ModuleTrialIssued
moduleLicenceKey: string, moduleSlug: string, trialPeriodDays: int,
usagePeriod: UsagePeriod, customerEmail: string, message: ?string
StandaloneModuleLicence
moduleLicenceKey: string, moduleSlug: string, status: string,
active: bool, attached: bool, motherLicenceKey: ?string,
reason: ?string, expiresAt: ?CarbonImmutable, message: ?string
StandaloneModuleActivation
moduleLicenceKey: string, status: string, expiresAt: ?CarbonImmutable,
usagePeriod: ?UsagePeriod, customer: ?Customer, already: bool,
message: ?string
StandaloneModuleAttachment
motherLicenceKey: string, modules: list<ModuleEntitlement>,
message: ?string
SoftwareIdentity
token: string, key: string, name: string, slug: string,
environment: string, hasModules: bool, hasApiKey: bool,
apiKeyFingerprint: ?string, linkedProductionToken: ?string,
hasPreviousApiKeyGracePeriod: bool, apiKeyPreviousExpiresAt: ?CarbonImmutable
SoftwareInstanceRegistration
instance: SoftwareInstance, apiKey: string
SoftwareInstance
id: int, label: ?string, licenceKeyMask: string,
clientReference: ?string, status: string, lastSeenAt: ?CarbonImmutable,
revokedAt: ?CarbonImmutable, registeredAt: ?CarbonImmutable
PackageDownload
url: string, expiresAt: CarbonImmutable, checksum: string
UpdateCheckResult
updateAvailable: bool, latestVersion: ?string, release: ?SoftwareRelease,
url: ?string, expiresAt: ?CarbonImmutable, checksum: ?string,
signature: ?string
SoftwareRelease
id: int, version: string, platform: ?string, channel: ?string,
releaseNotes: ?string, checksum: string, signature: ?string,
fileSize: ?int, isYanked: bool, publishedAt: ?CarbonImmutable
Paginated<TItem>
items: TItem[], currentPage: int, lastPage: int, perPage: int,
total: int — returned by every marketplace catalogue list method.
Software
id: int, name: string, slug: string, tagline: ?string,
description: ?string, logoUrl: ?string, bannerUrl: ?string,
licenseType: ?string, acquisitionMode: string, status: ?string,
isFeatured: bool, hasModules: bool, pricePerDayXof: ?int,
lifetimePriceXof: ?int, downloadsCount: int, ratingAvg: ?float,
ratingCount: int, publisher: ?Publisher, categories: Category[],
tags: Tag[]
Package
id: int, name: string, slug: string, description: ?string,
iconUrl: ?string, type: ?string, isOfficial: bool, isFeatured: bool,
isIncludedInBase: bool, acquisitionMode: string, pricePerDayXof: ?int,
lifetimePriceXof: ?int, hasTrialMode: bool, trialPeriodDays: ?int,
status: ?string, downloadsCount: int, ratingAvg: ?float,
ratingCount: int, software: ?Software, publisher: ?Publisher
PackageRelease
id: int, version: string, platform: ?string, channel: ?string,
releaseNotes: ?string, minSoftwareVersion: ?string,
maxSoftwareVersion: ?string, checksum: string, signature: ?string,
fileSize: ?int, isYanked: bool, publishedAt: ?CarbonImmutable
Publisher
id: int, name: string, slug: string, description: ?string,
logoUrl: ?string, website: ?string, isVerified: bool, status: ?string
Category
id: int, name: string, slug: string, icon: ?string, type: ?string,
position: int, children: Category[]
Tag
id: int, name: string, slug: string
SearchResults
softwares: Software[], packages: Package[]
RegistraCredentials / AppStationCredentials
Internal, resolved by ProjectConfigReader and injected by the service
provider — you won't normally construct these yourself outside of tests.
RegistraCredentials: apiKey: string, baseUrl: string,
environment: string. AppStationCredentials: apiKey: string,
baseUrl: string.
Design notes & anti-patterns
- Don't call
registerSoftwareInstance()on every request or every app boot without a stable$clientReference. Each call without one — or with a freshly generated one — creates a brand-new App Station instance and API key, orphaning the previous ones server-side. Register once, persist the result, replay it. - Don't expect the runtime facade (
ApsConnect::...) to persist anything for you. No migrations, no models, no cache. If you need to look up "which App Station instance belongs to this tenant", that lookup lives in your application's own data model.aps-connect:release --packis the one deliberate exception — it writes a zip to disk by design; see Packaging & publishing a release. - Don't try to set
api.baseUrlthrough Laravel config or an env var. It only ever comes fromappstation.conf.json— there is no config/env fallback, by design. - Don't catch
RegistraValidationExceptionexpecting to catch an App Station failure, or vice versa. The two hierarchies are intentionally separate. - Don't skip signature verification on
checkForUpdate()'ssignaturefield when App Station provides one — it exists precisely so you can detect a tampered or mis-delivered update package before installing it.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Aps Connect! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Aps Connect is open-sourced software licensed under the MIT license.