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 DeskFlow — see Meet DeskFlow 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[deskflow-api] App --> Serve[php pionia serve :8000] Serve --> Ping["GET /api/v1/ping"] Ping --> Moonlight["POST task.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: DeskFlow

Every hands-on page in these docs uses the same example app so you are not learning on random User and Todo snippets.

Northwind Studio

Northwind Studio is a fictional digital agency — designers and developers who ship client websites. They are not a real company (the name is a nod to the classic sample database, but this is our own story).

They need an internal tool: who is doing what, which client project a task belongs to, and which team member owns each item.

DeskFlow

DeskFlow is that internal task board API. Northwind staff use it from a small React app or curl — not public customers.

PieceMeaning
DeskFlowThe product name — task board for the agency
deskflow-apiThe Pionia repo you create with Composer
task serviceList and create tasks (task.list, task.create)
member serviceLogin as staff (member.login)
project serviceGroup tasks by client project
alex@northwind.studioSample developer account in examples

When you see those names in code samples, they all refer to this one tutorial app. Full step-by-step build: DeskFlow tutorial (after you install below).

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
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/)
providers/Optional service providers (make:provider)
environment/.env + settings.ini
storage/Logs, cache, uploads
worker.php + .rr.yamlOptional RoadRunner workers

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 Task

Open services/TaskService.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\TaskService::class,
]);

Call it:

curl -s -X POST http://127.0.0.1:8000/api/v1/ \
  -H 'Content-Type: application/json' \
  -d '{"service":"task","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 onlyDeskFlow 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 deskflow-api/ where the pionia script lives.
  • Using port 3000 or 8003 — DeskFlow 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

DeskFlow tutorial

Build DeskFlow task.list hands-on.

Application structure

Map every folder in your repo.

Services

TaskService and MainSwitch registration.