Composer packages

Who this is for

You are packaging reusable logic for Pionia Shop or other Pionia apps — a phone normalizer plugin with no boot hooks, or a billing provider that registers middleware, commands, and an API switch.

What you will learn

  • When to ship a plain Composer library vs a Provider subclass
  • Minimal plugin structure and a full provider package layout
  • Local path-repository development and Packagist checklist

Before you start

Before you start
  • App providers — hook reference and boot order
  • A sandbox app (composer create-project pionia/pionia-app sandbox)
  • Composer 2.x and PHP 8.5+

How it works

flowchart LR
  Consumer[Pionia Shop app] --> Require[composer require]
  Require --> Plugin["acme/phone-normalizer"]
  Require --> ProviderPkg["acme/pionia-billing"]
  Plugin --> Service[Used from ProductService]
  ProviderPkg --> Prov[BillingProvider]
  Prov --> INI["[app_providers]"]
  INI --> Boot[Pionia boot hooks]

Plugins vs providers

KindHooks into Pionia boot?Example
PluginNo — plain PHP libraryValidator, HTTP client, DTO mapper
ProviderYes — middleware, auth, routes, commandsacme/pionia-billing

Plugins are normal Composer packages. Require them and use them from services.

Providers extend Pionia\Base\Provider\Provider and register capabilities during application boot. See App providers.

Minimal plugin

composer.json in your package:

{
  "name": "acme/phone-normalizer",
  "require": { "php": ">=8.5" },
  "autoload": { "psr-4": { "Acme\\Phone\\": "src/" } }
}

src/Normalizer.php — no Pionia imports required:

namespace Acme\Phone;

final class Normalizer
{
    public static function e164(string $raw): string
    {
        return preg_replace('/\D/', '', $raw) ?? '';
    }
}

Use from Pionia Shop’s CustomerService:

use Acme\Phone\Normalizer;

protected function registerAction(\Pionia\Collections\Arrayable $data): \Pionia\Http\Response\ApiResponse
{
    $phone = Normalizer::e164((string) $data->get('phone'));

    return response(0, 'OK', ['phone' => $phone]);
}

Package with a provider

Structure:

acme-pionia-billing/
  composer.json
  src/
    BillingProvider.php
    BillingSwitch.php
    Middleware/BillingContextMiddleware.php
    Commands/SyncInvoicesCommand.php

BillingProvider.php

namespace Acme\Billing;

use Pionia\Base\Provider\Provider;
use Pionia\Http\Routing\PioniaRouter;
use Pionia\Middlewares\MiddlewareChain;

class BillingProvider extends Provider
{
    public function middlewares(MiddlewareChain $chain): MiddlewareChain
    {
        return $chain->add(Middleware\BillingContextMiddleware::class);
    }

    public function commands(): array
    {
        return ['billing:sync' => Commands\SyncInvoicesCommand::class];
    }

    public function routes(PioniaRouter $router): PioniaRouter
    {
        // Use a unique version slug — not "v2" unless you own that API surface
        return $router->switch(BillingSwitch::class, 'billing');
    }
}

CLI command in the package — extend Pionia\Console\Command:

namespace Acme\Billing\Commands;

use Pionia\Console\Command;

class SyncInvoicesCommand extends Command
{
    protected string $name = 'billing:sync';
    protected string $description = 'Pull invoices from the billing API';

    protected function handle(): int
    {
        $this->info('Syncing…');

        return Command::SUCCESS;
    }
}

Input helpers live under Pionia\Console\Input\ (InputArgument, InputOption) — same concepts as other PHP CLIs, but native to Pionia.

Consumer app wiring

After composer require acme/pionia-billing:

; environment/settings.ini
[app_providers]
billing=Acme\Billing\BillingProvider

Or in bootstrap/application.php:

$app = AppRealm::create(__DIR__);
$app->web()->addAppProvider(\Acme\Billing\BillingProvider::class);
return $app;

Clear cache when removing a provider:

php pionia cache:clear

Package development loop

  1. Create a local app with composer create-project pionia/pionia-app sandbox.
  2. Add a path repository to composer.json:
"repositories": [
  { "type": "path", "url": "../acme-pionia-billing", "options": { "symlink": true } }
],
"require": {
  "acme/pionia-billing": "@dev"
}
  1. Run composer update acme/pionia-billing and register the provider.
  2. Hit /api/billing/ (or your chosen version) and php pionia billing:sync.

Checklist before Packagist

  • Provider FQCN documented in README
  • Unique API version string in routes()
  • No hard dependency on the consumer app’s Application\ namespace
  • Optional RoadRunner / Redis features declared in suggest, not require
  • @moonlight-* tags on public actions if you ship HTTP API docs

Common mistakes

  • Importing Application\Services\ProductService from a package — packages must not depend on app namespaces
  • Using v1 as the package switch slug — collides with the host app’s MainSwitch
  • Shipping secrets or .env samples with real keys in the package README
  • Forgetting to document [app_providers] registration — consumers see a silent no-op install

What’s next

App providers

Full hook reference and boot order.

Commands

CLI conventions for package commands.

Middleware

HTTP pipeline for package middleware.