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
Providersubclass - Minimal plugin structure and a full provider package layout
- Local path-repository development and Packagist checklist
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
| Kind | Hooks into Pionia boot? | Example |
|---|---|---|
| Plugin | No — plain PHP library | Validator, HTTP client, DTO mapper |
| Provider | Yes — middleware, auth, routes, commands | acme/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.phpBillingProvider.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\BillingProviderOr 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:clearPackage development loop
- Create a local app with
composer create-project pionia/pionia-app sandbox. - Add a path repository to
composer.json:
"repositories": [
{ "type": "path", "url": "../acme-pionia-billing", "options": { "symlink": true } }
],
"require": {
"acme/pionia-billing": "@dev"
}- Run
composer update acme/pionia-billingand register the provider. - Hit
/api/billing/(or your chosen version) andphp pionia billing:sync.
Checklist before Packagist
-
ProviderFQCN 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, notrequire -
@moonlight-*tags on public actions if you ship HTTP API docs
Common mistakes
- Importing
Application\Services\ProductServicefrom a package — packages must not depend on app namespaces - Using
v1as the package switch slug — collides with the host app’sMainSwitch - Shipping secrets or
.envsamples 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.