Introduction
On this page
- Who this is for
- What you will learn
- Before you start
- How it works
- Meet Pionia
- Why Moonlight?
- A minimal example
- What you are building: Pionia Shop
- Installation
- Project layout (what landed on disk)
- Bootstrap flow
- Your first custom service (5 minutes)
- CLI without memorizing paths
- Next steps
- Documentation map
- Production behind Nginx
- What’s new in v3
- Upgrading from v2
- Common mistakes
- What’s next

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-projectvs existing Pionia tree) - What lands on disk after scaffold (
services/,switches/,environment/) - How
AppRealmboots for HTTP and CLI from the same bootstrap file
Before you start
- PHP 8.5+ and Composer
- Terminal basics (
cd,curl) - New to PHP? PHP basics for Pionia
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{
"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.
| Piece | Meaning |
|---|---|
| Pionia Shop | The product you build in the tutorial |
| pionia-shop | Composer project folder name |
| product service | Catalog (product.list, product.create) |
| customer service | Register / login (customer.login) |
| order / wallet | Checkout and store credit |
| ada@pionia.shop | Sample 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)
| Path | Role |
|---|---|
bootstrap/application.php | return AppRealm::create(__DIR__) — builds the DI container (singleton) |
environment/settings.ini | [app_switches] maps API versions to switch classes |
environment/.env | APP_NAME, JWT_SECRET, PORT, and other secrets |
public/index.php | Web entry → bootHttp() |
pionia | CLI 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.yaml | Optional 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 workersCLI
./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 ProductOpen 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 only → Pionia Shop tutorial Step 1 → Services
API + Vite frontend → Tutorial → Vite integration
Documentation map
- API tutorial — Pionia Shop tutorial
- Application structure
- Services & actions
- Requests & responses
- RoadRunner
- Extending via providers
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;
}
}RoadRunner (recommended for persistent workers)
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 root —
php pionia servemust run frompionia-shop/where thepioniascript lives. - Using port 3000 or 8003 — Pionia Shop docs default to 8000 via
PORTinenvironment/.env. - Editing vendor/ — business logic belongs in
services/andswitches/, never invendor/pionia/. - Skipping
[app_switches]after adding a service — register aliases inMainSwitchor 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.