Minimal PSR-15 micro-framework for REST APIs.
Add the repository to your composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/r3rc/spine"
}
],
"require": {
"r3rc/spine": "dev-main"
}
}Then run:
composer install<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Spine\Core\Application;
use Spine\Core\Env;
use Spine\Http\ErrorMiddleware;
use Spine\Identity\Jwt;
use Spine\Identity\ResolveIdentity;
// Load environment
Env::load(__DIR__ . '/../.env');
// Configure JWT
Jwt::configure(Env::get('JWT_SECRET'));
// Create application
$app = Application::create();
// Middleware pipeline
$app->pipe(ErrorMiddleware::class);
$app->pipe(ResolveIdentity::class);
// Routes
$app->router->get('/health', fn () => ['status' => 'ok']);
$app->router->post('/auth/login', App\Auth\Login::class);
$app->router->group('/users', function ($r) {
$r->get('', App\Users\ListUsers::class);
$r->post('', App\Users\CreateUser::class);
$r->get('/{id}', App\Users\GetUser::class);
})->pipe(RequireIdentity::class);
// Run
$app->run();Extend Handler for type-safe request handling:
<?php
use Psr\Http\Message\ResponseInterface;
use Spine\Http\Handler;
use Spine\Validation\Validator;
final class CreateUser extends Handler
{
protected function execute(): ResponseInterface
{
$data = Validator::validate($this->body(), [
'email' => 'required|email',
'name' => 'required|min:2',
'password' => 'required|min:8',
]);
// Create user...
return $this->created(['id' => $user['id']]);
}
}// Response helpers
$this->ok($data); // 200
$this->created($data); // 201
$this->accepted($data); // 202
$this->noContent(); // 204
$this->json($data, $status);
// Request helpers
$this->param('id'); // Route parameter
$this->body(); // Parsed body as array
$this->query('page', 1); // Query parameter with default
$this->request(); // PSR-7 ServerRequestInterface<?php
use Spine\Http\HttpException;
// Define domain errors
final class UserErrors
{
public static function notFound(): HttpException
{
return HttpException::notFound('user.not_found', 'User not found')();
}
public static function emailTaken(): HttpException
{
return HttpException::conflict('user.email_taken', 'Email already exists')();
}
}
// Usage
throw UserErrors::notFound();
throw UserErrors::emailTaken();HttpException::badRequest($code, $message); // 400
HttpException::unauthorized($code, $message); // 401
HttpException::forbidden($code, $message); // 403
HttpException::notFound($code, $message); // 404
HttpException::conflict($code, $message); // 409
HttpException::unprocessable($code, $message); // 422
HttpException::tooManyRequests($code, $message); // 429
HttpException::internal($code, $message); // 500
HttpException::serviceUnavailable($code, $message); // 503
// Validation errors
HttpException::validation([
['code' => 'validation.required', 'message' => 'Email is required', 'target' => 'email'],
]);{
"code": "user.not_found",
"message": "User not found"
}{
"code": "validation.failed",
"message": "Validation failed",
"details": [
{
"code": "validation.invalid",
"message": "The email is not valid",
"target": "email"
}
]
}<?php
use Spine\Identity\Identity;
use Spine\Identity\IdentityContext;
use Spine\Identity\Jwt;
// Configure (once at bootstrap)
Jwt::configure('your-secret-key', 'HS256');
// Create token
$identity = new Identity(
id: $user['id'],
role: $user['role'],
claims: ['company_id' => $user['company_id']],
);
$token = Jwt::sign($identity, expiresIn: 86400);
// Access identity (after ResolveIdentity middleware)
$identity = IdentityContext::get();
$identity->id;
$identity->role;
$identity->claim('company_id');
$identity->hasRole('admin');
$identity->hasAnyRole('admin', 'supervisor');use Spine\Identity\ResolveIdentity;
use Spine\Identity\RequireIdentity;
use Spine\Identity\RequireRole;
// Global: extract identity from Authorization header
$app->pipe(ResolveIdentity::class);
// Route-level: require authenticated user
$router->group('/users', fn ($r) => ...)
->pipe(RequireIdentity::class);
// Route-level: require specific role
$router->group('/admin', fn ($r) => ...)
->pipe(new RequireRole('admin'));
// Multiple roles allowed
$router->group('/management', fn ($r) => ...)
->pipe(new RequireRole('admin', 'supervisor'));Uses rakit/validation syntax:
<?php
use Spine\Validation\Validator;
$data = Validator::validate($this->body(), [
'email' => 'required|email',
'name' => 'required|min:2|max:100',
'age' => 'required|integer|min:18',
'role' => 'required|in:user,admin,supervisor',
'website' => 'url',
'tags' => 'array',
'tags.*' => 'string|max:50',
]);
// $data contains only validated fields
// Throws HttpException::validation() on failure<?php
use PDO;
use Spine\Database\Database;
use Spine\Database\DatabaseContext;
// Setup (once at bootstrap)
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db = new Database($pdo);
DatabaseContext::set($db);
// Usage anywhere
$db = DatabaseContext::get();
// Single row
$user = $db->one('SELECT * FROM users WHERE id = ?', [$id]);
// Multiple rows
$users = $db->many('SELECT * FROM users WHERE role = ?', ['admin']);
// Execute without return
$db->run('UPDATE users SET name = ? WHERE id = ?', [$name, $id]);
// Check existence
$exists = $db->exists('SELECT 1 FROM users WHERE email = ?', [$email]);
// Count
$total = $db->count('SELECT COUNT(*) FROM users');
// Transactions
$db->transaction(function (Database $tx) {
$tx->run('INSERT INTO users (email) VALUES (?)', ['a@b.com']);
$tx->run('INSERT INTO profiles (user_id) VALUES (?)', [$userId]);
});
// Access PDO directly
$pdo = $db->pdo();<?php
use Spine\Core\Env;
// Load .env file
Env::load(__DIR__ . '/../.env');
// Get values
Env::get('DB_HOST'); // Throws if not set
Env::find('DB_HOST', 'localhost'); // Default if not set
Env::int('DB_PORT', 5432); // As integer
Env::bool('APP_DEBUG', false); // As boolean
Env::has('DB_HOST'); // Check if setCreate typed contexts for dependency injection:
<?php
use Spine\Core\Context;
final class ConfigContext extends Context
{
public static function set(Config $config): void
{
self::store($config);
}
public static function get(): Config
{
$config = self::retrieve();
if (!$config instanceof Config) {
throw new RuntimeException('Invalid config type');
}
return $config;
}
}
// Usage
ConfigContext::set($config);
$config = ConfigContext::get();
ConfigContext::has();
ConfigContext::clear();
Context::clearAll();<?php
$router = $app->router;
// Basic routes
$router->get('/users', ListUsers::class);
$router->post('/users', CreateUser::class);
$router->get('/users/{id}', GetUser::class);
$router->put('/users/{id}', UpdateUser::class);
$router->patch('/users/{id}', PatchUser::class);
$router->delete('/users/{id}', DeleteUser::class);
// Callable handlers
$router->get('/health', fn () => ['status' => 'ok']);
// Groups with prefix
$router->group('/api/v1', function ($r) {
$r->get('/users', ListUsers::class);
$r->post('/users', CreateUser::class);
});
// Groups with middleware
$router->group('/admin', function ($r) {
$r->get('/stats', AdminStats::class);
})->pipe(new RequireRole('admin'));
// Nested groups
$router->group('/api', function ($r) {
$r->group('/users', function ($r) {
$r->get('', ListUsers::class);
$r->post('', CreateUser::class);
})->pipe(RequireIdentity::class);
});
// List all routes
$routes = $router->getRoutes();- PHP 8.3+
- PDO extension (for Database)
nikic/fast-route— Routingnyholm/psr7— PSR-7 implementationnyholm/psr7-server— Request factorypsr/http-server-middleware— PSR-15 interfacesfirebase/php-jwt— JWT handlingrakit/validation— Validation
MIT