gianvoci / nanocore
Lightweight PHP micro-framework with routing, config management, a micro ORM, and utility functions. Zero dependencies.
Requires
- php: >=8.5
README
A lightweight PHP micro-framework with routing, config management, a micro ORM, and utility functions. Zero dependencies, PHP 8.5+.
Features
- Pattern-based routing with path parameters and wildcards
- Env-based config management with dot-notation access
- NanoORM: lightweight ORM with CRUD, joins, schema auto-discovery
- HTTP client with retry and SSRF protection
- Request body parser with size limit
- HTML template rendering with XSS protection
- Background process execution
- Built-in error handling (JSON responses)
- Security hardening out of the box
Requirements
- PHP >= 8.5
- No extensions beyond standard PHP (curl extension needed for
curlRequest)
Installation
composer require gianvoci/nanocore
Or via local path in composer.json:
{
"repositories": [{"type": "path", "url": "../nanocore"}],
"require": {"gianvoci/nanocore": "@dev"}
}
Quick Start
Create index.php:
<?php require_once __DIR__ . '/vendor/autoload.php'; use NanoCore\NanoCore; $app = new NanoCore(); $app->addRoute('GET', '/', function ($app, $params) { return ['message' => 'Hello, NanoCore!']; }); $app->addRoute('GET', '/users/@id', function ($app, $params) { return ['user_id' => $params['id']]; }); $app->run();
Run with PHP built-in server:
php -S localhost:8000
Test it:
curl http://localhost:8000/ # {"message":"Hello, NanoCore!"} curl http://localhost:8000/users/42 # {"user_id":"42"}
Routing
Registering Routes
$app->addRoute(string $method, string $path, callable $handler);
- Method: GET, POST, PUT, DELETE, PATCH, etc. (case-insensitive)
- Path: auto-normalized (trailing slashes removed, duplicate slashes collapsed)
- Handler: receives
($app, $params)— must be callable
Path Parameters
| Syntax | Captures | Example |
|---|---|---|
@name |
Single segment | /users/@id → ['id' => '42'] |
@* |
Rest of path | /files/@* → ['wildcard' => 'docs/readme.md'] |
| Multiple | One per segment | /api/@version/@resource → ['version' => 'v1', 'resource' => 'users'] |
Query Parameters
Query params are automatically merged with path params. Path params take precedence on collision:
$app->addRoute('GET', '/search/@query', function ($app, $params) { // $params contains 'query' from path + any query string params return $params; }); // GET /search/php?limit=10&page=1 // → ['query' => 'php', 'limit' => '10', 'page' => '1']
Error Responses
Unmatched routes return 404. Exceptions in handlers use the exception code as HTTP status:
$app->addRoute('GET', '/users/@id', function ($app, $params) { throw new \Exception('User not found', 404); }); // Response: {"error":"User not found","code":404} with HTTP 404
Configuration
Config is stored in .env (auto-created as empty if missing). Access via dot-notation:
// Read $dbHost = $app->configGet('DB.HOST'); // → "localhost" $dbPort = $app->configGet('DB.PORT'); // → "3306" $fullDb = $app->configGet('DB'); // → ['HOST' => 'localhost', 'PORT' => '3306'] // Write $app->configSet('DB.HOST', 'localhost'); $app->configSet('DB.PORT', '3306');
.env Format
# Database DB.HOST=localhost DB.PORT=3306 DB.NAME=myapp # Quoted values (quotes are stripped) APP.TITLE="My Application" # Variable interpolation DB.URL=${DB.HOST}:${DB.PORT} # Inline comments APP.DEBUG=true # enabled for dev # export prefix is silently stripped export APP.ENV=production
All values are strings — DB.PORT=3306 returns "3306", not 3306.
.env.local Override
Create a .env.local file to override values from .env (useful for local development):
# .env.local DB.HOST=127.0.0.1 APP.DEBUG=true
Config Template
A .env.example file is included as a template with all available settings commented out. Copy it to .env and fill in your values.
PHP.ini Settings
Set PHP directives through config (only allowed directives are applied):
PHP.INI.display_errors=1 PHP.INI.error_reporting=E_ALL PHP.INI.date.timezone=Europe/Rome
Allowed directives: display_errors, error_log, error_reporting, log_errors, upload_max_filesize, post_max_size, max_execution_time, memory_limit, default_charset, date.timezone, session.cookie_httponly, session.cookie_secure, session.use_strict_mode.
Protected Keys
PHP.INI and CORE cannot be modified via configSet(). They're managed internally.
NanoORM
A lightweight ORM that auto-discovers your table schema. Works with MySQL and SQLite.
Setup
use NanoCore\NanoORM; $pdo = new PDO('sqlite:app.db'); // or: $pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass'); $users = new NanoORM($pdo, 'users'); // PK defaults to 'id' $settings = new NanoORM($pdo, 'user_settings', 'user_id'); // Custom primary key
Table name and primary key are validated — must match /^[a-zA-Z_][a-zA-Z0-9_]*$/.
CRUD Operations
Create:
$user = new NanoORM($pdo, 'users'); $user->fill(['name' => 'Jane', 'email' => 'jane@example.com']); $user->save(); echo $user->getId(); // Auto-generated ID
Read:
$user = (new NanoORM($pdo, 'users'))->findById(1); echo $user->name; // Magic getter echo $user->email; $actives = (new NanoORM($pdo, 'users'))->findBy('status', 'active', 10); $recent = (new NanoORM($pdo, 'posts'))->findAll( ['published' => 1], 'created_at DESC', 10 );
Update:
$user = (new NanoORM($pdo, 'users'))->findById(1); $user->email = 'newemail@example.com'; $user->save(); // UPDATE because isNew is false
Delete:
$user = (new NanoORM($pdo, 'users'))->findById(1); $user->delete(); // Batch delete (new NanoORM($pdo, 'users'))->deleteWhere(['status' => 'inactive']);
Joins
$orders = new NanoORM($pdo, 'orders'); $orders ->addJoin('users', 'user_id', 'id', 'INNER', ['name', 'email']) ->addJoin('products', 'product_id', 'id', 'LEFT', ['title', 'price']); $rows = $orders->fetchWithJoins(['status' => 'completed']); // $rows = [ // ['id' => 1, 'status' => 'completed', 'j0_name' => 'Jane', 'j1_title' => 'Widget', ...], // ... // ]
Join types: INNER, LEFT, RIGHT, FULL, CROSS.
ORM Methods Reference
| Method | Returns | Description |
|---|---|---|
fill(array $data) |
self |
Set multiple fields at once |
save() |
bool |
Insert (new) or update (existing) |
delete() |
bool |
Delete current record by PK |
deleteWhere(array $conds) |
int |
Delete matching records, returns affected rows |
findById($id) |
self|null |
Find by primary key |
findBy($field, $value, $limit) |
array |
Find by field value |
findAll($conds, $orderBy, $limit) |
array |
Find with conditions, order, limit |
addJoin($table, $local, $foreign, $type, $fields) |
self |
Register a JOIN |
fetchWithJoins($conds) |
array |
Execute query with registered JOINs |
toArray() |
array |
Get all field data |
clear() |
self |
Reset data, joins, and isNew state (preserves schema) |
getId() |
mixed |
Get primary key value |
isNew() |
bool |
Check if record is unsaved |
getTable() |
string |
Get table name |
Magic properties via __get, __set, __isset, __unset for field access.
HTTP Client
// Simple GET $data = NanoCore::curlRequest('https://api.example.com/users'); // POST with body $result = NanoCore::curlRequest('https://api.example.com/users', [ 'method' => 'POST', 'params' => ['name' => 'Jane', 'email' => 'jane@example.com'], 'headers' => ['Content-Type: application/json', 'Authorization: Bearer token123'], ]);
Features:
- Automatic JSON decoding (returns raw string if not valid JSON)
- Up to 5 retries with linear backoff (100ms, 200ms, 300ms...)
- 30s connect timeout, 30s total timeout
- SSRF protection: only
http/httpsschemes, blocks private/restricted IPs, resolves DNS to validate
Request Body
// In a route handler: $app->addRoute('POST', '/users', function ($app, $params) { $body = $app->body; // Shorthand — reads and JSON-decodes the request body // Or with options: $body = $app->getBodyRequest(10_485_760, true); // 10MB limit, enforce JSON Content-Type // ... create user ... return ['status' => 'created']; });
Default size limit: 10MB. Throws if exceeded.
HTML Rendering
$html = $app->renderHtml('templates/user.html', [ '{{NAME}}' => $user->name, '{{EMAIL}}' => $user->email, ]);
- Template path is validated (must be within project root)
- String values are HTML-escaped by default (prevents XSS)
- Pass
falseas third argument to disable escaping:$app->renderHtml($file, $data, false)
Background Processes
// String mode (backward compatible) $app->execDetach('php process.php'); // Array mode (safe — each argument is properly escaped) $app->execDetach(['php', 'process.php', '--user', $userId, '--action', 'notify']);
Output is logged to nanocore.log in the project root.
Output buffering is flushed safely — no errors if no buffer is active.
Magic Properties
$app->body; // → reads request body (JSON decoded) $app->cli; // → true if running in CLI mode $app->myVar = 'hello'; // → store custom data echo $app->myVar; // → 'hello'
Security
NanoCore has security protections built in:
| Protection | Where | Description |
|---|---|---|
| SSRF Prevention | curlRequest |
Only http/https URLs. Blocks private IPs, localhost, and restricted ranges. DNS resolution is checked. |
| SQL Injection | NanoORM | All identifiers validated. Field names backtick-quoted in queries. PDO prepared statements for all values. |
| XSS Prevention | renderHtml |
HTML-escaping enabled by default. Path traversal blocked. |
| Config Tampering | configSet |
Protected keys (PHP.INI, CORE) cannot be modified. Atomic file writes. |
| Command Injection | execDetach |
Array mode escapes each argument independently. |
| Arbitrary ini_set | Constructor | Only 13 safe PHP directives are allowed. |
| Error Disclosure | Error handlers | No file paths or line numbers in error responses. |
Error Handling
NanoCore registers custom handlers on construction:
- All PHP errors are converted to
ErrorException - Uncaught exceptions return JSON with appropriate HTTP status
- No internal paths leaked in responses
- All JSON responses use
JSON_THROW_ON_ERRORto prevent silent encoding failures
In route handlers, throw exceptions with HTTP codes:
throw new \Exception('Not found', 404); throw new \Exception('Unauthorized', 401); throw new \Exception('Server error', 500);
License
GPL-3.0 — see LICENSE