PHP REST Framework

The fastest way to build
PHP REST APIs.

FastREST started as a zero-learning-curve framework — drop in a controller, name a method, get a route. That spirit is unchanged in v2. What is changed is everything underneath: full PSR compliance, proper HTTP verb routing, secure parameterised queries, an injectable middleware pipeline, and a DI container that makes every component swappable.

PSR-3 Logging PSR-4 Autoloading PSR-7 HTTP Messages PSR-11 Container PSR-15 Middleware PHP 8.1+ MIT License

Quick Start

1
Install dependencies
composer install
2
Set up environment
cp .env.example .env
# Fill in DB credentials and log config
3
Serve locally
php -S localhost:8000 public/index.php
4
Run tests
composer test

Point a production web server's document root at /public. Everything above it is private.

root /var/www/fastrest/public;
location / {
    try_files $uri $uri/ /index.php?$query_string;
}

🧩 Core Concepts

FastREST v2 is built on five PHP standard interfaces. You never depend on a concrete implementation — only on the interface. This means every layer is replaceable without touching your business logic.

PSR-4
Autoloading
Composer
PSR-3
Logging
Monolog
PSR-7
HTTP Messages
Nyholm/PSR-7
PSR-11
DI Container
PHP-DI
PSR-15
Middleware
Built-in pipeline

A request flows through the system like this:

Browser / API Client
        ↓
  public/index.php          ← Only public file
        ↓
  MiddlewarePipeline         ← PSR-15
  ├── RequestLoggerMiddleware
  ├── CorsMiddleware
  ├── AuthMiddleware         ← your custom middleware
  ├── JsonBodyParserMiddleware
  └── Router                 ← PSR-15 final handler
        ↓
  Controller method          ← plain class, injected deps
        ↓
  Response::json(...)        ← PSR-7 ResponseInterface
        ↓
  public/index.php emits it

🗺 Routing

Routes are defined explicitly in config/routes.php, one line per route. Each maps an HTTP verb and a URI pattern to a controller method.

// config/routes.php
return static function (Router $router): void {

    $router->get('/products',          [ProductController::class, 'index']);
    $router->get('/products/{id}',     [ProductController::class, 'show']);
    $router->post('/products',        [ProductController::class, 'store']);
    $router->put('/products/{id}',    [ProductController::class, 'update']);
    $router->delete('/products/{id}', [ProductController::class, 'destroy']);
    $router->any('/health',           [HealthController::class, 'check']);

};

Route parameters

Segments in {braces} are captured and available as request attributes:

public function show(ServerRequestInterface $request): ResponseInterface
{
    $id = $request->getAttribute('id'); // "42"
}
Automatic 405
If a URI matches but the HTTP method doesn't, the framework returns 405 Method Not Allowed with a correct Allow header — you write none of that code.

🎛 Controllers

Controllers are plain PHP classes. No base class to extend, no interface to implement. Dependencies go in the constructor — the DI container resolves them automatically.

namespace FastREST\Controllers;

use FastREST\Database\Database;
use FastREST\Http\Response;
use FastREST\Exceptions\HttpException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;

class ProductController
{
    public function __construct(
        private readonly Database        $db,
        private readonly LoggerInterface $logger,
    ) {}

    // GET /products
    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $rows = $this->db->select('products');
        return Response::json(['status' => 'success', 'data' => $rows]);
    }

    // GET /products/{id}
    public function show(ServerRequestInterface $request): ResponseInterface
    {
        $id   = (int) $request->getAttribute('id');
        $rows = $this->db->select('products', [], ['id' => $id]);

        if (empty($rows)) {
            throw new HttpException(404, "Product $id not found.");
        }

        return Response::json(['status' => 'success', 'data' => $rows[0]]);
    }
}

Reading request data

// Query string: GET /products?status=active
$status = $request->getQueryParams()['status'] ?? null;

// JSON body (parsed by middleware): POST with Content-Type: application/json
$body = (array) $request->getParsedBody();

// Route parameter
$id = $request->getAttribute('id');

// Header
$token = $request->getHeaderLine('Authorization');

📤 Responses & Errors

Response factory

use FastREST\Http\Response;

// 200 OK
return Response::json(['status' => 'success', 'data' => $rows]);

// 201 Created
return Response::json(['status' => 'success'], 201);

// 204 No Content (after DELETE)
return Response::noContent();

// Structured error response
return Response::error('Validation failed', 422, ['field' => 'name is required']);

Throwing HTTP errors

Throw HttpException from anywhere — controllers, middleware, or services. The pipeline catches it and sends a JSON error response with the right status code.

throw new HttpException(404, 'Product not found.');
throw new HttpException(422, 'Missing required fields.');
throw new HttpException(405, 'Method Not Allowed', ['Allow' => 'GET, POST']);

The JSON error shape is always consistent:

{
    "status":  "error",
    "code":    404,
    "message": "Product not found."
}

🔗 Middleware

Middleware wraps every request. It can inspect, modify, or short-circuit the request before it reaches a controller, and modify the response on the way back.

Built-in middleware

ClassWhat it does
RequestLoggerMiddlewareLogs method, URI, IP, status code, duration
JsonBodyParserMiddlewareDecodes application/json bodies into getParsedBody()
CorsMiddlewareAdds CORS headers; handles OPTIONS preflight (204)

Writing a middleware

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class AuthMiddleware implements MiddlewareInterface
{
    public function __construct(private readonly TokenValidator $validator) {}

    public function process(
        ServerRequestInterface  $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $token = $request->getHeaderLine('Authorization');

        if (!$this->validator->isValid($token)) {
            throw new HttpException(401, 'Unauthorized');
        }

        // Attach user to request — available in controllers via getAttribute()
        $request = $request->withAttribute('user', $this->validator->getUser($token));

        return $handler->handle($request);
    }
}

Registering middleware

Order matters — the pipeline runs top to bottom on the request, and bottom to top on the response.

// public/index.php
$pipeline = new MiddlewarePipeline([
    $container->get(RequestLoggerMiddleware::class),  // 1st — log everything
    $container->get(CorsMiddleware::class),            // 2nd — CORS headers
    $container->get(AuthMiddleware::class),            // 3rd — authentication
    $container->get(JsonBodyParserMiddleware::class),  // 4th — parse body
    $router,                                           // last — dispatch
]);

💉 Dependency Injection

Services are wired in config/container.php. The container resolves constructor dependencies automatically — you never call new inside a controller.

// config/container.php
use function DI\create;
use function DI\factory;
use function DI\get;

return [

    // Factory — full control
    MyService::class => factory(function (): MyService {
        return new MyService($_ENV['MY_API_KEY']);
    }),

    // Autowired — PHP-DI reads the constructor type-hints
    OrderService::class => create(OrderService::class)
        ->constructor(get(Database::class), get(Mailer::class)),

    // Interface binding — swap the concrete class here, nothing else changes
    CacheInterface::class => get(RedisCache::class),

];

🗄 Database

Safe, parameterised CRUD. Column and table names are validated against an identifier regex. ORDER BY directions are whitelisted. No raw string interpolation anywhere.

INSERT

$db->insert('products', [
    'name'   => 'Widget',
    'price'  => 9.99,
    'status' => 'active',
]);
// Returns true on success, false on failure.

SELECT

// All rows
$rows = $db->select('products');

// Filtered, ordered, limited
$rows = $db->select(
    table:   'products',
    columns: ['id', 'name', 'price'],
    where:   ['status' => 'active'],
    order:   ['price' => 'DESC'],
    limit:   10,
);

UPDATE

$db->update('products', ['price' => 12.99], ['id' => 5]);

DELETE

$db->delete('products', ['id' => 5]);

Parameterised raw query

$rows = $db->preparedQuery(
    'SELECT * FROM products WHERE price BETWEEN :min AND :max',
    ['min' => 5.0, 'max' => 50.0],
);
Warning
$db->query($sql) executes raw SQL without binding. Use only for trusted, hard-coded strings — never interpolate user input.

🔨 QueryBuilder

Fluent builder for complex SELECTs — joins, group by, having, multiple conditions.

use FastREST\Database\QueryBuilder;

$rows = (new QueryBuilder('products', ['p.id', 'p.name', 'c.name AS category']))
    ->leftJoin('categories c', 'p.category_id = c.id')
    ->where('p.status', '=', 'active')
    ->orWhere('p.featured', '=', 1)
    ->where('p.price', '>', 0)
    ->groupBy('p.id')
    ->having('COUNT(p.id) > 0')
    ->orderBy('p.name', 'ASC')
    ->limit(20)
    ->execute($db);

Methods reference

MethodDescription
where($col, $op, $value)AND WHERE condition
orWhere($col, $op, $value)OR WHERE condition
whereRaw($condition)Raw AND WHERE (manual binding via withParam)
leftJoin($table, $on)LEFT JOIN
innerJoin($table, $on)INNER JOIN
join($type, $table, $on)Any join type (INNER, LEFT, RIGHT, CROSS…)
groupBy($column)GROUP BY
having($condition)HAVING
orderBy($col, $dir)ORDER BY — direction is whitelisted ASC/DESC
limit($n)LIMIT
toSql()Returns the SQL string
getParams()Returns the bound params array
execute($db)Runs the query and returns rows

Allowed where() operators: =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE, IN, NOT IN, IS NULL, IS NOT NULL

📋 Logging

The logger is a Psr\Log\LoggerInterface — any PSR-3 logger works. Monolog ships as the default. Inject it via constructor and use it normally:

$this->logger->debug('Cache miss',    ['key' => $cacheKey]);
$this->logger->info('User logged in', ['user_id' => $id]);
$this->logger->warning('Slow query',    ['ms' => 1200]);
$this->logger->error('Payment failed', ['order' => $id]);

Configure in .env:

LOG_CHANNEL=stderr      # or: file
LOG_LEVEL=debug         # minimum level to record
LOG_PATH=/var/log/app.log

🌐 HTTP Client

A cURL wrapper with proper error handling, all HTTP verbs, and SSL verification on by default. Errors are never swallowed — they throw and log.

use FastREST\Helpers\HttpClient;

$client = new HttpClient('https://api.example.com', $logger);

$response = $client->get('/users', ['Authorization' => 'Bearer ' . $token]);
$response = $client->post('/orders', ['product_id' => 5, 'qty' => 2]);
$response = $client->put('/products/5', ['price' => 14.99]);
$response = $client->patch('/products/5', ['status' => 'sale']);
$response = $client->delete('/products/5');

// Reading the response
$response->statusCode;          // int
$response->isSuccess();         // true for 2xx
$response->json();              // decoded array or null
$response->body;                // raw string
$response->getHeader('ETag');  // response header

🔑 OAuth 1.0

use FastREST\Helpers\OauthHelper;

$headers = OauthHelper::buildAuthorizationHeaders(
    method:         'POST',
    url:            'https://api.example.com/oauth/request_token',
    consumerKey:    $_ENV['OAUTH_CONSUMER_KEY'],
    consumerSecret: $_ENV['OAUTH_CONSUMER_SECRET'],
    token:          $accessToken,       // optional
    tokenSecret:    $accessTokenSecret, // optional
);

$client   = new HttpClient('https://api.example.com', $logger);
$response = $client->post('/resource', $body, $headers);

The nonce uses random_bytes() — cryptographically secure, not str_shuffle.

🔄 Swapping Modules

Every component is bound to a standard interface. Swapping an implementation is a one-file change in config/container.php. Controllers and services never change.

Swap the logger (PSR-3)

// config/container.php
use Psr\Log\LoggerInterface;
use Acme\MyCustomLogger;

return [
    LoggerInterface::class => factory(fn() => new MyCustomLogger()),
];

Every class that type-hints LoggerInterface now receives MyCustomLogger.

Swap the DI container (PSR-11)

The framework only calls $container->get(ClassName::class) in the router. To replace PHP-DI with e.g. League Container or Symfony DI:

// public/index.php
$container = (new YourContainerBuilder())->build();
$router    = new Router($container);  // that's the only change

Swap the PSR-7 HTTP library

The router, middleware, and controllers only type-hint PSR-7 interfaces. To switch from Nyholm to Guzzle PSR-7 or Laminas Diactoros:

  1. Replace nyholm/psr7 in composer.json
  2. Update the request factory in public/index.php
  3. Nothing else changes

Add any PSR-15 middleware package

composer require middlewares/rate-limit
use Middlewares\RateLimit;

$pipeline = new MiddlewarePipeline([
    $container->get(RequestLoggerMiddleware::class),
    new RateLimit($limiter),   // ← drop it in
    $container->get(CorsMiddleware::class),
    $router,
]);

⚙️ Configuration

# Application
APP_ENV=development     # development | production
APP_DEBUG=true
APP_TIMEZONE=UTC

# Database
DB_TYPE=mysql           # mysql | pgsql | sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=your_database
DB_USER=your_user
DB_PASS=your_password

# Logging
LOG_CHANNEL=stderr      # stderr | file
LOG_LEVEL=debug
LOG_PATH=/var/log/fastrest/app.log
Production tip
In production, set these as real environment variables (Apache SetEnv, Nginx fastcgi_param, Docker ENV). The .env file is a convenience for local dev only — it's in .gitignore and should never be committed.

🧪 Testing

Tests use PHPUnit with an in-memory SQLite database — no real database required, no test-environment config needed.

composer test

Testing a controller

use FastREST\Database\Connection;
use FastREST\Database\Database;
use FastREST\Controllers\ProductController;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

class ProductControllerTest extends TestCase
{
    private ProductController $controller;

    protected function setUp(): void
    {
        $conn = new Connection('sqlite', '', 0, ':memory:', '', '');
        $db   = new Database($conn, new NullLogger());
        $db->query('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)');
        $db->insert('products', ['name' => 'Widget', 'price' => 9.99]);

        $this->controller = new ProductController($db, new NullLogger());
    }

    public function testIndexReturns200(): void
    {
        $factory  = new Psr17Factory();
        $request  = $factory->createServerRequest('GET', '/products');
        $response = $this->controller->index($request);

        $this->assertSame(200, $response->getStatusCode());
        $body = json_decode((string) $response->getBody(), true);
        $this->assertSame('success', $body['status']);
    }
}

🚀 Migrating from v1

Controllers

v1v2
static function indexAction()public function index(ServerRequestInterface $r)
return array('status' => 'success')return Response::json(['status' => 'success'])
Database::getInstance()Constructor-injected $this->db
Logger::getInstance()Constructor-injected $this->logger
Route auto-detected from URLExplicit entry in config/routes.php

Config

v1v2
config/config.php with define().env file via $_ENV
Credentials hard-coded, committed to git.env excluded from git
On explicit routes
v1 auto-detected routes from class and method names. v2 routes are explicit. This is intentional — explicit routes are readable, grep-able, secure (no path traversal), and make it obvious what's in your API at a glance.

📁 Project Structure

fastrest/
├── config/
│   ├── container.php        ← Service wiring (PSR-11 definitions)
│   └── routes.php           ← Route table
├── public/
│   └── index.php            ← Entry point — only web-accessible dir
├── src/
│   ├── Controllers/         ← Your application controllers
│   ├── Database/
│   │   ├── Connection.php   ← PDO factory (env-aware, multi-DB)
│   │   ├── Database.php     ← CRUD helper (all bugs fixed)
│   │   └── QueryBuilder.php ← Fluent SELECT builder
│   ├── Exceptions/
│   │   └── HttpException.php
│   ├── Helpers/
│   │   ├── HttpClient.php   ← cURL wrapper (all methods)
│   │   ├── HttpResponse.php ← Response value object
│   │   └── OauthHelper.php  ← OAuth 1.0 header generator
│   ├── Http/
│   │   ├── MiddlewarePipeline.php
│   │   ├── Response.php     ← JSON response factory
│   │   └── Router.php       ← HTTP-verb-aware dispatcher
│   └── Middleware/
│       ├── CorsMiddleware.php
│       ├── JsonBodyParserMiddleware.php
│       └── RequestLoggerMiddleware.php
├── tests/
│   └── Unit/
│       ├── Database/
│       │   ├── DatabaseTest.php
│       │   └── QueryBuilderTest.php
│       └── Http/
│           └── RouterTest.php
├── .env.example
├── .gitignore
├── composer.json
├── phpunit.xml
└── README.md