simcript / pano
Pano application skeleton
Requires
- php: >=8.2
- simcript/pano-framework: ^1.0
Requires (Dev)
- phpunit/phpunit: ^12.5
README
A minimal, ready-to-run application skeleton built on top of the Pano nano-framework.
Pano is a lightweight PHP runtime that gives you an explicit, predictable
foundation with full architectural control. This skeleton wires up that
runtime with a sensible project layout, a working Default module, configuration,
and a web + CLI entry point so you can start building your own domains
immediately.
Built for Pano Framework
^1.4(currentlyv1.4.4).
Requirements
- PHP >= 8.2
- Composer
Installation
Create a new project with Composer:
composer create-project simcript/pano my-app
cd my-app
Or clone this repository and install dependencies manually:
git clone https://github.com/simcript/pano.git my-app
cd my-app
composer install
The .env file is created automatically from .env.example after install.
If it isn't, copy it yourself:
cp .env.example .env
Quick Start
Start the built-in PHP development server:
php -S localhost:8000 -t public
Open http://localhost:8000 in your browser — you should see the "It works!"
welcome page. The skeleton is alive.
Project Structure
my-app/
├── pano # CLI entry point (executable)
├── public/
│ ├── index.php # Web front controller
│ └── .htaccess # Apache rewrite rules
├── config/
│ ├── app.php # Application configuration
│ └── modules.php # Module ↔ domain/resolver mapping
├── modules/ # Your application lives here
│ └── Default/
│ ├── DefaultModule.php
│ ├── Handlers/
│ ├── Interceptors/
│ ├── Commands/
│ └── Views/
├── tests/ # PHPUnit test suite
├── .env # Environment variables (not committed)
├── .env.example
└── composer.json
Two constants are defined at the very start of every entry point and drive the whole runtime:
| Constant | Meaning |
|---|---|
PANO_STARTED |
Request start timestamp (microtime), for timing |
BASE_PATH |
Absolute path to the project root, with trailing / |
Everything — config loading, .env resolution, module paths — is relative to
BASE_PATH, so keep that in mind if you move entry points.
Configuration
.env
APP_NAME=Pano APP_ENV=local # "local" enables the dev/pano module APP_KEY=base64:key # Application key APP_DEBUG=true # Show detailed errors (disable in production) APP_URL=https://neda.tst MODULE_RESOLVER=path # "path" or "subdomain"
Tip: Pano parses
.envvalues.true/false/nullbecome proper booleans/null, and numeric strings become numbers.
config/app.php
Reads environment values into a config array. Access any value anywhere using
the config() helper:
config('app.name'); // "Pano" config('app.debug', false); // true, with a fallback
config/modules.php
Maps a module key to a module class. This is how Pano decides which module handles the current request:
return [ 'pano' => env('APP_ENV', 'production') === 'local' ? DefaultModule::class : null, '' => \Modules\Default\DefaultModule::class, ];
- The empty key
''is the default module. 'pano'is only active in thelocalenvironment.
The Module Resolver
Pano routes an incoming request to a module before it routes to a handler.
How the module is chosen depends on MODULE_RESOLVER:
path (default)
The first URL segment is the module key. The remainder is the route path.
| URL | Module key | Route |
|---|---|---|
/blog/posts/12 |
blog |
/posts/12 |
/ |
'' |
/ |
subdomain
The subdomain is the module key. The root domain is derived from APP_URL,
so only the leading sub-part of that host is treated as a module:
| Host | Module key |
|---|---|
blog.neda.tst (APP_URL=https://neda.tst) |
blog |
api.v2.neda.tst |
api.v2 |
neda.tst (the root itself) |
'' |
If the resolver is neither path nor subdomain, an Exception is thrown.
If no module matches the resolved key, Pano throws
"No module found for '<name>'". Make sure every reachable key is registered inconfig/modules.php.
Core Concepts
Pano is intentionally small. Five concepts carry the whole runtime:
| Concept | Responsibility |
|---|---|
| Module | A self-contained domain; defines routes, views, logging |
| Handler | A controller-like class that produces a Response |
| Interceptor | Runs before handlers (onRequest) and after (onResponse) |
| Command | A CLI action, like a console controller |
| View | Renders templates with layouts and sections |
The base contracts live in the Pano\Kernel namespace; ready-to-use concrete
implementations live in Pano\Foundation.
| Concept | Base (abstract) contract | Concrete implementation |
|---|---|---|
| Module | Pano\Kernel\BaseModule |
(you extend it) |
| Router | Pano\Kernel\BaseRouter |
Pano\Foundation\Router |
| Request | Pano\Kernel\BaseRequest |
Pano\Foundation\Request |
| Response | Pano\Kernel\BaseResponse |
Pano\Foundation\Response |
| View | Pano\Kernel\BaseView |
Pano\Foundation\View |
| Handler | Pano\Kernel\BaseHandler |
(you extend it) |
| Interceptor | Pano\Kernel\BaseInterceptor |
(you extend it) |
| Command | Pano\Kernel\BaseCommand |
(you extend it) |
| Logger | Pano\Kernel\BaseLogger |
Pano\Foundation\Logger |
| Exception | Pano\Kernel\BaseException |
Pano\Foundation\Exception |
Important: Always extend the
Pano\Kernel\Base*contracts (and use thePano\Foundation\*implementations). The olderPano\Core/Pano\Enumnamespaces no longer exist.
Building a Module
A module is a readonly class extending BaseModule. It must define three
methods: routes(), view(), and log().
<?php namespace Modules\Blog; use Pano\Foundation\Exception; use Pano\Foundation\Logger; use Pano\Foundation\View; use Pano\Kernel\BaseLogger; use Pano\Kernel\BaseModule; use Pano\Kernel\BaseRouter; use Pano\Kernel\BaseView; use Modules\Blog\Handlers\PostHandler; use Modules\Blog\Interceptors\AuthInterceptor; final readonly class BlogModule extends BaseModule { public function routes(): BaseRouter { $router = new Router($this->request, $this); // HTTP routes: $router->METHOD(path, Handler::class, action, [interceptors]) $router->get('/posts', PostHandler::class, 'index'); $router->get('/posts/[id]', PostHandler::class, 'show'); $router->post('/posts', PostHandler::class, 'store', [AuthInterceptor::class]); // CLI commands $router->command('blog:publish', \Modules\Blog\Commands\PublishCommand::class); return $router; } public function view(): BaseView { return new View($this->viewPath()); } public function log(): BaseLogger { return new Logger($this->logPath()); } }
BaseModule gives you convenient path helpers, all resolved relative to the
module's own directory via reflection:
$this->viewPath(); // .../Modules/Blog/Views $this->filePath(); // .../Modules/Blog/Files $this->logPath(); // .../Modules/Blog/Logs $this->path(); // .../Modules/Blog $this->name(); // "BlogModule" (short class name)
Register the module in config/modules.php:
return [ '' => \Modules\Default\DefaultModule::class, 'blog' => \Modules\Blog\BlogModule::class, ];
Now requests to /blog/... are handled by BlogModule.
Routing
Routes are registered inside the module's routes() method on the injected
$router. Supported HTTP verbs:
$router->get('/path', Handler::class, 'action'); $router->post('/path', Handler::class, 'action'); $router->put('/path', Handler::class, 'action'); $router->delete('/path', Handler::class, 'action');
Route Parameters
Parameters are declared with brackets and are passed as method arguments in declaration order:
// Route $router->get('/users/[id]/posts/[postId]', UserHandler::class, 'post'); // Handler — argument order matches the route declaration public function post($id, $postId): Response { ... }
Parameter flags:
| Syntax | Meaning |
|---|---|
[id] |
Required segment |
[id?] |
Optional (must be the last segment) |
[id*] |
Catch-all (must be the last segment) |
$router->get('/files/[path*]', FileHandler::class, 'show');
Optional and catch-all parameters must always be the last route segment.
Handlers
A handler is a class extending BaseHandler. Each action is a public method
that returns a Response. The return type must be declared and must be
BaseResponse (or a subclass) — Pano enforces this.
The handler receives the request and its module via constructor injection:
<?php namespace Modules\Blog\Handlers; use Pano\Foundation\Response; use Pano\Kernel\BaseHandler; use Pano\Kernel\HttpStatusEnum; final class PostHandler extends BaseHandler { public function index(): Response { return Response::json([ 'posts' => ['Pano 101', 'Routing in depth'], ]); } public function show($id): Response { return Response::json(['id' => $id, 'title' => 'Hello Pano']); } public function store(): Response { // getData() auto-parses the body based on Content-Type: // $_POST → application/json → application/x-www-form-urlencoded $data = $this->request->getData(); // ...persist... return Response::json(['created' => true], HttpStatusEnum::CREATED); } }
BaseHandler exposes:
$this->request; // the current Request $this->module; // the owning Module
Method override
HTML forms can only GET/POST. To submit PUT/DELETE/PATCH, Pano
honors a method override on POST requests via either:
- the
X-HTTP-Method-Overrideheader, or - a
_methodfield in the POST body.
<form method="POST" action="/posts/12"> <input type="hidden" name="_method" value="DELETE"> </form>
The resolved verb becomes the route's HTTP method automatically.
Responses
Pano\Foundation\Response offers expressive factory methods:
Response::html($htmlString); // text/html Response::json($arrayOrObject); // application/json Response::text($plain); // text/plain Response::redirect($url); // 302 redirect Response::stream(fn() => readfile($path), 'application/pdf'); // streamed body Response::terminal('Done'); // CLI: green text Response::terminal('Failed', ResultCodeEnum::ERROR); // CLI: red text Response::make($body, HttpStatusEnum::CREATED, ['X-Foo' => 'bar']); // custom
Every method accepts an optional HttpStatusEnum and a headers array. The JSON
helper encodes with JSON_UNESCAPED_UNICODE so Persian/Arabic text is preserved.
Fluent mutation
Every mutator returns $this, so you can chain after construction:
return Response::json($data) ->setStatus(HttpStatusEnum::CREATED) ->setHeader('X-Request-Id', $id) ->setHeaders(['Cache-Control' => 'no-store']); // Or override the body after creation return Response::make(null) ->setStatus(HttpStatusEnum::NO_CONTENT);
Useful HttpStatusEnum values: OK, CREATED, NO_CONTENT, MOVED_PERMANENTLY,
FOUND, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
UNPROCESSABLE_ENTITY, INTERNAL_SERVER_ERROR, … (all standard HTTP codes).
Automatic error rendering
If a handler (or anything in the pipeline) throws, Response::exception($e, $request)
turns the throwable into an appropriate response:
| Context | Output |
|---|---|
| CLI request | colored terminal line, ResultCodeEnum::ERROR |
expectsJson() |
$e->toArray($debug) as JSON |
| otherwise | $e->toHtml($debug) as HTML |
This is wired into the global handler, so you never need to wrap your handlers in try/catch for rendering.
Sending
Calling send() writes the HTTP status line, headers, and body to the output
stream. It is idempotent — a response can only be sent once. The router
calls send() for you; you normally just return the response.
The Bag
Bag (Pano\Foundation\Bag, extending Pano\Kernel\BaseBag) is Pano's
lightweight, chainable key/value container. You already met it as
$request->attributes. You can also use it anywhere you need structured data.
It behaves like an array — it implements ArrayAccess, IteratorAggregate,
and Countable:
use Pano\Foundation\Bag; $bag = new Bag(['name' => 'Pano', 'tags' => ['php', 'web']]); $bag['name']; // get $bag['name'] = 'X'; // set isset($bag['name']); // has count($bag); foreach ($bag as $k => $v) { ... }
Basic operations
$bag->get('name', $default); $bag->set('key', $value); // returns $this (mutable) $bag->has('key'); $bag->remove('key'); // returns $this $bag->all();
Functional helpers
Most helpers return a new Bag (immutable style), so they chain:
$bag->merge($otherBagOrArray); // union by key $bag->replace($otherBagOrArray); // array_replace semantics $bag->only(['name', 'email']); // keep only these keys $bag->except(['password']); // drop these keys $bag->map(fn($v, $k) => strtoupper($v)); $bag->filter(fn($v, $k) => $v !== null);
Deep search
Bags can be nested (arrays and other Bags inside). Pano finds values or keys anywhere in the tree and returns their dot-paths:
$bag->find('php'); // 'tags.0' — first path to the VALUE $bag->findAll('php'); // ['tags.0'] — all paths to the value $bag->findKey('tags'); // 'tags' — first path to the KEY $bag->findAllKeys('tags'); // ['tags'] — all paths to the key
Paths use . as the separator (e.g. user.address.city), making Bag handy
for config trees, JSON payloads, and nested request data.
Interceptors
Interceptors are cross-cutting filters. They run before the handler
(onRequest) and after it returns (onResponse), in registration order.
<?php namespace Modules\Blog\Interceptors; use Pano\Kernel\BaseInterceptor; use Pano\Kernel\BaseResponse; use Pano\Foundation\Exception; use Pano\Foundation\Response; use Pano\Kernel\HttpStatusEnum; class AuthInterceptor extends BaseInterceptor { public function onRequest(): void { // headers are LOWERCASED $token = $this->request->getHeaders()['authorization'] ?? ''; if (!str_starts_with($token, 'Bearer ')) { throw new Exception( 'Unauthorized', 401, HttpStatusEnum::UNAUTHORIZED ); } // pass data downstream to the handler via the shared attributes Bag $this->request->attributes->set('userId', $this->resolve($token)); } public function onResponse(BaseResponse $response): BaseResponse { // Add headers to every response handled by this interceptor return $response->setHeader('X-Module', 'Blog'); } }
Attach interceptors to a route as the fourth argument:
$this->router->post('/posts', PostHandler::class, 'store', [AuthInterceptor::class]);
Execution order
For a route registered with [A::class, B::class]:
A::onRequest() →
B::onRequest() →
Handler::action() (returns a Response)
B::onResponse(response) ←
A::onResponse(response) ←
response->send()
So onRequest runs in registration order and onResponse runs in
reverse order — exactly like layered middleware (Russian-doll model).
Sharing state with the handler
The same $request instance is shared across all interceptors and the
handler, so data left on $request->attributes in onRequest() is visible in
the handler. This is the recommended way to pass the authenticated user, a
request ID, etc. (See The Bag.)
Requests
The current request is available on handlers and interceptors as $this->request
(Pano\Foundation\Request). Key accessors:
$this->request->getMethod(); // HttpMethodEnum (honors _method / X-HTTP-Method-Override) $this->request->getUrl(); // path part of the URL (without the module segment) $this->request->getQueries(); // parsed query params (associative) $this->request->getData(); // body, auto-parsed by Content-Type $this->request->getHeaders(); // all headers — keys are LOWERCASED $this->request->getFiles(); // $_FILES, normalized (multi-file flattened) $this->request->getSegments(); // URL segments as array $this->request->getHost(); // scheme + host $this->request->expectsJson(); // true if Accept header asks for JSON
Body parsing (getData())
getData() decodes the request body based on the Content-Type:
| Content-Type | getData() returns |
|---|---|
| (form submitted) | $_POST |
application/json |
decoded JSON array |
application/x-www-form-urlencoded |
parse_str array |
| other / empty | [] (empty array) |
File uploads (getFiles())
getFiles() returns $_FILES normalized. Multi-file inputs
(e.g. <input name="photos[]">) are restructured into an indexed list, so you
always iterate a flat array:
foreach ($this->request->getFiles()['photos'] ?? [] as $file) { move_uploaded_file($file['tmp_name'], $target); }
Headers
Keys are lowercased, so read them in lowercase regardless of how the client sent them:
$this->request->getHeaders()['authorization']; // not 'Authorization'
Sharing state — $request->attributes
The request carries a mutable Bag named attributes. It is the idiomatic
channel for passing data from an interceptor to the handler — the
authenticated user, a request ID, feature flags, etc. (See The Bag.)
// in an interceptor (runs before the handler) $this->request->attributes->set('user', $user); // in the handler $user = $this->request->attributes->get('user');
Views & Templating
A module renders templates from its Views/ directory using
Pano\Foundation\View. Templates are plain PHP, with layout + section support.
Render a view with data:
return Response::html( $this->module->view() ->with(['post' => $post]) ->layout('layout') ->render('post/show') );
Views/layout.php — the wrapper:
<!DOCTYPE html> <html> <head><title><?= $this->section('title', 'Pano') ?></title></head> <body> <?php $this->section('content') ?> </body> </html>
Views/post/show.php — a page that fills the layout's sections:
<?php $this->start('title') ?><?= $this->e($post['title']) ?><?php $this->end() ?> <div class="post"> <h1><?= $this->e($post['title']) ?></h1> <p><?= $this->e($post['body']) ?></p> </div>
Template helpers available as $this inside views:
| Method | Description |
|---|---|
$this->start('name') / $this->end() |
Open/close a named section |
$this->section('name', 'default') |
Echo a section's content (with fallback) |
$this->fragment('partials/card', $data) |
Include a sub-template (with extra data) |
$this->e($value) |
HTML-escape a stringable value |
Always escape untrusted output with
$this->e().
Logging
Each module owns its logs. Create the logger via $this->log() and call any
PSR-style level method:
$this->module->log()->info('Post created', ['id' => $post['id']]); $this->module->log()->error('DB connection failed', ['host' => $host]); $this->module->log()->warning('Slow query', ['ms' => 1200]);
Available levels: emergency, alert, critical, error, warning,
notice, info, debug.
By default the framework's Logger writes to a daily file under the module's
Logs/ directory (log-YYYY-MM-DD.log).
CLI Commands
Pano has a single CLI entry point: the pano executable at the project root.
Invocation format
php pano <module-path> <command> [positional args...] [--options...]
The first positional argument is the module path (matching a key in
config/modules.php), the second is the command name.
Examples for the skeleton's Default module:
# Named module (registered under 'pano' key — only active in local env) php pano pano app:info # Root module (registered under '' key) php pano / app:info # Pass positional arguments and options php pano blog blog:publish 42 --dry-run --batch=100
Windows note: The
/used for the root module can be mangled bycmd.exe/ Git Bash path conversion. Prefix the command withMSYS_NO_PATHCONV=1when invoking from Git Bash, or use a named module key instead:MSYS_NO_PATHCONV=1 php pano / app:info
Registering a command
Inside a module's routes(), call command() with a command name and a
command class:
$router->command('app:info', \Modules\Default\Commands\DefaultCommand::class); $router->command('blog:publish', \Modules\Blog\Commands\PublishCommand::class);
The command class must extend BaseCommand and implement handle():
<?php namespace Modules\Blog\Commands; use Pano\Kernel\BaseCommand; use Pano\Kernel\ResultCodeEnum; final class PublishCommand extends BaseCommand { public function handle(array $arguments): ResultCodeEnum { // Positional arguments: $arguments (indexed array) $id = $arguments[0] ?? null; // --options are available on the request $dryRun = $this->request->getOptions()['dry-run'] ?? false; if ($id === null) { $this->error('Usage: blog:publish <id>'); return ResultCodeEnum::INVALID; } $this->info("Published post {$id}" . ($dryRun ? ' (dry-run)' : '')); return ResultCodeEnum::OK; } }
Inside a command
BaseCommand gives you:
$this->request; // the CLIRequest $this->module; // the owning module (so $this->module->log() works in CLI too) $this->info($text); // print a green line $this->error($text); // print a red line
The $arguments array received by handle() is exactly getPositional()
(i.e. everything after the command that does not start with --).
Return codes
handle() returns a ResultCodeEnum:
| Value | Meaning |
|---|---|
OK |
success |
ERROR |
general failure |
INVALID |
invalid input / usage error |
This drives the terminal output color and signals failure to the shell.
Exceptions & Errors
Throw Pano\Foundation\Exception to control the HTTP response status, message,
and optional payload. Pano formats it automatically based on the request:
use Pano\Foundation\Exception; use Pano\Kernel\HttpStatusEnum; throw new Exception( 'Post not found', code: 404, status: HttpStatusEnum::NOT_FOUND, payload: ['hint' => 'Check the post id'] );
How it renders
| Context | Output |
|---|---|
| CLI request | colored terminal line |
expectsJson() |
JSON body { "message": ..., "data": ... } |
| otherwise | HTML error page |
In APP_DEBUG=true mode, the rendered body includes the exception class name and
stack trace; in production it is hidden.
Custom exception types
For richer domain errors, extend BaseException and implement toArray() and
toHtml():
namespace Modules\Blog\Exceptions; use Pano\Kernel\BaseException; use Pano\Kernel\HttpStatusEnum; final class ValidationException extends BaseException { public function toArray(bool $debug = false): array { return [ 'message' => $this->getMessage(), 'errors' => $this->payload ?? [], ]; } public function toHtml(bool $debug = false): string { return '<h1>Validation failed</h1><pre>' . htmlspecialchars($this->getMessage()) . '</pre>'; } }
Throw it anywhere in a handler or interceptor — the global handler will render it correctly.
Non-BaseException throwables
Plain \Throwable instances (PHP errors, third-party exceptions) are rendered as
a generic 500 Server Error, with the real message shown only in debug mode.
Helper Functions
These globals are always available (autoloaded by the framework):
| Function | Description |
|---|---|
env($key, $default = null) |
Read a value from .env |
config($key, $default = null) |
Dot-notation config read (e.g. config('app.name')) |
url($path) |
Build an absolute URL using APP_URL |
currentUrl() |
The current request's absolute URL |
dd(...$args) |
Dump-and-die debug helper (CLI or HTML aware) |
env('APP_NAME', 'Pano'); config('app.debug', false); url('posts/42'); dd($user, $request->getData());
Testing
The skeleton ships with PHPUnit. Tests live in tests/ under the Tests\
namespace.
./vendor/bin/phpunit
A starter test is included at tests/DefaultModuleTest.php. Example:
<?php namespace Tests; use PHPUnit\Framework\TestCase; use Modules\Default\DefaultModule; class DefaultModuleTest extends TestCase { public function test_module_class_exists() { $this->assertTrue(class_exists(DefaultModule::class)); } }
phpunit.xml is preconfigured with the Pano Test Suite.
Web Server Setup
Apache
public/.htaccess is already configured. Point your virtual host DocumentRoot
to the public/ directory. It handles:
- Removing trailing slashes
- Serving real files/directories directly
- Forwarding everything else to
public/index.php - Preserving the
Authorizationheader (for JWT / Bearer tokens)
Nginx
server { listen 80; server_name your-domain.test; root /var/www/my-app/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }
Development server
php -S localhost:8000 -t public
Build Your First Feature — Checklist
- Create a module under
modules/<Name>/<Name>Module.phpextendingPano\Kernel\BaseModule. - Register it in
config/modules.phpunder a resolver key. - Add handlers (extending
Pano\Kernel\BaseHandler) returning aPano\Foundation\Response. - Define routes in the module's
routes()method. - (Optional) Add interceptors for auth/validation, commands for CLI tasks, and views for HTML.
- Test it with
./vendor/bin/phpunit.
Learn More
- Framework source & docs: simcript/pano-framework
- Architecture:
ARCHITECTURE.mdin the framework repo - Philosophy:
MANIFESTO.mdin the framework repo
Pano is deliberately unopinionated — you bring the architecture. The framework should never make decisions on your behalf.
License
The MIT License (MIT). See LICENSE.