Skip to content

Shipping an app as a package

You've written an app. This page covers turning it into a standalone Composer package that any host installs with composer require and nothing else. The app appears in the launcher with zero host code.

Four things make a shippable package: the manifest, the provider, the app itself, and how the host wires it in. There's a fifth if you're shipping a custom widget.

1. The composer.json

json
{
    "name": "acme/dashboard-app",
    "require": {
        "php": "^8.3",
        "system-x/core": "^1.0"
    },
    "autoload": { "psr-4": { "Acme\\Dashboard\\": "src/" } },
    "extra": { "laravel": { "providers": [ "Acme\\Dashboard\\DashboardServiceProvider" ] } }
}
  • require: system-x/core declares the dependency on the framework. Your package doesn't vendor or reimplement anything.
  • extra.laravel.providers is how Laravel's package auto-discovery finds your provider. That's the whole mechanism; the host never edits a config file.

One snag if you develop the package inside a host via a path repository before publishing: a path package with no version field can fail to resolve a * constraint under the host's default stability. Add a "version" line while it's a path package, and drop it when you publish, because a published package gets its version from the git tag.

2. The provider: register in boot(), not register()

This is the load-bearing rule of the whole page. Get it wrong and your app silently never appears. No error, no exception, just missing from the launcher.

php
class DashboardServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app->make(AppRegistry::class)->register('acme.dashboard', DashboardApp::class);
    }
}

The reason: the AppRegistry singleton is bound by the framework's own provider, and Laravel calls every discovered provider's register() in a pass that isn't dependency ordered. If your package name sorts before system-x/core, your register() runs first. Resolving the registry there autowires a throwaway instance, your registration lands on it, and it's gone by the time the request continues. By the time boot() runs, every provider's register() has finished, so the real singleton exists and your call lands on it.

This only bites a separate package. An app registered inside the host's own provider doesn't hit it.

3. The app itself

Nothing about writing the app changes when it ships as a package. Widgets, durable properties and handlers all work exactly as described in Building an app. Two things are specific to shipping:

  • Use a dotted vendor.name slug, like acme.dashboard, not a bare name. The registry throws on duplicates, so a bare slug that collides with the framework or another package fails at boot. The dotted prefix keeps you out of the way.
  • Compose from the core widgets, or ship your own. An app built entirely from the built-in widgets needs no client code at all. If you need a bespoke control, see the custom widget section below.

4. How a host consumes it

Once published:

bash
composer require acme/dashboard-app

Auto-discovery picks up the provider, the provider registers the app, and it appears in the launcher. During development, a host can consume it as a path repository instead:

json
{
    "require": { "acme/dashboard-app": "*" },
    "repositories": [
        { "type": "path", "url": "packages/dashboard-app", "options": { "symlink": true } }
    ]
}

If you're iterating without a full composer update, run php artisan package:discover after wiring the package in, or discovery won't pick up the new provider.

5. Shipping a custom widget

A package can ship a widget type the framework knows nothing about, with its own client behaviour and styling. A custom widget is three registrations, all in the provider's boot():

The PHP builder. A class extending SystemX\Core\Wire\Node with a dotted type like acme.gauge, registered in the WidgetRegistry:

php
$this->app->make(WidgetRegistry::class)->register('acme.gauge', Gauge::class);

The serialiser is type agnostic, so the node reaches the client as {type: 'acme.gauge', props, id} with no framework change.

The client renderer. A plain object with create and update, registered into the shared global:

js
window.SystemX.renderers.register('acme.gauge', { create, update });

No build step and no import from the framework. The global is the whole seam. Ship the file (and any CSS) in your package's own dist/ directory, hand-written or built however you like.

The asset registration. Point the AssetRegistry at your dist/, keyed by your vendor id:

php
$this->app->make(AssetRegistry::class)
    ->register('acme', __DIR__.'/../dist', js: 'dashboard.js', css: 'dashboard.css');

The framework's shell emits your JS and CSS as content-hashed tags after the core bundle, so the renderer registry already exists when your script runs, and your renderer registers before first paint. The files are served hash-validated from the package route.

For interactivity, use the delegated dispatch pattern: stamp data-sx-id and data-sx-events on your element in create and attach no listener. The desktop's dispatcher does the rest, and a bound handler round-trips exactly like a built-in button. The full renderer contract, including the rules for widgets that need their own listeners, is on Adding a widget.

The tools

Two artisan commands help here:

  • php artisan system-x:make-app Name scaffolds an app class into the host (with a namespace option to put it elsewhere). It generates and prints the registration snippet; it never auto-registers. Useful as a starting skeleton to move into a new package.
  • php artisan system-x:doctor verifies a setup. It lists every registered app (run it after wiring your package in, and if your app isn't in the list, your provider wasn't discovered), and it cross-checks the widget registries so a dotted widget type with no client bundle fails loudly. It exits non-zero on a mismatch, so it works as a CI check.