Introduction

Pionia Logo

Who this is for

You want to ship a versioned JSON API with PHP. This page installs Pionia and sends your first ping. The hands-on app we use everywhere is Pionia Shop — see Meet Pionia Shop below before you open the tutorial.

What you will learn

  • Two install paths (composer create-project vs existing Pionia tree)
  • What lands on disk after scaffold (services/, switches/, environment/)
  • How AppRealm boots for HTTP and CLI from the same bootstrap file

Before you start

Before you start

How it works

flowchart LR
  Composer[composer create-project] --> App[pionia-shop]
  App --> Serve["php pionia serve :8000"]
  Serve --> Ping["GET /api/v1/ping"]
  Ping --> Moonlight["POST product.list"]

Meet Pionia

Pionia is a PHP 8.5+ framework for versioned JSON APIs. Clients POST { "service", "action" } to /api/v1/; your business logic lives in plain PHP service classes. Optional Vite frontends, RoadRunner workers, and Porm (fluent SQL) grow with you — from a afternoon prototype to production.

Why Moonlight?

One URL per API version keeps frontends simple and lets you version breaking changes cleanly. Read Moonlight overview for the full picture.

A minimal example

With the server running:

curl -s http://127.0.0.1:8000/api/v1/ping
Result
{
  "returnCode": 0,
  "returnMessage": "OK",
  "returnData": { "pong": true }
}

What you are building: Pionia Shop

Every hands-on page uses the same small store so names stay consistent.

Pionia Shop is a fictional online store + wallet named after the framework. Ada browses products, logs in, places orders, and pays from her wallet balance.

PieceMeaning
Pionia ShopThe product you build in the tutorial
pionia-shopComposer project folder name
product serviceCatalog (product.list, product.create)
customer serviceRegister / login (customer.login)
order / walletCheckout and store credit
ada@pionia.shopSample customer in examples

Tables: products, customers, orders, order_items, wallets, wallet_transactions. Full walkthrough: Pionia Shop tutorial.

Installation

Default URL: http://127.0.0.1:8000/ (PORT in environment/.env).

Project layout (what landed on disk)

PathRole
bootstrap/application.phpreturn AppRealm::create(__DIR__) — builds the DI container (singleton)
environment/settings.ini[app_switches] maps API versions to switch classes
environment/.envAPP_NAME, JWT_SECRET, PORT, and other secrets
public/index.phpWeb entry → bootHttp()
pioniaCLI entry → bootConsole() (same bootstrap as HTTP)
services/Business logic (*Service classes, *Action methods)
switches/API version wiring (MainSwitch/api/v1/)
database/migrations/Schema migrations (php pionia migrate)
providers/Optional service providers (make:provider)
environment/.env + settings.ini
storage/Logs, cache, uploads
worker.php + .rr.yamlOptional RoadRunner workers

Set APP_NAME=Pionia Shop in .env so the welcome page and docs title match your store — see Welcome page and branding.

Workspace Trust

VS Code and JetBrains may ask you to trust the new folder once. Accept so Composer and the PHP language server work normally.

Helpers (app(), logger(), router()) are available after AppRealm::create() completes — not before require of application.php returns.

Bootstrap flow

HTTP

public/index.php
  → require bootstrap/application.php
  → AppRealm::create()  (registers [app_switches] from settings.ini)
  → $app->bootHttp()   // or handleRequest() in workers

CLI

./pionia list
  → require bootstrap/application.php
  → $app->bootConsole()

Both paths share the same AppRealm singleton (app() / realm() / container() are aliases).

Your first custom service (5 minutes)

php pionia make:service Product

Open services/ProductService.php, add an action:

protected function listAction(\Pionia\Collections\Arrayable $data): \Pionia\Http\Response\ApiResponse
{
    return response(0, 'OK', ['tasks' => []]);
}

Register the service alias in switches/MainSwitch.php:

return arr([
    'welcome' => \Application\Services\WelcomeService::class,
    'task' => \Application\Services\ProductService::class,
]);

Call it:

curl -s -X POST http://127.0.0.1:8000/api/v1/ \
  -H 'Content-Type: application/json' \
  -d '{"service":"product","action":"list"}'

CLI without memorizing paths

From the project root:

php pionia list
php pionia make:service Invoice
php pionia api:docs --ui
composer run serve
composer run pionia -- cache:clear   # passes args after --

Commands extend Pionia\Console\Command. See Commands.

Next steps

API backend onlyPionia Shop tutorial Step 1Services

API + Vite frontendTutorialVite integration

Documentation map

Production behind Nginx

HTTP/2 and TLS are infrastructure concerns — configure them in Nginx (or Caddy), not in Pionia PHP code. Nginx terminates TLS and HTTP/2; Pionia receives plain HTTP on the backend.

PHP-FPM (traditional)

Document root = public/:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/certs/api.example.com-fullchain.pem;
    ssl_certificate_key /etc/ssl/private/api.example.com.key;

    root /var/www/my-api/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_param HTTPS on;
    }
}

Run php pionia runserver --detach on an internal port, proxy from Nginx:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/certs/api.example.com-fullchain.pem;
    ssl_certificate_key /etc/ssl/private/api.example.com.key;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

HTTP/2 multiplexing ends at Nginx; the proxy to RoadRunner uses HTTP/1.1 — no application code changes.

Alternatively, RoadRunner can terminate TLS directly in .rr.yaml — see HTTP/2 and TLS on RoadRunner.

Related: RoadRunner · Production performance.

What’s new in v3

See Pionia v3 release notes for the complete changelog.

Upgrading from v2

See Upgrading from v2 for AppRealm, ApiSwitch, and ApiResponse renames.

Common mistakes

  • Running commands outside the project rootphp pionia serve must run from pionia-shop/ where the pionia script lives.
  • Using port 3000 or 8003 — Pionia Shop docs default to 8000 via PORT in environment/.env.
  • Editing vendor/ — business logic belongs in services/ and switches/, never in vendor/pionia/.
  • Skipping [app_switches] after adding a service — register aliases in MainSwitch or Moonlight returns unknown service errors.

What’s next

Pionia Shop tutorial

Build Pionia Shop product.list hands-on.

Application structure

Map every folder in your repo.

Services

ProductService and MainSwitch registration.